From 2015c7d9fe9fc0db8fa9332dd8d8ffc434856d53 Mon Sep 17 00:00:00 2001 From: Ayyan Date: Fri, 16 Jan 2026 05:42:31 -0500 Subject: [PATCH] refactor(ui/chat): use tview.ScrollList for messagesList (#708) --- go.mod | 4 +- go.sum | 8 +- internal/ui/chat/message_input.go | 2 +- internal/ui/chat/messages_list.go | 230 ++++++++++++++++-------------- internal/ui/chat/state.go | 55 +++++-- 5 files changed, 172 insertions(+), 127 deletions(-) diff --git a/go.mod b/go.mod index 3b3a2d4..58f9aa9 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ go 1.25.3 require ( github.com/BurntSushi/toml v1.6.0 github.com/andybalholm/brotli v1.2.0 - github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e + github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4 github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26 github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d @@ -16,7 +16,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/klauspost/compress v1.18.2 + github.com/klauspost/compress v1.18.3 github.com/ncruces/zenity v0.10.14 github.com/sahilm/fuzzy v0.1.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e diff --git a/go.sum b/go.sum index a27279d..d7e1209 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e h1:3BbJN6zXY3AskufYkkFerQF+n42MUyeYi6SLrqBaoJk= -github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY= +github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4 h1:+CTr6AUbwBPY7TcsDFFS9xkiX7W4RgV5/HYMP8bjeF8= +github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -50,8 +50,8 @@ github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8 github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= diff --git a/internal/ui/chat/message_input.go b/internal/ui/chat/message_input.go index eb20262..46fe92a 100644 --- a/internal/ui/chat/message_input.go +++ b/internal/ui/chat/message_input.go @@ -220,7 +220,7 @@ func (mi *messageInput) send() { mi.typingTimer = nil } mi.reset() - mi.chatView.messagesList.Highlight() + mi.chatView.messagesList.clearSelection() mi.chatView.messagesList.ScrollToEnd() } diff --git a/internal/ui/chat/messages_list.go b/internal/ui/chat/messages_list.go index 7b9f16f..6b3bbb6 100644 --- a/internal/ui/chat/messages_list.go +++ b/internal/ui/chat/messages_list.go @@ -34,10 +34,10 @@ import ( ) type messagesList struct { - *tview.TextView - cfg *config.Config - chatView *View - selectedMessageID discord.MessageID + *tview.ScrollList + cfg *config.Config + chatView *View + messages []discord.Message renderer *markdown.Renderer @@ -51,29 +51,25 @@ type messagesList struct { func newMessagesList(cfg *config.Config, chatView *View) *messagesList { ml := &messagesList{ - TextView: tview.NewTextView(), - cfg: cfg, - chatView: chatView, - renderer: markdown.NewRenderer(cfg.Theme.MessagesList), + ScrollList: tview.NewScrollList(), + cfg: cfg, + chatView: chatView, + renderer: markdown.NewRenderer(cfg.Theme.MessagesList), } ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme) - ml. - SetDynamicColors(true). - SetRegions(true). - SetWordWrap(true). - ScrollToEnd(). - SetHighlightedFunc(ml.onHighlighted). - SetTitle("Messages"). - SetInputCapture(ml.onInputCapture) + ml.SetTitle("Messages") + ml.SetBuilder(ml.buildItem) + ml.SetTrackEnd(true) + ml.SetInputCapture(ml.onInputCapture) return ml } func (ml *messagesList) reset() { - ml.selectedMessageID = 0 + ml.messages = nil ml. Clear(). - Highlight(). + SetBuilder(ml.buildItem). SetTitle("") } @@ -87,18 +83,60 @@ func (ml *messagesList) setTitle(channel discord.Channel) { } func (ml *messagesList) drawMessages(messages []discord.Message) { - writer := ml.BatchWriter() - defer writer.Close() - for _, m := range slices.Backward(messages) { - ml.drawMessage(writer, m) - } + ml.messages = slices.Grow(ml.messages[:0], len(messages)) + ml.messages = ml.messages[:len(messages)] + copy(ml.messages, messages) + slices.Reverse(ml.messages) } -func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) { - // Region tags are square brackets that contain a region ID in double quotes - // https://pkg.go.dev/github.com/ayn2op/tview#hdr-Regions_and_Highlights - fmt.Fprintf(writer, `["%s"]`, message.ID) +func (ml *messagesList) addMessage(message discord.Message) { + ml.messages = append(ml.messages, message) +} +func (ml *messagesList) setMessage(index int, message discord.Message) { + if index < 0 || index >= len(ml.messages) { + return + } + + ml.messages[index] = message +} + +func (ml *messagesList) deleteMessage(index int) { + if index < 0 || index >= len(ml.messages) { + return + } + + ml.messages = append(ml.messages[:index], ml.messages[index+1:]...) +} + +func (ml *messagesList) clearSelection() { + ml.SetCursor(-1) +} + +func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem { + if index < 0 || index >= len(ml.messages) { + return nil + } + + message := ml.messages[index] + tv := tview.NewTextView(). + SetWrap(true). + SetWordWrap(true). + SetDynamicColors(true). + SetText(ml.renderMessage(message)) + if index == cursor { + tv.SetTextStyle(tcell.StyleDefault.Reverse(true)) + } + return tv +} + +func (ml *messagesList) renderMessage(message discord.Message) string { + var b strings.Builder + ml.writeMessage(&b, message) + return b.String() +} + +func (ml *messagesList) writeMessage(writer io.Writer, message discord.Message) { if ml.cfg.HideBlockedUsers { isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID) if isBlocked { @@ -129,9 +167,6 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) { ml.drawTimestamps(writer, message.Timestamp) ml.drawAuthor(writer, message) } - - // Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region. - io.WriteString(writer, "[\"\"]\n") } func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string { @@ -239,138 +274,117 @@ func (ml *messagesList) drawPinnedMessage(w io.Writer, message discord.Message) } func (ml *messagesList) selectedMessage() (*discord.Message, error) { - if !ml.selectedMessageID.IsValid() { + if len(ml.messages) == 0 { + return nil, errors.New("no messages available") + } + + cursor := ml.Cursor() + if cursor == -1 || cursor >= len(ml.messages) { return nil, errors.New("no message is currently selected") } - selected := ml.chatView.SelectedChannel() - if selected == nil { - return nil, errors.New("no channel is currently selected") - } - - m, err := ml.chatView.state.Cabinet.Message(selected.ID, ml.selectedMessageID) - if err != nil { - return nil, fmt.Errorf("failed to retrieve selected message: %w", err) - } - - return m, nil + return &ml.messages[cursor], nil } func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey { switch event.Name() { case ml.cfg.Keys.MessagesList.ScrollUp: - return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone) + ml.ScrollUp() + return nil case ml.cfg.Keys.MessagesList.ScrollDown: - return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone) + ml.ScrollDown() + return nil case ml.cfg.Keys.MessagesList.ScrollTop: - return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone) + ml.SetCursor(0) + return nil case ml.cfg.Keys.MessagesList.ScrollBottom: - return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone) + ml.ScrollToEnd() + return nil case ml.cfg.Keys.MessagesList.Cancel: - ml.selectedMessageID = 0 - ml.Highlight() + ml.clearSelection() + return nil case ml.cfg.Keys.MessagesList.SelectPrevious, ml.cfg.Keys.MessagesList.SelectNext, ml.cfg.Keys.MessagesList.SelectFirst, ml.cfg.Keys.MessagesList.SelectLast, ml.cfg.Keys.MessagesList.SelectReply: ml._select(event.Name()) + return nil case ml.cfg.Keys.MessagesList.YankID: ml.yankID() + return nil case ml.cfg.Keys.MessagesList.YankContent: ml.yankContent() + return nil case ml.cfg.Keys.MessagesList.YankURL: ml.yankURL() + return nil case ml.cfg.Keys.MessagesList.Open: ml.open() + return nil case ml.cfg.Keys.MessagesList.Reply: ml.reply(false) + return nil case ml.cfg.Keys.MessagesList.ReplyMention: ml.reply(true) + return nil case ml.cfg.Keys.MessagesList.Edit: ml.edit() + return nil case ml.cfg.Keys.MessagesList.Delete: ml.delete() + return nil case ml.cfg.Keys.MessagesList.DeleteConfirm: ml.confirmDelete() + return nil } - return nil + return event } func (ml *messagesList) _select(name string) { - selectedChannel := ml.chatView.SelectedChannel() - if selectedChannel == nil { - return - } - - messages, err := ml.chatView.state.Cabinet.Messages(selectedChannel.ID) - if err != nil { - slog.Error("failed to get messages", "err", err, "channel_id", selectedChannel.ID) - return - } + messages := ml.messages if len(messages) == 0 { return } - messageIdx := slices.IndexFunc(messages, func(m discord.Message) bool { - return m.ID == ml.selectedMessageID - }) - // Allow "no highlight yet" to fall through and pick the latest message. - if len(ml.GetHighlights()) != 0 && messageIdx == -1 { - return - } + cursor := ml.Cursor() switch name { case ml.cfg.Keys.MessagesList.SelectPrevious: - // If no message is currently selected, select the latest message. - if len(ml.GetHighlights()) == 0 { - ml.selectedMessageID = messages[0].ID - } else if messageIdx < len(messages)-1 { - ml.selectedMessageID = messages[messageIdx+1].ID - } else { - return + switch { + case cursor == -1: + cursor = len(messages) - 1 + case cursor > 0: + cursor-- } case ml.cfg.Keys.MessagesList.SelectNext: - // If no message is currently selected, select the latest message. - if len(ml.GetHighlights()) == 0 { - ml.selectedMessageID = messages[0].ID - } else if messageIdx > 0 { - ml.selectedMessageID = messages[messageIdx-1].ID - } else { - return + switch { + case cursor == -1: + cursor = len(messages) - 1 + case cursor < len(messages)-1: + cursor++ } case ml.cfg.Keys.MessagesList.SelectFirst: - ml.selectedMessageID = messages[len(messages)-1].ID + cursor = 0 case ml.cfg.Keys.MessagesList.SelectLast: - ml.selectedMessageID = messages[0].ID + cursor = len(messages) - 1 case ml.cfg.Keys.MessagesList.SelectReply: - if ml.selectedMessageID == 0 { + if cursor == -1 || cursor >= len(messages) { return } - if ref := messages[messageIdx].ReferencedMessage; ref != nil { + if ref := messages[cursor].ReferencedMessage; ref != nil { refIdx := slices.IndexFunc(messages, func(m discord.Message) bool { return m.ID == ref.ID }) if refIdx != -1 { - ml.selectedMessageID = messages[refIdx].ID + cursor = refIdx } - } - } - - ml.Highlight(ml.selectedMessageID.String()) - ml.ScrollToHighlight() -} - -func (ml *messagesList) onHighlighted(added, removed, remaining []string) { - if len(added) > 0 { - id, err := discord.ParseSnowflake(added[0]) - if err != nil { - slog.Error("failed to parse region id as int to use as message id", "err", err) + } else { return } - - ml.selectedMessageID = discord.MessageID(id) } + + ml.SetCursor(cursor) } func (ml *messagesList) yankID() { @@ -545,17 +559,17 @@ func (ml *messagesList) openURL(url string) { } func (ml *messagesList) reply(mention bool) { - msg, err := ml.selectedMessage() + message, err := ml.selectedMessage() if err != nil { slog.Error("failed to get selected message", "err", err) return } - name := msg.Author.DisplayOrUsername() - if msg.GuildID.IsValid() { - member, err := ml.chatView.state.Cabinet.Member(msg.GuildID, msg.Author.ID) + name := message.Author.DisplayOrUsername() + if message.GuildID.IsValid() { + member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID) if err != nil { - slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err) + slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err) } else { if member.Nick != "" { name = member.Nick @@ -564,7 +578,7 @@ func (ml *messagesList) reply(mention bool) { } data := ml.chatView.messageInput.sendMessageData - data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID} + data.Reference = &discord.MessageReference{MessageID: message.ID} data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False} title := "Replying to " @@ -646,16 +660,10 @@ func (ml *messagesList) delete() { return } - ml.selectedMessageID = 0 - ml.Highlight() - if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil { slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err) return } - - // No need to redraw messages after deletion, onMessageDelete will do - // its work after the event returns } func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) { diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index c317ae1..0b6da2b 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -3,10 +3,12 @@ package chat import ( "context" "log/slog" + "slices" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/notifications" "github.com/ayn2op/tview" + "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/gateway" "github.com/diamondburned/arikawa/v3/session" "github.com/diamondburned/arikawa/v3/state" @@ -114,8 +116,9 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) { selectedChannel := v.SelectedChannel() if selectedChannel != nil && selectedChannel.ID == message.ChannelID { v.removeTyper(message.Author.ID) - v.messagesList.drawMessage(v.messagesList, message.Message) - v.app.Draw() + v.app.QueueUpdateDraw(func() { + v.messagesList.addMessage(message.Message) + }) } else { if err := notifications.Notify(v.state, message, v.cfg); err != nil { slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID) @@ -125,21 +128,55 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) { func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) { if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { - v.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID}) + index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool { + return m.ID == message.ID + }) + if index < 0 { + return + } + + v.app.QueueUpdateDraw(func() { + v.messagesList.setMessage(index, message.Message) + }) } } func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) { if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID { - messages, err := v.state.Cabinet.Messages(message.ChannelID) - if err != nil { - slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID) + prevCursor := v.messagesList.Cursor() + deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool { + return m.ID == message.ID + }) + if deletedIndex < 0 { return } - v.messagesList.reset() - v.messagesList.drawMessages(messages) - v.app.Draw() + v.app.QueueUpdateDraw(func() { + v.messagesList.deleteMessage(deletedIndex) + }) + + // Keep cursor stable when possible after removal. + newCursor := prevCursor + if prevCursor == deletedIndex { + // Prefer previous item; fall forward if we deleted the first. + newCursor = deletedIndex - 1 + if newCursor < 0 { + if deletedIndex < len(v.messagesList.messages) { + newCursor = deletedIndex + } else { + newCursor = -1 + } + } + } else if prevCursor > deletedIndex { + // Shift back since the list shrank before the cursor. + newCursor = prevCursor - 1 + } + if newCursor != prevCursor { + // Avoid redundant cursor updates if nothing changed. + v.app.QueueUpdateDraw(func() { + v.messagesList.SetCursor(newCursor) + }) + } } }