From 135be0facaaa72031305d494c870328688d870d5 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Wed, 21 Oct 2020 16:47:17 -0700 Subject: [PATCH] message list now stored as btree --- go.mod | 3 +- go.sum | 5 + .../ui/messages/container/compact/message.go | 4 +- internal/ui/messages/container/container.go | 48 +--- internal/ui/messages/container/cozy/cozy.go | 52 ++-- .../container/cozy/message_collapsed.go | 4 +- .../messages/container/cozy/message_full.go | 4 +- internal/ui/messages/container/grid.go | 222 +++++----------- internal/ui/messages/container/gridmessage.go | 35 +++ internal/ui/messages/container/store.go | 242 ++++++++++++++++++ internal/ui/messages/message/message.go | 1 + internal/ui/messages/view.go | 4 +- .../ui/primitives/completion/completer.go | 5 - 13 files changed, 407 insertions(+), 222 deletions(-) create mode 100644 internal/ui/messages/container/gridmessage.go create mode 100644 internal/ui/messages/container/store.go diff --git a/go.mod b/go.mod index ce0ebcf..4cbe6fb 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,14 @@ require ( github.com/Xuanwo/go-locale v1.0.0 github.com/alecthomas/chroma v0.7.3 github.com/diamondburned/cchat v0.3.7 - github.com/diamondburned/cchat-discord v0.0.0-20201015062850-090259a6b4ca + github.com/diamondburned/cchat-discord v0.0.0-20201020232329-a9f3804f5613 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 github.com/disintegration/imaging v1.6.2 github.com/goodsign/monday v1.0.0 + github.com/google/btree v1.0.0 github.com/gotk3/gotk3 v0.4.1-0.20200524052254-cb2aa31c6194 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/ianlancetaylor/cgosymbolizer v0.0.0-20200424224625-be1b05b0b279 diff --git a/go.sum b/go.sum index a086425..eaa077c 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/diamondburned/arikawa v1.2.0 h1:3dFmpk/G4UwO+Kto0tXd5AbaCKC9KH2ZfnA8U github.com/diamondburned/arikawa v1.2.0/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/arikawa v1.3.0 h1:up5q5Ya/QbiFqhMejvl+c03YdsgzkzspsJOWW30A2lk= github.com/diamondburned/arikawa v1.3.0/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= +github.com/diamondburned/arikawa v1.3.6 h1:DhxWDO4fyXAZ4VFrWdJOqiiJKJRpkrehGJwPtZ2eos0= +github.com/diamondburned/arikawa v1.3.6/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= github.com/diamondburned/cchat v0.0.43 h1:HetAujSaUSdnQgAUZgprNLARjf/MSWXpCfWdvX2wOCU= github.com/diamondburned/cchat v0.0.43/go.mod h1:+zXktogE45A0om4fT6B/z6Ii7FXNafjxsNspI0rlhbU= github.com/diamondburned/cchat v0.0.45 h1:HMVSKx1h6lh2OenWaBTvMSK531hWaXAW7I0tKZepYug= @@ -113,6 +115,8 @@ github.com/diamondburned/cchat-discord v0.0.0-20201009173316-1907986ceb08 h1:iyt 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-discord v0.0.0-20201020232329-a9f3804f5613 h1:yNgvF5JqsFvAddD0kHiD0trx1Er4Bjt4K8iPrp5ULGc= +github.com/diamondburned/cchat-discord v0.0.0-20201020232329-a9f3804f5613/go.mod h1:gsdyDkcELVS0PoTwUaDTyiVI69Kmyy0YWCjyL0x5DzM= 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= @@ -350,6 +354,7 @@ golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3 h1:qDJKu1y/1SjhWac4BQZjLljqvqiWUhjmDMnonmVGDAU= golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/ui/messages/container/compact/message.go b/internal/ui/messages/container/compact/message.go index 695fbad..d8dd5ff 100644 --- a/internal/ui/messages/container/compact/message.go +++ b/internal/ui/messages/container/compact/message.go @@ -39,6 +39,6 @@ func NewEmptyMessage() Message { return Message{message.NewEmptyContainer()} } -func (m Message) Attach(grid *gtk.Grid, row int) { - container.AttachRow(grid, row, m.Timestamp, m.Username, m.Content) +func (m Message) Attach() []gtk.IWidget { + return []gtk.IWidget{m.Timestamp, m.Username, m.Content} } diff --git a/internal/ui/messages/container/container.go b/internal/ui/messages/container/container.go index ce6b61e..f8c796d 100644 --- a/internal/ui/messages/container/container.go +++ b/internal/ui/messages/container/container.go @@ -18,17 +18,11 @@ type GridMessage interface { // Focusable should return a widget that can be focused. Focusable() gtk.IWidget // Attach should only be called once. - Attach(grid *gtk.Grid, row int) + Attach() []gtk.IWidget // AttachMenu should override the stored constructor. AttachMenu(items []menu.Item) // save memory } -func AttachRow(grid *gtk.Grid, row int, widgets ...gtk.IWidget) { - for i, w := range widgets { - grid.Attach(w, i, row, 1, 1) - } -} - type PresendGridMessage interface { GridMessage message.PresendContainer @@ -43,10 +37,10 @@ type Container interface { cchat.MessagesContainer // Thread-unsafe methods. - CreateMessageUnsafe(cchat.MessageCreate) + + CreateMessageUnsafe(cchat.MessageCreate) int UpdateMessageUnsafe(cchat.MessageUpdate) DeleteMessageUnsafe(cchat.MessageDelete) - PrependMessageUnsafe(cchat.MessageCreate) // FirstMessage returns the first message in the buffer. Nil is returned if // there's nothing. @@ -72,7 +66,7 @@ type Controller interface { Bottomed() bool // AuthorEvent is called on message create/update. This is used to update // the typer state. - AuthorEvent(a cchat.Author) + OnAuthorEvent(a cchat.Author) } // Constructor is an interface for making custom message implementations which @@ -91,12 +85,6 @@ type GridContainer struct { Controller } -// gridMessage w/ required internals -type gridMessage struct { - GridMessage - presend message.PresendContainer // this shouldn't be here but i'm lazy -} - var _ Container = (*GridContainer)(nil) func NewGridContainer(constr Constructor, ctrl Controller) *GridContainer { @@ -108,28 +96,18 @@ func NewGridContainer(constr Constructor, ctrl Controller) *GridContainer { // CreateMessageUnsafe inserts a message as well as cleaning up the backlog if // the user is scrolled to the bottom. -func (c *GridContainer) CreateMessageUnsafe(msg cchat.MessageCreate) { +func (c *GridContainer) CreateMessageUnsafe(msg cchat.MessageCreate) int { // Insert the message first. - c.GridStore.CreateMessageUnsafe(msg) + ix := c.GridStore.CreateMessageUnsafe(msg) // Determine if the user is scrolled to the bottom for cleaning up. - if !c.Bottomed() { - return + if c.Bottomed() { + // Clean up the backlog. The function allows a negative n, which would + // be a no-op. + c.PopEarliestMessages(c.MessagesLen() - BacklogLimit) } - // Clean up the backlog. - if clean := len(c.messages) - BacklogLimit; clean > 0 { - // Remove them from the map and the container. - for _, id := range c.messageIDs[:clean] { - delete(c.messages, id) - // We can gradually pop the first item off here, as we're removing - // from 0th, and items are being shifted backwards. - c.Grid.RemoveRow(0) - } - - // Cut the message IDs away by shifting the slice. - c.messageIDs = append(c.messageIDs[:0], c.messageIDs[clean:]...) - } + return ix } func (c *GridContainer) CreateMessage(msg cchat.MessageCreate) { @@ -143,7 +121,3 @@ func (c *GridContainer) UpdateMessage(msg cchat.MessageUpdate) { func (c *GridContainer) DeleteMessage(msg cchat.MessageDelete) { gts.ExecAsync(func() { c.DeleteMessageUnsafe(msg) }) } - -func (c *GridContainer) PrependMessage(msg cchat.MessageCreate) { - gts.ExecAsync(func() { c.PrependMessageUnsafe(msg) }) -} diff --git a/internal/ui/messages/container/cozy/cozy.go b/internal/ui/messages/container/cozy/cozy.go index de82a6b..11cace0 100644 --- a/internal/ui/messages/container/cozy/cozy.go +++ b/internal/ui/messages/container/cozy/cozy.go @@ -129,14 +129,43 @@ 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) + ix := c.GridContainer.CreateMessageUnsafe(msg) - // Should we collapse this message? Yes, if the current message's author - // is the same as the last author. + // We need to do certain checks to messages that are prepended to the + // top of the buffer. This was originally in the now-deprecated + // PrependMessage function. + if ix == 0 { + // See if we need to uncollapse the second message. + if sec := c.NthMessage(1); sec != nil { + // If the author isn't the same, then ignore. + if sec.AuthorID() != msg.Author().ID() { + return + } + + // The author is the same; collapse. + c.compact(sec) + } + + return + } + + // Should we collapse the last message? Yes if the current message's + // author is the same as the last author. if c.lastMessageIsAuthor(msg.Author().ID(), 1) { c.compact(c.GridContainer.LastMessage()) } + // See if we need to collapse the second message. + if sec := c.NthMessage(1); sec != nil { + // If the author isn't the same, then ignore. + if sec.AuthorID() != msg.Author().ID() { + return + } + + // The author is the same; collapse. + c.compact(sec) + } + // Did the handler wipe old messages? It will only do so if the user is // scrolled to the bottom. if !c.Bottomed() { @@ -211,23 +240,6 @@ func (c *Container) uncompact(msg container.GridMessage) { c.GridStore.SwapMessage(full) } -func (c *Container) PrependMessage(msg cchat.MessageCreate) { - gts.ExecAsync(func() { - c.GridContainer.PrependMessageUnsafe(msg) - - // See if we need to uncollapse the second message. - if sec := c.NthMessage(1); sec != nil { - // If the author isn't the same, then ignore. - if sec.AuthorID() != msg.Author().ID() { - return - } - - // The author is the same; collapse. - c.compact(sec) - } - }) -} - func (c *Container) compact(msg container.GridMessage) { // Exit if the message is already collapsed. if collapse, ok := msg.(Collapsible); !ok || collapse.Collapsed() { diff --git a/internal/ui/messages/container/cozy/message_collapsed.go b/internal/ui/messages/container/cozy/message_collapsed.go index 9c1ffd5..4cf14a5 100644 --- a/internal/ui/messages/container/cozy/message_collapsed.go +++ b/internal/ui/messages/container/cozy/message_collapsed.go @@ -55,8 +55,8 @@ func (c *CollapsedMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { return c.GenericContainer } -func (c *CollapsedMessage) Attach(grid *gtk.Grid, row int) { - container.AttachRow(grid, row, c.Timestamp, c.Content) +func (c *CollapsedMessage) Attach() []gtk.IWidget { + return []gtk.IWidget{c.Timestamp, c.Content} } func (c *CollapsedMessage) Focusable() gtk.IWidget { diff --git a/internal/ui/messages/container/cozy/message_full.go b/internal/ui/messages/container/cozy/message_full.go index 50fe39b..190c774 100644 --- a/internal/ui/messages/container/cozy/message_full.go +++ b/internal/ui/messages/container/cozy/message_full.go @@ -125,9 +125,9 @@ func (m *FullMessage) Unwrap(grid *gtk.Grid) *message.GenericContainer { return m.GenericContainer } -func (m *FullMessage) Attach(grid *gtk.Grid, row int) { +func (m *FullMessage) Attach() []gtk.IWidget { m.Avatar.Show() - container.AttachRow(grid, row, m.Avatar, m.MainBox) + return []gtk.IWidget{m.Avatar, m.MainBox} } func (m *FullMessage) Focusable() gtk.IWidget { diff --git a/internal/ui/messages/container/grid.go b/internal/ui/messages/container/grid.go index c3442e9..fb46227 100644 --- a/internal/ui/messages/container/grid.go +++ b/internal/ui/messages/container/grid.go @@ -1,8 +1,6 @@ package container import ( - "fmt" - "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/log" "github.com/diamondburned/cchat-gtk/internal/ui/messages/input" @@ -17,8 +15,9 @@ type GridStore struct { Construct Constructor Controller Controller - messages map[string]*gridMessage - messageIDs []string // ids or nonces + store *messageStore + // messages map[string]*gridMessage + // messageIDs []string // ids or nonces } func NewGridStore(constr Constructor, ctrl Controller) *GridStore { @@ -35,22 +34,22 @@ func NewGridStore(constr Constructor, ctrl Controller) *GridStore { Grid: grid, Construct: constr, Controller: ctrl, - messages: map[string]*gridMessage{}, + store: newMessageStore(), } } func (c *GridStore) MessagesLen() int { - return len(c.messages) + return c.store.Len() } -// findIndex searches backwards for idnonce. -func (c *GridStore) findIndex(idnonce string) int { - for i := len(c.messageIDs) - 1; i >= 0; i-- { - if c.messageIDs[i] == idnonce { - return i - } +func (c *GridStore) attachGrid(row int, widgets []gtk.IWidget) { + // Cover a special case with attaching to the 0th row. + if row == 0 { + c.Grid.InsertRow(0) + } + for i, w := range widgets { + c.Grid.Attach(w, i, row, 1, 1) } - return -1 } type CoordinateTranslator interface { @@ -60,12 +59,11 @@ type CoordinateTranslator interface { var _ CoordinateTranslator = (*gtk.Widget)(nil) func (c *GridStore) TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y int) { - i := c.findIndex(msg.ID()) - if i < 0 { + m := c.store.Message(msg.ID(), "") + if m == nil { return 0 } - m, _ := c.messages[c.messageIDs[i]] w, _ := m.Focusable().(CoordinateTranslator) // x is not needed. @@ -86,27 +84,24 @@ func (c *GridStore) TranslateCoordinates(parent gtk.IWidget, msg GridMessage) (y // // TODO: combine compact and full so they share the same attach method. func (c *GridStore) SwapMessage(msg GridMessage) bool { - // Get the current message's index. - var ix = c.findIndex(msg.ID()) - if ix == -1 { - return false - } - // Wrap msg inside a *gridMessage if it's not already. mg, ok := msg.(*gridMessage) if !ok { mg = &gridMessage{GridMessage: msg} } + // Get the current message's index. + var ix = c.store.SwapMessage(mg) + 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) // Let the new message be attached on top of the to-be-replaced message. - mg.Attach(c.Grid, ix) - - // Set the message into the map. - c.messages[mg.ID()] = mg + c.attachGrid(ix, mg.Attach()) // Delete the to-be-replaced message, which we have shifted downwards // earlier, so we add 1. @@ -117,121 +112,51 @@ func (c *GridStore) SwapMessage(msg GridMessage) bool { // Before returns the message before the given ID, or nil if none. func (c *GridStore) Before(id string) GridMessage { - return c.getOffsetted(id, -1) + return c.store.MessageBefore(id) } // After returns the message after the given ID, or nil if none. func (c *GridStore) After(id string) GridMessage { - return c.getOffsetted(id, 1) -} - -func (c *GridStore) getOffsetted(id string, offset int) GridMessage { - // Get the current index. - var ix = c.findIndex(id) - if ix == -1 { - return nil - } - ix += offset - - if ix < 0 || ix >= len(c.messages) { - return nil - } - - return c.messages[c.messageIDs[ix]].GridMessage + return c.store.MessageAfter(id) } // 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) { - // FindMessage already looks from the latest messages. - var msg = c.FindMessage(func(msg GridMessage) bool { - return msg.AuthorID() == userID - }) - + msg := c.store.LastMessageFrom(userID) if msg == nil { + // "Backwards-compatibility is repeating the mistakes of yesterday, + // today." return "", false } - return msg.ID(), true } // FindMessage iterates backwards and returns the message if isMessage() returns // true on that message. func (c *GridStore) FindMessage(isMessage func(msg GridMessage) bool) GridMessage { - for i := len(c.messageIDs) - 1; i >= 0; i-- { - msg := c.messages[c.messageIDs[i]] - // Ignore sending messages. - if msg.presend != nil { - continue - } - // Check. - if msg := msg.GridMessage; isMessage(msg) { - return msg - } - } - return nil + return c.store.FindMessage(isMessage) } // NthMessage returns the nth message. func (c *GridStore) NthMessage(n int) GridMessage { - if len(c.messageIDs) > 0 && n >= 0 && n < len(c.messageIDs) { - return c.messages[c.messageIDs[n]].GridMessage - } - return nil + return c.store.NthMessage(n).unwrap() } // FirstMessage returns the first message. func (c *GridStore) FirstMessage() GridMessage { - return c.NthMessage(0) + return c.store.FirstMessage().unwrap() } // LastMessage returns the latest message. func (c *GridStore) LastMessage() GridMessage { - return c.NthMessage(c.MessagesLen() - 1) + return c.store.LastMessage().unwrap() } // 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 { - if m := c.message(msgID, nonce); m != nil { - return m.GridMessage - } - return nil -} - -func (c *GridStore) message(msgID cchat.ID, nonce string) *gridMessage { - // Search using the ID first. - m, ok := c.messages[msgID] - if ok { - return m - } - - // Is this an existing message? - 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[msgID] = m - - // Set the right 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] = msgID - } else { - log.Error(fmt.Errorf("Missed ID %s in slice index %d", msgID, ix)) - } - - return m - } - } - - return nil + return c.store.Message(msgID, nonce).unwrap() } // AddPresendMessage inserts an input.PresendMessage into the container and @@ -244,38 +169,23 @@ func (c *GridStore) AddPresendMessage(msg input.PresendMessage) PresendGridMessa presend: presend, } + // Crash and burn if -1 is returned. + ix := c.store.InsertMessage(msgc) + if ix == -1 { + panic("BUG: -1 returned from store.InsertMessage") + } + // Set the message into the grid. - msgc.Attach(c.Grid, c.MessagesLen()) - // Append the NONCE. - c.messageIDs = append(c.messageIDs, msgc.Nonce()) - // Set the NONCE into the message map. - c.messages[msgc.Nonce()] = msgc + c.attachGrid(ix, msgc.Attach()) return presend } -func (c *GridStore) PrependMessageUnsafe(msg cchat.MessageCreate) { - msgc := &gridMessage{ - GridMessage: c.Construct.NewMessage(msg), - } - - c.Grid.InsertRow(0) - msgc.Attach(c.Grid, 0) - - // Prepend the message ID. - c.messageIDs = append(c.messageIDs, "") - copy(c.messageIDs[1:], c.messageIDs) - c.messageIDs[0] = msgc.ID() - - // Set the message into the map. - c.messages[msgc.ID()] = msgc - - c.Controller.BindMenu(msgc) -} - -func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { +// CreateMessageUnsafe adds msg into the message view. It returns -1 if the +// message was "upserted," that is if it's updated instead of inserted. +func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) int { // Call the event handler last. - defer c.Controller.AuthorEvent(msg.Author()) + defer c.Controller.OnAuthorEvent(msg.Author()) // Attempt to update before insertion (aka upsert). if msgc := c.Message(msg.ID(), msg.Nonce()); msgc != nil { @@ -284,24 +194,29 @@ func (c *GridStore) CreateMessageUnsafe(msg cchat.MessageCreate) { msgc.UpdateTimestamp(msg.Time()) c.Controller.BindMenu(msgc) - return + return -1 } msgc := &gridMessage{ GridMessage: c.Construct.NewMessage(msg), } - // Copy from PresendMessage. - msgc.Attach(c.Grid, c.MessagesLen()) - c.messageIDs = append(c.messageIDs, msgc.ID()) - c.messages[msgc.ID()] = msgc + // Crash and burn if -1 is returned. + ix := c.store.InsertMessage(msgc) + if ix == -1 { + panic("BUG: -1 returned from store.InsertMessage") + } + // Set the message into the grid. + c.attachGrid(ix, msgc.Attach()) c.Controller.BindMenu(msgc) + + return ix } func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { // Call the event handler last. - defer c.Controller.AuthorEvent(msg.Author()) + defer c.Controller.OnAuthorEvent(msg.Author()) if msgc := c.Message(msg.ID(), ""); msgc != nil { if author := msg.Author(); author != nil { @@ -316,26 +231,31 @@ func (c *GridStore) UpdateMessageUnsafe(msg cchat.MessageUpdate) { } func (c *GridStore) DeleteMessageUnsafe(msg cchat.MessageDelete) { - c.PopMessage(msg.ID()) + c.store.DeleteMessage(msg.ID()) } // PopMessage deletes a message off of the list and return the deleted message. -func (c *GridStore) PopMessage(id string) (msg GridMessage) { - // Search for the index. - var ix = c.findIndex(id) - if ix < 0 { +func (c *GridStore) PopMessage(id string) GridMessage { + msg, ix := c.store.PopMessage(id) + if msg == nil { return nil } - // Grab the message before deleting. - msg = c.messages[id] - // Remove off of the Gtk grid. c.Grid.RemoveRow(ix) - // Pop off the slice. - c.messageIDs = append(c.messageIDs[:ix], c.messageIDs[ix+1:]...) - // Delete off the map. - delete(c.messages, id) - return + return msg.GridMessage +} + +func (c *GridStore) PopEarliestMessages(n int) { + poppedIxs := c.store.PopEarliestMessages(n) + if poppedIxs == 0 { + return + } + // Get the count of messages after deletion. We can then gradually decrement + // poppedN to get the deleted message indices. + for poppedIxs > 0 { + c.Grid.RemoveRow(poppedIxs) + poppedIxs-- + } } diff --git a/internal/ui/messages/container/gridmessage.go b/internal/ui/messages/container/gridmessage.go new file mode 100644 index 0000000..3df57f2 --- /dev/null +++ b/internal/ui/messages/container/gridmessage.go @@ -0,0 +1,35 @@ +package container + +import ( + "github.com/diamondburned/cchat-gtk/internal/ui/messages/message" + "github.com/google/btree" +) + +// gridMessage w/ required internals +type gridMessage struct { + GridMessage + presend message.PresendContainer // this shouldn't be here but i'm lazy +} + +// Less compares the time while accounting for equal time with different IDs. +func (g *gridMessage) Less(than btree.Item) bool { + thanMessage := than.(*gridMessage) + + // Time must never match if the IDs don't. + if thanMessage.Time().Equal(g.Time()) { + if thanMessage.ID() != g.ID() { + // Always return less = true because this shouldn't be equal. + return true + } + } + + return g.Time().Before(thanMessage.Time()) +} + +// unwrap returns nil if g is nil. Otherwise, it unwraps the gridMessage. +func (g *gridMessage) unwrap() GridMessage { + if g == nil { + return nil + } + return g.GridMessage +} diff --git a/internal/ui/messages/container/store.go b/internal/ui/messages/container/store.go new file mode 100644 index 0000000..de7e92b --- /dev/null +++ b/internal/ui/messages/container/store.go @@ -0,0 +1,242 @@ +package container + +import ( + "github.com/diamondburned/cchat" + "github.com/google/btree" +) + +// messageStore implements various data structures for optimized message get and +// insert. +type messageStore struct { + msgTree btree.BTree + messageIDs map[string]*gridMessage + messageNonces map[string]*gridMessage +} + +func newMessageStore() *messageStore { + return &messageStore{ + msgTree: *btree.New(2), + messageIDs: make(map[string]*gridMessage, 100), + messageNonces: make(map[string]*gridMessage, 5), + } +} + +func (ms *messageStore) Len() int { + return ms.msgTree.Len() +} + +// InsertMessage inserts the message into the store and return the new index. +func (ms *messageStore) InsertMessage(msg *gridMessage) int { + return ms.replaceMessage(msg, false) +} + +// SwapMessage overrides the old message with the same ID with the given one. It +// returns an index if the message is replaced, or -1 if the message is not. +func (ms *messageStore) SwapMessage(msg *gridMessage) int { + return ms.replaceMessage(msg, true) +} + +func (ms *messageStore) replaceMessage(msg *gridMessage, replaceOnly bool) int { + var ix = -1 + + // Guarantee that no new messages are added. + if replaced := ms.msgTree.ReplaceOrInsert(msg); replaced == nil && replaceOnly { + // Nil is returned, meaning a new message is added. This is bad. + ms.msgTree.Delete(msg) + return ix + } + + var id = msg.ID() + if id != "" { + ms.messageIDs[id] = msg + delete(ms.messageNonces, msg.Nonce()) // superfluous guarantee + } else { + // Assume nonce is non-empty. Probably not a good idea. + ms.messageNonces[msg.Nonce()] = msg + } + + insertAt := ms.msgTree.Len() - 1 + + ms.msgTree.Descend(func(item btree.Item) bool { + if id == item.(*gridMessage).ID() { + ix = insertAt + return false // break + } + insertAt-- + return true + }) + + return ix +} + +func (ms *messageStore) MessageBefore(id cchat.ID) GridMessage { + return ms.getOffsetted(id, true) +} + +func (ms *messageStore) MessageAfter(id cchat.ID) GridMessage { + return ms.getOffsetted(id, false) +} + +// getOffsetted returns the unwrapped message. +func (ms *messageStore) getOffsetted(id cchat.ID, before bool) GridMessage { + var last, found *gridMessage + var next bool + + // We need to ascend, as next and before implies ascending order from 0 to + // last. + ms.msgTree.Ascend(func(item btree.Item) bool { + message := item.(*gridMessage) + if next { + found = message + return false // break + } + if message.ID() == id { + if before { + found = last + return false // break + } else { + next = true + return true + } + } + last = message + return true + }) + + if found == nil { + return nil + } + + return found.GridMessage +} + +// FirstMessage returns the earliest message. +func (ms *messageStore) FirstMessage() *gridMessage { + return ms.msgTree.Min().(*gridMessage) +} + +// LastMessage returns the latest message. +func (ms *messageStore) LastMessage() *gridMessage { + return ms.msgTree.Max().(*gridMessage) +} + +// NthMessage returns the nth message ordered from earliest to latest. It is +// fairly slow. +func (ms *messageStore) NthMessage(n int) (message *gridMessage) { + insertAt := ms.msgTree.Len() - 1 + + ms.msgTree.Descend(func(item btree.Item) bool { + if n == insertAt { + message = item.(*gridMessage) + return false // break + } + insertAt-- + return true + }) + + return +} + +// LastMessageFrom returns the latest message with the given user ID. This is +// used for the input prompt. +func (ms *messageStore) LastMessageFrom(userID string) GridMessage { + return ms.FindMessage(func(gridMsg GridMessage) bool { + return gridMsg.AuthorID() == userID + }) +} + +// FindMessage implicitly unwraps the GridMessage before passing it into the +// handler and returning it. +func (ms *messageStore) FindMessage(isMessage func(GridMessage) bool) (found GridMessage) { + ms.msgTree.Descend(func(item btree.Item) bool { + message := item.(*gridMessage) + // Ignore sending messages. + if message.presend != nil { + return true + } + if unwrapped := message.GridMessage; isMessage(unwrapped) { + found = unwrapped + return false + } + return true + }) + return +} + +func (ms *messageStore) get(id, nonce string) *gridMessage { + if id != "" { + m, ok := ms.messageIDs[id] + if ok { + return m + } + } + + m, ok := ms.messageNonces[nonce] + if ok { + return m + } + + return nil +} + +func (ms *messageStore) Message(msgID cchat.ID, nonce string) *gridMessage { + message := ms.get(msgID, nonce) + + // If the message was obtained from a nonce, then try to move it off. + if nonce != "" && message != nil { + // Move the message from nonce state to ID. + delete(ms.messageNonces, nonce) + ms.messageIDs[msgID] = message + + // Set the right ID. + message.presend.SetDone(msgID) + // Destroy the presend struct. + message.presend = nil + } + + return message +} + +func (ms *messageStore) DeleteMessage(msgID cchat.ID) { + m, ok := ms.messageIDs[msgID] + if ok { + ms.msgTree.Delete(m) + delete(ms.messageIDs, msgID) + } +} + +func (ms *messageStore) PopMessage(id cchat.ID) (popped *gridMessage, ix int) { + ix = ms.msgTree.Len() - 1 + + ms.msgTree.Descend(func(item btree.Item) bool { + if gridMsg := item.(*gridMessage); id == gridMsg.ID() { + popped = gridMsg + + // Delete off of the state. + ms.msgTree.Delete(item) + delete(ms.messageIDs, id) + delete(ms.messageNonces, popped.Nonce()) // superfluous + + return false // break + } + ix-- + return true + }) + + return +} + +// PopEarliestMessages pops the n earliest messages. n can be less than or equal +// to 0, which would be a no-op. +func (ms *messageStore) PopEarliestMessages(n int) (poppedIxs int) { + for ; n > 0 && ms.Len() > 0; n-- { + gridMsg := ms.msgTree.DeleteMin().(*gridMessage) + delete(ms.messageIDs, gridMsg.ID()) + delete(ms.messageNonces, gridMsg.Nonce()) + + // We can keep incrementing the index as we delete things. This is + // because we're deleting from 0 and up. + poppedIxs++ + } + return +} diff --git a/internal/ui/messages/message/message.go b/internal/ui/messages/message/message.go index b9ce639..d14ca18 100644 --- a/internal/ui/messages/message/message.go +++ b/internal/ui/messages/message/message.go @@ -17,6 +17,7 @@ import ( type Container interface { ID() string + Time() time.Time AuthorID() string AvatarURL() string // avatar Nonce() string diff --git a/internal/ui/messages/view.go b/internal/ui/messages/view.go index 7f79cf5..da30ad3 100644 --- a/internal/ui/messages/view.go +++ b/internal/ui/messages/view.go @@ -364,8 +364,8 @@ func (v *View) AddPresendMessage(msg input.PresendMessage) func(error) { } } -// AuthorEvent should be called on message create/update/delete. -func (v *View) AuthorEvent(author cchat.Author) { +// OnAuthorEvent should be called on message create/update/delete. +func (v *View) OnAuthorEvent(author cchat.Author) { // Remove the author from the typing list if it's not nil. if author != nil { v.Typing.RemoveAuthor(author) diff --git a/internal/ui/primitives/completion/completer.go b/internal/ui/primitives/completion/completer.go index 6a34d5e..55c3ed2 100644 --- a/internal/ui/primitives/completion/completer.go +++ b/internal/ui/primitives/completion/completer.go @@ -2,7 +2,6 @@ package completion import ( "fmt" - "log" "github.com/diamondburned/cchat" "github.com/diamondburned/cchat-gtk/internal/gts/httputil" @@ -138,20 +137,16 @@ func (c *Completer) onChange() { t, v, blank := State(c.Buffer) c.cursor = v - 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 c.Popdown() - log.Println("RESET INDEX TO -1") return } c.words, c.index = c.Splitter(t, v) - log.Println("INDEX:", c.index) c.complete() }