diff --git a/go.mod b/go.mod index 8caf47f..346880f 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/diamondburned/cchat v0.0.49 github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e 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/disintegration/imaging v1.6.2 github.com/goodsign/monday v1.0.0 diff --git a/go.sum b/go.sum index 84c6fa0..ee5562c 100644 --- a/go.sum +++ b/go.sum @@ -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/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-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/go.mod h1:kBQKaukR/LyCfhED99/T4/XxUMDNEEzf1Fx6vreD3RQ= github.com/diamondburned/ningen v0.1.1-0.20200717072304-e483f86c08e6 h1:YN0cj0aOCa+tKmx0aD5qsbSYaIJnyrA0/+eygMKP+/w= diff --git a/internal/ui/messages/header.go b/internal/ui/messages/header.go index 3a6d26c..594388a 100644 --- a/internal/ui/messages/header.go +++ b/internal/ui/messages/header.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/handy" "github.com/gotk3/gotk3/gtk" + "github.com/gotk3/gotk3/pango" ) // const BreadcrumbSlash = `` @@ -16,30 +17,76 @@ const BreadcrumbSlash = " 〉" type Header struct { 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", ` .right-breadcrumb { - margin: 0 14px; + margin-left: 14px; } `) 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.SetUseMarkup(true) bc.SetXAlign(0.0) + bc.SetEllipsize(pango.ELLIPSIZE_MIDDLE) + bc.SetSingleLineMode(true) + bc.SetHExpand(true) + bc.SetMaxWidthChars(75) bc.Show() 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.SetShowCloseButton(true) + header.PackStart(rbk) header.PackStart(bc) + header.PackEnd(mb) header.Show() + // Hack to hide the title. + b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + header.SetCustomTitle(b) + return &Header{ - HeaderBar: *header, - Breadcrumb: bc, + HeaderBar: *header, + ShowBackBtn: rbk, + BackButton: bk, + Breadcrumb: bc, + ShowMembers: mb, } } @@ -47,18 +94,71 @@ func (h *Header) Reset() { 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) { if b == nil { - h.Breadcrumb.SetText("") + h.breadcrumbs = nil + h.updateBreadcrumb() return } - var crumb = b.Breadcrumb() - for i := range crumb { - crumb[i] = html.EscapeString(crumb[i]) + h.breadcrumbs = b.Breadcrumb() + if len(h.breadcrumbs) < 2 { + return } - h.Breadcrumb.SetMarkup( - BreadcrumbSlash + " " + strings.Join(crumb, " "+BreadcrumbSlash+" "), - ) + // Skip the service name and username. + h.breadcrumbs = h.breadcrumbs[2:] + + for i := range h.breadcrumbs { + h.breadcrumbs[i] = html.EscapeString(h.breadcrumbs[i]) + } + + h.updateBreadcrumb() } diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 34cf789..90c0b80 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -33,7 +33,7 @@ var textCSS = primitives.PrepareCSS(` } .message-input, .message-input * { - background-color: transparent; + background-color: mix(@theme_bg_color, @theme_fg_color, 0.03); } .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 { text, _ := gtk.TextViewNew() text.SetSensitive(false) @@ -68,8 +74,6 @@ func NewView(ctrl Controller) *InputView { f := NewField(text, ctrl) f.Show() - primitives.AddClass(f, "input-field") - return &InputView{f, c} } @@ -120,8 +124,17 @@ func (s *fieldState) Reset() { *s = fieldState{} } -var inputFieldCSS = primitives.PrepareCSS(` - .input-field { margin: 3px 5px } +var inputFieldCSS = primitives.PrepareClassCSS("input-field", ` + .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 { @@ -133,7 +146,7 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field { field.TextScroll = scrollinput.NewV(text, 150) 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.SetRelief(gtk.RELIEF_NONE) @@ -146,15 +159,13 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field { field.send.Show() primitives.AddClass(field.send, "send-button") - // Keep this number the same as size-allocate below -------v - field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 5) + field.FieldBox, _ = gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) field.FieldBox.PackStart(field.Username, false, false, 0) field.FieldBox.PackStart(field.attach, false, false, 0) field.FieldBox.PackStart(field.TextScroll, true, true, 0) field.FieldBox.PackStart(field.send, false, false, 0) field.FieldBox.Show() - primitives.AddClass(field.FieldBox, "input-field") - primitives.AttachCSS(field.FieldBox, inputFieldCSS) + inputFieldCSS(field.FieldBox) field.Attachments = attachment.New() 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.FieldBox, false, false, 0) field.Box.Show() + inputBoxCSS(field.Box) text.SetFocusHAdjustment(field.TextScroll.GetHAdjustment()) 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) { // Calculate the left width: from the left of the message box to the // 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. field.Attachments.SetMarginStart(leftWidth) }) diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go index 205ed93..92e0fbe 100644 --- a/internal/ui/messages/input/username/username.go +++ b/internal/ui/messages/input/username/username.go @@ -77,12 +77,12 @@ func NewContainer() *Container { func (u *Container) SetRevealChild(reveal bool) { // 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. func (u *Container) shouldReveal() bool { - return !u.label.GetLabel().Empty() && showUser + return (!u.label.GetLabel().Empty() || u.avatar.URL() != "") && showUser } func (u *Container) Reset() { @@ -96,7 +96,7 @@ func (u *Container) Update(session cchat.Session, sender cchat.ServerMessageSend // Set the fallback username. u.label.SetLabelUnsafe(session.Name()) // 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. if nicknamer, ok := sender.(cchat.ServerNickname); ok { @@ -120,7 +120,7 @@ func (u *Container) SetLabel(content text.Rich) { u.label.SetLabelUnsafe(content) // 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 // URL is empty, as the name might not be. - if url != "" { - u.SetRevealChild(true) - } + u.SetRevealChild(true) }) } diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go index 2589051..f62996e 100644 --- a/internal/ui/messages/memberlist/memberlist.go +++ b/internal/ui/messages/memberlist/memberlist.go @@ -21,16 +21,21 @@ import ( var MemberListWidth = 250 +type Controller interface { + MemberListUpdated(c *Container) +} + type Container struct { *gtk.Revealer Scroll *gtk.ScrolledWindow Main *gtk.Box + ctrl Controller + + // states // map id -> *Section Sections map[string]*Section - - // states - stop func() + stop func() } 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.SetSizeRequest(250, -1) main.Show() @@ -52,7 +57,7 @@ func New() *Container { rev, _ := gtk.RevealerNew() rev.SetTransitionType(gtk.REVEALER_TRANSITION_TYPE_SLIDE_RIGHT) - rev.SetTransitionDuration(50) + rev.SetTransitionDuration(75) rev.SetRevealChild(false) rev.Add(sw) @@ -60,10 +65,16 @@ func New() *Container { Revealer: rev, Scroll: sw, Main: main, + ctrl: ctrl, 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. func (c *Container) Reset() { 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 func() { - c.stop = f - c.Revealer.SetRevealChild(true) - }, nil + return func() { c.stop = f }, nil }) } @@ -138,6 +146,8 @@ func (c *Container) SetSectionsUnsafe(sections []cchat.MemberListSection) { c.Main.Add(section) c.Sections[section.ID] = section } + + c.ctrl.MemberListUpdated(c) } func (c *Container) SetMemberUnsafe(sectionID string, member cchat.ListMember) { diff --git a/internal/ui/messages/sadface/sadface.go b/internal/ui/messages/sadface/sadface.go index 5a597d2..b89ba22 100644 --- a/internal/ui/messages/sadface/sadface.go +++ b/internal/ui/messages/sadface/sadface.go @@ -10,20 +10,15 @@ import ( const FaceSize = 56 -type WidgetUnreferencer interface { - gtk.IWidget - Unref() -} - type FaceView struct { gtk.Stack - placeholder WidgetUnreferencer + placeholder gtk.IWidget Face *Container Loading *Spinner } -func New(parent gtk.IWidget, placeholder WidgetUnreferencer) *FaceView { +func New(parent gtk.IWidget, placeholder gtk.IWidget) *FaceView { c := NewContainer() c.Show() @@ -55,14 +50,6 @@ func (v *FaceView) Reset() { v.Stack.SetVisibleChildName("empty") } -// func (v *FaceView) Disable() { -// v.Stack.SetSensitive(false) -// } - -// func (v *FaceView) Enable() { -// v.Stack.SetSensitive(true) -// } - func (v *FaceView) SetMain() { v.ensurePlaceholderDestroyed() v.Loading.Spinner.Stop() @@ -104,7 +91,6 @@ type Spinner struct { func NewSpinner() *Spinner { s, _ := gtk.SpinnerNew() s.SetSizeRequest(FaceSize, FaceSize) - s.Start() s.Show() b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index cb98d30..018e083 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -20,6 +20,8 @@ import ( "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/menu" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" + "github.com/diamondburned/handy" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" ) @@ -40,6 +42,8 @@ func init() { } 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 // when it's loading messages. OnMessageBusy() @@ -54,8 +58,9 @@ type View struct { Header *Header FaceView *sadface.FaceView - Grid *gtk.Grid + Leaflet *handy.Leaflet + LeftBox *gtk.Box Scroller *autoscroll.ScrolledWindow InputView *input.InputView @@ -64,20 +69,29 @@ type View struct { Container container.Container contType int // msgIndex - MemberList *memberlist.Container + MemberList *memberlist.Container // right box // Inherit some useful methods. 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 { view := &View{ctrl: c} view.Typing = typing.New() view.Typing.Show() - view.MemberList = memberlist.New() + view.MemberList = memberlist.New(view) view.MemberList.Show() view.MsgBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 2) @@ -89,6 +103,7 @@ func NewView(c Controller) *View { view.Scroller.SetVExpand(true) view.Scroller.SetHExpand(true) view.Scroller.Show() + messageScroller(view.Scroller) view.MsgBox.SetFocusHAdjustment(view.Scroller.GetHAdjustment()) view.MsgBox.SetFocusVAdjustment(view.Scroller.GetVAdjustment()) @@ -113,27 +128,51 @@ func NewView(c Controller) *View { view.InputView.SetHExpand(true) view.InputView.Show() - view.Grid, _ = gtk.GridNew() - view.Grid.Attach(view.Scroller, 0, 0, 1, 1) - view.Grid.Attach(sep, 0, 1, 1, 1) - view.Grid.Attach(view.InputView, 0, 2, 1, 1) - view.Grid.Attach(view.MemberList, 1, 0, 1, 3) - view.Grid.Show() + view.LeftBox, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + view.LeftBox.PackStart(view.Scroller, true, true, 0) + view.LeftBox.PackStart(sep, false, false, 0) + view.LeftBox.PackStart(view.InputView, false, false, 0) + view.LeftBox.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. - drag.BindFileDest(view.Grid, view.InputView.Attachments.AddFiles) + drag.BindFileDest(view.LeftBox, view.InputView.Attachments.AddFiles) // placeholder logo logo, _ := gtk.ImageNewFromPixbuf(icons.Logo256Variant2(128)) logo.Show() - view.FaceView = sadface.New(view.Grid, logo) + view.FaceView = sadface.New(view.Leaflet, logo) view.FaceView.Show() + messageStack(view.FaceView) view.Header = NewHeader() 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.PackStart(view.Header, false, false, 0) @@ -173,6 +212,9 @@ func (v *View) Reset() { v.MemberList.Reset() // Reset the member list. 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. v.Scroller.Bottomed = true @@ -180,8 +222,45 @@ func (v *View) Reset() { 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. -func (v *View) JoinServer(session cchat.Session, server ServerMessage) { +func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traverse.Breadcrumber) { // Reset before setting. v.Reset() @@ -221,6 +300,9 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage) { // Set the cancel handler. v.state.setcurrent(s) + // Set the headerbar's breadcrumb. + v.Header.SetBreadcrumber(bc) + // Try setting the typing indicator if available. v.Typing.TrySubscribe(server) @@ -253,7 +335,10 @@ func (v *View) FetchBacklog() { } 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") }) } @@ -386,6 +471,14 @@ func (s *state) SessionID() string { 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 // Backlogger returns the backlogger instance if it's allowed to fetch more diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index aefec61..d62d19f 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -2,9 +2,11 @@ package primitives import ( "runtime/debug" + "time" "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/handy" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" @@ -115,8 +117,20 @@ type ImageIconSetter interface { } func SetImageIcon(img ImageIconSetter, icon string, sizepx int) { - img.SetProperty("icon-name", icon) - img.SetProperty("pixel-size", sizepx) + // Prioritize SetSize() + 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) } @@ -261,3 +275,28 @@ func AttachCSS(ctx StyleContexter, prov *gtk.CssProvider) { func InlineCSS(ctx StyleContexter, css string) { 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) + } + }) +} diff --git a/internal/ui/primitives/roundimage/avatar.go b/internal/ui/primitives/roundimage/avatar.go new file mode 100644 index 0000000..d12e348 --- /dev/null +++ b/internal/ui/primitives/roundimage/avatar.go @@ -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 +} diff --git a/internal/ui/primitives/roundimage/button.go b/internal/ui/primitives/roundimage/button.go new file mode 100644 index 0000000..bfa4c05 --- /dev/null +++ b/internal/ui/primitives/roundimage/button.go @@ -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) +} diff --git a/internal/ui/primitives/roundimage/roundimage.go b/internal/ui/primitives/roundimage/roundimage.go index 681a72d..f545eb0 100644 --- a/internal/ui/primitives/roundimage/roundimage.go +++ b/internal/ui/primitives/roundimage/roundimage.go @@ -15,57 +15,6 @@ const ( 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 { SetRadius(float64) } @@ -87,59 +36,6 @@ type Imager interface { 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 { *gtk.Image Radius float64 diff --git a/internal/ui/primitives/roundimage/static.go b/internal/ui/primitives/roundimage/static.go new file mode 100644 index 0000000..f68df50 --- /dev/null +++ b/internal/ui/primitives/roundimage/static.go @@ -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 +} diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index 2dd4bc7..5e31553 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -135,6 +135,9 @@ func (i *Icon) SetIcon(url 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) { ni := &nullIcon{} f, err := iconer.Icon(ctx, ni) @@ -161,8 +164,11 @@ type EventIcon struct { func NewEventIcon(sizepx int, pp ...imgutil.Processor) *EventIcon { icn := NewIcon(sizepx, pp...) - icn.Show() + return WrapEventIcon(icn) +} +func WrapEventIcon(icn *Icon) *EventIcon { + icn.Show() evb, _ := gtk.EventBoxNew() evb.Add(icn) @@ -189,11 +195,15 @@ var ( ) 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.Show() - img, _ := roundimage.NewStaticImage(nil, 0) - img.Show() i := NewCustomIcon(img, 0) i.Show() @@ -205,7 +215,9 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { b, _ := gtk.ToggleButtonNew() b.Add(box) - img.ConnectHandlers(b) + if connector, ok := img.(roundimage.Connector); ok { + connector.ConnectHandlers(b) + } return &ToggleButtonImage{ ToggleButton: *b, diff --git a/internal/ui/service/header.go b/internal/ui/service/header.go index 57c72bb..521b8dc 100644 --- a/internal/ui/service/header.go +++ b/internal/ui/service/header.go @@ -91,7 +91,7 @@ func NewHeader() *Header { header.PackStart(appmenu) header.PackStart(sep) header.PackStart(svcname) - header.PackStart(sesmenu) + header.PackEnd(sesmenu) // Hack to hide the title. b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 6095a66..f7bb7a7 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -8,6 +8,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/log" "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/roundimage" "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/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: libhandy avatar generation? - service.Icon = rich.NewIcon(IconSize) + avatar := roundimage.NewAvatar(IconSize) + avatar.SetText(svc.Name().String()) + avatar.Show() + + service.Icon = rich.NewCustomIcon(avatar, IconSize) service.Icon.Show() // potentially nonstandard service.Icon.SetPlaceholderIcon("text-html-symbolic", IconSize) // TODO: hover for name. We use tooltip for now. service.Icon.SetTooltipMarkup(markup.Render(svc.Name())) - // TODO: add a padding serviceIconCSS(service.Icon) if iconer, ok := svc.(cchat.Icon); ok { diff --git a/internal/ui/service/session/server/button/button.go b/internal/ui/service/session/server/button/button.go index b2dbe01..9613cdb 100644 --- a/internal/ui/service/session/server/button/button.go +++ b/internal/ui/service/session/server/button/button.go @@ -14,7 +14,7 @@ const UnreadColorDefs = ` ` type ToggleButtonImage struct { - rich.ToggleButtonImage + *rich.ToggleButtonImage extraMenu []menu.Item menu *menu.LazyMenu @@ -57,10 +57,14 @@ var serverButtonCSS = primitives.PrepareClassCSS("server-button", ` func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { b := rich.NewToggleButtonImage(content) + return WrapToggleButtonImage(b) +} + +func WrapToggleButtonImage(b *rich.ToggleButtonImage) *ToggleButtonImage { b.Show() tb := &ToggleButtonImage{ - ToggleButtonImage: *b, + ToggleButtonImage: b, clicked: func(bool) {}, menu: menu.NewLazyMenu(b.ToggleButton), @@ -73,7 +77,8 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { func (b *ToggleButtonImage) SetSelected(selected bool) { // Set the clickability the opposite as the boolean. - b.SetSensitive(!selected) + // b.SetSensitive(!selected) + b.SetInconsistent(selected) if selected { primitives.AddClass(b, "selected-server") diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go index ecd0ee2..f8d12f9 100644 --- a/internal/ui/service/session/server/children.go +++ b/internal/ui/service/session/server/children.go @@ -69,6 +69,7 @@ func (c *Children) Init() { if c.IsHollow() { c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) c.Box.SetMarginStart(ChildrenMargin) + c.Box.SetHExpand(true) childrenCSS(c.Box) // Check if we're still loading. This is effectively restoring the @@ -180,6 +181,24 @@ func (c *Children) LoadAll() { 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 @@ -198,6 +217,7 @@ func (c *Children) saveSelectedRow() (restore func()) { if oldID != "" { for _, row := range c.Rows { if row.Server.ID() == oldID { + row.Init() row.Button.SetActive(true) } } diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index d14cbf1..beebb91 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -5,6 +5,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/gts" "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/roundimage" "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/traverse" @@ -109,7 +110,7 @@ func (r *ServerRow) Init() { case cchat.ServerMessage: 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 { *gtk.Box + Avatar *roundimage.Avatar Button *button.ToggleButtonImage parentcrumb traverse.Breadcrumber @@ -181,7 +183,14 @@ func (r *Row) Init(name text.Rich) { 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.SetRelief(gtk.RELIEF_NONE) r.Button.Show() @@ -193,10 +202,6 @@ func (r *Row) Init(name text.Rich) { 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 // recursively create 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) { AssertUnhollow(r) r.Button.SetLabelUnsafe(name) + r.Avatar.SetText(name.Content) } func (r *Row) SetIconer(v interface{}) { diff --git a/internal/ui/service/session/servers.go b/internal/ui/service/session/servers.go index 9d32f89..a6e4789 100644 --- a/internal/ui/service/session/servers.go +++ b/internal/ui/service/session/servers.go @@ -29,7 +29,10 @@ type Servers struct { } 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 { diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 0409eab..b33186b 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -8,6 +8,7 @@ import ( "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/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/rich" "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. type Row struct { *gtk.ListBoxRow - icon *rich.EventIcon // nilable + avatar *roundimage.Avatar + icon *rich.EventIcon // nilable parentcrumb traverse.Breadcrumber @@ -112,6 +114,7 @@ var rowIconCSS = primitives.PrepareClassCSS("session-icon", ` padding: 4px; margin: 0; } + .session-icon.failed { background-color: alpha(red, 0.45); } @@ -136,7 +139,14 @@ func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Servicer) *Row { 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.Show() rowIconCSS(row.icon.Icon) @@ -295,6 +305,7 @@ func (r *Row) SetSession(ses cchat.Session) { r.sessionID = ses.ID() r.SetTooltipMarkup(markup.Render(ses.Name())) r.icon.Icon.SetPlaceholderIcon(IconName, IconSize) + r.avatar.SetText(ses.Name().Content) // If the session has an icon, then use it. if iconer, ok := ses.(cchat.Icon); ok { diff --git a/internal/ui/service/view.go b/internal/ui/service/view.go index ad2e7d0..67bf160 100644 --- a/internal/ui/service/view.go +++ b/internal/ui/service/view.go @@ -2,6 +2,7 @@ package service import ( "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/service/session" "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.SetHomogeneous(true) view.ServerStack.Show() + primitives.AddClass(view.ServerStack, "server-stack") view.ServerView, _ = gtk.ScrolledWindowNew(nil, nil) view.ServerView.SetPolicy(gtk.POLICY_NEVER, gtk.POLICY_AUTOMATIC) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 082404b..440a1c8 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -7,6 +7,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/config/preferences" "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/auth" "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 */ .input-field * { min-height: 0 } + + /* Hide all scroll undershoots */ + undershoot { background-size: 0 } `) } @@ -71,10 +75,12 @@ func NewApplication() *App { app := &App{} app.Services = service.NewView(app) - app.Services.SetSizeRequest(leftMinWidth, -1) + app.Services.SetSizeRequest(leftCurrentWidth, -1) + app.Services.SetHExpand(false) app.Services.Show() app.MessageView = messages.NewView(app) + app.MessageView.SetHExpand(true) app.MessageView.Show() app.HeaderGroup = handy.HeaderGroupNew() @@ -82,6 +88,7 @@ func NewApplication() *App { app.HeaderGroup.AddHeaderBar(&app.MessageView.Header.HeaderBar) app.Leaflet = *handy.LeafletNew() + app.Leaflet.SetTransitionType(handy.LeafletTransitionTypeUnder) app.Leaflet.Add(app.Services) app.Leaflet.Add(app.MessageView) app.Leaflet.Show() @@ -90,6 +97,8 @@ func NewApplication() *App { // The action name for this is "app.preferences". gts.AddAppAction("preferences", preferences.SpawnPreferenceDialog) + primitives.LeafletOnFold(&app.Leaflet, app.MessageView.SetFolded) + 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) { + // 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? if app.lastSelector != nil { 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(true) - // Assert that server is also a list, then join the server. - app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage)) + app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), srv) } // MessageView methods. +func (app *App) GoBack() { + app.Leaflet.Navigate(handy.NavigationDirectionBack) +} + func (app *App) OnMessageBusy() { // Disable the server list because we don't want the user to switch around // while we're loading.