Add support for rendering spoilers. Fixes #331

This commit is contained in:
Tulir Asokan 2022-04-15 15:14:18 +03:00
parent 751a158fbf
commit a5bdba204e
18 changed files with 212 additions and 81 deletions

View File

@ -10,6 +10,8 @@
(thanks to [@tleb] in [#354]).
* Added tab-completion support for `/toggle` options
(thanks to [@n-peugnet] in [#362]).
* Added initial support for rendering spoilers in messages.
* Fixed mentions being lost when editing messages.
* Fixed date change messages showing the wrong date.
* Fixed some whitespace in HTML being rendered even when it shouldn't.

View File

@ -34,7 +34,7 @@ import (
)
type MessageRenderer interface {
Draw(screen mauview.Screen)
Draw(screen mauview.Screen, msg *UIMessage)
NotificationContent() string
PlainText() string
CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage)
@ -324,7 +324,7 @@ func (msg *UIMessage) DrawReactions(screen mauview.Screen) {
func (msg *UIMessage) Draw(screen mauview.Screen) {
proxyScreen := msg.DrawReply(screen)
msg.Renderer.Draw(proxyScreen)
msg.Renderer.Draw(proxyScreen, msg)
msg.DrawReactions(proxyScreen)
if msg.IsSelected {
w, h := screen.Size()

View File

@ -83,7 +83,7 @@ func (msg *ExpandedTextMessage) Height() int {
return len(msg.buffer)
}
func (msg *ExpandedTextMessage) Draw(screen mauview.Screen) {
func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) {
for y, line := range msg.buffer {
line.Draw(screen, 0, y)
}

View File

@ -170,7 +170,7 @@ func (msg *FileMessage) Height() int {
return len(msg.buffer)
}
func (msg *FileMessage) Draw(screen mauview.Screen) {
func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) {
for y, line := range msg.buffer {
line.Draw(screen, 0, y)
}

View File

@ -38,7 +38,7 @@ type BaseEntity struct {
}
// AdjustStyle changes the style of this text entity.
func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
be.Style = fn(be.Style)
return be
}
@ -87,7 +87,7 @@ func (be *BaseEntity) String() string {
}
// CalculateBuffer prepares this entity for rendering with the given parameters.
func (be *BaseEntity) CalculateBuffer(width, startX int, bare bool) int {
func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
be.height = be.DefaultHeight
be.startX = startX
if be.Block {
@ -96,6 +96,6 @@ func (be *BaseEntity) CalculateBuffer(width, startX int, bare bool) int {
return be.startX
}
func (be *BaseEntity) Draw(screen mauview.Screen) {
func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) {
panic("Called Draw() of BaseEntity")
}

View File

@ -40,8 +40,8 @@ func NewBlockquoteEntity(children []Entity) *BlockquoteEntity {
}}
}
func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
be.BaseEntity = be.BaseEntity.AdjustStyle(fn).(*BaseEntity)
func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
return be
}
@ -49,8 +49,8 @@ func (be *BlockquoteEntity) Clone() Entity {
return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)}
}
func (be *BlockquoteEntity) Draw(screen mauview.Screen) {
be.ContainerEntity.Draw(screen)
func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) {
be.ContainerEntity.Draw(screen, ctx)
for y := 0; y < be.height; y++ {
screen.SetContent(0, y, BlockQuoteChar, nil, be.Style)
}

View File

@ -32,8 +32,8 @@ func NewBreakEntity() *BreakEntity {
}
// AdjustStyle changes the style of this text entity.
func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
be.BaseEntity = be.BaseEntity.AdjustStyle(fn).(*BaseEntity)
func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
return be
}
@ -49,6 +49,6 @@ func (be *BreakEntity) String() string {
return "&html.BreakEntity{},\n"
}
func (be *BreakEntity) Draw(screen mauview.Screen) {
func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) {
// No-op, the logic happens in containers
}

View File

@ -46,12 +46,14 @@ func (ce *CodeBlockEntity) Clone() Entity {
}
}
func (ce *CodeBlockEntity) Draw(screen mauview.Screen) {
func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) {
screen.Fill(' ', ce.Background)
ce.ContainerEntity.Draw(screen)
ce.ContainerEntity.Draw(screen, ctx)
}
func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
// Don't allow adjusting code block style.
func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
if reason != AdjustStyleReasonNormal {
ce.ContainerEntity.AdjustStyle(fn, reason)
}
return ce
}

View File

@ -61,15 +61,15 @@ func (ce *ContainerEntity) PlainText() string {
}
// AdjustStyle recursively changes the style of this entity and all its children.
func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
for _, child := range ce.Children {
child.AdjustStyle(fn)
child.AdjustStyle(fn, reason)
}
ce.Style = fn(ce.Style)
return ce
}
// clone creates a deep copy of this base entity.
// Clone creates a deep copy of this base entity.
func (ce *ContainerEntity) Clone() Entity {
children := make([]Entity, len(ce.Children))
for i, child := range ce.Children {
@ -98,7 +98,7 @@ func (ce *ContainerEntity) String() string {
}
// Draw draws this entity onto the given mauview Screen.
func (ce *ContainerEntity) Draw(screen mauview.Screen) {
func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
if len(ce.Children) == 0 {
return
}
@ -110,7 +110,7 @@ func (ce *ContainerEntity) Draw(screen mauview.Screen) {
proxyScreen.OffsetY++
}
proxyScreen.Height = entity.Height()
entity.Draw(proxyScreen)
entity.Draw(proxyScreen, ctx)
proxyScreen.SetStyle(ce.Style)
proxyScreen.OffsetY += entity.Height() - 1
_, isBreak := entity.(*BreakEntity)
@ -122,8 +122,8 @@ func (ce *ContainerEntity) Draw(screen mauview.Screen) {
}
// CalculateBuffer prepares this entity and all its children for rendering with the given parameters
func (ce *ContainerEntity) CalculateBuffer(width, startX int, bare bool) int {
ce.BaseEntity.CalculateBuffer(width, startX, bare)
func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
ce.BaseEntity.CalculateBuffer(width, startX, ctx)
if len(ce.Children) > 0 {
ce.height = 0
childStartX := ce.startX
@ -132,7 +132,7 @@ func (ce *ContainerEntity) CalculateBuffer(width, startX int, bare bool) int {
if entity.IsBlock() || childStartX == 0 || ce.height == 0 {
ce.height++
}
childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, bare)
childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx)
ce.height += entity.Height() - 1
_, isBreak := entity.(*BreakEntity)
if prevBreak && isBreak {

View File

@ -24,11 +24,23 @@ import (
// AdjustStyleFunc is a lambda function type to edit an existing tcell Style.
type AdjustStyleFunc func(tcell.Style) tcell.Style
type AdjustStyleReason int
const (
AdjustStyleReasonNormal AdjustStyleReason = iota
AdjustStyleReasonHideSpoiler
)
type DrawContext struct {
IsSelected bool
BareMessages bool
}
type Entity interface {
// AdjustStyle recursively changes the style of the entity and all its children.
AdjustStyle(AdjustStyleFunc) Entity
AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity
// Draw draws the entity onto the given mauview Screen.
Draw(screen mauview.Screen)
Draw(screen mauview.Screen, ctx DrawContext)
// IsBlock returns whether or not it's a block-type entity.
IsBlock() bool
// GetTag returns the HTML tag of the entity.
@ -43,7 +55,7 @@ type Entity interface {
// Height returns the render height of the entity.
Height() int
// CalculateBuffer prepares the entity and all its children for rendering with the given parameters
CalculateBuffer(width, startX int, bare bool) int
CalculateBuffer(width, startX int, ctx DrawContext) int
getStartX() int

View File

@ -36,8 +36,8 @@ func NewHorizontalLineEntity() *HorizontalLineEntity {
}}
}
func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
he.BaseEntity = he.BaseEntity.AdjustStyle(fn).(*BaseEntity)
func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
return he
}
@ -45,7 +45,7 @@ func (he *HorizontalLineEntity) Clone() Entity {
return NewHorizontalLineEntity()
}
func (he *HorizontalLineEntity) Draw(screen mauview.Screen) {
func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) {
width, _ := screen.Size()
for x := 0; x < width; x++ {
screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style)

View File

@ -59,8 +59,9 @@ func NewListEntity(ordered bool, start int, children []Entity) *ListEntity {
return entity
}
func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
le.BaseEntity = le.BaseEntity.AdjustStyle(fn).(*BaseEntity)
func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
le.ContainerEntity.AdjustStyle(fn, reason)
return le
}
@ -72,7 +73,7 @@ func (le *ListEntity) Clone() Entity {
}
}
func (le *ListEntity) Draw(screen mauview.Screen) {
func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) {
width, _ := screen.Size()
proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style}
@ -85,7 +86,7 @@ func (le *ListEntity) Draw(screen mauview.Screen) {
} else {
screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style)
}
entity.Draw(proxyScreen)
entity.Draw(proxyScreen, ctx)
proxyScreen.SetStyle(le.Style)
proxyScreen.OffsetY += entity.Height()
}

View File

@ -75,22 +75,23 @@ func AdjustStyleBackgroundColor(color tcell.Color) func(tcell.Style) tcell.Style
}
}
func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string {
func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) {
for _, attr := range node.Attr {
if attr.Key == attribute {
return attr.Val
return attr.Val, true
}
}
return ""
return "", false
}
func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string {
val, _ := parser.maybeGetAttribute(node, attribute)
return val
}
func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool {
for _, attr := range node.Attr {
if attr.Key == attribute {
return true
}
}
return false
_, ok := parser.maybeGetAttribute(node, attribute)
return ok
}
func (parser *htmlParser) listToEntity(node *html.Node) Entity {
@ -124,21 +125,25 @@ func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity {
}
switch node.Data {
case "b", "strong":
entity.AdjustStyle(AdjustStyleBold)
entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
case "i", "em":
entity.AdjustStyle(AdjustStyleItalic)
entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal)
case "s", "del", "strike":
entity.AdjustStyle(AdjustStyleStrikethrough)
entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal)
case "u", "ins":
entity.AdjustStyle(AdjustStyleUnderline)
entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal)
case "font", "span":
fgColor, ok := parser.parseColor(node, "data-mx-color", "color")
if ok {
entity.AdjustStyle(AdjustStyleTextColor(fgColor))
entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal)
}
bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color")
if ok {
entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor))
entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal)
}
spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler")
if isSpoiler {
return NewSpoilerEntity(entity, spoilerReason)
}
}
return entity
@ -175,7 +180,7 @@ func (parser *htmlParser) headerToEntity(node *html.Node) Entity {
[]Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")},
parser.nodeToEntities(node.FirstChild)...,
),
}).AdjustStyle(AdjustStyleBold)
}).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal)
}
func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity {
@ -468,7 +473,7 @@ func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.Messa
},
Children: []Entity{
NewTextEntity("* "),
NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(sender))),
NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(sender)), AdjustStyleReasonNormal),
NewTextEntity(" "),
root,
},

120
ui/messages/html/spoiler.go Normal file
View File

@ -0,0 +1,120 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package html
import (
"fmt"
"strings"
"go.mau.fi/mauview"
"go.mau.fi/tcell"
)
type SpoilerEntity struct {
reason string
hidden *ContainerEntity
visible *ContainerEntity
}
const SpoilerColor = tcell.ColorYellow
func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity {
hidden := visible.Clone().(*ContainerEntity)
hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
return style.Foreground(SpoilerColor).Background(SpoilerColor)
}, AdjustStyleReasonHideSpoiler)
if len(reason) > 0 {
reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason))
hidden.Children = append([]Entity{reasonEnt}, hidden.Children...)
visible.Children = append([]Entity{reasonEnt}, visible.Children...)
}
return &SpoilerEntity{
reason: reason,
hidden: hidden,
visible: visible,
}
}
func (se *SpoilerEntity) Clone() Entity {
return &SpoilerEntity{
reason: se.reason,
hidden: se.hidden.Clone().(*ContainerEntity),
visible: se.visible.Clone().(*ContainerEntity),
}
}
func (se *SpoilerEntity) IsBlock() bool {
return false
}
func (se *SpoilerEntity) GetTag() string {
return "span"
}
func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
if ctx.IsSelected {
se.visible.Draw(screen, ctx)
} else {
se.hidden.Draw(screen, ctx)
}
}
func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
if reason != AdjustStyleReasonHideSpoiler {
se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style {
return fn(style).Foreground(SpoilerColor).Background(SpoilerColor)
}, reason)
se.visible.AdjustStyle(fn, reason)
}
return se
}
func (se *SpoilerEntity) PlainText() string {
if len(se.reason) > 0 {
return fmt.Sprintf("spoiler: %s", se.reason)
} else {
return "spoiler"
}
}
func (se *SpoilerEntity) String() string {
var buf strings.Builder
_, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason)
buf.WriteString("\n visible=")
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n "))
buf.WriteString("\n hidden=")
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n "))
buf.WriteString("\n]},")
return buf.String()
}
func (se *SpoilerEntity) Height() int {
return se.visible.Height()
}
func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
se.hidden.CalculateBuffer(width, startX, ctx)
return se.visible.CalculateBuffer(width, startX, ctx)
}
func (se *SpoilerEntity) getStartX() int {
return se.visible.getStartX()
}
func (se *SpoilerEntity) IsEmpty() bool {
return se.visible.IsEmpty()
}

View File

@ -49,8 +49,8 @@ func (te *TextEntity) IsEmpty() bool {
return len(te.Text) == 0
}
func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc) Entity {
te.BaseEntity = te.BaseEntity.AdjustStyle(fn).(*BaseEntity)
func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
return te
}
@ -69,7 +69,7 @@ func (te *TextEntity) String() string {
return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity)
}
func (te *TextEntity) Draw(screen mauview.Screen) {
func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) {
width, _ := screen.Size()
x := te.startX
for y, line := range te.buffer {
@ -78,8 +78,8 @@ func (te *TextEntity) Draw(screen mauview.Screen) {
}
}
func (te *TextEntity) CalculateBuffer(width, startX int, bare bool) int {
te.BaseEntity.CalculateBuffer(width, startX, bare)
func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
te.BaseEntity.CalculateBuffer(width, startX, ctx)
if len(te.Text) == 0 {
return te.startX
}
@ -94,7 +94,7 @@ func (te *TextEntity) CalculateBuffer(width, startX int, bare bool) int {
for {
// TODO add option no wrap and character wrap options
extract := runewidth.Truncate(text, width-textStartX, "")
extract, wordWrapped := trim(extract, text, bare)
extract, wordWrapped := trim(extract, text, ctx.BareMessages)
if !wordWrapped && textStartX > 0 {
if bufPtr < len(te.buffer) {
te.buffer[bufPtr] = ""

View File

@ -28,9 +28,7 @@ import (
type HTMLMessage struct {
Root html.Entity
FocusedBg tcell.Color
TextColor tcell.Color
focused bool
}
func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage {
@ -41,15 +39,11 @@ func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *U
func (hw *HTMLMessage) Clone() MessageRenderer {
return &HTMLMessage{
Root: hw.Root.Clone(),
FocusedBg: hw.FocusedBg,
Root: hw.Root.Clone(),
}
}
func (hw *HTMLMessage) Draw(screen mauview.Screen) {
if hw.focused {
screen.SetStyle(tcell.StyleDefault.Background(hw.FocusedBg).Foreground(hw.TextColor))
}
func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) {
if hw.TextColor != tcell.ColorDefault {
hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style {
fg, _, _ := style.Decompose()
@ -57,18 +51,10 @@ func (hw *HTMLMessage) Draw(screen mauview.Screen) {
return style.Foreground(hw.TextColor)
}
return style
})
}, html.AdjustStyleReasonNormal)
}
screen.Clear()
hw.Root.Draw(screen)
}
func (hw *HTMLMessage) Focus() {
hw.focused = true
}
func (hw *HTMLMessage) Blur() {
hw.focused = false
hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected})
}
func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool {
@ -90,7 +76,10 @@ func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width
// TODO account for bare messages in initial startX
startX := 0
hw.TextColor = msg.TextColor()
hw.Root.CalculateBuffer(width, startX, preferences.BareMessageView)
hw.Root.CalculateBuffer(width, startX, html.DrawContext{
IsSelected: msg.IsSelected,
BareMessages: preferences.BareMessageView,
})
}
func (hw *HTMLMessage) Height() int {

View File

@ -59,7 +59,7 @@ const RedactionMaxWidth = 40
var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0))
func (msg *RedactedMessage) Draw(screen mauview.Screen) {
func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) {
w, _ := screen.Size()
for x := 0; x < w && x < RedactionMaxWidth; x++ {
screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle)

View File

@ -21,9 +21,9 @@ import (
"time"
"go.mau.fi/mauview"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/ui/messages/tstring"
)
@ -95,7 +95,7 @@ func (msg *TextMessage) Height() int {
return len(msg.buffer)
}
func (msg *TextMessage) Draw(screen mauview.Screen) {
func (msg *TextMessage) Draw(screen mauview.Screen, _ *UIMessage) {
for y, line := range msg.buffer {
line.Draw(screen, 0, y)
}