From cb4c82d3e1bab4e25e5633141f1c3795c21dbbe7 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Sun, 18 Oct 2026 22:40:59 -0400 Subject: [PATCH] perf(messageslist): keep each message's built view instead of rebuilding it on every draw (#850) --- internal/ui/chat/messageslist/embed.go | 3 +- internal/ui/chat/messageslist/message.go | 47 ++------------------- internal/ui/chat/messageslist/model.go | 37 +++++++++------- internal/ui/chat/messageslist/model_test.go | 36 +++++++--------- 4 files changed, 42 insertions(+), 81 deletions(-) diff --git a/internal/ui/chat/messageslist/embed.go b/internal/ui/chat/messageslist/embed.go index 4b54be2..d234019 100644 --- a/internal/ui/chat/messageslist/embed.go +++ b/internal/ui/chat/messageslist/embed.go @@ -53,8 +53,7 @@ func (m Model) embedViews(message discord.Message, root ast.Node) []tview.Widget barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) } - content := &messageView{render: func(int) []text.Span { return m.renderEmbed(message, lines, lineStyles) }} - bar := box.New(content). + bar := box.New(messageText(m.renderEmbed(message, lines, lineStyles))). Borders(tview.BordersLeft). BorderSet(tview.BorderSet{Left: embedBar}). BorderStyle(barStyle). diff --git a/internal/ui/chat/messageslist/message.go b/internal/ui/chat/messageslist/message.go index d4188ea..6921e0d 100644 --- a/internal/ui/chat/messageslist/message.go +++ b/internal/ui/chat/messageslist/message.go @@ -19,56 +19,15 @@ import ( "github.com/yuin/goldmark/v2/ast" ) -// messageViews returns the views of the text of message and of what it replies to. -// They capture the model and the message, so they are made here: capturing them in buildItem would copy both to the heap on every call, as the list builds every item each time it is laid out. -// root is the parsed content of message, or nil to parse it when it is rendered. -func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) { - style := m.cfg.Theme.MessagesList.MessageStyle.Style - content = &messageView{render: func(int) []text.Span { return m.renderMessage(message, root, style, grouped) }} - reply = &messageView{render: func(int) []text.Span { - builder := new(markdown.Builder) - m.drawReply(builder, message, style) - return builder.Finish() - }} - return content, reply -} - func (m Model) blocked(user discord.User) bool { return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID) } -// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws. -type messageView struct { - render func(width int) []text.Span - width int - rendered bool - lines [][]text.Span +// messageText returns spans as text that fills its width, keeping its wrapped lines until the width changes, as the list measures every message when it draws. +func messageText(spans []text.Span) tview.Widget { + return text.Rich(spans...).Width(layout.Fill).Wrapping(text.WrappingWordOrGlyph).Paragraph(new(text.Paragraph)) } -func (v *messageView) wrap(width int) { - if !v.rendered || v.width != width { - v.lines, v.width, v.rendered = text.Lines(v.render(width), width, text.WrappingWordOrGlyph), width, true - } -} - -func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink } - -func (v *messageView) Layout(limits layout.Limits) layout.Size { - return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size { - v.wrap(limits.Max.Width) - return layout.Size{Height: len(v.lines)} - }) -} - -func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { - v.wrap(area.Width) - for i, line := range v.lines[:min(len(v.lines), area.Height)] { - text.Rich(line...).Wrapping(text.WrappingNone).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y + i, Width: area.Width, Height: 1}) - } -} - -func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg } - func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) []text.Span { builder := new(markdown.Builder) m.writeMessage(builder, message, root, baseStyle, grouped) diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 4651356..732a67e 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -27,10 +27,8 @@ import ( type messageItem struct { message discord.Message - // text and reply are the message and what it replies to as wrapped, or nil until they are shown. - text, reply *messageView - // embeds are the embeds of the message, made with text. - embeds []tview.Widget + // view is the message as drawn, keeping its wrapped text, or nil until it is shown and after what it shows changes. + view tview.Widget // separator is the line with the date of the message after it, or nil for a message. separator tview.Widget // grouped reports whether the message is drawn without a header, under that of the message before it. @@ -263,16 +261,22 @@ func (m Model) buildItem(index int) tview.Widget { return item.separator } - message := &item.message - if item.text == nil { - root := m.contentRoot(item) - item.text, item.reply = m.messageViews(*message, root, item.grouped) - item.embeds = m.embedViews(*message, root) + // A voice message shows its player, which changes as it plays. + if item.view == nil || isVoiceMessage(item.message) { + item.view = m.itemView(item) } + return item.view +} + +// itemView returns the message of item with its header or avatar, embeds, previews, reactions and what it replies to. +func (m Model) itemView(item *messageItem) tview.Widget { + message := &item.message + root := m.contentRoot(item) + style := m.cfg.Theme.MessagesList.MessageStyle.Style // Attachments are named in the text, before the embeds. - body := column.New(item.text).Height(layout.Shrink) - for _, embed := range item.embeds { + body := column.New(messageText(m.renderMessage(*message, root, style, item.grouped))).Height(layout.Shrink) + for _, embed := range m.embedViews(*message, root) { body = body.Push(embed) } if isVoiceMessage(*message) { @@ -301,7 +305,9 @@ func (m Model) buildItem(index int) tview.Widget { } if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) { - view = column.New(item.reply, view).Height(layout.Shrink) + builder := new(markdown.Builder) + m.drawReply(builder, *message, style) + view = column.New(messageText(builder.Finish()), view).Height(layout.Shrink) } return view @@ -351,7 +357,7 @@ func (m *Model) rebuildItems() { } // A message that joins or leaves a group is drawn again, with or without its header. if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped { - item.grouped, item.text = grouped, nil + item.grouped, item.view = grouped, nil } if i == m.cursor { cursor = len(items) @@ -499,7 +505,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { m.images[msg.proxy] = preview // An emoji is drawn within the text of the messages that use it, so the text is rendered again. for i := range m.items { - m.items[i].text = nil + m.items[i].view = nil } return m, cmd } @@ -510,6 +516,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } m.items[index].previews[msg.proxy] = preview + m.items[index].view = nil return m, cmd case playMsg: return m, m.play(discord.Attachment(msg)) @@ -643,7 +650,7 @@ func (m Model) canDeleteMessage(message discord.Message) bool { func (m Model) InvalidateRendered() { for i := range m.items { - m.items[i].text, m.items[i].root = nil, nil + m.items[i].view, m.items[i].root = nil, nil } } diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index c1ed2c0..1fc5c02 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -46,15 +46,11 @@ func TestModelBuildItem(t *testing.T) { if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 { t.Fatal("messages are not oldest first") } - // text returns the wrapped text kept for the newest message once it is shown. - text := func() *messageView { - index := ml.messageIndex(len(ml.items), -1) - ml.buildItem(index) - return ml.items[index].text - } - view := text() - if text() != view { - t.Fatal("message text was not reused") + // kept reports whether the newest message keeps the view it was shown with. + kept := func() bool { return ml.items[ml.messageIndex(len(ml.items), -1)].view != nil } + ml.buildItem(ml.messageIndex(len(ml.items), -1)) + if !kept() { + t.Fatal("message view was not kept") } if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil { t.Fatal("older messages were not fetched once on reaching the top") @@ -66,21 +62,21 @@ func TestModelBuildItem(t *testing.T) { ml.selectBottom() *ml, _ = ml.Update(olderMessagesLoadedMsg{channelID: 1, older: []discord.Message{{ID: 1}}}) selected, ok := ml.selectedMessage() - if !ok || selected.ID != 3 || text() != view { - t.Fatal("prepend lost selection or cached text") + if !ok || selected.ID != 3 || !kept() { + t.Fatal("prepend lost selection or cached view") } ml.deleteMessage(ml.messageIndex(-1, 1)) - if text() != view { - t.Fatal("deletion discarded another message's text") + if !kept() { + t.Fatal("deletion discarded another message's view") } ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"}) - edited := text() - if edited == view { - t.Fatal("edit reused stale text") + if kept() { + t.Fatal("edit reused stale view") } + ml.buildItem(ml.messageIndex(len(ml.items), -1)) ml.InvalidateRendered() - if text() == edited { - t.Fatal("global invalidation reused stale text") + if kept() { + t.Fatal("global invalidation reused stale view") } ml.reset() if len(ml.items) != 0 { @@ -278,7 +274,7 @@ func TestModelShouldGroup(t *testing.T) { second := ml.messageIndex(newest, -1) ml.buildItem(second) ml.deleteMessage(ml.messageIndex(-1, 1)) - if first := ml.messageIndex(-1, 1); ml.items[first].grouped || ml.items[first].text != nil { + if first := ml.messageIndex(-1, 1); ml.items[first].grouped || ml.items[first].view != nil { t.Fatal("deleting the first message did not give the next its header") } if ml.AddMessage(message("a", 3*time.Minute, discord.DefaultMessage)); !ml.items[len(ml.items)-1].grouped { @@ -286,7 +282,7 @@ func TestModelShouldGroup(t *testing.T) { } } -func TestMessageViewLayout(t *testing.T) { +func TestModelBuildItemLayout(t *testing.T) { cfg := testConfig(t) cfg.UI.MessagesList.Compact = true ml := newTestModel(t, cfg)