Added Leaflet and partial Avatar support

This commit is contained in:
diamondburned 2020-08-28 18:42:28 -07:00
parent 0b155d5b07
commit b48526aed3
23 changed files with 643 additions and 199 deletions

2
go.mod
View File

@ -10,7 +10,7 @@ require (
github.com/diamondburned/cchat v0.0.49 github.com/diamondburned/cchat v0.0.49
github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e
github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526 github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/goodsign/monday v1.0.0 github.com/goodsign/monday v1.0.0

2
go.sum
View File

@ -91,6 +91,8 @@ github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a h1:wEldljb421/
github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= github.com/diamondburned/gotk3 v0.0.0-20200816224505-3cd69b83a48a/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q=
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526 h1:GnTwaD+RKiCJiJ9yhcUb6M5o4VwFExTqmcl8Dg+EVdw= github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526 h1:GnTwaD+RKiCJiJ9yhcUb6M5o4VwFExTqmcl8Dg+EVdw=
github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ= github.com/diamondburned/handy v0.0.0-20200827040421-5b4a15843526/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ=
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4 h1:qF5VHC35+GyCjUmKz+1O94xpFc0JQd4Ui3h+I955pJw=
github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4/go.mod h1:V0qyhW4v6KPFwtDpXdBm5aWH7zWEyrzZpcB6MPnKArQ=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo= github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 h1:OWxllHbUptXzDias6YI4MM0R3o50q8MfhkkwVIlfiNo=
github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ=
github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6 h1:YN0cj0aOCa+tKmx0aD5qsbSYaIJnyrA0/+eygMKP+/w= github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6 h1:YN0cj0aOCa+tKmx0aD5qsbSYaIJnyrA0/+eygMKP+/w=

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/handy" "github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
"github.com/gotk3/gotk3/pango"
) )
// const BreadcrumbSlash = `<span rise="-1024" size="x-large">❭</span>` // const BreadcrumbSlash = `<span rise="-1024" size="x-large">❭</span>`
@ -16,30 +17,76 @@ const BreadcrumbSlash = " 〉"
type Header struct { type Header struct {
handy.HeaderBar handy.HeaderBar
Breadcrumb *gtk.Label ShowBackBtn *gtk.Revealer
BackButton *gtk.Button
Breadcrumb *gtk.Label
ShowMembers *gtk.ToggleButton
breadcrumbs []string
minicrumbs bool
} }
var backButtonCSS = primitives.PrepareClassCSS("back-button", `
.back-button {
margin-left: 14px;
}
`)
var rightBreadcrumbCSS = primitives.PrepareClassCSS("right-breadcrumb", ` var rightBreadcrumbCSS = primitives.PrepareClassCSS("right-breadcrumb", `
.right-breadcrumb { .right-breadcrumb {
margin: 0 14px; margin-left: 14px;
} }
`) `)
func NewHeader() *Header { func NewHeader() *Header {
bk, _ := gtk.ButtonNewFromIconName("go-previous-symbolic", gtk.ICON_SIZE_BUTTON)
bk.SetVAlign(gtk.ALIGN_CENTER)
bk.Show()
backButtonCSS(bk)
rbk, _ := gtk.RevealerNew()
rbk.Add(bk)
rbk.SetRevealChild(false)
rbk.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_LEFT)
rbk.SetTransitionDuration(50)
rbk.Show()
bc, _ := gtk.LabelNew(BreadcrumbSlash) bc, _ := gtk.LabelNew(BreadcrumbSlash)
bc.SetUseMarkup(true) bc.SetUseMarkup(true)
bc.SetXAlign(0.0) bc.SetXAlign(0.0)
bc.SetEllipsize(pango.ELLIPSIZE_MIDDLE)
bc.SetSingleLineMode(true)
bc.SetHExpand(true)
bc.SetMaxWidthChars(75)
bc.Show() bc.Show()
rightBreadcrumbCSS(bc) rightBreadcrumbCSS(bc)
memberIcon, _ := gtk.ImageNewFromIconName("system-users-symbolic", gtk.ICON_SIZE_BUTTON)
memberIcon.Show()
mb, _ := gtk.ToggleButtonNew()
mb.SetVAlign(gtk.ALIGN_CENTER)
mb.SetImage(memberIcon)
mb.SetActive(false)
mb.SetSensitive(false)
header := handy.HeaderBarNew() header := handy.HeaderBarNew()
header.SetShowCloseButton(true) header.SetShowCloseButton(true)
header.PackStart(rbk)
header.PackStart(bc) header.PackStart(bc)
header.PackEnd(mb)
header.Show() header.Show()
// Hack to hide the title.
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
header.SetCustomTitle(b)
return &Header{ return &Header{
HeaderBar: *header, HeaderBar: *header,
Breadcrumb: bc, ShowBackBtn: rbk,
BackButton: bk,
Breadcrumb: bc,
ShowMembers: mb,
} }
} }
@ -47,18 +94,71 @@ func (h *Header) Reset() {
h.SetBreadcrumber(nil) h.SetBreadcrumber(nil)
} }
func (h *Header) OnBackPressed(fn func()) {
h.BackButton.Connect("clicked", fn)
}
func (h *Header) OnShowMembersToggle(fn func(show bool)) {
h.ShowMembers.Connect("toggled", func() {
fn(h.ShowMembers.GetActive())
})
}
func (h *Header) SetShowBackButton(show bool) {
h.ShowBackBtn.SetRevealChild(show)
}
func (h *Header) SetCanShowMembers(canShow bool) {
if canShow {
h.ShowMembers.Show()
h.ShowMembers.SetSensitive(true)
} else {
h.ShowMembers.Hide()
h.ShowMembers.SetSensitive(false)
}
}
// SetMiniBreadcrumb sets whether or not the breadcrumb should display the full
// label.
func (h *Header) SetMiniBreadcrumb(mini bool) {
h.minicrumbs = mini
h.updateBreadcrumb()
}
// updateBreadcrumb updates the breadcrumb label from the local state.
func (h *Header) updateBreadcrumb() {
switch {
case len(h.breadcrumbs) == 0:
h.Breadcrumb.SetText("")
case h.minicrumbs:
h.Breadcrumb.SetMarkup(h.breadcrumbs[len(h.breadcrumbs)-1])
default:
h.Breadcrumb.SetMarkup(
BreadcrumbSlash + " " + strings.Join(h.breadcrumbs, " "+BreadcrumbSlash+" "),
)
}
}
func (h *Header) SetBreadcrumber(b traverse.Breadcrumber) { func (h *Header) SetBreadcrumber(b traverse.Breadcrumber) {
if b == nil { if b == nil {
h.Breadcrumb.SetText("") h.breadcrumbs = nil
h.updateBreadcrumb()
return return
} }
var crumb = b.Breadcrumb() h.breadcrumbs = b.Breadcrumb()
for i := range crumb { if len(h.breadcrumbs) < 2 {
crumb[i] = html.EscapeString(crumb[i]) return
} }
h.Breadcrumb.SetMarkup( // Skip the service name and username.
BreadcrumbSlash + " " + strings.Join(crumb, " "+BreadcrumbSlash+" "), h.breadcrumbs = h.breadcrumbs[2:]
)
for i := range h.breadcrumbs {
h.breadcrumbs[i] = html.EscapeString(h.breadcrumbs[i])
}
h.updateBreadcrumb()
} }

View File

@ -33,7 +33,7 @@ var textCSS = primitives.PrepareCSS(`
} }
.message-input, .message-input * { .message-input, .message-input * {
background-color: transparent; background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
} }
.message-input * { .message-input * {
@ -47,6 +47,12 @@ var textCSS = primitives.PrepareCSS(`
} }
`) `)
var inputBoxCSS = primitives.PrepareClassCSS("input-box", `
.input-box {
background-color: @theme_bg_color;
}
`)
func NewView(ctrl Controller) *InputView { func NewView(ctrl Controller) *InputView {
text, _ := gtk.TextViewNew() text, _ := gtk.TextViewNew()
text.SetSensitive(false) text.SetSensitive(false)
@ -68,8 +74,6 @@ func NewView(ctrl Controller) *InputView {
f := NewField(text, ctrl) f := NewField(text, ctrl)
f.Show() f.Show()
primitives.AddClass(f, "input-field")
return &InputView{f, c} return &InputView{f, c}
} }
@ -120,8 +124,17 @@ func (s *fieldState) Reset() {
*s = fieldState{} *s = fieldState{}
} }
var inputFieldCSS = primitives.PrepareCSS(` var inputFieldCSS = primitives.PrepareClassCSS("input-field", `
.input-field { margin: 3px 5px } .input-field {
margin: 3px 5px;
margin-top: 1px;
}
`)
var scrolledInputCSS = primitives.PrepareClassCSS("scrolled-input", `
.scrolled-input {
margin: 0 5px;
}
`) `)
func NewField(text *gtk.TextView, ctrl Controller) *Field { func NewField(text *gtk.TextView, ctrl Controller) *Field {
@ -133,7 +146,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.TextScroll = scrollinput.NewV(text, 150) field.TextScroll = scrollinput.NewV(text, 150)
field.TextScroll.Show() field.TextScroll.Show()
primitives.AddClass(field.TextScroll, "scrolled-input") scrolledInputCSS(field.TextScroll)
field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON) field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON)
field.attach.SetRelief(gtk.RELIEF_NONE) field.attach.SetRelief(gtk.RELIEF_NONE)
@ -146,15 +159,13 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.send.Show() field.send.Show()
primitives.AddClass(field.send, "send-button") primitives.AddClass(field.send, "send-button")
// Keep this number the same as size-allocate below -------v field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)
field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5)
field.FieldBox.PackStart(field.Username, false, false, 0) field.FieldBox.PackStart(field.Username, false, false, 0)
field.FieldBox.PackStart(field.attach, false, false, 0) field.FieldBox.PackStart(field.attach, false, false, 0)
field.FieldBox.PackStart(field.TextScroll, true, true, 0) field.FieldBox.PackStart(field.TextScroll, true, true, 0)
field.FieldBox.PackStart(field.send, false, false, 0) field.FieldBox.PackStart(field.send, false, false, 0)
field.FieldBox.Show() field.FieldBox.Show()
primitives.AddClass(field.FieldBox, "input-field") inputFieldCSS(field.FieldBox)
primitives.AttachCSS(field.FieldBox, inputFieldCSS)
field.Attachments = attachment.New() field.Attachments = attachment.New()
field.Attachments.Show() field.Attachments.Show()
@ -163,6 +174,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.Box.PackStart(field.Attachments, false, false, 0) field.Box.PackStart(field.Attachments, false, false, 0)
field.Box.PackStart(field.FieldBox, false, false, 0) field.Box.PackStart(field.FieldBox, false, false, 0)
field.Box.Show() field.Box.Show()
inputBoxCSS(field.Box)
text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment()) text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment())
text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment()) text.SetFocusVAdjustment(field.TextScroll.GetVAdjustment())
@ -178,7 +190,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field {
field.Username.Connect("size-allocate", func(w gtk.IWidget) { field.Username.Connect("size-allocate", func(w gtk.IWidget) {
// Calculate the left width: from the left of the message box to the // Calculate the left width: from the left of the message box to the
// right of the attach button, covering the username container. // right of the attach button, covering the username container.
var leftWidth = 5*2 + field.attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth() var leftWidth = 5 + field.attach.GetAllocatedWidth() + w.ToWidget().GetAllocatedWidth()
// Set the autocompleter's left margin to be the same. // Set the autocompleter's left margin to be the same.
field.Attachments.SetMarginStart(leftWidth) field.Attachments.SetMarginStart(leftWidth)
}) })

View File

@ -77,12 +77,12 @@ func NewContainer() *Container {
func (u *Container) SetRevealChild(reveal bool) { func (u *Container) SetRevealChild(reveal bool) {
// Only reveal if showUser is true. // Only reveal if showUser is true.
u.Revealer.SetRevealChild(reveal && showUser) u.Revealer.SetRevealChild(reveal && u.shouldReveal())
} }
// shouldReveal returns whether or not the container should reveal. // shouldReveal returns whether or not the container should reveal.
func (u *Container) shouldReveal() bool { func (u *Container) shouldReveal() bool {
return !u.label.GetLabel().Empty() && showUser return (!u.label.GetLabel().Empty() || u.avatar.URL() != "") && showUser
} }
func (u *Container) Reset() { func (u *Container) Reset() {
@ -96,7 +96,7 @@ func (u *Container) Update(session cchat.Session, sender cchat.ServerMessageSend
// Set the fallback username. // Set the fallback username.
u.label.SetLabelUnsafe(session.Name()) u.label.SetLabelUnsafe(session.Name())
// Reveal the name if it's not empty. // Reveal the name if it's not empty.
u.SetRevealChild(u.shouldReveal()) u.SetRevealChild(true)
// Does sender (aka Server) implement ServerNickname? If yes, use it. // Does sender (aka Server) implement ServerNickname? If yes, use it.
if nicknamer, ok := sender.(cchat.ServerNickname); ok { if nicknamer, ok := sender.(cchat.ServerNickname); ok {
@ -120,7 +120,7 @@ func (u *Container) SetLabel(content text.Rich) {
u.label.SetLabelUnsafe(content) u.label.SetLabelUnsafe(content)
// Reveal if the name is not empty. // Reveal if the name is not empty.
u.SetRevealChild(u.shouldReveal()) u.SetRevealChild(true)
}) })
} }
@ -131,9 +131,7 @@ func (u *Container) SetIcon(url string) {
// Reveal if the icon URL is not empty. We don't touch anything if the // Reveal if the icon URL is not empty. We don't touch anything if the
// URL is empty, as the name might not be. // URL is empty, as the name might not be.
if url != "" { u.SetRevealChild(true)
u.SetRevealChild(true)
}
}) })
} }

View File

@ -21,16 +21,21 @@ import (
var MemberListWidth = 250 var MemberListWidth = 250
type Controller interface {
MemberListUpdated(c *Container)
}
type Container struct { type Container struct {
*gtk.Revealer *gtk.Revealer
Scroll *gtk.ScrolledWindow Scroll *gtk.ScrolledWindow
Main *gtk.Box Main *gtk.Box
ctrl Controller
// states
// map id -> *Section // map id -> *Section
Sections map[string]*Section Sections map[string]*Section
stop func()
// states
stop func()
} }
var memberListCSS = primitives.PrepareClassCSS("member-list", ` var memberListCSS = primitives.PrepareClassCSS("member-list", `
@ -39,7 +44,7 @@ var memberListCSS = primitives.PrepareClassCSS("member-list", `
} }
`) `)
func New() *Container { func New(ctrl Controller) *Container {
main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
main.SetSizeRequest(250, -1) main.SetSizeRequest(250, -1)
main.Show() main.Show()
@ -52,7 +57,7 @@ func New() *Container {
rev, _ := gtk.RevealerNew() rev, _ := gtk.RevealerNew()
rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT)
rev.SetTransitionDuration(50) rev.SetTransitionDuration(75)
rev.SetRevealChild(false) rev.SetRevealChild(false)
rev.Add(sw) rev.Add(sw)
@ -60,10 +65,16 @@ func New() *Container {
Revealer: rev, Revealer: rev,
Scroll: sw, Scroll: sw,
Main: main, Main: main,
ctrl: ctrl,
Sections: map[string]*Section{}, Sections: map[string]*Section{},
} }
} }
// IsEmpty returns whether or not the member view container is empty.
func (c *Container) IsEmpty() bool {
return len(c.Sections) == 0
}
// Reset removes all old sections. // Reset removes all old sections.
func (c *Container) Reset() { func (c *Container) Reset() {
if c.stop != nil { if c.stop != nil {
@ -94,10 +105,7 @@ func (c *Container) TryAsyncList(server cchat.ServerMessage) {
return nil, errors.Wrap(err, "Failed to list members") return nil, errors.Wrap(err, "Failed to list members")
} }
return func() { return func() { c.stop = f }, nil
c.stop = f
c.Revealer.SetRevealChild(true)
}, nil
}) })
} }
@ -138,6 +146,8 @@ func (c *Container) SetSectionsUnsafe(sections []cchat.MemberListSection) {
c.Main.Add(section) c.Main.Add(section)
c.Sections[section.ID] = section c.Sections[section.ID] = section
} }
c.ctrl.MemberListUpdated(c)
} }
func (c *Container) SetMemberUnsafe(sectionID string, member cchat.ListMember) { func (c *Container) SetMemberUnsafe(sectionID string, member cchat.ListMember) {

View File

@ -10,20 +10,15 @@ import (
const FaceSize = 56 const FaceSize = 56
type WidgetUnreferencer interface {
gtk.IWidget
Unref()
}
type FaceView struct { type FaceView struct {
gtk.Stack gtk.Stack
placeholder WidgetUnreferencer placeholder gtk.IWidget
Face *Container Face *Container
Loading *Spinner Loading *Spinner
} }
func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView { func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView {
c := NewContainer() c := NewContainer()
c.Show() c.Show()
@ -55,14 +50,6 @@ func (v *FaceView) Reset() {
v.Stack.SetVisibleChildName("empty") v.Stack.SetVisibleChildName("empty")
} }
// func (v *FaceView) Disable() {
// v.Stack.SetSensitive(false)
// }
// func (v *FaceView) Enable() {
// v.Stack.SetSensitive(true)
// }
func (v *FaceView) SetMain() { func (v *FaceView) SetMain() {
v.ensurePlaceholderDestroyed() v.ensurePlaceholderDestroyed()
v.Loading.Spinner.Stop() v.Loading.Spinner.Stop()
@ -104,7 +91,6 @@ type Spinner struct {
func NewSpinner() *Spinner { func NewSpinner() *Spinner {
s, _ := gtk.SpinnerNew() s, _ := gtk.SpinnerNew()
s.SetSizeRequest(FaceSize, FaceSize) s.SetSizeRequest(FaceSize, FaceSize)
s.Start()
s.Show() s.Show()
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)

View File

@ -20,6 +20,8 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@ -40,6 +42,8 @@ func init() {
} }
type Controller interface { type Controller interface {
// GoBack tells the main leaflet to go back to the services list.
GoBack()
// OnMessageBusy is called when the message buffer is busy. This happens // OnMessageBusy is called when the message buffer is busy. This happens
// when it's loading messages. // when it's loading messages.
OnMessageBusy() OnMessageBusy()
@ -54,8 +58,9 @@ type View struct {
Header *Header Header *Header
FaceView *sadface.FaceView FaceView *sadface.FaceView
Grid *gtk.Grid Leaflet *handy.Leaflet
LeftBox *gtk.Box
Scroller *autoscroll.ScrolledWindow Scroller *autoscroll.ScrolledWindow
InputView *input.InputView InputView *input.InputView
@ -64,20 +69,29 @@ type View struct {
Container container.Container Container container.Container
contType int // msgIndex contType int // msgIndex
MemberList *memberlist.Container MemberList *memberlist.Container // right box
// Inherit some useful methods. // Inherit some useful methods.
state state
ctrl Controller ctrl Controller
parentFolded bool // folded state
} }
var messageStack = primitives.PrepareClassCSS("message-stack", `
.message-stack {
background-color: mix(@theme_bg_color, @theme_fg_color, 0.03);
}
`)
var messageScroller = primitives.PrepareClassCSS("message-scroller", ``)
func NewView(c Controller) *View { func NewView(c Controller) *View {
view := &View{ctrl: c} view := &View{ctrl: c}
view.Typing = typing.New() view.Typing = typing.New()
view.Typing.Show() view.Typing.Show()
view.MemberList = memberlist.New() view.MemberList = memberlist.New(view)
view.MemberList.Show() view.MemberList.Show()
view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2)
@ -89,6 +103,7 @@ func NewView(c Controller) *View {
view.Scroller.SetVExpand(true) view.Scroller.SetVExpand(true)
view.Scroller.SetHExpand(true) view.Scroller.SetHExpand(true)
view.Scroller.Show() view.Scroller.Show()
messageScroller(view.Scroller)
view.MsgBox.SetFocusHAdjustment(view.Scroller.GetHAdjustment()) view.MsgBox.SetFocusHAdjustment(view.Scroller.GetHAdjustment())
view.MsgBox.SetFocusVAdjustment(view.Scroller.GetVAdjustment()) view.MsgBox.SetFocusVAdjustment(view.Scroller.GetVAdjustment())
@ -113,27 +128,51 @@ func NewView(c Controller) *View {
view.InputView.SetHExpand(true) view.InputView.SetHExpand(true)
view.InputView.Show() view.InputView.Show()
view.Grid, _ = gtk.GridNew() view.LeftBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.Grid.Attach(view.Scroller, 0, 0, 1, 1) view.LeftBox.PackStart(view.Scroller, true, true, 0)
view.Grid.Attach(sep, 0, 1, 1, 1) view.LeftBox.PackStart(sep, false, false, 0)
view.Grid.Attach(view.InputView, 0, 2, 1, 1) view.LeftBox.PackStart(view.InputView, false, false, 0)
view.Grid.Attach(view.MemberList, 1, 0, 1, 3) view.LeftBox.Show()
view.Grid.Show()
primitives.AddClass(view.Grid, "message-view") view.Leaflet = handy.LeafletNew()
view.Leaflet.Add(view.LeftBox)
view.Leaflet.Add(view.MemberList)
view.Leaflet.SetVisibleChild(view.LeftBox)
view.Leaflet.Show()
primitives.AddClass(view.Leaflet, "message-view")
// Bind a file drag-and-drop box into the main view box. // Bind a file drag-and-drop box into the main view box.
drag.BindFileDest(view.Grid, view.InputView.Attachments.AddFiles) drag.BindFileDest(view.LeftBox, view.InputView.Attachments.AddFiles)
// placeholder logo // placeholder logo
logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256Variant2(128)) logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256Variant2(128))
logo.Show() logo.Show()
view.FaceView = sadface.New(view.Grid, logo) view.FaceView = sadface.New(view.Leaflet, logo)
view.FaceView.Show() view.FaceView.Show()
messageStack(view.FaceView)
view.Header = NewHeader() view.Header = NewHeader()
view.Header.Show() view.Header.Show()
view.Header.OnBackPressed(view.ctrl.GoBack)
view.Header.OnShowMembersToggle(func(show bool) {
// If the leaflet is folded, then we should always reveal the child. Its
// visibility should be determined by the leaflet's state.
if view.parentFolded {
view.MemberList.SetRevealChild(true)
if show {
view.Leaflet.SetVisibleChild(view.MemberList)
} else {
view.Leaflet.SetVisibleChild(view.LeftBox)
}
} else {
// Leaflet's visible child does not matter if it's not folded,
// though we should still set the visible child to LeftBox in case
// that changes.
view.MemberList.SetRevealChild(show)
view.Leaflet.SetVisibleChild(view.LeftBox)
}
})
view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) view.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
view.Box.PackStart(view.Header, false, false, 0) view.Box.PackStart(view.Header, false, false, 0)
@ -173,6 +212,9 @@ func (v *View) Reset() {
v.MemberList.Reset() // Reset the member list. v.MemberList.Reset() // Reset the member list.
v.FaceView.Reset() // Switch back to the main screen. v.FaceView.Reset() // Switch back to the main screen.
// Bring the leaflet view back to the message.
v.Leaflet.SetVisibleChild(v.LeftBox)
// Keep the scroller at the bottom. // Keep the scroller at the bottom.
v.Scroller.Bottomed = true v.Scroller.Bottomed = true
@ -180,8 +222,45 @@ func (v *View) Reset() {
v.createMessageContainer() v.createMessageContainer()
} }
func (v *View) SetFolded(folded bool) {
v.parentFolded = folded
// Change to a mini breadcrumb if we're collapsed.
v.Header.SetMiniBreadcrumb(folded)
// Show the right back button if we're collapsed.
v.Header.SetShowBackButton(folded)
// Hide the username in the input bar if we're collapsed.
v.InputView.Username.SetRevealChild(!folded)
// Hide the member list automatically on folded.
if folded {
v.Header.ShowMembers.SetActive(false)
}
}
// MemberListUpdated is called everytime the member list is updated.
func (v *View) MemberListUpdated(c *memberlist.Container) {
// We can show the members list if it's not empty.
var empty = c.IsEmpty()
v.Header.SetCanShowMembers(!empty)
// If the member list is now empty, then hide the entire thing.
if empty {
// We can set active to false, which would trigger the above callback
// and hide the member list.
v.Header.ShowMembers.SetActive(false)
} else {
// Restore visibility.
if !v.Leaflet.GetFolded() && v.Header.ShowMembers.GetActive() {
c.SetRevealChild(true)
}
}
}
// JoinServer is not thread-safe, but it calls backend functions asynchronously. // JoinServer is not thread-safe, but it calls backend functions asynchronously.
func (v *View) JoinServer(session cchat.Session, server ServerMessage) { func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traverse.Breadcrumber) {
// Reset before setting. // Reset before setting.
v.Reset() v.Reset()
@ -221,6 +300,9 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage) {
// Set the cancel handler. // Set the cancel handler.
v.state.setcurrent(s) v.state.setcurrent(s)
// Set the headerbar's breadcrumb.
v.Header.SetBreadcrumber(bc)
// Try setting the typing indicator if available. // Try setting the typing indicator if available.
v.Typing.TrySubscribe(server) v.Typing.TrySubscribe(server)
@ -253,7 +335,10 @@ func (v *View) FetchBacklog() {
} }
gts.Async(func() (func(), error) { gts.Async(func() (func(), error) {
err := backlogger.MessagesBefore(context.Background(), firstMsg.ID(), v.Container) ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second)
defer cancel()
err := backlogger.MessagesBefore(ctx, firstMsg.ID(), v.Container)
return done, errors.Wrap(err, "Failed to get messages before ID") return done, errors.Wrap(err, "Failed to get messages before ID")
}) })
} }
@ -386,6 +471,14 @@ func (s *state) SessionID() string {
return "" return ""
} }
// ServerID returns the server ID, or an empty string if there's no server.
func (s *state) ServerID() string {
if s.server != nil {
return s.server.ID()
}
return ""
}
const backloggingFreq = time.Second * 3 const backloggingFreq = time.Second * 3
// Backlogger returns the backlogger instance if it's allowed to fetch more // Backlogger returns the backlogger instance if it's allowed to fetch more

View File

@ -2,9 +2,11 @@ package primitives
import ( import (
"runtime/debug" "runtime/debug"
"time"
"github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/glib"
"github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/gtk"
@ -115,8 +117,20 @@ type ImageIconSetter interface {
} }
func SetImageIcon(img ImageIconSetter, icon string, sizepx int) { func SetImageIcon(img ImageIconSetter, icon string, sizepx int) {
img.SetProperty("icon-name", icon) // Prioritize SetSize()
img.SetProperty("pixel-size", sizepx) if setter, ok := img.(interface{ SetSize(int) }); ok {
setter.SetSize(sizepx)
} else {
img.SetProperty("pixel-size", sizepx)
}
// Prioritize SetIconName().
if setter, ok := img.(interface{ SetIconName(string) }); ok {
setter.SetIconName(icon)
} else {
img.SetProperty("icon-name", icon)
}
img.SetSizeRequest(sizepx, sizepx) img.SetSizeRequest(sizepx, sizepx)
} }
@ -261,3 +275,28 @@ func AttachCSS(ctx StyleContexter, prov *gtk.CssProvider) {
func InlineCSS(ctx StyleContexter, css string) { func InlineCSS(ctx StyleContexter, css string) {
AttachCSS(ctx, PrepareCSS(css)) AttachCSS(ctx, PrepareCSS(css))
} }
// LeafletOnFold binds a callback to a leaflet that would be called when the
// leaflet's folded state changes.
func LeafletOnFold(leaflet *handy.Leaflet, foldedFn func(folded bool)) {
var lastFold = leaflet.GetFolded()
foldedFn(lastFold)
// Give each callback a 500ms wait for animations to complete.
const dt = 500 * time.Millisecond
var last = time.Now()
leaflet.ConnectAfter("size-allocate", func() {
// Ignore if this event is too recent.
if now := time.Now(); now.Add(-dt).Before(last) {
return
} else {
last = now
}
if folded := leaflet.GetFolded(); folded != lastFold {
lastFold = folded
foldedFn(folded)
}
})
}

View File

@ -0,0 +1,89 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/handy"
"github.com/gotk3/gotk3/gdk"
"github.com/gotk3/gotk3/gtk"
)
// TODO: GIF support
// TextSetter is an interface for setting texts.
type TextSetter interface {
SetText(text string)
}
func TrySetText(imager Imager, text string) {
if setter, ok := imager.(TextSetter); ok {
setter.SetText(text)
}
}
// Avatar is a static HdyAvatar container.
type Avatar struct {
handy.Avatar
pixbuf *gdk.Pixbuf
}
var (
_ Imager = (*Avatar)(nil)
_ TextSetter = (*Avatar)(nil)
_ httputil.ImageContainer = (*Avatar)(nil)
)
func NewAvatar(size int) *Avatar {
a := handy.AvatarNew(size, "", true)
if a == nil {
return nil
}
return &Avatar{*a, nil}
}
// SetSizeRequest sets the avatar size. The actual size is min(w, h).
func (a *Avatar) SetSizeRequest(w, h int) {
var min = w
if w > h {
min = h
}
a.Avatar.SetSize(min)
a.Avatar.SetSizeRequest(w, h)
}
func (a *Avatar) loadFunc(int) *gdk.Pixbuf {
return a.pixbuf
}
// SetRadius is a no-op.
func (a *Avatar) SetRadius(float64) {}
// SetFromPixbuf sets the pixbuf.
func (a *Avatar) SetFromPixbuf(pb *gdk.Pixbuf) {
a.pixbuf = pb
a.Avatar.SetImageLoadFunc(a.loadFunc)
}
func (a *Avatar) SetFromAnimation(pa *gdk.PixbufAnimation) {
a.pixbuf = pa.GetStaticImage()
a.Avatar.SetImageLoadFunc(a.loadFunc)
}
func (a *Avatar) GetPixbuf() *gdk.Pixbuf {
return a.pixbuf
}
// GetAnimation returns nil.
func (a *Avatar) GetAnimation() *gdk.PixbufAnimation {
return nil
}
// GetImage returns nil.
func (a *Avatar) GetImage() *gtk.Image {
return nil
}
func (a *Avatar) GetStorageType() gtk.ImageType {
return gtk.IMAGE_PIXBUF
}

View File

@ -0,0 +1,57 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gtk"
)
// Button implements a rounded button with a rounded image. This widget only
// supports a full circle for rounding.
type Button struct {
*gtk.Button
Image Imager
}
var roundButtonCSS = primitives.PrepareClassCSS("round-button", `
.round-button {
padding: 0;
border-radius: 50%;
}
`)
func NewButton() (*Button, error) {
image, _ := NewImage(0)
image.Show()
b, _ := NewEmptyButton()
b.SetImage(image)
return b, nil
}
func NewEmptyButton() (*Button, error) {
b, _ := gtk.ButtonNew()
b.SetRelief(gtk.RELIEF_NONE)
roundButtonCSS(b)
return &Button{Button: b}, nil
}
// NewCustomButton creates a new rounded button with the given Imager. If the
// given Imager implements the Connector interface (aka *StaticImage), then the
// function will implicitly connect its handlers to the button.
func NewCustomButton(img Imager) (*Button, error) {
b, _ := NewEmptyButton()
b.SetImage(img)
if connector, ok := img.(Connector); ok {
connector.ConnectHandlers(b)
}
return b, nil
}
func (b *Button) SetImage(img Imager) {
b.Image = img
b.Button.SetImage(img)
}

View File

@ -15,57 +15,6 @@ const (
circle = 2 * math.Pi circle = 2 * math.Pi
) )
// Button implements a rounded button with a rounded image. This widget only
// supports a full circle for rounding.
type Button struct {
*gtk.Button
Image Imager
}
var roundButtonCSS = primitives.PrepareClassCSS("round-button", `
.round-button {
padding: 0;
border-radius: 50%;
}
`)
func NewButton() (*Button, error) {
image, _ := NewImage(0)
image.Show()
b, _ := NewEmptyButton()
b.SetImage(image)
return b, nil
}
func NewEmptyButton() (*Button, error) {
b, _ := gtk.ButtonNew()
b.SetRelief(gtk.RELIEF_NONE)
roundButtonCSS(b)
return &Button{Button: b}, nil
}
// NewCustomButton creates a new rounded button with the given Imager. If the
// given Imager implements the Connector interface (aka *StaticImage), then the
// function will implicitly connect its handlers to the button.
func NewCustomButton(img Imager) (*Button, error) {
b, _ := NewEmptyButton()
b.SetImage(img)
if connector, ok := img.(Connector); ok {
connector.ConnectHandlers(b)
}
return b, nil
}
func (b *Button) SetImage(img Imager) {
b.Image = img
b.Button.SetImage(img)
}
type RadiusSetter interface { type RadiusSetter interface {
SetRadius(float64) SetRadius(float64)
} }
@ -87,59 +36,6 @@ type Imager interface {
GetImage() *gtk.Image GetImage() *gtk.Image
} }
// StaticImage is an image that only plays a GIF if it's hovered on top of.
type StaticImage struct {
*Image
animation *gdk.PixbufAnimation
}
var (
_ Imager = (*StaticImage)(nil)
_ Connector = (*StaticImage)(nil)
_ httputil.ImageContainer = (*StaticImage)(nil)
)
func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) {
i, err := NewImage(radius)
if err != nil {
return nil, err
}
var s = &StaticImage{i, nil}
if parent != nil {
s.ConnectHandlers(parent)
}
return s, nil
}
func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func() {
if s.animation != nil {
s.Image.SetFromAnimation(s.animation)
}
})
connector.Connect("leave-notify-event", func() {
if s.animation != nil {
s.Image.SetFromPixbuf(s.animation.GetStaticImage())
}
})
}
func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) {
s.animation = nil
s.Image.SetFromPixbuf(pb)
}
func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
s.animation = anim
s.Image.SetFromPixbuf(anim.GetStaticImage())
}
func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation {
return s.animation
}
type Image struct { type Image struct {
*gtk.Image *gtk.Image
Radius float64 Radius float64

View File

@ -0,0 +1,60 @@
package roundimage
import (
"github.com/diamondburned/cchat-gtk/internal/gts/httputil"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/gotk3/gotk3/gdk"
)
// StaticImage is an image that only plays a GIF if it's hovered on top of.
type StaticImage struct {
*Image
animation *gdk.PixbufAnimation
}
var (
_ Imager = (*StaticImage)(nil)
_ Connector = (*StaticImage)(nil)
_ httputil.ImageContainer = (*StaticImage)(nil)
)
func NewStaticImage(parent primitives.Connector, radius float64) (*StaticImage, error) {
i, err := NewImage(radius)
if err != nil {
return nil, err
}
var s = &StaticImage{i, nil}
if parent != nil {
s.ConnectHandlers(parent)
}
return s, nil
}
func (s *StaticImage) ConnectHandlers(connector primitives.Connector) {
connector.Connect("enter-notify-event", func() {
if s.animation != nil {
s.Image.SetFromAnimation(s.animation)
}
})
connector.Connect("leave-notify-event", func() {
if s.animation != nil {
s.Image.SetFromPixbuf(s.animation.GetStaticImage())
}
})
}
func (s *StaticImage) SetFromPixbuf(pb *gdk.Pixbuf) {
s.animation = nil
s.Image.SetFromPixbuf(pb)
}
func (s *StaticImage) SetFromAnimation(anim *gdk.PixbufAnimation) {
s.animation = anim
s.Image.SetFromPixbuf(anim.GetStaticImage())
}
func (s *StaticImage) GetAnimation() *gdk.PixbufAnimation {
return s.animation
}

View File

@ -135,6 +135,9 @@ func (i *Icon) SetIcon(url string) {
} }
func (i *Icon) AsyncSetIconer(iconer cchat.Icon, errwrap string) { func (i *Icon) AsyncSetIconer(iconer cchat.Icon, errwrap string) {
// Reveal to show the placeholder.
i.SetRevealChild(true)
AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) { AsyncUse(i.r, func(ctx context.Context) (interface{}, func(), error) {
ni := &nullIcon{} ni := &nullIcon{}
f, err := iconer.Icon(ctx, ni) f, err := iconer.Icon(ctx, ni)
@ -161,8 +164,11 @@ type EventIcon struct {
func NewEventIcon(sizepx int, pp ...imgutil.Processor) *EventIcon { func NewEventIcon(sizepx int, pp ...imgutil.Processor) *EventIcon {
icn := NewIcon(sizepx, pp...) icn := NewIcon(sizepx, pp...)
icn.Show() return WrapEventIcon(icn)
}
func WrapEventIcon(icn *Icon) *EventIcon {
icn.Show()
evb, _ := gtk.EventBoxNew() evb, _ := gtk.EventBoxNew()
evb.Add(icn) evb.Add(icn)
@ -189,11 +195,15 @@ var (
) )
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
img, _ := roundimage.NewStaticImage(nil, 0)
img.Show()
return NewCustomToggleButtonImage(img, content)
}
func NewCustomToggleButtonImage(img RoundIconContainer, content text.Rich) *ToggleButtonImage {
l := NewLabel(content) l := NewLabel(content)
l.Show() l.Show()
img, _ := roundimage.NewStaticImage(nil, 0)
img.Show()
i := NewCustomIcon(img, 0) i := NewCustomIcon(img, 0)
i.Show() i.Show()
@ -205,7 +215,9 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b, _ := gtk.ToggleButtonNew() b, _ := gtk.ToggleButtonNew()
b.Add(box) b.Add(box)
img.ConnectHandlers(b) if connector, ok := img.(roundimage.Connector); ok {
connector.ConnectHandlers(b)
}
return &ToggleButtonImage{ return &ToggleButtonImage{
ToggleButton: *b, ToggleButton: *b,

View File

@ -91,7 +91,7 @@ func NewHeader() *Header {
header.PackStart(appmenu) header.PackStart(appmenu)
header.PackStart(sep) header.PackStart(sep)
header.PackStart(svcname) header.PackStart(svcname)
header.PackStart(sesmenu) header.PackEnd(sesmenu)
// Hack to hide the title. // Hack to hide the title.
b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0)

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat-gtk/internal/ui/service/session"
@ -96,14 +97,16 @@ func NewService(svc cchat.Service, svclctrl ListController) *Service {
// TODO: have it so the button changes to the session avatar when collapsed // TODO: have it so the button changes to the session avatar when collapsed
// TODO: libhandy avatar generation? avatar := roundimage.NewAvatar(IconSize)
service.Icon = rich.NewIcon(IconSize) avatar.SetText(svc.Name().String())
avatar.Show()
service.Icon = rich.NewCustomIcon(avatar, IconSize)
service.Icon.Show() service.Icon.Show()
// potentially nonstandard // potentially nonstandard
service.Icon.SetPlaceholderIcon("text-html-symbolic", IconSize) service.Icon.SetPlaceholderIcon("text-html-symbolic", IconSize)
// TODO: hover for name. We use tooltip for now. // TODO: hover for name. We use tooltip for now.
service.Icon.SetTooltipMarkup(markup.Render(svc.Name())) service.Icon.SetTooltipMarkup(markup.Render(svc.Name()))
// TODO: add a padding
serviceIconCSS(service.Icon) serviceIconCSS(service.Icon)
if iconer, ok := svc.(cchat.Icon); ok { if iconer, ok := svc.(cchat.Icon); ok {

View File

@ -14,7 +14,7 @@ const UnreadColorDefs = `
` `
type ToggleButtonImage struct { type ToggleButtonImage struct {
rich.ToggleButtonImage *rich.ToggleButtonImage
extraMenu []menu.Item extraMenu []menu.Item
menu *menu.LazyMenu menu *menu.LazyMenu
@ -57,10 +57,14 @@ var serverButtonCSS = primitives.PrepareClassCSS("server-button", `
func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
b := rich.NewToggleButtonImage(content) b := rich.NewToggleButtonImage(content)
return WrapToggleButtonImage(b)
}
func WrapToggleButtonImage(b *rich.ToggleButtonImage) *ToggleButtonImage {
b.Show() b.Show()
tb := &ToggleButtonImage{ tb := &ToggleButtonImage{
ToggleButtonImage: *b, ToggleButtonImage: b,
clicked: func(bool) {}, clicked: func(bool) {},
menu: menu.NewLazyMenu(b.ToggleButton), menu: menu.NewLazyMenu(b.ToggleButton),
@ -73,7 +77,8 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage {
func (b *ToggleButtonImage) SetSelected(selected bool) { func (b *ToggleButtonImage) SetSelected(selected bool) {
// Set the clickability the opposite as the boolean. // Set the clickability the opposite as the boolean.
b.SetSensitive(!selected) // b.SetSensitive(!selected)
b.SetInconsistent(selected)
if selected { if selected {
primitives.AddClass(b, "selected-server") primitives.AddClass(b, "selected-server")

View File

@ -69,6 +69,7 @@ func (c *Children) Init() {
if c.IsHollow() { if c.IsHollow() {
c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0)
c.Box.SetMarginStart(ChildrenMargin) c.Box.SetMarginStart(ChildrenMargin)
c.Box.SetHExpand(true)
childrenCSS(c.Box) childrenCSS(c.Box)
// Check if we're still loading. This is effectively restoring the // Check if we're still loading. This is effectively restoring the
@ -180,6 +181,24 @@ func (c *Children) LoadAll() {
c.Box.Add(row) c.Box.Add(row)
} }
} }
// Check if we have icons.
var hasIcon bool
for _, row := range c.Rows {
if row.HasIcon() {
hasIcon = true
break
}
}
// If we have an icon, then show all other possibly empty icons. HdyAvatar
// will generate a placeholder.
if hasIcon {
for _, row := range c.Rows {
row.UseEmptyIcon()
}
}
} }
// saveSelectedRow saves the current selected row and returns a callback that // saveSelectedRow saves the current selected row and returns a callback that
@ -198,6 +217,7 @@ func (c *Children) saveSelectedRow() (restore func()) {
if oldID != "" { if oldID != "" {
for _, row := range c.Rows { for _, row := range c.Rows {
if row.Server.ID() == oldID { if row.Server.ID() == oldID {
row.Init()
row.Button.SetActive(true) row.Button.SetActive(true)
} }
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/gts"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse"
@ -109,7 +110,7 @@ func (r *ServerRow) Init() {
case cchat.ServerMessage: case cchat.ServerMessage:
primitives.AddClass(r, "server-message") primitives.AddClass(r, "server-message")
r.Button.SetClickedIfTrue(func() { r.ctrl.RowSelected(r, server) }) r.Button.SetClicked(func(bool) { r.ctrl.RowSelected(r, server) })
} }
} }
@ -152,6 +153,7 @@ func (r *ServerRow) SetUnreadUnsafe(unread, mentioned bool) {
type Row struct { type Row struct {
*gtk.Box *gtk.Box
Avatar *roundimage.Avatar
Button *button.ToggleButtonImage Button *button.ToggleButtonImage
parentcrumb traverse.Breadcrumber parentcrumb traverse.Breadcrumber
@ -181,7 +183,14 @@ func (r *Row) Init(name text.Rich) {
return return
} }
r.Button = button.NewToggleButtonImage(name) r.Avatar = roundimage.NewAvatar(IconSize)
r.Avatar.SetText(name.Content)
r.Avatar.Show()
btn := rich.NewCustomToggleButtonImage(r.Avatar, name)
btn.Show()
r.Button = button.WrapToggleButtonImage(btn)
r.Button.Box.SetHAlign(gtk.ALIGN_START) r.Button.Box.SetHAlign(gtk.ALIGN_START)
r.Button.SetRelief(gtk.RELIEF_NONE) r.Button.SetRelief(gtk.RELIEF_NONE)
r.Button.Show() r.Button.Show()
@ -193,10 +202,6 @@ func (r *Row) Init(name text.Rich) {
r.childrenSetErr(r.childrenErr) r.childrenSetErr(r.childrenErr)
} }
func (r *Row) Breadcrumb() traverse.Breadcrumb {
return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText())
}
// SetHollowServerList sets the row to a hollow server list (children) and // SetHollowServerList sets the row to a hollow server list (children) and
// recursively create // recursively create
func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) { func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) {
@ -255,10 +260,31 @@ func (r *Row) childrenSetErr(err error) {
} }
} }
// UseEmptyIcon forces the row to show a placeholder icon.
func (r *ServerRow) UseEmptyIcon() {
AssertUnhollow(r)
r.Button.Image.SetSize(IconSize)
r.Button.Image.SetRevealChild(true)
}
// HasIcon returns true if the current row has an icon.
func (r *ServerRow) HasIcon() bool {
return !r.IsHollow() && r.Button.Image.GetRevealChild()
}
func (r *Row) Breadcrumb() traverse.Breadcrumb {
if r.IsHollow() {
return nil
}
return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText())
}
func (r *Row) SetLabelUnsafe(name text.Rich) { func (r *Row) SetLabelUnsafe(name text.Rich) {
AssertUnhollow(r) AssertUnhollow(r)
r.Button.SetLabelUnsafe(name) r.Button.SetLabelUnsafe(name)
r.Avatar.SetText(name.Content)
} }
func (r *Row) SetIconer(v interface{}) { func (r *Row) SetIconer(v interface{}) {

View File

@ -29,7 +29,10 @@ type Servers struct {
} }
var toplevelCSS = primitives.PrepareClassCSS("top-level", ` var toplevelCSS = primitives.PrepareClassCSS("top-level", `
.top-level { margin: 0 3px } .top-level {
margin: 0 3px;
margin-top: 3px;
}
`) `)
func NewServers(p traverse.Breadcrumber, ctrl server.Controller) *Servers { func NewServers(p traverse.Breadcrumber, ctrl server.Controller) *Servers {

View File

@ -8,6 +8,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner"
"github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich"
"github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup"
@ -51,7 +52,8 @@ type Servicer interface {
// Row represents a session row entry in the session List. // Row represents a session row entry in the session List.
type Row struct { type Row struct {
*gtk.ListBoxRow *gtk.ListBoxRow
icon *rich.EventIcon // nilable avatar *roundimage.Avatar
icon *rich.EventIcon // nilable
parentcrumb traverse.Breadcrumber parentcrumb traverse.Breadcrumber
@ -112,6 +114,7 @@ var rowIconCSS = primitives.PrepareClassCSS("session-icon", `
padding: 4px; padding: 4px;
margin: 0; margin: 0;
} }
.session-icon.failed { .session-icon.failed {
background-color: alpha(red, 0.45); background-color: alpha(red, 0.45);
} }
@ -136,7 +139,14 @@ func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Servicer) *Row {
parentcrumb: parent, parentcrumb: parent,
} }
row.icon = rich.NewEventIcon(IconSize) row.avatar = roundimage.NewAvatar(IconSize)
row.avatar.SetText(name.Content)
row.avatar.Show()
icon := rich.NewCustomIcon(row.avatar, IconSize)
icon.Show()
row.icon = rich.WrapEventIcon(icon)
row.icon.Icon.SetPlaceholderIcon(IconName, IconSize) row.icon.Icon.SetPlaceholderIcon(IconName, IconSize)
row.icon.Show() row.icon.Show()
rowIconCSS(row.icon.Icon) rowIconCSS(row.icon.Icon)
@ -295,6 +305,7 @@ func (r *Row) SetSession(ses cchat.Session) {
r.sessionID = ses.ID() r.sessionID = ses.ID()
r.SetTooltipMarkup(markup.Render(ses.Name())) r.SetTooltipMarkup(markup.Render(ses.Name()))
r.icon.Icon.SetPlaceholderIcon(IconName, IconSize) r.icon.Icon.SetPlaceholderIcon(IconName, IconSize)
r.avatar.SetText(ses.Name().Content)
// If the session has an icon, then use it. // If the session has an icon, then use it.
if iconer, ok := ses.(cchat.Icon); ok { if iconer, ok := ses.(cchat.Icon); ok {

View File

@ -2,6 +2,7 @@ package service
import ( import (
"github.com/diamondburned/cchat" "github.com/diamondburned/cchat"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives/singlestack" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/singlestack"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat-gtk/internal/ui/service/session"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server"
@ -55,6 +56,7 @@ func NewView(ctrller Controller) *View {
view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE) view.ServerStack.SetTransitionType(gtk.STACK_TRANSITION_TYPE_CROSSFADE)
view.ServerStack.SetHomogeneous(true) view.ServerStack.SetHomogeneous(true)
view.ServerStack.Show() view.ServerStack.Show()
primitives.AddClass(view.ServerStack, "server-stack")
view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil) view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil)
view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC)

View File

@ -7,6 +7,7 @@ import (
"github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/log"
"github.com/diamondburned/cchat-gtk/internal/ui/config/preferences" "github.com/diamondburned/cchat-gtk/internal/ui/config/preferences"
"github.com/diamondburned/cchat-gtk/internal/ui/messages" "github.com/diamondburned/cchat-gtk/internal/ui/messages"
"github.com/diamondburned/cchat-gtk/internal/ui/primitives"
"github.com/diamondburned/cchat-gtk/internal/ui/service" "github.com/diamondburned/cchat-gtk/internal/ui/service"
"github.com/diamondburned/cchat-gtk/internal/ui/service/auth" "github.com/diamondburned/cchat-gtk/internal/ui/service/auth"
"github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/diamondburned/cchat-gtk/internal/ui/service/session"
@ -29,6 +30,9 @@ func init() {
/* Hack to fix the input bar being high in Adwaita */ /* Hack to fix the input bar being high in Adwaita */
.input-field * { min-height: 0 } .input-field * { min-height: 0 }
/* Hide all scroll undershoots */
undershoot { background-size: 0 }
`) `)
} }
@ -71,10 +75,12 @@ func NewApplication() *App {
app := &App{} app := &App{}
app.Services = service.NewView(app) app.Services = service.NewView(app)
app.Services.SetSizeRequest(leftMinWidth, -1) app.Services.SetSizeRequest(leftCurrentWidth, -1)
app.Services.SetHExpand(false)
app.Services.Show() app.Services.Show()
app.MessageView = messages.NewView(app) app.MessageView = messages.NewView(app)
app.MessageView.SetHExpand(true)
app.MessageView.Show() app.MessageView.Show()
app.HeaderGroup = handy.HeaderGroupNew() app.HeaderGroup = handy.HeaderGroupNew()
@ -82,6 +88,7 @@ func NewApplication() *App {
app.HeaderGroup.AddHeaderBar(&app.MessageView.Header.HeaderBar) app.HeaderGroup.AddHeaderBar(&app.MessageView.Header.HeaderBar)
app.Leaflet = *handy.LeafletNew() app.Leaflet = *handy.LeafletNew()
app.Leaflet.SetTransitionType(handy.LeafletTransitionTypeUnder)
app.Leaflet.Add(app.Services) app.Leaflet.Add(app.Services)
app.Leaflet.Add(app.MessageView) app.Leaflet.Add(app.MessageView)
app.Leaflet.Show() app.Leaflet.Show()
@ -90,6 +97,8 @@ func NewApplication() *App {
// The action name for this is "app.preferences". // The action name for this is "app.preferences".
gts.AddAppAction("preferences", preferences.SpawnPreferenceDialog) gts.AddAppAction("preferences", preferences.SpawnPreferenceDialog)
primitives.LeafletOnFold(&app.Leaflet, app.MessageView.SetFolded)
return app return app
} }
@ -128,6 +137,14 @@ func (app *App) SessionSelected(svc *service.Service, ses *session.Row) {
} }
func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) { func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) {
// Change to the message view.
app.Leaflet.SetVisibleChild(app.MessageView)
// Assert that the new server is not the same one.
if app.MessageView.ServerID() == srv.Server.ID() {
return
}
// Is there an old row that we should deactivate? // Is there an old row that we should deactivate?
if app.lastSelector != nil { if app.lastSelector != nil {
app.lastSelector(false) app.lastSelector(false)
@ -137,12 +154,15 @@ func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.
app.lastSelector = srv.SetSelected app.lastSelector = srv.SetSelected
app.lastSelector(true) app.lastSelector(true)
// Assert that server is also a list, then join the server. app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), srv)
app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage))
} }
// MessageView methods. // MessageView methods.
func (app *App) GoBack() {
app.Leaflet.Navigate(handy.NavigationDirectionBack)
}
func (app *App) OnMessageBusy() { func (app *App) OnMessageBusy() {
// Disable the server list because we don't want the user to switch around // Disable the server list because we don't want the user to switch around
// while we're loading. // while we're loading.