diff --git a/go.mod b/go.mod index a198a3f..404dc61 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/alecthomas/chroma/v2 v2.27.0 github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 - github.com/ayn2op/tview v0.0.0-20261015233535-ab884c65f4fe + github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 @@ -24,7 +24,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/mattn/go-shellwords v1.0.16 github.com/ncruces/zenity v0.10.15 - github.com/rivo/uniseg v0.4.7 + github.com/rockorager/go-uucode v1.2.2 github.com/sahilm/fuzzy v0.1.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 @@ -50,7 +50,7 @@ require ( github.com/danieljoos/wincred v1.2.3 // indirect github.com/dchest/jsmin v1.0.0 // indirect github.com/disgoorg/godave v0.3.0 // indirect - github.com/dlclark/regexp2/v2 v2.9.0 // indirect + github.com/dlclark/regexp2/v2 v2.9.1 // indirect github.com/ebitengine/purego v0.11.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.2.4 // indirect @@ -62,7 +62,7 @@ require ( github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/thomas-vilte/mls-go v1.6.0 // indirect - github.com/tphakala/simd v1.12.0 // indirect + github.com/tphakala/simd v1.13.0 // indirect github.com/twmb/murmur3 v1.2.1 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.design/x/x11 v0.2.0 // indirect diff --git a/go.sum b/go.sum index 4293182..3a3ae44 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935 h1:oVxkY1/nVsVQ8Cn github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks= -github.com/ayn2op/tview v0.0.0-20261015233535-ab884c65f4fe h1:XBuBkzSmoomT6agxYt2d5+1WNF4T+AFuI52lZsrD8/I= -github.com/ayn2op/tview v0.0.0-20261015233535-ab884c65f4fe/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= +github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f h1:f3Kiqss18xrNTzyB5MfzVTGfrX1tUwMPENmku8BdNGg= +github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= @@ -42,8 +42,8 @@ github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= github.com/disgoorg/godave v0.3.0 h1:37F3ZuiMd8/EXeoCtTeE8iP2eT2nWsfEa4/ITwoKfe4= github.com/disgoorg/godave v0.3.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y= -github.com/dlclark/regexp2/v2 v2.9.0 h1:kKj+BlRuUhZzJk8B3yOBxWKxDFnx2Ilq4e7KNdq/Mxk= -github.com/dlclark/regexp2/v2 v2.9.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dlclark/regexp2/v2 v2.9.1 h1:q6bZW/NN9VodqMjYXpuic2lZxdHPfFTzGS+MJxIJmKQ= +github.com/dlclark/regexp2/v2 v2.9.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -76,8 +76,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 h1:m1yKMZwDSXkT5o2MKhd6ihdzb2dYb6eElNE04xjOSEY= github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rockorager/go-uucode v1.2.2 h1:bqVVrZtdwb8v4OB5BIw/aSAKXvGMbOjQwDTHsRC7XhU= +github.com/rockorager/go-uucode v1.2.2/go.mod h1:0BZXIGRvWIHt1ruBeViNGcuKyQ3+BRHQbgyXkqRCz38= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= @@ -96,8 +96,8 @@ github.com/thomas-vilte/mls-go v1.6.0 h1:HGxrRe0kRfEAXu7MtTemNzqGmC/758rfGkarhYG github.com/thomas-vilte/mls-go v1.6.0/go.mod h1:Cxl407ecAW94wwYLOycpQUp0UyfBk6dNmvs1hkw2yLg= github.com/tphakala/go-opus v1.2.1 h1:j3DlcDf+9VTUzOEZ6kODoZoLMb/MTN2+ft24VuO8FaQ= github.com/tphakala/go-opus v1.2.1/go.mod h1:CzYWGAXjMJHIqu/aTowxgmut2D0+OFJCjQ9+YJHDJV0= -github.com/tphakala/simd v1.12.0 h1:qjCO4f/ega4+2znt8bp+lb6R8a7bSri+/KpH+cGHOrA= -github.com/tphakala/simd v1.12.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE= +github.com/tphakala/simd v1.13.0 h1:toN0PH/OIyPlaSaaQQ9Z/IwjVFrrAq2iJsi0Lbtlm2Q= +github.com/tphakala/simd v1.13.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE= github.com/twmb/murmur3 v1.2.1 h1:TJzZGygOc1VJ7jiBpy8m7KAAzm67zy1jvkUxxEjJp+g= github.com/twmb/murmur3 v1.2.1/go.mod h1:LVqs/avIy0flspVtmBd4VSbogDepFl6Wkj0i0ToccHE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= diff --git a/internal/markdown/builder.go b/internal/markdown/builder.go new file mode 100644 index 0000000..185159b --- /dev/null +++ b/internal/markdown/builder.go @@ -0,0 +1,46 @@ +package markdown + +import ( + "strings" + + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/text" +) + +// Builder builds the spans of a text. +type Builder struct { + spans []text.Span +} + +// Write appends value in style. +func (b *Builder) Write(value string, style tview.Style) { + if value != "" { + b.spans = append(b.spans, text.Span{Text: value, Style: style}) + } +} + +// WriteSpans appends spans. +func (b *Builder) WriteSpans(spans ...text.Span) { + b.spans = append(b.spans, spans...) +} + +// LineEmpty reports whether nothing was written since the last line break. +func (b *Builder) LineEmpty() bool { + return len(b.spans) == 0 || strings.HasSuffix(b.spans[len(b.spans)-1].Text, "\n") +} + +// NewLine starts a new line. +func (b *Builder) NewLine() { + b.Write("\n", tview.Style{}) +} + +// Finish returns the spans, without a line break at the end, which would add an empty line. +func (b *Builder) Finish() []text.Span { + if n := len(b.spans); n > 0 { + last := &b.spans[n-1] + if last.Text = strings.TrimSuffix(last.Text, "\n"); last.Text == "" { + b.spans = b.spans[:n-1] + } + } + return b.spans +} diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 1e38636..a9e5075 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -10,7 +10,7 @@ import ( md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/text" "github.com/gdamore/tcell/v3" "github.com/yuin/goldmark/v2/ast" ) @@ -25,8 +25,8 @@ func NewRenderer(cfg *config.Config) *Renderer { return &Renderer{cfg: cfg} } -func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) richtext.Text { - builder := new(richtext.Builder) +func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) []text.Span { + builder := new(Builder) styleStack := []tview.Style{base} linkDepth := 0 listDepth := 0 @@ -112,7 +112,7 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) ri return builder.Finish() } -func (r *Renderer) renderCodeBlock(builder *richtext.Builder, source []byte, node *ast.CodeBlock, base tview.Style) { +func (r *Renderer) renderCodeBlock(builder *Builder, source []byte, node *ast.CodeBlock, base tview.Style) { var code strings.Builder lines := node.Value.Segments() for _, line := range lines { diff --git a/internal/markdown/renderer_test.go b/internal/markdown/renderer_test.go index 56a601c..a4108fb 100644 --- a/internal/markdown/renderer_test.go +++ b/internal/markdown/renderer_test.go @@ -26,14 +26,12 @@ func TestRendererRenderText(t *testing.T) { t.Run(test.name, func(t *testing.T) { source := []byte(test.source) rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, md.Parse(source), tcell.StyleDefault) - lines := make([]string, len(rendered)) - for i, line := range rendered { - for _, segment := range line { - lines[i] += segment.Text - } + var got strings.Builder + for _, span := range rendered { + got.WriteString(span.Text) } - if got := strings.Join(lines, "\n"); got != test.want { - t.Fatalf("got %q, want %q", got, test.want) + if got.String() != test.want { + t.Fatalf("got %q, want %q", got.String(), test.want) } }) } diff --git a/internal/ui/chat/composer/model.go b/internal/ui/chat/composer/model.go index 828cabb..9bcc90e 100644 --- a/internal/ui/chat/composer/model.go +++ b/internal/ui/chat/composer/model.go @@ -31,7 +31,7 @@ import ( "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/textarea" "github.com/gdamore/tcell/v3" - "github.com/rivo/uniseg" + "github.com/rockorager/go-uucode" "github.com/yuin/goldmark/v2/ast" "golang.design/x/clipboard" ) @@ -556,7 +556,7 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle { c := p.c size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}}) value, cursor := c.text, c.cursor - col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) + col := uucode.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width) return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height} } diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index f1013cd..afc89e9 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -18,7 +18,7 @@ import ( "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" - "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/text" "github.com/ayn2op/tview/tree" "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" @@ -128,7 +128,7 @@ func (m *Model) rebuild(event *gateway.ReadyEvent) { func (m Model) refreshReadStyles(event *read.UpdateEvent) { if event.GuildID.IsValid() { if node := m.findNodeByReference(event.GuildID); node != nil { - setNodeLineStyle(node, m.guildNodeStyle(event.GuildID)) + setNodeNameStyle(node, m.guildNodeStyle(event.GuildID)) } } @@ -139,10 +139,10 @@ func (m Model) refreshReadStyles(event *read.UpdateEvent) { channel, err := m.state.Cabinet.Channel(event.ChannelID) if err != nil { indication := m.state.ChannelIsUnread(event.ChannelID, ningen.UnreadOpts{IncludeMutedCategories: true}) - setNodeLineStyle(node, unreadStyle(indication)) + setNodeNameStyle(node, unreadStyle(indication)) return } - setNodeLineStyle(node, m.channelNodeStyle(*channel)) + setNodeNameStyle(node, m.channelNodeStyle(*channel)) } func (m Model) updateDMNodeStyle(userID discord.UserID) { @@ -155,7 +155,7 @@ func (m Model) updateDMNodeStyle(userID discord.UserID) { if node == nil || !ok { return } - setNodeLineStyle(node, m.channelNodeStyle(*channel)) + setNodeNameStyle(node, m.channelNodeStyle(*channel)) } func (m Model) moveDMToFront(channelID discord.ChannelID) { @@ -180,7 +180,7 @@ func (m Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[disco folderNode := tree.NewNode(name).SetExpanded(m.cfg.UI.GuildsTree.AutoExpandFolders) if folder.Color != 0 { folderStyle := tcell.StyleDefault.Foreground(tcell.NewHexColor(int32(folder.Color))) - setNodeLineStyle(folderNode, folderStyle) + setNodeNameStyle(folderNode, folderStyle) } m.root.AddChild(folderNode) @@ -217,7 +217,7 @@ func (m Model) createGuildNode(parent *tree.Node, guild discord.Guild) { SetExpandable(true). SetExpanded(false). SetIndent(m.cfg.Sidebar.Indents.Guild) - setNodeLineStyle(guildNode, m.guildNodeStyle(guild.ID)) + setNodeNameStyle(guildNode, m.guildNodeStyle(guild.ID)) parent.AddChild(guildNode) m.nodes[discord.Snowflake(guild.ID)] = guildNode } @@ -230,7 +230,7 @@ func (m Model) createChannelNode(parent *tree.Node, channel discord.Channel) { indents := m.cfg.Sidebar.Indents channelNode := tree.NewNode(ui.ChannelToString(channel, m.cfg.Icons, m.state)).SetReference(channel.ID) - setNodeLineStyle(channelNode, m.channelNodeStyle(channel)) + setNodeNameStyle(channelNode, m.channelNodeStyle(channel)) switch channel.Type { case discord.DirectMessage: channelNode.SetIndent(indents.DM) @@ -274,13 +274,13 @@ func (m *Model) showVoiceMembers(guildID discord.GuildID) { } // After its name, how full it is, if it can be. - line := node.Line()[:1] + spans := node.Spans()[:1] if channel.VoiceUserLimit > 0 { label := fmt.Sprintf("%d/%d", len(node.Children()), channel.VoiceUserLimit) - line = append(line, richtext.NewSegment(" ", tview.Style{}), richtext.NewSegment(label, m.cfg.Theme.GuildsTree.VoiceLimitStyle.Style)) + spans = append(spans, text.Span{Text: " "}, text.Span{Text: label, Style: m.cfg.Theme.GuildsTree.VoiceLimitStyle.Style}) } - node.SetLine(line) + node.SetSpans(spans...) } m.styleVoiceMembers() @@ -327,7 +327,7 @@ func (m Model) styleVoiceMembers() { style = m.cfg.Theme.GuildsTree.SpeakingStyle.Style } - setNodeLineStyle(member, style) + setNodeNameStyle(member, style) } } @@ -438,11 +438,11 @@ func isVoice(t discord.ChannelType) bool { return t == discord.GuildVoice || t == discord.GuildStageVoice } -// setNodeLineStyle styles the name of node, which is the first segment of its line. -func setNodeLineStyle(node *tree.Node, style tview.Style) { - line := node.Line() - line[0].Style = style - node.SetLine(line) +// setNodeNameStyle styles the name of node, which is the first span of its text. +func setNodeNameStyle(node *tree.Node, style tview.Style) { + spans := node.Spans() + spans[0].Style = style + node.SetSpans(spans...) } func (m Model) createChannelNodes(node *tree.Node, channels []discord.Channel) { diff --git a/internal/ui/chat/memberstree/model.go b/internal/ui/chat/memberstree/model.go index 2ef15e8..ae07f42 100644 --- a/internal/ui/chat/memberstree/model.go +++ b/internal/ui/chat/memberstree/model.go @@ -14,7 +14,7 @@ import ( "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/keybind" - "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/text" "github.com/ayn2op/tview/tree" "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" @@ -251,7 +251,7 @@ func (m Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListGr ref := groupRef(group.ID) node := m.reuse(ref.String()). - SetLine(richtext.NewLine(richtext.NewSegment(name, style))). + SetSpans(text.Span{Text: name, Style: style}). SetReference(ref). SetExpandable(true) m.root.AddChild(node) @@ -269,21 +269,17 @@ func (m Model) addMember(group *tree.Node, guildID discord.GuildID, member disco if ok { nameStyle = nameStyle.Foreground(tcell.NewHexColor(int32(color))) } - line := richtext.NewLine( - richtext.NewSegment(statusMarker, tcell.StyleDefault), - richtext.NewSegment(" ", tcell.StyleDefault), - richtext.NewSegment(name, nameStyle), - ) - m.setStatus(line, m.status(guildID, member.User.ID)) - group.AddChild(m.reuse(member.User.ID.String()).SetLine(line).SetReference(memberRef{userID: member.User.ID, index: index})) + spans := []text.Span{{Text: statusMarker}, {Text: " "}, {Text: name, Style: nameStyle}} + m.setStatus(spans, m.status(guildID, member.User.ID)) + group.AddChild(m.reuse(member.User.ID.String()).SetSpans(spans...).SetReference(memberRef{userID: member.User.ID, index: index})) } const statusMarker = "●" -// setStatus styles the status dot and name of a member line for status. -func (m Model) setStatus(line richtext.Line, status discord.Status) { - line[0].Style = m.cfg.Theme.MembersTree.StatusStyle(status) - line[2].Style = line[2].Style.Dim(status == discord.OfflineStatus) +// setStatus styles the status dot and name of a member's spans for status. +func (m Model) setStatus(spans []text.Span, status discord.Status) { + spans[0].Style = m.cfg.Theme.MembersTree.StatusStyle(status) + spans[2].Style = spans[2].Style.Dim(status == discord.OfflineStatus) } // reuse takes the node of id out of the index, so that it is reused once, or else returns a new node. Either way the node has no children. @@ -298,9 +294,9 @@ func (m Model) reuse(id string) *tree.Node { func (m Model) refreshStatus(userID discord.UserID) { if node, ok := m.nodes[userID.String()]; ok { - line := node.Line() - m.setStatus(line, m.status(m.channel.GuildID, userID)) - node.SetLine(line) + spans := node.Spans() + m.setStatus(spans, m.status(m.channel.GuildID, userID)) + node.SetSpans(spans...) } } diff --git a/internal/ui/chat/memberstree/model_test.go b/internal/ui/chat/memberstree/model_test.go index d0e2317..8e063e9 100644 --- a/internal/ui/chat/memberstree/model_test.go +++ b/internal/ui/chat/memberstree/model_test.go @@ -52,18 +52,18 @@ func setGuildMembers(t *testing.T, m Model, items string) *discord.Channel { func lines(m Model) []string { var lines []string for _, group := range m.root.Children() { - lines = append(lines, text(group)) + lines = append(lines, nodeText(group)) for _, member := range group.Children() { - lines = append(lines, " "+text(member)) + lines = append(lines, " "+nodeText(member)) } } return lines } -func text(node *tree.Node) string { +func nodeText(node *tree.Node) string { var s string - for _, segment := range node.Line() { - s += segment.Text + for _, span := range node.Spans() { + s += span.Text } return s } diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index 59d09cd..9779193 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -8,7 +8,7 @@ import ( "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/viewport" - "github.com/rivo/uniseg" + "github.com/rockorager/go-uucode" ) // Model shows the mention suggestions as a list in a box. @@ -91,7 +91,7 @@ func (m Model) SelectedInsertText() (string, bool) { func (m Model) maxDisplayWidth() int { width := 0 for _, item := range m.items { - width = max(width, uniseg.StringWidth(item.DisplayText)) + width = max(width, uucode.StringWidth(item.DisplayText)) } return width } diff --git a/internal/ui/chat/messageslist/embed.go b/internal/ui/chat/messageslist/embed.go index e568e47..44bb5e5 100644 --- a/internal/ui/chat/messageslist/embed.go +++ b/internal/ui/chat/messageslist/embed.go @@ -6,10 +6,11 @@ import ( "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/richtext" + "github.com/ayn2op/tview/text" "github.com/gdamore/tcell/v3" - "github.com/rivo/uniseg" + "github.com/rockorager/go-uucode" "github.com/yuin/goldmark/v2/ast" ) @@ -17,9 +18,9 @@ import ( const embedPrefix = " ▎ " // embedPrefixWidth is how many cells embedPrefix takes. -var embedPrefixWidth = uniseg.StringWidth(embedPrefix) +var embedPrefixWidth = uucode.StringWidth(embedPrefix) -func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) { +func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) { if len(message.Embeds) == 0 { return } @@ -48,12 +49,11 @@ func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, ba continue } - embedText := make(richtext.Text, 0, len(lines)*2) prefixStyle := defaultPrefixStyle if embed.Color != discord.NullColor && embed.Color != 0 { prefixStyle = prefixStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) } - prefix := richtext.NewSegment(embedPrefix, prefixStyle) + prefix := text.Span{Text: embedPrefix, Style: prefixStyle} builder.NewLine() for _, line := range lines { if strings.TrimSpace(line.text) == "" { @@ -64,27 +64,27 @@ func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, ba lineStyle := lineStyles[line.kind] // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. rendered, _, _ := m.renderContent(msg, lineStyle, line.kind == embedLineDescription) - for _, renderedLine := range rendered { - if line.url != "" { - renderedLine = lineWithURL(renderedLine, line.url) - } - // The prefix is added after wrapping, so every visual line has it, not only the first. - for _, wrapped := range richtext.Wrap(renderedLine, wrapWidth) { - embedText = append(embedText, append(richtext.Line{prefix}, wrapped...)) - } + if line.url != "" { + rendered = spansWithURL(rendered, line.url) } - } - if len(embedText) > 0 { - builder.WriteText(embedText) + // The prefix is added after wrapping, so every visual line has it, not only the first. + for _, wrapped := range text.Lines(rendered, wrapWidth, text.WrappingGlyph) { + if !builder.LineEmpty() { + builder.NewLine() + } + + builder.WriteSpans(prefix) + builder.WriteSpans(wrapped...) + } } } } -func lineWithURL(line richtext.Line, rawURL string) richtext.Line { - out := make(richtext.Line, len(line)) - for i, segment := range line { - out[i] = segment +func spansWithURL(spans []text.Span, rawURL string) []text.Span { + out := make([]text.Span, len(spans)) + for i, span := range spans { + out[i] = span out[i].Style = out[i].Style.Url(rawURL) } return out diff --git a/internal/ui/chat/messageslist/message.go b/internal/ui/chat/messageslist/message.go index 7b2d980..3226eb9 100644 --- a/internal/ui/chat/messageslist/message.go +++ b/internal/ui/chat/messageslist/message.go @@ -10,10 +10,10 @@ import ( "github.com/ayn2op/arikawa/v3/discord" md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/arikawa/v3/state" + "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/tview" "github.com/ayn2op/tview/layout" - "github.com/ayn2op/tview/richtext" - "github.com/ayn2op/tview/textview" + "github.com/ayn2op/tview/text" "github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3/color" "github.com/yuin/goldmark/v2/ast" @@ -21,15 +21,15 @@ import ( // 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. -func (m Model) messageViews(message discord.Message, grouped bool) (text, reply *messageView) { +func (m Model) messageViews(message discord.Message, grouped bool) (content, reply *messageView) { style := m.cfg.Theme.MessagesList.MessageStyle.Style - text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width, grouped) }} - reply = &messageView{render: func(int) richtext.Text { - builder := new(richtext.Builder) + content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, style, width, grouped) }} + reply = &messageView{render: func(int) []text.Span { + builder := new(markdown.Builder) m.drawReply(builder, message, style) return builder.Finish() }} - return text, reply + return content, reply } func (m Model) blocked(user discord.User) bool { @@ -38,33 +38,32 @@ func (m Model) blocked(user discord.User) bool { // 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) richtext.Text + render func(width int) []text.Span width int rendered bool - lines richtext.Text + lines [][]text.Span } -func (v *messageView) at(width int) textview.Widget { +func (v *messageView) wrap(width int) { if !v.rendered || v.width != width { - v.lines, v.width, v.rendered = nil, width, true - for _, line := range v.render(width) { - v.lines = append(v.lines, richtext.WrapWords(line, width)...) - } + v.lines, v.width, v.rendered = text.Lines(v.render(width), width, text.WrappingWordOrGlyph), width, true } - return textview.New(v.lines).Wrap(false) } 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.at(limits.Max.Width) + v.wrap(limits.Max.Width) return layout.Size{Height: len(v.lines)} }) } func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { - v.at(area.Width).Draw(screen, area) + 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 } @@ -95,18 +94,25 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) { func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } -func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int, grouped bool) richtext.Text { - builder := new(richtext.Builder) +func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int, grouped bool) []text.Span { + builder := new(markdown.Builder) m.writeMessage(builder, message, baseStyle, width, grouped) - text := builder.Finish() - // Without a header or content, the first line is the empty one that attachments and embeds start below. - if len(text) > 1 && len(text[0]) == 0 { - text = text[1:] - } - return text + // Without a header or content, the message starts with the line break that attachments and embeds start below. + return trimLeadingLineBreaks(builder.Finish()) } -func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { +// trimLeadingLineBreaks returns spans without the line breaks they start with. +func trimLeadingLineBreaks(spans []text.Span) []text.Span { + for len(spans) > 0 { + if spans[0].Text = strings.TrimLeft(spans[0].Text, "\n"); spans[0].Text != "" { + break + } + spans = spans[1:] + } + return spans +} + +func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { if m.blocked(message.Author) { builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) return @@ -131,7 +137,7 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, m.drawReactions(builder, message.Reactions, baseStyle) } -func (m Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tview.Style) { +func (m Model) drawReactions(builder *markdown.Builder, reactions []discord.Reaction, baseStyle tview.Style) { if len(reactions) == 0 { return } @@ -161,7 +167,7 @@ func (m Model) formatTimestamp(ts discord.Timestamp) string { const editedIndicator = " (edited)" // drawLayout draws the header of message, unless it is grouped, and its content. -func (m Model) drawLayout(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { +func (m Model) drawLayout(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { if m.cfg.UI.MessagesList.Compact { m.drawCompact(builder, message, baseStyle, grouped, content) return @@ -170,7 +176,7 @@ func (m Model) drawLayout(builder *richtext.Builder, message discord.Message, ba m.drawCozy(builder, message, baseStyle, grouped, content) } -func (m Model) drawCompact(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { +func (m Model) drawCompact(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { dimStyle := baseStyle.Dim(true) if m.cfg.Timestamps.Enabled { builder.Write(m.formatTimestamp(message.Timestamp)+" ", dimStyle) @@ -191,7 +197,7 @@ func (m Model) drawCompact(builder *richtext.Builder, message discord.Message, b } } -func (m Model) drawCozy(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { +func (m Model) drawCozy(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) { dimStyle := baseStyle.Dim(true) if grouped { if content != nil { @@ -218,7 +224,7 @@ func (m Model) drawCozy(builder *richtext.Builder, message discord.Message, base } } -func (m Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) { +func (m Model) drawAuthor(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) { name := message.Author.DisplayOrUsername() foreground := tcell.ColorDefault @@ -256,7 +262,7 @@ func (m Model) memberForMessage(message discord.Message) *discord.Member { // drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds). // root is nil when markdown rendering is disabled. -func (m Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) { +func (m Model) drawContent(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) { content, root, source := m.renderContent(message, baseStyle, false) if m.cfg.Markdown.Enabled { startsWithCodeBlock := false @@ -266,26 +272,18 @@ func (m Model) drawContent(builder *richtext.Builder, message discord.Message, b } } - if startsWithCodeBlock { - // Keep code blocks visually separate from "timestamp + author". - if !builder.LineEmpty() { - builder.NewLine() - } - - for len(content) > 0 && len(content[0]) == 0 { - content = content[1:] - } - } else { - for len(content) > 1 && len(content[0]) == 0 { - content = content[1:] - } + // Keep code blocks visually separate from "timestamp + author". + if startsWithCodeBlock && !builder.LineEmpty() { + builder.NewLine() } + + content = trimLeadingLineBreaks(content) } - builder.WriteText(content) + builder.WriteSpans(content...) return root, source } -func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) { +func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) ([]text.Span, ast.Node, []byte) { // Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both. if forceMarkdown || m.cfg.Markdown.Enabled { c := []byte(message.Content) @@ -293,12 +291,12 @@ func (m Model) renderContent(message discord.Message, baseStyle tview.Style, for return m.renderer.RenderText(c, root, baseStyle), root, c } - b := new(richtext.Builder) + b := new(markdown.Builder) b.Write(message.Content, baseStyle) return b.Finish(), nil, nil } -func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) { +func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) { // Convert discord.MessageSnapshotMessage to discord.Message with common fields. message := discord.Message{ Type: snapshot.Type, @@ -318,7 +316,7 @@ func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Mes m.drawContent(builder, message, baseStyle) } -func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { +func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) { var contentRoot ast.Node var contentSource []byte content := func() { contentRoot, contentSource = m.drawContent(builder, message, baseStyle) } @@ -347,7 +345,7 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes } } -func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool) { +func (m Model) drawForwardedMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, grouped bool) { dimStyle := baseStyle.Dim(true) m.drawLayout(builder, message, baseStyle, grouped, func() { builder.Write(m.cfg.UI.MessagesList.ForwardedIndicator+" ", dimStyle) @@ -356,7 +354,7 @@ func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.M }) } -func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) { +func (m Model) drawReply(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) { dimStyle := baseStyle.Dim(true) builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle) @@ -370,6 +368,6 @@ func (m Model) drawReply(builder *richtext.Builder, message discord.Message, bas } } -func drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) { +func drawPinnedMessage(builder *markdown.Builder, message discord.Message, baseStyle tview.Style) { builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle) } diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 1403ae5..0ebe8e1 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -216,16 +216,12 @@ func TestModelDrawLayout(t *testing.T) { ml := newTestModel(t, cfg) message := discord.Message{Author: discord.User{Username: "a"}, Content: "hi", EditedTimestamp: discord.NowTimestamp()} - for compact, want := range map[bool]string{false: "a T (edited)\nhi\n", true: "T a hi (edited)\n"} { + for compact, want := range map[bool]string{false: "a T (edited)\nhi", true: "T a hi (edited)"} { cfg.UI.MessagesList.Compact = compact var got strings.Builder - for _, line := range ml.renderMessage(message, tview.Style{}, 80, false) { - for _, segment := range line { - got.WriteString(segment.Text) - } - - got.WriteByte('\n') + for _, span := range ml.renderMessage(message, tview.Style{}, 80, false) { + got.WriteString(span.Text) } if got.String() != want { @@ -273,7 +269,7 @@ func TestModelShouldGroup(t *testing.T) { if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped { t.Fatal("only the first message has a header") } - if got := ml.renderMessage(ml.items[newest].message, tview.Style{}, 80, true); len(got) != 1 || got[0][0].Text != "hi" { + if got := ml.renderMessage(ml.items[newest].message, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" { t.Fatalf("grouped message = %v, want only its content", got) } second := ml.messageIndex(newest, -1) diff --git a/internal/ui/chat/messageslist/voice.go b/internal/ui/chat/messageslist/voice.go index d15da3b..3bf6293 100644 --- a/internal/ui/chat/messageslist/voice.go +++ b/internal/ui/chat/messageslist/voice.go @@ -80,8 +80,7 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget { return rail[cell], style.Dim(true) }) - width, _ := text.New(symbol).Size() - play := button.New().Label(symbol).Width(width).OnClick(playMsg(a)) + play := button.New().Label(symbol).OnClick(playMsg(a)) return row.New(play, waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1)) } diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go index d7edf1b..f14c26e 100644 --- a/internal/ui/login/password/model.go +++ b/internal/ui/login/password/model.go @@ -76,7 +76,6 @@ func (m Model) View() tview.Widget { submit := button.New(). Label("Login"). Width(layout.Fixed(len("Login") + 4)). - Height(layout.Fixed(1)). Focused(m.focus == focusButton). OnClick(submitMsg{}) return column.New( diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index 50dfd98..a683316 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -8,8 +8,7 @@ import ( "github.com/ayn2op/tview" "github.com/ayn2op/tview/center" - "github.com/ayn2op/tview/richtext" - "github.com/ayn2op/tview/textview" + "github.com/ayn2op/tview/text" "github.com/ayn2op/tview/viewport" "github.com/gdamore/tcell/v3" "github.com/skip2/go-qrcode" @@ -132,7 +131,7 @@ func halfBlock(top, bottom bool) rune { func (m Model) View() tview.Widget { code := m.code() return center.New( - viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState). + viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.AlignmentCenter), m.scrollState). Height(layout.Shrink). Focused(true). OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }), @@ -140,27 +139,27 @@ func (m Model) View() tview.Widget { } // code returns the QR code above the status. -func (m Model) code() richtext.Text { +func (m Model) code() string { var out strings.Builder if m.qrCode != nil { bitmap := m.qrCode.Bitmap() for y := 0; y < len(bitmap); y += 2 { + if y > 0 { + out.WriteByte('\n') + } for x := range bitmap[y] { top := bitmap[y][x] bottom := y+1 < len(bitmap) && bitmap[y+1][x] out.WriteRune(halfBlock(top, bottom)) } - out.WriteByte('\n') } } if m.status != "" { if out.Len() > 0 { - out.WriteByte('\n') + out.WriteString("\n\n") } out.WriteString(m.status) } - builder := new(richtext.Builder) - builder.Write(out.String(), tcell.StyleDefault) - return builder.Finish() + return out.String() } diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index 405d1d8..dafefdb 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -57,7 +57,6 @@ func (m Model) View() tview.Widget { submit := button.New(). Label("Login"). Width(layout.Fixed(len("Login") + 4)). - Height(layout.Fixed(1)). Focused(m.buttonFocused). OnClick(submitMsg{}) return column.New(