From a10230a8cd9d58150cdbf33e917cfa8b3d97cfcc Mon Sep 17 00:00:00 2001 From: diamondburned Date: Wed, 14 Oct 2020 23:32:11 -0700 Subject: [PATCH] Works with cchat v0.3 --- .gitignore | 2 + go.mod | 8 +- go.sum | 36 ++++ internal/keyring/keyring.go | 12 +- internal/ui/messages/container/container.go | 3 +- internal/ui/messages/container/cozy/cozy.go | 4 +- .../messages/container/cozy/message_full.go | 8 +- internal/ui/messages/container/grid.go | 26 ++- .../messages/input/completion/completion.go | 114 ----------- internal/ui/messages/input/input.go | 59 +++--- internal/ui/messages/input/keydown.go | 4 +- internal/ui/messages/input/sendable.go | 18 +- .../ui/messages/input/username/username.go | 10 +- internal/ui/messages/memberlist/memberlist.go | 26 +-- internal/ui/messages/message/message.go | 15 +- internal/ui/messages/typing/state.go | 4 +- internal/ui/messages/typing/typing.go | 10 +- internal/ui/messages/view.go | 48 ++--- .../ui/primitives/completion/completer.go | 183 +++++++++++++++--- internal/ui/primitives/completion/utils.go | 10 +- internal/ui/rich/image.go | 2 +- internal/ui/rich/labeluri/labeluri.go | 28 +-- internal/ui/rich/parser/hl/hl.go | 6 +- internal/ui/rich/parser/markup/markup.go | 123 +++++++----- internal/ui/service/list.go | 2 +- internal/ui/service/savepath/savepath.go | 22 ++- internal/ui/service/service.go | 43 ++-- internal/ui/service/session/list.go | 13 ++ .../ui/service/session/server/children.go | 52 ++++- .../session/{ => server}/commander/buffer.go | 32 +-- .../{ => server}/commander/commander.go | 99 +++------- internal/ui/service/session/server/server.go | 74 +++++-- internal/ui/service/session/servers.go | 4 +- internal/ui/service/session/session.go | 32 +-- internal/ui/service/view.go | 12 +- internal/ui/ui.go | 4 +- 36 files changed, 642 insertions(+), 506 deletions(-) delete mode 100644 internal/ui/messages/input/completion/completion.go rename internal/ui/service/session/{ => server}/commander/buffer.go (62%) rename internal/ui/service/session/{ => server}/commander/commander.go (60%) diff --git a/.gitignore b/.gitignore index 7eee852..827680a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ cchat-gtk +.direnv +.envrc diff --git a/go.mod b/go.mod index 8b6acc4..9da1f5e 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ replace github.com/gotk3/gotk3 => github.com/diamondburned/gotk3 v0.0.0-20200816 require ( github.com/Xuanwo/go-locale v0.2.0 github.com/alecthomas/chroma v0.7.3 - github.com/diamondburned/cchat v0.0.49 - github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e - github.com/diamondburned/cchat-mock v0.0.0-20200709231652-ad222ce5a74b + github.com/diamondburned/cchat v0.3.7 + github.com/diamondburned/cchat-discord v0.0.0-20201015062850-090259a6b4ca + github.com/diamondburned/cchat-mock v0.0.0-20201014202453-b9838fab0ab0 github.com/diamondburned/gspell v0.0.0-20200830182722-77e5d27d6894 github.com/diamondburned/handy v0.0.0-20200829011954-4667e7a918f4 github.com/diamondburned/imgutil v0.0.0-20200710174014-8a3be144a972 @@ -23,6 +23,6 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/twmb/murmur3 v1.1.3 github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 - golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect gopkg.in/yaml.v2 v2.2.7 // indirect ) diff --git a/go.sum b/go.sum index 4a4465d..98b458c 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,25 @@ github.com/diamondburned/cchat v0.0.48 h1:MAzGzKY20JBh/LnirOZVPwbMq07xfqu4Lb4XsV github.com/diamondburned/cchat v0.0.48/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.49 h1:zP6QvjdRU3UqDZt3rEqjkR/5M68XRVms7htHfE9tLOc= github.com/diamondburned/cchat v0.0.49/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= +github.com/diamondburned/cchat v0.2.11 h1:w4c/6t02htGtVj6yIjznecOGMlkcj0TmmLy+K48gHeM= +github.com/diamondburned/cchat v0.2.11/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.2.12 h1:R4wrBdhELMfhv2Kn3xL/H3ci8UcLXzFRPq1IrY4+js4= +github.com/diamondburned/cchat v0.2.12/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.2.13 h1:12xmJ1DpLZTG9icGZSXruCPT2BylOdhgXfKKwbqUXx4= +github.com/diamondburned/cchat v0.2.13/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.2.14 h1:mxcwre0LMjimrN5UAZVmerBqg9p2OxgjF27fJ1ASMjw= +github.com/diamondburned/cchat v0.2.14/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.2.15 h1:GYKD4VTrWCf1zIsFmyDVsUYaLjvVhgEh7qrg4KtaM0k= +github.com/diamondburned/cchat v0.2.15/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.1 h1:7NbVjT50dmLxcHPm+eDFF5jcaZw3t/9IdSEkZ/md1Rg= +github.com/diamondburned/cchat v0.3.1/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.2 h1:KcaWAN5qztKsSVsVGAWE4Mr779fOFLwLkxqlGh2bLo8= +github.com/diamondburned/cchat v0.3.2/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.3 h1:FFdcahDUGGP/h9BvVjoYOKgNXSRTQS+6Blb8keQmxVw= +github.com/diamondburned/cchat v0.3.3/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.5/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= +github.com/diamondburned/cchat v0.3.7 h1:0t3FkbzC/pBRAR3w0uYznJ+7dYqcR1M48a9wgz4JkIg= +github.com/diamondburned/cchat v0.3.7/go.mod h1:IlMtF+XIvAJh0GL/2yFdf0/34w+Hdy5A1GgvSwAXtQI= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401 h1:llmx/8UiJoTcHUw+GE5/rESVVmmnLh1HEPx3wRj+oQY= github.com/diamondburned/cchat-discord v0.0.0-20200719175346-af912db55401/go.mod h1:+hSrIVYj5tIPLAorDsHj2Tbt2fWlZtOanzfEUHX53HM= github.com/diamondburned/cchat-discord v0.0.0-20200730000036-2c93cdc1974e h1:EA5Vg0x57qLURJP80XhABBW+X0sbQSh2gw5qvPbZTs4= @@ -84,8 +103,24 @@ github.com/diamondburned/cchat-discord v0.0.0-20200820222718-68cfafc4c318 h1:mRG github.com/diamondburned/cchat-discord v0.0.0-20200820222718-68cfafc4c318/go.mod h1:rhUseXyWXTVw0Da8edbQMHU9I4LRQ2zcRB3zRqg/oe4= github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e h1:higtJiL7t6owP2dVAwJxItnpsD1MUypWDVant2uYv6g= github.com/diamondburned/cchat-discord v0.0.0-20200821041521-647c854d7b5e/go.mod h1:rhUseXyWXTVw0Da8edbQMHU9I4LRQ2zcRB3zRqg/oe4= +github.com/diamondburned/cchat-discord v0.0.0-20201007015315-da520786d74b h1:IJYC5vKdT9zTX/vLRXKIpv9xC6FNVq13O3X5ndSsN6g= +github.com/diamondburned/cchat-discord v0.0.0-20201007015315-da520786d74b/go.mod h1:Bp7CERMjWVJf/Rv8pO8pdcg/ZLuvJ24TenDAzfW+Nl8= +github.com/diamondburned/cchat-discord v0.0.0-20201009070751-b6694ea24a39 h1:P1KcSdD1a8fszRH3exaT9B63OtKx1MKTR6YllbiXRXQ= +github.com/diamondburned/cchat-discord v0.0.0-20201009070751-b6694ea24a39/go.mod h1:ijG0kx3DLVygYUlhVPvvBAlLW8cNtUuXdFtAUtigvOw= +github.com/diamondburned/cchat-discord v0.0.0-20201009173316-1907986ceb08 h1:iytskZ4dvc6KLlMDCpFcTIB7nIZkbprjDnaL2bCkduE= +github.com/diamondburned/cchat-discord v0.0.0-20201009173316-1907986ceb08/go.mod h1:BF8CJaW6rdYDGjFd2qXODS5nSu9vvW7OehgkXIB8B0M= +github.com/diamondburned/cchat-discord v0.0.0-20201015062850-090259a6b4ca h1:36MnUdiunaz4hsqDO0313Nc03y59PzIPZtmEF8gUeCg= +github.com/diamondburned/cchat-discord v0.0.0-20201015062850-090259a6b4ca/go.mod h1:S0PDR6aj2qE871JSy94YvwtprQJCWwkIJWzRu7S1Asc= 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/cchat-mock v0.0.0-20201004204741-b841407af381 h1:8JWNJMgoa3fL2py3gXSeC3NiAC+39EZp+JmvaoDBTUU= +github.com/diamondburned/cchat-mock v0.0.0-20201004204741-b841407af381/go.mod h1:dObDshcI3LXSicnuBBoRiCV6j0H5FZwp6wq4yANMdyQ= +github.com/diamondburned/cchat-mock v0.0.0-20201009070609-ab7eccf48e52 h1:0XES1llczz7181MtWsmMBvSibBZg9CAlGx+eDpyvzdM= +github.com/diamondburned/cchat-mock v0.0.0-20201009070609-ab7eccf48e52/go.mod h1:qDKTtPsdMeAmOV1QWwIII1hBzjcCZOXsbxDYoyuw2eo= +github.com/diamondburned/cchat-mock v0.0.0-20201009173002-83501e8aad33 h1:1rw4gQwAPAR47OLB06st0RP77jCrH+29EH/Xgu0otKI= +github.com/diamondburned/cchat-mock v0.0.0-20201009173002-83501e8aad33/go.mod h1:tshTM0VduaKpzqYnVYdAozXVk/dBkEkoyM3KXua+cIk= +github.com/diamondburned/cchat-mock v0.0.0-20201014202453-b9838fab0ab0 h1:GwceonhYEE6QZzqMTCc/OsBJ+CZ9omWzN/4if+e62uA= +github.com/diamondburned/cchat-mock v0.0.0-20201014202453-b9838fab0ab0/go.mod h1:hYNki0Ic/d7zFVXTJIjp/td1W4OpxDNcVY8layxgTyc= 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/gotk3 v0.0.0-20200816224505-3cd69b83a48a h1:wEldljb421/Jp84RNb0zBfqmiWt/TTQzUE6R1ap6UuQ= @@ -138,6 +173,7 @@ github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= github.com/go-test/deep v1.0.6 h1:UHSEyLZUwX9Qoi99vVwvewiMC8mM2bf7XEM2nqvzEn8= github.com/go-test/deep v1.0.6/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= +github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8= github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 99aa994..b9b3acc 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -27,14 +27,8 @@ type Session struct { func ConvertSession(ses cchat.Session) *Session { var name = ses.Name().Content - saver, ok := ses.(cchat.SessionSaver) - if !ok { - return nil - } - - s, err := saver.Save() - if err != nil { - log.Error(errors.Wrapf(err, "Failed to save session ID %s (%s)", ses.ID(), name)) + saver := ses.AsSessionSaver() + if saver == nil { return nil } @@ -47,7 +41,7 @@ func ConvertSession(ses cchat.Session) *Session { return &Session{ ID: ses.ID(), Name: name, - Data: s, + Data: saver.SaveSession(), } } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index 958c0c9..ce6b61e 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -41,7 +41,6 @@ type Container interface { // Thread-safe methods. cchat.MessagesContainer - cchat.MessagePrepender // Thread-unsafe methods. CreateMessageUnsafe(cchat.MessageCreate) @@ -73,7 +72,7 @@ type Controller interface { Bottomed() bool // AuthorEvent is called on message create/update. This is used to update // the typer state. - AuthorEvent(a cchat.MessageAuthor) + AuthorEvent(a cchat.Author) } // Constructor is an interface for making custom message implementations which diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index f218a61..de82a6b 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -70,10 +70,10 @@ func (c *Container) NewMessage(msg cchat.MessageCreate) container.GridMessage { author := msg.Author() // Try and reuse an existing avatar if the author has one. - if avatarURL, ok := author.(cchat.MessageAuthorAvatar); ok { + if avatarURL := author.Avatar(); avatarURL != "" { // Try reusing the avatar, but fetch it from the interndet if we can't // reuse. The reuse function does this for us. - c.reuseAvatar(author.ID(), avatarURL.Avatar(), full) + c.reuseAvatar(author.ID(), author.Avatar(), full) } return full diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index 0c77a4a..50fe39b 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -139,14 +139,10 @@ func (m *FullMessage) UpdateTimestamp(t time.Time) { m.Timestamp.SetText(humanize.TimeAgoLong(t)) } -func (m *FullMessage) UpdateAuthor(author cchat.MessageAuthor) { +func (m *FullMessage) UpdateAuthor(author cchat.Author) { // Call the parent's method to update the labels. m.GenericContainer.UpdateAuthor(author) - - // If the author has an avatar: - if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok { - m.Avatar.SetURL(avatarer.Avatar()) - } + m.Avatar.SetURL(author.Avatar()) } // CopyAvatarPixbuf sets the pixbuf into the given container. This shares the diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/grid.go index a9afc46..c3442e9 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/grid.go @@ -192,41 +192,39 @@ func (c *GridStore) LastMessage() GridMessage { // Message finds the message state in the container. It is not thread-safe. This // exists for backwards compatibility. -func (c *GridStore) Message(msg cchat.MessageHeader) GridMessage { - if m := c.message(msg); m != nil { +func (c *GridStore) Message(msgID cchat.ID, nonce string) GridMessage { + if m := c.message(msgID, nonce); m != nil { return m.GridMessage } return nil } -func (c *GridStore) message(msg cchat.MessageHeader) *gridMessage { +func (c *GridStore) message(msgID cchat.ID, nonce string) *gridMessage { // Search using the ID first. - m, ok := c.messages[msg.ID()] + m, ok := c.messages[msgID] if ok { return m } // Is this an existing message? - if noncer, ok := msg.(cchat.MessageNonce); ok { - var nonce = noncer.Nonce() - + if nonce != "" { // Things in this map are guaranteed to have presend != nil. m, ok := c.messages[nonce] if ok { // Replace the nonce key with ID. delete(c.messages, nonce) - c.messages[msg.ID()] = m + c.messages[msgID] = m // Set the right ID. - m.presend.SetDone(msg.ID()) + m.presend.SetDone(msgID) // Destroy the presend struct. m.presend = nil // Replace the nonce inside the ID slice with the actual ID. if ix := c.findIndex(nonce); ix > -1 { - c.messageIDs[ix] = msg.ID() + c.messageIDs[ix] = msgID } else { - log.Error(fmt.Errorf("Missed ID %s in slice index %d", msg.ID(), ix)) + log.Error(fmt.Errorf("Missed ID %s in slice index %d", msgID, ix)) } return m @@ -280,7 +278,7 @@ func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { defer c.Controller.AuthorEvent(msg.Author()) // Attempt to update before insertion (aka upsert). - if msgc := c.Message(msg); msgc != nil { + if msgc := c.Message(msg.ID(), msg.Nonce()); msgc != nil { msgc.UpdateAuthor(msg.Author()) msgc.UpdateContent(msg.Content(), false) msgc.UpdateTimestamp(msg.Time()) @@ -305,11 +303,11 @@ func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { // Call the event handler last. defer c.Controller.AuthorEvent(msg.Author()) - if msgc := c.Message(msg); msgc != nil { + if msgc := c.Message(msg.ID(), ""); msgc != nil { if author := msg.Author(); author != nil { msgc.UpdateAuthor(author) } - if content := msg.Content(); !content.Empty() { + if content := msg.Content(); !content.IsEmpty() { msgc.UpdateContent(content, true) } } diff --git a/internal/ui/messages/input/completion/completion.go b/internal/ui/messages/input/completion/completion.go deleted file mode 100644 index bb27f22..0000000 --- a/internal/ui/messages/input/completion/completion.go +++ /dev/null @@ -1,114 +0,0 @@ -package completion - -import ( - "fmt" - - "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts/httputil" - "github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion" - "github.com/diamondburned/cchat-gtk/internal/ui/rich" - "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" - "github.com/diamondburned/cchat/text" - "github.com/diamondburned/imgutil" - "github.com/gotk3/gotk3/gtk" -) - -const ( - ImageSmall = 25 - ImageLarge = 40 - ImagePadding = 6 -) - -var ppIcon = []imgutil.Processor{imgutil.Round(true)} - -type View struct { - *completion.Completer - entries []cchat.CompletionEntry - completer cchat.ServerMessageSendCompleter -} - -func New(text *gtk.TextView) *View { - v := &View{} - c := completion.NewCompleter(text, v) - v.Completer = c - - return v -} - -func (v *View) Reset() { - v.SetCompleter(nil) -} - -func (v *View) SetCompleter(completer cchat.ServerMessageSendCompleter) { - v.Clear() - v.Hide() - v.completer = completer -} - -func (v *View) Update(words []string, i int) []gtk.IWidget { - // If we don't have a completer, then don't run. - if v.completer == nil { - return nil - } - - v.entries = v.completer.CompleteMessage(words, i) - - var widgets = make([]gtk.IWidget, len(v.entries)) - - for i, entry := range v.entries { - // Container that holds the label. - lbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) - lbox.SetVAlign(gtk.ALIGN_CENTER) - lbox.Show() - - // Label for the primary text. - l := rich.NewLabel(entry.Text) - l.Show() - lbox.PackStart(l, false, false, 0) - - // Get the iamge size so we can change and use if needed. The default - var size = ImageSmall - if !entry.Secondary.Empty() { - size = ImageLarge - - s := rich.NewLabel(text.Rich{}) - s.SetMarkup(fmt.Sprintf( - `%s`, - markup.Render(entry.Secondary), - )) - s.Show() - - lbox.PackStart(s, false, false, 0) - } - - b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) - b.PackEnd(lbox, true, true, ImagePadding) - b.Show() - - // Do we have an icon? - if entry.IconURL != "" { - img, _ := gtk.ImageNew() - img.SetMarginStart(ImagePadding) - img.SetSizeRequest(size, size) - img.Show() - - // Prepend the image into the box. - b.PackEnd(img, false, false, 0) - - var pps []imgutil.Processor - if !entry.Image { - pps = ppIcon - } - - httputil.AsyncImageSized(img, entry.IconURL, size, size, pps...) - } - - widgets[i] = b - } - - return widgets -} - -func (v *View) Word(i int) string { - return v.entries[i].Raw -} diff --git a/internal/ui/messages/input/input.go b/internal/ui/messages/input/input.go index 48519a9..683d05a 100644 --- a/internal/ui/messages/input/input.go +++ b/internal/ui/messages/input/input.go @@ -7,9 +7,9 @@ import ( "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/attachment" - "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/completion" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input/username" "github.com/diamondburned/cchat-gtk/internal/ui/primitives" + "github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput" "github.com/diamondburned/gspell" "github.com/gotk3/gotk3/gtk" @@ -24,7 +24,7 @@ type Controller interface { type InputView struct { *Field - Completer *completion.View + Completer *completion.Completer } var textCSS = primitives.PrepareCSS(` @@ -69,7 +69,7 @@ func NewView(ctrl Controller) *InputView { primitives.AttachCSS(text, textCSS) // Bind the text event handler to text first. - c := completion.New(text) + c := completion.NewCompleter(text) // Bind the input callback later. f := NewField(text, ctrl) @@ -78,12 +78,18 @@ func NewView(ctrl Controller) *InputView { return &InputView{f, c} } -func (v *InputView) SetSender(session cchat.Session, sender cchat.ServerMessageSender) { - v.Field.SetSender(session, sender) +func (v *InputView) SetMessenger(session cchat.Session, messenger cchat.Messenger) { + v.Field.SetMessenger(session, messenger) + + if messenger == nil { + return + } // Ignore ok; completer can be nil. - completer, _ := sender.(cchat.ServerMessageSendCompleter) - v.Completer.SetCompleter(completer) + // TODO: this is possibly racy vs the above SetMessenger. + if sender := messenger.AsSender(); sender != nil { + v.Completer.SetCompleter(sender.AsCompleter()) + } } type Field struct { @@ -111,11 +117,12 @@ type Field struct { } type fieldState struct { - UserID string - Sender cchat.ServerMessageSender - upload bool // true if server supports files - editor cchat.ServerMessageEditor - typer cchat.ServerMessageTypingIndicator + UserID string + Messenger cchat.Messenger + Sender cchat.Sender + upload bool // true if server supports files + editor cchat.Editor + typing cchat.TypingIndicator editingID string // never empty lastTyped time.Time @@ -215,30 +222,30 @@ func (f *Field) Reset() { f.clearText() } -// SetSender changes the sender of the input field. If nil, the input will be -// disabled. Reset() should be called first. -func (f *Field) SetSender(session cchat.Session, sender cchat.ServerMessageSender) { +// SetMessenger changes the messenger of the input field. If nil, the input +// will be disabled. Reset() should be called first. +func (f *Field) SetMessenger(session cchat.Session, messenger cchat.Messenger) { // Update the left username container in the input. - f.Username.Update(session, sender) + f.Username.Update(session, messenger) f.UserID = session.ID() // Set the sender. - if sender != nil { - f.Sender = sender + if messenger != nil { + f.Messenger = messenger + f.Sender = messenger.AsSender() f.text.SetSensitive(true) // Allow editor to be nil. - f.editor, _ = sender.(cchat.ServerMessageEditor) + f.editor = f.Messenger.AsEditor() // Allow typer to be nil. - f.typer, _ = sender.(cchat.ServerMessageTypingIndicator) + f.typing = f.Messenger.AsTypingIndicator() // See if we can upload files. - _, allowUpload := sender.(cchat.ServerMessageAttachmentSender) - f.SetAllowUpload(allowUpload) + f.SetAllowUpload(f.Sender.CanAttach()) // Populate the duration state if typer is not nil. - if f.typer != nil { - f.typerDura = f.typer.TypingTimeout() + if f.typing != nil { + f.typerDura = f.typing.TypingTimeout() } } } @@ -262,7 +269,7 @@ func (f *Field) SetAllowUpload(allow bool) { // Editable returns whether or not the input field can be edited. func (f *Field) Editable(msgID string) bool { - return f.editor != nil && f.editor.MessageEditable(msgID) + return f.editor != nil && f.editor.IsEditable(msgID) } func (f *Field) StartEditing(msgID string) bool { @@ -272,7 +279,7 @@ func (f *Field) StartEditing(msgID string) bool { } // Try and request the old message content for editing. - content, err := f.editor.RawMessageContent(msgID) + content, err := f.editor.RawContent(msgID) if err != nil { // TODO: show error log.Error(errors.Wrap(err, "Failed to get message content")) diff --git a/internal/ui/messages/input/keydown.go b/internal/ui/messages/input/keydown.go index 16cb0ea..08fee65 100644 --- a/internal/ui/messages/input/keydown.go +++ b/internal/ui/messages/input/keydown.go @@ -105,7 +105,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool { // If the server supports typing indication, then announce that we are // typing with a proper rate limit. - if f.typer != nil { + if f.typing != nil { // Get the current time; if the next timestamp is before now, then that // means it's time for us to update it and send a typing indication. if now := time.Now(); f.lastTyped.Add(f.typerDura).Before(now) { @@ -113,7 +113,7 @@ func (f *Field) keyDown(tv *gtk.TextView, ev *gdk.Event) bool { f.lastTyped = now // Send asynchronously. go func() { - if err := f.typer.Typing(); err != nil { + if err := f.typing.Typing(); err != nil { log.Error(errors.Wrap(err, "Failed to announce typing")) } }() diff --git a/internal/ui/messages/input/sendable.go b/internal/ui/messages/input/sendable.go index bc1235e..63dba0b 100644 --- a/internal/ui/messages/input/sendable.go +++ b/internal/ui/messages/input/sendable.go @@ -46,7 +46,7 @@ func (f *Field) sendInput() { // Are we editing anything? if id := f.editingID; f.Editable(id) && id != "" { go func() { - if err := f.editor.EditMessage(id, text); err != nil { + if err := f.editor.Edit(id, text); err != nil { log.Error(errors.Wrap(err, "Failed to edit message")) } }() @@ -87,7 +87,7 @@ func (f *Field) SendMessage(data PresendMessage) { // Copy the sender to prevent race conditions. var sender = f.Sender gts.Async(func() (func(), error) { - if err := sender.SendMessage(data); err != nil { + if err := sender.Send(data); err != nil { return func() { onErr(err) }, errors.Wrap(err, "Failed to send message") } return nil, nil @@ -104,11 +104,13 @@ type SendMessageData struct { files []attachment.File } +var _ cchat.SendableMessage = (*SendMessageData)(nil) + type PresendMessage interface { cchat.MessageHeader // returns nonce and time cchat.SendableMessage - cchat.MessageNonce - cchat.SendableMessageAttachments + cchat.Noncer + cchat.Attachments // These methods are reserved for internal use. @@ -145,6 +147,10 @@ func (s SendMessageData) AuthorAvatarURL() string { return s.authorURL } +func (s SendMessageData) AsNoncer() cchat.Noncer { + return s +} + func (s SendMessageData) Nonce() string { return s.nonce } @@ -153,6 +159,10 @@ 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 { diff --git a/internal/ui/messages/input/username/username.go b/internal/ui/messages/input/username/username.go index 92e0fbe..638bfc8 100644 --- a/internal/ui/messages/input/username/username.go +++ b/internal/ui/messages/input/username/username.go @@ -82,7 +82,7 @@ func (u *Container) SetRevealChild(reveal bool) { // shouldReveal returns whether or not the container should reveal. func (u *Container) shouldReveal() bool { - return (!u.label.GetLabel().Empty() || u.avatar.URL() != "") && showUser + return (!u.label.GetLabel().IsEmpty() || u.avatar.URL() != "") && showUser } func (u *Container) Reset() { @@ -92,19 +92,19 @@ func (u *Container) Reset() { } // Update is not thread-safe. -func (u *Container) Update(session cchat.Session, sender cchat.ServerMessageSender) { +func (u *Container) Update(session cchat.Session, messenger cchat.Messenger) { // Set the fallback username. u.label.SetLabelUnsafe(session.Name()) // Reveal the name if it's not empty. u.SetRevealChild(true) - // Does sender (aka Server) implement ServerNickname? If yes, use it. - if nicknamer, ok := sender.(cchat.ServerNickname); ok { + // Does messenger implement Nicknamer? If yes, use it. + if nicknamer := messenger.AsNicknamer(); nicknamer != nil { u.label.AsyncSetLabel(nicknamer.Nickname, "Error fetching server nickname") } // Does session implement an icon? Update if yes. - if iconer, ok := session.(cchat.Icon); ok { + if iconer := session.AsIconer(); iconer != nil { u.avatar.AsyncSetIconer(iconer, "Error fetching session icon URL") } } diff --git a/internal/ui/messages/memberlist/memberlist.go b/internal/ui/messages/memberlist/memberlist.go index f62996e..a7849a3 100644 --- a/internal/ui/messages/memberlist/memberlist.go +++ b/internal/ui/messages/memberlist/memberlist.go @@ -93,9 +93,9 @@ func (c *Container) Reset() { // TryAsyncList tries to set the member list from the given server. It does type // assertions and handles asynchronicity. Reset must be called before this. -func (c *Container) TryAsyncList(server cchat.ServerMessage) { - ls, ok := server.(cchat.ServerMessageMemberLister) - if !ok { +func (c *Container) TryAsyncList(server cchat.Messenger) { + ls := server.AsMemberLister() + if ls == nil { return } @@ -109,7 +109,7 @@ func (c *Container) TryAsyncList(server cchat.ServerMessage) { }) } -func (c *Container) SetSections(sections []cchat.MemberListSection) { +func (c *Container) SetSections(sections []cchat.MemberSection) { gts.ExecAsync(func() { c.SetSectionsUnsafe(sections) }) } @@ -121,7 +121,7 @@ func (c *Container) RemoveMember(sectionID string, id string) { gts.ExecAsync(func() { c.RemoveMemberUnsafe(sectionID, id) }) } -func (c *Container) SetSectionsUnsafe(sections []cchat.MemberListSection) { +func (c *Container) SetSectionsUnsafe(sections []cchat.MemberSection) { var newSections = make([]*Section, len(sections)) for i, section := range sections { @@ -191,7 +191,7 @@ var sectionBodyCSS = primitives.PrepareClassCSS("section-body", ` } `) -func NewSection(sect cchat.MemberListSection) *Section { +func NewSection(sect cchat.MemberSection) *Section { header := rich.NewLabel(text.Rich{}) header.Show() sectionHeaderCSS(header) @@ -359,7 +359,7 @@ func NewMember(member cchat.ListMember) *Member { func (m *Member) Update(member cchat.ListMember) { m.ListBoxRow.SetName(member.Name().Content) - if iconer, ok := member.(cchat.Icon); ok { + if iconer := member.AsIconer(); iconer != nil { m.Avatar.AsyncSetIconer(iconer, "Failed to get member list icon") } @@ -370,7 +370,7 @@ func (m *Member) Update(member cchat.ListMember) { statusColors(member.Status()), m.output.Markup, )) - if bot := member.Secondary(); !bot.Empty() { + if bot := member.Secondary(); !bot.IsEmpty() { txt.WriteByte('\n') txt.WriteString(fmt.Sprintf( `%s`, @@ -392,15 +392,15 @@ func (m *Member) Popup() { } } -func statusColors(status cchat.UserStatus) uint32 { +func statusColors(status cchat.Status) uint32 { switch status { - case cchat.OnlineStatus: + case cchat.StatusOnline: return 0x43B581 - case cchat.BusyStatus: + case cchat.StatusBusy: return 0xF04747 - case cchat.IdleStatus: + case cchat.StatusIdle: return 0xFAA61A - case cchat.OfflineStatus: + case cchat.StatusOffline: fallthrough default: return 0x747F8D diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index b4fea25..b9ce639 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -21,7 +21,7 @@ type Container interface { AvatarURL() string // avatar Nonce() string - UpdateAuthor(cchat.MessageAuthor) + UpdateAuthor(cchat.Author) UpdateAuthorName(text.Rich) UpdateContent(c text.Rich, edited bool) UpdateTimestamp(time.Time) @@ -79,12 +79,9 @@ func NewContainer(msg cchat.MessageCreate) *GenericContainer { c := NewEmptyContainer() c.id = msg.ID() c.time = msg.Time() + c.nonce = msg.Nonce() c.authorID = msg.Author().ID() - if noncer, ok := msg.(cchat.MessageNonce); ok { - c.nonce = noncer.Nonce() - } - return c } @@ -180,14 +177,10 @@ func (m *GenericContainer) UpdateTimestamp(t time.Time) { m.Timestamp.SetTooltipText(t.Format(time.Stamp)) } -func (m *GenericContainer) UpdateAuthor(author cchat.MessageAuthor) { +func (m *GenericContainer) UpdateAuthor(author cchat.Author) { m.authorID = author.ID() + m.avatarURL = author.Avatar() m.UpdateAuthorName(author.Name()) - - // Set the avatar URL for future access on-demand. - if avatarer, ok := author.(cchat.MessageAuthorAvatar); ok { - m.avatarURL = avatarer.Avatar() - } } func (m *GenericContainer) UpdateAuthorName(name text.Rich) { diff --git a/internal/ui/messages/typing/state.go b/internal/ui/messages/typing/state.go index c817f4f..aa9213a 100644 --- a/internal/ui/messages/typing/state.go +++ b/internal/ui/messages/typing/state.go @@ -21,7 +21,7 @@ type State struct { stopper func() // stops the event loop, not used atm } -var _ cchat.TypingIndicator = (*State)(nil) +var _ cchat.TypingContainer = (*State)(nil) func NewState(changed func(s *State, empty bool)) *State { s := &State{changed: changed} @@ -41,7 +41,7 @@ func (s *State) reset() { } // Subscribe is thread-safe. -func (s *State) Subscribe(indicator cchat.ServerMessageTypingIndicator) { +func (s *State) Subscribe(indicator cchat.TypingIndicator) { gts.Async(func() (func(), error) { c, err := indicator.TypingSubscribe(s) if err != nil { diff --git a/internal/ui/messages/typing/typing.go b/internal/ui/messages/typing/typing.go index 5d4f6e7..75d1f8f 100644 --- a/internal/ui/messages/typing/typing.go +++ b/internal/ui/messages/typing/typing.go @@ -71,17 +71,17 @@ func (c *Container) Reset() { c.SetRevealChild(false) } -func (c *Container) RemoveAuthor(author cchat.MessageAuthor) { +func (c *Container) RemoveAuthor(author cchat.Author) { c.state.removeTyper(author.ID()) } -func (c *Container) TrySubscribe(svmsg cchat.ServerMessage) bool { - ti, ok := svmsg.(cchat.ServerMessageTypingIndicator) - if !ok { +func (c *Container) TrySubscribe(svmsg cchat.Messenger) bool { + var tindicator = svmsg.AsTypingIndicator() + if tindicator == nil { return false } - c.state.Subscribe(ti) + c.state.Subscribe(tindicator) return true } diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 018e083..7f79cf5 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -260,7 +260,7 @@ func (v *View) MemberListUpdated(c *memberlist.Container) { } // JoinServer is not thread-safe, but it calls backend functions asynchronously. -func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traverse.Breadcrumber) { +func (v *View) JoinServer(session cchat.Session, server cchat.Server, bc traverse.Breadcrumber) { // Reset before setting. v.Reset() @@ -268,21 +268,25 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traver v.FaceView.SetLoading() v.ctrl.OnMessageBusy() - // Bind the state. - v.state.bind(session, server) + // Get the messenger once. + var messenger = server.AsMessenger() + // Exit if this server is not a messenger. + if messenger == nil { + return + } + + // Bind the state. + v.state.bind(session, server, messenger) - // Skipping ok check because sender can be nil. Without the empty - // check, Go will panic. - sender, _ := server.(cchat.ServerMessageSender) // We're setting this variable before actually calling JoinServer. This is // because new messages created by JoinServer will use this state for things // such as determinining if it's deletable or not. - v.InputView.SetSender(session, sender) + v.InputView.SetMessenger(session, messenger) gts.Async(func() (func(), error) { // We can use a background context here, as the user can't go anywhere // that would require cancellation anyway. This is done in ui.go. - s, err := server.JoinServer(context.Background(), v.Container) + s, err := messenger.JoinServer(context.Background(), v.Container) if err != nil { err = errors.Wrap(err, "Failed to join server") // Even if we're erroring out, we're running the done() callback @@ -304,10 +308,10 @@ func (v *View) JoinServer(session cchat.Session, server ServerMessage, bc traver v.Header.SetBreadcrumber(bc) // Try setting the typing indicator if available. - v.Typing.TrySubscribe(server) + v.Typing.TrySubscribe(messenger) // Try and use the list. - v.MemberList.TryAsyncList(server) + v.MemberList.TryAsyncList(messenger) }, nil }) } @@ -338,7 +342,7 @@ func (v *View) FetchBacklog() { ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second) defer cancel() - err := backlogger.MessagesBefore(ctx, firstMsg.ID(), v.Container) + err := backlogger.Backlog(ctx, firstMsg.ID(), v.Container) return done, errors.Wrap(err, "Failed to get messages before ID") }) } @@ -361,7 +365,7 @@ func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) { } // AuthorEvent should be called on message create/update/delete. -func (v *View) AuthorEvent(author cchat.MessageAuthor) { +func (v *View) AuthorEvent(author cchat.Author) { // Remove the author from the typing list if it's not nil. if author != nil { v.Typing.RemoveAuthor(author) @@ -381,7 +385,7 @@ func (v *View) retryMessage(msg input.PresendMessage, presend container.PresendG } go func() { - if err := sender.SendMessage(msg); err != nil { + if err := sender.Send(msg); err != nil { // Set the message's state to errored again, but we don't need to // rebind the menu. gts.ExecAsync(func() { presend.SetSentError(err) }) @@ -404,7 +408,7 @@ func (v *View) BindMenu(msg container.GridMessage) { // Do we have any custom actions? If yes, append it. if v.hasActions() { - var actions = v.actioner.MessageActions(msg.ID()) + var actions = v.actioner.Actions(msg.ID()) var items = make([]menu.Item, len(actions)) for i, action := range actions { @@ -424,7 +428,7 @@ func (v *View) makeActionItem(action, msgID string) menu.Item { go func() { // Run, get the error, and try to log it. The logger will ignore nil // errors. - err := v.state.actioner.DoMessageAction(action, msgID) + err := v.state.actioner.Do(action, msgID) log.Error(errors.Wrap(err, "Failed to do action "+action)) }() }) @@ -433,15 +437,15 @@ func (v *View) makeActionItem(action, msgID string) menu.Item { // ServerMessage combines Server and ServerMessage from cchat. type ServerMessage interface { cchat.Server - cchat.ServerMessage + cchat.Messenger } type state struct { session cchat.Session server cchat.Server - actioner cchat.ServerMessageActioner - backlogger cchat.ServerMessageBacklogger + actioner cchat.Actioner + backlogger cchat.Backlogger current func() // stop callback author string @@ -483,7 +487,7 @@ const backloggingFreq = time.Second * 3 // Backlogger returns the backlogger instance if it's allowed to fetch more // backlogs. -func (s *state) Backlogger() cchat.ServerMessageBacklogger { +func (s *state) Backlogger() cchat.Backlogger { if s.backlogger == nil || s.current == nil { return nil } @@ -498,11 +502,11 @@ func (s *state) Backlogger() cchat.ServerMessageBacklogger { return s.backlogger } -func (s *state) bind(session cchat.Session, server ServerMessage) { +func (s *state) bind(session cchat.Session, server cchat.Server, msgr cchat.Messenger) { s.session = session s.server = server - s.actioner, _ = server.(cchat.ServerMessageActioner) - s.backlogger, _ = server.(cchat.ServerMessageBacklogger) + s.actioner = msgr.AsActioner() + s.backlogger = msgr.AsBacklogger() } func (s *state) setcurrent(fn func()) { diff --git a/internal/ui/primitives/completion/completer.go b/internal/ui/primitives/completion/completer.go index d642024..74f8ce2 100644 --- a/internal/ui/primitives/completion/completer.go +++ b/internal/ui/primitives/completion/completer.go @@ -1,34 +1,51 @@ package completion import ( + "fmt" + "log" + + "github.com/diamondburned/cchat" + "github.com/diamondburned/cchat-gtk/internal/gts/httputil" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput" + "github.com/diamondburned/cchat-gtk/internal/ui/rich" + "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/markup" + "github.com/diamondburned/cchat/text" "github.com/diamondburned/cchat/utils/split" + "github.com/diamondburned/imgutil" "github.com/gotk3/gotk3/gtk" ) -type Completeable interface { - Update([]string, int) []gtk.IWidget - Word(i int) string -} +const ( + ImageSmall = 25 + ImageLarge = 40 + ImagePadding = 6 +) + +// post-processor icon +var ppIcon = []imgutil.Processor{imgutil.Round(true)} type Completer struct { - ctrl Completeable - Input *gtk.TextView Buffer *gtk.TextBuffer List *gtk.ListBox Popover *gtk.Popover + popdown bool - Words []string - Index int - Cursor int + Splitter split.SplitFunc + + words []string + index int64 + cursor int64 + + entries []cchat.CompletionEntry + completer cchat.Completer } -func WrapCompleter(input *gtk.TextView, ctrl Completeable) { - NewCompleter(input, ctrl) +func WrapCompleter(input *gtk.TextView) { + NewCompleter(input) } -func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer { +func NewCompleter(input *gtk.TextView) *Completer { l, _ := gtk.ListBoxNew() l.Show() @@ -43,11 +60,11 @@ func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer { ibuf, _ := input.GetBuffer() c := &Completer{ - Input: input, - Buffer: ibuf, - List: l, - Popover: p, - ctrl: ctrl, + Input: input, + Buffer: ibuf, + List: l, + Popover: p, + Splitter: split.SpaceIndexed, } // This one is for buffer modification. @@ -56,17 +73,40 @@ func NewCompleter(input *gtk.TextView, ctrl Completeable) *Completer { input.Connect("move-cursor", c.onChange) l.Connect("row-activated", func(l *gtk.ListBox, r *gtk.ListBoxRow) { - SwapWord(ibuf, ctrl.Word(r.GetIndex()), c.Cursor) + SwapWord(ibuf, c.entries[r.GetIndex()].Raw, c.cursor) + c.onChange() // signal change c.Clear() - c.Hide() + c.Popdown() input.GrabFocus() }) return c } -func (c *Completer) Hide() { - c.Popover.Popdown() +// SetCompleter sets the current completer. If completer is nil, then the +// completer is disabled. +func (c *Completer) SetCompleter(completer cchat.Completer) { + c.Clear() + c.Popdown() + c.completer = completer +} + +func (c *Completer) Reset() { + c.SetCompleter(nil) +} + +func (c *Completer) Popup() { + if c.popdown { + c.Popover.Popup() + c.popdown = false + } +} + +func (c *Completer) Popdown() { + if !c.popdown { + c.Popover.Popdown() + c.popdown = true + } } func (c *Completer) Clear() { @@ -82,19 +122,36 @@ func (c *Completer) Clear() { }) } +// Words returns the buffer content split into words. +func (c *Completer) Content() []string { + // This method not to be confused with c.words, which contains the state of + // completer words. + + text, _ := c.Buffer.GetText(c.Buffer.GetStartIter(), c.Buffer.GetEndIter(), true) + if text == "" { + return nil + } + words, _ := c.Splitter(text, 0) + return words +} + func (c *Completer) onChange() { t, v, blank := State(c.Buffer) - c.Cursor = v + c.cursor = v - // If the curssor is on a blank character, then we should not + log.Println("STATE:", t, v, blank) + + // If the cursor is on a blank character, then we should not // autocomplete anything, so we set the states to nil. if blank { - c.Words = nil - c.Index = -1 - } else { - c.Words, c.Index = split.SpaceIndexed(t, v) + c.words = nil + c.index = -1 + log.Println("RESET INDEX TO -1") + return } + c.words, c.index = c.Splitter(t, v) + log.Println("INDEX:", c.index) c.complete() } @@ -102,15 +159,15 @@ func (c *Completer) complete() { c.Clear() var widgets []gtk.IWidget - if len(c.Words) > 0 { - widgets = c.ctrl.Update(c.Words, c.Index) + if len(c.words) > 0 { + widgets = c.update() } if len(widgets) > 0 { c.Popover.SetPointingTo(CursorRect(c.Input)) - c.Popover.Popup() + c.Popup() } else { - c.Hide() + c.Popdown() return } @@ -126,3 +183,67 @@ func (c *Completer) complete() { } } } + +func (c *Completer) update() []gtk.IWidget { + // If we don't have a completer, then don't run. + if c.completer == nil { + return nil + } + + c.entries = c.completer.Complete(c.words, c.index) + + var widgets = make([]gtk.IWidget, len(c.entries)) + + for i, entry := range c.entries { + // Container that holds the label. + lbox, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) + lbox.SetVAlign(gtk.ALIGN_CENTER) + lbox.Show() + + // Label for the primary text. + l := rich.NewLabel(entry.Text) + l.Show() + lbox.PackStart(l, false, false, 0) + + // Get the iamge size so we can change and use if needed. The default + var size = ImageSmall + if !entry.Secondary.IsEmpty() { + size = ImageLarge + + s := rich.NewLabel(text.Rich{}) + s.SetMarkup(fmt.Sprintf( + `%s`, + markup.Render(entry.Secondary), + )) + s.Show() + + lbox.PackStart(s, false, false, 0) + } + + b, _ := gtk.BoxNew(gtk.ORIENTATION_HORIZONTAL, 0) + b.PackEnd(lbox, true, true, ImagePadding) + b.Show() + + // Do we have an icon? + if entry.IconURL != "" { + img, _ := gtk.ImageNew() + img.SetMarginStart(ImagePadding) + img.SetSizeRequest(size, size) + img.Show() + + // Prepend the image into the box. + b.PackEnd(img, false, false, 0) + + var pps []imgutil.Processor + if !entry.Image { + pps = ppIcon + } + + httputil.AsyncImageSized(img, entry.IconURL, size, size, pps...) + } + + widgets[i] = b + } + + return widgets +} diff --git a/internal/ui/primitives/completion/utils.go b/internal/ui/primitives/completion/utils.go index f0c5b9b..13f60bb 100644 --- a/internal/ui/primitives/completion/utils.go +++ b/internal/ui/primitives/completion/utils.go @@ -78,7 +78,7 @@ func KeyDownHandler(l *gtk.ListBox, focus func()) KeyDownHandlerFn { } } -func SwapWord(b *gtk.TextBuffer, word string, offset int) { +func SwapWord(b *gtk.TextBuffer, word string, offset int64) { // Get iter for word replacing. start, end := GetWordIters(b, offset) b.Delete(start, end) @@ -93,7 +93,7 @@ func CursorRect(i *gtk.TextView) gdk.Rectangle { return *r } -func State(buf *gtk.TextBuffer) (text string, offset int, blank bool) { +func State(buf *gtk.TextBuffer) (text string, offset int64, blank bool) { // obtain current state mark := buf.GetInsert() iter := buf.GetIterAtMark(mark) @@ -102,7 +102,7 @@ func State(buf *gtk.TextBuffer) (text string, offset int, blank bool) { start, end := buf.GetBounds() text, _ = buf.GetText(start, end, true) - offset = iter.GetOffset() + offset = int64(iter.GetOffset()) // We need the rune before the cursor. iter.BackwardChar() @@ -118,8 +118,8 @@ const searchFlags = 0 | gtk.TEXT_SEARCH_TEXT_ONLY | gtk.TEXT_SEARCH_VISIBLE_ONLY -func GetWordIters(buf *gtk.TextBuffer, offset int) (start, end *gtk.TextIter) { - iter := buf.GetIterAtOffset(offset) +func GetWordIters(buf *gtk.TextBuffer, offset int64) (start, end *gtk.TextIter) { + iter := buf.GetIterAtOffset(int(offset)) var ok bool diff --git a/internal/ui/rich/image.go b/internal/ui/rich/image.go index 5e31553..472d146 100644 --- a/internal/ui/rich/image.go +++ b/internal/ui/rich/image.go @@ -134,7 +134,7 @@ func (i *Icon) SetIcon(url string) { gts.ExecAsync(func() { i.SetIconUnsafe(url) }) } -func (i *Icon) AsyncSetIconer(iconer cchat.Icon, errwrap string) { +func (i *Icon) AsyncSetIconer(iconer cchat.Iconer, errwrap string) { // Reveal to show the placeholder. i.SetRevealChild(true) diff --git a/internal/ui/rich/labeluri/labeluri.go b/internal/ui/rich/labeluri/labeluri.go index 86a96ed..bdc82f1 100644 --- a/internal/ui/rich/labeluri/labeluri.go +++ b/internal/ui/rich/labeluri/labeluri.go @@ -90,8 +90,8 @@ func BindRichLabel(label Labeler) { bind(label, func(uri string, ptr gdk.Rectangle) bool { var output = label.Output() - if mention := output.IsMention(uri); mention != nil { - if p := NewPopoverMentioner(label, output.Input, mention); p != nil { + if segment := output.IsMention(uri); segment != nil { + if p := NewPopoverMentioner(label, output.Input, segment); p != nil { p.SetPointingTo(ptr) p.Popup() } @@ -103,19 +103,24 @@ func BindRichLabel(label Labeler) { }) } -func PopoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) { +func PopoverMentioner(rel gtk.IWidget, input string, mention text.Segment) { if p := NewPopoverMentioner(rel, input, mention); p != nil { p.Popup() } } -func NewPopoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) *gtk.Popover { - var info = mention.MentionInfo() - if info.Empty() { +func NewPopoverMentioner(rel gtk.IWidget, input string, segment text.Segment) *gtk.Popover { + var mention = segment.AsMentioner() + if mention == nil { return nil } - start, end := mention.Bounds() + var info = mention.MentionInfo() + if info.IsEmpty() { + return nil + } + + start, end := segment.Bounds() h := input[start:end] box, _ := gtk.BoxNew(gtk.ORIENTATION_VERTICAL, 0) @@ -125,12 +130,11 @@ func NewPopoverMentioner(rel gtk.IWidget, input string, mention text.Mentioner) var url string var round bool - switch v := mention.(type) { - case text.MentionerImage: - url = v.Image() - case text.MentionerAvatar: - url = v.Avatar() + if avatarer := segment.AsAvatarer(); avatarer != nil { + url = avatarer.Avatar() round = true + } else if imager := segment.AsImager(); imager != nil { + url = imager.Image() } if url != "" { diff --git a/internal/ui/rich/parser/hl/hl.go b/internal/ui/rich/parser/hl/hl.go index c60803c..893a085 100644 --- a/internal/ui/rich/parser/hl/hl.go +++ b/internal/ui/rich/parser/hl/hl.go @@ -10,7 +10,6 @@ import ( "github.com/alecthomas/chroma/styles" "github.com/diamondburned/cchat-gtk/internal/ui/config" "github.com/diamondburned/cchat-gtk/internal/ui/rich/parser/attrmap" - "github.com/diamondburned/cchat/text" ) var ( @@ -40,15 +39,14 @@ func Tokenize(language, source string) chroma.Iterator { return i } -func Segments(appendmap *attrmap.AppendMap, src string, seg text.Codeblocker) { - var start, end = seg.Bounds() +func Segments(appendmap *attrmap.AppendMap, src string, start, end int, lang string) { appendmap.Span( start, end, `font_family="monospace"`, `insert_hyphens="false"`, // all my homies hate hyphens ) - if i := Tokenize(seg.CodeblockLanguage(), src[start:end]); i != nil { + if i := Tokenize(lang, src[start:end]); i != nil { fmtter.segments(appendmap, start, i) } } diff --git a/internal/ui/rich/parser/markup/markup.go b/internal/ui/rich/parser/markup/markup.go index ec30757..5a7baef 100644 --- a/internal/ui/rich/parser/markup/markup.go +++ b/internal/ui/rich/parser/markup/markup.go @@ -25,14 +25,20 @@ func hyphenate(text string) string { type RenderOutput struct { Markup string Input string // useless to keep parts, as Go will keep all alive anyway - Mentions []text.Mentioner + Mentions []MentionSegment +} + +// MentionSegment is a type that satisfies both Segment and Mentioner. +type MentionSegment struct { + text.Segment + text.Mentioner } // f_Mention is used to print and parse mention URIs. const f_Mention = "cchat://mention/%d" // %d == Mentions[i] // IsMention returns the mention if the URI is correct, or nil if none. -func (r RenderOutput) IsMention(uri string) text.Mentioner { +func (r RenderOutput) IsMention(uri string) text.Segment { var i int if _, err := fmt.Sscanf(uri, f_Mention, &i); err != nil { @@ -98,34 +104,36 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { var appended = attrmap.NewAppendedMap() // map to store mentions - var mentions []text.Mentioner + var mentions []MentionSegment // Parse all segments. for _, segment := range content.Segments { start, end := segment.Bounds() - if segment, ok := segment.(text.Linker); ok { - appended.Anchor(start, end, segment.Link()) + if linker := segment.AsLinker(); linker != nil { + appended.Anchor(start, end, linker.Link()) } - if segment, ok := segment.(text.Imager); ok { - // Ends don't matter with images. - appended.Open(start, composeImageMarkup(segment)) + // Only inline images if start == end per specification. + if start == end { + if imager := segment.AsImager(); imager != nil { + appended.Open(start, composeImageMarkup(imager)) + } + + if avatarer := segment.AsAvatarer(); avatarer != nil { + // Ends don't matter with images. + appended.Open(start, composeAvatarMarkup(avatarer)) + } } - if segment, ok := segment.(text.Avatarer); ok { - // Ends don't matter with images. - appended.Open(start, composeAvatarMarkup(segment)) - } - - if segment, ok := segment.(text.Colorer); ok { - appended.Span(start, end, fmt.Sprintf("color=\"#%06X\"", segment.Color())) + if colorer := segment.AsColorer(); colorer != nil { + appended.Span(start, end, colorAttrs(colorer.Color(), false)...) } // Mentioner needs to be before colorer, as we'd want the below color // segment to also highlight the full mention as well as make the // padding part of the hyperlink. - if segment, ok := segment.(text.Mentioner); ok { + if mentioner := segment.AsMentioner(); mentioner != nil { // Render the mention into "cchat://mention:0" or such. Other // components will take care of showing the information. if !cfg.NoMentionLinks { @@ -133,32 +141,35 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { } // Add the mention segment into the list regardless of hyperlinks. - mentions = append(mentions, segment) + mentions = append(mentions, MentionSegment{ + Segment: segment, + Mentioner: mentioner, + }) - if segment, ok := segment.(text.Colorer); ok { - // Add a dimmed background highlight and pad the button-like - // link. - appended.Span( - start, end, - "bgalpha=\"10%\"", - fmt.Sprintf("bgcolor=\"#%06X\"", segment.Color()), - ) - appended.Pad(start, end) + if colorer := segment.AsColorer(); colorer != nil { + // Only pad the name and add a dimmed background if the bounds + // do not cover the whole segment. + var cover = (start == 0) && (end == len(content.Content)) + appended.Span(start, end, colorAttrs(colorer.Color(), !cover)...) + if !cover { + appended.Pad(start, end) + } } } - if segment, ok := segment.(text.Attributor); ok { - appended.Span(start, end, markupAttr(segment.Attribute())) + if attributor := segment.AsAttributor(); attributor != nil { + appended.Span(start, end, markupAttr(attributor.Attribute())) } - if segment, ok := segment.(text.Codeblocker); ok { + if codeblocker := segment.AsCodeblocker(); codeblocker != nil { + start, end := segment.Bounds() // Syntax highlight the codeblock. - hl.Segments(&appended, content.Content, segment) + hl.Segments(&appended, content.Content, start, end, codeblocker.CodeblockLanguage()) } // TODO: make this not shit. Maybe make it somehow not rely on green // arrows. Or maybe. - if _, ok := segment.(text.Quoteblocker); ok { + if segment.AsQuoteblocker() != nil { appended.Span(start, end, `color="#789922"`) } } @@ -181,19 +192,37 @@ func RenderCmplxWithConfig(content text.Rich, cfg RenderConfig) RenderOutput { } } -func color(c uint32, bg bool) []string { - var hex = fmt.Sprintf("#%06X", c) +// splitRGBA splits the given rgba integer into rgb and a. +func splitRGBA(rgba uint32) (rgb, a uint32) { + rgb = rgba >> 8 // extract the RGB bits + a = rgba & 0xFF // extract the A bits + return +} - var attrs = []string{ - fmt.Sprintf(`color="%s"`, hex), +// colorAttrs renders the given color into a list of attributes. +func colorAttrs(c uint32, bg bool) []string { + // Split the RGBA color value to calculate. + rgb, a := splitRGBA(c) + + // Render the hex representation beforehand. + hex := fmt.Sprintf("#%06X", rgb) + + attrs := make([]string, 1, 4) + attrs[0] = fmt.Sprintf(`color="%s"`, hex) + + // If we have an alpha that isn't solid (100%), then write it. + if a < 0xFF { + // Calculate alpha percentage. + perc := a * 100 / 255 + attrs = append(attrs, fmt.Sprintf(`fgalpha="%d%%"`, perc)) } + // Draw a faded background if we explicitly requested for one. if bg { - attrs = append( - attrs, - `bgalpha="10%"`, - fmt.Sprintf(`bgcolor="%s"`, hex), - ) + // Calculate how faded the background should be for visual purposes. + perc := a * 10 / 255 // always 10% or less. + attrs = append(attrs, fmt.Sprintf(`bgalpha="%d%%"`, perc)) + attrs = append(attrs, fmt.Sprintf(`bgcolor="%s"`, hex)) } return attrs @@ -274,25 +303,25 @@ func markupAttr(attr text.Attribute) string { } var attrs = make([]string, 0, 1) - if attr.Has(text.AttrBold) { + if attr.Has(text.AttributeBold) { attrs = append(attrs, `weight="bold"`) } - if attr.Has(text.AttrItalics) { + if attr.Has(text.AttributeItalics) { attrs = append(attrs, `style="italic"`) } - if attr.Has(text.AttrUnderline) { + if attr.Has(text.AttributeUnderline) { attrs = append(attrs, `underline="single"`) } - if attr.Has(text.AttrStrikethrough) { + if attr.Has(text.AttributeStrikethrough) { attrs = append(attrs, `strikethrough="true"`) } - if attr.Has(text.AttrSpoiler) { + if attr.Has(text.AttributeSpoiler) { attrs = append(attrs, `alpha="35%"`) // no fancy click here } - if attr.Has(text.AttrMonospace) { + if attr.Has(text.AttributeMonospace) { attrs = append(attrs, `font_family="monospace"`) } - if attr.Has(text.AttrDimmed) { + if attr.Has(text.AttributeDimmed) { attrs = append(attrs, `alpha="35%"`) } diff --git a/internal/ui/service/list.go b/internal/ui/service/list.go index b50cf33..790e987 100644 --- a/internal/ui/service/list.go +++ b/internal/ui/service/list.go @@ -10,7 +10,7 @@ import ( ) type ViewController interface { - RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage) + MessengerSelected(*session.Row, *server.ServerRow) SessionSelected(*Service, *session.Row) AuthenticateSession(*List, *Service) OnSessionRemove(*Service, *session.Row) diff --git a/internal/ui/service/savepath/savepath.go b/internal/ui/service/savepath/savepath.go index 7e72a54..f29db90 100644 --- a/internal/ui/service/savepath/savepath.go +++ b/internal/ui/service/savepath/savepath.go @@ -91,18 +91,22 @@ func Save() { } func Update(b traverse.Breadcrumber, expanded bool) { - var path = traverse.TryID(b) - var node = paths + if expanded { + var path = traverse.TryID(b) + var node = paths - // Descend and initialize. - for i := 0; i < len(path); i++ { - ch, ok := node[path[i]] - if !ok { - ch = make(pathMap) - node[path[i]] = ch + // Descend and initialize. + for i := 0; i < len(path); i++ { + ch, ok := node[path[i]] + if !ok { + ch = make(pathMap) + node[path[i]] = ch + } + + node = ch } + } else { - node = ch } Save() diff --git a/internal/ui/service/service.go b/internal/ui/service/service.go index 635970c..8b36b41 100644 --- a/internal/ui/service/service.go +++ b/internal/ui/service/service.go @@ -20,8 +20,8 @@ import ( const IconSize = 48 type ListController interface { - // RowSelected is called when a server message row is clicked. - RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage) + // MessengerSelected is called when a server message row is clicked. + MessengerSelected(*session.Row, *server.ServerRow) // SessionSelected tells the view to change the session view. SessionSelected(*Service, *session.Row) // AuthenticateSession tells View to call to the parent's authenticator. @@ -109,7 +109,7 @@ func NewService(svc cchat.Service, svclctrl ListController) *Service { service.Icon.SetTooltipMarkup(markup.Render(svc.Name())) serviceIconCSS(service.Icon) - if iconer, ok := svc.(cchat.Icon); ok { + if iconer := svc.AsIconer(); iconer != nil { service.Icon.AsyncSetIconer(iconer, "Failed to set service icon") } @@ -156,6 +156,10 @@ func (s *Service) AuthenticateSession() { } func (s *Service) AddLoadingSession(id, name string) *session.Row { + if srow := s.BodyList.Session(id); srow != nil { + return srow + } + srow := session.NewLoading(s, id, name, s) srow.Show() @@ -163,7 +167,13 @@ func (s *Service) AddLoadingSession(id, name string) *session.Row { return srow } +// AddSession adds the given session. It returns nil if the session already +// exists with the given ID. func (s *Service) AddSession(ses cchat.Session) *session.Row { + if srow := s.BodyList.Session(ses.ID()); srow != nil { + return srow + } + srow := session.New(s, ses, s) srow.Show() @@ -187,19 +197,15 @@ func (s *Service) OnSessionDisconnect(row *session.Row) { } s.svclctrl.OnSessionDisconnect(s, row) - - // WHY WAS THIS HERE?!?!?! - // s.BodyList.RemoveSessionRow(row.Session.ID()) - // s.SaveAllSessions() } -func (s *Service) RowSelected(r *session.Row, sv *server.ServerRow, m cchat.ServerMessage) { - s.svclctrl.RowSelected(r, sv, m) +func (s *Service) MessengerSelected(r *session.Row, sv *server.ServerRow) { + s.svclctrl.MessengerSelected(r, sv) } func (s *Service) RemoveSession(row *session.Row) { s.svclctrl.OnSessionRemove(s, row) - s.BodyList.RemoveSessionRow(row.Session.ID()) + s.BodyList.RemoveSessionRow(row.ID()) s.SaveAllSessions() } @@ -230,13 +236,13 @@ func (s *Service) SaveAllSessions() { } func (s *Service) RestoreSession(row *session.Row, id string) { - rs, ok := s.service.(cchat.SessionRestorer) - if !ok { + rs := s.service.AsSessionRestorer() + if rs == nil { return } if k := keyring.RestoreSession(s.service, id); k != nil { - restoreAsync(row, rs, *k) + row.RestoreSession(rs, *k) return } @@ -248,19 +254,14 @@ func (s *Service) RestoreSession(row *session.Row, id string) { // restoreAll restores all sessions. func (s *Service) restoreAll() { - rs, ok := s.service.(cchat.SessionRestorer) - if !ok { + rs := s.service.AsSessionRestorer() + if rs == nil { return } // Session is not a pointer, so we can pass it into arguments safely. for _, ses := range keyring.RestoreSessions(s.service) { row := s.AddLoadingSession(ses.ID, ses.Name) - restoreAsync(row, rs, ses) + row.RestoreSession(rs, ses) } } - -// restoreAsync asynchronously restores a single session. -func restoreAsync(r *session.Row, res cchat.SessionRestorer, k keyring.Session) { - r.RestoreSession(res, k) -} diff --git a/internal/ui/service/session/list.go b/internal/ui/service/session/list.go index 9ddca2d..7ef19c1 100644 --- a/internal/ui/service/session/list.go +++ b/internal/ui/service/session/list.go @@ -84,7 +84,20 @@ func (sl *List) Sessions() []*Row { return rows } +// Session returns the session row with the given ID. A nil Row is returned if +// none is found. +func (sl *List) Session(id string) *Row { + row, _ := sl.sessions[id] + return row +} + +// AddSessionRow adds the given row as a session into the list. func (sl *List) AddSessionRow(id string, row *Row) { + // !!! IMPORTANT !!! Guarantee that there is NO collision. + if _, ok := sl.sessions[id]; ok { + panic("BUG: Duplicate session; AddSessionRow caller did not check Session.") + } + // Insert the row RIGHT BEFORE the add button. sl.ListBox.Insert(row, len(sl.sessions)) // Set the map, which increases the length by 1. diff --git a/internal/ui/service/session/server/children.go b/internal/ui/service/session/server/children.go index 59f433b..fccc6ab 100644 --- a/internal/ui/service/session/server/children.go +++ b/internal/ui/service/session/server/children.go @@ -11,7 +11,7 @@ import ( ) type Controller interface { - RowSelected(*ServerRow, cchat.ServerMessage) + MessengerSelected(*ServerRow) } // Children is a children server with a reference to the parent. By default, a @@ -170,6 +170,56 @@ func (c *Children) SetServers(servers []cchat.Server) { }) } +func (c *Children) findID(id cchat.ID) (int, *ServerRow) { + for i, row := range c.Rows { + if row.Server.ID() == id { + return i, row + } + } + return -1, nil +} + +func (c *Children) insertAt(row *ServerRow, i int) { + c.Rows = append(c.Rows[:i], append([]*ServerRow{row}, c.Rows[i:]...)...) + + if !c.IsHollow() { + c.Box.Add(row) + c.Box.ReorderChild(row, i) + } +} + +func (c *Children) UpdateServer(update cchat.ServerUpdate) { + gts.ExecAsync(func() { + prevID, replace := update.PreviousID() + + // TODO: I don't think this code unhollows a new server. + var newServer = NewHollowServer(c, update, c.rowctrl) + var i, oldRow = c.findID(prevID) + + // If we're appending a new row, then replace is false. + if !replace { + // Increment the old row's index so we know where to insert. + c.insertAt(newServer, i+1) + return + } + + // Only update the server if the old row was found. + if oldRow == nil { + return + } + + c.Rows[i] = newServer + + if !c.IsHollow() { + // Update the UI as well. + // TODO: check if this reorder is correct. + c.Box.Remove(oldRow) + c.Box.Add(newServer) + c.Box.ReorderChild(newServer, i) + } + }) +} + // LoadAll forces all children rows to be unhollowed (initialized). It does // NOT check if the children container itself is hollow. func (c *Children) LoadAll() { diff --git a/internal/ui/service/session/commander/buffer.go b/internal/ui/service/session/server/commander/buffer.go similarity index 62% rename from internal/ui/service/session/commander/buffer.go rename to internal/ui/service/session/server/commander/buffer.go index 3fffded..abf90ba 100644 --- a/internal/ui/service/session/commander/buffer.go +++ b/internal/ui/service/session/server/commander/buffer.go @@ -1,27 +1,23 @@ package commander import ( + "bytes" "fmt" "github.com/diamondburned/cchat" - "github.com/diamondburned/cchat-gtk/internal/gts" "github.com/gotk3/gotk3/gtk" ) -type SessionCommander interface { - cchat.Session - cchat.Commander -} - +// Buffer represents an unbuffered API around the text buffer. type Buffer struct { *gtk.TextBuffer - svcname string - cmder SessionCommander + name string + cmder cchat.Commander } // NewBuffer creates a new buffer with the given SessionCommander, or returns // nil if cmder is nil. -func NewBuffer(svc cchat.Service, cmder SessionCommander) *Buffer { +func NewBuffer(name string, cmder cchat.Commander) *Buffer { if cmder == nil { return nil } @@ -33,7 +29,7 @@ func NewBuffer(svc cchat.Service, cmder SessionCommander) *Buffer { b.CreateTag("system", map[string]interface{}{ "foreground": "#808080", }) - return &Buffer{b, svc.Name().Content, cmder} + return &Buffer{b, name, cmder} } // WriteError is not thread-safe. @@ -46,15 +42,19 @@ func (b *Buffer) WriteSystem(bytes []byte) { b.InsertWithTagByName(b.GetEndIter(), string(bytes), "system") } -// Printlnf is not thread-safe. -func (b *Buffer) Printlnf(f string, v ...interface{}) { +// Systemlnf is not thread-safe. +func (b *Buffer) Systemlnf(f string, v ...interface{}) { b.WriteSystem([]byte(fmt.Sprintf(f+"\n", v...))) } -// Write is thread-safe. -func (b *Buffer) Write(bytes []byte) (int, error) { - gts.ExecAsync(func() { b.Insert(b.GetEndIter(), string(bytes)) }) - return len(bytes), nil +func (b *Buffer) WriteOutput(output []byte) { + var iter = b.GetEndIter() + + b.Insert(iter, string(output)) + + if !bytes.HasSuffix(output, []byte("\n")) { + b.Insert(iter, "\n") + } } func (b *Buffer) ShowDialog() { diff --git a/internal/ui/service/session/commander/commander.go b/internal/ui/service/session/server/commander/commander.go similarity index 60% rename from internal/ui/service/session/commander/commander.go rename to internal/ui/service/session/server/commander/commander.go index 86d5bbb..80b719f 100644 --- a/internal/ui/service/session/commander/commander.go +++ b/internal/ui/service/session/server/commander/commander.go @@ -1,8 +1,6 @@ package commander import ( - "fmt" - "io" "time" "github.com/diamondburned/cchat" @@ -11,10 +9,9 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/autoscroll" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/completion" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/scrollinput" + "github.com/diamondburned/cchat/utils/split" "github.com/gotk3/gotk3/gdk" "github.com/gotk3/gotk3/gtk" - "github.com/gotk3/gotk3/pango" - "github.com/pkg/errors" ) var monospace = primitives.PrepareCSS(` @@ -24,16 +21,12 @@ var monospace = primitives.PrepareCSS(` } `) -var commandPadding = primitives.PrepareCSS(` - * { padding: 8px 12px; } -`) - type Session struct { *gtk.Box cmder cchat.Commander + cmplt *completion.Completer buffer *Buffer - cmplt *completer inputbuf *gtk.TextBuffer @@ -42,14 +35,11 @@ type Session struct { } func SpawnDialog(buf *Buffer) { - s := NewSession(buf.cmder, buf) + s := NewSession(buf) s.Show() h, _ := gtk.HeaderBarNew() - h.SetTitle(fmt.Sprintf( - "Commander: %s on %s", - buf.cmder.Name().Content, buf.svcname, - )) + h.SetTitle("Commander: " + buf.name) h.SetShowCloseButton(true) h.Show() @@ -60,12 +50,13 @@ func SpawnDialog(buf *Buffer) { d.Show() } -func NewSession(cmder cchat.Commander, buf *Buffer) *Session { +func NewSession(buf *Buffer) *Session { view, _ := gtk.TextViewNewWithBuffer(buf.TextBuffer) view.SetEditable(false) view.SetProperty("monospace", true) view.SetPixelsAboveLines(1) view.SetWrapMode(gtk.WRAP_WORD_CHAR) + view.SetBorderWidth(8) view.Show() scroll := autoscroll.NewScrolledWindow() @@ -75,6 +66,7 @@ func NewSession(cmder cchat.Commander, buf *Buffer) *Session { input, _ := gtk.TextViewNew() input.SetSizeRequest(-1, 35) // magic height 35px + input.SetBorderWidth(8) primitives.AttachCSS(input, monospace) input.Show() @@ -91,11 +83,15 @@ func NewSession(cmder cchat.Commander, buf *Buffer) *Session { b.PackStart(sep, false, false, 0) b.PackStart(inputscroll, false, false, 0) + completer := completion.NewCompleter(input) + completer.Splitter = split.ArgsIndexed + completer.SetCompleter(buf.cmder.AsCompleter()) + session := &Session{ Box: b, - cmder: cmder, + cmder: buf.cmder, + cmplt: completer, buffer: buf, - cmplt: newCompleter(input, cmder), inputbuf: inputbuf, } @@ -105,8 +101,6 @@ func NewSession(cmder cchat.Commander, buf *Buffer) *Session { primitives.AddClass(b, "commander") primitives.AddClass(view, "command-buffer") primitives.AddClass(input, "command-input") - primitives.AttachCSS(view, commandPadding) - primitives.AttachCSS(input, commandPadding) return session } @@ -117,14 +111,10 @@ func (s *Session) inputActivate(v *gtk.TextView, ev *gdk.Event) bool { return false } + // Get the slice of words. + var words = s.cmplt.Content() // If the input is empty, then ignore. - if len(s.cmplt.Words) == 0 { - return true - } - - r, err := s.cmder.RunCommand(s.cmplt.Words) - if err != nil { - s.buffer.WriteError(err) + if len(words) == 0 { return true } @@ -132,19 +122,22 @@ func (s *Session) inputActivate(v *gtk.TextView, ev *gdk.Event) bool { s.inputbuf.Delete(s.inputbuf.GetBounds()) var then = time.Now() - s.buffer.Printlnf("%s: Running command...", then.Format(time.Kitchen)) + s.buffer.Systemlnf("%s > %q", then.Format(time.Kitchen), words) go func() { - _, err := io.Copy(s.buffer, r) - r.Close() + out, err := s.cmder.Run(words) gts.ExecAsync(func() { + if out != nil { + s.buffer.WriteOutput(out) + } + if err != nil { - s.buffer.WriteError(errors.Wrap(err, "Internal error")) + s.buffer.WriteError(err) } var now = time.Now() - s.buffer.Printlnf( + s.buffer.Systemlnf( "%s: Finished running command, took %s.", now.Format(time.Kitchen), now.Sub(then).String(), @@ -154,47 +147,3 @@ func (s *Session) inputActivate(v *gtk.TextView, ev *gdk.Event) bool { return true } - -type completer struct { - *completion.Completer - - completer cchat.CommandCompleter - choices []string -} - -func newCompleter(input *gtk.TextView, v cchat.Commander) *completer { - completer := &completer{} - completer.Completer = completion.NewCompleter(input, completer) - - c, ok := v.(cchat.CommandCompleter) - if ok { - completer.completer = c - } - - return completer -} - -func (c *completer) Update(words []string, offset int) []gtk.IWidget { - if c.completer == nil { - return nil - } - - c.choices = c.completer.CompleteCommand(words, offset) - var widgets = make([]gtk.IWidget, 0, len(c.choices)) - - for _, choice := range c.choices { - l, _ := gtk.LabelNew(choice) - l.SetXAlign(0) - l.SetEllipsize(pango.ELLIPSIZE_END) - primitives.AttachCSS(l, monospace) - l.Show() - - widgets = append(widgets, l) - } - - return widgets -} - -func (c *completer) Word(i int) string { - return c.choices[i] -} diff --git a/internal/ui/service/session/server/server.go b/internal/ui/service/session/server/server.go index 41dd0c4..d6f9d39 100644 --- a/internal/ui/service/session/server/server.go +++ b/internal/ui/service/session/server/server.go @@ -3,14 +3,18 @@ package server import ( "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts" + "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/menu" "github.com/diamondburned/cchat-gtk/internal/ui/primitives/roundimage" "github.com/diamondburned/cchat-gtk/internal/ui/rich" "github.com/diamondburned/cchat-gtk/internal/ui/service/savepath" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button" + "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" ) @@ -26,20 +30,23 @@ func AssertUnhollow(hollower interface{ IsHollow() bool }) { type ServerRow struct { *gtk.Box - Avatar *roundimage.Avatar - Button *button.ToggleButtonImage + Avatar *roundimage.Avatar + Button *button.ToggleButtonImage + ActionsMenu *actions.Menu + + Server cchat.Server + ctrl Controller parentcrumb traverse.Breadcrumber + cmder *commander.Buffer + // non-nil if server list and the function returns error childrenErr error childrev *gtk.Revealer children *Children - serverList cchat.ServerList - - ctrl Controller - Server cchat.Server + serverList cchat.Lister // State that's updated even when stale. Initializations will use these. unread bool @@ -68,13 +75,18 @@ func NewHollowServer(p traverse.Breadcrumber, sv cchat.Server, ctrl Controller) cancelUnread: func() {}, } - switch sv := sv.(type) { - case cchat.ServerList: - serverRow.SetHollowServerList(sv, ctrl) + var ( + lister = sv.AsLister() + messenger = sv.AsMessenger() + ) + + switch { + case lister != nil: + serverRow.SetHollowServerList(lister, ctrl) serverRow.children.SetUnreadHandler(serverRow.SetUnreadUnsafe) - case cchat.ServerMessage: - if unreader, ok := sv.(cchat.ServerMessageUnreadIndicator); ok { + case messenger != nil: + if unreader := messenger.AsUnreadIndicator(); unreader != nil { gts.Async(func() (func(), error) { c, err := unreader.UnreadIndicate(serverRow) if err != nil { @@ -125,8 +137,29 @@ func (r *ServerRow) Init() { // Restore the read state. r.Button.SetUnreadUnsafe(r.unread, r.mentioned) // update with state - switch server := r.Server.(type) { - case cchat.ServerList: + // Make the Actions menu. + r.ActionsMenu = actions.NewMenu("server") + r.ActionsMenu.InsertActionGroup(r) + + if cmder := r.Server.AsCommander(); cmder != nil { + r.cmder = commander.NewBuffer(r.Server.Name().String(), cmder) + r.ActionsMenu.AddAction("Command Prompt", r.cmder.ShowDialog) + } + + // Bind right clicks and show a popover menu on such event. + r.Button.Connect("button-press-event", func(_ gtk.IWidget, ev *gdk.Event) { + if gts.EventIsRightClick(ev) { + r.ActionsMenu.Popover(r).Popup() + } + }) + + var ( + lister = r.Server.AsLister() + messenger = r.Server.AsMessenger() + ) + + switch { + case lister != nil: primitives.AddClass(r, "server-list") r.children.Init() r.children.Show() @@ -139,9 +172,9 @@ func (r *ServerRow) Init() { r.Box.PackStart(r.childrev, false, false, 0) r.Button.SetClicked(r.SetRevealChild) - case cchat.ServerMessage: + case messenger != nil: primitives.AddClass(r, "server-message") - r.Button.SetClicked(func(bool) { r.ctrl.RowSelected(r, server) }) + r.Button.SetClicked(func(bool) { r.ctrl.MessengerSelected(r) }) } } @@ -188,7 +221,7 @@ func (r *ServerRow) IsHollow() bool { // SetHollowServerList sets the row to a hollow server list (children) and // recursively create -func (r *ServerRow) SetHollowServerList(list cchat.ServerList, ctrl Controller) { +func (r *ServerRow) SetHollowServerList(list cchat.Lister, ctrl Controller) { r.serverList = list r.children = NewHollowChildren(r, ctrl) @@ -196,6 +229,9 @@ func (r *ServerRow) SetHollowServerList(list cchat.ServerList, ctrl Controller) go func() { var err = list.Servers(r.children) + if err != nil { + log.Error(errors.Wrap(err, "Failed to get servers")) + } gts.ExecAsync(func() { // Announce that we're not loading anymore. @@ -224,6 +260,7 @@ func (r *ServerRow) Reset() { } // Reset the state. + r.ActionsMenu.Reset() r.serverList = nil r.children = nil } @@ -279,10 +316,11 @@ func (r *ServerRow) SetLabelUnsafe(name text.Rich) { r.Avatar.SetText(name.Content) } -func (r *ServerRow) SetIconer(v interface{}) { +// SetIconer takes in a Namer for AsIconer. +func (r *ServerRow) SetIconer(v cchat.Namer) { AssertUnhollow(r) - if iconer, ok := v.(cchat.Icon); ok { + if iconer := v.AsIconer(); iconer != nil { r.Button.Image.SetSize(IconSize) r.Button.Image.AsyncSetIconer(iconer, "Error getting server icon URL") } diff --git a/internal/ui/service/session/servers.go b/internal/ui/service/session/servers.go index a6e4789..d404b89 100644 --- a/internal/ui/service/session/servers.go +++ b/internal/ui/service/session/servers.go @@ -25,7 +25,7 @@ type Servers struct { spinner *spinner.Boxed // non-nil if loading. // state - ServerList cchat.ServerList + ServerList cchat.Lister } var toplevelCSS = primitives.PrepareClassCSS("top-level", ` @@ -72,7 +72,7 @@ func (s *Servers) IsLoading() bool { // SetList indicates that the server list has been loaded. Unlike // server.Children, this method will load immediately. -func (s *Servers) SetList(slist cchat.ServerList) { +func (s *Servers) SetList(slist cchat.Lister) { primitives.RemoveChildren(s) s.ServerList = slist s.load() diff --git a/internal/ui/service/session/session.go b/internal/ui/service/session/session.go index 77d8d43..7ff9892 100644 --- a/internal/ui/service/session/session.go +++ b/internal/ui/service/session/session.go @@ -12,9 +12,9 @@ import ( "github.com/diamondburned/cchat-gtk/internal/ui/primitives/spinner" "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/session/commander" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server" "github.com/diamondburned/cchat-gtk/internal/ui/service/session/server/button" + "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" @@ -35,9 +35,9 @@ type Servicer interface { // SessionSelected is called when the row is clicked. The parent container // should change the views to show this session's *Servers. SessionSelected(*Row) - // RowSelected is called when a server that can display messages (aka - // implements ServerMessage) is called. - RowSelected(*Row, *server.ServerRow, cchat.ServerMessage) + // MessengerSelected is called when a server that can display messages (aka + // implements Messenger) is called. + MessengerSelected(*Row, *server.ServerRow) // RestoreSession is called with the session ID to ask the controller to // restore it from keyring information. RestoreSession(*Row, string) // ID string, async @@ -312,7 +312,7 @@ func (r *Row) SetSession(ses cchat.Session) { r.avatar.SetText(ses.Name().Content) // If the session has an icon, then use it. - if iconer, ok := ses.(cchat.Icon); ok { + if iconer := ses.AsIconer(); iconer != nil { r.icon.Icon.AsyncSetIconer(iconer, "Failed to set session icon") } @@ -330,11 +330,10 @@ func (r *Row) SetSession(ses cchat.Session) { // Set the commander, if any. The function will return nil if the assertion // returns nil. As such, we assert with an ignored ok bool, allowing cmd to // be nil. - cmd, _ := ses.(commander.SessionCommander) - r.cmder = commander.NewBuffer(r.svcctrl.Service(), cmd) - - // Show the command button if the session actually supports the commander. - if r.cmder != nil { + if cmder := ses.AsCommander(); cmder != nil { + r.cmder = commander.NewBuffer(ses.Name().String(), cmder) + // Show the command button if the session actually supports the + // commander. r.ActionsMenu.AddAction("Command Prompt", r.ShowCommander) } @@ -342,8 +341,8 @@ func (r *Row) SetSession(ses cchat.Session) { r.Servers.SetList(ses) } -func (r *Row) RowSelected(sr *server.ServerRow, smsg cchat.ServerMessage) { - r.svcctrl.RowSelected(r, sr, smsg) +func (r *Row) MessengerSelected(sr *server.ServerRow) { + r.svcctrl.MessengerSelected(r, sr) } // RemoveSession removes itself from the session list. @@ -351,10 +350,15 @@ func (r *Row) RemoveSession() { // Remove the session off the list. r.svcctrl.RemoveSession(r) + var session = r.Session + if session == nil { + return + } + // Asynchrously disconnect. go func() { - if err := r.Session.Disconnect(); err != nil { - log.Error(errors.Wrap(err, "Non-fatal, failed to disconnect removed session")) + if err := session.Disconnect(); err != nil { + log.Error(errors.Wrap(err, "non-fatal; failed to disconnect removed session")) } }() } diff --git a/internal/ui/service/view.go b/internal/ui/service/view.go index 67bf160..d53dd5b 100644 --- a/internal/ui/service/view.go +++ b/internal/ui/service/view.go @@ -12,8 +12,8 @@ import ( type Controller interface { // SessionSelected is called when SessionSelected(svc *Service, srow *session.Row) - // RowSelected is wrapped around session's MessageRowSelected. - RowSelected(*session.Row, *server.ServerRow, cchat.ServerMessage) + // MessengerSelected is wrapped around session's MessengerSelected. + MessengerSelected(*session.Row, *server.ServerRow) // AuthenticateSession is called to spawn the authentication dialog. AuthenticateSession(*List, *Service) // OnSessionRemove is called to remove a session. This should also clear out @@ -102,11 +102,11 @@ func (v *View) SessionSelected(svc *Service, srow *session.Row) { v.Controller.SessionSelected(svc, srow) } -// RowSelected is called when a row is selected. It updates the header then -// calls the application's RowSelected method. -func (v *View) RowSelected(srow *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) { +// MessengerSelected is called when a row is selected. It updates the header +// then calls the application's RowSelected method. +func (v *View) MessengerSelected(srow *session.Row, srv *server.ServerRow) { v.Header.SetBreadcrumber(srv) - v.Controller.RowSelected(srow, srv, smsg) + v.Controller.MessengerSelected(srow, srv) } func (v *View) OnSessionRemove(s *Service, r *session.Row) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f2d801a..86a4c2f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -132,7 +132,7 @@ func (app *App) SessionSelected(svc *service.Service, ses *session.Row) { app.MessageView.Reset() } -func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat.ServerMessage) { +func (app *App) MessengerSelected(ses *session.Row, srv *server.ServerRow) { // Change to the message view. app.Leaflet.SetVisibleChild(app.MessageView) @@ -150,7 +150,7 @@ func (app *App) RowSelected(ses *session.Row, srv *server.ServerRow, smsg cchat. app.lastSelector = srv.SetSelected app.lastSelector(true) - app.MessageView.JoinServer(ses.Session, smsg.(messages.ServerMessage), srv) + app.MessageView.JoinServer(ses.Session, srv.Server, srv) } // MessageView methods.