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)))
}
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).

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)