From b9e310e7fa8aaaeb499925c2f99fef542f486eb5 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Fri, 16 Oct 2026 18:52:15 -0400 Subject: [PATCH] fix(markdown): fix nested lists and simplify the renderer --- internal/markdown/renderer.go | 169 +++++++++++++---------------- internal/markdown/renderer_test.go | 31 +++--- 2 files changed, 91 insertions(+), 109 deletions(-) diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 410d510..1e38636 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -17,9 +17,6 @@ import ( type Renderer struct { cfg *config.Config - - listIx *int - listNested int } const codeBlockIndent = " " @@ -29,12 +26,10 @@ func NewRenderer(cfg *config.Config) *Renderer { } func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) richtext.Text { - r.listIx = nil - r.listNested = 0 - builder := new(richtext.Builder) styleStack := []tview.Style{base} linkDepth := 0 + listDepth := 0 currentStyle := func() tview.Style { return styleStack[len(styleStack)-1] @@ -50,94 +45,66 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) ri theme := r.cfg.Theme.MessagesList _ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { - switch node := node.(type) { - case *ast.Document: - // noop - case *ast.Heading: - if entering { - builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle()) - } else { + if !entering { + switch node := node.(type) { + case *ast.Heading: builder.NewLine() - } - case *ast.Text: - if entering { - builder.Write(string(node.Value.Bytes(source)), currentStyle()) - switch { - case node.HardLineBreak(): - builder.NewLine() - builder.NewLine() - case node.SoftLineBreak(): + case *ast.ListItem: + // A list that the item ends with already ended the line. + if _, ok := node.LastChild().(*ast.List); !ok { builder.NewLine() } + case *ast.Link: + linkDepth-- + popStyle() + case *ast.List: + listDepth-- + case *md.Inline: + if !r.shouldMask(node) { + popStyle() + } + } + return ast.WalkContinue, nil + } + + switch node := node.(type) { + case *ast.Heading: + builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle()) + case *ast.Text: + builder.Write(string(node.Value.Bytes(source)), currentStyle()) + switch { + case node.HardLineBreak(): + builder.NewLine() + builder.NewLine() + case node.SoftLineBreak(): + builder.NewLine() } case *ast.CodeBlock: - if entering { - builder.NewLine() - r.renderCodeBlock(builder, source, node, currentStyle()) - } + builder.NewLine() + r.renderCodeBlock(builder, source, node, currentStyle()) case *ast.AutoLink: - if entering { - url := string(node.Destination.Bytes(source)) - style := tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url) - builder.Write(url, style) - } + url := string(node.Destination.Bytes(source)) + style := tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url) + builder.Write(url, style) case *ast.Link: - if entering { - url := string(node.Destination.Bytes(source)) - linkDepth++ - pushStyle(tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)) - } else { - if linkDepth > 0 { - linkDepth-- - } - popStyle() - } + url := string(node.Destination.Bytes(source)) + linkDepth++ + pushStyle(tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)) case *ast.List: - if node.IsOrdered() { - start := node.Start - r.listIx = &start - } else { - r.listIx = nil - } - - if entering { - builder.NewLine() - r.listNested++ - } else { - r.listNested-- - } + builder.NewLine() + listDepth++ case *ast.ListItem: - if entering { - builder.Write(strings.Repeat(" ", r.listNested-1), currentStyle()) - if r.listIx != nil { - builder.Write(strconv.Itoa(*r.listIx)+". ", currentStyle()) - *r.listIx++ - } else { - builder.Write("- ", currentStyle()) - } - } else { - builder.NewLine() - } + builder.Write(strings.Repeat(" ", listDepth-1)+listMarker(node), currentStyle()) case *md.Inline: - if r.cfg.Markdown.MaskSpoilers && node.Attr&md.AttrSpoiler != 0 { - if entering { - builder.Write("[spoiler]", currentStyle()) - } + if r.shouldMask(node) { + builder.Write("[spoiler]", currentStyle()) return ast.WalkSkipChildren, nil } - if entering { - pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0)) - } else { - popStyle() - } + pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0)) case *md.Mention: - if entering { - builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style)) - } + builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style)) case *md.Emoji: - if entering { - builder.Write(":"+node.Name+":", tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style)) - } + builder.Write(":"+node.Name+":", tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style)) } return ast.WalkContinue, nil }) @@ -214,6 +181,25 @@ func (r *Renderer) renderCodeBlock(builder *richtext.Builder, source []byte, nod } } +// shouldMask reports whether node is a spoiler shown as "[spoiler]" in place of what it hides. +func (r *Renderer) shouldMask(node *md.Inline) bool { + return r.cfg.Markdown.MaskSpoilers && node.Attr&md.AttrSpoiler != 0 +} + +// listMarker returns what goes before item: its number in an ordered list, counted from the list's start, or a dash. +func listMarker(item *ast.ListItem) string { + list := item.Parent().(*ast.List) + if !list.IsOrdered() { + return "- " + } + + number := list.Start + for sibling := item.PreviousSibling(); sibling != nil; sibling = sibling.PreviousSibling() { + number++ + } + return strconv.Itoa(number) + ". " +} + func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style { style := base if entry.Colour.IsSet() { @@ -224,23 +210,16 @@ func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style { )) } // Token background colors are not applied, so code blocks keep the user's terminal/chat background. - switch entry.Bold { - case chroma.Yes: - style = style.Bold(true) - case chroma.No: - style = style.Bold(false) + + // An attribute chroma passes on is left as it is. + if entry.Bold != chroma.Pass { + style = style.Bold(entry.Bold == chroma.Yes) } - switch entry.Italic { - case chroma.Yes: - style = style.Italic(true) - case chroma.No: - style = style.Italic(false) + if entry.Italic != chroma.Pass { + style = style.Italic(entry.Italic == chroma.Yes) } - switch entry.Underline { - case chroma.Yes: - style = style.Underline(true) - case chroma.No: - style = style.Underline(false) + if entry.Underline != chroma.Pass { + style = style.Underline(entry.Underline == chroma.Yes) } return style } diff --git a/internal/markdown/renderer_test.go b/internal/markdown/renderer_test.go index a27471a..56a601c 100644 --- a/internal/markdown/renderer_test.go +++ b/internal/markdown/renderer_test.go @@ -10,27 +10,30 @@ import ( ) func TestRendererRenderText(t *testing.T) { - source := []byte("before ||secret|| after") - node := md.Parse(source) - for _, test := range []struct { - name string - mask bool - want string + name string + source string + mask bool + want string }{ - {"spoilers_masked", true, "before [spoiler] after"}, - {"spoilers_visible", false, "before secret after"}, + {"spoilers_masked", "before ||secret|| after", true, "before [spoiler] after"}, + {"spoilers_visible", "before ||secret|| after", false, "before secret after"}, + {"ordered_list_after_nested", "1. a\n2. b\n - x\n - y\n3. c", false, "\n1. a\n2. b\n - x\n - y\n3. c"}, + {"ordered_list_after_nested_ordered", "1. a\n 1. x\n 2. y\n2. b", false, "\n1. a\n 1. x\n 2. y\n2. b"}, + {"list_nested_twice", "- a\n - b\n - c\n- d", false, "\n- a\n - b\n - c\n- d"}, + {"ordered_list_start", "3. a\n4. b", false, "\n3. a\n4. b"}, } { t.Run(test.name, func(t *testing.T) { - rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, node, tcell.StyleDefault) - var got strings.Builder - for _, line := range rendered { + 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 { - got.WriteString(segment.Text) + lines[i] += segment.Text } } - if got.String() != test.want { - t.Fatalf("mask=%t: got %q, want %q", test.mask, got.String(), test.want) + if got := strings.Join(lines, "\n"); got != test.want { + t.Fatalf("got %q, want %q", got, test.want) } }) }