Added recursive unread states; improved lazy-loading

This commit is contained in:
diamondburned 2020-07-18 00:16:47 -07:00
parent d965ed2118
commit 8cf1fcd8a1
19 changed files with 582 additions and 222 deletions

2
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -13,6 +13,8 @@ import (
"github.com/pkg/errors"
)
// TODO:
type ImageContainer interface {
SetFromPixbuf(*gdk.Pixbuf)
SetFromAnimation(*gdk.PixbufAnimation)

View File

@ -7,7 +7,7 @@ import (
"github.com/gotk3/gotk3/gtk"
)
const TPS = 15 // tps
const TPS = 24 // tps
type State struct {
throttling bool

View File

@ -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"

View File

@ -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) {

View File

@ -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(`<span color="red">%s</span>`, content))

View File

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

View File

@ -35,13 +35,14 @@ func (a *AppendMap) appendIndex(ind int) {
}
func (a *AppendMap) Anchor(start, end int, href string) {
a.Openf(start, `<a href="%s">`, html.EscapeString(href))
a.Close(end, "</a>")
a.AnchorNU(start, end, href)
}
// AnchorNU makes a new <a> tag without underlines and colors.
func (a *AppendMap) AnchorNU(start, end int, href string) {
a.Anchor(start, end, href)
a.Openf(start, `<a href="%s">`, html.EscapeString(href))
a.Close(end, "</a>")
// a.Anchor(start, end, href)
a.Span(start, end, `underline="none"`)
}

View File

@ -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, `<a href="%s">`, html.EscapeString(segment.Link()))
appended.Close(end, "</a>")
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 = `<a href="%s"><span underline="none">%s</span></a>`
)
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(
`<a href="%s">%s</a>`,
f_AnchorNoUnderline,
html.EscapeString(u.String()), html.EscapeString(imager.ImageText()),
)
}
@ -211,7 +227,7 @@ func composeAvatarMarkup(avatarer text.Avatarer) string {
}
return fmt.Sprintf(
`<a href="%s">%s</a>`,
f_AnchorNoUnderline,
html.EscapeString(u.String()), html.EscapeString(avatarer.AvatarText()),
)
}

View File

@ -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"
)

View File

@ -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")
}

View File

@ -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 {

View File

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

View File

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

View File

@ -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"

View File

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

View File

@ -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...)
}

View File

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