diff --git a/go.mod b/go.mod index a6ad11c..f202b67 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/Xuanwo/go-locale v1.0.0 github.com/alecthomas/chroma v0.7.3 github.com/diamondburned/cchat v0.6.4 - github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799 + github.com/diamondburned/cchat-discord v0.0.0-20210501221918-71c3069fa46f github.com/diamondburned/gspell v0.0.0-20201229064336-e43698fd5828 github.com/diamondburned/handy v0.0.0-20210329054445-387ad28eb2c2 github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 diff --git a/go.sum b/go.sum index 5910f7d..28941e1 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff h1:p5X github.com/diamondburned/cchat-discord v0.0.0-20210326063953-deb4ccb32bff/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs= github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799 h1:xxqeuAx0T9SsS8DYKe4jxzL2saEpLyQeAttD0sX/g1E= github.com/diamondburned/cchat-discord v0.0.0-20210501072434-cc2b2ee4c799/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs= +github.com/diamondburned/cchat-discord v0.0.0-20210501221918-71c3069fa46f h1:IDC3qToEm5owHf5FlJY9q9Kjbsv45+nly4I2YMv76lE= +github.com/diamondburned/cchat-discord v0.0.0-20210501221918-71c3069fa46f/go.mod h1:zbm+BpkQOMD6s87x4FrP3lTt9ddJLWTTPXyMROT+LZs= github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db h1:VQI2PdbsdsRJ7d669kp35GbCUO44KZ0Xfqdu4o/oqVg= github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db/go.mod h1:M87kjNzWVPlkZycFNzpGPKQXzkHNnZphuwMf3E9ckgc= github.com/diamondburned/gotk3 v0.0.0-20201209182406-e7291341a091 h1:lQpSWzbi3rQf66aMSip/rIypasIFwqCqF0Wfn5og6gw= diff --git a/internal/gts/gts.go b/internal/gts/gts.go index e1fbb8d..6312e59 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -1,6 +1,7 @@ package gts import ( + "context" "io" "os" "time" @@ -154,6 +155,7 @@ func Main(wfn func() MainApplication) { // Async runs fn asynchronously, then runs the function it returns in the Gtk // main thread. +// TODO: deprecate Async. func Async(fn func() (func(), error)) { go func() { f, err := fn() @@ -168,27 +170,66 @@ func Async(fn func() (func(), error)) { }() } +// AsyncCancel is similar to AsyncCtx, but the context is created internally. +func AsyncCancel(fn func(ctx context.Context) (func(), error)) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + // fn() is assumed to use the same given ctx. + f, err := fn(ctx) + if err != nil { + log.Error(err) + } + + // Attempt to run the callback if it's there. + if f != nil { + ExecAsyncCtx(ctx, f) + } + }() + + return cancel +} + +// AsyncCtx does what Async does, except the returned callback will not be +// executed if the given context has expired or the returned callback is called. +func AsyncCtx(ctx context.Context, fn func() (func(), error)) { + go func() { + // fn() is assumed to use the same given ctx. + f, err := fn() + if err != nil { + log.Error(err) + } + + // Attempt to run the callback if it's there. + if f != nil { + ExecAsyncCtx(ctx, f) + } + }() +} + // ExecLater executes the function asynchronously with a low priority. func ExecLater(fn func()) { glib.IdleAddPriority(glib.PRIORITY_DEFAULT_IDLE, fn) } // ExecAsync executes function asynchronously in the Gtk main thread. +// TODO: deprecate Async. func ExecAsync(fn func()) { glib.IdleAddPriority(glib.PRIORITY_HIGH, fn) } -// ExecSync executes the function asynchronously, but returns a channel that -// indicates when the job is done. -func ExecSync(fn func()) <-chan struct{} { - var ch = make(chan struct{}) +// ExecAsyncCtx executes the function asynchronously in the Gtk main thread only +// if the context has not expired. This API has absolutely no race conditions if +// the context is only canceled in the main thread. +func ExecAsyncCtx(ctx context.Context, fn func()) { + ExecAsync(func() { + select { + case <-ctx.Done(): - glib.IdleAddPriority(glib.PRIORITY_HIGH, func() { - fn() - close(ch) + default: + fn() + } }) - - return ch } // DoAfter calls f after the given duration in the Gtk main loop. diff --git a/internal/humanize/humanize.go b/internal/humanize/humanize.go index 3524961..c09ce33 100644 --- a/internal/humanize/humanize.go +++ b/internal/humanize/humanize.go @@ -21,8 +21,8 @@ type truncator struct { var shortTruncators = []truncator{ {d: Day, s: "15:04"}, {d: Week, s: "Mon 15:04"}, - {d: Year, s: "15:04 02/01"}, - {d: -1, s: "15:04 02/01/2006"}, + {d: Year, s: "02/01 15:04"}, + {d: -1, s: "02/01/2006 15:04"}, } func TimeAgo(t time.Time) string { diff --git a/internal/ui/config/config.go b/internal/ui/config/config.go index 1cbe46f..9f434dd 100644 --- a/internal/ui/config/config.go +++ b/internal/ui/config/config.go @@ -110,3 +110,16 @@ var toRestore = map[string]interface{}{} func RegisterConfig(filename string, jsonValue interface{}) { toRestore[filename] = jsonValue } + +// Updaters contains a list of callbacks to be called when something is updated. +type Updaters []func() + +func (us *Updaters) Add(f func()) { + *us = append(*us, f) +} + +func (us *Updaters) Updated() { + for _, f := range *us { + f() + } +} diff --git a/internal/ui/messages/container/compact/compact.go b/internal/ui/messages/container/compact/compact.go index 08b2eb4..8276f18 100644 --- a/internal/ui/messages/container/compact/compact.go +++ b/internal/ui/messages/container/compact/compact.go @@ -6,10 +6,29 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/gotk3/gotk3/gtk" ) type Container struct { *container.ListContainer + sg SizeGroups +} + +type SizeGroups struct { + Timestamp *gtk.SizeGroup + Username *gtk.SizeGroup +} + +func NewSizeGroups() SizeGroups { + sg1, _ := gtk.SizeGroupNew(gtk.SIZE_GROUP_HORIZONTAL) + sg2, _ := gtk.SizeGroupNew(gtk.SIZE_GROUP_HORIZONTAL) + + return SizeGroups{sg1, sg2} +} + +func (sgs *SizeGroups) Add(msg Message) { + sgs.Timestamp.AddWidget(msg.Timestamp) + sgs.Username.AddWidget(msg.Username) } var _ container.Container = (*Container)(nil) @@ -17,11 +36,12 @@ var _ container.Container = (*Container)(nil) func NewContainer(ctrl container.Controller) *Container { c := container.NewListContainer(ctrl) primitives.AddClass(c, "compact-container") - return &Container{c} + return &Container{c, NewSizeGroups()} } func (c *Container) NewPresendMessage(state *message.PresendState) container.PresendMessageRow { msg := WrapPresendMessage(state) + c.sg.Add(msg.Message) c.addMessage(msg) return msg } @@ -29,6 +49,7 @@ func (c *Container) NewPresendMessage(state *message.PresendState) container.Pre func (c *Container) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { msg := WrapMessage(message.NewState(msg)) + c.sg.Add(msg) c.addMessage(msg) c.CleanMessages() }) diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go index 8228198..85e56c1 100644 --- a/internal/ui/messages/container/compact/message.go +++ b/internal/ui/messages/container/compact/message.go @@ -16,8 +16,7 @@ import ( var messageTimeCSS = primitives.PrepareClassCSS("", ` .message-time { - margin-left: 1em; - margin-right: 1em; + margin: 0 8px; } `) @@ -52,13 +51,18 @@ var _ container.MessageRow = (*Message)(nil) func WrapMessage(ct *message.State) Message { ts := message.NewTimestamp() ts.SetVAlign(gtk.ALIGN_START) + ts.SetHAlign(gtk.ALIGN_END) + ts.SetXAlign(1.00) ts.SetText(humanize.TimeAgo(ct.Time)) ts.SetTooltipText(ct.Time.Format(time.Stamp)) ts.Show() messageTimeCSS(ts) user := message.NewUsername() - user.SetMaxWidthChars(25) + user.SetMaxWidthChars(22) + user.SetHAlign(gtk.ALIGN_END) + user.SetXAlign(1.0) + user.SetJustify(gtk.JUSTIFY_RIGHT) user.SetEllipsize(pango.ELLIPSIZE_NONE) user.SetLineWrap(true) user.SetLineWrapMode(pango.WRAP_WORD_CHAR) diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index 825ba18..ae84e59 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -10,10 +10,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives" ) -const ( - AvatarSize = 40 - AvatarMargin = 10 -) +const AvatarSize = message.AvatarSize // NewMessage creates a new message. func NewMessage( @@ -47,7 +44,7 @@ func NewContainer(ctrl container.Controller) *Container { return &Container{ListContainer: c} } -const splitDuration = 10 * time.Minute +const splitDuration = 3 * time.Minute // isCollapsible returns true if the given lastMsg has matching conditions with // the given msg. diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 6e56f40..af235af 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -3,7 +3,6 @@ package cozy import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" - "github.com/gotk3/gotk3/gtk" ) // Collapsed is a message that follows after FullMessage. It does not show @@ -11,36 +10,26 @@ import ( type CollapsedMessage struct { // Author is still updated normally. *message.State - Timestamp *gtk.Label } // WrapCollapsedMessage wraps the given message state to be a collapsed message. func WrapCollapsedMessage(gc *message.State) *CollapsedMessage { - // Set Timestamp's padding accordingly to Avatar's. - ts := message.NewTimestamp() - ts.SetSizeRequest(AvatarSize, -1) - ts.SetVAlign(gtk.ALIGN_START) - ts.SetXAlign(0.5) // middle align - ts.SetMarginEnd(container.ColumnSpacing) - ts.SetMarginStart(container.ColumnSpacing * 2) - // Set Content's padding accordingly to FullMessage's main box. - gc.Content.SetMarginEnd(container.ColumnSpacing * 2) + gc.Content.SetMarginStart(container.ColumnSpacing*2 + AvatarSize) + gc.Content.SetMarginEnd(container.ColumnSpacing) - gc.PackStart(ts, false, false, 0) gc.PackStart(gc.Content, true, true, 0) gc.SetClass("cozy-collapsed") return &CollapsedMessage{ - State: gc, - Timestamp: ts, + State: gc, } } func (c *CollapsedMessage) Revert() *message.State { c.ClearBox() + c.Content.SetMarginStart(0) c.Content.SetMarginEnd(0) - c.Timestamp.Destroy() return c.Unwrap() } diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index 2110bd1..1863815 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -61,7 +61,7 @@ func WrapFullMessage(gc *message.State) *FullMessage { header.Show() avatar := NewAvatar(gc.Row) - avatar.SetMarginStart(container.ColumnSpacing * 2) + avatar.SetMarginStart(container.ColumnSpacing) avatar.Connect("clicked", func(w gtk.IWidget) { if output := header.Output(); len(output.Mentions) > 0 { labeluri.PopoverMentioner(w, output.Input, output.Mentions[0]) @@ -78,12 +78,10 @@ func WrapFullMessage(gc *message.State) *FullMessage { main, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) main.PackStart(header, false, false, 0) main.PackStart(gc.Content, false, false, 0) - main.SetMarginEnd(container.ColumnSpacing * 2) + main.SetMarginEnd(container.ColumnSpacing) main.SetMarginStart(container.ColumnSpacing) main.Show() - - // Also attach a class for the main box shown on the right. - primitives.AddClass(main, "cozy-main") + mainCSS(main) gc.PackStart(avatar, false, false, 0) gc.PackStart(main, true, true, 0) diff --git a/internal/ui/messages/container/list.go b/internal/ui/messages/container/list.go index 5f6ec35..53a354e 100644 --- a/internal/ui/messages/container/list.go +++ b/internal/ui/messages/container/list.go @@ -479,6 +479,7 @@ func (c *ListStore) Highlight(msg MessageRow) { } func destroyMsg(row *messageRow) { + row.Revert() row.state.Author.Name.Stop() row.state.Row.Destroy() } diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go index f8cb536..a9047cc 100644 --- a/internal/ui/messages/input/username/username.go +++ b/internal/ui/messages/input/username/username.go @@ -14,14 +14,16 @@ import ( const AvatarSize = 24 -var showUser = true -var currentRevealer = func(bool) {} // noop by default +var ( + showUser = true + updaters config.Updaters +) func init() { // Bind this revealer in settings. config.AppearanceAdd("Show Username in Input", config.Switch( &showUser, - func(b bool) { currentRevealer(b) }, + func(b bool) { updaters.Updated() }, )) } @@ -55,7 +57,7 @@ func NewContainer() *Container { // Bind the current global revealer to this revealer for settings. This // operation should be thread-safe, as everything is being done in the main // thread. - currentRevealer = rev.SetRevealChild + updaters.Add(func() { rev.SetRevealChild(showUser) }) author := message.NewCustomAuthor("", text.Plain("self")) @@ -68,6 +70,7 @@ func NewContainer() *Container { u.avatar = roundimage.NewImage(0) u.avatar.SetSize(AvatarSize) + u.avatar.SetHAlign(gtk.ALIGN_CENTER) u.avatar.SetPlaceholderIcon("user-available-symbolic", AvatarSize) u.avatar.Show() diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index a18376c..516a925 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -15,6 +15,8 @@ import ( "github.com/gotk3/gotk3/pango" ) +const AvatarSize = 40 + // Container describes a message container that wraps a state. These methods are // made for containers to override; methods not meant to be override are not // exposed and will be done directly on the State. @@ -70,6 +72,7 @@ func NewState(msg cchat.MessageCreate) *State { // immediately afterwards; it is invalid once the state is used. func NewEmptyState() *State { ctbody := labeluri.NewLabel(text.Rich{}) + ctbody.Tooltip = false ctbody.SetHAlign(gtk.ALIGN_FILL) ctbody.SetEllipsize(pango.ELLIPSIZE_NONE) ctbody.SetLineWrap(true) diff --git a/internal/ui/rich/label.go b/internal/ui/rich/label.go index 3ed4ece..8876a7a 100644 --- a/internal/ui/rich/label.go +++ b/internal/ui/rich/label.go @@ -24,6 +24,8 @@ func RenderSkipImages(rich text.Rich) markup.RenderOutput { // need to manually type Label struct { gtk.Label + Tooltip bool + label text.Rich output markup.RenderOutput render LabelRenderer @@ -41,7 +43,7 @@ func NewStaticLabel(rich text.Rich) *Label { label.SetMarkup(markup.Render(rich)) } - return &Label{Label: *label} + return &Label{Label: *label, Tooltip: true} } // NewLabel creates a self-updating label. @@ -83,7 +85,10 @@ func (l *Label) SetLabel(content text.Rich) { l.output = out l.SetMarkup(out.Markup) - l.SetTooltipMarkup(out.Markup) + + if l.Tooltip { + l.SetTooltipMarkup(out.Markup) + } } // SetRenderer sets a custom renderer. If the given renderer is nil, then the diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 1489964..62c25a2 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -59,6 +59,7 @@ type ServerRow struct { mentioned bool showLabel bool + UnreadIndicator cchat.UnreadIndicator // callback to cancel unread indicator cancelUnread func() } @@ -96,9 +97,10 @@ func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl ParentContro serverRow.children.SetUnreadHandler(serverRow.SetUnreadUnsafe) case messenger != nil: - if unreader := messenger.AsUnreadIndicator(); unreader != nil { + serverRow.UnreadIndicator = messenger.AsUnreadIndicator() + if serverRow.UnreadIndicator != nil { gts.Async(func() (func(), error) { - c, err := unreader.UnreadIndicate(&serverRow) + c, err := serverRow.UnreadIndicator.UnreadIndicate(&serverRow) if err != nil { return nil, errors.Wrap(err, "Failed to use unread indicator") } diff --git a/internal/ui/service/session/servers.go b/internal/ui/service/session/servers.go index d5a80b0..d240266 100644 --- a/internal/ui/service/session/servers.go +++ b/internal/ui/service/session/servers.go @@ -181,8 +181,10 @@ func (s *Servers) setDone() { s.SetVisibleChild(s.Main) // stop the spinner. - s.spinner.Destroy() - s.spinner = nil + if s.spinner != nil { + s.spinner.Destroy() + s.spinner = nil + } } // setLoading shows a loading spinner. Use this after the session row is diff --git a/internal/ui/style.css b/internal/ui/style.css index e2e1fb1..164f8bb 100644 --- a/internal/ui/style.css +++ b/internal/ui/style.css @@ -20,7 +20,12 @@ undershoot { background-size: 0 } */ .top-level .server-list.expanded { - background-color: @borders; + background-color: @theme_bg_color; +} + +.top-level .server-list.expanded > .server-button, +.top-level .server-list.expanded > revealer > .server-children { + background-color: mix(alpha(@theme_selected_bg_color, 0.5), @borders, 0.25); } .top-level .server-button {