v0.5.1 8e4df5c

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

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