okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

Stanislav (Stas) Katkovfix(styled): allow semicolons in hyperlink URLs (#172)ae99b73

main
24.7 KiB652 linesraw
1package uv
2
3import (
4	"image/color"
5	"strings"
6	"testing"
7
8	"github.com/charmbracelet/x/ansi"
9)
10
11func TestStyledString(t *testing.T) {
12	cases := []struct {
13		name           string
14		input          string
15		expected       *Buffer
16		expectedWidth  int
17		expectedHeight int
18	}{
19		{
20			name:           "single line",
21			input:          "Hello, World!",
22			expectedWidth:  13,
23			expectedHeight: 1,
24			expected: &Buffer{
25				Lines: []Line{
26					{
27						newWcCell("H", nil, nil),
28						newWcCell("e", nil, nil),
29						newWcCell("l", nil, nil),
30						newWcCell("l", nil, nil),
31						newWcCell("o", nil, nil),
32						newWcCell(",", nil, nil),
33						newWcCell(" ", nil, nil),
34						newWcCell("W", nil, nil),
35						newWcCell("o", nil, nil),
36						newWcCell("r", nil, nil),
37						newWcCell("l", nil, nil),
38						newWcCell("d", nil, nil),
39						newWcCell("!", nil, nil),
40					},
41				},
42			},
43		},
44		{
45			name:           "multiple lines",
46			input:          "Hello,\nWorld!",
47			expectedWidth:  6,
48			expectedHeight: 2,
49			expected: &Buffer{
50				Lines: []Line{
51					{
52						newWcCell("H", nil, nil),
53						newWcCell("e", nil, nil),
54						newWcCell("l", nil, nil),
55						newWcCell("l", nil, nil),
56						newWcCell("o", nil, nil),
57						newWcCell(",", nil, nil),
58					},
59					{
60						newWcCell("W", nil, nil),
61						newWcCell("o", nil, nil),
62						newWcCell("r", nil, nil),
63						newWcCell("l", nil, nil),
64						newWcCell("d", nil, nil),
65						newWcCell("!", nil, nil),
66					},
67				},
68			},
69		},
70		{
71			name:           "empty string",
72			input:          "",
73			expectedWidth:  0,
74			expectedHeight: 1,
75			expected: &Buffer{
76				Lines: []Line{{}},
77			},
78		},
79		{
80			name:           "multiple lines different width",
81			input:          "Hello,\nWorld!\nThis is a test.",
82			expectedWidth:  15,
83			expectedHeight: 3,
84			expected: &Buffer{
85				Lines: []Line{
86					{
87						newWcCell("H", nil, nil),
88						newWcCell("e", nil, nil),
89						newWcCell("l", nil, nil),
90						newWcCell("l", nil, nil),
91						newWcCell("o", nil, nil),
92						newWcCell(",", nil, nil),
93						newWcCell(" ", nil, nil),
94						newWcCell(" ", nil, nil),
95						newWcCell(" ", nil, nil),
96						newWcCell(" ", nil, nil),
97						newWcCell(" ", nil, nil),
98						newWcCell(" ", nil, nil),
99						newWcCell(" ", nil, nil),
100						newWcCell(" ", nil, nil),
101						newWcCell(" ", nil, nil),
102					},
103					{
104						newWcCell("W", nil, nil),
105						newWcCell("o", nil, nil),
106						newWcCell("r", nil, nil),
107						newWcCell("l", nil, nil),
108						newWcCell("d", nil, nil),
109						newWcCell("!", nil, nil),
110						newWcCell(" ", nil, nil),
111						newWcCell(" ", nil, nil),
112						newWcCell(" ", nil, nil),
113						newWcCell(" ", nil, nil),
114						newWcCell(" ", nil, nil),
115						newWcCell(" ", nil, nil),
116						newWcCell(" ", nil, nil),
117						newWcCell(" ", nil, nil),
118						newWcCell(" ", nil, nil),
119					},
120					{
121						newWcCell("T", nil, nil),
122						newWcCell("h", nil, nil),
123						newWcCell("i", nil, nil),
124						newWcCell("s", nil, nil),
125						newWcCell(" ", nil, nil),
126						newWcCell("i", nil, nil),
127						newWcCell("s", nil, nil),
128						newWcCell(" ", nil, nil),
129						newWcCell("a", nil, nil),
130						newWcCell(" ", nil, nil),
131						newWcCell("t", nil, nil),
132						newWcCell("e", nil, nil),
133						newWcCell("s", nil, nil),
134						newWcCell("t", nil, nil),
135						newWcCell(".", nil, nil),
136					},
137				},
138			},
139		},
140		{
141			name:           "unicode characters",
142			input:          "Hello, 世界!",
143			expectedWidth:  12,
144			expectedHeight: 1,
145			expected: &Buffer{
146				Lines: []Line{
147					{
148						newWcCell("H", nil, nil),
149						newWcCell("e", nil, nil),
150						newWcCell("l", nil, nil),
151						newWcCell("l", nil, nil),
152						newWcCell("o", nil, nil),
153						newWcCell(",", nil, nil),
154						newWcCell(" ", nil, nil),
155						newWcCell("世", nil, nil),
156						Cell{},
157						newWcCell("界", nil, nil),
158						Cell{},
159						newWcCell("!", nil, nil),
160					},
161				},
162			},
163		},
164		{
165			name:           "styled hello world string",
166			input:          "\x1b[31;1;4mHello, \x1b[32;22;4mWorld!\x1b[0m",
167			expectedWidth:  13,
168			expectedHeight: 1,
169			expected: &Buffer{
170				Lines: []Line{
171					{
172						newWcCell("H", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
173						newWcCell("e", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
174						newWcCell("l", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
175						newWcCell("l", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
176						newWcCell("o", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
177						newWcCell(",", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
178						newWcCell(" ", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
179						newWcCell("W", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
180						newWcCell("o", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
181						newWcCell("r", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
182						newWcCell("l", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
183						newWcCell("d", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
184						newWcCell("!", &Style{Fg: ansi.Green, Underline: UnderlineStyleSingle}, nil),
185					},
186				},
187			},
188		},
189		{
190			name:           "complex styling with multiple SGR sequences",
191			input:          "\x1b[31;1;2;4mR\x1b[22;1med\x1b[0m \x1b[32;3mGreen\x1b[0m \x1b[34;9mBlue\x1b[0m \x1b[33;7mYellow\x1b[0m \x1b[35;5mPurple\x1b[0m",
192			expectedWidth:  28,
193			expectedHeight: 1,
194			expected: &Buffer{
195				Lines: []Line{
196					{
197						newWcCell("R", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold | AttrFaint}, nil),
198						newWcCell("e", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
199						newWcCell("d", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
200						newWcCell(" ", nil, nil),
201						newWcCell("G", &Style{Fg: ansi.Green, Attrs: AttrItalic}, nil),
202						newWcCell("r", &Style{Fg: ansi.Green, Attrs: AttrItalic}, nil),
203						newWcCell("e", &Style{Fg: ansi.Green, Attrs: AttrItalic}, nil),
204						newWcCell("e", &Style{Fg: ansi.Green, Attrs: AttrItalic}, nil),
205						newWcCell("n", &Style{Fg: ansi.Green, Attrs: AttrItalic}, nil),
206						newWcCell(" ", nil, nil),
207						newWcCell("B", &Style{Fg: ansi.Blue, Attrs: AttrStrikethrough}, nil),
208						newWcCell("l", &Style{Fg: ansi.Blue, Attrs: AttrStrikethrough}, nil),
209						newWcCell("u", &Style{Fg: ansi.Blue, Attrs: AttrStrikethrough}, nil),
210						newWcCell("e", &Style{Fg: ansi.Blue, Attrs: AttrStrikethrough}, nil),
211						newWcCell(" ", nil, nil),
212						newWcCell("Y", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
213						newWcCell("e", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
214						newWcCell("l", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
215						newWcCell("l", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
216						newWcCell("o", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
217						newWcCell("w", &Style{Fg: ansi.Yellow, Attrs: AttrReverse}, nil),
218						newWcCell(" ", nil, nil),
219						newWcCell("P", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
220						newWcCell("u", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
221						newWcCell("r", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
222						newWcCell("p", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
223						newWcCell("l", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
224						newWcCell("e", &Style{Fg: ansi.Magenta, Attrs: AttrBlink}, nil),
225					},
226				},
227			},
228		},
229		{
230			name:           "different underline styles",
231			input:          "\x1b[4:1mSingle\x1b[0m \x1b[4:2mDouble\x1b[0m \x1b[4:3mCurly\x1b[0m \x1b[4:4mDotted\x1b[0m \x1b[4:5mDashed\x1b[0m",
232			expectedWidth:  33,
233			expectedHeight: 1,
234			expected: &Buffer{
235				Lines: []Line{
236					{
237						newWcCell("S", &Style{Underline: UnderlineStyleSingle}, nil),
238						newWcCell("i", &Style{Underline: UnderlineStyleSingle}, nil),
239						newWcCell("n", &Style{Underline: UnderlineStyleSingle}, nil),
240						newWcCell("g", &Style{Underline: UnderlineStyleSingle}, nil),
241						newWcCell("l", &Style{Underline: UnderlineStyleSingle}, nil),
242						newWcCell("e", &Style{Underline: UnderlineStyleSingle}, nil),
243						newWcCell(" ", nil, nil),
244						newWcCell("D", &Style{Underline: UnderlineStyleDouble}, nil),
245						newWcCell("o", &Style{Underline: UnderlineStyleDouble}, nil),
246						newWcCell("u", &Style{Underline: UnderlineStyleDouble}, nil),
247						newWcCell("b", &Style{Underline: UnderlineStyleDouble}, nil),
248						newWcCell("l", &Style{Underline: UnderlineStyleDouble}, nil),
249						newWcCell("e", &Style{Underline: UnderlineStyleDouble}, nil),
250						newWcCell(" ", nil, nil),
251						newWcCell("C", &Style{Underline: UnderlineStyleCurly}, nil),
252						newWcCell("u", &Style{Underline: UnderlineStyleCurly}, nil),
253						newWcCell("r", &Style{Underline: UnderlineStyleCurly}, nil),
254						newWcCell("l", &Style{Underline: UnderlineStyleCurly}, nil),
255						newWcCell("y", &Style{Underline: UnderlineStyleCurly}, nil),
256						newWcCell(" ", nil, nil),
257						newWcCell("D", &Style{Underline: UnderlineStyleDotted}, nil),
258						newWcCell("o", &Style{Underline: UnderlineStyleDotted}, nil),
259						newWcCell("t", &Style{Underline: UnderlineStyleDotted}, nil),
260						newWcCell("t", &Style{Underline: UnderlineStyleDotted}, nil),
261						newWcCell("e", &Style{Underline: UnderlineStyleDotted}, nil),
262						newWcCell("d", &Style{Underline: UnderlineStyleDotted}, nil),
263						newWcCell(" ", nil, nil),
264						newWcCell("D", &Style{Underline: UnderlineStyleDashed}, nil),
265						newWcCell("a", &Style{Underline: UnderlineStyleDashed}, nil),
266						newWcCell("s", &Style{Underline: UnderlineStyleDashed}, nil),
267						newWcCell("h", &Style{Underline: UnderlineStyleDashed}, nil),
268						newWcCell("e", &Style{Underline: UnderlineStyleDashed}, nil),
269						newWcCell("d", &Style{Underline: UnderlineStyleDashed}, nil),
270					},
271				},
272			},
273		},
274		{
275			name:           "truecolor and 256 color support",
276			input:          "\x1b[38;2;255;0;0mRGB Red\x1b[0m \x1b[48;2;0;255;0mRGB Green BG\x1b[0m \x1b[38;5;33m256 Blue\x1b[0m",
277			expectedWidth:  29,
278			expectedHeight: 1,
279			expected: &Buffer{
280				Lines: []Line{
281					{
282						newWcCell("R", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
283						newWcCell("G", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
284						newWcCell("B", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
285						newWcCell(" ", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
286						newWcCell("R", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
287						newWcCell("e", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
288						newWcCell("d", &Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}}, nil),
289						newWcCell(" ", nil, nil),
290						newWcCell("R", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
291						newWcCell("G", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
292						newWcCell("B", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
293						newWcCell(" ", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
294						newWcCell("G", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
295						newWcCell("r", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
296						newWcCell("e", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
297						newWcCell("e", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
298						newWcCell("n", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
299						newWcCell(" ", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
300						newWcCell("B", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
301						newWcCell("G", &Style{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}}, nil),
302						newWcCell(" ", nil, nil),
303						newWcCell("2", &Style{Fg: ansi.IndexedColor(33)}, nil),
304						newWcCell("5", &Style{Fg: ansi.IndexedColor(33)}, nil),
305						newWcCell("6", &Style{Fg: ansi.IndexedColor(33)}, nil),
306						newWcCell(" ", &Style{Fg: ansi.IndexedColor(33)}, nil),
307						newWcCell("B", &Style{Fg: ansi.IndexedColor(33)}, nil),
308						newWcCell("l", &Style{Fg: ansi.IndexedColor(33)}, nil),
309						newWcCell("u", &Style{Fg: ansi.IndexedColor(33)}, nil),
310						newWcCell("e", &Style{Fg: ansi.IndexedColor(33)}, nil),
311						newWcCell("e", &Style{Fg: ansi.IndexedColor(33)}, nil),
312					},
313				},
314			},
315		},
316		{
317			name:           "hyperlink support",
318			input:          "Normal \x1b]8;;https://charm.sh\x1b\\Charm\x1b]8;;\x1b\\ Text \x1b]8;;https://github.com/charmbracelet\x1b\\GitHub\x1b]8;;\x1b\\",
319			expectedWidth:  24,
320			expectedHeight: 1,
321			expected: &Buffer{
322				Lines: []Line{
323					{
324						newWcCell("N", nil, nil),
325						newWcCell("o", nil, nil),
326						newWcCell("r", nil, nil),
327						newWcCell("m", nil, nil),
328						newWcCell("a", nil, nil),
329						newWcCell("l", nil, nil),
330						newWcCell(" ", nil, nil),
331						newWcCell("C", nil, &Link{URL: "https://charm.sh"}),
332						newWcCell("h", nil, &Link{URL: "https://charm.sh"}),
333						newWcCell("a", nil, &Link{URL: "https://charm.sh"}),
334						newWcCell("r", nil, &Link{URL: "https://charm.sh"}),
335						newWcCell("m", nil, &Link{URL: "https://charm.sh"}),
336						newWcCell(" ", nil, nil),
337						newWcCell("T", nil, nil),
338						newWcCell("e", nil, nil),
339						newWcCell("x", nil, nil),
340						newWcCell("t", nil, nil),
341						newWcCell(" ", nil, nil),
342						newWcCell("G", nil, &Link{URL: "https://github.com/charmbracelet"}),
343						newWcCell("i", nil, &Link{URL: "https://github.com/charmbracelet"}),
344						newWcCell("t", nil, &Link{URL: "https://github.com/charmbracelet"}),
345						newWcCell("H", nil, &Link{URL: "https://github.com/charmbracelet"}),
346						newWcCell("u", nil, &Link{URL: "https://github.com/charmbracelet"}),
347						newWcCell("b", nil, &Link{URL: "https://github.com/charmbracelet"}),
348					},
349				},
350			},
351		},
352		{
353			name:           "complex mixed styling with hyperlinks",
354			input:          "\x1b[31;1;2;3mR\x1b[22;23;1med \x1b]8;;https://charm.sh\x1b\\\x1b[4mCharm\x1b]8;;\x1b\\\x1b[0m \x1b[38;5;33;48;2;0;100;0m\x1b]8;;https://github.com\x1b\\GitHub\x1b]8;;\x1b\\\x1b[0m",
355			expectedWidth:  16,
356			expectedHeight: 1,
357			expected: &Buffer{
358				Lines: []Line{
359					{
360						newWcCell("R", &Style{Fg: ansi.Red, Attrs: AttrBold | AttrFaint | AttrItalic}, nil),
361						newWcCell("e", &Style{Fg: ansi.Red, Attrs: AttrBold}, nil),
362						newWcCell("d", &Style{Fg: ansi.Red, Attrs: AttrBold}, nil),
363						newWcCell(" ", &Style{Fg: ansi.Red, Attrs: AttrBold}, nil),
364						newWcCell("C", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, &Link{URL: "https://charm.sh"}),
365						newWcCell("h", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, &Link{URL: "https://charm.sh"}),
366						newWcCell("a", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, &Link{URL: "https://charm.sh"}),
367						newWcCell("r", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, &Link{URL: "https://charm.sh"}),
368						newWcCell("m", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, &Link{URL: "https://charm.sh"}),
369						newWcCell(" ", nil, nil),
370						newWcCell("G", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
371						newWcCell("i", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
372						newWcCell("t", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
373						newWcCell("H", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
374						newWcCell("u", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
375						newWcCell("b", &Style{Fg: ansi.IndexedColor(33), Bg: color.RGBA{R: 0, G: 100, B: 0, A: 255}}, &Link{URL: "https://github.com"}),
376					},
377				},
378			},
379		},
380		{
381			// A sequence that carries data rather than driving the cursor
382			// rides in the content of the cell that follows it, so it reaches
383			// the terminal when the cell is painted. See #95.
384			name:           "APC sequence rides the next cell",
385			input:          "\x1b_foo\x1b\\bar",
386			expectedWidth:  3,
387			expectedHeight: 1,
388			expected: &Buffer{
389				Lines: []Line{
390					{
391						newWcCell("\x1b_foo\x1b\\b", nil, nil),
392						newWcCell("a", nil, nil),
393						newWcCell("r", nil, nil),
394					},
395				},
396			},
397		},
398		{
399			// At the end of the string there is no following cell, so it folds
400			// into the last one. A width-0 cell one past the content would sit
401			// outside the bounds whenever the string filled them.
402			name:           "trailing APC folds into the last cell",
403			input:          "bar\x1b_foo\x1b\\",
404			expectedWidth:  3,
405			expectedHeight: 1,
406			expected: &Buffer{
407				Lines: []Line{
408					{
409						newWcCell("b", nil, nil),
410						newWcCell("a", nil, nil),
411						newWcCell("r\x1b_foo\x1b\\", nil, nil),
412					},
413				},
414			},
415		},
416		{
417			// Cursor movement is not carried. Replaying it from inside a cell
418			// would move the real cursor somewhere the renderer's model cannot
419			// see, so it stays dropped.
420			name:           "cursor movement is not carried",
421			input:          "a\x1b[5Cb",
422			expectedWidth:  2,
423			expectedHeight: 1,
424			expected: &Buffer{
425				Lines: []Line{
426					{
427						newWcCell("a", nil, nil),
428						newWcCell("b", nil, nil),
429					},
430				},
431			},
432		},
433	}
434
435	for i, tc := range cases {
436		t.Run(tc.name, func(t *testing.T) {
437			t.Logf("Running case %d: %s for %q", i+1, tc.name, tc.input)
438			ss := NewStyledString(tc.input)
439			area := ss.Bounds()
440			buf := NewScreenBuffer(area.Dx(), area.Dy())
441			ss.Draw(buf, area)
442			if buf.Width() != tc.expectedWidth {
443				t.Errorf("case %d expected width %d, got %d", i+1, tc.expectedWidth, buf.Width())
444			}
445			if buf.Height() != tc.expectedHeight {
446				t.Errorf("case %d expected height %d, got %d", i+1, tc.expectedHeight, buf.Height())
447			}
448			for y, line := range buf.Lines {
449				for x, cell := range line {
450					if !cellEqual(tc.expected.CellAt(x, y), &cell) {
451						t.Errorf("case %d expected cell (%d, %d) %#v, got %#v", y+1, x, y, tc.expected.CellAt(x, y), &cell)
452					}
453				}
454			}
455		})
456	}
457}
458
459func TestStyledStringEmptyLines(t *testing.T) {
460	// This test uses an input that results in empty lines when drawn to a smaller
461	// screen buffer.
462	input := "\x1b[31;1;4mHello, \x1b[32;22;4mWorld!\x1b[0m"
463	ss := NewStyledString(input)
464	scr := NewScreenBuffer(5, 3)
465	ss.Draw(scr, scr.Bounds())
466	expected := &Buffer{
467		Lines: []Line{
468			{
469				newWcCell("H", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
470				newWcCell("e", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
471				newWcCell("l", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
472				newWcCell("l", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
473				newWcCell("o", &Style{Fg: ansi.Red, Underline: UnderlineStyleSingle, Attrs: AttrBold}, nil),
474			},
475			NewLine(5),
476			NewLine(5),
477		},
478	}
479	for y, line := range scr.Lines {
480		for x, cell := range line {
481			if !cellEqual(expected.CellAt(x, y), &cell) {
482				t.Errorf("expected cell (%d, %d) %#v, got %#v", x, y, expected.CellAt(x, y), &cell)
483			}
484		}
485	}
486}
487
488func TestStyledStringLinesUnboundedContent(t *testing.T) {
489	const destination = "https://example.com"
490	input := "\x1b[31mab\x1b[0m\n" + ansi.SetHyperlink(destination, "") + "界" + ansi.ResetHyperlink()
491	lines := NewStyledString(input).Lines(ansi.GraphemeWidth)
492
493	if got := Lines(lines).String(); got != "ab\n界" {
494		t.Fatalf("Lines().String() = %q, want %q", got, "ab\n界")
495	}
496	if cell := lines[1][0]; cell.Width != 2 || cell.Link.URL != destination {
497		t.Fatalf("linked cell = %#v, want width 2 and link %q", cell, destination)
498	}
499}
500
501func newWcCell(s string, style *Style, link *Link) Cell {
502	c := NewCell(ansi.WcWidth, s)
503	if style != nil {
504		c.Style = *style
505	}
506	if link != nil {
507		c.Link = *link
508	}
509	return *c
510}
511
512func TestReadLinkAllowsSemicolonsInURL(t *testing.T) {
513	var link Link
514	ReadLink([]byte("8;id=123;Other;Guide.md"), &link)
515
516	if link.Params != "id=123" || link.URL != "Other;Guide.md" {
517		t.Fatalf("ReadLink() = %#v, want params %q and URL %q", link, "id=123", "Other;Guide.md")
518	}
519}
520
521// ASCII-heavy line: the common case. Guards the printString re-decode
522// pre-filter (str[1] >= 0xc0) from regressing plain-text draws.
523func BenchmarkPrintStringASCII(b *testing.B) {
524	line := strings.Repeat("The quick brown fox jumps over the lazy dog. ", 4)
525	buf := NewScreenBuffer(200, 4)
526	b.ResetTimer()
527	for i := 0; i < b.N; i++ {
528		NewStyledString(line).Draw(buf, Rect(0, 0, 200, 1))
529	}
530}
531
532// GraphemeWidth variant of the ASCII draw, since the decoder selection
533// differs.
534func BenchmarkPrintStringASCIIGrapheme(b *testing.B) {
535	line := strings.Repeat("The quick brown fox jumps over the lazy dog. ", 4)
536	buf := NewScreenBuffer(200, 4)
537	buf.Method = ansi.GraphemeWidth
538	b.ResetTimer()
539	for i := 0; i < b.N; i++ {
540		NewStyledString(line).Draw(buf, Rect(0, 0, 200, 1))
541	}
542}
543
544// Cluster-heavy line: keycaps, VS16 emoji, combining marks. Exercises the
545// FirstGraphemeCluster re-decode path.
546func BenchmarkPrintStringClusters(b *testing.B) {
547	line := strings.Repeat("1️⃣❤️é☹️", 20)
548	buf := NewScreenBuffer(200, 4)
549	b.ResetTimer()
550	for i := 0; i < b.N; i++ {
551		NewStyledString(line).Draw(buf, Rect(0, 0, 200, 1))
552	}
553}
554
555func TestStyledStringCombiningMarks(t *testing.T) {
556	cases := []struct {
557		name string
558		in   string
559		want []string
560	}{
561		{"combining mark folds into its base", "a\u0301b", []string{"a\u0301", "b"}},
562		{"keycap stays in one cell", "1\ufe0f\u20e3x", []string{"1\ufe0f\u20e3", "x"}},
563		{"style change between cells", "a\u0301\x1b[31mR", []string{"a\u0301", "R"}},
564		// The re-decode cannot fold this one, since the escape sequence sits
565		// between the base and the mark. The mark still belongs to the cell
566		// before it, and dropping it would lose the glyph.
567		{"style change between base and mark", "a\x1b[31m\u0301b", []string{"a\u0301", "b"}},
568	}
569
570	for _, tc := range cases {
571		t.Run(tc.name, func(t *testing.T) {
572			buf := NewScreenBuffer(10, 1)
573			NewStyledString(tc.in).Draw(buf, Rect(0, 0, 10, 1))
574			for x, want := range tc.want {
575				if got := buf.CellAt(x, 0).Content; got != want {
576					t.Errorf("cell %d = %q, want %q", x, got, want)
577				}
578			}
579		})
580	}
581}
582
583// A string-type sequence is only carried when it actually ended, and only when
584// it is one of the kinds a cell has any business replaying.
585func TestPassThroughTerminators(t *testing.T) {
586	for name, tc := range map[string]struct {
587		input   string
588		carried bool
589	}{
590		"APC ends with ST": {"\x1b_x\x1b\\a", true},
591		"DCS ends with ST": {"\x1bP1$r0m\x1b\\a", true},
592		"SOS ends with ST": {"\x1bXfoo\x1b\\a", true},
593		"PM ends with ST":  {"\x1b^bar\x1b\\a", true},
594
595		// The parser returns what it has when the input runs out. An
596		// unterminated introducer in a cell would have the terminal swallow
597		// everything painted after it.
598		"APC never terminated": {"a\x1b_x", false},
599		"OSC never terminated": {"a\x1b]0;t", false},
600		"DCS never terminated": {"a\x1bP1$r0m", false},
601
602		// 0x9c is a UTF-8 continuation byte as well as C1 ST, so a sequence
603		// that ran out partway through a character can end in a byte that
604		// looks like a terminator. U+071C encodes as dc 9c.
605		"APC cut off mid-character": {"a\x1b_x\u071c", false},
606		"OSC cut off mid-character": {"a\x1b]0;\u071c", false},
607		"APC ends with C1 ST":       {"\x1b_x\x9ca", false},
608
609		// An OSC carries data but a cell is painted again on every repaint. A
610		// title survives that; a clipboard write or a notification should not
611		// fire again on each resize.
612		"OSC ends with BEL": {"\x1b]0;t\x07a", false},
613		"OSC ends with ST":  {"\x1b]0;t\x1b\\a", false},
614	} {
615		t.Run(name, func(t *testing.T) {
616			ss := NewStyledString(tc.input)
617			area := ss.Bounds()
618			buf := NewScreenBuffer(area.Dx(), area.Dy())
619			ss.Draw(buf, area)
620
621			var content string
622			for x := 0; x < buf.Width(); x++ {
623				if c := buf.CellAt(x, 0); c != nil {
624					content += c.Content
625				}
626			}
627			// Every input above introduces its sequence with ESC, so an ESC
628			// surviving into the cells is the sequence riding along.
629			if carried := strings.ContainsRune(content, ansi.ESC); carried != tc.carried {
630				t.Errorf("carried = %v, want %v (cells hold %q)", carried, tc.carried, content)
631			}
632		})
633	}
634}
635
636// A carried sequence must not change how wide its cell measures.
637// [lineHasDrift] works the width out from the content, so a sequence that
638// counted would have the renderer treat every line holding one as drift-prone
639// and repaint it whole.
640func TestPassThroughDoesNotAffectWidth(t *testing.T) {
641	ss := NewStyledString("\x1b_x\x1b\\abc")
642	area := ss.Bounds()
643	buf := NewScreenBuffer(area.Dx(), area.Dy())
644	ss.Draw(buf, area)
645
646	if c := buf.CellAt(0, 0); c == nil || c.Width != 1 {
647		t.Errorf("cell holding a sequence has width %v, want 1", c)
648	}
649	if lineHasDrift(ansi.WcWidth, buf.Line(0)) {
650		t.Error("a line holding a pass-through sequence was flagged drift-prone")
651	}
652}