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) { |
| 59 | 59 | } |
| 60 | 60 | } |
| 61 | 61 | |
| 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. |
| 66 | func 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 | |
| 62 | 96 | func TestInlineDropsLinkTargets(t *testing.T) { |
| 63 | 97 | got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md") |
| 64 | 98 | 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 { |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | // 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. |
| 55 | 58 | func (w *out) para(s, first, rest string) { |
| 56 | 59 | prefix := first |
| 60 | var open []string |
| 57 | 61 | for _, hard := range strings.Split(s, "\n") { |
| 58 | 62 | 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") |
| 60 | 73 | prefix = rest |
| 61 | 74 | } |
| 62 | 75 | } |
| 63 | 76 | } |
| 64 | 77 | |
| 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. |
| 81 | func 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 | |
| 65 | 101 | // code writes lines verbatim under prefix plus four spaces, |
| 66 | 102 | // highlighted when colour is on. |
| 67 | 103 | func (w *out) code(src, lang, prefix string) { |
| @@ -103,7 +139,8 @@ func (w *out) link(text, target string) string { |
| 103 | 139 | if text == "" { |
| 104 | 140 | return target |
| 105 | 141 | } |
| 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 { |
| 107 | 144 | return text |
| 108 | 145 | } |
| 109 | 146 | return text + " (" + target + ")" |