v0.1.0 7deb448

screen_csi_test.go RawHistoryFolder

1package vt
2
3import (
4	"testing"
5
6	"github.com/bnema/vev/pkg/renderer"
7	"github.com/stretchr/testify/require"
8)
9
10func TestParseCSIInts(t *testing.T) {
11	t.Parallel()
12
13	tests := []struct {
14		name   string
15		params string
16		want   []int
17	}{
18		{name: "empty", params: "", want: nil},
19		{name: "single", params: "5", want: []int{5}},
20		{name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
21		{name: "leading empty token", params: ";5", want: []int{0, 5}},
22		{name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
23		{name: "trailing empty token", params: "5;", want: []int{5, 0}},
24		{name: "question prefix", params: "?25", want: []int{25}},
25		{name: "greater prefix", params: ">0;1", want: []int{0, 1}},
26		{name: "prefix order", params: "?>7", want: []int{7}},
27		{name: "junk token", params: "12x;4", want: []int{0, 4}},
28	}
29
30	for _, tt := range tests {
31		t.Run(tt.name, func(t *testing.T) {
32			require.Equal(t, tt.want, parseCSIInts(tt.params))
33		})
34	}
35}
36
37func TestParseSGRInts(t *testing.T) {
38	t.Parallel()
39
40	tests := []struct {
41		name   string
42		params string
43		want   []int
44	}{
45		{name: "empty", params: "", want: nil},
46		{name: "single", params: "1", want: []int{1}},
47		{name: "multiple", params: "1;31", want: []int{1, 31}},
48		{name: "leading empty token", params: ";5", want: []int{0, 5}},
49		{name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
50		{name: "trailing empty token", params: "5;", want: []int{5, 0}},
51		{name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
52		{name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
53		{name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
54		{name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
55		{name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
56		{name: "non color colon group", params: "1:2:3", want: []int{1}},
57		{name: "junk token", params: "abc;31", want: []int{0, 31}},
58	}
59
60	for _, tt := range tests {
61		t.Run(tt.name, func(t *testing.T) {
62			require.Equal(t, tt.want, parseSGRInts(tt.params))
63		})
64	}
65}
66
67func TestSGR(t *testing.T) {
68	tests := []struct {
69		name  string
70		seq   string
71		check func(t *testing.T, s *Screen)
72	}{
73		{
74			name: "SGR 0 resets to default style",
75			seq:  "\x1b[0m",
76			check: func(t *testing.T, s *Screen) {
77				if s.Style != (renderer.DefaultStyle()) {
78					t.Errorf("style after SGR 0 = %+v, want default", s.Style)
79				}
80			},
81		},
82		{
83			name: "SGR 1 sets bold",
84			seq:  "\x1b[1m",
85			check: func(t *testing.T, s *Screen) {
86				if !s.Style.Bold {
87					t.Error("expected bold")
88				}
89			},
90		},
91		{
92			name: "SGR 7 sets inverse",
93			seq:  "\x1b[7m",
94			check: func(t *testing.T, s *Screen) {
95				if !s.Style.Inverse {
96					t.Error("expected inverse")
97				}
98			},
99		},
100		{
101			name: "SGR 31 sets red foreground",
102			seq:  "\x1b[31m",
103			check: func(t *testing.T, s *Screen) {
104				if s.Style.Foreground != 1 {
105					t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
106				}
107			},
108		},
109		{
110			name: "SGR 44 sets blue background",
111			seq:  "\x1b[44m",
112			check: func(t *testing.T, s *Screen) {
113				if s.Style.Background != 4 {
114					t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
115				}
116			},
117		},
118		{
119			name: "SGR 38;5 sets 256-color foreground",
120			seq:  "\x1b[38;5;82m",
121			check: func(t *testing.T, s *Screen) {
122				if s.Style.Foreground != 82 {
123					t.Errorf("foreground = %d, want 82", s.Style.Foreground)
124				}
125			},
126		},
127		{
128			name: "SGR 48;5 sets 256-color background",
129			seq:  "\x1b[48;5;200m",
130			check: func(t *testing.T, s *Screen) {
131				if s.Style.Background != 200 {
132					t.Errorf("background = %d, want 200", s.Style.Background)
133				}
134			},
135		},
136		{
137			name: "SGR 91 sets bright red foreground",
138			seq:  "\x1b[91m",
139			check: func(t *testing.T, s *Screen) {
140				if s.Style.Foreground != 9 {
141					t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
142				}
143			},
144		},
145		{
146			name: "SGR 107 sets bright white background",
147			seq:  "\x1b[107m",
148			check: func(t *testing.T, s *Screen) {
149				if s.Style.Background != 15 {
150					t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
151				}
152			},
153		},
154		{
155			name: "SGR 22 resets bold",
156			seq:  "\x1b[1mHello\x1b[22mWorld",
157			check: func(t *testing.T, s *Screen) {
158				if s.Style.Bold {
159					t.Error("bold should be reset after 22")
160				}
161			},
162		},
163		{
164			name: "SGR 27 resets inverse",
165			seq:  "\x1b[7m\x1b[27m",
166			check: func(t *testing.T, s *Screen) {
167				if s.Style.Inverse {
168					t.Error("inverse should be reset after 27")
169				}
170			},
171		},
172		{
173			name: "SGR 39 resets foreground to default",
174			seq:  "\x1b[31m\x1b[39m",
175			check: func(t *testing.T, s *Screen) {
176				if s.Style.Foreground != -1 {
177					t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
178				}
179			},
180		},
181		{
182			name: "SGR 49 resets background to default",
183			seq:  "\x1b[44m\x1b[49m",
184			check: func(t *testing.T, s *Screen) {
185				if s.Style.Background != -1 {
186					t.Errorf("background should be default after 49, got %d", s.Style.Background)
187				}
188			},
189		},
190		{
191			name: "SGR style is applied to written cells",
192			seq:  "\x1b[1;31mX",
193			check: func(t *testing.T, s *Screen) {
194				c := cellAt(s, 0, 0)
195				if !c.Style.Bold {
196					t.Error("cell should be bold")
197				}
198				if c.Style.Foreground != 1 {
199					t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
200				}
201			},
202		},
203		{
204			name: "multiple sequential SGR sequences accumulate",
205			seq:  "\x1b[31m\x1b[1m\x1b[44mX",
206			check: func(t *testing.T, s *Screen) {
207				c := cellAt(s, 0, 0)
208				if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
209					t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
210				}
211			},
212		},
213		{
214			name: "SGR 3 sets italic on style and cell, SGR 23 clears it",
215			seq:  "\x1b[3mX\x1b[23mY",
216			check: func(t *testing.T, s *Screen) {
217				if !cellAt(s, 0, 0).Style.Italic {
218					t.Errorf("first cell italic = false, want true")
219				}
220				if cellAt(s, 1, 0).Style.Italic {
221					t.Errorf("second cell italic = true, want false after SGR 23")
222				}
223				if s.Style.Italic {
224					t.Errorf("current style italic = true, want false after SGR 23")
225				}
226			},
227		},
228		{
229			name: "SGR preserves dim underline blink and strikethrough with resets",
230			seq:  "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
231			check: func(t *testing.T, s *Screen) {
232				first := cellAt(s, 0, 0).Style
233				if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
234					t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
235				}
236				second := cellAt(s, 1, 0).Style
237				if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
238					t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
239				}
240			},
241		},
242		{
243			name: "SGR 22 resets both bold and dim",
244			seq:  "\x1b[1;2m\x1b[22m",
245			check: func(t *testing.T, s *Screen) {
246				if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
247					t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
248				}
249			},
250		},
251		{
252			name: "SGR underline style and color are preserved and reset",
253			seq:  "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
254			check: func(t *testing.T, s *Screen) {
255				first := cellAt(s, 0, 0).Style
256				if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
257					t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
258				}
259				want := renderer.RGB{R: 255, G: 0, B: 0}
260				if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
261					t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
262				}
263				second := cellAt(s, 1, 0).Style
264				if second.HasUnderlineColorRGB || second.HasUnderlineColor {
265					t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
266				}
267			},
268		},
269		{
270			name: "SGR 4 colon zero clears underline",
271			seq:  "\x1b[4:3mA\x1b[4:0mB",
272			check: func(t *testing.T, s *Screen) {
273				first := cellAt(s, 0, 0).Style
274				if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
275					t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
276				}
277				second := cellAt(s, 1, 0).Style
278				if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
279					t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
280				}
281			},
282		},
283		{
284			name: "empty SGR params reset style",
285			seq:  "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
286			check: func(t *testing.T, s *Screen) {
287				if s.Style != (renderer.DefaultStyle()) {
288					t.Errorf("style after empty SGR = %+v, want default", s.Style)
289				}
290			},
291		},
292	}
293
294	for _, tt := range tests {
295		t.Run(tt.name, func(t *testing.T) {
296			s := NewScreen(10, 3)
297			s.Write([]byte(tt.seq))
298			tt.check(t, s)
299		})
300	}
301}
302
303func TestSGRTruecolor(t *testing.T) {
304	tests := []struct {
305		name  string
306		seq   string
307		check func(t *testing.T, s *Screen)
308	}{
309		{
310			name: "SGR 38;2 sets truecolor foreground on style and cell",
311			seq:  "\x1b[38;2;12;34;56mX",
312			check: func(t *testing.T, s *Screen) {
313				want := renderer.RGB{R: 12, G: 34, B: 56}
314				if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
315					t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
316				}
317				if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
318					t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
319				}
320			},
321		},
322		{
323			name: "SGR 38:2 sets truecolor foreground",
324			seq:  "\x1b[38:2:12:34:56mX",
325			check: func(t *testing.T, s *Screen) {
326				want := renderer.RGB{R: 12, G: 34, B: 56}
327				if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
328					t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
329				}
330				if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
331					t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
332				}
333			},
334		},
335		{
336			name: "SGR 38:2 empty color space sets truecolor foreground",
337			seq:  "\x1b[38:2::12:34:56mX",
338			check: func(t *testing.T, s *Screen) {
339				want := renderer.RGB{R: 12, G: 34, B: 56}
340				if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
341					t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
342				}
343				if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
344					t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
345				}
346			},
347		},
348		{
349			name: "SGR 38:2 color space id sets truecolor foreground",
350			seq:  "\x1b[38:2:1:12:34:56mX",
351			check: func(t *testing.T, s *Screen) {
352				want := renderer.RGB{R: 12, G: 34, B: 56}
353				if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
354					t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
355				}
356				if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
357					t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
358				}
359			},
360		},
361		{
362			name: "mixed semicolon and colon SGR groups",
363			seq:  "\x1b[1;38:2:10:20:30;7mX",
364			check: func(t *testing.T, s *Screen) {
365				want := renderer.RGB{R: 10, G: 20, B: 30}
366				if !s.Style.Bold || !s.Style.Inverse {
367					t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
368				}
369				if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
370					t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
371				}
372			},
373		},
374		{
375			name: "truncated colon truecolor group does not consume next parameter",
376			seq:  "\x1b[38:2:1:2;31m",
377			check: func(t *testing.T, s *Screen) {
378				if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
379					t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
380				}
381			},
382		},
383		{
384			name: "SGR 48;2 sets truecolor background",
385			seq:  "\x1b[48;2;200;100;50m",
386			check: func(t *testing.T, s *Screen) {
387				want := renderer.RGB{R: 200, G: 100, B: 50}
388				if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
389					t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
390				}
391			},
392		},
393		{
394			name: "SGR 48:2 sets truecolor background",
395			seq:  "\x1b[48:2:200:100:50m",
396			check: func(t *testing.T, s *Screen) {
397				want := renderer.RGB{R: 200, G: 100, B: 50}
398				if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
399					t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
400				}
401			},
402		},
403		{
404			name: "SGR 38:5 sets 256-color foreground",
405			seq:  "\x1b[38:5:82m",
406			check: func(t *testing.T, s *Screen) {
407				if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
408					t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
409				}
410			},
411		},
412		{
413			name: "SGR 48:5 sets 256-color background",
414			seq:  "\x1b[48:5:200m",
415			check: func(t *testing.T, s *Screen) {
416				if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
417					t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
418				}
419			},
420		},
421		{
422			name: "truncated colon foreground index group does not consume next parameter",
423			seq:  "\x1b[38:5;31m",
424			check: func(t *testing.T, s *Screen) {
425				if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
426					t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
427				}
428			},
429		},
430		{
431			name: "truncated colon background index group does not consume next parameter",
432			seq:  "\x1b[48:5;44m",
433			check: func(t *testing.T, s *Screen) {
434				if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
435					t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
436				}
437			},
438		},
439		{
440			name: "SGR 39 after truecolor clears truecolor foreground",
441			seq:  "\x1b[38;2;12;34;56m\x1b[39m",
442			check: func(t *testing.T, s *Screen) {
443				if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
444					t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
445				}
446			},
447		},
448		{
449			name: "truecolor components are clamped to 0-255",
450			seq:  "\x1b[38;2;-1;300;42m",
451			check: func(t *testing.T, s *Screen) {
452				want := renderer.RGB{R: 0, G: 255, B: 42}
453				if s.Style.ForegroundRGB != want {
454					t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
455				}
456			},
457		},
458	}
459
460	for _, tt := range tests {
461		t.Run(tt.name, func(t *testing.T) {
462			s := NewScreen(10, 3)
463			s.Write([]byte(tt.seq))
464			tt.check(t, s)
465		})
466	}
467}
468
469func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
470	tests := []struct {
471		name          string
472		seq           string
473		foreground    int
474		background    int
475		hasForeground bool
476		hasBackground bool
477	}{
478		{name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1},
479		{name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1},
480		{name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true},
481		{name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15},
482		{name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200},
483		{name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1},
484		{name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true},
485	}
486
487	for _, tt := range tests {
488		t.Run(tt.name, func(t *testing.T) {
489			s := NewScreen(10, 3)
490			s.Write([]byte(tt.seq))
491
492			if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground {
493				t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground)
494			}
495			if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground {
496				t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground)
497			}
498		})
499	}
500}
501
502func TestClearAndErase(t *testing.T) {
503	tests := []struct {
504		name string
505		run  func(t *testing.T)
506	}{
507		{
508			name: "CSI 2J clears the whole screen",
509			run: func(t *testing.T) {
510				s := NewScreen(5, 3)
511				s.Write([]byte("HelloWorldABCDE"))
512				// Clear damage from writing.
513				s.ClearDamage()
514
515				s.Write([]byte("\x1b[2J"))
516
517				// All cells should be blank.
518				for y := range 3 {
519					for x := range 5 {
520						if c := cellAt(s, x, y); c.Rune != ' ' {
521							t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
522						}
523					}
524				}
525
526				d := s.Damage()
527				if !hasDamageKind(d, renderer.DamageClear) {
528					t.Error("expected DamageClear after CSI 2 J")
529				}
530			},
531		},
532		{
533			name: "CSI K clears from cursor to end of line",
534			run: func(t *testing.T) {
535				s := NewScreen(10, 3)
536				s.Write([]byte("HelloWorld"))
537				s.ClearDamage()
538
539				// Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
540				s.Write([]byte("\x1b[1;6H"))
541				s.Write([]byte("\x1b[K"))
542
543				for x := 5; x < 10; x++ {
544					if c := cellAt(s, x, 0); c.Rune != ' ' {
545						t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
546					}
547				}
548				// First 5 chars should remain.
549				assertCell(t, s, 0, 0, 'H')
550
551				d := s.Damage()
552				if !hasDamageKind(d, renderer.DamageClear) {
553					t.Error("expected DamageClear after CSI K")
554				}
555			},
556		},
557		{
558			name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
559			run: func(t *testing.T) {
560				s := NewScreen(5, 3)
561				s.Write([]byte("abcdefghijklmno"))
562				s.Write([]byte("\x1b[2;3H"))
563				s.ClearDamage()
564				s.Write([]byte("\x1b[J"))
565				assertCell(t, s, 0, 0, 'a')
566				assertCell(t, s, 1, 1, 'g')
567				if c := cellAt(s, 2, 1); c.Rune != ' ' {
568					t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
569				}
570
571				s = NewScreen(5, 3)
572				s.Write([]byte("abcdefghijklmno"))
573				s.Write([]byte("\x1b[2;3H"))
574				s.Write([]byte("\x1b[1J"))
575				if c := cellAt(s, 0, 0); c.Rune != ' ' {
576					t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
577				}
578				assertCell(t, s, 3, 1, 'i')
579			},
580		},
581		{
582			name: "erase line modes 0/1/2",
583			run: func(t *testing.T) {
584				s := NewScreen(5, 2)
585				s.Write([]byte("abcde"))
586				s.Write([]byte("\x1b[1;3H"))
587				s.Write([]byte("\x1b[1K"))
588				if c := cellAt(s, 0, 0); c.Rune != ' ' {
589					t.Fatalf("line start clear left %q", c.Rune)
590				}
591				assertCell(t, s, 3, 0, 'd')
592
593				s = NewScreen(5, 2)
594				s.Write([]byte("abcde"))
595				s.Write([]byte("\x1b[1;3H"))
596				s.Write([]byte("\x1b[2K"))
597				for x := range 5 {
598					if c := cellAt(s, x, 0); c.Rune != ' ' {
599						t.Fatalf("line clear left %q at %d", c.Rune, x)
600					}
601				}
602			},
603		},
604	}
605
606	for _, tt := range tests {
607		t.Run(tt.name, tt.run)
608	}
609}
610
611func TestBackgroundColorErase(t *testing.T) {
612	tests := []struct {
613		name      string
614		width     int
615		height    int
616		seq       string
617		startX    int
618		wantStyle renderer.Style
619	}{
620		{
621			name:      "indexed background for EL",
622			width:     8,
623			height:    1,
624			seq:       "\x1b[1;31;48;5;236mX\x1b[K",
625			startX:    1,
626			wantStyle: renderer.Style{Foreground: -1, Background: 236},
627		},
628		{
629			name:   "truecolor background for full ED",
630			width:  3,
631			height: 2,
632			seq:    "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J",
633			wantStyle: renderer.Style{
634				Foreground:       -1,
635				Background:       -1,
636				HasBackgroundRGB: true,
637				BackgroundRGB:    renderer.RGB{R: 12, G: 34, B: 56},
638			},
639		},
640		{
641			name:      "default background after SGR 49",
642			width:     2,
643			height:    1,
644			seq:       "\x1b[48;2;12;34;56m\x1b[49m\x1b[K",
645			wantStyle: renderer.DefaultStyle(),
646		},
647		{
648			name:      "default background after SGR 0",
649			width:     2,
650			height:    1,
651			seq:       "\x1b[48;2;12;34;56m\x1b[0m\x1b[K",
652			wantStyle: renderer.DefaultStyle(),
653		},
654	}
655
656	for _, tt := range tests {
657		t.Run(tt.name, func(t *testing.T) {
658			s := NewScreen(tt.width, tt.height)
659			s.Write([]byte(tt.seq))
660
661			for y := range tt.height {
662				for x := tt.startX; x < tt.width; x++ {
663					cell := cellAt(s, x, y)
664					if cell.Rune != ' ' || cell.Style != tt.wantStyle {
665						t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
666					}
667				}
668			}
669		})
670	}
671}
672
673func TestCursorMove(t *testing.T) {
674	tests := []struct {
675		name    string
676		seq     string
677		initRow int
678		initCol int
679		wantRow int
680		wantCol int
681	}{
682		{name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
683		{name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
684		{name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
685		{name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
686		{name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
687		{name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
688	}
689
690	for _, tt := range tests {
691		t.Run(tt.name, func(t *testing.T) {
692			s := NewScreen(10, 5)
693			s.Row, s.Col = tt.initRow, tt.initCol
694			s.Write([]byte(tt.seq))
695
696			if s.Row != tt.wantRow || s.Col != tt.wantCol {
697				t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
698			}
699		})
700	}
701}
702
703func TestCSIEditingSequences(t *testing.T) {
704	tests := []struct {
705		name string
706		run  func(t *testing.T)
707	}{
708		{
709			name: "directional cursor moves (A/B/C/D)",
710			run: func(t *testing.T) {
711				s := NewScreen(10, 4)
712				s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
713
714				assertCell(t, s, 4, 2, 'X')
715				assertCell(t, s, 3, 2, '<')
716				assertCell(t, s, 4, 1, '^')
717				assertCell(t, s, 5, 2, 'v')
718				assertCell(t, s, 9, 2, '>')
719			},
720		},
721		{
722			name: "insert and delete characters (@ and P)",
723			run: func(t *testing.T) {
724				s := NewScreen(8, 2)
725				s.Write([]byte("abcdef"))
726				s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
727				if got := lineText(s, 0); got != "abXYcdef" {
728					t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
729				}
730
731				s.Write([]byte("\x1b[1;4H\x1b[3P"))
732				if got := lineText(s, 0); got != "abXef   " {
733					t.Fatalf("line after DCH = %q, want %q", got, "abXef   ")
734				}
735			},
736		},
737		{
738			name: "insert and delete lines (L and M)",
739			run: func(t *testing.T) {
740				s := NewScreen(5, 4)
741				s.Write([]byte("11111222223333344444"))
742				s.Write([]byte("\x1b[2;1H\x1b[L"))
743				if got := lineText(s, 1); got != "     " {
744					t.Fatalf("inserted line = %q, want blank", got)
745				}
746				if got := lineText(s, 2); got != "22222" {
747					t.Fatalf("shifted line = %q, want 22222", got)
748				}
749
750				s.Write([]byte("\x1b[2;1H\x1b[M"))
751				if got := lineText(s, 1); got != "22222" {
752					t.Fatalf("line after delete = %q, want 22222", got)
753				}
754			},
755		},
756		{
757			name: "scroll region (CSI r) confines LF scrolling",
758			run: func(t *testing.T) {
759				s := NewScreen(5, 4)
760				s.Write([]byte("11111222223333344444"))
761				s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
762
763				if got := lineText(s, 0); got != "11111" {
764					t.Fatalf("row outside region changed: %q", got)
765				}
766				if got := lineText(s, 1); got != "33333" {
767					t.Fatalf("region did not scroll up, row 1 = %q", got)
768				}
769				if got := lineText(s, 2); got != "     " {
770					t.Fatalf("region bottom not blanked, row 2 = %q", got)
771				}
772				if got := lineText(s, 3); got != "44444" {
773					t.Fatalf("row below region changed: %q", got)
774				}
775			},
776		},
777		{
778			name: "DECOM homes and addresses relative to scroll region",
779			run: func(t *testing.T) {
780				s := NewScreen(6, 5)
781				s.Write([]byte("\x1b[2;4r\x1b[?6h"))
782				if s.Row != 1 || s.Col != 0 {
783					t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
784				}
785
786				s.Write([]byte("\x1b[2;3H"))
787				if s.Row != 2 || s.Col != 2 {
788					t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
789				}
790				s.Write([]byte("\x1b[99;1H"))
791				if s.Row != 3 || s.Col != 0 {
792					t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
793				}
794			},
795		},
796		{
797			name: "DECOM already set homes DECSTBM to scroll region",
798			run: func(t *testing.T) {
799				s := NewScreen(6, 5)
800				s.Write([]byte("\x1b[?6h\x1b[2;4r"))
801				if s.Row != 1 || s.Col != 0 {
802					t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
803				}
804
805				s.Write([]byte("\x1b[2;3H"))
806				if s.Row != 2 || s.Col != 2 {
807					t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
808				}
809				s.Write([]byte("\x1b[99;1H"))
810				if s.Row != 3 || s.Col != 0 {
811					t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
812				}
813			},
814		},
815		{
816			name: "DECOM reset homes and restores full-frame addressing",
817			run: func(t *testing.T) {
818				s := NewScreen(6, 5)
819				s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
820				if s.Row != 0 || s.Col != 0 {
821					t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
822				}
823
824				s.Write([]byte("\x1b[5;6H"))
825				if s.Row != 4 || s.Col != 5 {
826					t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
827				}
828			},
829		},
830		{
831			name: "private alternate screen mode restores normal screen on exit",
832			run: func(t *testing.T) {
833				s := NewScreen(20, 5)
834				s.Write([]byte("normal-line"))
835				s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
836				s.Write([]byte("\x1b[?1049lafter"))
837
838				if got := lineText(s, 0); got != "normal-lineafter    " {
839					t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
840				}
841				if got := lineText(s, 2); got != "                    " {
842					t.Fatalf("alternate content leaked into row 2: %q", got)
843				}
844			},
845		},
846	}
847
848	for _, tt := range tests {
849		t.Run(tt.name, tt.run)
850	}
851}
852
853func TestCSIScrollUp(t *testing.T) {
854	tests := []struct {
855		name string
856		run  func(t *testing.T)
857	}{
858		{
859			name: "CSI S with no param scrolls up by 1 and preserves cursor",
860			run: func(t *testing.T) {
861				s := NewScreen(5, 4)
862				s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
863				s.ClearDamage()
864
865				// CSI S with no param → scroll up by 1.
866				s.Write([]byte("\x1b[S"))
867
868				// Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
869				assertCell(t, s, 0, 0, 'B')
870				assertCell(t, s, 0, 1, 'C')
871				assertCell(t, s, 0, 2, 'D')
872				assertCell(t, s, 0, 3, ' ')
873
874				// Cursor should NOT have moved.
875				if s.Col != 5 || s.Row != 3 {
876					t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
877				}
878
879				d := s.Damage()
880				if !hasDamageKind(d, renderer.DamageScrollUp) {
881					t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
882				}
883			},
884		},
885		{
886			name: "CSI 2S scrolls up by an explicit count",
887			run: func(t *testing.T) {
888				s := NewScreen(5, 4)
889				s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
890				s.ClearDamage()
891
892				// CSI 2 S → scroll up by 2.
893				s.Write([]byte("\x1b[2S"))
894
895				// Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
896				assertCell(t, s, 0, 0, 'C')
897				assertCell(t, s, 0, 1, 'D')
898				assertCell(t, s, 0, 2, ' ')
899				assertCell(t, s, 0, 3, ' ')
900
901				// Cursor preserved.
902				if s.Col != 5 || s.Row != 3 {
903					t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
904				}
905			},
906		},
907		{
908			name: "CSI 100S on a 3-row screen clamps to the full screen",
909			run: func(t *testing.T) {
910				s := NewScreen(5, 3)
911				s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
912				s.ClearDamage()
913
914				// CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
915				s.Write([]byte("\x1b[100S"))
916
917				// All rows should be blank.
918				for y := range 3 {
919					for x := range 5 {
920						assertCell(t, s, x, y, ' ')
921					}
922				}
923			},
924		},
925		{
926			name: "CSI 0S defaults to a scroll count of 1",
927			run: func(t *testing.T) {
928				s := NewScreen(5, 3)
929				s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
930				s.ClearDamage()
931
932				// CSI 0 S → default to 1 (parameter 0 means default in VT spec).
933				s.Write([]byte("\x1b[0S"))
934
935				// Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
936				assertCell(t, s, 0, 0, 'B')
937				assertCell(t, s, 0, 1, 'C')
938				assertCell(t, s, 0, 2, ' ')
939			},
940		},
941		{
942			name: "CSI 3S reports scroll count 3 and blanked-row text damage",
943			run: func(t *testing.T) {
944				s := NewScreen(5, 4)
945				s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
946				s.ClearDamage()
947
948				s.Write([]byte("\x1b[3S")) // scroll up by 3
949
950				d := s.Damage()
951				var scrollDamage *renderer.Damage
952				for i, dd := range d {
953					if dd.Kind == renderer.DamageScrollUp {
954						scrollDamage = &d[i]
955						break
956					}
957				}
958				if scrollDamage == nil {
959					t.Fatal("expected DamageScrollUp")
960				}
961				if scrollDamage.Count != 3 {
962					t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
963				}
964				if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
965					t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
966				}
967
968				// Should also have text damage for the blanked rows (bottom 3 rows).
969				if !hasDamageKind(d, renderer.DamageText) {
970					t.Error("expected DamageText for blanked rows")
971				}
972			},
973		},
974		{
975			name: "cursor position is preserved across a scroll-up",
976			run: func(t *testing.T) {
977				s := NewScreen(10, 5)
978				s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
979				s.ClearDamage()
980
981				// Move cursor to middle of row 2.
982				s.Write([]byte("\x1b[3;3H")) // row=3, col=3
983				s.Write([]byte("\x1b[2S"))   // scroll up by 2
984
985				// Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
986				if s.Row != 2 || s.Col != 2 {
987					t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
988				}
989			},
990		},
991	}
992
993	for _, tt := range tests {
994		t.Run(tt.name, tt.run)
995	}
996}

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