refactor: move to the tview text widget and go-uucode

This commit is contained in:
ayn2op 2026-10-16 22:13:15 -04:00
parent 03d1ef13d2
commit 805621bc2e
17 changed files with 190 additions and 160 deletions

8
go.mod
View file

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

16
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
rendered = spansWithURL(rendered, 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...))
for _, wrapped := range text.Lines(rendered, wrapWidth, text.WrappingGlyph) {
if !builder.LineEmpty() {
builder.NewLine()
}
builder.WriteSpans(prefix)
builder.WriteSpans(wrapped...)
}
}
}
}
if len(embedText) > 0 {
builder.WriteText(embedText)
}
}
}
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

View file

@ -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() {
if startsWithCodeBlock && !builder.LineEmpty() {
builder.NewLine()
}
for len(content) > 0 && len(content[0]) == 0 {
content = content[1:]
content = trimLeadingLineBreaks(content)
}
} else {
for len(content) > 1 && len(content[0]) == 0 {
content = content[1:]
}
}
}
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)
}

View file

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

View file

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

View file

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

View file

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

View file

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