v0.5.1 8e4df5c

screen_csi_test.go RawHistoryFolder

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

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