perf(messageslist): keep each message's built view instead of rebuilding it on every draw (#850)
This commit is contained in:
parent
1bcd7650c8
commit
cb4c82d3e1
4 changed files with 42 additions and 81 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue