diff --git a/go.mod b/go.mod index 7154531..860e6f0 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,8 @@ replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20201230 require ( github.com/Xuanwo/go-locale v1.0.0 github.com/alecthomas/chroma v0.7.3 - github.com/diamondburned/cchat v0.3.15 - github.com/diamondburned/cchat-discord v0.0.0-20210101084233-d8599a528770 + github.com/diamondburned/cchat v0.3.17 + github.com/diamondburned/cchat-discord v0.0.0-20210102085253-a691813b9041 github.com/diamondburned/cchat-mock v0.0.0-20201115033644-df8d1b10f9db github.com/diamondburned/gspell v0.0.0-20201229064336-e43698fd5828 github.com/diamondburned/handy v0.0.0-20201229063418-ec23c1370374 diff --git a/go.sum b/go.sum index 5e96e12..6d71c98 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/diamondburned/cchat v0.3.11 h1:C1f9Tp7Kz3t+T1SlepL1RS7b/kACAKWAIZXAgJ github.com/diamondburned/cchat v0.3.11/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= github.com/diamondburned/cchat v0.3.15 h1:BJf8ZiRtDWTGMtQ3QqjNU0H+784WSrkJEpFGkKY5gEw= github.com/diamondburned/cchat v0.3.15/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.17 h1:pGwas8Y0SBU7yg4EQ/MvrbqZhrnRhPBYm1AiRsL147s= +github.com/diamondburned/cchat v0.3.17/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= github.com/diamondburned/cchat-discord v0.0.0-20201220054426-918719599f2d h1:n61DxLdX7nPj7KA1N/azaR8wa0pnDBDT6Yi1seOsBWM= github.com/diamondburned/cchat-discord v0.0.0-20201220054426-918719599f2d/go.mod h1:pvp1TOHK7NUM+GDRPixQGsKyCSbGYhiseK2jM+1I+ms= github.com/diamondburned/cchat-discord v0.0.0-20201220081640-288591a535af h1:pTdxsrVSYCdraGormbu1t8uQJMe/OD/ZIz9KljDWAvc= @@ -72,6 +74,12 @@ github.com/diamondburned/cchat-discord v0.0.0-20201231025836-96e97aa11705 h1:g0h github.com/diamondburned/cchat-discord v0.0.0-20201231025836-96e97aa11705/go.mod h1:rFBGZYLq0g6Pb/WGN/K0++kXrhCYlQQ1nc2FX4r8CO0= github.com/diamondburned/cchat-discord v0.0.0-20210101084233-d8599a528770 h1:2BeUPBxGYZm3QioWYw9/4PCJAA2x/I8JTYWS4dmqc3o= github.com/diamondburned/cchat-discord v0.0.0-20210101084233-d8599a528770/go.mod h1:Kv+sNS+3UHHDqiuwEkmfaY8nDr3LPryRzCHaV1E6VeM= +github.com/diamondburned/cchat-discord v0.0.0-20210101223535-ef4cb5318534 h1:v11tEMATMxKhoSzJjvfGgI1AGuxilzQ3xJxVO2tOQ0w= +github.com/diamondburned/cchat-discord v0.0.0-20210101223535-ef4cb5318534/go.mod h1:KL3i+ER58BrJ8JBkpy6WQ0mDZdlkgz7KWm3Ex7i6Mk0= +github.com/diamondburned/cchat-discord v0.0.0-20210102040711-73b0d3f39c41 h1:cUV4BBN8w1Mfp3U2JFyA2R9yyTO6bkqC2wR1rqRYXSg= +github.com/diamondburned/cchat-discord v0.0.0-20210102040711-73b0d3f39c41/go.mod h1:KL3i+ER58BrJ8JBkpy6WQ0mDZdlkgz7KWm3Ex7i6Mk0= +github.com/diamondburned/cchat-discord v0.0.0-20210102085253-a691813b9041 h1:ZTovoKIyXiK5VFRTVrY6YWHIFR5x98u9Q+k9rMjZzvg= +github.com/diamondburned/cchat-discord v0.0.0-20210102085253-a691813b9041/go.mod h1:KL3i+ER58BrJ8JBkpy6WQ0mDZdlkgz7KWm3Ex7i6Mk0= 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 a5d0ead..2272165 100644 --- a/internal/gts/gts.go +++ b/internal/gts/gts.go @@ -190,7 +190,11 @@ func DoAfter(d time.Duration, f func()) { // DoAfterMs calls f after the given ms in the Gtk main loop. func DoAfterMs(ms uint, f func()) { - glib.TimeoutAddPriority(ms, glib.PRIORITY_HIGH_IDLE, f) + if secs := ms / 1000; secs*1000 == ms { + glib.TimeoutSecondsAddPriority(secs, glib.PRIORITY_HIGH_IDLE, f) + } else { + glib.TimeoutAddPriority(ms, glib.PRIORITY_HIGH_IDLE, f) + } } // AfterFunc mimics time.AfterFunc's API but runs the callback inside the Gtk @@ -201,7 +205,15 @@ func AfterFunc(d time.Duration, f func()) (stop func()) { // AfterMsFunc is similar to AfterFunc but takes in milliseconds instead. func AfterMsFunc(ms uint, f func()) (stop func()) { - h := glib.TimeoutAddPriority(ms, glib.PRIORITY_HIGH_IDLE, func() bool { f(); return true }) + fn := func() bool { f(); return true } + + var h glib.SourceHandle + if secs := ms / 1000; secs*1000 == ms { + h = glib.TimeoutSecondsAddPriority(secs, glib.PRIORITY_HIGH_IDLE, fn) + } else { + h = glib.TimeoutAddPriority(ms, glib.PRIORITY_HIGH_IDLE, fn) + } + return func() { glib.SourceRemove(h) } } diff --git a/internal/ui/messages/container/compact/compact.go b/internal/ui/messages/container/compact/compact.go index f31eb98..ecdd173 100644 --- a/internal/ui/messages/container/compact/compact.go +++ b/internal/ui/messages/container/compact/compact.go @@ -9,36 +9,36 @@ import ( ) type Container struct { - *container.GridContainer + *container.ListContainer } func NewContainer(ctrl container.Controller) *Container { - c := container.NewGridContainer(constructor{}, ctrl) + c := container.NewListContainer(constructor{}, ctrl) primitives.AddClass(c, "compact-conatainer") return &Container{c} } func (c *Container) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { - c.GridContainer.CreateMessageUnsafe(msg) - c.GridContainer.CleanMessages() + c.ListContainer.CreateMessageUnsafe(msg) + c.ListContainer.CleanMessages() }) } func (c *Container) UpdateMessage(msg cchat.MessageUpdate) { - gts.ExecAsync(func() { c.GridContainer.UpdateMessageUnsafe(msg) }) + gts.ExecAsync(func() { c.ListContainer.UpdateMessageUnsafe(msg) }) } func (c *Container) DeleteMessage(msg cchat.MessageDelete) { - gts.ExecAsync(func() { c.GridContainer.DeleteMessageUnsafe(msg) }) + gts.ExecAsync(func() { c.ListContainer.DeleteMessageUnsafe(msg) }) } type constructor struct{} -func (constructor) NewMessage(msg cchat.MessageCreate) container.GridMessage { +func (constructor) NewMessage(msg cchat.MessageCreate) container.MessageRow { return NewMessage(msg) } -func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { +func (constructor) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow { return NewPresendMessage(msg) } diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go index d8dd5ff..1d8442f 100644 --- a/internal/ui/messages/container/compact/message.go +++ b/internal/ui/messages/container/compact/message.go @@ -5,7 +5,9 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/container" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" "github.com/gotk3/gotk3/gtk" + "github.com/gotk3/gotk3/pango" ) type PresendMessage struct { @@ -14,7 +16,8 @@ type PresendMessage struct { } func NewPresendMessage(msg input.PresendMessage) PresendMessage { - var msgc = message.NewPresendContainer(msg) + msgc := message.NewPresendContainer(msg) + attachCompact(msgc.GenericContainer) return PresendMessage{ PresendContainer: msgc, @@ -26,19 +29,48 @@ type Message struct { *message.GenericContainer } -var _ container.GridMessage = (*Message)(nil) +var _ container.MessageRow = (*Message)(nil) func NewMessage(msg cchat.MessageCreate) Message { msgc := message.NewContainer(msg) + attachCompact(msgc) message.FillContainer(msgc, msg) return Message{msgc} } func NewEmptyMessage() Message { - return Message{message.NewEmptyContainer()} + ct := message.NewEmptyContainer() + attachCompact(ct) + + return Message{ct} } -func (m Message) Attach() []gtk.IWidget { - return []gtk.IWidget{m.Timestamp, m.Username, m.Content} +var messageTimeCSS = primitives.PrepareClassCSS("message-time", ` + .message-time { + margin-left: 1em; + margin-right: 1em; + } +`) + +var messageAuthorCSS = primitives.PrepareClassCSS("message-author", ` + .message-author { + margin-right: 0.5em; + } +`) + +func attachCompact(container *message.GenericContainer) { + container.Timestamp.SetVAlign(gtk.ALIGN_START) + container.Username.SetMaxWidthChars(25) + container.Username.SetEllipsize(pango.ELLIPSIZE_NONE) + container.Username.SetLineWrap(true) + container.Username.SetLineWrapMode(pango.WRAP_WORD_CHAR) + + messageTimeCSS(container.Timestamp) + messageAuthorCSS(container.Username) + + container.PackStart(container.Timestamp, false, false, 0) + container.PackStart(container.Username, false, false, 0) + container.PackStart(container.Content, true, true, 0) + container.SetClass("compact") } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 9bcf200..44439bf 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -5,6 +5,7 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/labeluri" "github.com/gotk3/gotk3/gtk" ) @@ -12,18 +13,18 @@ import ( // once. const BacklogLimit = 35 -type GridMessage interface { +type MessageRow interface { message.Container - // Focusable should return a widget that can be focused. - Focusable() gtk.IWidget // Attach should only be called once. - Attach() []gtk.IWidget + Row() *gtk.ListBoxRow // AttachMenu should override the stored constructor. AttachMenu(items []menu.Item) // save memory + // SetReferenceHighlighter sets the reference highlighter into the message. + SetReferenceHighlighter(refer labeluri.ReferenceHighlighter) } -type PresendGridMessage interface { - GridMessage +type PresendMessageRow interface { + MessageRow message.PresendContainer } @@ -32,11 +33,6 @@ type PresendGridMessage interface { type Container interface { gtk.IWidget - // Thread-safe methods. - // cchat.MessagesContainer - - // Thread-unsafe methods. - // Reset resets the message container to its original state. Reset() @@ -48,13 +44,18 @@ type Container interface { // FirstMessage returns the first message in the buffer. Nil is returned if // there's nothing. - FirstMessage() GridMessage - // TranslateCoordinates is used for scrolling to the message. - TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y int) + FirstMessage() MessageRow // AddPresendMessage adds and displays an unsent message. - AddPresendMessage(msg input.PresendMessage) PresendGridMessage + AddPresendMessage(msg input.PresendMessage) PresendMessageRow // LatestMessageFrom returns the last message ID with that author. LatestMessageFrom(authorID string) (msgID string, ok bool) + // Message finds and returns the message, if any. + Message(id cchat.ID, nonce string) MessageRow + + // Highlight temporarily highlights the given message. + Highlight(msg MessageRow) + // Unhighlight removes the message highlight. + Unhighlight() // UI methods. @@ -65,7 +66,7 @@ type Container interface { // Controller is for menu actions. type Controller interface { // BindMenu expects the controller to add actioner into the message. - BindMenu(GridMessage) + BindMenu(MessageRow) // Bottomed returns whether or not the message scroller is at the bottom. Bottomed() bool // AuthorEvent is called on message create/update. This is used to update @@ -74,45 +75,45 @@ type Controller interface { } // Constructor is an interface for making custom message implementations which -// allows GridContainer to generically work with. +// allows ListContainer to generically work with. type Constructor interface { - NewMessage(cchat.MessageCreate) GridMessage - NewPresendMessage(input.PresendMessage) PresendGridMessage + NewMessage(cchat.MessageCreate) MessageRow + NewPresendMessage(input.PresendMessage) PresendMessageRow } -const ColumnSpacing = 10 +const ColumnSpacing = 8 -// GridContainer is an implementation of Container, which allows flexible +// ListContainer is an implementation of Container, which allows flexible // message grids. -type GridContainer struct { - *GridStore +type ListContainer struct { + *ListStore Controller } -// gridMessage w/ required internals -type gridMessage struct { - GridMessage +// messageRow w/ required internals +type messageRow struct { + MessageRow presend message.PresendContainer // this shouldn't be here but i'm lazy } -var _ Container = (*GridContainer)(nil) +var _ Container = (*ListContainer)(nil) -func NewGridContainer(constr Constructor, ctrl Controller) *GridContainer { - return &GridContainer{ - GridStore: NewGridStore(constr, ctrl), +func NewListContainer(constr Constructor, ctrl Controller) *ListContainer { + return &ListContainer{ + ListStore: NewListStore(constr, ctrl), Controller: ctrl, } } // CreateMessageUnsafe inserts a message. It does not clean up old messages. -func (c *GridContainer) CreateMessageUnsafe(msg cchat.MessageCreate) { +func (c *ListContainer) CreateMessageUnsafe(msg cchat.MessageCreate) { // Insert the message first. - c.GridStore.CreateMessageUnsafe(msg) + c.ListStore.CreateMessageUnsafe(msg) } // CleanMessages cleans up the oldest messages if the user is scrolled to the // bottom. True is returned if there were changes. -func (c *GridContainer) CleanMessages() bool { +func (c *ListContainer) CleanMessages() bool { // Determine if the user is scrolled to the bottom for cleaning up. if c.Bottomed() { // Clean up the backlog. diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index bc79a82..bc1f1b0 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -7,13 +7,12 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" - "github.com/gotk3/gotk3/gtk" ) // Unwrapper provides an interface for messages to be unwrapped. This is used to // convert between collapsed and full messages. type Unwrapper interface { - Unwrap(grid *gtk.Grid) *message.GenericContainer + Unwrap() *message.GenericContainer } var ( @@ -43,20 +42,20 @@ const ( ) type Container struct { - *container.GridContainer + *container.ListContainer } func NewContainer(ctrl container.Controller) *Container { c := &Container{} - c.GridContainer = container.NewGridContainer(c, ctrl) + c.ListContainer = container.NewListContainer(c, ctrl) // A not-so-generous row padding, as we will rely on margins per widget. - c.GridContainer.Grid.SetRowSpacing(4) + // c.ListContainer.Grid.SetRowSpacing(4) primitives.AddClass(c, "cozy-container") return c } -func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { +func (c *Container) NewMessage(msg cchat.MessageCreate) container.MessageRow { // We're not checking for a collapsed message here anymore, as the // CreateMessage method will do that. @@ -79,7 +78,7 @@ func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { return full } -func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendGridMessage { +func (c *Container) NewPresendMessage(msg input.PresendMessage) container.PresendMessageRow { // We can do the check here since we're never using NewPresendMessage for // backlog messages. if c.lastMessageIsAuthor(msg.AuthorID(), msg.Author().String(), 0) { @@ -95,9 +94,9 @@ func (c *Container) NewPresendMessage(msg input.PresendMessage) container.Presen return full } -func (c *Container) findAuthorID(authorID string) container.GridMessage { +func (c *Container) findAuthorID(authorID string) container.MessageRow { // Search the old author if we have any. - return c.GridStore.FindMessage(func(msgc container.GridMessage) bool { + return c.ListStore.FindMessage(func(msgc container.MessageRow) bool { return msgc.AuthorID() == authorID }) } @@ -122,11 +121,11 @@ func (c *Container) reuseAvatar(authorID, avatarURL string, full *FullMessage) { func (c *Container) lastMessageIsAuthor(id cchat.ID, name string, offset int) bool { // Get the offfsetth message from last. - var last = c.GridStore.NthMessage((c.GridStore.MessagesLen() - 1) + offset) + var last = c.ListStore.NthMessage((c.ListStore.MessagesLen() - 1) + offset) return gridMessageIsAuthor(last, id, name) } -func gridMessageIsAuthor(gridMsg container.GridMessage, id cchat.ID, name string) bool { +func gridMessageIsAuthor(gridMsg container.MessageRow, id cchat.ID, name string) bool { return gridMsg != nil && gridMsg.AuthorID() == id && gridMsg.AuthorName() == name @@ -136,11 +135,11 @@ func (c *Container) CreateMessage(msg cchat.MessageCreate) { gts.ExecAsync(func() { // Create the message in the parent's handler. This handler will also // wipe old messages. - c.GridContainer.CreateMessageUnsafe(msg) + c.ListContainer.CreateMessageUnsafe(msg) // Did the handler wipe old messages? It will only do so if the user is // scrolled to the bottom. - if c.GridContainer.CleanMessages() { + if c.ListContainer.CleanMessages() { // We need to uncollapse the first (top) message. No length check is // needed here, as we just inserted a message. c.uncompact(c.FirstMessage()) @@ -149,15 +148,15 @@ func (c *Container) CreateMessage(msg cchat.MessageCreate) { switch msg.ID() { // Should we collapse this message? Yes, if the current message is // inserted at the end and its author is the same as the last author. - case c.GridContainer.LastMessage().ID(): + case c.ListContainer.LastMessage().ID(): author := msg.Author() if c.lastMessageIsAuthor(author.ID(), author.Name().String(), -1) { - c.compact(c.GridContainer.LastMessage()) + c.compact(c.ListContainer.LastMessage()) } // If we've prepended the message, then see if we need to collapse the // second message. - case c.GridContainer.FirstMessage().ID(): + case c.ListContainer.FirstMessage().ID(): if sec := c.NthMessage(1); sec != nil { // The author is the same; collapse. author := msg.Author() @@ -179,17 +178,17 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { // Get the previous and next message before deleting. We'll need them to // evaluate whether we need to change anything. - prev, next := c.GridStore.Around(msg.ID()) + prev, next := c.ListStore.Around(msg.ID()) // The function doesn't actually try and re-collapse the bottom message // when a sandwiched message is deleted. This is fine. // Delete the message off of the parent's container. - msg := c.GridStore.PopMessage(msg.ID()) + msg := c.ListStore.PopMessage(msg.ID()) // Don't calculate if we don't have any messages, or no messages before // and after. - if c.GridStore.MessagesLen() == 0 || prev == nil || next == nil { + if c.ListStore.MessagesLen() == 0 || prev == nil || next == nil { return } @@ -211,7 +210,7 @@ func (c *Container) DeleteMessage(msg cchat.MessageDelete) { }) } -func (c *Container) uncompact(msg container.GridMessage) { +func (c *Container) uncompact(msg container.MessageRow) { // We should only uncompact the message if it's compacted in the first // place. if collapse, ok := msg.(Collapsible); !ok || !collapse.Collapsed() { @@ -225,17 +224,17 @@ func (c *Container) uncompact(msg container.GridMessage) { } // Start the "lengthy" uncollapse process. - full := WrapFullMessage(uw.Unwrap(c.Grid)) + full := WrapFullMessage(uw.Unwrap()) // Update the container to reformat everything including the timestamps. message.RefreshContainer(full, full.GenericContainer) // Update the avatar if needed be, since we're now showing it. c.reuseAvatar(msg.AuthorID(), msg.AvatarURL(), full) // Swap the old next message out for a new one. - c.GridStore.SwapMessage(full) + c.ListStore.SwapMessage(full) } -func (c *Container) compact(msg container.GridMessage) { +func (c *Container) compact(msg container.MessageRow) { // Exit if the message is already collapsed. if collapse, ok := msg.(Collapsible); !ok || collapse.Collapsed() { return @@ -246,8 +245,8 @@ func (c *Container) compact(msg container.GridMessage) { return } - compact := WrapCollapsedMessage(uw.Unwrap(c.Grid)) + compact := WrapCollapsedMessage(uw.Unwrap()) message.RefreshContainer(compact, compact.GenericContainer) - c.GridStore.SwapMessage(compact) + c.ListStore.SwapMessage(compact) } diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 8ef09ef..77ddb2d 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -30,11 +30,14 @@ func WrapCollapsedMessage(gc *message.GenericContainer) *CollapsedMessage { gc.Timestamp.SetVAlign(gtk.ALIGN_START) gc.Timestamp.SetXAlign(0.5) // middle align gc.Timestamp.SetMarginStart(container.ColumnSpacing * 2) + gc.Timestamp.SetMarginTop(container.ColumnSpacing) // Set Content's padding accordingly to FullMessage's main box. gc.Content.ToWidget().SetMarginEnd(container.ColumnSpacing * 2) - gc.Username.SetMaxWidthChars(30) + gc.PackStart(gc.Timestamp, false, false, 0) + gc.PackStart(gc.Content, true, true, 0) + gc.SetClass("cozy-collapsed") return &CollapsedMessage{ GenericContainer: gc, @@ -48,23 +51,15 @@ func (c *CollapsedMessage) UpdateTimestamp(t time.Time) { c.Timestamp.SetText(humanize.TimeAgoShort(t)) } -func (c *CollapsedMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { +func (c *CollapsedMessage) Unwrap() *message.GenericContainer { // Remove GenericContainer's widgets from the containers. - grid.Remove(c.Timestamp) - grid.Remove(c.Content) + c.Remove(c.Timestamp) + c.Remove(c.Content) // Return after removing. return c.GenericContainer } -func (c *CollapsedMessage) Attach() []gtk.IWidget { - return []gtk.IWidget{c.Timestamp, c.Content} -} - -func (c *CollapsedMessage) Focusable() gtk.IWidget { - return c.Timestamp -} - type CollapsedSendingMessage struct { *CollapsedMessage message.PresendContainer diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index c081e22..b529931 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -36,9 +36,9 @@ type AvatarPixbufCopier interface { } var ( - _ AvatarPixbufCopier = (*FullMessage)(nil) - _ message.Container = (*FullMessage)(nil) - _ container.GridMessage = (*FullMessage)(nil) + _ AvatarPixbufCopier = (*FullMessage)(nil) + _ message.Container = (*FullMessage)(nil) + _ container.MessageRow = (*FullMessage)(nil) ) var boldCSS = primitives.PrepareCSS(` @@ -64,14 +64,14 @@ func NewFullMessage(msg cchat.MessageCreate) *FullMessage { func WrapFullMessage(gc *message.GenericContainer) *FullMessage { avatar := NewAvatar() - avatar.SetMarginTop(TopFullMargin) + avatar.SetMarginTop(TopFullMargin / 2) avatar.SetMarginStart(container.ColumnSpacing * 2) avatar.Connect("clicked", func(w gtk.IWidget) { if output := gc.Username.Output(); len(output.Mentions) > 0 { labeluri.PopoverMentioner(w, output.Input, output.Mentions[0]) } }) - // We don't call avatar.Show(). That's called in Attach. + avatar.Show() // Style the timestamp accordingly. gc.Timestamp.SetXAlign(0.0) // left-align @@ -96,11 +96,16 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage { main.PackStart(gc.Content, false, false, 0) main.SetMarginTop(TopFullMargin) main.SetMarginEnd(container.ColumnSpacing * 2) + main.SetMarginStart(container.ColumnSpacing) main.Show() // Also attach a class for the main box shown on the right. primitives.AddClass(main, "cozy-main") + gc.PackStart(avatar, false, false, 0) + gc.PackStart(main, true, true, 0) + gc.SetClass("cozy-full") + return &FullMessage{ GenericContainer: gc, Avatar: avatar, @@ -111,7 +116,7 @@ func WrapFullMessage(gc *message.GenericContainer) *FullMessage { func (m *FullMessage) Collapsed() bool { return false } -func (m *FullMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { +func (m *FullMessage) Unwrap() *message.GenericContainer { // Remove GenericContainer's widgets from the containers. m.HeaderBox.Remove(m.Username) m.HeaderBox.Remove(m.Timestamp) @@ -122,22 +127,13 @@ func (m *FullMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { m.Avatar.Hide() // Remove the message from the grid. - grid.Remove(m.Avatar) - grid.Remove(m.MainBox) + m.Remove(m.Avatar) + m.Remove(m.MainBox) // Return after removing. return m.GenericContainer } -func (m *FullMessage) Attach() []gtk.IWidget { - m.Avatar.Show() - return []gtk.IWidget{m.Avatar, m.MainBox} -} - -func (m *FullMessage) Focusable() gtk.IWidget { - return m.Avatar -} - func (m *FullMessage) UpdateTimestamp(t time.Time) { m.GenericContainer.UpdateTimestamp(t) m.Timestamp.SetText(humanize.TimeAgoLong(t)) @@ -177,9 +173,8 @@ type FullSendingMessage struct { } var ( - // _ AvatarPixbufCopier = (*FullSendingMessage)(nil) - _ message.Container = (*FullSendingMessage)(nil) - _ container.GridMessage = (*FullSendingMessage)(nil) + _ message.Container = (*FullSendingMessage)(nil) + _ container.MessageRow = (*FullSendingMessage)(nil) ) func NewFullSendingMessage(msg input.PresendMessage) *FullSendingMessage { diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/list.go similarity index 59% rename from internal/ui/messages/container/grid.go rename to internal/ui/messages/container/list.go index e435b22..02dedba 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/list.go @@ -2,13 +2,14 @@ package container import ( "container/list" + "log" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/log" + "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" "github.com/gotk3/gotk3/gtk" - "github.com/pkg/errors" ) type messageKey struct { @@ -19,57 +20,51 @@ type messageKey struct { func nonceKey(nonce string) messageKey { return messageKey{nonce, true} } func idKey(id cchat.ID) messageKey { return messageKey{id, false} } -type GridStore struct { - *gtk.Grid +var messageListCSS = primitives.PrepareClassCSS("message-list", ` + .message-list { background: transparent; } +`) + +type ListStore struct { + *gtk.ListBox Construct Constructor Controller Controller resetMe bool - messages map[messageKey]*gridMessage + messages map[messageKey]*messageRow messageList *list.List } -func NewGridStore(constr Constructor, ctrl Controller) *GridStore { - grid, _ := gtk.GridNew() - grid.SetColumnSpacing(ColumnSpacing) - grid.SetRowSpacing(5) - grid.SetMarginStart(5) - grid.SetMarginEnd(5) - grid.Show() +func NewListStore(constr Constructor, ctrl Controller) *ListStore { + listBox, _ := gtk.ListBoxNew() + listBox.SetSelectionMode(gtk.SELECTION_NONE) + listBox.Show() + messageListCSS(listBox) - primitives.AddClass(grid, "message-grid") - - return &GridStore{ - Grid: grid, + return &ListStore{ + ListBox: listBox, Construct: constr, Controller: ctrl, - messages: make(map[messageKey]*gridMessage, BacklogLimit+1), + messages: make(map[messageKey]*messageRow, BacklogLimit+1), messageList: list.New(), } } -func (c *GridStore) Reset() { - primitives.RemoveChildren(c.Grid) - c.messages = make(map[messageKey]*gridMessage, BacklogLimit+1) +func (c *ListStore) Reset() { + primitives.RemoveChildren(c.ListBox) + c.messages = make(map[messageKey]*messageRow, BacklogLimit+1) c.messageList = list.New() } -func (c *GridStore) MessagesLen() int { +func (c *ListStore) MessagesLen() int { return c.messageList.Len() } -func (c *GridStore) attachGrid(row int, widgets []gtk.IWidget) { - for i, w := range widgets { - c.Grid.Attach(w, i, row, 1, 1) - } -} - -func (c *GridStore) findElement(id cchat.ID) (*list.Element, *gridMessage, int) { +func (c *ListStore) findElement(id cchat.ID) (*list.Element, *messageRow, int) { var index = c.messageList.Len() - 1 for elem := c.messageList.Back(); elem != nil; elem = elem.Prev() { - if gridMsg := elem.Value.(*gridMessage); gridMsg.ID() == id { + if gridMsg := elem.Value.(*messageRow); gridMsg.ID() == id { return elem, gridMsg, index } index-- @@ -78,89 +73,62 @@ func (c *GridStore) findElement(id cchat.ID) (*list.Element, *gridMessage, int) } // findIndex searches backwards for id. -func (c *GridStore) findIndex(id cchat.ID) (*gridMessage, int) { +func (c *ListStore) findIndex(id cchat.ID) (*messageRow, int) { _, gridMsg, ix := c.findElement(id) return gridMsg, ix } -type CoordinateTranslator interface { - TranslateCoordinates(dest gtk.IWidget, srcX int, srcY int) (destX int, destY int, e error) -} - -var _ CoordinateTranslator = (*gtk.Widget)(nil) - -func (c *GridStore) TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y int) { - m, i := c.findIndex(msg.ID()) - if i < 0 { - return 0 - } - - w, _ := m.Focusable().(CoordinateTranslator) - - // x is not needed. - _, y, err := w.TranslateCoordinates(parent, 0, 0) - if err != nil { - log.Error(errors.Wrap(err, "Failed to translate coords while focusing")) - return - } - - return y -} - // Swap changes the message with the ID to the given message. This provides a // low level API for edits that need a new Attach method. // // TODO: combine compact and full so they share the same attach method. -func (c *GridStore) SwapMessage(msg GridMessage) bool { - // Wrap msg inside a *gridMessage if it's not already. - m, ok := msg.(*gridMessage) +func (c *ListStore) SwapMessage(msg MessageRow) bool { + // Wrap msg inside a *messageRow if it's not already. + m, ok := msg.(*messageRow) if !ok { - m = &gridMessage{GridMessage: msg} + m = &messageRow{MessageRow: msg} } // Get the current message's index. - _, ix := c.findIndex(msg.ID()) + oldMsg, ix := c.findIndex(msg.ID()) if ix == -1 { return false } // Add a row at index. The actual row we want to delete will be shifted // downwards. - c.Grid.InsertRow(ix) + c.ListBox.Insert(m.Row(), ix) - // Delete the to-be-replaced message, which we have shifted downwards - // earlier, so we add 1. - c.Grid.RemoveRow(ix + 1) - - // Let the new message be attached on top of the to-be-replaced message. - c.attachGrid(ix, m.Attach()) + // Delete the to-be-replaced message. + oldMsg.Row().Destroy() // Set the message into the map. - c.messages[idKey(m.ID())] = m + row := c.messages[idKey(m.ID())] + *row = *m return true } // Around returns the message before and after the given ID, or nil if none. -func (c *GridStore) Around(id cchat.ID) (before, after GridMessage) { +func (c *ListStore) Around(id cchat.ID) (before, after MessageRow) { gridBefore, gridAfter := c.around(id) if gridBefore != nil { - before = gridBefore.GridMessage + before = gridBefore.MessageRow } if gridAfter != nil { - after = gridAfter.GridMessage + after = gridAfter.MessageRow } return } -func (c *GridStore) around(id cchat.ID) (before, after *gridMessage) { - var last *gridMessage +func (c *ListStore) around(id cchat.ID) (before, after *messageRow) { + var last *messageRow var next bool for elem := c.messageList.Front(); elem != nil; elem = elem.Next() { - message := elem.Value.(*gridMessage) + message := elem.Value.(*messageRow) if next { after = message break @@ -179,9 +147,9 @@ func (c *GridStore) around(id cchat.ID) (before, after *gridMessage) { // LatestMessageFrom returns the latest message with the given user ID. This is // used for the input prompt. -func (c *GridStore) LatestMessageFrom(userID string) (msgID string, ok bool) { +func (c *ListStore) LatestMessageFrom(userID string) (msgID string, ok bool) { // FindMessage already looks from the latest messages. - var msg = c.FindMessage(func(msg GridMessage) bool { + var msg = c.FindMessage(func(msg MessageRow) bool { return msg.AuthorID() == userID }) @@ -194,14 +162,14 @@ func (c *GridStore) LatestMessageFrom(userID string) (msgID string, ok bool) { // FindMessage iterates backwards and returns the message if isMessage() returns // true on that message. -func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessage { +func (c *ListStore) FindMessage(isMessage func(msg MessageRow) bool) MessageRow { for elem := c.messageList.Back(); elem != nil; elem = elem.Prev() { - gridMsg := elem.Value.(*gridMessage) + gridMsg := elem.Value.(*messageRow) // Ignore sending messages. if gridMsg.presend != nil { continue } - if gridMsg := gridMsg.GridMessage; isMessage(gridMsg) { + if gridMsg := gridMsg.MessageRow; isMessage(gridMsg) { return gridMsg } } @@ -210,11 +178,11 @@ func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessag } // NthMessage returns the nth message. -func (c *GridStore) NthMessage(n int) GridMessage { +func (c *ListStore) NthMessage(n int) MessageRow { var index = 0 for elem := c.messageList.Front(); elem != nil; elem = elem.Next() { if index == n { - return elem.Value.(*gridMessage).GridMessage + return elem.Value.(*messageRow).MessageRow } index++ } @@ -223,33 +191,33 @@ func (c *GridStore) NthMessage(n int) GridMessage { } // FirstMessage returns the first message. -func (c *GridStore) FirstMessage() GridMessage { +func (c *ListStore) FirstMessage() MessageRow { if c.messageList.Len() == 0 { return nil } // Long unwrap. - return c.messageList.Front().Value.(*gridMessage).GridMessage + return c.messageList.Front().Value.(*messageRow).MessageRow } // LastMessage returns the latest message. -func (c *GridStore) LastMessage() GridMessage { +func (c *ListStore) LastMessage() MessageRow { if c.messageList.Len() == 0 { return nil } // Long unwrap. - return c.messageList.Back().Value.(*gridMessage).GridMessage + return c.messageList.Back().Value.(*messageRow).MessageRow } // Message finds the message state in the container. It is not thread-safe. This // exists for backwards compatibility. -func (c *GridStore) Message(msgID cchat.ID, nonce string) GridMessage { +func (c *ListStore) Message(msgID cchat.ID, nonce string) MessageRow { if m := c.message(msgID, nonce); m != nil { - return m.GridMessage + return m.MessageRow } return nil } -func (c *GridStore) message(msgID cchat.ID, nonce string) *gridMessage { +func (c *ListStore) message(msgID cchat.ID, nonce string) *messageRow { // Search using the ID first. m, ok := c.messages[idKey(msgID)] if ok { @@ -279,16 +247,16 @@ func (c *GridStore) message(msgID cchat.ID, nonce string) *gridMessage { // AddPresendMessage inserts an input.PresendMessage into the container and // returning a wrapped widget interface. -func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessage { +func (c *ListStore) AddPresendMessage(msg input.PresendMessage) PresendMessageRow { presend := c.Construct.NewPresendMessage(msg) - msgc := &gridMessage{ - GridMessage: presend, - presend: presend, + msgc := &messageRow{ + MessageRow: presend, + presend: presend, } - // Set the message into the grid. - c.attachGrid(c.MessagesLen(), msgc.Attach()) + // Set the message into the list. + c.ListBox.Insert(msgc.Row(), c.MessagesLen()) // Append the message. c.messageList.PushBack(msgc) // Set the NONCE into the message map. @@ -297,26 +265,31 @@ func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessa return presend } +func (c *ListStore) bindMessage(msgc *messageRow) { + msgc.SetReferenceHighlighter(c) + c.Controller.BindMenu(msgc.MessageRow) +} + // Many attempts were made to have CreateMessageUnsafe return an index. That is // unreliable. The index might be off if the message buffer is cleaned up. Don't // rely on it. -func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { +func (c *ListStore) CreateMessageUnsafe(msg cchat.MessageCreate) { // Call the event handler last. defer c.Controller.AuthorEvent(msg.Author()) - // Attempt to update before insertion (aka upsert). + // Do not attempt to update before insertion (aka upsert). if msgc := c.message(msg.ID(), msg.Nonce()); msgc != nil { msgc.UpdateAuthor(msg.Author()) msgc.UpdateContent(msg.Content(), false) msgc.UpdateTimestamp(msg.Time()) - c.Controller.BindMenu(msgc.GridMessage) + c.bindMessage(msgc) return } - msgc := &gridMessage{ - GridMessage: c.Construct.NewMessage(msg), + msgc := &messageRow{ + MessageRow: c.Construct.NewMessage(msg), } msgTime := msg.Time() @@ -325,7 +298,7 @@ func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { // Iterate and compare timestamp to find where to insert a message. for after != nil { - if msgTime.After(after.Value.(*gridMessage).Time()) { + if msgTime.After(after.Value.(*messageRow).Time()) { break } index-- @@ -343,16 +316,15 @@ func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { } // Set the message into the grid. - c.Grid.InsertRow(index) - c.attachGrid(index, msgc.Attach()) + c.ListBox.Insert(msgc.Row(), index) - // Set the NONCE into the message map. - c.messages[nonceKey(msgc.Nonce())] = msgc + // Set the ID into the message map. + c.messages[idKey(msgc.ID())] = msgc - c.Controller.BindMenu(msgc) + c.bindMessage(msgc) } -func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { +func (c *ListStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { // Call the event handler last. defer c.Controller.AuthorEvent(msg.Author()) @@ -368,21 +340,21 @@ func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { return } -func (c *GridStore) DeleteMessageUnsafe(msg cchat.MessageDelete) { +func (c *ListStore) DeleteMessageUnsafe(msg cchat.MessageDelete) { c.PopMessage(msg.ID()) } // PopMessage deletes a message off of the list and return the deleted message. -func (c *GridStore) PopMessage(id cchat.ID) (msg GridMessage) { +func (c *ListStore) PopMessage(id cchat.ID) (msg MessageRow) { // Get the raw element to delete it off the list. - elem, gridMsg, ix := c.findElement(id) + elem, gridMsg, _ := c.findElement(id) if elem == nil { return nil } - msg = gridMsg.GridMessage + msg = gridMsg.MessageRow // Remove off of the Gtk grid. - c.Grid.RemoveRow(ix) + gridMsg.Row().Destroy() // Pop off the slice. c.messageList.Remove(elem) // Delete off the map. @@ -393,7 +365,7 @@ func (c *GridStore) PopMessage(id cchat.ID) (msg GridMessage) { // DeleteEarliest deletes the n earliest messages. It does nothing if n is or // less than 0. -func (c *GridStore) DeleteEarliest(n int) { +func (c *ListStore) DeleteEarliest(n int) { if n <= 0 { return } @@ -401,7 +373,7 @@ func (c *GridStore) DeleteEarliest(n int) { // Since container/list nils out the next element, we can't just call Next // after deleting, so we have to call Next manually before Removing. for elem := c.messageList.Front(); elem != nil && n != 0; n-- { - gridMsg := elem.Value.(*gridMessage) + gridMsg := elem.Value.(*messageRow) if id := gridMsg.ID(); id != "" { delete(c.messages, idKey(id)) @@ -410,10 +382,30 @@ func (c *GridStore) DeleteEarliest(n int) { delete(c.messages, nonceKey(nonce)) } - c.Grid.RemoveRow(0) + gridMsg.Row().Destroy() next := elem.Next() c.messageList.Remove(elem) elem = next } } + +func (c *ListStore) HighlightReference(ref markup.ReferenceSegment) { + msg := c.message(ref.MessageID(), "") + log.Println("Highlighting", ref.MessageID()) + if msg != nil { + c.Highlight(msg) + } +} + +func (c *ListStore) Highlight(msg MessageRow) { + gts.ExecLater(func() { + row := msg.Row() + row.GrabFocus() + c.ListBox.DragHighlightRow(row) + }) +} + +func (c *ListStore) Unhighlight() { + c.ListBox.DragUnhighlightRow() +} diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 4480276..068f9f1 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -18,7 +18,14 @@ import ( // Controller is an interface to control message containers. type Controller interface { AddPresendMessage(msg PresendMessage) (onErr func(error)) - LatestMessageFrom(userID string) (messageID string, ok bool) + LatestMessageFrom(userID cchat.ID) (messageID cchat.ID, ok bool) + MessageAuthorMarkup(msgID cchat.ID) (markup string, ok bool) +} + +// LabelBorrower is an interface that allows the caller to borrow a label. +type LabelBorrower interface { + BorrowLabel(markup string) + Unborrow() } type InputView struct { @@ -53,7 +60,7 @@ var inputBoxCSS = primitives.PrepareClassCSS("input-box", ` } `) -func NewView(ctrl Controller) *InputView { +func NewView(ctrl Controller, labeler LabelBorrower) *InputView { text, _ := gtk.TextViewNew() text.SetSensitive(false) text.SetWrapMode(gtk.WRAP_WORD_CHAR) @@ -72,7 +79,7 @@ func NewView(ctrl Controller) *InputView { c := completion.NewCompleter(text) // Bind the input callback later. - f := NewField(text, ctrl) + f := NewField(text, ctrl, labeler) f.Show() return &InputView{f, c} @@ -99,6 +106,13 @@ func (v *InputView) SetMessenger(session cchat.Session, messenger cchat.Messenge // wrapSpellCheck is a no-op but is replaced by gspell in ./spellcheck.go. var wrapSpellCheck = func(textView *gtk.TextView) {} +const ( + sendButtonIcon = "mail-send-symbolic" + editButtonIcon = "document-edit-symbolic" + replyButtonIcon = "mail-reply-sender-symbolic" + sendButtonSize = gtk.ICON_SIZE_BUTTON +) + type Field struct { // Box contains the field box and the attachment container. *gtk.Box @@ -113,10 +127,13 @@ type Field struct { text *gtk.TextView // const buffer *gtk.TextBuffer // const - send *gtk.Button + sendIcon *gtk.Image + send *gtk.Button + attach *gtk.Button - ctrl Controller + ctrl Controller + indicator LabelBorrower // Embed a state field which allows us to easily reset it. fieldState @@ -130,7 +147,9 @@ type fieldState struct { editor cchat.Editor typing cchat.TypingIndicator - editingID string // never empty + replyingID cchat.ID + editingID cchat.ID + lastTyped time.Time typerDura time.Duration } @@ -152,8 +171,12 @@ var scrolledInputCSS = primitives.PrepareClassCSS("scrolled-input", ` } `) -func NewField(text *gtk.TextView, ctrl Controller) *Field { - field := &Field{text: text, ctrl: ctrl} +func NewField(text *gtk.TextView, ctrl Controller, labeler LabelBorrower) *Field { + field := &Field{ + text: text, + ctrl: ctrl, + indicator: labeler, + } field.buffer, _ = text.GetBuffer() field.Username = username.NewContainer() @@ -163,13 +186,17 @@ func NewField(text *gtk.TextView, ctrl Controller) *Field { field.TextScroll.Show() scrolledInputCSS(field.TextScroll) - field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", gtk.ICON_SIZE_BUTTON) + field.attach, _ = gtk.ButtonNewFromIconName("mail-attachment-symbolic", sendButtonSize) field.attach.SetRelief(gtk.RELIEF_NONE) field.attach.SetSensitive(false) // Only show this if the server supports it (upload == true). primitives.AddClass(field.attach, "attach-button") - field.send, _ = gtk.ButtonNewFromIconName("mail-send-symbolic", gtk.ICON_SIZE_BUTTON) + field.sendIcon, _ = gtk.ImageNewFromIconName(sendButtonIcon, sendButtonSize) + field.sendIcon.Show() + + field.send, _ = gtk.ButtonNew() + field.send.SetImage(field.sendIcon) field.send.SetRelief(gtk.RELIEF_NONE) field.send.Show() primitives.AddClass(field.send, "send-button") @@ -278,12 +305,27 @@ func (f *Field) SetAllowUpload(allow bool) { } } +func (f *Field) StartReplyingTo(msgID cchat.ID) { + // Clear the input to prevent mixing. + f.clearText() + + f.replyingID = msgID + f.sendIcon.SetFromIconName(replyButtonIcon, gtk.ICON_SIZE_BUTTON) + + name, ok := f.ctrl.MessageAuthorMarkup(msgID) + if !ok { + name = "message" + } + + f.indicator.BorrowLabel("Replying to " + name) +} + // Editable returns whether or not the input field can be edited. -func (f *Field) Editable(msgID string) bool { +func (f *Field) Editable(msgID cchat.ID) bool { return f.editor != nil && f.editor.IsEditable(msgID) } -func (f *Field) StartEditing(msgID string) bool { +func (f *Field) StartEditing(msgID cchat.ID) bool { // Do we support message editing? If not, exit. if !f.Editable(msgID) { return false @@ -297,11 +339,18 @@ func (f *Field) StartEditing(msgID string) bool { return false } + // Clear the input before editing to prevent mixing replying and editing + // together. + f.clearText() + // Set the current editing state and set the input after requesting the // content. f.editingID = msgID f.buffer.SetText(content) + f.indicator.BorrowLabel("Editing Message") + f.sendIcon.SetFromIconName(editButtonIcon, sendButtonSize) + return true } @@ -312,15 +361,17 @@ func (f *Field) StopEditing() bool { return false } - f.editingID = "" f.clearText() - return true } // clearText resets the input field func (f *Field) clearText() { + f.editingID = "" + f.replyingID = "" f.buffer.Delete(f.buffer.GetBounds()) + f.sendIcon.SetFromIconName(sendButtonIcon, sendButtonSize) + f.indicator.Unborrow() f.Attachments.Reset() } diff --git a/internal/ui/messages/input/keydown.go b/internal/ui/messages/input/keydown.go index db8ecf7..5f379ac 100644 --- a/internal/ui/messages/input/keydown.go +++ b/internal/ui/messages/input/keydown.go @@ -67,14 +67,9 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool { // Take the event. return true - // There are multiple things to do here when we press the Escape key. + // Clear text when the Escape key is pressed. case key == gdk.KEY_Escape: - // First, we'd want to cancel editing if we have one. - if f.editingID != "" { - return f.StopEditing() // always returns true - } - - // Second... Nothing yet? + f.clearText() // Ctrl+V is paste. case key == gdk.KEY_v && bithas(mask, cntrlMask): diff --git a/internal/ui/messages/input/sendable.go b/internal/ui/messages/input/sendable.go index 63dba0b..d0cb93c 100644 --- a/internal/ui/messages/input/sendable.go +++ b/internal/ui/messages/input/sendable.go @@ -40,8 +40,8 @@ func (f *Field) sendInput() { return } - // Get the input text. - var text = f.getText() + // Get the input text and the reply ID. + text := f.getText() // Are we editing anything? if id := f.editingID; f.Editable(id) && id != "" { @@ -70,6 +70,7 @@ func (f *Field) sendInput() { authorID: f.UserID, authorURL: f.Username.GetIconURL(), nonce: f.generateNonce(), + replyID: f.replyingID, files: attachments, }) @@ -94,23 +95,38 @@ func (f *Field) SendMessage(data PresendMessage) { }) } +// Files is a list of attachments. +type Files []attachment.File + +// Attachments returns the list of files as a list of cchat attachments. +func (files Files) Attachments() []cchat.MessageAttachment { + var attachments = make([]cchat.MessageAttachment, len(files)) + for i, file := range files { + attachments[i] = file.AsAttachment() + } + return attachments +} + +// SendMessageData contains what is to be sent in a message. It behaves +// similarly to a regular CreateMessage. type SendMessageData struct { time time.Time content string author text.Rich - authorID string + authorID cchat.ID authorURL string // avatar nonce string - files []attachment.File + replyID cchat.ID + files Files } var _ cchat.SendableMessage = (*SendMessageData)(nil) +// PresendMessage is an interface for any message about to be sent. type PresendMessage interface { cchat.MessageHeader // returns nonce and time cchat.SendableMessage cchat.Noncer - cchat.Attachments // These methods are reserved for internal use. @@ -123,50 +139,15 @@ type PresendMessage interface { var _ PresendMessage = (*SendMessageData)(nil) // ID returns a pseudo ID for internal use. -func (s SendMessageData) ID() string { - return s.nonce -} - -func (s SendMessageData) Time() time.Time { - return s.time -} - -func (s SendMessageData) Content() string { - return s.content -} - -func (s SendMessageData) Author() text.Rich { - return s.author -} - -func (s SendMessageData) AuthorID() string { - return s.authorID -} - -func (s SendMessageData) AuthorAvatarURL() string { - return s.authorURL -} - -func (s SendMessageData) AsNoncer() cchat.Noncer { - return s -} - -func (s SendMessageData) Nonce() string { - return s.nonce -} - -func (s SendMessageData) Files() []attachment.File { - return s.files -} - -func (s SendMessageData) AsAttachments() cchat.Attachments { - return s -} - -func (s SendMessageData) Attachments() []cchat.MessageAttachment { - var attachments = make([]cchat.MessageAttachment, len(s.files)) - for i, file := range s.files { - attachments[i] = file.AsAttachment() - } - return attachments -} +func (s SendMessageData) ID() string { return s.nonce } +func (s SendMessageData) Time() time.Time { return s.time } +func (s SendMessageData) Content() string { return s.content } +func (s SendMessageData) Author() text.Rich { return s.author } +func (s SendMessageData) AuthorID() string { return s.authorID } +func (s SendMessageData) AuthorAvatarURL() string { return s.authorURL } +func (s SendMessageData) AsNoncer() cchat.Noncer { return s } +func (s SendMessageData) Nonce() string { return s.nonce } +func (s SendMessageData) Files() []attachment.File { return s.files } +func (s SendMessageData) AsAttacher() cchat.Attacher { return s.files } +func (s SendMessageData) AsReplier() cchat.Replier { return s } +func (s SendMessageData) ReplyingTo() cchat.ID { return s.replyID } diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go index f514ed0..c941821 100644 --- a/internal/ui/messages/input/username/username.go +++ b/internal/ui/messages/input/username/username.go @@ -114,6 +114,11 @@ func (u *Container) GetLabel() text.Rich { return u.label.GetLabel() } +// GetLabelMarkup is not thread-safe. +func (u *Container) GetLabelMarkup() string { + return u.label.Label.GetLabel() +} + // SetLabel is thread-safe. func (u *Container) SetLabel(content text.Rich) { gts.ExecAsync(func() { diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index 7f20562..166acbf 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -20,6 +20,7 @@ type Container interface { Time() time.Time AuthorID() string AuthorName() string + AuthorMarkup() string AvatarURL() string // avatar Nonce() string @@ -48,6 +49,10 @@ func RefreshContainer(c Container, gc *GenericContainer) { // GenericContainer provides a single generic message container for subpackages // to use. type GenericContainer struct { + *gtk.Box + row *gtk.ListBoxRow // contains Box + class string + id string time time.Time authorID string @@ -67,7 +72,7 @@ type GenericContainer struct { var _ Container = (*GenericContainer)(nil) -var timestampCSS = primitives.PrepareCSS(` +var timestampCSS = primitives.PrepareClassCSS("message-time", ` .message-time { opacity: 0.3; font-size: 0.8em; @@ -76,6 +81,12 @@ var timestampCSS = primitives.PrepareCSS(` } `) +var authorCSS = primitives.PrepareClassCSS("message-author", ` + .message-author { + color: mix(@theme_bg_color, @theme_fg_color, 0.8); + } +`) + // NewContainer creates a new message container with the given ID and nonce. It // does not update the widgets, so FillContainer should be called afterwards. func NewContainer(msg cchat.MessageCreate) *GenericContainer { @@ -91,14 +102,12 @@ func NewContainer(msg cchat.MessageCreate) *GenericContainer { func NewEmptyContainer() *GenericContainer { ts, _ := gtk.LabelNew("") ts.SetEllipsize(pango.ELLIPSIZE_MIDDLE) - ts.SetXAlign(1) // right align + ts.SetXAlign(0.5) // centre align ts.SetVAlign(gtk.ALIGN_END) ts.Show() user := labeluri.NewLabel(text.Rich{}) - user.SetLineWrap(true) - user.SetLineWrapMode(pango.WRAP_WORD_CHAR) - user.SetXAlign(1) // right align + user.SetXAlign(0) // left align user.SetVAlign(gtk.ALIGN_START) user.SetTrackVisitedLinks(false) user.Show() @@ -117,26 +126,25 @@ func NewEmptyContainer() *GenericContainer { ctbox.PackStart(ctbody, false, false, 0) ctbox.Show() - // Causes bugs with selections. + box, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + box.Show() - // ctbody.Connect("grab-notify", func(l *gtk.Label, grabbed bool) { - // if grabbed { - // // Hack to stop the label from selecting everything after being - // // refocused. - // ctbody.SetSelectable(false) - // gts.ExecAsync(func() { ctbody.SetSelectable(true) }) - // } - // }) + row, _ := gtk.ListBoxRowNew() + row.Add(box) + row.Show() // Add CSS classes. primitives.AddClass(ts, "message-time") + primitives.AddClass(row, "message-row") primitives.AddClass(user, "message-author") primitives.AddClass(ctbody, "message-content") - - // Attach the timestamp CSS. - primitives.AttachCSS(ts, timestampCSS) + timestampCSS(ts) + authorCSS(ts) gc := &GenericContainer{ + Box: box, + row: row, + Timestamp: ts, Username: user, Content: ctbox, @@ -157,6 +165,25 @@ func NewEmptyContainer() *GenericContainer { return gc } +// Row returns the internal list box row. It is used to satisfy MessageRow. +func (m *GenericContainer) Row() *gtk.ListBoxRow { return m.row } + +// SetClass sets the internal row's class. +func (m *GenericContainer) SetClass(class string) { + if m.class != "" { + primitives.RemoveClass(m.row, m.class) + } + + primitives.AddClass(m.row, class) + m.class = class +} + +// SetReferenceHighlighter sets the reference highlighter into the message. +func (m *GenericContainer) SetReferenceHighlighter(r labeluri.ReferenceHighlighter) { + m.Username.SetReferenceHighlighter(r) + m.ContentBody.SetReferenceHighlighter(r) +} + func (m *GenericContainer) ID() string { return m.id } @@ -173,6 +200,10 @@ func (m *GenericContainer) AuthorName() string { return m.authorName } +func (m *GenericContainer) AuthorMarkup() string { + return m.Username.Label.Label.GetLabel() +} + func (m *GenericContainer) AvatarURL() string { return m.avatarURL } @@ -195,6 +226,7 @@ func (m *GenericContainer) UpdateAuthor(author cchat.Author) { func (m *GenericContainer) UpdateAuthorName(name text.Rich) { cfg := markup.RenderConfig{} + cfg.NoReferencing = true cfg.SetForegroundAnchor(m.ContentBody) m.authorName = name.String() diff --git a/internal/ui/messages/typing/dots.go b/internal/ui/messages/typing/dots.go index efda33b..de6aa74 100644 --- a/internal/ui/messages/typing/dots.go +++ b/internal/ui/messages/typing/dots.go @@ -31,26 +31,18 @@ var dotsCSS = primitives.PrepareCSS(` const breathingChar = "●" func NewDots() *gtk.Box { - c1, _ := gtk.LabelNew(breathingChar) - c1.Show() - c2, _ := gtk.LabelNew(breathingChar) - c2.Show() - c3, _ := gtk.LabelNew(breathingChar) - c3.Show() - b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - b.Add(c1) - b.Add(c2) - b.Add(c3) - primitives.AddClass(b, "breathing-dots") - primitives.AttachCSS(c1, dotsCSS) - primitives.AttachCSS(c1, smallfonts) - primitives.AttachCSS(c2, dotsCSS) - primitives.AttachCSS(c2, smallfonts) - primitives.AttachCSS(c3, dotsCSS) - primitives.AttachCSS(c3, smallfonts) + for i := 0; i < 3; i++ { + c, _ := gtk.LabelNew(breathingChar) + c.Show() + + primitives.AttachCSS(c, dotsCSS) + primitives.AttachCSS(c, smallfonts) + + b.Add(c) + } return b } diff --git a/internal/ui/messages/typing/state.go b/internal/ui/messages/typing/state.go index aa9213a..1dfc4e2 100644 --- a/internal/ui/messages/typing/state.go +++ b/internal/ui/messages/typing/state.go @@ -18,7 +18,7 @@ type State struct { // consts changed func(s *State, empty bool) - stopper func() // stops the event loop, not used atm + stopper func() } var _ cchat.TypingContainer = (*State)(nil) @@ -45,7 +45,7 @@ func (s *State) Subscribe(indicator cchat.TypingIndicator) { gts.Async(func() (func(), error) { c, err := indicator.TypingSubscribe(s) if err != nil { - return nil, errors.Wrap(err, "Failed to subscribe to typing indicator") + return nil, errors.Wrap(err, "failed to subscribe to typing indicator") } return func() { diff --git a/internal/ui/messages/typing/typing.go b/internal/ui/messages/typing/typing.go index 9b70255..2395547 100644 --- a/internal/ui/messages/typing/typing.go +++ b/internal/ui/messages/typing/typing.go @@ -10,16 +10,25 @@ import ( "github.com/gotk3/gotk3/pango" ) -var typingIndicatorCSS = primitives.PrepareCSS(` +var typingIndicatorCSS = primitives.PrepareClassCSS("typing-indicator", ` .typing-indicator { margin: 0 6px; margin-top: 2px; + padding: 0 4px; + border-radius: 6px 6px 0 0; + color: alpha(@theme_fg_color, 0.8); background-color: @theme_base_color; } `) +var typingLabelCSS = primitives.PrepareClassCSS("typing-label", ` + .typing-label { + padding-left: 2px; + } +`) + var smallfonts = primitives.PrepareCSS(` * { font-size: 0.9em; } `) @@ -27,6 +36,14 @@ var smallfonts = primitives.PrepareCSS(` type Container struct { *gtk.Revealer state *State + + dots *gtk.Box + label *gtk.Label + + // borrow, if true, will not update the label until it is set to false. + borrow bool + // markup stores the label if the label view is not borrowed. + markup string } func New() *Container { @@ -37,10 +54,11 @@ func New() *Container { l.SetXAlign(0) l.SetEllipsize(pango.ELLIPSIZE_END) l.Show() + typingLabelCSS(l) primitives.AttachCSS(l, smallfonts) b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - b.PackStart(d, false, false, 4) + b.PackStart(d, false, false, 0) b.PackStart(l, true, true, 0) b.Show() @@ -50,21 +68,31 @@ func New() *Container { r.SetRevealChild(false) r.Add(b) - primitives.AddClass(b, "typing-indicator") - primitives.AttachCSS(b, typingIndicatorCSS) + typingIndicatorCSS(b) - state := NewState(func(s *State, empty bool) { - r.SetRevealChild(!empty) - l.SetMarkup(render(s.typers)) + container := &Container{ + Revealer: r, + dots: d, + label: l, + } + + container.state = NewState(func(s *State, empty bool) { + if !empty { + container.markup = render(s.typers) + } else { + container.markup = "" + } + + if !container.borrow { + r.SetRevealChild(!empty) + l.SetMarkup(container.markup) + } }) // On label destroy, stop the state loop as well. - l.Connect("destroy", func(interface{}) { state.stopper() }) + l.Connect("destroy", func(interface{}) { container.state.stopper() }) - return &Container{ - Revealer: r, - state: state, - } + return container } func (c *Container) Reset() { @@ -72,6 +100,28 @@ func (c *Container) Reset() { c.SetRevealChild(false) } +// BorrowLabel borrows the container label. The typing indicator will display +// the given markup string instead of the markup it is intended to display until +// Unborrow is called. +func (c *Container) BorrowLabel(markup string) { + c.borrow = true + c.label.SetMarkup(markup) + c.dots.Hide() // bad, TODO use revealer + c.SetRevealChild(true) +} + +// Unborrow stops borrowing the typing indicator, returning it to the state it +// is supposed to show. Calling Unborrow multiple times will only take effect +// for the first time. +func (c *Container) Unborrow() { + if c.borrow { + c.label.SetMarkup(c.markup) + c.SetRevealChild(c.markup != "") + c.dots.Show() // bad, TODO use revealer + c.borrow = false + } +} + func (c *Container) RemoveAuthor(author cchat.Author) { c.state.removeTyper(author.ID()) } diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 5b7c26c..a011205 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -135,7 +135,7 @@ func NewView(c Controller) *View { sep.SetHExpand(true) sep.Show() - view.InputView = input.NewView(view) + view.InputView = input.NewView(view, view.Typing) view.InputView.SetHExpand(true) view.InputView.Show() @@ -363,10 +363,7 @@ func (v *View) FetchBacklog() { var done = func() { v.ctrl.OnMessageDone() - - // Restore scrolling. - y := v.Container.TranslateCoordinates(v.MsgBox, firstMsg) - v.Scroller.GetVAdjustment().SetValue(float64(y)) + v.Container.Highlight(firstMsg) } gts.Async(func() (func(), error) { @@ -403,13 +400,22 @@ func (v *View) AuthorEvent(author cchat.Author) { } } +func (v *View) MessageAuthorMarkup(msgID cchat.ID) (string, bool) { + msg := v.Container.Message(msgID, "") + if msg == nil { + return "", false + } + + return msg.AuthorMarkup(), true +} + // LatestMessageFrom returns the last message ID with that author. func (v *View) LatestMessageFrom(userID string) (msgID string, ok bool) { return v.Container.LatestMessageFrom(userID) } // retryMessage sends the message. -func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendGridMessage) { +func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendMessageRow) { var sender = v.InputView.Sender if sender == nil { return @@ -426,9 +432,13 @@ func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendG // BindMenu attaches the menu constructor into the message with the needed // states and callbacks. -func (v *View) BindMenu(msg container.GridMessage) { +func (v *View) BindMenu(msg container.MessageRow) { // Add 1 for the edit menu item. - var mitems []menu.Item + var mitems = []menu.Item{ + menu.SimpleItem( + "Reply", func() { v.InputView.StartReplyingTo(msg.ID()) }, + ), + } // Do we have editing capabilities? If yes, append a button to allow it. if v.InputView.Editable(msg.ID()) { diff --git a/internal/ui/primitives/actions/menu.go b/internal/ui/primitives/actions/menu.go index fb1252f..fd00ba5 100644 --- a/internal/ui/primitives/actions/menu.go +++ b/internal/ui/primitives/actions/menu.go @@ -3,11 +3,15 @@ package actions import ( "fmt" + "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/glib" "github.com/gotk3/gotk3/gtk" ) type ActionGroupInserter interface { + primitives.Connector InsertActionGroup(prefix string, action glib.IActionGroup) } @@ -39,6 +43,15 @@ func (m *Menu) InsertActionGroup(w ActionGroupInserter) { w.InsertActionGroup(m.prefix, m) } +func (m *Menu) BindRightClick(w ActionGroupInserter) { + m.InsertActionGroup(w) + w.Connect("button-press-event", func(w gtk.IWidget, ev *gdk.Event) { + if gts.EventIsRightClick(ev) { + m.Popup(w) + } + }) +} + // Popup pops up the menu popover. It does not pop up anything if there are no // menu items. func (m *Menu) Popup(relative gtk.IWidget) { diff --git a/internal/ui/primitives/menu/menu.go b/internal/ui/primitives/menu/menu.go index 5231ee4..8cb5709 100644 --- a/internal/ui/primitives/menu/menu.go +++ b/internal/ui/primitives/menu/menu.go @@ -15,9 +15,9 @@ type LazyMenu struct { } func NewLazyMenu(bindTo primitives.Connector) *LazyMenu { - l := &LazyMenu{} + l := LazyMenu{} bindTo.Connect("button-press-event", l.popup) - return l + return &l } func (m *LazyMenu) popup(w gtk.IWidget, ev *gdk.Event) { diff --git a/internal/ui/primitives/primitives.go b/internal/ui/primitives/primitives.go index 84b51d0..c665dec 100644 --- a/internal/ui/primitives/primitives.go +++ b/internal/ui/primitives/primitives.go @@ -185,6 +185,14 @@ func HandleDestroyCtx(ctx context.Context, connector Connector) context.Context return ctx } +func OnRightClick(connector Connector, fn func()) { + connector.Connect("button-press-event", func(c Connector, ev *gdk.Event) { + if gts.EventIsRightClick(ev) { + fn() + } + }) +} + func BindMenu(connector Connector, menu *gtk.Menu) { connector.Connect("button-press-event", func(c Connector, ev *gdk.Event) { if gts.EventIsRightClick(ev) { diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index be885eb..10519e5 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -48,6 +48,7 @@ type Labeler interface { // Label implements a label that's already bounded to the markup URI handlers. type Label struct { *rich.Label + *BoundBox output markup.RenderOutput } @@ -62,7 +63,7 @@ func NewLabel(txt text.Rich) *Label { l.Label.SetLabelUnsafe(txt) // test // Bind and return. - BindRichLabel(l) + l.BoundBox = BindRichLabel(l) return l } @@ -87,21 +88,50 @@ func (l *Label) SetOutput(o markup.RenderOutput) { l.SetMarkup(o.Markup) } -func BindRichLabel(label Labeler) { - bind(label, func(uri string, ptr gdk.Rectangle) bool { - var output = label.Output() +type ReferenceHighlighter interface { + HighlightReference(ref markup.ReferenceSegment) +} - if segment := output.IsMention(uri); segment != nil { - if p := NewPopoverMentioner(label, output.Input, segment); p != nil { - p.SetPointingTo(ptr) - p.Popup() - } +// BoundBox is a box wrapping elements that can be interacted with from the +// parsed labels. +type BoundBox struct { + label Labeler + refer ReferenceHighlighter +} - return true +func BindRichLabel(label Labeler) *BoundBox { + bound := BoundBox{label: label} + bind(label, bound.activate) + return &bound +} + +func (bound *BoundBox) activate(uri string, ptr gdk.Rectangle) bool { + var output = bound.label.Output() + + switch segment := output.URISegment(uri).(type) { + case markup.MentionSegment: + popover := NewPopoverMentioner(bound.label, output.Input, segment) + if popover != nil { + popover.SetPointingTo(ptr) + popover.Popup() } + return true + + case markup.ReferenceSegment: + if bound.refer != nil { + bound.refer.HighlightReference(segment) + } + + return true + + default: return false - }) + } +} + +func (bound *BoundBox) SetReferenceHighlighter(refer ReferenceHighlighter) { + bound.refer = refer } func PopoverMentioner(rel gtk.IWidget, input string, mention text.Segment) { diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index 33e9cbc..5d95c94 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -45,32 +45,38 @@ type ReferenceSegment struct { } const ( - // f_Mention is used to print and parse mention URIs. - f_Mention = "cchat://mention/%d" // %d == Mentions[i] - f_Reference = "cchat://reference/%d" // %d == References[i] + MentionType = "mention" + ReferenceType = "reference" ) -// IsMention returns the mention if the URI is correct, or nil if none. -func (r RenderOutput) IsMention(uri string) text.Segment { - var i int - - _, err := fmt.Sscanf(uri, f_Mention, &i) - if err != nil || i >= len(r.Mentions) { - return nil +func fmtSegmentURI(stype string, ix int) string { + u := url.URL{ + Scheme: "cchat", + Host: stype, + Path: strconv.Itoa(ix), } - - return r.Mentions[i] + return u.String() } -func (r RenderOutput) IsReference(uri string) text.Segment { - var i int - - _, err := fmt.Sscanf(uri, f_Reference, &i) - if err != nil || i >= len(r.References) { +func (r RenderOutput) URISegment(uri string) text.Segment { + u, err := url.Parse(uri) + if err != nil || u.Scheme != "cchat" { return nil } - return r.References[i] + i, err := strconv.Atoi(strings.TrimPrefix(u.Path, "/")) + if err != nil { + panic("Invalid path " + u.Path) + } + + switch u.Host { + case MentionType: + return r.Mentions[i] + case ReferenceType: + return r.References[i] + default: + panic("Unknown internal URI ID: " + u.Host) + } } func Render(content text.Rich) string { @@ -86,6 +92,10 @@ type RenderConfig struct { // NoMentionLinks, if true, will not render any mentions. NoMentionLinks bool + // NoReferencing, if true, will not parse reference links and prefer + // mentions. + NoReferencing bool + // AnchorColor forces all anchors to be of a certain color. This is used if // the boolean is true. Else, all mention links will not work and regular // links will be of the default color. @@ -176,7 +186,7 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { if mentioner := segment.AsMentioner(); mentioner != nil && !cfg.NoMentionLinks { // 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))) + appended.AnchorNU(start, end, fmtSegmentURI(MentionType, len(mentions))) hasAnchor = true // Add the mention segment into the list regardless of hyperlinks. @@ -213,16 +223,18 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { // Don't use AnchorColor for the link, as we're technically just // borrowing the anchor tag for its use. We should also prefer the // username popover (Mention) over this. - if reference := segment.AsMessageReferencer(); !hasAnchor && reference != nil { - // Render the mention into "cchat://reference:0" or such. Other - // components will take care of showing the information. - appended.AnchorNU(start, end, fmt.Sprintf(f_Reference, len(references))) + if !cfg.NoReferencing && !hasAnchor { + if reference := segment.AsMessageReferencer(); reference != nil { + // Render the mention into "cchat://reference:0" or such. Other + // components will take care of showing the information. + appended.AnchorNU(start, end, fmtSegmentURI(ReferenceType, len(references))) - // Add the mention segment into the list regardless of hyperlinks. - references = append(references, ReferenceSegment{ - Segment: segment, - MessageReferencer: reference, - }) + // Add the mention segment into the list regardless of hyperlinks. + references = append(references, ReferenceSegment{ + Segment: segment, + MessageReferencer: reference, + }) + } } if attributor := segment.AsAttributor(); attributor != nil { diff --git a/internal/ui/service/config/config.go b/internal/ui/service/config/config.go index ea7c5a4..b7b162f 100644 --- a/internal/ui/service/config/config.go +++ b/internal/ui/service/config/config.go @@ -3,38 +3,95 @@ package config import ( + "fmt" + "hash/fnv" + "io" + "strconv" + "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" + "github.com/diamondburned/cchat-gtk/internal/ui/config" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/menu" + "github.com/diamondburned/cchat/text" "github.com/gotk3/gotk3/gtk" + "github.com/pkg/errors" ) -type Configurator interface { +type Configurator struct { cchat.Service cchat.Configurator } func MenuItem(conf Configurator) menu.Item { - return menu.SimpleItem("Configure", func() { - SpawnConfigurator(conf) + return menu.SimpleItem("Configure", func() { Spawn(conf) }) +} + +// Restore restores the config in the background. +func Restore(conf Configurator) { + gts.Async(func() (func(), error) { + c, err := conf.Configuration() + if err != nil { + return nil, errors.Wrapf(err, "failed to get %s config", conf.Name()) + } + + file := serviceFile(conf) + + if err := config.UnmarshalFromFile(file, c); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal %s config", conf.Name()) + } + + if err := conf.SetConfiguration(c); err != nil { + return nil, errors.Wrapf(err, "failed to set %s config", conf.Name()) + } + + return nil, nil }) } -func SpawnConfigurator(conf Configurator) error { - c, err := conf.Configuration() - if err != nil { - return err - } +func Spawn(conf Configurator) error { + gts.Async(func() (func(), error) { + c, err := conf.Configuration() + if err != nil { + return nil, errors.Wrapf(err, "failed to get %s config", conf.Name()) + } - Spawn(conf.Name().Content, c, func() error { - return conf.SetConfiguration(c) + file := serviceFile(conf) + + err = config.UnmarshalFromFile(file, c) + err = errors.Wrapf(err, "failed to unmarshal %s config", conf.Name()) + + return func() { + spawn(conf.Name().String(), c, func(finalized bool) error { + if err := conf.SetConfiguration(c); err != nil { + return err + } + + if finalized { + gts.Async(func() (func(), error) { + return nil, config.MarshalToFile(file, c) + }) + } + + return nil + }) + }, err }) return nil } -func Spawn(name string, conf map[string]string, apply func() error) { - container := newContainer(conf, apply) +func serviceFile(conf Configurator) string { + return fmt.Sprintf("service-%s.json", dumbHash(conf.Name())) +} + +func dumbHash(name text.Rich) string { + hash := fnv.New32a() + io.WriteString(hash, name.String()) + return strconv.FormatUint(uint64(hash.Sum32()), 36) +} + +func spawn(name string, conf map[string]string, apply func(final bool) error) { + container := newContainer(conf, func() error { return apply(false) }) container.Grid.SetVAlign(gtk.ALIGN_START) sw, _ := gtk.ScrolledWindowNew(nil, nil) @@ -62,5 +119,8 @@ func Spawn(name string, conf map[string]string, apply func() error) { d.Add(b) d.SetTitle(title) d.SetTitlebar(h) + + d.Connect("destroy", func(*gtk.Dialog) { apply(true) }) + d.Show() } diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 372d999..9894d48 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -7,10 +7,12 @@ import ( "github.com/diamondburned/cchat-gtk/internal/keyring" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives/actions" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/drag" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" + "github.com/diamondburned/cchat-gtk/internal/ui/service/config" "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/session/server/traverse" @@ -42,11 +44,13 @@ type Service struct { *gtk.Box Button *gtk.ToggleButton Icon *rich.Icon + Menu *actions.Menu BodyRev *gtk.Revealer // revealed BodyList *session.List // not really supposed to be here - service cchat.Service // state + service cchat.Service // state + Configurator cchat.Configurator } var serviceCSS = primitives.PrepareClassCSS("service", ` @@ -127,6 +131,20 @@ func NewService(svc cchat.Service, svclctrl ListController) *Service { }) serviceButtonCSS(service.Button) + // Bind session.* actions into row. + service.Menu = actions.NewMenu("service") + // Bind right clicks and show a popover menu on such event. + service.Menu.BindRightClick(service.Button) + + if configurator := svc.AsConfigurator(); configurator != nil { + cfg := config.Configurator{ + Service: svc, + Configurator: configurator, + } + config.Restore(cfg) + service.Menu.AddAction("Configure", func() { config.Spawn(cfg) }) + } + // Intermediary box to contain both the icon and the revealer. service.Box, _ = gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) service.Box.PackStart(service.Button, false, false, 0) diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 242b533..9c0b68c 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -17,7 +17,6 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/commander" "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" "github.com/pkg/errors" ) @@ -176,10 +175,8 @@ func newRow(parent traverse.Breadcrumber, name text.Rich, ctrl Controller) *Row row.ActionsMenu.InsertActionGroup(row) // Bind right clicks and show a popover menu on such event. - row.iconBox.Connect("button-press-event", func(_ interface{}, ev *gdk.Event) { - if gts.EventIsRightClick(ev) { - row.ActionsMenu.Popup(row) - } + primitives.OnRightClick(row.iconBox, func() { + row.ActionsMenu.Popup(row) }) // Bind drag-and-drop events.