v0.4.1 03674e0

renderer_test.go RawHistoryFolder

1package ansi
2
3import (
4	"bytes"
5	"strings"
6	"testing"
7)
8
9// markFrame writes uniform printable content so each row is distinguishable.
10func markFrame(f *Frame) {
11	for y := range f.Height {
12		for x := range f.Width {
13			f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()})
14		}
15	}
16}
17
18// outputContains checks that the output contains all the given substrings.
19func outputContains(t *testing.T, data []byte, subs ...string) {
20	t.Helper()
21	s := string(data)
22	for _, sub := range subs {
23		if !strings.Contains(s, sub) {
24			t.Errorf("output should contain %q", sub)
25		}
26	}
27}
28
29func outputEndsWith(t *testing.T, data []byte, suffix string) {
30	t.Helper()
31	s := string(data)
32	if !strings.HasSuffix(s, suffix) {
33		t.Errorf("output should end with %q, got %q", suffix, s)
34	}
35}
36
37// ---------------------------------------------------------------------------
38// First draw
39// ---------------------------------------------------------------------------
40
41func TestFirstDraw(t *testing.T) {
42	tests := []struct {
43		name        string
44		damage      []Damage
45		minCSICount int
46	}{
47		{
48			name:        "explicit text damage",
49			damage:      []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
50			minCSICount: 3,
51		},
52		{
53			name:   "full redraw damage",
54			damage: []Damage{FullRedraw()},
55		},
56	}
57
58	for _, tt := range tests {
59		t.Run(tt.name, func(t *testing.T) {
60			r := New(Capabilities{})
61			frame := NewFrame(5, 3)
62			markFrame(&frame)
63
64			out, err := r.Draw(frame, tt.damage)
65			if err != nil {
66				t.Fatal(err)
67			}
68			if len(out) == 0 {
69				t.Fatal("expected non-empty output on first draw")
70			}
71			outputContains(t, out, "\x1b[0m")
72			if tt.minCSICount > 0 {
73				// Should have cursor-position sequences for all three rows.
74				if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount {
75					t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c)
76				}
77			}
78		})
79	}
80}
81
82// ---------------------------------------------------------------------------
83// No-op
84// ---------------------------------------------------------------------------
85
86func TestNoOp(t *testing.T) {
87	tests := []struct {
88		name         string
89		width        int
90		height       int
91		firstDamage  []Damage
92		secondDamage []Damage
93	}{
94		{
95			name:         "nil damage on unchanged frame",
96			width:        5,
97			height:       3,
98			firstDamage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
99			secondDamage: nil,
100		},
101		{
102			name:         "empty damage slice on unchanged frame",
103			width:        3,
104			height:       2,
105			firstDamage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}},
106			secondDamage: []Damage{},
107		},
108	}
109
110	for _, tt := range tests {
111		t.Run(tt.name, func(t *testing.T) {
112			r := New(Capabilities{})
113			frame := NewFrame(tt.width, tt.height)
114			markFrame(&frame)
115
116			// First draw – populate shadow.
117			out1, err := r.Draw(frame, tt.firstDamage)
118			if err != nil {
119				t.Fatal(err)
120			}
121			if len(out1) == 0 {
122				t.Fatal("first draw must produce output")
123			}
124
125			// Second draw with no changes.
126			out2, err := r.Draw(frame, tt.secondDamage)
127			if err != nil {
128				t.Fatal(err)
129			}
130			if len(out2) != 0 {
131				t.Fatalf("expected no-op (empty output), got %q", string(out2))
132			}
133		})
134	}
135}
136
137func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
138	r := New(Capabilities{})
139	frame := NewFrame(3, 2)
140	markFrame(&frame)
141	if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}); err != nil {
142		t.Fatal(err)
143	}
144
145	prepared, err := r.Prepare(frame, []Damage{{Kind: DamageText, X: frame.Width, Y: 0, Width: 1, Height: 1}}, false)
146	if err != nil {
147		t.Fatal(err)
148	}
149	if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil {
150		t.Fatal("no-op prepared draw owns frame storage")
151	}
152	prepared.Commit()
153}
154
155// ---------------------------------------------------------------------------
156// Style reset discipline
157// ---------------------------------------------------------------------------
158
159func TestStyleResetDiscipline(t *testing.T) {
160	tests := []struct {
161		name string
162		run  func(t *testing.T)
163	}{
164		{
165			name: "single styled cell among default cells",
166			run: func(t *testing.T) {
167				r := New(Capabilities{})
168				frame := NewFrame(4, 2)
169
170				// One styled cell, rest default.
171				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
172
173				out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
174				if err != nil {
175					t.Fatal(err)
176				}
177				// Must end with style reset.
178				outputEndsWith(t, out, "\x1b[0m")
179			},
180		},
181		{
182			name: "after scroll damage",
183			run: func(t *testing.T) {
184				r := New(Capabilities{})
185				frame := NewFrame(4, 3)
186				markFrame(&frame)
187
188				// Populate shadow.
189				_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
190				if err != nil {
191					t.Fatal(err)
192				}
193
194				// Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
195				frame2 := NewFrame(4, 3)
196				copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
197				for i := 8; i < 12; i++ {
198					frame2.Cells[i] = BlankCell() // bottom row blank
199				}
200				frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
201
202				damage := []Damage{
203					{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
204					{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
205				}
206
207				out, err := r.Draw(frame2, damage)
208				if err != nil {
209					t.Fatal(err)
210				}
211				// The output must end with a style reset.
212				outputEndsWith(t, out, "\x1b[0m")
213			},
214		},
215	}
216
217	for _, tt := range tests {
218		t.Run(tt.name, tt.run)
219	}
220}
221
222func TestStyleEqualUsesActiveColorMode(t *testing.T) {
223	tests := []struct {
224		name      string
225		left      Style
226		right     Style
227		wantEqual bool
228	}{
229		{
230			name:      "ignores inactive indexed foreground",
231			left:      Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
232			right:     Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
233			wantEqual: true,
234		},
235		{
236			name:      "compares active RGB values",
237			left:      Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
238			right:     Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
239			wantEqual: false,
240		},
241	}
242
243	for _, tt := range tests {
244		t.Run(tt.name, func(t *testing.T) {
245			if got := tt.left.Equal(tt.right); got != tt.wantEqual {
246				t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
247			}
248		})
249	}
250}
251
252func TestRendererEmitsSGR(t *testing.T) {
253	tests := []struct {
254		name    string
255		width   int
256		height  int
257		setup   func(frame Frame)
258		damage  []Damage
259		wantAll []string
260	}{
261		{
262			name:   "truecolor foreground and background",
263			width:  1,
264			height: 1,
265			setup: func(frame Frame) {
266				frame.Set(0, 0, Cell{
267					Rune: 'X',
268					Style: Style{
269						Foreground:       -1,
270						Background:       -1,
271						HasForegroundRGB: true,
272						ForegroundRGB:    RGB{R: 12, G: 34, B: 56},
273						HasBackgroundRGB: true,
274						BackgroundRGB:    RGB{R: 200, G: 100, B: 50},
275					},
276				})
277			},
278			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
279			wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
280		},
281		{
282			name:   "style changes across adjacent color modes",
283			width:  3,
284			height: 1,
285			setup: func(frame Frame) {
286				frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
287				frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
288				frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
289			},
290			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
291			wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
292		},
293		{
294			name:   "bold italic inverse",
295			width:  1,
296			height: 1,
297			setup: func(frame Frame) {
298				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}})
299			},
300			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
301			wantAll: []string{"\x1b[0;1;3;7m", "X"},
302		},
303		{
304			name:   "extended SGR attributes and underline color",
305			width:  1,
306			height: 1,
307			setup: func(frame Frame) {
308				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
309			},
310			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
311			wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
312		},
313	}
314
315	for _, tt := range tests {
316		t.Run(tt.name, func(t *testing.T) {
317			r := New(Capabilities{})
318			frame := NewFrame(tt.width, tt.height)
319			tt.setup(frame)
320
321			out, err := r.Draw(frame, tt.damage)
322			if err != nil {
323				t.Fatal(err)
324			}
325
326			outputContains(t, out, tt.wantAll...)
327		})
328	}
329}
330
331// ---------------------------------------------------------------------------
332// Scroll fast path
333// ---------------------------------------------------------------------------
334
335func TestScrollFastPath(t *testing.T) {
336	tests := []struct {
337		name string
338	}{
339		{name: "scroll up by one row exposes new bottom row via scroll region"},
340	}
341
342	for _, tt := range tests {
343		t.Run(tt.name, func(t *testing.T) {
344			r := New(Capabilities{})
345			frame := NewFrame(5, 4)
346			markFrame(&frame)
347
348			// Populate shadow.
349			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
350			if err != nil {
351				t.Fatal(err)
352			}
353
354			// Build a new frame that has been scrolled up by 1 (like the VT model does).
355			scrolled := NewFrame(5, 4)
356			copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
357			for i := 15; i < 20; i++ {
358				scrolled.Cells[i] = BlankCell() // row 3 blanked
359			}
360			scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
361			scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
362			scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
363
364			damage := []Damage{
365				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
366				{Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
367			}
368
369			out, err := r.Draw(scrolled, damage)
370			if err != nil {
371				t.Fatal(err)
372			}
373			if len(out) == 0 {
374				t.Fatal("expected non-empty output for scroll")
375			}
376			// Should contain scroll-region sequences.
377			outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
378			outputContains(t, out, "\x1b[r")    // restore scroll region
379			// Should contain the new text on the exposed row.
380			outputContains(t, out, "N")
381			outputContains(t, out, "e")
382			outputContains(t, out, "w")
383		})
384	}
385}
386
387// ---------------------------------------------------------------------------
388// Synchronized output wrapper
389// ---------------------------------------------------------------------------
390
391func TestSynchronizedOutput(t *testing.T) {
392	tests := []struct {
393		name string
394		run  func(t *testing.T)
395	}{
396		{
397			name: "wraps output in sync CSI",
398			run: func(t *testing.T) {
399				r := New(Capabilities{SynchronizedOutput: true})
400				frame := NewFrame(3, 2)
401				markFrame(&frame)
402
403				out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
404				if err != nil {
405					t.Fatal(err)
406				}
407				if !strings.HasPrefix(string(out), SyncStartCSI) {
408					t.Errorf("expected output to start with sync start CSI")
409				}
410				if !strings.HasSuffix(string(out), SyncEndCSI) {
411					t.Errorf("expected output to end with sync end CSI, got %q", string(out))
412				}
413			},
414		},
415		{
416			name: "no-op returns empty output, not wrapped empty output",
417			run: func(t *testing.T) {
418				r := New(Capabilities{SynchronizedOutput: true})
419				frame := NewFrame(3, 2)
420				markFrame(&frame)
421
422				// First draw.
423				_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
424				if err != nil {
425					t.Fatal(err)
426				}
427
428				// No-op – must return nil, not wrapped empty output.
429				out, err := r.Draw(frame, nil)
430				if err != nil {
431					t.Fatal(err)
432				}
433				if len(out) != 0 {
434					t.Fatalf("expected empty output for no-op, got %q", string(out))
435				}
436			},
437		},
438	}
439
440	for _, tt := range tests {
441		t.Run(tt.name, tt.run)
442	}
443}
444
445// ---------------------------------------------------------------------------
446// WrapSynchronized standalone function
447// ---------------------------------------------------------------------------
448
449func TestWrapSynchronized(t *testing.T) {
450	content := []byte("\x1b[0mhello")
451
452	tests := []struct {
453		name    string
454		content []byte
455		enabled bool
456		check   func(t *testing.T, got []byte)
457	}{
458		{
459			name:    "enabled wraps with sync start/end and preserves content",
460			content: content,
461			enabled: true,
462			check: func(t *testing.T, got []byte) {
463				if !strings.HasPrefix(string(got), SyncStartCSI) {
464					t.Errorf("missing sync start")
465				}
466				if !strings.HasSuffix(string(got), SyncEndCSI) {
467					t.Errorf("missing sync end")
468				}
469				if !strings.Contains(string(got), "hello") {
470					t.Errorf("missing original content")
471				}
472			},
473		},
474		{
475			name:    "disabled returns content unchanged",
476			content: content,
477			enabled: false,
478			check: func(t *testing.T, got []byte) {
479				if string(got) != string(content) {
480					t.Errorf("disabled wrapping should return content unchanged")
481				}
482			},
483		},
484		{
485			name:    "empty input returns empty",
486			content: nil,
487			enabled: true,
488			check: func(t *testing.T, got []byte) {
489				if len(got) != 0 {
490					t.Errorf("empty input should return empty")
491				}
492			},
493		},
494	}
495
496	for _, tt := range tests {
497		t.Run(tt.name, func(t *testing.T) {
498			got := WrapSynchronized(tt.content, tt.enabled)
499			tt.check(t, got)
500		})
501	}
502}
503
504// ---------------------------------------------------------------------------
505// Partial damage draw
506// ---------------------------------------------------------------------------
507
508func TestPartialDamage(t *testing.T) {
509	tests := []struct {
510		name string
511	}{
512		{name: "single changed cell repositions cursor and resets style"},
513	}
514
515	for _, tt := range tests {
516		t.Run(tt.name, func(t *testing.T) {
517			r := New(Capabilities{})
518			frame := NewFrame(4, 2)
519			markFrame(&frame)
520
521			// Populate shadow.
522			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
523			if err != nil {
524				t.Fatal(err)
525			}
526
527			// Change one cell.
528			frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
529			damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
530
531			out, err := r.Draw(frame, damage)
532			if err != nil {
533				t.Fatal(err)
534			}
535			if len(out) == 0 {
536				t.Fatal("expected non-empty output for partial damage")
537			}
538			// Should position cursor at (2,1) — 1-indexed (3;2).
539			outputContains(t, out, "\x1b[2;3H")
540			outputContains(t, out, "Z")
541			outputEndsWith(t, out, "\x1b[0m")
542		})
543	}
544}
545
546// ---------------------------------------------------------------------------
547// Reset
548// ---------------------------------------------------------------------------
549
550func TestReset(t *testing.T) {
551	tests := []struct {
552		name string
553	}{
554		{name: "clears shadow state and forces a full draw next time"},
555	}
556
557	for _, tt := range tests {
558		t.Run(tt.name, func(t *testing.T) {
559			r := New(Capabilities{})
560			frame := NewFrame(3, 2)
561			markFrame(&frame)
562
563			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
564			if err != nil {
565				t.Fatal(err)
566			}
567
568			r.Reset()
569
570			if r.width != 0 || r.height != 0 || r.shadow != nil {
571				t.Fatal("Reset should clear width/height/shadow")
572			}
573
574			// After reset, the next draw should be a full draw.
575			out, err := r.Draw(frame, nil)
576			if err != nil {
577				t.Fatal(err)
578			}
579			if len(out) == 0 {
580				t.Fatal("expected non-empty output after reset")
581			}
582		})
583	}
584}
585
586// ---------------------------------------------------------------------------
587// Damage helper
588// ---------------------------------------------------------------------------
589
590func TestFullRedraw(t *testing.T) {
591	tests := []struct {
592		name string
593		run  func(t *testing.T)
594	}{
595		{
596			name: "known same-size full redraw diffs against shadow",
597			run: func(t *testing.T) {
598				r := New(Capabilities{})
599				frame := NewFrame(3, 2)
600				markFrame(&frame)
601
602				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
603					t.Fatal(err)
604				}
605
606				changed := frame
607				changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
608				out, err := r.Draw(changed, []Damage{FullRedraw()})
609				if err != nil {
610					t.Fatal(err)
611				}
612				got := string(out)
613				if strings.Contains(got, "ABC") {
614					t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
615				}
616				if !strings.Contains(got, "ZEF") {
617					t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
618				}
619				outputEndsWith(t, out, "\x1b[0m")
620			},
621		},
622		{
623			name: "unchanged known same-size full redraw is a no-op",
624			run: func(t *testing.T) {
625				r := New(Capabilities{})
626				frame := NewFrame(3, 2)
627				markFrame(&frame)
628
629				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
630					t.Fatal(err)
631				}
632				out, err := r.Draw(frame, []Damage{FullRedraw()})
633				if err != nil {
634					t.Fatal(err)
635				}
636				if len(out) != 0 {
637					t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
638				}
639			},
640		},
641		{
642			name: "reset full redraw emits complete frame",
643			run: func(t *testing.T) {
644				r := New(Capabilities{})
645				frame := NewFrame(3, 2)
646				markFrame(&frame)
647
648				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
649					t.Fatal(err)
650				}
651				r.Reset()
652
653				out, err := r.Draw(frame, []Damage{FullRedraw()})
654				if err != nil {
655					t.Fatal(err)
656				}
657				got := string(out)
658				if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
659					t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
660				}
661				outputEndsWith(t, out, "\x1b[0m")
662			},
663		},
664	}
665
666	for _, tt := range tests {
667		t.Run(tt.name, tt.run)
668	}
669}
670
671// ---------------------------------------------------------------------------
672// Scroll with no remaining damage
673// ---------------------------------------------------------------------------
674
675func TestScrollOnlyDamage(t *testing.T) {
676	tests := []struct {
677		name string
678	}{
679		{name: "scroll damage plus new-row text damage from the VT model"},
680	}
681
682	for _, tt := range tests {
683		t.Run(tt.name, func(t *testing.T) {
684			r := New(Capabilities{})
685			frame := NewFrame(4, 3)
686			markFrame(&frame)
687
688			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
689			if err != nil {
690				t.Fatal(err)
691			}
692
693			// Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
694			scrolled := NewFrame(4, 3)
695			copy(scrolled.Cells[0:8], frame.Cells[4:12])
696			for i := 8; i < 12; i++ {
697				scrolled.Cells[i] = BlankCell()
698			}
699			scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
700
701			damage := []Damage{
702				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
703				{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
704			}
705
706			out, err := r.Draw(scrolled, damage)
707			if err != nil {
708				t.Fatal(err)
709			}
710			if len(out) == 0 {
711				t.Fatal("expected output for scroll")
712			}
713			// Should include scroll region
714			outputContains(t, out, "\x1b[1;3r")
715			outputContains(t, out, "\x1b[r")
716			outputEndsWith(t, out, "\x1b[0m")
717		})
718	}
719}
720
721// ---------------------------------------------------------------------------
722// writeCursor output format
723// ---------------------------------------------------------------------------
724
725func TestWriteCursor(t *testing.T) {
726	tests := []struct {
727		name string
728		y, x int
729		want string
730	}{
731		{name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
732		{name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
733	}
734
735	for _, tt := range tests {
736		t.Run(tt.name, func(t *testing.T) {
737			var buf bytes.Buffer
738			writeCursor(&buf, tt.y, tt.x)
739			if buf.String() != tt.want {
740				t.Errorf("unexpected cursor positioning: %q", buf.String())
741			}
742		})
743	}
744}
745
746// ---------------------------------------------------------------------------
747// isSafeScroll edge cases
748// ---------------------------------------------------------------------------
749
750func TestIsSafeScroll(t *testing.T) {
751	frame := NewFrame(10, 8)
752
753	tests := []struct {
754		name string
755		d    Damage
756		want bool
757	}{
758		{"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true},
759		{"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false},
760		{"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false},
761		{"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false},
762		{"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false},
763		{"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false},
764		{"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false},
765	}
766
767	for _, tt := range tests {
768		t.Run(tt.name, func(t *testing.T) {
769			got := isSafeScroll(frame, tt.d)
770			if got != tt.want {
771				t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want)
772			}
773		})
774	}
775}
776
777// ---------------------------------------------------------------------------
778// BlankCell / Cell.Equal
779// ---------------------------------------------------------------------------
780
781func TestCellEqual(t *testing.T) {
782	tests := []struct {
783		name string
784		a    Cell
785		b    Cell
786		want bool
787	}{
788		{
789			name: "blank cells should be equal",
790			a:    BlankCell(),
791			b:    BlankCell(),
792			want: true,
793		},
794		{
795			name: "different runes should not be equal",
796			a:    Cell{Rune: 'A', Style: DefaultStyle()},
797			b:    BlankCell(),
798			want: false,
799		},
800		{
801			name: "different styles should not be equal",
802			a:    Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
803			b:    BlankCell(),
804			want: false,
805		},
806		{
807			name: "continuation flag participates in equality",
808			a:    Cell{Continuation: true},
809			b:    Cell{},
810			want: false,
811		},
812		{
813			name: "two continuation cells are equal",
814			a:    Cell{Continuation: true},
815			b:    Cell{Continuation: true},
816			want: true,
817		},
818	}
819
820	for _, tt := range tests {
821		t.Run(tt.name, func(t *testing.T) {
822			if got := tt.a.Equal(tt.b); got != tt.want {
823				t.Errorf("Equal() = %v, want %v", got, tt.want)
824			}
825		})
826	}
827}
828
829// ---------------------------------------------------------------------------
830// Frame helpers
831// ---------------------------------------------------------------------------
832
833func TestFrameValidate(t *testing.T) {
834	tests := []struct {
835		name    string
836		frame   Frame
837		wantErr bool
838	}{
839		{name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
840		{name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
841		{name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
842	}
843
844	for _, tt := range tests {
845		t.Run(tt.name, func(t *testing.T) {
846			err := tt.frame.Validate()
847			if tt.wantErr && err == nil {
848				t.Fatal("expected error, got nil")
849			}
850			if !tt.wantErr && err != nil {
851				t.Fatalf("unexpected error: %v", err)
852			}
853		})
854	}
855}
856
857func TestFrameAtSet(t *testing.T) {
858	tests := []struct {
859		name string
860	}{
861		{name: "At/Set round-trip and unaffected cells stay blank"},
862	}
863
864	for _, tt := range tests {
865		t.Run(tt.name, func(t *testing.T) {
866			f := NewFrame(3, 2)
867			cell := Cell{Rune: 'X', Style: Style{Bold: true}}
868			f.Set(1, 1, cell)
869
870			got := f.At(1, 1)
871			if got.Rune != 'X' || !got.Style.Bold {
872				t.Errorf("At/Set round-trip failed: got %+v", got)
873			}
874
875			// Unaffected cells are blank.
876			got = f.At(0, 0)
877			if got != BlankCell() {
878				t.Errorf("unexpected cell at (0,0): %+v", got)
879			}
880		})
881	}
882}
883
884// ---------------------------------------------------------------------------
885// Damage helpers
886// ---------------------------------------------------------------------------
887
888func TestSameDamage(t *testing.T) {
889	tests := []struct {
890		name string
891		a    Damage
892		b    Damage
893		want bool
894	}{
895		{
896			name: "identical damages should be equal",
897			a:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
898			b:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
899			want: true,
900		},
901		{
902			name: "different counts should not be equal",
903			a:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
904			b:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
905			want: false,
906		},
907	}
908
909	for _, tt := range tests {
910		t.Run(tt.name, func(t *testing.T) {
911			if got := sameDamage(tt.a, tt.b); got != tt.want {
912				t.Errorf("sameDamage() = %v, want %v", got, tt.want)
913			}
914		})
915	}
916}
917
918func TestDamageCoversCell(t *testing.T) {
919	damage := []Damage{
920		{Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
921	}
922
923	tests := []struct {
924		name string
925		x, y int
926		want bool
927	}{
928		{name: "should cover (3,3)", x: 3, y: 3, want: true},
929		{name: "should cover (6,4)", x: 6, y: 4, want: true},
930		{name: "should not cover (1,3)", x: 1, y: 3, want: false},
931		{name: "should not cover (3,5)", x: 3, y: 5, want: false},
932		{name: "should not cover (7,3)", x: 7, y: 3, want: false},
933	}
934
935	for _, tt := range tests {
936		t.Run(tt.name, func(t *testing.T) {
937			if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
938				t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
939			}
940		})
941	}
942}
943
944// ---------------------------------------------------------------------------
945// needsFull
946// ---------------------------------------------------------------------------
947
948func TestNeedsFull(t *testing.T) {
949	tests := []struct {
950		name   string
951		damage []Damage
952		want   bool
953	}{
954		{name: "nil damage should not need full", damage: nil, want: false},
955		{name: "empty damage should not need full", damage: []Damage{}, want: false},
956		{name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
957		{name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
958	}
959
960	for _, tt := range tests {
961		t.Run(tt.name, func(t *testing.T) {
962			if got := needsFull(tt.damage); got != tt.want {
963				t.Errorf("needsFull() = %v, want %v", got, tt.want)
964			}
965		})
966	}
967}
968
969func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
970	tests := []struct {
971		name string
972		run  func(t *testing.T)
973	}{
974		{
975			name: "falls back to full redraw when scroll fast path is unsafe",
976			run: func(t *testing.T) {
977				r := New(Capabilities{})
978				frame := NewFrame(4, 3)
979				markFrame(&frame)
980				if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
981					t.Fatal(err)
982				}
983
984				changed := NewFrame(4, 3)
985				markFrame(&changed)
986				changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
987				damage := []Damage{
988					{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
989					{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
990				}
991				out, err := r.Draw(changed, damage)
992				if err != nil {
993					t.Fatal(err)
994				}
995				if rows := strings.Count(string(out), "H"); rows < changed.Height {
996					t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
997				}
998				for i := range changed.Cells {
999					if !r.shadow[i].Equal(changed.Cells[i]) {
1000						t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
1001					}
1002				}
1003			},
1004		},
1005		{
1006			name: "out-of-bounds damage rectangles are clamped without panicking",
1007			run: func(t *testing.T) {
1008				r := New(Capabilities{})
1009				frame := NewFrame(3, 2)
1010				markFrame(&frame)
1011				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
1012					t.Fatal(err)
1013				}
1014				frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
1015				defer func() {
1016					if r := recover(); r != nil {
1017						t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1018					}
1019				}()
1020				if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1021					t.Fatal(err)
1022				}
1023			},
1024		},
1025	}
1026
1027	for _, tt := range tests {
1028		t.Run(tt.name, tt.run)
1029	}
1030}
1031
1032// ---------------------------------------------------------------------------
1033// Wide (double-width) character rendering
1034// ---------------------------------------------------------------------------
1035
1036func TestWideCharRendering(t *testing.T) {
1037	// A wide left cell {Rune: r} followed by a continuation cell
1038	// {Continuation: true}. The renderer must emit the rune once and emit
1039	// nothing at all for the continuation cell (the terminal advances two
1040	// columns by itself). It must never emit a space for a continuation.
1041	tests := []struct {
1042		name  string
1043		build func() Frame
1044		want  string
1045	}{
1046		{
1047			name: "single wide char followed by ascii",
1048			build: func() Frame {
1049				f := NewFrame(4, 1)
1050				f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1051				f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1052				f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1053				return f
1054			},
1055			// row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1056			want: "\x1b[1;1H你A \x1b[0m",
1057		},
1058		{
1059			name: "two adjacent wide chars then ascii",
1060			build: func() Frame {
1061				f := NewFrame(6, 1)
1062				f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1063				f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1064				f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1065				f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1066				f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1067				return f
1068			},
1069			want: "\x1b[1;1H你好X \x1b[0m",
1070		},
1071	}
1072
1073	for _, tt := range tests {
1074		t.Run(tt.name, func(t *testing.T) {
1075			r := New(Capabilities{})
1076			frame := tt.build()
1077			out, err := r.Draw(frame, []Damage{FullRedraw()})
1078			if err != nil {
1079				t.Fatal(err)
1080			}
1081			if string(out) != tt.want {
1082				t.Errorf("output = %q, want %q", string(out), tt.want)
1083			}
1084			// No continuation must ever be rendered as a space: the visible
1085			// glyph count is exactly the number of non-continuation cells.
1086			// A second draw with no damage must be a no-op, proving the shadow
1087			// matches the frame (continuation cells included).
1088			out2, err := r.Draw(frame, nil)
1089			if err != nil {
1090				t.Fatal(err)
1091			}
1092			if len(out2) != 0 {
1093				t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1094			}
1095		})
1096	}
1097}
1098
1099func TestRendererPrepare(t *testing.T) {
1100	fullFrame := testFrame("ABC", "DEF")
1101	fullDamage := []Damage{FullRedraw()}
1102
1103	tests := []struct {
1104		name string
1105		run  func(*testing.T)
1106	}{
1107		{name: "zero commit is safe", run: func(t *testing.T) {
1108			var prepared PreparedDraw
1109			prepared.Commit()
1110		}},
1111
1112		{name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) {
1113			r := New(Capabilities{})
1114			first, err := r.Prepare(fullFrame, fullDamage, false)
1115			if err != nil {
1116				t.Fatal(err)
1117			}
1118			want := append([]byte(nil), first.Bytes()...)
1119
1120			second, err := r.Prepare(fullFrame, fullDamage, false)
1121			if err != nil {
1122				t.Fatal(err)
1123			}
1124			if !bytes.Equal(second.Bytes(), want) {
1125				t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1126			}
1127
1128			other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1129			if _, err := r.Prepare(other, fullDamage, false); err != nil {
1130				t.Fatal(err)
1131			}
1132			if !bytes.Equal(first.Bytes(), want) {
1133				t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1134			}
1135		}},
1136
1137		{name: "commit then no-op", run: func(t *testing.T) {
1138			r := New(Capabilities{})
1139			prepared, err := r.Prepare(fullFrame, fullDamage, false)
1140			if err != nil {
1141				t.Fatal(err)
1142			}
1143			prepared.Commit()
1144
1145			next, err := r.Prepare(fullFrame, nil, false)
1146			if err != nil {
1147				t.Fatal(err)
1148			}
1149			if len(next.Bytes()) != 0 {
1150				t.Fatalf("unchanged frame emitted %q", next.Bytes())
1151			}
1152		}},
1153
1154		{name: "reset preparation is transactional", run: func(t *testing.T) {
1155			r := New(Capabilities{})
1156			initial, err := r.Prepare(fullFrame, fullDamage, false)
1157			if err != nil {
1158				t.Fatal(err)
1159			}
1160			initial.Commit()
1161
1162			resetFrame := testFrame("ABC", "XYZ")
1163			resetPrepared, err := r.Prepare(resetFrame, nil, true)
1164			if err != nil {
1165				t.Fatal(err)
1166			}
1167			want, err := New(Capabilities{}).Draw(resetFrame, nil)
1168			if err != nil {
1169				t.Fatal(err)
1170			}
1171			if !bytes.Equal(resetPrepared.Bytes(), want) {
1172				t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1173			}
1174
1175			unchanged, err := r.Prepare(fullFrame, nil, false)
1176			if err != nil {
1177				t.Fatal(err)
1178			}
1179			if len(unchanged.Bytes()) != 0 {
1180				t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1181			}
1182
1183			resetPrepared, err = r.Prepare(resetFrame, nil, true)
1184			if err != nil {
1185				t.Fatal(err)
1186			}
1187			resetPrepared.Commit()
1188			afterCommit, err := r.Prepare(resetFrame, nil, false)
1189			if err != nil {
1190				t.Fatal(err)
1191			}
1192			if len(afterCommit.Bytes()) != 0 {
1193				t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1194			}
1195		}},
1196
1197		{name: "successful no-byte commit", run: func(t *testing.T) {
1198			r := New(Capabilities{})
1199			initial, err := r.Prepare(fullFrame, fullDamage, false)
1200			if err != nil {
1201				t.Fatal(err)
1202			}
1203			initial.Commit()
1204
1205			noOp, err := r.Prepare(fullFrame, nil, false)
1206			if err != nil {
1207				t.Fatal(err)
1208			}
1209			if len(noOp.Bytes()) != 0 {
1210				t.Fatalf("no-op emitted %q", noOp.Bytes())
1211			}
1212			noOp.Commit()
1213
1214			again, err := r.Prepare(fullFrame, nil, false)
1215			if err != nil {
1216				t.Fatal(err)
1217			}
1218			if len(again.Bytes()) != 0 {
1219				t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1220			}
1221		}},
1222
1223		{name: "double commit is idempotent", run: func(t *testing.T) {
1224			base := testFrame("0000", "1111", "2222", "3333")
1225			scrolled := testFrame("1111", "2222", "3333", "new!")
1226			damage := []Damage{
1227				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1228				{Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1229			}
1230			r := New(Capabilities{})
1231			if _, err := r.Draw(base, fullDamage); err != nil {
1232				t.Fatal(err)
1233			}
1234
1235			prepared, err := r.Prepare(scrolled, damage, false)
1236			if err != nil {
1237				t.Fatal(err)
1238			}
1239			prepared.Commit()
1240			prepared.Commit()
1241
1242			next, err := r.Prepare(scrolled, nil, false)
1243			if err != nil {
1244				t.Fatal(err)
1245			}
1246			if len(next.Bytes()) != 0 {
1247				t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1248			}
1249		}},
1250
1251		{name: "copied prepared draw commits scroll once", run: func(t *testing.T) {
1252			base := testFrame("0000", "1111", "2222", "3333")
1253			scrolled := testFrame("1111", "2222", "3333", "new!")
1254			damage := []Damage{
1255				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1256				{Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1257			}
1258			r := New(Capabilities{})
1259			if _, err := r.Draw(base, fullDamage); err != nil {
1260				t.Fatal(err)
1261			}
1262
1263			prepared, err := r.Prepare(scrolled, damage, false)
1264			if err != nil {
1265				t.Fatal(err)
1266			}
1267			copied := prepared
1268			prepared.Commit()
1269			copied.Commit()
1270
1271			next, err := r.Prepare(scrolled, nil, false)
1272			if err != nil {
1273				t.Fatal(err)
1274			}
1275			if len(next.Bytes()) != 0 {
1276				t.Fatalf("copied commit advanced state twice: %q", next.Bytes())
1277			}
1278		}},
1279
1280		{name: "commit uses immutable frame snapshot", run: func(t *testing.T) {
1281			r := New(Capabilities{})
1282			base := testFrame("ABC", "DEF")
1283			if _, err := r.Draw(base, fullDamage); err != nil {
1284				t.Fatal(err)
1285			}
1286
1287			changed := base.Clone()
1288			changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()})
1289			want := changed.Clone()
1290			prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
1291			if err != nil {
1292				t.Fatal(err)
1293			}
1294			if !strings.Contains(string(prepared.Bytes()), "X") {
1295				t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes())
1296			}
1297
1298			changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()})
1299			prepared.Commit()
1300
1301			next, err := r.Prepare(want, nil, false)
1302			if err != nil {
1303				t.Fatal(err)
1304			}
1305			if len(next.Bytes()) != 0 {
1306				t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes())
1307			}
1308		}},
1309
1310		{name: "matches concrete ANSI fixtures", run: func(t *testing.T) {
1311			tests := []struct {
1312				name    string
1313				prepare func(t *testing.T) PreparedDraw
1314				want    string
1315			}{
1316				{
1317					name: "full draw",
1318					prepare: func(t *testing.T) PreparedDraw {
1319						prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1320						if err != nil {
1321							t.Fatal(err)
1322						}
1323						return prepared
1324					},
1325					want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1326				},
1327				{
1328					name: "synchronized full draw",
1329					prepare: func(t *testing.T) PreparedDraw {
1330						prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false)
1331						if err != nil {
1332							t.Fatal(err)
1333						}
1334						return prepared
1335					},
1336					want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI,
1337				},
1338				{
1339					name: "fragmented damage",
1340					prepare: func(t *testing.T) PreparedDraw {
1341						base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1342						frame := base.Clone()
1343						frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1344						frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1345						frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1346						damage := []Damage{
1347							{Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1348							{Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1349							{Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1350						}
1351						r := New(Capabilities{})
1352						if _, err := r.Draw(base, fullDamage); err != nil {
1353							t.Fatal(err)
1354						}
1355						prepared, err := r.Prepare(frame, damage, false)
1356						if err != nil {
1357							t.Fatal(err)
1358						}
1359						return prepared
1360					},
1361					want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1362				},
1363				{
1364					name: "scroll",
1365					prepare: func(t *testing.T) PreparedDraw {
1366						base := testFrame("00000", "11111", "22222", "33333")
1367						frame := testFrame("11111", "22222", "33333", "new!!")
1368						damage := []Damage{
1369							{Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1370							{Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1371						}
1372						r := New(Capabilities{})
1373						if _, err := r.Draw(base, fullDamage); err != nil {
1374							t.Fatal(err)
1375						}
1376						prepared, err := r.Prepare(frame, damage, false)
1377						if err != nil {
1378							t.Fatal(err)
1379						}
1380						return prepared
1381					},
1382					want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1383				},
1384			}
1385
1386			for _, tt := range tests {
1387				t.Run(tt.name, func(t *testing.T) {
1388					if got := string(tt.prepare(t).Bytes()); got != tt.want {
1389						t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1390					}
1391				})
1392			}
1393		}},
1394	}
1395
1396	for _, tt := range tests {
1397		t.Run(tt.name, tt.run)
1398	}
1399}
1400
1401func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1402	frame := NewFrame(2, 1)
1403	committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}
1404
1405	if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1406		t.Fatal("PlanDelta accepted an invalid committed frame")
1407	}
1408}
1409
1410func TestWideCharDamageEmission(t *testing.T) {
1411	// Populate the shadow with a blank frame, then place a wide char and draw
1412	// with a text-damage rect covering the pair. The continuation cell must
1413	// not produce a space.
1414	r := New(Capabilities{})
1415	blank := NewFrame(4, 1)
1416	if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1417		t.Fatal(err)
1418	}
1419
1420	frame := NewFrame(4, 1)
1421	frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1422	frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1423
1424	out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1425	if err != nil {
1426		t.Fatal(err)
1427	}
1428	got := string(out)
1429	if !strings.Contains(got, "好") {
1430		t.Errorf("output %q missing wide rune", got)
1431	}
1432	if strings.Contains(got, "好 ") {
1433		t.Errorf("output %q emitted a space for the continuation cell", got)
1434	}
1435}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close