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)))
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue