okami.mom/ultraviolet

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

Kieran Klukasfix(renderer): erase rows a frame no longer draws after a resize8786532

upstream
67.1 KiB2326 linesraw
1package uv
2
3import (
4	"bytes"
5	"fmt"
6	"image/color"
7	"io"
8	"strings"
9	"testing"
10
11	"github.com/charmbracelet/colorprofile"
12	"github.com/charmbracelet/x/ansi"
13)
14
15func TestSimpleRendererOutput(t *testing.T) {
16	const w, h = 5, 3
17	var buf bytes.Buffer
18	r := NewTerminalRenderer(&buf, []string{
19		"TERM=xterm-256color", // This will enable 256 colors for the renderer
20		"COLORTERM=truecolor", // This will enable true color support for the renderer
21	})
22
23	r.SetFullscreen(true)
24	r.SetRelativeCursor(false)
25	r.SaveCursor()
26	r.Erase()
27
28	// r.SetTabStops(5) // Use tab character \t for cursor movements.
29	// r.SetBackspace(true) // Use backspace character \b for cursor movements.
30	// r.SetMapNewline(true) // Map newline characters to \r\n for proper line endings.
31	r.Resize(w, h)
32
33	cellbuf := NewRenderBuffer(5, 3)
34	// 'X', ' ', ' ', ' ', ' '
35	// ' ', 'X', ' ', ' ', ' '
36	// ' ', ' ', 'X', ' ', ' '
37
38	cell := Cell{Content: "X", Width: 1}
39	cellbuf.SetCell(0, 0, &cell)
40	cellbuf.SetCell(1, 1, &cell)
41	cellbuf.SetCell(2, 2, &cell)
42
43	r.Render(cellbuf)
44	if err := r.Flush(); err != nil {
45		t.Fatalf("failed to flush renderer: %v", err)
46	}
47
48	expected := "\x1b[H\x1b[2JX\nX\nX"
49	if buf.String() != expected {
50		t.Errorf("expected output:\n%q\nbut got:\n%q", expected, buf.String())
51	}
52}
53
54func TestInlineRendererOutput(t *testing.T) {
55	var buf bytes.Buffer
56	r := NewTerminalRenderer(&buf, []string{
57		"TERM=xterm-256color", // This will enable 256 colors for the renderer
58		"COLORTERM=truecolor", // This will enable true color support for the renderer
59	})
60
61	r.SetRelativeCursor(true) // Use relative cursor movements.
62
63	const physicalWidth, physicalHeight = 80, 24 // Terminal width
64	const width, height = 80, 3                  // Application width
65	r.Resize(physicalWidth, physicalHeight)
66	cellbuf := NewRenderBuffer(physicalWidth, height)
67
68	for i, r := range "Hello, World!" {
69		cell := Cell{Content: string(r), Width: 1}
70		cellbuf.SetCell(i, 0, &cell)
71	}
72
73	r.Render(cellbuf)
74	if err := r.Flush(); err != nil {
75		t.Fatalf("failed to flush renderer: %v", err)
76	}
77
78	expected := "\rHello, World!"
79	if buf.String() != expected {
80		t.Errorf("expected output:\n%q\nbut got:\n%q", expected, buf.String())
81	}
82}
83
84func TestRendererColorProfile(t *testing.T) {
85	tests := []struct {
86		name     string
87		profile  colorprofile.Profile
88		env      []string
89		expected colorprofile.Profile
90	}{
91		{
92			name:     "truecolor",
93			profile:  colorprofile.TrueColor,
94			env:      []string{"COLORTERM=truecolor"},
95			expected: colorprofile.TrueColor,
96		},
97		{
98			name:     "256 color",
99			profile:  colorprofile.ANSI256,
100			env:      []string{"TERM=xterm-256color"},
101			expected: colorprofile.ANSI256,
102		},
103		{
104			name:     "16 color",
105			profile:  colorprofile.ANSI,
106			env:      []string{"TERM=xterm"},
107			expected: colorprofile.ANSI,
108		},
109	}
110
111	for _, tt := range tests {
112		t.Run(tt.name, func(t *testing.T) {
113			var buf bytes.Buffer
114			r := NewTerminalRenderer(&buf, tt.env)
115			r.SetColorProfile(tt.profile)
116
117			// Test that the profile was set correctly by rendering a colored cell
118			cellbuf := NewRenderBuffer(1, 1)
119			cell := Cell{
120				Content: "X",
121				Width:   1,
122				Style:   Style{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}},
123			}
124			cellbuf.SetCell(0, 0, &cell)
125
126			r.Render(cellbuf)
127			if err := r.Flush(); err != nil {
128				t.Fatalf("failed to flush renderer: %v", err)
129			}
130
131			// The output should contain color sequences appropriate for the profile
132			output := buf.String()
133			if !strings.Contains(output, "X") {
134				t.Errorf("expected output to contain 'X', got: %q", output)
135			}
136		})
137	}
138}
139
140func TestRendererPosition(t *testing.T) {
141	var buf bytes.Buffer
142	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
143
144	// Initial position should be -1, -1
145	x, y := r.Position()
146	if x != -1 || y != -1 {
147		t.Errorf("expected initial position (-1, -1), got (%d, %d)", x, y)
148	}
149
150	// Set position
151	r.SetPosition(5, 10)
152	x, y = r.Position()
153	if x != 5 || y != 10 {
154		t.Errorf("expected position (5, 10), got (%d, %d)", x, y)
155	}
156}
157
158func TestRendererMoveTo(t *testing.T) {
159	var buf bytes.Buffer
160	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
161
162	r.MoveTo(5, 3)
163	if err := r.Flush(); err != nil {
164		t.Fatalf("failed to flush renderer: %v", err)
165	}
166
167	// Should contain cursor positioning sequence
168	output := buf.String()
169	if !strings.Contains(output, "\x1b[") {
170		t.Errorf("expected cursor positioning sequence in output: %q", output)
171	}
172}
173
174func TestRendererWriteString(t *testing.T) {
175	var buf bytes.Buffer
176	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
177
178	n, err := r.WriteString("Hello, World!")
179	if err != nil {
180		t.Fatalf("failed to write string: %v", err)
181	}
182
183	if n != 13 {
184		t.Errorf("expected to write 13 bytes, wrote %d", n)
185	}
186
187	if err := r.Flush(); err != nil {
188		t.Fatalf("failed to flush renderer: %v", err)
189	}
190
191	output := buf.String()
192	if !strings.Contains(output, "Hello, World!") {
193		t.Errorf("expected output to contain 'Hello, World!', got: %q", output)
194	}
195}
196
197func TestRendererWrite(t *testing.T) {
198	var buf bytes.Buffer
199	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
200
201	data := []byte("Hello, World!")
202	n, err := r.Write(data)
203	if err != nil {
204		t.Fatalf("failed to write bytes: %v", err)
205	}
206
207	if n != len(data) {
208		t.Errorf("expected to write %d bytes, wrote %d", len(data), n)
209	}
210
211	if err := r.Flush(); err != nil {
212		t.Fatalf("failed to flush renderer: %v", err)
213	}
214
215	output := buf.String()
216	if !strings.Contains(output, "Hello, World!") {
217		t.Errorf("expected output to contain 'Hello, World!', got: %q", output)
218	}
219}
220
221func TestRendererRedraw(t *testing.T) {
222	var buf bytes.Buffer
223	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
224
225	cellbuf := NewRenderBuffer(3, 1)
226	cell := Cell{Content: "X", Width: 1}
227	cellbuf.SetCell(0, 0, &cell)
228
229	// First render
230	r.Render(cellbuf)
231	if err := r.Flush(); err != nil {
232		t.Fatalf("failed to flush renderer: %v", err)
233	}
234
235	firstOutput := buf.String()
236	buf.Reset()
237
238	// Redraw should force a full redraw
239	r.Redraw(cellbuf)
240	if err := r.Flush(); err != nil {
241		t.Fatalf("failed to flush renderer: %v", err)
242	}
243
244	secondOutput := buf.String()
245
246	// Both outputs should contain the cell content
247	if !strings.Contains(firstOutput, "X") {
248		t.Errorf("expected first output to contain 'X', got: %q", firstOutput)
249	}
250	if !strings.Contains(secondOutput, "X") {
251		t.Errorf("expected second output to contain 'X', got: %q", secondOutput)
252	}
253}
254
255func TestRendererErase(t *testing.T) {
256	var buf bytes.Buffer
257	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
258
259	cellbuf := NewRenderBuffer(3, 1)
260	cell := Cell{Content: "X", Width: 1}
261	cellbuf.SetCell(0, 0, &cell)
262
263	// Mark for erase
264	r.Erase()
265
266	// Render should perform a full clear
267	r.Render(cellbuf)
268	if err := r.Flush(); err != nil {
269		t.Fatalf("failed to flush renderer: %v", err)
270	}
271
272	output := buf.String()
273	if !strings.Contains(output, "X") {
274		t.Errorf("expected output to contain 'X', got: %q", output)
275	}
276}
277
278func TestRendererResize(t *testing.T) {
279	var buf bytes.Buffer
280	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
281
282	// Test resize
283	r.Resize(80, 24)
284
285	// Should not crash and should handle the resize
286	cellbuf := NewRenderBuffer(80, 24)
287	r.Render(cellbuf)
288	if err := r.Flush(); err != nil {
289		t.Fatalf("failed to flush renderer: %v", err)
290	}
291}
292
293func TestRendererPrependString(t *testing.T) {
294	var buf bytes.Buffer
295	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
296
297	r.Resize(10, 5)
298	cellbuf := NewRenderBuffer(10, 5)
299
300	r.PrependString(cellbuf, "Prepended line")
301	r.Render(cellbuf)
302	if err := r.Flush(); err != nil {
303		t.Fatalf("failed to flush renderer: %v", err)
304	}
305
306	output := buf.String()
307	if !strings.Contains(output, "Prepended line") {
308		t.Errorf("expected output to contain 'Prepended line', got: %q", output)
309	}
310}
311
312func TestRendererPrependLines(t *testing.T) {
313	var buf bytes.Buffer
314	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
315
316	r.Resize(10, 5)
317	cellbuf := NewRenderBuffer(10, 5)
318
319	// Create a line to prepend
320	line := make(Line, 5)
321	for i, ch := range "Hello" {
322		line[i] = Cell{Content: string(ch), Width: 1}
323	}
324
325	r.PrependString(cellbuf, line.Render())
326	r.Render(cellbuf)
327	if err := r.Flush(); err != nil {
328		t.Fatalf("failed to flush renderer: %v", err)
329	}
330
331	output := buf.String()
332	if !strings.Contains(output, "Hello") {
333		t.Errorf("expected output to contain 'Hello', got: %q", output)
334	}
335}
336
337func TestRendererCapabilities(t *testing.T) {
338	tests := []struct {
339		name string
340		term string
341		test func(*testing.T, *TerminalRenderer)
342	}{
343		{
344			name: "xterm capabilities",
345			term: "xterm-256color",
346			test: func(t *testing.T, r *TerminalRenderer) {
347				// xterm should support all capabilities
348				if !r.caps.Contains(capCHA) {
349					t.Error("expected xterm to support CHA")
350				}
351				// NOTE: We have disabled HPA for xterm due to some terminals
352				// not supporting it correctly i.e. Konsole.
353				// if !r.caps.Contains(capHPA) {
354				// 	t.Error("expected xterm to support HPA")
355				// }
356			},
357		},
358		{
359			name: "linux terminal capabilities",
360			term: "linux",
361			test: func(t *testing.T, r *TerminalRenderer) {
362				// linux terminal has limited capabilities
363				if !r.caps.Contains(capVPA) {
364					t.Error("expected linux to support VPA")
365				}
366				if !r.caps.Contains(capHPA) {
367					t.Error("expected linux to support HPA")
368				}
369				if r.caps.Contains(capREP) {
370					t.Error("expected linux to not support REP")
371				}
372			},
373		},
374	}
375
376	for _, tt := range tests {
377		t.Run(tt.name, func(t *testing.T) {
378			var buf bytes.Buffer
379			r := NewTerminalRenderer(&buf, []string{"TERM=" + tt.term})
380			tt.test(t, r)
381		})
382	}
383}
384
385func TestRendererTabStops(t *testing.T) {
386	var buf bytes.Buffer
387	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
388
389	// Enable tab stops
390	r.SetTabStops(8)
391
392	// Test that tab stops are set
393	cellbuf := NewRenderBuffer(20, 1)
394	r.Render(cellbuf)
395	if err := r.Flush(); err != nil {
396		t.Fatalf("failed to flush renderer: %v", err)
397	}
398
399	// Disable tab stops
400	r.SetTabStops(-1)
401
402	r.Render(cellbuf)
403	if err := r.Flush(); err != nil {
404		t.Fatalf("failed to flush renderer: %v", err)
405	}
406}
407
408func TestRendererBackspace(t *testing.T) {
409	var buf bytes.Buffer
410	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
411
412	// Enable backspace optimization
413	r.SetBackspace(true)
414
415	cellbuf := NewRenderBuffer(10, 1)
416	r.Render(cellbuf)
417	if err := r.Flush(); err != nil {
418		t.Fatalf("failed to flush renderer: %v", err)
419	}
420
421	// Disable backspace optimization
422	r.SetBackspace(false)
423
424	r.Render(cellbuf)
425	if err := r.Flush(); err != nil {
426		t.Fatalf("failed to flush renderer: %v", err)
427	}
428}
429
430func TestRendererMapNewline(t *testing.T) {
431	var buf bytes.Buffer
432	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
433
434	// Enable newline mapping
435	r.SetMapNewline(true)
436
437	cellbuf := NewRenderBuffer(10, 2)
438	r.Render(cellbuf)
439	if err := r.Flush(); err != nil {
440		t.Fatalf("failed to flush renderer: %v", err)
441	}
442
443	// Disable newline mapping
444	r.SetMapNewline(false)
445
446	r.Render(cellbuf)
447	if err := r.Flush(); err != nil {
448		t.Fatalf("failed to flush renderer: %v", err)
449	}
450}
451
452func TestRendererTouched(t *testing.T) {
453	var buf bytes.Buffer
454	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
455
456	cellbuf := NewRenderBuffer(5, 3)
457
458	// Initially, no lines should be touched (empty buffer)
459	touched := cellbuf.TouchedLines()
460	if touched != 0 {
461		t.Errorf("expected 0 touched lines initially, got %d", touched)
462	}
463
464	// Mark some lines as touched by setting cells
465	cell := Cell{Content: "X", Width: 1}
466	cellbuf.SetCell(0, 0, &cell)
467	cellbuf.SetCell(0, 2, &cell)
468
469	// Should have touched lines where we set cells
470	touched = cellbuf.TouchedLines()
471	if touched != 2 {
472		t.Errorf("expected 2 touched lines after setting cells, got %d", touched)
473	}
474
475	// After rendering, the Touched method still counts all lines as touched
476	// because the renderer sets all LineData to non-nil (even with FirstCell: -1, LastCell: -1)
477	// This is the actual behavior of the renderer
478	r.Render(cellbuf)
479	touched = cellbuf.TouchedLines()
480	if touched != 3 {
481		t.Errorf("expected 3 touched lines after render (all lines have LineData), got %d", touched)
482	}
483
484	// But if we check the actual touched state by looking at FirstCell/LastCell
485	actualTouched := 0
486	for _, lineData := range cellbuf.Touched {
487		if lineData != nil && (lineData.FirstCell != -1 || lineData.LastCell != -1) {
488			actualTouched++
489		}
490	}
491	if actualTouched != 0 {
492		t.Errorf("expected 0 actually touched lines after render, got %d", actualTouched)
493	}
494}
495
496// Test wide character handling
497func TestRendererWideCharacters(t *testing.T) {
498	var buf bytes.Buffer
499	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
500
501	cellbuf := NewRenderBuffer(10, 1)
502
503	// Test wide characters (emoji, CJK characters)
504	wideChars := []string{"🌟", "中", "文", "字"}
505	for i, char := range wideChars {
506		cell := Cell{Content: char, Width: 2} // Wide characters typically have width 2
507		cellbuf.SetCell(i*2, 0, &cell)
508	}
509
510	r.Render(cellbuf)
511	if err := r.Flush(); err != nil {
512		t.Fatalf("failed to flush renderer: %v", err)
513	}
514
515	output := buf.String()
516	for _, char := range wideChars {
517		if !strings.Contains(output, char) {
518			t.Errorf("expected output to contain wide character '%s', got: %q", char, output)
519		}
520	}
521}
522
523// Test zero-width characters
524func TestRendererZeroWidthCharacters(t *testing.T) {
525	var buf bytes.Buffer
526	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
527
528	cellbuf := NewRenderBuffer(5, 1)
529
530	// Test zero-width characters (combining marks, etc.)
531	cell := Cell{Content: "a\u0301", Width: 1} // 'a' with combining acute accent
532	cellbuf.SetCell(0, 0, &cell)
533
534	// Zero-width cell
535	zeroCell := Cell{Content: "\u200B", Width: 0} // Zero-width space
536	cellbuf.SetCell(1, 0, &zeroCell)
537
538	r.Render(cellbuf)
539	if err := r.Flush(); err != nil {
540		t.Fatalf("failed to flush renderer: %v", err)
541	}
542
543	output := buf.String()
544	if !strings.Contains(output, "a\u0301") {
545		t.Errorf("expected output to contain combining character, got: %q", output)
546	}
547}
548
549// Test styled text rendering
550func TestRendererStyledText(t *testing.T) {
551	var buf bytes.Buffer
552	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
553
554	cellbuf := NewRenderBuffer(10, 1)
555
556	// Test various styles
557	styles := []Style{
558		{Attrs: AttrBold},
559		{Fg: color.RGBA{R: 255, G: 0, B: 0, A: 255}},
560		{Bg: color.RGBA{R: 0, G: 255, B: 0, A: 255}},
561		{Attrs: AttrBold, Fg: color.RGBA{R: 0, G: 0, B: 255, A: 255}},
562	}
563
564	for i, style := range styles {
565		cell := Cell{Content: "X", Width: 1, Style: style}
566		cellbuf.SetCell(i, 0, &cell)
567	}
568
569	r.Render(cellbuf)
570	if err := r.Flush(); err != nil {
571		t.Fatalf("failed to flush renderer: %v", err)
572	}
573
574	output := buf.String()
575	// Should contain ANSI escape sequences for styling
576	if !strings.Contains(output, "\x1b[") {
577		t.Errorf("expected output to contain ANSI escape sequences, got: %q", output)
578	}
579}
580
581// Test hyperlink rendering
582func TestRendererHyperlinks(t *testing.T) {
583	var buf bytes.Buffer
584	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
585
586	cellbuf := NewRenderBuffer(10, 1)
587
588	// Test hyperlink
589	link := NewLink("https://example.com")
590	cell := Cell{Content: "link", Width: 4, Link: link}
591	cellbuf.SetCell(0, 0, &cell)
592
593	r.Render(cellbuf)
594	if err := r.Flush(); err != nil {
595		t.Fatalf("failed to flush renderer: %v", err)
596	}
597
598	output := buf.String()
599	if !strings.Contains(output, "link") {
600		t.Errorf("expected output to contain 'link', got: %q", output)
601	}
602}
603
604// Moving between rows resets the pen, which closes any open hyperlink. Every
605// row the link covers has to reopen it, or only the first row stays clickable.
606func TestRendererHyperlinkReopensOnEachRow(t *testing.T) {
607	const w, h = 4, 2
608	var buf bytes.Buffer
609	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
610	r.SetColorProfile(colorprofile.TrueColor)
611
612	cellbuf := NewRenderBuffer(w, h)
613	link := NewLink("https://example.com")
614	for y := range h {
615		for x := range w {
616			cellbuf.SetCell(x, y, &Cell{Content: "a", Width: 1, Link: link})
617		}
618	}
619
620	r.Render(cellbuf)
621	if err := r.Flush(); err != nil {
622		t.Fatalf("failed to flush renderer: %v", err)
623	}
624
625	output := buf.String()
626	opens := strings.Count(output, ansi.SetHyperlink(link.URL, link.Params))
627	if opens != h {
628		t.Errorf("expected the link to open once per row (%d), got %d: %q", h, opens, output)
629	}
630}
631
632func TestRendererSwitchBuffer(t *testing.T) {
633	var buf bytes.Buffer
634	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
635
636	// Start with small buffer
637	cellbuf := NewRenderBuffer(5, 3)
638	cell := Cell{Content: "X", Width: 1}
639	cellbuf.SetCell(0, 0, &cell)
640
641	r.Render(cellbuf)
642	if err := r.Flush(); err != nil {
643		t.Fatalf("failed to flush renderer: %v", err)
644	}
645
646	// Resize to larger buffer
647	largeBuf := NewRenderBuffer(10, 6)
648	largeBuf.SetCell(0, 1, &cell) // Place at visible position
649
650	r.Render(largeBuf)
651	if err := r.Flush(); err != nil {
652		t.Fatalf("failed to flush renderer: %v", err)
653	}
654
655	output := buf.String()
656	// Home, draw X at (0,0); erase it, since the new frame's row 0 is blank;
657	// newline, draw X at (0,1); pad cursor to row 5.
658	expected := "\x1b[HX\r\x1b[K\nX\r\n\n\n\n"
659	if output != expected {
660		t.Errorf("expected output after resize to be %q, got: %q", expected, output)
661	}
662}
663
664// Test relative cursor movement
665func TestRendererRelativeCursor(t *testing.T) {
666	var buf bytes.Buffer
667	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
668
669	r.SetRelativeCursor(true)
670
671	cellbuf := NewRenderBuffer(10, 3)
672	cell := Cell{Content: "X", Width: 1}
673	cellbuf.SetCell(5, 1, &cell)
674
675	r.Render(cellbuf)
676	if err := r.Flush(); err != nil {
677		t.Fatalf("failed to flush renderer: %v", err)
678	}
679
680	output := buf.String()
681	if !strings.Contains(output, "X") {
682		t.Errorf("expected output to contain 'X', got: %q", output)
683	}
684
685	// Test disabling relative cursor
686	r.SetRelativeCursor(false)
687	buf.Reset()
688
689	r.Render(cellbuf)
690	if err := r.Flush(); err != nil {
691		t.Fatalf("failed to flush renderer: %v", err)
692	}
693}
694
695// Test logger functionality
696func TestRendererLogger(t *testing.T) {
697	var buf bytes.Buffer
698	var logBuf bytes.Buffer
699
700	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
701
702	// Create a simple logger
703	logger := &testLogger{buf: &logBuf}
704	r.SetLogger(logger)
705
706	cellbuf := NewRenderBuffer(3, 1)
707	cell := Cell{Content: "X", Width: 1}
708	cellbuf.SetCell(0, 0, &cell)
709
710	r.Render(cellbuf)
711	if err := r.Flush(); err != nil {
712		t.Fatalf("failed to flush renderer: %v", err)
713	}
714
715	// Should have logged output
716	if logBuf.Len() == 0 {
717		t.Error("expected logger to have recorded output")
718	}
719
720	// Test removing logger
721	r.SetLogger(nil)
722	logBuf.Reset()
723
724	r.Render(cellbuf)
725	if err := r.Flush(); err != nil {
726		t.Fatalf("failed to flush renderer: %v", err)
727	}
728
729	// Should not have logged anything
730	if logBuf.Len() != 0 {
731		t.Error("expected no logging after removing logger")
732	}
733}
734
735// Test scroll optimization
736func TestRendererScrollOptimization(t *testing.T) {
737	var buf bytes.Buffer
738	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
739
740	r.SetFullscreen(true) // Scroll optimization is enabled in alt screen mode
741
742	cellbuf := NewRenderBuffer(10, 5)
743
744	// Fill buffer with content
745	for y := 0; y < 5; y++ {
746		for x := 0; x < 10; x++ {
747			cell := Cell{Content: string(rune('A' + y)), Width: 1}
748			cellbuf.SetCell(x, y, &cell)
749		}
750	}
751
752	// First render
753	r.Render(cellbuf)
754	if err := r.Flush(); err != nil {
755		t.Fatalf("failed to flush renderer: %v", err)
756	}
757
758	buf.Reset()
759
760	// Simulate scrolling by shifting content up
761	newBuf := NewRenderBuffer(10, 5)
762	for y := 0; y < 4; y++ {
763		for x := 0; x < 10; x++ {
764			cell := Cell{Content: string(rune('A' + y + 1)), Width: 1}
765			newBuf.SetCell(x, y, &cell)
766		}
767	}
768	// Add new line at bottom
769	for x := 0; x < 10; x++ {
770		cell := Cell{Content: "F", Width: 1}
771		newBuf.SetCell(x, 4, &cell)
772	}
773
774	r.Render(newBuf)
775	if err := r.Flush(); err != nil {
776		t.Fatalf("failed to flush renderer: %v", err)
777	}
778
779	output := buf.String()
780	if !strings.Contains(output, "F") {
781		t.Errorf("expected output to contain new content 'F', got: %q", output)
782	}
783}
784
785// Test multiple prepend operations
786func TestRendererMultiplePrepends(t *testing.T) {
787	var buf bytes.Buffer
788	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
789
790	r.Resize(20, 10)
791	cellbuf := NewRenderBuffer(20, 10)
792
793	// Prepend multiple strings
794	r.PrependString(cellbuf, "First line")
795	r.PrependString(cellbuf, "Second line")
796
797	// Prepend multiple lines
798	line1 := make(Line, 10)
799	line2 := make(Line, 10)
800	for i, ch := range "Third line" {
801		if i < len(line1) {
802			line1[i] = Cell{Content: string(ch), Width: 1}
803		}
804	}
805	for i, ch := range "Fourth lin" {
806		if i < len(line2) {
807			line2[i] = Cell{Content: string(ch), Width: 1}
808		}
809	}
810
811	r.PrependString(cellbuf, strings.Join([]string{line1.Render(), line2.Render()}, "\n"))
812
813	r.Render(cellbuf)
814	if err := r.Flush(); err != nil {
815		t.Fatalf("failed to flush renderer: %v", err)
816	}
817
818	output := buf.String()
819	expectedStrings := []string{"First line", "Second line", "Third line", "Fourth lin"}
820	for _, expected := range expectedStrings {
821		if !strings.Contains(output, expected) {
822			t.Errorf("expected output to contain '%s', got: %q", expected, output)
823		}
824	}
825}
826
827// Test error conditions and edge cases
828func TestRendererEdgeCases(t *testing.T) {
829	var buf bytes.Buffer
830	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
831
832	// Test with empty buffer
833	emptyBuf := NewRenderBuffer(0, 0)
834	r.Render(emptyBuf)
835	if err := r.Flush(); err != nil {
836		t.Fatalf("failed to flush renderer with empty buffer: %v", err)
837	}
838
839	// Test with nil cells
840	cellbuf := NewRenderBuffer(3, 3)
841	cellbuf.SetCell(1, 1, nil) // Set nil cell
842
843	r.Render(cellbuf)
844	if err := r.Flush(); err != nil {
845		t.Fatalf("failed to flush renderer with nil cells: %v", err)
846	}
847
848	// Test with very large buffer
849	largeBuf := NewRenderBuffer(1000, 1000)
850	cell := Cell{Content: "X", Width: 1}
851	largeBuf.SetCell(999, 999, &cell)
852
853	r.Render(largeBuf)
854	if err := r.Flush(); err != nil {
855		t.Fatalf("failed to flush renderer with large buffer: %v", err)
856	}
857}
858
859// Test terminal-specific optimizations
860func TestRendererTerminalOptimizations(t *testing.T) {
861	tests := []struct {
862		name string
863		term string
864		test func(*testing.T, *TerminalRenderer)
865	}{
866		{
867			name: "alacritty optimizations",
868			term: "alacritty",
869			test: func(t *testing.T, r *TerminalRenderer) {
870				// Alacritty has specific capability limitations
871				if r.caps.Contains(capCHT) {
872					t.Error("expected alacritty to not support CHT")
873				}
874			},
875		},
876		{
877			name: "screen optimizations",
878			term: "screen",
879			test: func(t *testing.T, r *TerminalRenderer) {
880				// Screen terminal has specific limitations
881				if r.caps.Contains(capREP) {
882					t.Error("expected screen to not support REP")
883				}
884			},
885		},
886		{
887			name: "tmux optimizations",
888			term: "tmux",
889			test: func(t *testing.T, r *TerminalRenderer) {
890				// tmux should support most capabilities
891				if !r.caps.Contains(capVPA) {
892					t.Error("expected tmux to support VPA")
893				}
894			},
895		},
896	}
897
898	for _, tt := range tests {
899		t.Run(tt.name, func(t *testing.T) {
900			var buf bytes.Buffer
901			r := NewTerminalRenderer(&buf, []string{"TERM=" + tt.term})
902			tt.test(t, r)
903		})
904	}
905}
906
907// Test cursor movement optimizations
908func TestRendererCursorMovementOptimizations(t *testing.T) {
909	var buf bytes.Buffer
910	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
911
912	// Test tab optimization
913	r.SetTabStops(8)
914	cellbuf := NewRenderBuffer(20, 1)
915
916	// Place content at tab stops
917	cell := Cell{Content: "X", Width: 1}
918	cellbuf.SetCell(8, 0, &cell)  // First tab stop
919	cellbuf.SetCell(16, 0, &cell) // Second tab stop
920
921	r.Render(cellbuf)
922	if err := r.Flush(); err != nil {
923		t.Fatalf("failed to flush renderer: %v", err)
924	}
925
926	output := buf.String()
927	if !strings.Contains(output, "X") {
928		t.Errorf("expected output to contain 'X', got: %q", output)
929	}
930}
931
932// Test backspace optimization
933func TestRendererBackspaceOptimization(t *testing.T) {
934	var buf bytes.Buffer
935	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
936
937	r.SetBackspace(true)
938	cellbuf := NewRenderBuffer(10, 1)
939
940	// Place content that would benefit from backspace optimization
941	cell := Cell{Content: "X", Width: 1}
942	cellbuf.SetCell(5, 0, &cell)
943
944	// Move cursor to position that would use backspace
945	r.MoveTo(8, 0)
946	r.MoveTo(3, 0) // Should use backspace to move left
947
948	r.Render(cellbuf)
949	if err := r.Flush(); err != nil {
950		t.Fatalf("failed to flush renderer: %v", err)
951	}
952
953	output := buf.String()
954	if !strings.Contains(output, "X") {
955		t.Errorf("expected output to contain 'X', got: %q", output)
956	}
957}
958
959// Test newline mapping
960func TestRendererNewlineMapping(t *testing.T) {
961	var buf bytes.Buffer
962	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
963
964	r.SetMapNewline(true)
965	r.SetRelativeCursor(true)
966
967	cellbuf := NewRenderBuffer(10, 3)
968	cell := Cell{Content: "X", Width: 1}
969	cellbuf.SetCell(0, 0, &cell)
970	cellbuf.SetCell(0, 1, &cell)
971	cellbuf.SetCell(0, 2, &cell)
972
973	r.Render(cellbuf)
974	if err := r.Flush(); err != nil {
975		t.Fatalf("failed to flush renderer: %v", err)
976	}
977
978	output := buf.String()
979	// Should contain newlines for multi-line content
980	if !strings.Contains(output, "X") {
981		t.Errorf("expected output to contain 'X', got: %q", output)
982	}
983}
984
985// Test underline styles
986func TestRendererUnderlineStyles(t *testing.T) {
987	var buf bytes.Buffer
988	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
989
990	cellbuf := NewRenderBuffer(10, 1)
991
992	// Test different underline styles
993	styles := []Style{
994		{Underline: UnderlineStyleSingle},
995		{Underline: UnderlineStyleDouble},
996		{Underline: UnderlineStyleCurly},
997		{Underline: UnderlineStyleDotted},
998		{Underline: UnderlineStyleDashed},
999	}
1000
1001	for i, style := range styles {
1002		if i < cellbuf.Width() {
1003			cell := Cell{Content: "U", Width: 1, Style: style}
1004			cellbuf.SetCell(i, 0, &cell)
1005		}
1006	}
1007
1008	r.Render(cellbuf)
1009	if err := r.Flush(); err != nil {
1010		t.Fatalf("failed to flush renderer: %v", err)
1011	}
1012
1013	output := buf.String()
1014	if !strings.Contains(output, "U") {
1015		t.Errorf("expected output to contain 'U', got: %q", output)
1016	}
1017}
1018
1019// Test italic and other text attributes
1020func TestRendererTextAttributes(t *testing.T) {
1021	var buf bytes.Buffer
1022	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1023
1024	cellbuf := NewRenderBuffer(10, 1)
1025
1026	// Test various text attributes
1027	styles := []Style{
1028		{Attrs: AttrItalic},
1029		{Attrs: AttrFaint},
1030		{Attrs: AttrBlink},
1031		{Attrs: AttrReverse},
1032		{Attrs: AttrStrikethrough},
1033	}
1034
1035	for i, style := range styles {
1036		if i < cellbuf.Width() {
1037			cell := Cell{Content: "A", Width: 1, Style: style}
1038			cellbuf.SetCell(i, 0, &cell)
1039		}
1040	}
1041
1042	r.Render(cellbuf)
1043	if err := r.Flush(); err != nil {
1044		t.Fatalf("failed to flush renderer: %v", err)
1045	}
1046
1047	output := buf.String()
1048	if !strings.Contains(output, "A") {
1049		t.Errorf("expected output to contain 'A', got: %q", output)
1050	}
1051}
1052
1053// Test color downsampling
1054func TestRendererColorDownsampling(t *testing.T) {
1055	tests := []struct {
1056		name    string
1057		profile colorprofile.Profile
1058	}{
1059		{"TrueColor", colorprofile.TrueColor},
1060		{"ANSI256", colorprofile.ANSI256},
1061		{"ANSI", colorprofile.ANSI},
1062		{"Ascii", colorprofile.Ascii},
1063	}
1064
1065	for _, tt := range tests {
1066		t.Run(tt.name, func(t *testing.T) {
1067			var buf bytes.Buffer
1068			r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1069			r.SetColorProfile(tt.profile)
1070
1071			cellbuf := NewRenderBuffer(3, 1)
1072
1073			// Test with high-precision color that needs downsampling
1074			cell := Cell{
1075				Content: "C",
1076				Width:   1,
1077				Style:   Style{Fg: color.RGBA{R: 123, G: 234, B: 45, A: 255}},
1078			}
1079			cellbuf.SetCell(0, 0, &cell)
1080
1081			r.Render(cellbuf)
1082			if err := r.Flush(); err != nil {
1083				t.Fatalf("failed to flush renderer: %v", err)
1084			}
1085
1086			output := buf.String()
1087			if !strings.Contains(output, "C") {
1088				t.Errorf("expected output to contain 'C', got: %q", output)
1089			}
1090		})
1091	}
1092}
1093
1094// Test phantom cursor handling
1095func TestRendererPhantomCursor(t *testing.T) {
1096	var buf bytes.Buffer
1097	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1098	r.SetColorProfile(colorprofile.TrueColor)
1099
1100	r.SetFullscreen(true) // Use fullscreen rendering optimizations
1101	r.SetRelativeCursor(false)
1102	cellbuf := NewRenderBuffer(5, 3)
1103
1104	// Fill the last column to trigger phantom cursor behavior
1105	cell := Cell{Content: "X", Width: 1}
1106	for y := 0; y < cellbuf.Height(); y++ {
1107		cellbuf.SetCell(4, y, &cell) // Last column
1108	}
1109
1110	r.Render(cellbuf)
1111	if err := r.Flush(); err != nil {
1112		t.Fatalf("failed to flush renderer: %v", err)
1113	}
1114
1115	output := buf.String()
1116	expected := "\x1b[1;5HX\r\n\x1b[5GX\r\n\x1b[5G\x1b[?7lX\x1b[?7h"
1117	if output != expected {
1118		t.Errorf("expected no output for phantom cursor case, got: %q", output)
1119	}
1120}
1121
1122// Test line clearing optimizations
1123func TestRendererLineClearingOptimizations(t *testing.T) {
1124	var buf bytes.Buffer
1125	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1126
1127	cellbuf := NewRenderBuffer(10, 3)
1128
1129	// Fill first line completely
1130	cell := Cell{Content: "X", Width: 1}
1131	for x := 0; x < 10; x++ {
1132		cellbuf.SetCell(x, 0, &cell)
1133	}
1134
1135	// First render
1136	r.Render(cellbuf)
1137	if err := r.Flush(); err != nil {
1138		t.Fatalf("failed to flush renderer: %v", err)
1139	}
1140
1141	buf.Reset()
1142
1143	// Clear the line by creating new buffer with empty line
1144	newBuf := NewRenderBuffer(10, 3)
1145	// Only set one cell, leaving the rest empty
1146	newBuf.SetCell(0, 0, &cell)
1147
1148	r.Render(newBuf)
1149	if err := r.Flush(); err != nil {
1150		t.Fatalf("failed to flush renderer: %v", err)
1151	}
1152
1153	output := buf.String()
1154	if !strings.Contains(output, "X") {
1155		t.Errorf("expected output to contain 'X', got: %q", output)
1156	}
1157}
1158
1159// Test repeat character optimization
1160func TestRendererRepeatCharacterOptimization(t *testing.T) {
1161	var buf bytes.Buffer
1162	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1163
1164	cellbuf := NewRenderBuffer(20, 1)
1165
1166	// Fill with repeated characters that should trigger REP optimization
1167	cell := Cell{Content: "A", Width: 1}
1168	for x := 0; x < 15; x++ {
1169		cellbuf.SetCell(x, 0, &cell)
1170	}
1171
1172	r.Render(cellbuf)
1173	if err := r.Flush(); err != nil {
1174		t.Fatalf("failed to flush renderer: %v", err)
1175	}
1176
1177	output := buf.String()
1178	if !strings.Contains(output, "A") {
1179		t.Errorf("expected output to contain 'A', got: %q", output)
1180	}
1181}
1182
1183// Test erase character optimization
1184func TestRendererEraseCharacterOptimization(t *testing.T) {
1185	var buf bytes.Buffer
1186	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1187
1188	cellbuf := NewRenderBuffer(20, 1)
1189
1190	// Add some non-space content first
1191	cell := Cell{Content: "A", Width: 1}
1192	cellbuf.SetCell(0, 0, &cell)
1193
1194	// Fill with spaces that should trigger ECH optimization
1195	spaceCell := Cell{Content: " ", Width: 1}
1196	for x := 5; x < 15; x++ {
1197		cellbuf.SetCell(x, 0, &spaceCell)
1198	}
1199
1200	r.Render(cellbuf)
1201	if err := r.Flush(); err != nil {
1202		t.Fatalf("failed to flush renderer: %v", err)
1203	}
1204
1205	// The output should use erase character optimization for consecutive spaces
1206	output := buf.String()
1207	// Just verify it doesn't crash and produces some output with our content
1208	if !strings.Contains(output, "A") {
1209		t.Errorf("expected output to contain 'A', got: %q", output)
1210	}
1211}
1212
1213func TestRendererUpdates(t *testing.T) {
1214	cases := []struct {
1215		name     string
1216		frames   []string
1217		expected []string // expected ANSI escape sequence after each frame
1218	}{
1219		{
1220			name: "simple style change",
1221			frames: []string{
1222				"A",
1223				"\x1b[1mA",
1224			},
1225			expected: []string{
1226				"\rA",
1227				"\r\x1b[1mA\x1b[m",
1228			},
1229		},
1230		{
1231			name:   "style and link change",
1232			frames: []string{"A", "\x1b[31m\x1b]8;;https://example.com\x1b\\A\x1b]8;;\x1b\\"}, // red + link
1233			expected: []string{
1234				"\rA",
1235				"\r\x1b[31m\x1b]8;;https://example.com\aA\x1b[m\x1b]8;;\a",
1236			},
1237		},
1238		{
1239			// Covers comparing stored downsampled colors vs new true color styles
1240			// See commit 75d1e37ff1bb
1241			name: "the same true color style frames",
1242			frames: []string{
1243				" \x1b[38;2;255;128;0mABC\n DEF", // orange
1244				" \x1b[38;2;255;128;0mABC\n DEF", // orange
1245				" \x1b[38;2;255;128;0mABC\n DEF", // orange
1246			},
1247			expected: []string{
1248				"\r \x1b[38;5;208mABC\x1b[m\r\n\x1b[38;5;208m DEF\x1b[m",
1249				"",
1250				"",
1251			},
1252		},
1253	}
1254
1255	for _, tc := range cases {
1256		t.Run(tc.name, func(t *testing.T) {
1257			var buf bytes.Buffer
1258			r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color", "TTY_FORCE=1"})
1259			t.Logf("Profile: %v", r.profile)
1260			r.SetRelativeCursor(true) // Use absolute cursor movements since we're drawing fullscreen
1261
1262			scr := NewScreenBuffer(5, 3)
1263			for i, frameStr := range tc.frames {
1264				NewStyledString(frameStr).Draw(scr, scr.Bounds())
1265				r.Render(scr.RenderBuffer)
1266				if err := r.Flush(); err != nil {
1267					t.Fatalf("failed to flush renderer: %v", err)
1268				}
1269
1270				output := buf.String()
1271				expected := tc.expected[i]
1272				if output != expected {
1273					t.Errorf("frame %d: expected output %q, got %q", i, expected, output)
1274				}
1275
1276				buf.Reset()
1277			}
1278		})
1279	}
1280}
1281
1282func TestRendererPrependOneLine(t *testing.T) {
1283	var buf bytes.Buffer
1284	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1285
1286	r.Resize(10, 5)
1287	cellbuf := NewScreenBuffer(10, 5)
1288	NewStyledString("This-is-a .").Draw(cellbuf, cellbuf.Bounds())
1289	r.Render(cellbuf.RenderBuffer)
1290	if err := r.Flush(); err != nil {
1291		t.Fatalf("failed to flush renderer: %v", err)
1292	}
1293
1294	NewStyledString("This-is-a .").Draw(cellbuf, cellbuf.Bounds())
1295	r.PrependString(cellbuf.RenderBuffer, "Prepended-a-new-line")
1296	r.Render(cellbuf.RenderBuffer)
1297	if err := r.Flush(); err != nil {
1298		t.Fatalf("failed to flush renderer: %v", err)
1299	}
1300
1301	output := buf.String()
1302	expected := "\x1b[HThis-is-a\r\n\n\n\n\n\n\x1b[H\x1b[2LPrepended-a-new-line\r\n"
1303	if output != expected {
1304		t.Errorf("expected output to be %q, got: %q", expected, output)
1305	}
1306}
1307
1308func TestRendererEnterExitAltScreen(t *testing.T) {
1309	var buf bytes.Buffer
1310	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1311	cellbuf := NewRenderBuffer(3, 3)
1312
1313	// Simulate cursor change
1314	r.MoveTo(1, 1)
1315
1316	// Save cursor to compare later
1317	cur := r.cur
1318	if cur.X != 1 || cur.Y != 1 {
1319		t.Errorf("expected cursor to be at (1,1), got (%d,%d)", cur.X, cur.Y)
1320	}
1321
1322	// Enter alt screen
1323	r.EnterAltScreen()
1324
1325	// Ensure we render before processing further
1326	r.Render(cellbuf)
1327
1328	// Check fullscreen is enabled
1329	if !r.Fullscreen() {
1330		t.Errorf("expected fullscreen to be enabled in alt screen mode")
1331	}
1332
1333	// Check cursor position reset
1334	if r.cur.X != 0 || r.cur.Y != 0 {
1335		t.Errorf("expected cursor to be reset to (0,0) in alt screen mode, got (%d,%d)", r.cur.X, r.cur.Y)
1336	}
1337
1338	// Exit alt screen
1339	r.ExitAltScreen()
1340
1341	// Check relative cursor are disabled
1342	if !r.flags.Contains(tRelativeCursor) {
1343		t.Errorf("expected relative cursor to be enabled after exiting alt screen mode")
1344	}
1345
1346	// Check cursor position restored
1347	if r.cur.X != 1 || r.cur.Y != 1 {
1348		t.Errorf("expected cursor to be restored to (1,1) after exiting alt screen mode, got (%d,%d)", r.cur.X, r.cur.Y)
1349	}
1350
1351	if err := r.Flush(); err != nil {
1352		t.Fatalf("failed to flush renderer: %v", err)
1353	}
1354
1355	output := buf.String()
1356	expected := "\x1b[2;2H\x1b[?1049h\x1b[H\x1b[2J\x1b[?1049l"
1357	if output != expected {
1358		t.Errorf("expected output to be %q, got: %q", expected, output)
1359	}
1360}
1361
1362// Helper type for testing logger
1363type testLogger struct {
1364	buf *bytes.Buffer
1365}
1366
1367func (l *testLogger) Printf(format string, args ...interface{}) {
1368	l.buf.WriteString("LOG: ")
1369	l.buf.WriteString(format)
1370	l.buf.WriteByte('\n')
1371}
1372
1373// Steady-state render of a small changed region, no resize. The common
1374// per-frame path.
1375func BenchmarkRenderFrame(b *testing.B) {
1376	r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
1377	r.SetFullscreen(true)
1378	buf := NewScreenBuffer(80, 24)
1379	text := NewStyledString(strings.Repeat("x", 79))
1380	b.ResetTimer()
1381	for i := 0; i < b.N; i++ {
1382		text.Draw(buf, Rect(0, i%24, 80, 1))
1383		r.Render(buf.RenderBuffer)
1384	}
1385}
1386
1387// Render across alternating grow/shrink resizes, exercising the early curbuf
1388// resize and the forced clear on shrink. The buffers are built up front so the
1389// measurement is the renderer's resize handling and not ScreenBuffer.Resize
1390// reallocating a grid every iteration.
1391func BenchmarkRenderResize(b *testing.B) {
1392	sizes := [][2]int{{100, 30}, {60, 20}, {120, 40}, {80, 24}}
1393	bufs := make([]ScreenBuffer, len(sizes))
1394	for i, sz := range sizes {
1395		bufs[i] = NewScreenBuffer(sz[0], sz[1])
1396	}
1397
1398	r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
1399	r.SetFullscreen(true)
1400	text := NewStyledString(strings.Repeat("x", 40))
1401	text.Draw(bufs[0], Rect(0, 0, sizes[0][0], 1))
1402	r.Render(bufs[0].RenderBuffer)
1403
1404	b.ResetTimer()
1405	for i := 0; i < b.N; i++ {
1406		n := i % len(sizes)
1407		sz, buf := sizes[n], bufs[n]
1408		r.Resize(sz[0], sz[1])
1409		text.Draw(buf, Rect(0, 0, sz[0], 1))
1410		r.Render(buf.RenderBuffer)
1411	}
1412}
1413
1414// Resizes that report the size the terminal already is, with a frame shorter
1415// than the screen. Applications are told the size on a schedule rather than only
1416// when it changes, so this is the steady state, not an edge case: a duplicate
1417// SIGWINCH, or a handler that reports on every frame.
1418//
1419// The measurement is how little a resize that changed nothing costs. Comparing
1420// the reported size against the model instead of against the last report makes
1421// this repaint the whole screen every iteration, which the other resize
1422// benchmark cannot see because it always resizes to exactly the frame size.
1423func BenchmarkResizeSteadyShortFrame(b *testing.B) {
1424	r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
1425	r.SetFullscreen(true)
1426	r.Resize(80, 24)
1427
1428	buf := NewScreenBuffer(80, 23) // one row shorter than the screen
1429	text := NewStyledString(strings.Repeat("x", 40))
1430	text.Draw(buf, Rect(0, 0, 80, 1))
1431	r.Render(buf.RenderBuffer)
1432	if err := r.Flush(); err != nil {
1433		b.Fatalf("failed to flush renderer: %v", err)
1434	}
1435
1436	b.ResetTimer()
1437	for i := 0; i < b.N; i++ {
1438		r.Resize(80, 24)
1439		text.Draw(buf, Rect(0, i%23, 80, 1))
1440		r.Render(buf.RenderBuffer)
1441		if err := r.Flush(); err != nil {
1442			b.Fatalf("failed to flush renderer: %v", err)
1443		}
1444	}
1445}
1446
1447// A resize invalidates the renderer's cursor model so the next move is
1448// absolute. In relative cursor mode there is no absolute move, and -1 there
1449// means "first move, assume the origin", so invalidating would assert a
1450// position rather than forget one and every later row would land low.
1451func TestRendererInlineResizeKeepsCursorModel(t *testing.T) {
1452	var buf bytes.Buffer
1453	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1454	r.SetRelativeCursor(true)
1455	r.Resize(80, 24)
1456
1457	cellbuf := NewRenderBuffer(80, 3)
1458	for y := range 3 {
1459		cellbuf.SetCell(0, y, &Cell{Content: "a", Width: 1})
1460	}
1461	r.Render(cellbuf)
1462	if err := r.Flush(); err != nil {
1463		t.Fatalf("failed to flush renderer: %v", err)
1464	}
1465	buf.Reset()
1466
1467	// A resize event that changes nothing, as a SIGWINCH handler would send.
1468	r.Resize(80, 24)
1469	cellbuf.SetCell(0, 0, &Cell{Content: "b", Width: 1})
1470	r.Render(cellbuf)
1471	if err := r.Flush(); err != nil {
1472		t.Fatalf("failed to flush renderer: %v", err)
1473	}
1474
1475	// Cursor is on row 2 after the first render, so reaching row 0 has to move
1476	// up. Without the two-row move the "b" lands on row 2.
1477	expected := "\r\x1b[2Ab"
1478	if output := buf.String(); output != expected {
1479		t.Errorf("expected output after resize to be %q, got: %q", expected, output)
1480	}
1481}
1482
1483// A shrink forces a full repaint so the renderer does not diff against a model
1484// the terminal has reflowed underneath it. Inline mode shares the screen with
1485// whatever came before, so it keeps the narrower partial clear instead.
1486func TestRendererInlineShrinkClearsPartially(t *testing.T) {
1487	var buf bytes.Buffer
1488	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1489	r.SetRelativeCursor(true)
1490	r.Resize(80, 24)
1491
1492	cellbuf := NewRenderBuffer(80, 3)
1493	for y := range 3 {
1494		cellbuf.SetCell(0, y, &Cell{Content: "a", Width: 1})
1495	}
1496	r.Render(cellbuf)
1497	if err := r.Flush(); err != nil {
1498		t.Fatalf("failed to flush renderer: %v", err)
1499	}
1500	buf.Reset()
1501
1502	// The application gives up a row.
1503	cellbuf.Resize(80, 2)
1504	r.Resize(80, 24)
1505	cellbuf.SetCell(0, 1, &Cell{Content: "b", Width: 1})
1506	r.Render(cellbuf)
1507	if err := r.Flush(); err != nil {
1508		t.Fatalf("failed to flush renderer: %v", err)
1509	}
1510
1511	// Erase from row 2, the row the frame gave up, then up to row 1 to write the
1512	// cell that changed. The erase no longer reaches into the frame, so row 1 is
1513	// written because the application drew into it, not to put it back.
1514	expected := "\r\x1b[J\x1bMb\r"
1515	if output := buf.String(); output != expected {
1516		t.Errorf("expected output after shrink to be %q, got: %q", expected, output)
1517	}
1518}
1519
1520// The rows an inline frame gives up have to be erased whether or not the width
1521// moved at the same time. A terminal that changes width rewraps what it holds,
1522// which spreads the abandoned rows further than the model can account for
1523// rather than tidying them away, so a width change is the case that needs the
1524// erase most.
1525//
1526// The fuzzer found this one the first time it was allowed to draw inline
1527// frames: a row painted outside the new frame survived a resize that shrank the
1528// screen and widened it in the same step.
1529func TestRendererInlineShrinkErasesAcrossAWidthChange(t *testing.T) {
1530	var buf bytes.Buffer
1531	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1532	r.SetRelativeCursor(true)
1533	r.Resize(6, 10)
1534
1535	cellbuf := NewRenderBuffer(6, 4)
1536	cellbuf.SetCell(0, 2, &Cell{Content: "a", Width: 1})
1537	r.Render(cellbuf)
1538	if err := r.Flush(); err != nil {
1539		t.Fatalf("failed to flush renderer: %v", err)
1540	}
1541	buf.Reset()
1542
1543	// Two rows shorter and much wider, the shape of a terminal resize the
1544	// application reflowed its view for.
1545	cellbuf.Resize(23, 2)
1546	r.Resize(23, 10)
1547	cellbuf.SetCell(0, 0, &Cell{Content: "b", Width: 1})
1548	r.Render(cellbuf)
1549	if err := r.Flush(); err != nil {
1550		t.Fatalf("failed to flush renderer: %v", err)
1551	}
1552
1553	if out := buf.String(); !strings.Contains(out, ansi.EraseScreenBelow) {
1554		t.Errorf("shrink should erase the rows the frame gave up, got: %q", out)
1555	}
1556}
1557
1558// That erase starts at the last row of the new frame, so it takes that row with
1559// it on the way down. The row still belongs to the frame and still holds what
1560// it held before, and the application has no reason to draw into a row it did
1561// not change, so nothing marks it for the diff loop to visit.
1562//
1563// The result is residue in reverse: not old content surviving, but current
1564// content erased and never put back.
1565func TestRendererInlineShrinkLeavesItsOwnRowsAlone(t *testing.T) {
1566	var buf bytes.Buffer
1567	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1568	r.SetRelativeCursor(true)
1569	r.Resize(6, 10)
1570
1571	cellbuf := NewRenderBuffer(6, 8)
1572	cellbuf.SetCell(0, 1, &Cell{Content: "a", Width: 1})
1573	r.Render(cellbuf)
1574	if err := r.Flush(); err != nil {
1575		t.Fatalf("failed to flush renderer: %v", err)
1576	}
1577	buf.Reset()
1578
1579	// Six rows shorter. Row 1 survives the shrink and is not drawn into.
1580	cellbuf.Resize(6, 2)
1581	r.Render(cellbuf)
1582	if err := r.Flush(); err != nil {
1583		t.Fatalf("failed to flush renderer: %v", err)
1584	}
1585
1586	out := buf.String()
1587	if !strings.Contains(out, ansi.EraseScreenBelow) {
1588		t.Fatalf("expected the shrink to erase below the frame, got: %q", out)
1589	}
1590	if strings.Contains(out, "a") {
1591		t.Errorf("row 1 survives the shrink, so the erase should have left it alone "+
1592			"rather than taking it and painting it back: %q", out)
1593	}
1594}
1595
1596// A frame can collapse to nothing, and a buffer resized to zero rows reports
1597// zero columns too, so the erase below it has no row of its own to start from.
1598// Starting one row higher would reach above the frame, into rows that belong to
1599// whatever shared the screen first: a shell's output, another frame, scrollback.
1600// The renderer never wrote them and does not get to erase them.
1601func TestRendererInlineCollapseStaysBelowItsOrigin(t *testing.T) {
1602	var buf bytes.Buffer
1603	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1604	r.SetRelativeCursor(true)
1605	r.Resize(10, 20)
1606
1607	cellbuf := NewRenderBuffer(10, 4)
1608	cellbuf.SetCell(0, 0, &Cell{Content: "a", Width: 1})
1609	r.Render(cellbuf)
1610	if err := r.Flush(); err != nil {
1611		t.Fatalf("failed to flush renderer: %v", err)
1612	}
1613	buf.Reset()
1614
1615	cellbuf.Resize(10, 0)
1616	r.Render(cellbuf)
1617	if err := r.Flush(); err != nil {
1618		t.Fatalf("failed to flush renderer: %v", err)
1619	}
1620
1621	// Cursor up from the frame's first row would leave the frame entirely, and
1622	// the erase that follows would take the row above with it.
1623	if out := buf.String(); strings.Contains(out, ansi.CUU1) || strings.Contains(out, "\x1b[1A") {
1624		t.Errorf("collapsing the frame moved above its first row: %q", out)
1625	}
1626	if _, y := r.Position(); y < 0 {
1627		t.Errorf("collapsing the frame left the cursor model at row %d", y)
1628	}
1629}
1630
1631// The same shrink, but through the full-erase path an application takes when it
1632// knows the frame changed shape. The erase covers from the cursor to the end of
1633// the screen, so where the cursor is decides how much of the old frame it
1634// reaches, and the cursor is still on the last row of the frame that just ended.
1635//
1636// Clamping the remembered row to the new frame's height leaves the model
1637// claiming the cursor is already at the top. The move up is then computed as
1638// nothing, the erase runs from the bottom of the old frame, and every row above
1639// it survives into the new one.
1640func TestRendererInlineShrinkErasesFromTheTop(t *testing.T) {
1641	var buf bytes.Buffer
1642	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1643	r.SetRelativeCursor(true)
1644	r.Resize(80, 24)
1645
1646	cellbuf := NewRenderBuffer(80, 3)
1647	for y := range 3 {
1648		cellbuf.SetCell(0, y, &Cell{Content: "a", Width: 1})
1649	}
1650	r.Render(cellbuf)
1651	if err := r.Flush(); err != nil {
1652		t.Fatalf("failed to flush renderer: %v", err)
1653	}
1654	buf.Reset()
1655
1656	// Two rows shorter, painted from scratch rather than diffed.
1657	r.Erase()
1658	cellbuf.Touched = nil
1659	cellbuf.Resize(80, 1)
1660	cellbuf.Clear()
1661	cellbuf.SetCell(0, 0, &Cell{Content: "b", Width: 1})
1662	r.Render(cellbuf)
1663	if err := r.Flush(); err != nil {
1664		t.Fatalf("failed to flush renderer: %v", err)
1665	}
1666
1667	// Up two rows from row 2, erase what the old frame left, draw row 0.
1668	expected := "\r\x1b[2A\x1b[Jb\r"
1669	if output := buf.String(); output != expected {
1670		t.Errorf("expected output after shrink to be %q, got: %q", expected, output)
1671	}
1672}
1673
1674// Rows added by a grow have to be diffed like any other. The model is resized
1675// before the diff loop runs so the loop walks them; otherwise content drawn
1676// into a new row never reaches the terminal.
1677//
1678// The grow repaints the screen rather than diffing it. The terminal fills rows
1679// it gains from its own scrollback, and those lines arrive at the top and push
1680// the rest down, so no row keeps its meaning across the resize.
1681func TestRendererGrowPaintsNewRows(t *testing.T) {
1682	var buf bytes.Buffer
1683	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1684	r.SetFullscreen(true)
1685	r.Resize(10, 3)
1686
1687	cellbuf := NewRenderBuffer(10, 3)
1688	cellbuf.SetCell(0, 0, &Cell{Content: "A", Width: 1})
1689	r.Render(cellbuf)
1690	if err := r.Flush(); err != nil {
1691		t.Fatalf("failed to flush renderer: %v", err)
1692	}
1693	buf.Reset()
1694
1695	cellbuf.Resize(10, 6)
1696	r.Resize(10, 6)
1697	cellbuf.SetCell(0, 5, &Cell{Content: "Z", Width: 1}) // a row the grow added
1698	r.Render(cellbuf)
1699	if err := r.Flush(); err != nil {
1700		t.Fatalf("failed to flush renderer: %v", err)
1701	}
1702
1703	expected := "\x1b[H\x1b[2JA\r\x1b[6dZ"
1704	if output := buf.String(); output != expected {
1705		t.Errorf("expected output after grow to be %q, got: %q", expected, output)
1706	}
1707}
1708
1709// A fullscreen shrink scrolls the terminal to keep the cursor visible, moving
1710// every row the model has an opinion about. The next render has to repaint
1711// rather than diff against a model the terminal moved underneath it, and
1712// growing back does not undo the move.
1713//
1714// [TerminalRenderer.Render] catches a shrink it can see in the buffer
1715// dimensions. This one it cannot: the screen shrinks and grows back with no
1716// render in between, so only the resize itself can report it.
1717func TestRendererFullscreenShrinkRepaints(t *testing.T) {
1718	var buf bytes.Buffer
1719	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1720	r.SetFullscreen(true)
1721	r.Resize(10, 4)
1722
1723	cellbuf := NewRenderBuffer(10, 4)
1724	cellbuf.SetCell(0, 2, &Cell{Content: "a", Width: 1})
1725	r.Render(cellbuf)
1726	if err := r.Flush(); err != nil {
1727		t.Fatalf("failed to flush renderer: %v", err)
1728	}
1729	buf.Reset()
1730
1731	r.Resize(10, 2)
1732	r.Resize(10, 4)
1733	r.Render(cellbuf)
1734	if err := r.Flush(); err != nil {
1735		t.Fatalf("failed to flush renderer: %v", err)
1736	}
1737
1738	if out := buf.String(); !strings.Contains(out, ansi.EraseEntireScreen) {
1739		t.Errorf("shrink should force a repaint, got: %q", out)
1740	}
1741}
1742
1743// Losing columns is the same story told sideways: the terminal clips or
1744// rewraps every row to fit the narrower screen, and the model records none of
1745// it. Widening back restores the columns but not the content, so the model
1746// still claims cells the terminal no longer shows.
1747//
1748// The fuzzer found this one as residue: a row of clusters painted at the old
1749// width, narrowed and widened with no render in between, and the tail of the
1750// row still on screen afterwards.
1751func TestRendererFullscreenNarrowRepaints(t *testing.T) {
1752	var buf bytes.Buffer
1753	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1754	r.SetFullscreen(true)
1755	r.Resize(20, 2)
1756
1757	cellbuf := NewRenderBuffer(20, 2)
1758	for x := range 20 {
1759		cellbuf.SetCell(x, 0, &Cell{Content: "a", Width: 1})
1760	}
1761	r.Render(cellbuf)
1762	if err := r.Flush(); err != nil {
1763		t.Fatalf("failed to flush renderer: %v", err)
1764	}
1765	buf.Reset()
1766
1767	r.Resize(8, 2)
1768	r.Resize(20, 2)
1769	r.Render(cellbuf)
1770	if err := r.Flush(); err != nil {
1771		t.Fatalf("failed to flush renderer: %v", err)
1772	}
1773
1774	if out := buf.String(); !strings.Contains(out, ansi.EraseEntireScreen) {
1775		t.Errorf("narrowing should force a repaint, got: %q", out)
1776	}
1777}
1778
1779// The repaint a resize forces belongs to resizes that changed something. An
1780// application is free to draw a frame smaller than the screen, so comparing the
1781// reported size against the model would differ on every call and repaint the
1782// screen each time the renderer was told a size it already knew. A duplicate
1783// SIGWINCH costs nothing and a steady screen stays quiet.
1784func TestRendererResizeLatchesOnlyRealChanges(t *testing.T) {
1785	var buf bytes.Buffer
1786	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1787	r.SetFullscreen(true)
1788	r.Resize(20, 8)
1789
1790	// A frame two rows shorter than the screen, so the model and the reported
1791	// size disagree for as long as the application keeps drawing it.
1792	scr := NewScreenBuffer(20, 6)
1793	NewStyledString("hello").Draw(scr, Rect(0, 0, 20, 1))
1794	r.Render(scr.RenderBuffer)
1795	if err := r.Flush(); err != nil {
1796		t.Fatalf("failed to flush renderer: %v", err)
1797	}
1798
1799	for range 3 {
1800		buf.Reset()
1801		r.Resize(20, 8) // the size it already is
1802		r.Render(scr.RenderBuffer)
1803		if err := r.Flush(); err != nil {
1804			t.Fatalf("failed to flush renderer: %v", err)
1805		}
1806		if out := buf.String(); strings.Contains(out, ansi.EraseEntireScreen) {
1807			t.Fatalf("a resize that changed nothing repainted the screen: %q", out)
1808		}
1809	}
1810
1811	// A real change still latches, and still survives the grow back.
1812	buf.Reset()
1813	r.Resize(20, 4)
1814	r.Resize(20, 8)
1815	r.Render(scr.RenderBuffer)
1816	if err := r.Flush(); err != nil {
1817		t.Fatalf("failed to flush renderer: %v", err)
1818	}
1819	if out := buf.String(); !strings.Contains(out, ansi.EraseEntireScreen) {
1820		t.Errorf("a shrink and grow back should repaint, got: %q", out)
1821	}
1822}
1823
1824// A drift-prone line is painted with autowrap off. A terminal that measures a
1825// cluster wider than the model does would otherwise run past the right margin,
1826// spilling the line onto the next row, or scrolling the whole screen when the
1827// line is the last one. Neither shows up in the model, so the residue outlives
1828// every later frame.
1829func TestRendererNoWrapOnDriftLine(t *testing.T) {
1830	render := func(content string, width, y int) string {
1831		var buf bytes.Buffer
1832		r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1833		r.SetFullscreen(true)
1834		r.Resize(width, 2)
1835
1836		scr := NewScreenBuffer(width, 2)
1837		NewStyledString(content).Draw(scr, Rect(0, y, width, 1))
1838		r.Render(scr.RenderBuffer)
1839		if err := r.Flush(); err != nil {
1840			t.Fatalf("failed to flush renderer: %v", err)
1841		}
1842		return buf.String()
1843	}
1844
1845	for _, y := range []int{0, 1} {
1846		wide := render("世界", 6, y)
1847		if !strings.Contains(wide, ansi.ResetModeAutoWrap) || !strings.Contains(wide, ansi.SetModeAutoWrap) {
1848			t.Errorf("drift-prone row %d should paint with autowrap off, got: %q", y, wide)
1849		}
1850	}
1851
1852	// A row of plain cells cannot overflow, so it pays nothing.
1853	narrow := render("abc", 6, 1)
1854	if strings.Contains(narrow, ansi.ResetModeAutoWrap) {
1855		t.Errorf("plain row should not touch autowrap, got: %q", narrow)
1856	}
1857}
1858
1859// The repaint a height change forces belongs to fullscreen mode and to actual
1860// changes. Inline frames are shorter than the terminal by design, and a render
1861// that resizes nothing has nothing to distrust.
1862func TestHeightRepaintScope(t *testing.T) {
1863	render := func(r *TerminalRenderer, w, h int, mark string) {
1864		cb := NewRenderBuffer(w, h)
1865		cb.SetCell(0, 0, &Cell{Content: mark, Width: 1})
1866		r.Render(cb)
1867		if err := r.Flush(); err != nil {
1868			t.Fatalf("Flush: %v", err)
1869		}
1870	}
1871
1872	t.Run("inline mode leaves the height alone", func(t *testing.T) {
1873		var buf bytes.Buffer
1874		r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1875		r.SetRelativeCursor(true)
1876		render(r, 10, 2, "a")
1877		buf.Reset()
1878		render(r, 10, 5, "a")
1879		if strings.Contains(buf.String(), ansi.EraseEntireScreen) {
1880			t.Errorf("inline mode repainted on a height change: %q", buf.String())
1881		}
1882	})
1883
1884	t.Run("a steady size does not repaint", func(t *testing.T) {
1885		var buf bytes.Buffer
1886		r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1887		r.SetFullscreen(true)
1888		render(r, 10, 3, "a")
1889		buf.Reset()
1890		render(r, 10, 3, "b")
1891		if strings.Contains(buf.String(), ansi.EraseEntireScreen) {
1892			t.Errorf("repainted without a resize: %q", buf.String())
1893		}
1894	})
1895
1896	t.Run("degenerate sizes do not panic", func(t *testing.T) {
1897		for _, size := range [][2]int{{0, 0}, {10, 0}, {0, 5}, {1, 1}} {
1898			var buf bytes.Buffer
1899			r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1900			r.SetFullscreen(true)
1901			render(r, 10, 3, "a")
1902			r.Resize(size[0], size[1])
1903			r.Render(NewRenderBuffer(size[0], size[1]))
1904			if err := r.Flush(); err != nil {
1905				t.Fatalf("size %v: Flush: %v", size, err)
1906			}
1907		}
1908	})
1909}
1910
1911// A carried sequence has to reach the terminal, and only when its cell changes.
1912// Re-sending it every frame would have an image protocol retransmit the image
1913// at the frame rate.
1914func TestPassThroughSequenceReachesTerminalOnce(t *testing.T) {
1915	const apc = "\x1b_Ga=T\x1b\\"
1916
1917	var buf bytes.Buffer
1918	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1919	r.SetFullscreen(true)
1920
1921	scr := NewScreenBuffer(10, 2)
1922	NewStyledString(apc+"hi").Draw(scr, Rect(0, 0, 10, 1))
1923	r.Render(scr.RenderBuffer)
1924	if err := r.Flush(); err != nil {
1925		t.Fatalf("Flush: %v", err)
1926	}
1927	if n := strings.Count(buf.String(), apc); n != 1 {
1928		t.Errorf("first render sent the sequence %d times, want 1: %q", n, buf.String())
1929	}
1930
1931	buf.Reset()
1932	r.Render(scr.RenderBuffer)
1933	if err := r.Flush(); err != nil {
1934		t.Fatalf("Flush: %v", err)
1935	}
1936	if n := strings.Count(buf.String(), apc); n != 0 {
1937		t.Errorf("an unchanged render re-sent the sequence %d times: %q", n, buf.String())
1938	}
1939}
1940
1941// The ASCII short-circuit in lineHasDrift must never change its answer.
1942// Compare against the unoptimised definition over everything the conformance
1943// fuzzer can draw, plus every single byte.
1944func TestDriftShortCircuitAgreesWithFullCheck(t *testing.T) {
1945	full := func(m ansi.Method, c *Cell) bool {
1946		return c.Width > 1 || m.StringWidth(c.Content) != ansi.StringWidth(c.Content)
1947	}
1948
1949	var contents []string
1950	for b := 0; b < 256; b++ {
1951		contents = append(contents, string([]byte{byte(b)}))
1952	}
1953	contents = append(contents,
1954		"a", "\u4e16", "\uac00", "\U0001fae0", "e\u0301", "n\u0303",
1955		"\u2639\ufe0e", "\u2639\ufe0f", "\u2764\ufe0f", "\u2708\ufe0f",
1956		"\U0001f469\u200d\U0001f4bb", "\U0001f426\u200d\U0001f525",
1957		"\U0001f44d\U0001f3fd", "\U0001f44b\U0001f3ff",
1958		"\u26d3\ufe0f\u200d\U0001f4a5", "\U0001f1fa", "\U0001f1fa\U0001f1f8",
1959		"\U0001f1ef\U0001f1f5", "1\ufe0f\u20e3", "", " ", "\x1b_x\x1b\\a",
1960	)
1961
1962	for _, m := range []ansi.Method{ansi.WcWidth, ansi.GraphemeWidth} {
1963		for _, content := range contents {
1964			for _, w := range []int{0, 1, 2} {
1965				c := &Cell{Content: content, Width: w}
1966				line := Line{*c}
1967				got := lineHasDrift(m, line)
1968				want := false
1969				if c.Width != 0 && len(c.Content) != 0 {
1970					want = full(m, c)
1971				}
1972				if got != want {
1973					t.Errorf("method=%v content=%q width=%d: short-circuit says %v, full check says %v",
1974						m, content, w, got, want)
1975				}
1976			}
1977		}
1978	}
1979}
1980
1981// A grapheme cluster at the right margin is written with autowrap on, even in
1982// the lower right corner where a plain cell is not. With autowrap off the
1983// terminal never advances past the margin, so it reads the combining
1984// codepoints as part of the cell to the left: the mark moves one column back
1985// and the base rune is left alone at the margin. Both reference emulators
1986// place the cluster correctly when autowrap stays on.
1987func TestRendererMarginClusterKeepsAutowrap(t *testing.T) {
1988	paint := func(content string, y int) string {
1989		var buf bytes.Buffer
1990		r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
1991		r.SetFullscreen(true)
1992		r.Resize(6, 2)
1993
1994		scr := NewScreenBuffer(6, 2)
1995		NewStyledString(content).Draw(scr, Rect(0, y, 6, 1))
1996		r.Render(scr.RenderBuffer)
1997		if err := r.Flush(); err != nil {
1998			t.Fatalf("failed to flush renderer: %v", err)
1999		}
2000		return buf.String()
2001	}
2002
2003	// e + U+0301. Two codepoints, one column.
2004	const cluster = "e\u0301"
2005
2006	for _, y := range []int{0, 1} {
2007		out := paint("###"+cluster+cluster+cluster, y)
2008		if strings.Contains(out, ansi.ResetModeAutoWrap) {
2009			t.Errorf("row %d: cluster at the margin painted with autowrap off: %q", y, out)
2010		}
2011	}
2012
2013	// The corner still gets the autowrap dance when nothing can be split.
2014	if out := paint("######", 1); !strings.Contains(out, ansi.ResetModeAutoWrap) {
2015		t.Errorf("plain corner cell should paint with autowrap off, got: %q", out)
2016	}
2017}
2018
2019// A hardware scroll moves every row in its range, including rows the
2020// application never drew into this frame. The diff loop only visits rows the
2021// application touched, so without marking the range those rows keep the model's
2022// old opinion of them and the content the scroll carried away never comes back.
2023//
2024// Here the scroll puts row 2 where row 0 belongs, which is what makes it worth
2025// doing, and takes row 1 off the top of the screen on the way. Row 1 has to be
2026// painted again even though nothing drew into it.
2027func TestRendererScrollRepaintsRowsItMoved(t *testing.T) {
2028	var buf bytes.Buffer
2029	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2030	r.SetFullscreen(true)
2031	r.SetScrollOptim(true)
2032	r.Resize(4, 8)
2033
2034	scr := NewScreenBuffer(4, 8)
2035	NewStyledString("aa").Draw(scr, Rect(0, 1, 4, 1))
2036	NewStyledString("bb").Draw(scr, Rect(0, 2, 4, 1))
2037	r.Render(scr.RenderBuffer)
2038	if err := r.Flush(); err != nil {
2039		t.Fatalf("failed to flush renderer: %v", err)
2040	}
2041	buf.Reset()
2042
2043	// Row 1 keeps its "aa" from the frame before and is not drawn into.
2044	NewStyledString("bb").Draw(scr, Rect(0, 0, 4, 1))
2045	NewStyledString("aa").Draw(scr, Rect(0, 2, 4, 1))
2046	r.Render(scr.RenderBuffer)
2047	if err := r.Flush(); err != nil {
2048		t.Fatalf("failed to flush renderer: %v", err)
2049	}
2050
2051	out := buf.String()
2052	if !strings.Contains(out, ansi.ScrollUp(2)) {
2053		t.Fatalf("expected a hardware scroll, got: %q", out)
2054	}
2055	if n := strings.Count(out, "aa"); n != 2 {
2056		t.Errorf("scrolled rows painted %d times, want 2 (rows 1 and 2): %q", n, out)
2057	}
2058}
2059
2060func TestRendererInlineNarrowRepaints(t *testing.T) {
2061	var buf bytes.Buffer
2062	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2063	r.Resize(20, 2)
2064
2065	cellbuf := NewRenderBuffer(20, 2)
2066	for x := range 20 {
2067		cellbuf.SetCell(x, 0, &Cell{Content: "a", Width: 1})
2068	}
2069	r.Render(cellbuf)
2070	if err := r.Flush(); err != nil {
2071		t.Fatalf("failed to flush renderer: %v", err)
2072	}
2073	buf.Reset()
2074
2075	r.Resize(8, 2)
2076	r.Resize(20, 2)
2077	r.Render(cellbuf)
2078	if err := r.Flush(); err != nil {
2079		t.Fatalf("failed to flush renderer: %v", err)
2080	}
2081
2082	if out := buf.String(); !strings.Contains(out, "a") {
2083		t.Errorf("a terminal that narrowed and grew back rewrapped the frame, so it has to be repainted, got: %q", out)
2084	}
2085}
2086
2087func TestRendererCornerClusterLeavesNoPendingWrap(t *testing.T) {
2088	var buf bytes.Buffer
2089	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2090	r.SetFullscreen(true)
2091	r.Resize(6, 2)
2092
2093	cellbuf := NewRenderBuffer(6, 2)
2094	for x := range 6 {
2095		cellbuf.SetCell(x, 1, &Cell{Content: "#", Width: 1})
2096	}
2097	cellbuf.SetCell(5, 1, &Cell{Content: "e\u0301", Width: 1})
2098	r.Render(cellbuf)
2099	if err := r.Flush(); err != nil {
2100		t.Fatalf("failed to flush renderer: %v", err)
2101	}
2102
2103	if x, _ := r.Position(); x >= cellbuf.Width() {
2104		t.Errorf("a frame that ends in pending wrap on the last row scrolls the screen on the next print: cursor x=%d, width=%d", x, cellbuf.Width())
2105	}
2106}
2107
2108// The renderer's account of the rows it disturbed itself is sized to the screen
2109// it is about to paint, so a row that cannot be painted cannot be damaged and no
2110// reader has to bounds-check the record.
2111func TestRendererDamageIsSizedToTheScreen(t *testing.T) {
2112	var buf bytes.Buffer
2113	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2114	r.SetFullscreen(true)
2115
2116	tall := NewRenderBuffer(4, 6)
2117	tall.SetCell(0, 5, &Cell{Content: "a", Width: 1})
2118	r.Render(tall)
2119	if got := len(r.damaged); got != 6 {
2120		t.Errorf("record holds %d rows for a 6-row screen, want 6", got)
2121	}
2122
2123	// Out of range either way is dropped, rather than growing the record to fit
2124	// rows the screen does not have.
2125	r.damage(-3, 2)
2126	r.damage(6, 99)
2127	if got := len(r.damaged); got != 6 {
2128		t.Errorf("damage outside the screen resized the record to %d, want 6", got)
2129	}
2130	for y, damaged := range r.damaged {
2131		if damaged {
2132			t.Errorf("row %d is damaged, but every damage call was outside the screen", y)
2133		}
2134	}
2135
2136	// A shorter frame gets a shorter record, and carries nothing over from the
2137	// taller frame it replaced.
2138	r.damage(4, 2)
2139	short := NewRenderBuffer(4, 2)
2140	short.SetCell(0, 0, &Cell{Content: "b", Width: 1})
2141	r.Render(short)
2142	if got := len(r.damaged); got != 2 {
2143		t.Errorf("record holds %d rows for a 2-row screen, want 2", got)
2144	}
2145}
2146
2147// BenchmarkRenderFrameByHeight pins the shape of the win, not just its size.
2148//
2149// The cost of a frame used to scale with the height of the screen, because every
2150// render replaced one touch record per row. Reusing the records made it flat, and
2151// a benchmark at a single height cannot tell the two apart: run a few heights and
2152// the allocations per frame should not follow them.
2153func BenchmarkRenderFrameByHeight(b *testing.B) {
2154	for _, height := range []int{24, 100, 400} {
2155		b.Run(fmt.Sprintf("h%d", height), func(b *testing.B) {
2156			r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
2157			r.SetFullscreen(true)
2158			buf := NewScreenBuffer(80, height)
2159			text := NewStyledString(strings.Repeat("x", 79))
2160			b.ReportAllocs()
2161			b.ResetTimer()
2162			for i := 0; i < b.N; i++ {
2163				text.Draw(buf, Rect(0, i%height, 80, 1))
2164				r.Render(buf.RenderBuffer)
2165			}
2166		})
2167	}
2168}
2169
2170// TestRenderAllocationsDoNotFollowScreenHeight guards the property the record
2171// reuse bought, which a benchmark at one height cannot express.
2172//
2173// Every render used to replace one touch record per row, so the cost of a frame
2174// grew with the screen. A tall screen and a short one should now allocate the
2175// same amount per frame.
2176func TestRenderAllocationsDoNotFollowScreenHeight(t *testing.T) {
2177	perFrame := func(height int) float64 {
2178		r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
2179		r.SetFullscreen(true)
2180		buf := NewScreenBuffer(80, height)
2181		text := NewStyledString(strings.Repeat("x", 79))
2182
2183		row := 0
2184		draw := func() {
2185			row = (row + 1) % height
2186			text.Draw(buf, Rect(0, row, 80, 1))
2187			r.Render(buf.RenderBuffer)
2188		}
2189
2190		// Every row has to be drawn at least once first: a record is created on
2191		// first touch, and counting that one-off as per-frame cost would make a
2192		// tall screen look like it still scales.
2193		for range height * 2 {
2194			draw()
2195		}
2196		return testing.AllocsPerRun(256, draw)
2197	}
2198
2199	short, tall := perFrame(24), perFrame(400)
2200	if tall > short+8 {
2201		t.Errorf("a 400-row screen allocates %.0f per frame against %.0f for 24 rows, so the cost still follows the height",
2202			tall, short)
2203	}
2204}
2205
2206// A touch list shorter than the screen used to crash the scroll optimisation,
2207// which walked every row of the screen through it. An application can reach that
2208// state: the list is exported, so it can drop it and touch a single row.
2209//
2210// Enforced rather than tolerated, so the assertion is that the state cannot be
2211// built, not that a bounds check catches it. Every reader indexes the list by
2212// screen row, and there were five separate length checks standing in for this.
2213func TestRenderBufferTouchedCoversEveryRow(t *testing.T) {
2214	buf := NewRenderBuffer(5, 4)
2215
2216	if got := len(buf.Touched); got != buf.Height() {
2217		t.Errorf("a new buffer has %d touch entries for %d rows", got, buf.Height())
2218	}
2219
2220	// Dropping the list and touching one row restores the full length.
2221	buf.Touched = nil
2222	buf.SetCell(0, 0, &Cell{Content: "a", Width: 1})
2223	if got := len(buf.Touched); got != buf.Height() {
2224		t.Errorf("after dropping the list and touching one row, %d entries for %d rows", got, buf.Height())
2225	}
2226
2227	// And so does touching a row of a screen that has since grown.
2228	buf.Touched = buf.Touched[:1]
2229	buf.Resize(7, 9)
2230	buf.SetCell(0, 8, &Cell{Content: "c", Width: 1})
2231	if got := len(buf.Touched); got < buf.Height() {
2232		t.Errorf("after growing to %d rows and touching the last one, %d touch entries", buf.Height(), got)
2233	}
2234}
2235
2236// The crash itself: a short list reaching the scroll optimisation, which indexed
2237// it by screen row and ran off the end.
2238func TestRendererSurvivesAShortTouchList(t *testing.T) {
2239	r := NewTerminalRenderer(io.Discard, []string{"TERM=xterm-256color"})
2240	r.SetFullscreen(true)
2241	r.SetScrollOptim(true)
2242
2243	cellbuf := NewRenderBuffer(5, 4)
2244	cellbuf.SetCell(0, 0, &Cell{Content: "a", Width: 1})
2245	r.Render(cellbuf)
2246	if err := r.Flush(); err != nil {
2247		t.Fatalf("failed to flush renderer: %v", err)
2248	}
2249
2250	cellbuf.Touched = nil
2251	cellbuf.SetCell(0, 0, &Cell{Content: "b", Width: 1})
2252	r.Render(cellbuf)
2253	if err := r.Flush(); err != nil {
2254		t.Fatalf("failed to flush renderer: %v", err)
2255	}
2256}
2257
2258// A frame an application hands over with no touches recorded still has to be
2259// repainted when the terminal resized under it.
2260//
2261// The touch list is exported, so an application is free to drop it, and a fresh
2262// list records nothing. Both make TouchedLines report zero, which is otherwise
2263// the renderer's signal that there is nothing to do. A resize latched in the
2264// meantime has to override that, or the rows the terminal rewrapped stay put.
2265func TestRendererRepaintsAResizeWithNoTouches(t *testing.T) {
2266	var buf bytes.Buffer
2267	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2268	r.Resize(20, 2)
2269
2270	cellbuf := NewRenderBuffer(20, 2)
2271	for x := range 20 {
2272		cellbuf.SetCell(x, 0, &Cell{Content: "a", Width: 1})
2273	}
2274	r.Render(cellbuf)
2275	if err := r.Flush(); err != nil {
2276		t.Fatalf("failed to flush renderer: %v", err)
2277	}
2278
2279	// The application drops the list, so the next frame reports no touches.
2280	cellbuf.Touched = nil
2281	if got := cellbuf.TouchedLines(); got != 0 {
2282		t.Fatalf("expected a frame reporting no touches, got %d", got)
2283	}
2284
2285	buf.Reset()
2286	r.Resize(8, 2)
2287	r.Resize(20, 2)
2288	r.Render(cellbuf)
2289	if err := r.Flush(); err != nil {
2290		t.Fatalf("failed to flush renderer: %v", err)
2291	}
2292
2293	if out := buf.String(); !strings.Contains(out, "a") {
2294		t.Errorf("a resize has to repaint even a frame reporting no touches, got: %q", out)
2295	}
2296}
2297
2298// A frame that collapses and grows back to its old size between renders must
2299// erase what it last drew. The renderer sees no size change, so only the
2300// buffer can say those rows changed.
2301func TestRendererClearsRowsAFrameGotBack(t *testing.T) {
2302	var buf bytes.Buffer
2303	r := NewTerminalRenderer(&buf, []string{"TERM=xterm-256color"})
2304	r.SetRelativeCursor(true)
2305	r.Resize(6, 10)
2306
2307	cellbuf := NewRenderBuffer(6, 2)
2308	cellbuf.SetCell(0, 0, &Cell{Content: "a", Width: 1})
2309	r.Render(cellbuf)
2310	if err := r.Flush(); err != nil {
2311		t.Fatalf("failed to flush renderer: %v", err)
2312	}
2313
2314	cellbuf.Resize(6, 0)
2315	cellbuf.Resize(6, 2)
2316	cellbuf.SetCell(0, 1, &Cell{Content: "b", Width: 1})
2317	buf.Reset()
2318	r.Render(cellbuf)
2319	if err := r.Flush(); err != nil {
2320		t.Fatalf("failed to flush renderer: %v", err)
2321	}
2322
2323	if out := buf.String(); !strings.Contains(out, ansi.EraseLineRight) {
2324		t.Errorf("row 0 was never erased, wrote %q", out)
2325	}
2326}