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()
}
}