v0.1.0 7deb448

renderer_test.go RawHistoryFolder

1package renderer
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
137// ---------------------------------------------------------------------------
138// Style reset discipline
139// ---------------------------------------------------------------------------
140
141func TestStyleResetDiscipline(t *testing.T) {
142	tests := []struct {
143		name string
144		run  func(t *testing.T)
145	}{
146		{
147			name: "single styled cell among default cells",
148			run: func(t *testing.T) {
149				r := New(Capabilities{})
150				frame := NewFrame(4, 2)
151
152				// One styled cell, rest default.
153				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
154
155				out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
156				if err != nil {
157					t.Fatal(err)
158				}
159				// Must end with style reset.
160				outputEndsWith(t, out, "\x1b[0m")
161			},
162		},
163		{
164			name: "after scroll damage",
165			run: func(t *testing.T) {
166				r := New(Capabilities{})
167				frame := NewFrame(4, 3)
168				markFrame(&frame)
169
170				// Populate shadow.
171				_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
172				if err != nil {
173					t.Fatal(err)
174				}
175
176				// Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
177				frame2 := NewFrame(4, 3)
178				copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
179				for i := 8; i < 12; i++ {
180					frame2.Cells[i] = BlankCell() // bottom row blank
181				}
182				frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
183
184				damage := []Damage{
185					{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
186					{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
187				}
188
189				out, err := r.Draw(frame2, damage)
190				if err != nil {
191					t.Fatal(err)
192				}
193				// The output must end with a style reset (either from emitScrollUp or writeDamage).
194				outputEndsWith(t, out, "\x1b[0m")
195			},
196		},
197	}
198
199	for _, tt := range tests {
200		t.Run(tt.name, tt.run)
201	}
202}
203
204func TestStyleEqualUsesActiveColorMode(t *testing.T) {
205	tests := []struct {
206		name      string
207		left      Style
208		right     Style
209		wantEqual bool
210	}{
211		{
212			name:      "ignores inactive indexed foreground",
213			left:      Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
214			right:     Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
215			wantEqual: true,
216		},
217		{
218			name:      "compares active RGB values",
219			left:      Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
220			right:     Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
221			wantEqual: false,
222		},
223	}
224
225	for _, tt := range tests {
226		t.Run(tt.name, func(t *testing.T) {
227			if got := tt.left.Equal(tt.right); got != tt.wantEqual {
228				t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
229			}
230		})
231	}
232}
233
234func TestRendererEmitsSGR(t *testing.T) {
235	tests := []struct {
236		name    string
237		width   int
238		height  int
239		setup   func(frame Frame)
240		damage  []Damage
241		wantAll []string
242	}{
243		{
244			name:   "truecolor foreground and background",
245			width:  1,
246			height: 1,
247			setup: func(frame Frame) {
248				frame.Set(0, 0, Cell{
249					Rune: 'X',
250					Style: Style{
251						Foreground:       -1,
252						Background:       -1,
253						HasForegroundRGB: true,
254						ForegroundRGB:    RGB{R: 12, G: 34, B: 56},
255						HasBackgroundRGB: true,
256						BackgroundRGB:    RGB{R: 200, G: 100, B: 50},
257					},
258				})
259			},
260			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
261			wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
262		},
263		{
264			name:   "style changes across adjacent color modes",
265			width:  3,
266			height: 1,
267			setup: func(frame Frame) {
268				frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
269				frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
270				frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
271			},
272			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
273			wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
274		},
275		{
276			name:   "bold italic inverse",
277			width:  1,
278			height: 1,
279			setup: func(frame Frame) {
280				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}})
281			},
282			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
283			wantAll: []string{"\x1b[0;1;3;7m", "X"},
284		},
285		{
286			name:   "extended SGR attributes and underline color",
287			width:  1,
288			height: 1,
289			setup: func(frame Frame) {
290				frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
291			},
292			damage:  []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
293			wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
294		},
295	}
296
297	for _, tt := range tests {
298		t.Run(tt.name, func(t *testing.T) {
299			r := New(Capabilities{})
300			frame := NewFrame(tt.width, tt.height)
301			tt.setup(frame)
302
303			out, err := r.Draw(frame, tt.damage)
304			if err != nil {
305				t.Fatal(err)
306			}
307
308			outputContains(t, out, tt.wantAll...)
309		})
310	}
311}
312
313// ---------------------------------------------------------------------------
314// Scroll fast path
315// ---------------------------------------------------------------------------
316
317func TestScrollFastPath(t *testing.T) {
318	tests := []struct {
319		name string
320	}{
321		{name: "scroll up by one row exposes new bottom row via scroll region"},
322	}
323
324	for _, tt := range tests {
325		t.Run(tt.name, func(t *testing.T) {
326			r := New(Capabilities{})
327			frame := NewFrame(5, 4)
328			markFrame(&frame)
329
330			// Populate shadow.
331			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
332			if err != nil {
333				t.Fatal(err)
334			}
335
336			// Build a new frame that has been scrolled up by 1 (like the VT model does).
337			scrolled := NewFrame(5, 4)
338			copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
339			for i := 15; i < 20; i++ {
340				scrolled.Cells[i] = BlankCell() // row 3 blanked
341			}
342			scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
343			scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
344			scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
345
346			damage := []Damage{
347				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
348				{Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
349			}
350
351			out, err := r.Draw(scrolled, damage)
352			if err != nil {
353				t.Fatal(err)
354			}
355			if len(out) == 0 {
356				t.Fatal("expected non-empty output for scroll")
357			}
358			// Should contain scroll-region sequences.
359			outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
360			outputContains(t, out, "\x1b[r")    // restore scroll region
361			// Should contain the new text on the exposed row.
362			outputContains(t, out, "N")
363			outputContains(t, out, "e")
364			outputContains(t, out, "w")
365		})
366	}
367}
368
369// ---------------------------------------------------------------------------
370// Synchronized output wrapper
371// ---------------------------------------------------------------------------
372
373func TestSynchronizedOutput(t *testing.T) {
374	tests := []struct {
375		name string
376		run  func(t *testing.T)
377	}{
378		{
379			name: "wraps output in sync CSI",
380			run: func(t *testing.T) {
381				r := New(Capabilities{SynchronizedOutput: true})
382				frame := NewFrame(3, 2)
383				markFrame(&frame)
384
385				out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
386				if err != nil {
387					t.Fatal(err)
388				}
389				if !strings.HasPrefix(string(out), SyncStartCSI) {
390					t.Errorf("expected output to start with sync start CSI")
391				}
392				if !strings.HasSuffix(string(out), SyncEndCSI) {
393					t.Errorf("expected output to end with sync end CSI, got %q", string(out))
394				}
395			},
396		},
397		{
398			name: "no-op returns empty output, not wrapped empty output",
399			run: func(t *testing.T) {
400				r := New(Capabilities{SynchronizedOutput: true})
401				frame := NewFrame(3, 2)
402				markFrame(&frame)
403
404				// First draw.
405				_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
406				if err != nil {
407					t.Fatal(err)
408				}
409
410				// No-op – must return nil, not wrapped empty output.
411				out, err := r.Draw(frame, nil)
412				if err != nil {
413					t.Fatal(err)
414				}
415				if len(out) != 0 {
416					t.Fatalf("expected empty output for no-op, got %q", string(out))
417				}
418			},
419		},
420	}
421
422	for _, tt := range tests {
423		t.Run(tt.name, tt.run)
424	}
425}
426
427// ---------------------------------------------------------------------------
428// WrapSynchronized standalone function
429// ---------------------------------------------------------------------------
430
431func TestWrapSynchronized(t *testing.T) {
432	content := []byte("\x1b[0mhello")
433
434	tests := []struct {
435		name    string
436		content []byte
437		enabled bool
438		check   func(t *testing.T, got []byte)
439	}{
440		{
441			name:    "enabled wraps with sync start/end and preserves content",
442			content: content,
443			enabled: true,
444			check: func(t *testing.T, got []byte) {
445				if !strings.HasPrefix(string(got), SyncStartCSI) {
446					t.Errorf("missing sync start")
447				}
448				if !strings.HasSuffix(string(got), SyncEndCSI) {
449					t.Errorf("missing sync end")
450				}
451				if !strings.Contains(string(got), "hello") {
452					t.Errorf("missing original content")
453				}
454			},
455		},
456		{
457			name:    "disabled returns content unchanged",
458			content: content,
459			enabled: false,
460			check: func(t *testing.T, got []byte) {
461				if string(got) != string(content) {
462					t.Errorf("disabled wrapping should return content unchanged")
463				}
464			},
465		},
466		{
467			name:    "empty input returns empty",
468			content: nil,
469			enabled: true,
470			check: func(t *testing.T, got []byte) {
471				if len(got) != 0 {
472					t.Errorf("empty input should return empty")
473				}
474			},
475		},
476	}
477
478	for _, tt := range tests {
479		t.Run(tt.name, func(t *testing.T) {
480			got := WrapSynchronized(tt.content, tt.enabled)
481			tt.check(t, got)
482		})
483	}
484}
485
486// ---------------------------------------------------------------------------
487// Partial damage draw
488// ---------------------------------------------------------------------------
489
490func TestPartialDamage(t *testing.T) {
491	tests := []struct {
492		name string
493	}{
494		{name: "single changed cell repositions cursor and resets style"},
495	}
496
497	for _, tt := range tests {
498		t.Run(tt.name, func(t *testing.T) {
499			r := New(Capabilities{})
500			frame := NewFrame(4, 2)
501			markFrame(&frame)
502
503			// Populate shadow.
504			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
505			if err != nil {
506				t.Fatal(err)
507			}
508
509			// Change one cell.
510			frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
511			damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
512
513			out, err := r.Draw(frame, damage)
514			if err != nil {
515				t.Fatal(err)
516			}
517			if len(out) == 0 {
518				t.Fatal("expected non-empty output for partial damage")
519			}
520			// Should position cursor at (2,1) — 1-indexed (3;2).
521			outputContains(t, out, "\x1b[2;3H")
522			outputContains(t, out, "Z")
523			outputEndsWith(t, out, "\x1b[0m")
524		})
525	}
526}
527
528// ---------------------------------------------------------------------------
529// Reset
530// ---------------------------------------------------------------------------
531
532func TestReset(t *testing.T) {
533	tests := []struct {
534		name string
535	}{
536		{name: "clears shadow state and forces a full draw next time"},
537	}
538
539	for _, tt := range tests {
540		t.Run(tt.name, func(t *testing.T) {
541			r := New(Capabilities{})
542			frame := NewFrame(3, 2)
543			markFrame(&frame)
544
545			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
546			if err != nil {
547				t.Fatal(err)
548			}
549
550			r.Reset()
551
552			if r.width != 0 || r.height != 0 || r.shadow != nil {
553				t.Fatal("Reset should clear width/height/shadow")
554			}
555
556			// After reset, the next draw should be a full draw.
557			out, err := r.Draw(frame, nil)
558			if err != nil {
559				t.Fatal(err)
560			}
561			if len(out) == 0 {
562				t.Fatal("expected non-empty output after reset")
563			}
564		})
565	}
566}
567
568// ---------------------------------------------------------------------------
569// Damage helper
570// ---------------------------------------------------------------------------
571
572func TestFullRedraw(t *testing.T) {
573	tests := []struct {
574		name string
575		run  func(t *testing.T)
576	}{
577		{
578			name: "known same-size full redraw diffs against shadow",
579			run: func(t *testing.T) {
580				r := New(Capabilities{})
581				frame := NewFrame(3, 2)
582				markFrame(&frame)
583
584				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
585					t.Fatal(err)
586				}
587
588				changed := frame
589				changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
590				out, err := r.Draw(changed, []Damage{FullRedraw()})
591				if err != nil {
592					t.Fatal(err)
593				}
594				got := string(out)
595				if strings.Contains(got, "ABC") {
596					t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
597				}
598				if !strings.Contains(got, "ZEF") {
599					t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
600				}
601				outputEndsWith(t, out, "\x1b[0m")
602			},
603		},
604		{
605			name: "unchanged known same-size full redraw is a no-op",
606			run: func(t *testing.T) {
607				r := New(Capabilities{})
608				frame := NewFrame(3, 2)
609				markFrame(&frame)
610
611				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
612					t.Fatal(err)
613				}
614				out, err := r.Draw(frame, []Damage{FullRedraw()})
615				if err != nil {
616					t.Fatal(err)
617				}
618				if len(out) != 0 {
619					t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
620				}
621			},
622		},
623		{
624			name: "reset full redraw emits complete frame",
625			run: func(t *testing.T) {
626				r := New(Capabilities{})
627				frame := NewFrame(3, 2)
628				markFrame(&frame)
629
630				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
631					t.Fatal(err)
632				}
633				r.Reset()
634
635				out, err := r.Draw(frame, []Damage{FullRedraw()})
636				if err != nil {
637					t.Fatal(err)
638				}
639				got := string(out)
640				if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
641					t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
642				}
643				outputEndsWith(t, out, "\x1b[0m")
644			},
645		},
646	}
647
648	for _, tt := range tests {
649		t.Run(tt.name, tt.run)
650	}
651}
652
653// ---------------------------------------------------------------------------
654// Scroll with no remaining damage
655// ---------------------------------------------------------------------------
656
657func TestScrollOnlyDamage(t *testing.T) {
658	tests := []struct {
659		name string
660	}{
661		{name: "scroll damage plus new-row text damage from the VT model"},
662	}
663
664	for _, tt := range tests {
665		t.Run(tt.name, func(t *testing.T) {
666			r := New(Capabilities{})
667			frame := NewFrame(4, 3)
668			markFrame(&frame)
669
670			_, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
671			if err != nil {
672				t.Fatal(err)
673			}
674
675			// Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
676			scrolled := NewFrame(4, 3)
677			copy(scrolled.Cells[0:8], frame.Cells[4:12])
678			for i := 8; i < 12; i++ {
679				scrolled.Cells[i] = BlankCell()
680			}
681			scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
682
683			damage := []Damage{
684				{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
685				{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
686			}
687
688			out, err := r.Draw(scrolled, damage)
689			if err != nil {
690				t.Fatal(err)
691			}
692			if len(out) == 0 {
693				t.Fatal("expected output for scroll")
694			}
695			// Should include scroll region
696			outputContains(t, out, "\x1b[1;3r")
697			outputContains(t, out, "\x1b[r")
698			outputEndsWith(t, out, "\x1b[0m")
699		})
700	}
701}
702
703// ---------------------------------------------------------------------------
704// writeCursor output format
705// ---------------------------------------------------------------------------
706
707func TestWriteCursor(t *testing.T) {
708	tests := []struct {
709		name string
710		y, x int
711		want string
712	}{
713		{name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
714		{name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
715	}
716
717	for _, tt := range tests {
718		t.Run(tt.name, func(t *testing.T) {
719			var buf bytes.Buffer
720			writeCursor(&buf, tt.y, tt.x)
721			if buf.String() != tt.want {
722				t.Errorf("unexpected cursor positioning: %q", buf.String())
723			}
724		})
725	}
726}
727
728// ---------------------------------------------------------------------------
729// isSafeScroll edge cases
730// ---------------------------------------------------------------------------
731
732func TestIsSafeScroll(t *testing.T) {
733	frame := NewFrame(10, 8)
734
735	tests := []struct {
736		name string
737		d    Damage
738		want bool
739	}{
740		{"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true},
741		{"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false},
742		{"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false},
743		{"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false},
744		{"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false},
745		{"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false},
746		{"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false},
747	}
748
749	for _, tt := range tests {
750		t.Run(tt.name, func(t *testing.T) {
751			got := isSafeScroll(frame, tt.d)
752			if got != tt.want {
753				t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want)
754			}
755		})
756	}
757}
758
759// ---------------------------------------------------------------------------
760// BlankCell / Cell.Equal
761// ---------------------------------------------------------------------------
762
763func TestCellEqual(t *testing.T) {
764	tests := []struct {
765		name string
766		a    Cell
767		b    Cell
768		want bool
769	}{
770		{
771			name: "blank cells should be equal",
772			a:    BlankCell(),
773			b:    BlankCell(),
774			want: true,
775		},
776		{
777			name: "different runes should not be equal",
778			a:    Cell{Rune: 'A', Style: DefaultStyle()},
779			b:    BlankCell(),
780			want: false,
781		},
782		{
783			name: "different styles should not be equal",
784			a:    Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
785			b:    BlankCell(),
786			want: false,
787		},
788		{
789			name: "continuation flag participates in equality",
790			a:    Cell{Continuation: true},
791			b:    Cell{},
792			want: false,
793		},
794		{
795			name: "two continuation cells are equal",
796			a:    Cell{Continuation: true},
797			b:    Cell{Continuation: true},
798			want: true,
799		},
800	}
801
802	for _, tt := range tests {
803		t.Run(tt.name, func(t *testing.T) {
804			if got := tt.a.Equal(tt.b); got != tt.want {
805				t.Errorf("Equal() = %v, want %v", got, tt.want)
806			}
807		})
808	}
809}
810
811// ---------------------------------------------------------------------------
812// Frame helpers
813// ---------------------------------------------------------------------------
814
815func TestFrameValidate(t *testing.T) {
816	tests := []struct {
817		name    string
818		frame   Frame
819		wantErr bool
820	}{
821		{name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
822		{name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
823		{name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
824	}
825
826	for _, tt := range tests {
827		t.Run(tt.name, func(t *testing.T) {
828			err := tt.frame.Validate()
829			if tt.wantErr && err == nil {
830				t.Fatal("expected error, got nil")
831			}
832			if !tt.wantErr && err != nil {
833				t.Fatalf("unexpected error: %v", err)
834			}
835		})
836	}
837}
838
839func TestFrameAtSet(t *testing.T) {
840	tests := []struct {
841		name string
842	}{
843		{name: "At/Set round-trip and unaffected cells stay blank"},
844	}
845
846	for _, tt := range tests {
847		t.Run(tt.name, func(t *testing.T) {
848			f := NewFrame(3, 2)
849			cell := Cell{Rune: 'X', Style: Style{Bold: true}}
850			f.Set(1, 1, cell)
851
852			got := f.At(1, 1)
853			if got.Rune != 'X' || !got.Style.Bold {
854				t.Errorf("At/Set round-trip failed: got %+v", got)
855			}
856
857			// Unaffected cells are blank.
858			got = f.At(0, 0)
859			if got != BlankCell() {
860				t.Errorf("unexpected cell at (0,0): %+v", got)
861			}
862		})
863	}
864}
865
866// ---------------------------------------------------------------------------
867// Damage helpers
868// ---------------------------------------------------------------------------
869
870func TestSameDamage(t *testing.T) {
871	tests := []struct {
872		name string
873		a    Damage
874		b    Damage
875		want bool
876	}{
877		{
878			name: "identical damages should be equal",
879			a:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
880			b:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
881			want: true,
882		},
883		{
884			name: "different counts should not be equal",
885			a:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
886			b:    Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
887			want: false,
888		},
889	}
890
891	for _, tt := range tests {
892		t.Run(tt.name, func(t *testing.T) {
893			if got := sameDamage(tt.a, tt.b); got != tt.want {
894				t.Errorf("sameDamage() = %v, want %v", got, tt.want)
895			}
896		})
897	}
898}
899
900func TestDamageCoversCell(t *testing.T) {
901	damage := []Damage{
902		{Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
903	}
904
905	tests := []struct {
906		name string
907		x, y int
908		want bool
909	}{
910		{name: "should cover (3,3)", x: 3, y: 3, want: true},
911		{name: "should cover (6,4)", x: 6, y: 4, want: true},
912		{name: "should not cover (1,3)", x: 1, y: 3, want: false},
913		{name: "should not cover (3,5)", x: 3, y: 5, want: false},
914		{name: "should not cover (7,3)", x: 7, y: 3, want: false},
915	}
916
917	for _, tt := range tests {
918		t.Run(tt.name, func(t *testing.T) {
919			if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
920				t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
921			}
922		})
923	}
924}
925
926// ---------------------------------------------------------------------------
927// needsFull
928// ---------------------------------------------------------------------------
929
930func TestNeedsFull(t *testing.T) {
931	tests := []struct {
932		name   string
933		damage []Damage
934		want   bool
935	}{
936		{name: "nil damage should not need full", damage: nil, want: false},
937		{name: "empty damage should not need full", damage: []Damage{}, want: false},
938		{name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
939		{name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
940	}
941
942	for _, tt := range tests {
943		t.Run(tt.name, func(t *testing.T) {
944			if got := needsFull(tt.damage); got != tt.want {
945				t.Errorf("needsFull() = %v, want %v", got, tt.want)
946			}
947		})
948	}
949}
950
951func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
952	tests := []struct {
953		name string
954		run  func(t *testing.T)
955	}{
956		{
957			name: "falls back to full redraw when scroll fast path is unsafe",
958			run: func(t *testing.T) {
959				r := New(Capabilities{})
960				frame := NewFrame(4, 3)
961				markFrame(&frame)
962				if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
963					t.Fatal(err)
964				}
965
966				changed := NewFrame(4, 3)
967				markFrame(&changed)
968				changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
969				damage := []Damage{
970					{Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
971					{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
972				}
973				out, err := r.Draw(changed, damage)
974				if err != nil {
975					t.Fatal(err)
976				}
977				if rows := strings.Count(string(out), "H"); rows < changed.Height {
978					t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
979				}
980				for i := range changed.Cells {
981					if !r.shadow[i].Equal(changed.Cells[i]) {
982						t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
983					}
984				}
985			},
986		},
987		{
988			name: "out-of-bounds damage rectangles are clamped without panicking",
989			run: func(t *testing.T) {
990				r := New(Capabilities{})
991				frame := NewFrame(3, 2)
992				markFrame(&frame)
993				if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
994					t.Fatal(err)
995				}
996				frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
997				defer func() {
998					if r := recover(); r != nil {
999						t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1000					}
1001				}()
1002				if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1003					t.Fatal(err)
1004				}
1005			},
1006		},
1007	}
1008
1009	for _, tt := range tests {
1010		t.Run(tt.name, tt.run)
1011	}
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Wide (double-width) character rendering
1016// ---------------------------------------------------------------------------
1017
1018func TestWideCharRendering(t *testing.T) {
1019	// A wide left cell {Rune: r} followed by a continuation cell
1020	// {Continuation: true}. The renderer must emit the rune once and emit
1021	// nothing at all for the continuation cell (the terminal advances two
1022	// columns by itself). It must never emit a space for a continuation.
1023	tests := []struct {
1024		name  string
1025		build func() Frame
1026		want  string
1027	}{
1028		{
1029			name: "single wide char followed by ascii",
1030			build: func() Frame {
1031				f := NewFrame(4, 1)
1032				f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1033				f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1034				f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1035				return f
1036			},
1037			// row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1038			want: "\x1b[1;1H你A \x1b[0m",
1039		},
1040		{
1041			name: "two adjacent wide chars then ascii",
1042			build: func() Frame {
1043				f := NewFrame(6, 1)
1044				f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1045				f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1046				f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1047				f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1048				f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1049				return f
1050			},
1051			want: "\x1b[1;1H你好X \x1b[0m",
1052		},
1053	}
1054
1055	for _, tt := range tests {
1056		t.Run(tt.name, func(t *testing.T) {
1057			r := New(Capabilities{})
1058			frame := tt.build()
1059			out, err := r.Draw(frame, []Damage{FullRedraw()})
1060			if err != nil {
1061				t.Fatal(err)
1062			}
1063			if string(out) != tt.want {
1064				t.Errorf("output = %q, want %q", string(out), tt.want)
1065			}
1066			// No continuation must ever be rendered as a space: the visible
1067			// glyph count is exactly the number of non-continuation cells.
1068			// A second draw with no damage must be a no-op, proving the shadow
1069			// matches the frame (continuation cells included).
1070			out2, err := r.Draw(frame, nil)
1071			if err != nil {
1072				t.Fatal(err)
1073			}
1074			if len(out2) != 0 {
1075				t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1076			}
1077		})
1078	}
1079}
1080
1081func TestWideCharDamageEmission(t *testing.T) {
1082	// Populate the shadow with a blank frame, then place a wide char and draw
1083	// with a text-damage rect covering the pair. The continuation cell must
1084	// not produce a space.
1085	r := New(Capabilities{})
1086	blank := NewFrame(4, 1)
1087	if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1088		t.Fatal(err)
1089	}
1090
1091	frame := NewFrame(4, 1)
1092	frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1093	frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1094
1095	out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1096	if err != nil {
1097		t.Fatal(err)
1098	}
1099	got := string(out)
1100	if !strings.Contains(got, "好") {
1101		t.Errorf("output %q missing wide rune", got)
1102	}
1103	if strings.Contains(got, "好 ") {
1104		t.Errorf("output %q emitted a space for the continuation cell", got)
1105	}
1106}

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 12

Tags 11

↑↓ moveenter openesc close