diff --git a/go.mod b/go.mod index e0eb03a..1e68290 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Xuanwo/go-locale v0.2.0 github.com/alecthomas/chroma v0.7.3 github.com/diamondburned/cchat v0.0.45 - github.com/diamondburned/cchat-discord v0.0.0-20200717063909-2f4cb5f246c4 + github.com/diamondburned/cchat-discord v0.0.0-20200718071554-360b34de2a71 github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 github.com/disintegration/imaging v1.6.2 diff --git a/go.sum b/go.sum index e8a4fbe..fe67b5d 100644 --- a/go.sum +++ b/go.sum @@ -50,16 +50,16 @@ github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.45 h1:HMVSKx1h6lh2OenWaBTvMSK531hWaXAW7I0tKZepYug= github.com/diamondburned/cchat v0.0.45/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= -github.com/diamondburned/cchat-discord v0.0.0-20200717063909-2f4cb5f246c4 h1:otasqokx6kIGo9KnJt1F22MdCyUIvZmxZkLv+kcdKiI= -github.com/diamondburned/cchat-discord v0.0.0-20200717063909-2f4cb5f246c4/go.mod h1:Z0uWBUaheEtozKj4NMgsSK4X5a3Du5tYakDb5plEluY= +github.com/diamondburned/cchat-discord v0.0.0-20200718071554-360b34de2a71 h1:4alCL+MeOJuz//uhYKuzyn8RS6+yEQFwL9SGCluNRL4= +github.com/diamondburned/cchat-discord v0.0.0-20200718071554-360b34de2a71/go.mod h1:tFB/vWkCPp2kQ7v/VkpCvM1aoB6+fvwuEU/acA/g5CY= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b h1:sq0MXjJc3yAOZvuolRxOpKQNvpMLyTmsECxQqdYgF5E= github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b/go.mod h1:+bAf0m2o5qH54DmYJ/lR1HeITV53ol0JaoKyFFx3m3E= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d h1:Ha/I6PMKi+B4hpWclwlXj0tUMehR7Q0TNxPczzBwzPI= github.com/diamondburned/gotk3 v0.0.0-20200630065217-97aeb06d705d/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= 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.20200717013108-297a3bdf84dc h1:YZ84Kdlv91tdcyLfGfQ+LG9kWZN8dTKIic0KlEtGV0U= -github.com/diamondburned/ningen v0.1.1-0.20200717013108-297a3bdf84dc/go.mod h1:Sunqp1b9Tc0+DtWKslhf83Zepgj/TELB6h8J9HZCPqQ= +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/go.mod h1:Sunqp1b9Tc0+DtWKslhf83Zepgj/TELB6h8J9HZCPqQ= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= diff --git a/internal/gts/httputil/image.go b/internal/gts/httputil/image.go index 18adbd2..fa47cf4 100644 --- a/internal/gts/httputil/image.go +++ b/internal/gts/httputil/image.go @@ -13,6 +13,8 @@ import ( "github.com/pkg/errors" ) +// TODO: + type ImageContainer interface { SetFromPixbuf(*gdk.Pixbuf) SetFromAnimation(*gdk.PixbufAnimation) diff --git a/internal/gts/throttler/throttler.go b/internal/gts/throttler/throttler.go index 7e353ed..7d14f79 100644 --- a/internal/gts/throttler/throttler.go +++ b/internal/gts/throttler/throttler.go @@ -7,7 +7,7 @@ import ( "github.com/gotk3/gotk3/gtk" ) -const TPS = 15 // tps +const TPS = 24 // tps type State struct { throttling bool diff --git a/internal/ui/header.go b/internal/ui/header.go index b7a0aaa..1b0d67d 100644 --- a/internal/ui/header.go +++ b/internal/ui/header.go @@ -7,7 +7,7 @@ import ( "github.com/diamondburned/cchat-gtk/icons" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions" - "github.com/diamondburned/cchat-gtk/internal/ui/service/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat-gtk/internal/ui/service/session" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index e78be3d..8eb17eb 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -9,6 +9,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" @@ -189,8 +190,15 @@ func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) { } } +// authorRenderCfg is the config to render author names. It disables author +// mention links, as there's no way to make normal names not appear blue. +var authorRenderCfg = markup.RenderConfig{ + NoMentionLinks: true, +} + func (m *GenericContainer) UpdateAuthorName(name text.Rich) { - m.Username.SetLabelUnsafe(name) + var out = markup.RenderCmplxWithConfig(name, authorRenderCfg) + m.Username.SetOutput(out) } func (m *GenericContainer) UpdateContent(content text.Rich, edited bool) { diff --git a/internal/ui/messages/message/sending.go b/internal/ui/messages/message/sending.go index 28dbc48..95b547a 100644 --- a/internal/ui/messages/message/sending.go +++ b/internal/ui/messages/message/sending.go @@ -111,9 +111,9 @@ func (m *GenericPresendContainer) SetSentError(err error) { m.contentBox.Add(m.ContentBody) // Style the label appropriately by making it red. - var content = html.EscapeString(m.presend.Content()) - if content == "" { - content = EmptyContentPlaceholder + var content = EmptyContentPlaceholder + if m.presend != nil && m.presend.Content() != "" { + content = m.presend.Content() } m.ContentBody.SetMarkup(fmt.Sprintf(`%s`, content)) diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index f7c86f3..2db837d 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -77,6 +77,12 @@ func (l *Label) Output() markup.RenderOutput { return l.output } +// SetOutput sets the internal output and label. +func (l *Label) SetOutput(o markup.RenderOutput) { + l.output = o + l.SetMarkup(o.Markup) +} + func BindRichLabel(label Labeler) { bind(label, func(uri string, ptr gdk.Rectangle) bool { var output = label.Output() diff --git a/internal/ui/rich/parser/attrmap/attrmap.go b/internal/ui/rich/parser/attrmap/attrmap.go index ae08642..25e2263 100644 --- a/internal/ui/rich/parser/attrmap/attrmap.go +++ b/internal/ui/rich/parser/attrmap/attrmap.go @@ -35,13 +35,14 @@ func (a *AppendMap) appendIndex(ind int) { } func (a *AppendMap) Anchor(start, end int, href string) { - a.Openf(start, ``, html.EscapeString(href)) - a.Close(end, "") + a.AnchorNU(start, end, href) } // AnchorNU makes a new tag without underlines and colors. func (a *AppendMap) AnchorNU(start, end int, href string) { - a.Anchor(start, end, href) + a.Openf(start, ``, html.EscapeString(href)) + a.Close(end, "") + // a.Anchor(start, end, href) a.Span(start, end, `underline="none"`) } diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index bb2e72a..877be78 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -51,6 +51,16 @@ func Render(content text.Rich) string { // RenderCmplx renders content into a complete output. func RenderCmplx(content text.Rich) RenderOutput { + return RenderCmplxWithConfig(content, RenderConfig{}) +} + +type RenderConfig struct { + // NoMentionLinks prevents the renderer from wrapping mentions with a + // hyperlink. This prevents invalid colors. + NoMentionLinks bool +} + +func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // Fast path. if len(content.Segments) == 0 { return RenderOutput{ @@ -87,8 +97,7 @@ func RenderCmplx(content text.Rich) RenderOutput { start, end := segment.Bounds() if segment, ok := segment.(text.Linker); ok { - appended.Openf(start, ``, html.EscapeString(segment.Link())) - appended.Close(end, "") + appended.Anchor(start, end, segment.Link()) } if segment, ok := segment.(text.Imager); ok { @@ -111,7 +120,11 @@ func RenderCmplx(content text.Rich) RenderOutput { if segment, ok := segment.(text.Mentioner); ok { // Render the mention into "cchat://mention:0" or such. Other // components will take care of showing the information. - appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions))) + if cfg.NoMentionLinks { + appended.AnchorNU(start, end, fmt.Sprintf(f_Mention, len(mentions))) + } + + // Add the mention segment into the list regardless of hyperlinks. mentions = append(mentions, segment) if segment, ok := segment.(text.Colorer); ok { @@ -177,8 +190,11 @@ func color(c uint32, bg bool) []string { return attrs } -// string constant for formatting width and height in URL fragments -const f_FragmentSize = "w=%d;h=%d" +const ( + // string constant for formatting width and height in URL fragments + f_FragmentSize = "w=%d;h=%d" + f_AnchorNoUnderline = `%s` +) func composeImageMarkup(imager text.Imager) string { u, err := url.Parse(imager.Image()) @@ -193,7 +209,7 @@ func composeImageMarkup(imager text.Imager) string { } return fmt.Sprintf( - `%s`, + f_AnchorNoUnderline, html.EscapeString(u.String()), html.EscapeString(imager.ImageText()), ) } @@ -211,7 +227,7 @@ func composeAvatarMarkup(avatarer text.Avatarer) string { } return fmt.Sprintf( - `%s`, + f_AnchorNoUnderline, html.EscapeString(u.String()), html.EscapeString(avatarer.AvatarText()), ) } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index e0d80d0..6095a66 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -12,7 +12,7 @@ import ( "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/server" - "github.com/diamondburned/cchat-gtk/internal/ui/service/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/gotk3/gotk3/gtk" ) diff --git a/internal/ui/service/session/server/button/button.go b/internal/ui/service/session/server/button/button.go index 754b9d2..a2ac3c8 100644 --- a/internal/ui/service/session/server/button/button.go +++ b/internal/ui/service/session/server/button/button.go @@ -26,9 +26,30 @@ type ToggleButtonImage struct { var _ cchat.IconContainer = (*ToggleButtonImage)(nil) var serverButtonCSS = primitives.PrepareCSS(` - .read { color: alpha(@theme_fg_color, 0.5) } - .unread { color: @theme_fg_color } - .mentioned { color: red } + .selected-server { + border-left: 2px solid mix(@theme_base_color, @theme_fg_color, 0.1); + background-color: mix(@theme_base_color, @theme_fg_color, 0.1); + color: @theme_fg_color; + } + + .read { + color: alpha(@theme_fg_color, 0.5); + border-left: 2px solid transparent; + } + + .unread { + color: @theme_fg_color; + border-left: 2px solid alpha(@theme_fg_color, 0.75); + background-color: alpha(@theme_fg_color, 0.05); + } + + @define-color mentioned rgb(240, 71, 71); + + .mentioned { + color: @mentioned; + border-left: 2px solid alpha(@mentioned, 0.75); + background-color: alpha(@mentioned, 0.05); + } `) func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { @@ -47,6 +68,22 @@ func NewToggleButtonImage(content text.Rich) *ToggleButtonImage { return tb } +func (b *ToggleButtonImage) SetSelected(selected bool) { + // Set the clickability the opposite as the boolean. + b.SetSensitive(!selected) + + if selected { + primitives.AddClass(b, "selected-server") + } else { + primitives.RemoveClass(b, "selected-server") + } + + // Some special edge case that I forgot. + if !selected { + b.SetActive(false) + } +} + func (b *ToggleButtonImage) SetClicked(clicked func(bool)) { b.clicked = clicked } @@ -100,10 +137,11 @@ func (b *ToggleButtonImage) SetFailed(err error, retry func()) { func (b *ToggleButtonImage) SetUnreadUnsafe(unread, mentioned bool) { switch { - case unread: - b.readcss.SetClass(b, "unread") + // Prioritize mentions over unreads. case mentioned: b.readcss.SetClass(b, "mentioned") + case unread: + b.readcss.SetClass(b, "unread") default: b.readcss.SetClass(b, "read") } diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go index cf6fcca..dd57346 100644 --- a/internal/ui/service/session/server/children.go +++ b/internal/ui/service/session/server/children.go @@ -5,7 +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/service/loading" - "github.com/diamondburned/cchat-gtk/internal/ui/service/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/gotk3/gotk3/gtk" ) @@ -13,18 +13,25 @@ type Controller interface { RowSelected(*ServerRow, cchat.ServerMessage) } -// Children is a children server with a reference to the parent. +// Children is a children server with a reference to the parent. By default, a +// children will contain hollow rows. They are rows that do not yet have any +// widgets. This changes as soon as Row's Load is called. type Children struct { *gtk.Box - load *loading.Button // only not nil while loading + + load *loading.Button // only not nil while loading + loading bool Rows []*ServerRow Parent traverse.Breadcrumber rowctrl Controller + + // Unreadable state for children rows to use. The parent row that has this + // Children will bind a handler to this. + traverse.Unreadable } -// reserved var childrenCSS = primitives.PrepareClassCSS("server-children", ` .server-children { margin: 0; @@ -33,20 +40,73 @@ var childrenCSS = primitives.PrepareClassCSS("server-children", ` } `) -func NewChildren(p traverse.Breadcrumber, ctrl Controller) *Children { - main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - main.SetMarginStart(ChildrenMargin) - childrenCSS(main) - +// NewHollowChildren creates a hollow children, which is a children without any +// widgets. +func NewHollowChildren(p traverse.Breadcrumber, ctrl Controller) *Children { return &Children{ - Box: main, Parent: p, rowctrl: ctrl, } } -// setLoading shows the loading circle as a list child. +// NewChildren creates a hollow children then immediately unhollows it. +func NewChildren(p traverse.Breadcrumber, ctrl Controller) *Children { + c := NewHollowChildren(p, ctrl) + c.Init() + return c +} + +func (c *Children) IsHollow() bool { + return c.Box == nil +} + +// Init ensures that the children container is not hollow. It does nothing after +// the first call. It does not actually populate the list with widgets. This is +// done for lazy loading. To load everything, call LoadAll after this. +// +// Nothing but ServerRow should call this method. +func (c *Children) Init() { + if c.IsHollow() { + c.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + c.Box.SetMarginStart(ChildrenMargin) + childrenCSS(c.Box) + + // Check if we're still loading. This is effectively restoring the + // state that was set before we had widgets. + if c.loading { + c.setLoading() + } else { + c.setNotLoading() + } + } +} + +// Reset ensures that the children container is no longer hollow, then reset all +// states. +func (c *Children) Reset() { + // If the children container isn't hollow, then we have to remove the known + // rows from the container box. + if c.Box != nil { + // Remove old servers from the list. + for _, row := range c.Rows { + c.Box.Remove(row) + } + } + + // Wipe the list empty. + c.Rows = nil +} + +// setLoading shows the loading circle as a list child. If hollow, this function +// will only update the state. func (c *Children) setLoading() { + c.loading = true + + // Don't do the rest if we're still hollow. + if c.IsHollow() { + return + } + // Exit if we're already loading. if c.load != nil { return @@ -61,19 +121,16 @@ func (c *Children) setLoading() { c.Box.Add(c.load) } -func (c *Children) Reset() { - // Remove old servers from the list. - for _, row := range c.Rows { - c.Box.Remove(row) - } - - // Wipe the list empty. - c.Rows = nil -} - // setNotLoading removes the loading circle, if any. This is not in Reset() // anymore, since the backend may not necessarily call SetServers. func (c *Children) setNotLoading() { + c.loading = false + + // Don't call the rest if we're still hollow. + if c.IsHollow() { + return + } + // Do we have the spinning circle button? If yes, remove it. if c.load != nil { // Stop the loading mode. The reset function should do everything for us. @@ -85,31 +142,54 @@ func (c *Children) setNotLoading() { // SetServers is reserved for cchat.ServersContainer. func (c *Children) SetServers(servers []cchat.Server) { gts.ExecAsync(func() { - // Save the current state. - var oldID string - for _, row := range c.Rows { - if row.GetActive() { - oldID = row.Server.ID() - break - } + // Save the current state (if any) if the children container is not + // hollow. + if !c.IsHollow() { + restore := c.saveSelectedRow() + defer restore() } // Reset before inserting new servers. c.Reset() + // Insert hollow servers. c.Rows = make([]*ServerRow, len(servers)) - for i, server := range servers { - row := NewServerRow(c, server, c.rowctrl) - row.Show() - // row.SetFocusHAdjustment(c.GetFocusHAdjustment()) // inherit - // row.SetFocusVAdjustment(c.GetFocusVAdjustment()) - - c.Rows[i] = row - c.Box.Add(row) + c.Rows[i] = NewHollowServer(c, server, c.rowctrl) } - // Update parent reference? Only if it's activated. + // We should not unhollow everything here, but rather on uncollapse. + // Since the root node is always unhollow, calls to this function will + // pass the hollow test and unhollow its children nodes. That should not + // happen. + }) +} + +// LoadAll forces all children rows to be unhollowed (initialized). It does +// NOT check if the children container itself is hollow. +func (c *Children) LoadAll() { + AssertUnhollow(c) + + for _, row := range c.Rows { + row.Init() // this is the alloc-heavy method + row.Show() + c.Box.Add(row) + } +} + +// saveSelectedRow saves the current selected row and returns a callback that +// restores the selection. +func (c *Children) saveSelectedRow() (restore func()) { + // Save the current state. + var oldID string + for _, row := range c.Rows { + if row.GetActive() { + oldID = row.Server.ID() + break + } + } + + return func() { if oldID != "" { for _, row := range c.Rows { if row.Server.ID() == oldID { @@ -117,7 +197,9 @@ func (c *Children) SetServers(servers []cchat.Server) { } } } - }) + + // TODO Update parent reference? Only if it's activated. + } } func (c *Children) Breadcrumb() traverse.Breadcrumb { diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 05b6e66..2c612d6 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -7,7 +7,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" "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/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" "github.com/pkg/errors" @@ -16,9 +16,23 @@ import ( const ChildrenMargin = 24 const IconSize = 32 +func AssertUnhollow(hollower interface{ IsHollow() bool }) { + if hollower.IsHollow() { + panic("Server is hollow, but a normal method was called.") + } +} + type ServerRow struct { *Row + ctrl Controller Server cchat.Server + + // State that's updated even when stale. Initializations will use these. + unread bool + mentioned bool + + // callback to cancel unread indicator + cancelUnread func() } var serverCSS = primitives.PrepareClassCSS("server", ` @@ -30,34 +44,30 @@ var serverCSS = primitives.PrepareClassCSS("server", ` } `) -func NewServerRow(p traverse.Breadcrumber, server cchat.Server, ctrl Controller) *ServerRow { - row := NewRow(p, server.Name()) - row.SetIconer(server) - serverCSS(row) +// NewHollowServer creates a new hollow ServerRow. It will automatically create +// hollow children containers and rows for the given server. +func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller) *ServerRow { + var serverRow = &ServerRow{ + Row: NewHollowRow(p), + ctrl: ctrl, + Server: sv, + cancelUnread: func() {}, + } - var serverRow = &ServerRow{Row: row, Server: server} - - switch server := server.(type) { + switch sv := sv.(type) { case cchat.ServerList: - row.SetServerList(server, ctrl) - primitives.AddClass(row, "server-list") + serverRow.SetHollowServerList(sv, ctrl) + serverRow.children.SetUnreadHandler(serverRow.SetUnreadUnsafe) case cchat.ServerMessage: - row.Button.SetClickedIfTrue(func() { ctrl.RowSelected(serverRow, server) }) - primitives.AddClass(row, "server-message") - - // Check if the server is capable of indicating unread state. - if unreader, ok := server.(cchat.ServerMessageUnreadIndicator); ok { - // Set as read by default. - row.Button.SetUnreadUnsafe(false, false) - + if unreader, ok := sv.(cchat.ServerMessageUnreadIndicator); ok { gts.Async(func() (func(), error) { - c, err := unreader.UnreadIndicate(row) + c, err := unreader.UnreadIndicate(serverRow) if err != nil { return nil, errors.Wrap(err, "Failed to use unread indicator") } - return func() { row.Connect("destroy", c) }, nil + return func() { serverRow.cancelUnread = c }, nil }) } } @@ -65,67 +75,153 @@ func NewServerRow(p traverse.Breadcrumber, server cchat.Server, ctrl Controller) return serverRow } +// Init brings the row out of the hollow state. It loads the children (if any), +// but this process does not make more widgets. +func (r *ServerRow) Init() { + if !r.IsHollow() { + return + } + + // Initialize the row, which would fill up the button and others as well. + r.Row.Init(r.Server.Name()) + r.Row.SetIconer(r.Server) + serverCSS(r.Row) + + // Connect the destroyer, if any. + r.Row.Connect("destroy", r.cancelUnread) + + // Restore the read state. + r.Button.SetUnreadUnsafe(r.unread, r.mentioned) // update with state + + switch server := r.Server.(type) { + case cchat.ServerList: + primitives.AddClass(r, "server-list") + r.children.Init() + r.children.Show() + + r.childrev, _ = gtk.RevealerNew() + r.childrev.SetRevealChild(false) + r.childrev.Add(r.children) + r.childrev.Show() + + r.Box.PackStart(r.childrev, false, false, 0) + r.Button.SetClicked(r.SetRevealChild) + + case cchat.ServerMessage: + primitives.AddClass(r, "server-message") + r.Button.SetClickedIfTrue(func() { r.ctrl.RowSelected(r, server) }) + } +} + +// GetActiveServerMessage returns true if the row is currently selected AND it +// is a message row. +func (r *ServerRow) GetActiveServerMessage() bool { + // If the button is nil, then that probably means we're still in a hollow + // state. This obviously means nothing is being selected. + if r.Button == nil { + return false + } + + return r.children == nil && r.Button.GetActive() +} + +// SetUnread is thread-safe. +func (r *ServerRow) SetUnread(unread, mentioned bool) { + gts.ExecAsync(func() { r.SetUnreadUnsafe(unread, mentioned) }) +} + +func (r *ServerRow) SetUnreadUnsafe(unread, mentioned bool) { + // We're never unread if we're reading this current server. + if r.GetActiveServerMessage() { + unread, mentioned = false, false + } + + // Update the local state. + r.unread = unread + r.mentioned = mentioned + + // Button is nil if we're still in a hollow state. A nil check should tell + // us that. + if r.Button != nil { + r.Button.SetUnreadUnsafe(r.unread, r.mentioned) + } + + // Still update the parent's state even if we're hollow. + traverse.TrySetUnread(r.parentcrumb, r.Server.ID(), r.unread, r.mentioned) +} + type Row struct { *gtk.Box Button *button.ToggleButtonImage parentcrumb traverse.Breadcrumber + // non-nil if server list and the function returns error + childrenErr error + childrev *gtk.Revealer children *Children serverList cchat.ServerList - loaded bool } -func NewRow(parent traverse.Breadcrumber, name text.Rich) *Row { - button := button.NewToggleButtonImage(name) - button.Box.SetHAlign(gtk.ALIGN_START) - button.SetRelief(gtk.RELIEF_NONE) - button.Show() - - box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - box.PackStart(button, false, false, 0) - - row := &Row{ - Box: box, - Button: button, +func NewHollowRow(parent traverse.Breadcrumber) *Row { + return &Row{ parentcrumb: parent, } +} - return row +func (r *Row) IsHollow() bool { + return r.Box == nil +} + +// Init initializes the row from its initial hollow state. It does nothing after +// the first call. +func (r *Row) Init(name text.Rich) { + if !r.IsHollow() { + return + } + + r.Button = button.NewToggleButtonImage(name) + r.Button.Box.SetHAlign(gtk.ALIGN_START) + r.Button.SetRelief(gtk.RELIEF_NONE) + r.Button.Show() + + r.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + r.Box.PackStart(r.Button, false, false, 0) + + // Ensure errors are displayed. + r.childrenSetErr(r.childrenErr) } func (r *Row) Breadcrumb() traverse.Breadcrumb { return traverse.TryBreadcrumb(r.parentcrumb, r.Button.GetText()) } -func (r *Row) SetLabelUnsafe(name text.Rich) { - r.Button.SetLabelUnsafe(name) -} - -func (r *Row) SetIconer(v interface{}) { - if iconer, ok := v.(cchat.Icon); ok { - r.Button.Image.SetSize(IconSize) - r.Button.Image.AsyncSetIconer(iconer, "Error getting server icon URL") - } -} - -// SetServerList sets the row to a server list. -func (r *Row) SetServerList(list cchat.ServerList, ctrl Controller) { - r.Button.SetClicked(func(active bool) { - r.SetRevealChild(active) - }) - - r.children = NewChildren(r, ctrl) - r.children.Show() - - r.childrev, _ = gtk.RevealerNew() - r.childrev.SetRevealChild(false) - r.childrev.Add(r.children) - r.childrev.Show() - - r.Box.PackStart(r.childrev, false, false, 0) +// SetHollowServerList sets the row to a hollow server list (children) and +// recursively create +func (r *Row) SetHollowServerList(list cchat.ServerList, ctrl Controller) { r.serverList = list + + r.children = NewHollowChildren(r, ctrl) + r.children.setLoading() + + go func() { + var err = list.Servers(r.children) + + gts.ExecAsync(func() { + // Announce that we're not loading anymore. + r.children.setNotLoading() + + if !r.IsHollow() { + // Restore clickability. + r.SetSensitive(true) + } + + // Use the childrenX method instead of SetX. We can wrap nil + // errors. + r.childrenSetErr(errors.Wrap(err, "Failed to get servers")) + }) + }() } // Reset clears off all children servers. It's a no-op if there are none. @@ -139,13 +235,45 @@ func (r *Row) Reset() { } // Reset the state. - r.loaded = false r.serverList = nil r.children = nil } +func (r *Row) childrenSetErr(err error) { + // Update the state and only use this state field. + r.childrenErr = err + + // Only call this if we're not hollow. If we are, then Init() will read the + // state field above and render the failed button. + if !r.IsHollow() { + if err != nil { + // If the user chooses to retry, the list will automatically expand. + r.SetFailed(err, func() { r.SetRevealChild(true) }) + } else { + r.SetDone() + } + } +} + +func (r *Row) SetLabelUnsafe(name text.Rich) { + AssertUnhollow(r) + + r.Button.SetLabelUnsafe(name) +} + +func (r *Row) SetIconer(v interface{}) { + AssertUnhollow(r) + + if iconer, ok := v.(cchat.Icon); ok { + r.Button.Image.SetSize(IconSize) + r.Button.Image.AsyncSetIconer(iconer, "Error getting server icon URL") + } +} + // SetLoading is called by the parent struct. func (r *Row) SetLoading() { + AssertUnhollow(r) + r.SetSensitive(false) r.Button.SetLoading() } @@ -154,6 +282,8 @@ func (r *Row) SetLoading() { // because both of those errors share the same appearance, just different // callbacks. func (r *Row) SetFailed(err error, retry func()) { + AssertUnhollow(r) + r.SetSensitive(true) r.SetTooltipText(err.Error()) r.Button.SetFailed(err, retry) @@ -163,47 +293,39 @@ func (r *Row) SetFailed(err error, retry func()) { // SetDone is shared between the parent struct and the children list. This is // because both will use the same SetFailed. func (r *Row) SetDone() { + AssertUnhollow(r) + r.Button.SetNormal() r.SetSensitive(true) r.SetTooltipText("") } func (r *Row) SetNormalExtraMenu(items []menu.Item) { + AssertUnhollow(r) + r.Button.SetNormalExtraMenu(items) r.SetSensitive(true) r.SetTooltipText("") } -func (r *Row) childrenFailed(err error) { - // If the user chooses to retry, the list will automatically expand. - r.SetFailed(err, func() { r.SetRevealChild(true) }) -} - -func (r *Row) childrenDone() { - r.loaded = true - - // I don't think this is supposed to be called here... - // r.SetDone() -} - // SetSelected is used for highlighting the current message server. func (r *Row) SetSelected(selected bool) { - // Set the clickability the opposite as the boolean. - r.Button.SetSensitive(!selected) + AssertUnhollow(r) - // Some special edge case that I forgot. - if !selected { - r.Button.SetActive(false) - } + r.Button.SetSelected(selected) } func (r *Row) GetActive() bool { + AssertUnhollow(r) + return r.Button.GetActive() } // SetRevealChild reveals the list of servers. It does nothing if there are no // servers, meaning if Row does not represent a ServerList. func (r *Row) SetRevealChild(reveal bool) { + AssertUnhollow(r) + // Do the above noop check. if r.children == nil { return @@ -217,57 +339,19 @@ func (r *Row) SetRevealChild(reveal bool) { return } - // If we haven't loaded yet and we're still not loading, then load. - if !r.loaded && r.children.load == nil { - r.Load() - } + // Load the list of servers if we're still in loading mode. Before, we have + // to call Servers on this. Now, we already know that there are hollow + // servers in the children container. + r.children.LoadAll() } // GetRevealChild returns whether or not the server list is expanded, or always // false if there is no server list. func (r *Row) GetRevealChild() bool { + AssertUnhollow(r) + if r.childrev != nil { return r.childrev.GetRevealChild() } return false } - -// Load loads the row without uncollapsing it. -func (r *Row) Load() { - // Safeguard. - if r.children == nil || r.serverList == nil { - return - } - - // Set that we're now loading. - r.children.setLoading() - r.SetLoading() - r.SetSensitive(false) - - // Load the list of servers if we're still in loading mode. - go func() { - err := r.serverList.Servers(r.children) - gts.ExecAsync(func() { - // We're not loading anymore, so remove the loading circle. - r.children.setNotLoading() - // Restore clickability. - r.SetSensitive(true) - - // Use the childrenX method instead of SetX. - if err != nil { - r.childrenFailed(errors.Wrap(err, "Failed to get servers")) - } else { - r.childrenDone() - } - }) - }() -} - -// SetUnread is thread-safe. -func (r *Row) SetUnread(unread, mentioned bool) { - gts.ExecAsync(func() { r.SetUnreadUnsafe(unread, mentioned) }) -} - -func (r *Row) SetUnreadUnsafe(unread, mentioned bool) { - r.Button.SetUnreadUnsafe(unread, mentioned) -} diff --git a/internal/ui/service/session/server/traverse/traverse.go b/internal/ui/service/session/server/traverse/traverse.go new file mode 100644 index 0000000..80101f2 --- /dev/null +++ b/internal/ui/service/session/server/traverse/traverse.go @@ -0,0 +1,134 @@ +// Package traverse implements an extensible interface that allows children +// widgets to announce state changes to their parent container. +// +// The objective of this package is to allow for easier parent traversal without +// cluttering its structure with too much state. It also allows for proper +// encapsulation as well as a fallback mechanism without lengthy boolean checks. +package traverse + +import ( + "strings" +) + +type Breadcrumb []string + +func (b Breadcrumb) String() string { + return strings.Join([]string(b), "/") +} + +// Breadcrumber is the base interface that other interfaces extend on. A child +// must at minimum implement this interface to use any other. +type Breadcrumber interface { + // Breadcrumb returns the parent's path before the children's breadcrumb. + // This method recursively joins the parent's crumb with the children's, + // then eventually make its way up to the root node. + Breadcrumb() Breadcrumb +} + +// TryBreadcrumb accepts a nilable breadcrumber and handles it appropriately. +func TryBreadcrumb(i Breadcrumber, appended ...string) []string { + if i == nil { + return appended + } + + return append(i.Breadcrumb(), appended...) +} + +// Unreadabler extends Breadcrumber to add unread states to the parent node. +type Unreadabler interface { + SetState(id string, unread, mentioned bool) +} + +// TrySetUnread tries to check if a breadcrumber parent node supports +// Unreadabler. If it does, then this function will set the state appropriately. +func TrySetUnread(parent Breadcrumber, selfID string, unread, mentioned bool) { + if u, ok := parent.(Unreadabler); ok { + u.SetState(selfID, unread, mentioned) + } +} + +// UnreadSetter is an interface that a single row implements to set state. It +// does not have to do with Breadcrumber. +type UnreadSetter interface { + SetUnreadUnsafe(unread, mentioned bool) +} + +// Unreadable is a struct that nodes could embed to implement unreadable +// capability, that is, the unread and mentioned states. A zero-value Unreadable +// is a valid Unreadable without an update handler. +// +// Typically, parent nodes would implement this as a way to count the number of +// unread and mentioned children nodes. +type Unreadable struct { + UnreadableState + unreadHandler func(unread, mentioned bool) +} + +func NewUnreadable(unreadHandler UnreadSetter) *Unreadable { + u := &Unreadable{} + u.SetUnreadHandler(unreadHandler.SetUnreadUnsafe) + return u +} + +// SetUpdateHandler sets the parent's update handler. This update handler must +// refer to the parent's breadcrumb. +func (u *Unreadable) SetUnreadHandler(updateHandler func(unread, mentioned bool)) { + // Update with the current state. + if u.unreadHandler = updateHandler; updateHandler != nil { + updateHandler(u.State()) + } +} + +// SetState updates the node ID's state in this parent unreadable state +// container. +func (u *Unreadable) SetState(id string, unread, mentioned bool) { + u.UnreadableState.SetState(id, unread, mentioned) + + if u.unreadHandler != nil { + u.unreadHandler(u.UnreadableState.State()) + } +} + +// UnreadableState implements a map of unread children for indication. A +// zero-value UnreadableState is a valid value. +type UnreadableState struct { + // both maps represent sets of server IDs + unreads map[string]struct{} + mentions map[string]struct{} +} + +func NewUnreadableState() *UnreadableState { + return &UnreadableState{} +} + +func (s *UnreadableState) Reset() { + s.unreads = map[string]struct{}{} + s.mentions = map[string]struct{}{} +} + +func (s *UnreadableState) State() (unread, mentioned bool) { + unread = len(s.unreads) > 0 + mentioned = len(s.mentions) > 0 + + // Count mentioned as unread. + return unread || mentioned, mentioned +} + +func (s *UnreadableState) SetState(id string, unread, mentioned bool) { + if s.unreads == nil && s.mentions == nil { + s.Reset() + } + + setIf(unread, id, s.unreads) + setIf(mentioned, id, s.mentions) +} + +// setIf sets the ID into the given map if the cond boolean is true, or deletes +// it if the boolean is false. +func setIf(cond bool, id string, m map[string]struct{}) { + if cond { + m[id] = struct{}{} + } else { + delete(m, id) + } +} diff --git a/internal/ui/service/session/servers.go b/internal/ui/service/session/servers.go index ca43390..60d3c98 100644 --- a/internal/ui/service/session/servers.go +++ b/internal/ui/service/session/servers.go @@ -8,7 +8,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/humanize" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner" - "github.com/diamondburned/cchat-gtk/internal/ui/service/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/gotk3/gotk3/gtk" "github.com/gotk3/gotk3/pango" diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index bc9bd92..0553296 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -13,7 +13,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/commander" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" - "github.com/diamondburned/cchat-gtk/internal/ui/service/traverse" + "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/traverse" "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/gtk" @@ -176,6 +176,9 @@ func (r *Row) Activate() { if r.Session == nil { r.ReconnectSession() } else { + // Load all servers in this root node, then call the parent controller's + // method. + r.Servers.Children.LoadAll() r.svcctrl.SessionSelected(r) } } diff --git a/internal/ui/service/traverse/traverse.go b/internal/ui/service/traverse/traverse.go deleted file mode 100644 index 8b190fc..0000000 --- a/internal/ui/service/traverse/traverse.go +++ /dev/null @@ -1,21 +0,0 @@ -package traverse - -import "strings" - -type Breadcrumb []string - -func (b Breadcrumb) String() string { - return strings.Join([]string(b), "/") -} - -type Breadcrumber interface { - Breadcrumb() Breadcrumb -} - -// TryBreadcrumb accepts a nilable breadcrumber and handles it appropriately. -func TryBreadcrumb(i Breadcrumber, appended ...string) []string { - if i == nil { - return appended - } - return append(i.Breadcrumb(), appended...) -} diff --git a/profile.go b/profile.go index 0969549..2fbbb45 100644 --- a/profile.go +++ b/profile.go @@ -4,14 +4,11 @@ package main import ( "os" - "runtime" "runtime/pprof" _ "net/http/pprof" _ "github.com/ianlancetaylor/cgosymbolizer" - - "github.com/diamondburned/cchat-gtk/internal/log" ) func init() { @@ -21,18 +18,28 @@ func init() { // } // }() - runtime.SetBlockProfileRate(1) + // runtime.SetBlockProfileRate(1) + + // f, _ := os.Create("/tmp/cchat.pprof") + // p := pprof.Lookup("block") + + // destructor = func() { + // log.Println("==destructor==") + + // if err := p.WriteTo(f, 2); err != nil { + // log.Println("Profile writeTo error:", err) + // } + + // f.Close() + // } f, _ := os.Create("/tmp/cchat.pprof") - p := pprof.Lookup("block") + if err := pprof.StartCPUProfile(f); err != nil { + panic(err) + } destructor = func() { - log.Println("==destructor==") - - if err := p.WriteTo(f, 2); err != nil { - log.Println("Profile writeTo error:", err) - } - + pprof.StopCPUProfile() f.Close() } }