perf(messageslist): keep each message's built view instead of rebuilding it on every draw (#850)

This commit is contained in:
ayn2op 2026-10-18 22:40:59 -04:00 • committed by GitHub
parent 1bcd7650c8
commit cb4c82d3e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 42 additions and 81 deletions

View file

@ -53,8 +53,7 @@ func (m Model) embedViews(message discord.Message, root ast.Node) []tview.Widget
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
} }
content := &messageView{render: func(int) []text.Span { return m.renderEmbed(message, lines, lineStyles) }} bar := box.New(messageText(m.renderEmbed(message, lines, lineStyles))).
bar := box.New(content).
Borders(tview.BordersLeft). Borders(tview.BordersLeft).
BorderSet(tview.BorderSet{Left: embedBar}). BorderSet(tview.BorderSet{Left: embedBar}).
BorderStyle(barStyle). BorderStyle(barStyle).

View file

@ -19,56 +19,15 @@ import (
"github.com/yuin/goldmark/v2/ast" "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 { func (m Model) blocked(user discord.User) bool {
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID) 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. // 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.
type messageView struct { func messageText(spans []text.Span) tview.Widget {
render func(width int) []text.Span return text.Rich(spans...).Width(layout.Fill).Wrapping(text.WrappingWordOrGlyph).Paragraph(new(text.Paragraph))
width int
rendered bool
lines [][]text.Span
} }
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 { func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) []text.Span {
builder := new(markdown.Builder) builder := new(markdown.Builder)
m.writeMessage(builder, message, root, baseStyle, grouped) m.writeMessage(builder, message, root, baseStyle, grouped)

View file

@ -27,10 +27,8 @@ import (
type messageItem struct { type messageItem struct {
message discord.Message message discord.Message
// text and reply are the message and what it replies to as wrapped, or nil until they are shown. // view is the message as drawn, keeping its wrapped text, or nil until it is shown and after what it shows changes.
text, reply *messageView view tview.Widget
// embeds are the embeds of the message, made with text.
embeds []tview.Widget
// separator is the line with the date of the message after it, or nil for a message. // separator is the line with the date of the message after it, or nil for a message.
separator tview.Widget separator tview.Widget
// grouped reports whether the message is drawn without a header, under that of the message before it. // 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 return item.separator
} }
message := &item.message // A voice message shows its player, which changes as it plays.
if item.text == nil { if item.view == nil || isVoiceMessage(item.message) {
root := m.contentRoot(item) item.view = m.itemView(item)
item.text, item.reply = m.messageViews(*message, root, item.grouped)
item.embeds = m.embedViews(*message, root)
} }
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. // Attachments are named in the text, before the embeds.
body := column.New(item.text).Height(layout.Shrink) body := column.New(messageText(m.renderMessage(*message, root, style, item.grouped))).Height(layout.Shrink)
for _, embed := range item.embeds { for _, embed := range m.embedViews(*message, root) {
body = body.Push(embed) body = body.Push(embed)
} }
if isVoiceMessage(*message) { if isVoiceMessage(*message) {
@ -301,7 +305,9 @@ func (m Model) buildItem(index int) tview.Widget {
} }
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) { 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 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. // 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 { 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 { if i == m.cursor {
cursor = len(items) cursor = len(items)
@ -499,7 +505,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.images[msg.proxy] = preview m.images[msg.proxy] = preview
// An emoji is drawn within the text of the messages that use it, so the text is rendered again. // An emoji is drawn within the text of the messages that use it, so the text is rendered again.
for i := range m.items { for i := range m.items {
m.items[i].text = nil m.items[i].view = nil
} }
return m, cmd 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].previews[msg.proxy] = preview
m.items[index].view = nil
return m, cmd return m, cmd
case playMsg: case playMsg:
return m, m.play(discord.Attachment(msg)) return m, m.play(discord.Attachment(msg))
@ -643,7 +650,7 @@ func (m Model) canDeleteMessage(message discord.Message) bool {
func (m Model) InvalidateRendered() { func (m Model) InvalidateRendered() {
for i := range m.items { 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
} }
} }

View file

@ -46,15 +46,11 @@ func TestModelBuildItem(t *testing.T) {
if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 { if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 {
t.Fatal("messages are not oldest first") t.Fatal("messages are not oldest first")
} }
// text returns the wrapped text kept for the newest message once it is shown. // kept reports whether the newest message keeps the view it was shown with.
text := func() *messageView { kept := func() bool { return ml.items[ml.messageIndex(len(ml.items), -1)].view != nil }
index := ml.messageIndex(len(ml.items), -1) ml.buildItem(ml.messageIndex(len(ml.items), -1))
ml.buildItem(index) if !kept() {
return ml.items[index].text t.Fatal("message view was not kept")
}
view := text()
if text() != view {
t.Fatal("message text was not reused")
} }
if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil { if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil {
t.Fatal("older messages were not fetched once on reaching the top") t.Fatal("older messages were not fetched once on reaching the top")
@ -66,21 +62,21 @@ func TestModelBuildItem(t *testing.T) {
ml.selectBottom() ml.selectBottom()
*ml, _ = ml.Update(olderMessagesLoadedMsg{channelID: 1, older: []discord.Message{{ID: 1}}}) *ml, _ = ml.Update(olderMessagesLoadedMsg{channelID: 1, older: []discord.Message{{ID: 1}}})
selected, ok := ml.selectedMessage() selected, ok := ml.selectedMessage()
if !ok || selected.ID != 3 || text() != view { if !ok || selected.ID != 3 || !kept() {
t.Fatal("prepend lost selection or cached text") t.Fatal("prepend lost selection or cached view")
} }
ml.deleteMessage(ml.messageIndex(-1, 1)) ml.deleteMessage(ml.messageIndex(-1, 1))
if text() != view { if !kept() {
t.Fatal("deletion discarded another message's text") t.Fatal("deletion discarded another message's view")
} }
ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"}) ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"})
edited := text() if kept() {
if edited == view { t.Fatal("edit reused stale view")
t.Fatal("edit reused stale text")
} }
ml.buildItem(ml.messageIndex(len(ml.items), -1))
ml.InvalidateRendered() ml.InvalidateRendered()
if text() == edited { if kept() {
t.Fatal("global invalidation reused stale text") t.Fatal("global invalidation reused stale view")
} }
ml.reset() ml.reset()
if len(ml.items) != 0 { if len(ml.items) != 0 {
@ -278,7 +274,7 @@ func TestModelShouldGroup(t *testing.T) {
second := ml.messageIndex(newest, -1) second := ml.messageIndex(newest, -1)
ml.buildItem(second) ml.buildItem(second)
ml.deleteMessage(ml.messageIndex(-1, 1)) 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") 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 { 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 := testConfig(t)
cfg.UI.MessagesList.Compact = true cfg.UI.MessagesList.Compact = true
ml := newTestModel(t, cfg) ml := newTestModel(t, cfg)