Commit 49ab072901

49ab072901dd7458d817a4eca5656eba7af3fa3c

parent: 0670ca4ed6

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 02:24 UTC

termtext: close colour at the end of a wrapped line

Ref #254
internal/termtext/markdown_test.go +34
@@ -59,6 +59,40 @@ func TestMarkdownWidth(t *testing.T) {
5959 }
6060}
6161
62// TestWrappedColorClosesAtLineEnd covers a bold span that wraps mid-run:
63// the style must not bleed across the line break onto the unstyled
64// list-hang prefix, and stripping colour must reproduce the plain
65// rendering exactly.
66func TestWrappedColorClosesAtLineEnd(t *testing.T) {
67 src := "- item with **some very long bold phrase spanning many words here** and more text after it to force a wrap"
68 color := Markdown(src, Options{Width: 30, Color: true})
69 plain := Markdown(src, Options{Width: 30})
70
71 if got := stripSGR(color); got != plain {
72 t.Errorf("stripSGR(color) = %q, want %q", got, plain)
73 }
74
75 // Both continuation lines land wholly inside the wrapped bold span
76 // (the first) or start inside it (the second): the plain prefix
77 // always comes before the reopened style, never styled itself, and
78 // a run left open at wrap time is closed again at line's end.
79 lines := strings.Split(strings.TrimRight(color, "\n"), "\n")
80 for _, i := range []int{1, 2} {
81 if !strings.HasPrefix(lines[i], " \x1b[1m") {
82 t.Errorf("line %d: styled run starts before the plain prefix, or is missing: %q", i, lines[i])
83 }
84 }
85 if !strings.HasSuffix(lines[1], sgrReset) {
86 t.Errorf("line 1: open run never closed: %q", lines[1])
87 }
88 if want := " \x1b[1mbold phrase spanning many\x1b[0m"; lines[1] != want {
89 t.Errorf("line 1 = %q, want %q", lines[1], want)
90 }
91 if want := " \x1b[1mwords here\x1b[0m and more text"; lines[2] != want {
92 t.Errorf("line 2 = %q, want %q", lines[2], want)
93 }
94}
95
6296func TestInlineDropsLinkTargets(t *testing.T) {
6397 got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md")
6498 if got != "referenced in commit 6c4d1e1454 by cmc: landing" {
internal/termtext/termtext.go +40 −3
@@ -51,17 +51,53 @@ func (w *out) paint(sgr, s string) string {
5151}
5252
5353// para writes s wrapped to the width, the first line after first and
54// the rest after rest. Hard breaks in s ("\n") start a new line.
54// the rest after rest. Hard breaks in s ("\n") start a new line. An
55// SGR run open at a wrapped line's end is closed there and reopened
56// after the next line's prefix, so the prefix itself is never painted
57// and a style never bleeds past a line break.
5558func (w *out) para(s, first, rest string) {
5659 prefix := first
60 var open []string
5761 for _, hard := range strings.Split(s, "\n") {
5862 for _, line := range wrap(hard, w.o.Width-cells(rest)) {
59 w.b.WriteString(prefix + line + "\n")
63 w.b.WriteString(prefix)
64 for _, sgr := range open {
65 w.b.WriteString(sgr)
66 }
67 w.b.WriteString(line)
68 open = sgrOpen(line, open)
69 if len(open) > 0 {
70 w.b.WriteString(sgrReset)
71 }
72 w.b.WriteString("\n")
6073 prefix = rest
6174 }
6275 }
6376}
6477
78// sgrOpen scans s for SGR sequences, starting from the stack of runs
79// already open, and returns the stack still open at s's end. sgrReset
80// clears the whole stack; any other sequence pushes onto it.
81func sgrOpen(s string, open []string) []string {
82 for i := 0; i < len(s); i++ {
83 if s[i] != 0x1b {
84 continue
85 }
86 j := strings.IndexByte(s[i:], 'm')
87 if j < 0 {
88 break
89 }
90 sgr := s[i : i+j+1]
91 if sgr == sgrReset {
92 open = nil
93 } else {
94 open = append(open, sgr)
95 }
96 i += j
97 }
98 return open
99}
100
65101// code writes lines verbatim under prefix plus four spaces,
66102// highlighted when colour is on.
67103func (w *out) code(src, lang, prefix string) {
@@ -103,7 +139,8 @@ func (w *out) link(text, target string) string {
103139 if text == "" {
104140 return target
105141 }
106 if target == "" || target == text || strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text {
142 if target == "" || target == text || "mailto:"+text == target ||
143 strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text {
107144 return text
108145 }
109146 return text + " (" + target + ")"