detached cbbe70e

test(vt): split screen coverage by behavior

commit
cbbe70ee8997756e77490b4ffa36f07c5495e6a1
author
brice · · 2026-07-14 20:17 UTC
parent
66775da
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +3303 -3337
A pkg/vt/screen_csi_test.go +996 -0
@@ -0,0 +1,996 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func 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+
37+func 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+
67+func 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+
303+func 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+
469+func 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+
502+func 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+
611+func 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+
673+func 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+
703+func 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+
853+func 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+}
@@ -0,0 +1,996 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func 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+
37+func 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+
67+func 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+
303+func 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+
469+func 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+
502+func 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+
611+func 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+
673+func 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+
703+func 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+
853+func 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+}
A pkg/vt/screen_damage_test.go +240 -0
@@ -0,0 +1,240 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScrollDamage(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "scroll produces both DamageScrollUp and DamageText",
17+ run: func(t *testing.T) {
18+ s := NewScreen(5, 2)
19+ // Fill screen then cause a scroll.
20+ s.Write([]byte("AAAAABBBBB")) // both rows filled
21+ s.ClearDamage()
22+
23+ s.Write([]byte("CCCCC")) // forces a scroll up
24+
25+ d := s.Damage()
26+ if len(d) == 0 {
27+ t.Fatal("expected damage after scroll")
28+ }
29+
30+ if !hasDamageKind(d, renderer.DamageScrollUp) {
31+ t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
32+ }
33+ if !hasDamageKind(d, renderer.DamageText) {
34+ t.Errorf("expected DamageText in %v", damageKinds(d))
35+ }
36+ },
37+ },
38+ {
39+ name: "scroll damage coordinates match the full-width scrolled region",
40+ run: func(t *testing.T) {
41+ s := NewScreen(5, 3)
42+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
43+ s.ClearDamage()
44+
45+ // Write another char to force scroll.
46+ s.Write([]byte("D"))
47+
48+ d := s.Damage()
49+ var scrollDamage *renderer.Damage
50+ for i, dd := range d {
51+ if dd.Kind == renderer.DamageScrollUp {
52+ scrollDamage = &d[i]
53+ break
54+ }
55+ }
56+ if scrollDamage == nil {
57+ t.Fatal("expected scroll damage")
58+ }
59+ if scrollDamage.X != 0 || scrollDamage.Y != 0 {
60+ t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
61+ }
62+ if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
63+ t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
64+ }
65+ if scrollDamage.Count != 1 {
66+ t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
67+ }
68+ },
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, tt.run)
74+ }
75+}
76+
77+func TestDamageCoalescingBehavior(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ run func(t *testing.T)
81+ }{
82+ {
83+ name: "adjacent single-cell writes coalesce into one damage entry",
84+ run: func(t *testing.T) {
85+ s := NewScreen(20, 3)
86+ s.Write([]byte("ABC")) // Three adjacent chars on one line
87+
88+ d := s.Damage()
89+ // Should be coalesced into a single DamageText.
90+ if len(d) != 1 {
91+ t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
92+ }
93+ if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
94+ t.Errorf("unexpected coalesced damage: %+v", d[0])
95+ }
96+ },
97+ },
98+ {
99+ name: "newline breaks coalescing across lines",
100+ run: func(t *testing.T) {
101+ s := NewScreen(10, 3)
102+ s.Write([]byte("AB\nCD"))
103+
104+ d := s.Damage()
105+ // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
106+ textCount := 0
107+ for _, dd := range d {
108+ if dd.Kind == renderer.DamageText {
109+ textCount++
110+ }
111+ }
112+ if textCount < 2 {
113+ t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
114+ }
115+ },
116+ },
117+ {
118+ name: "writing replaces the initial FullRedraw with text damage",
119+ run: func(t *testing.T) {
120+ s := NewScreen(5, 3)
121+ // NewScreen already has FullRedraw in damage.
122+ d := s.Damage()
123+ if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
124+ t.Fatalf("expected single FullRedraw, got %+v", d)
125+ }
126+
127+ // Writing should replace it with text damage.
128+ s.Write([]byte("X"))
129+ d = s.Damage()
130+ if len(d) == 0 {
131+ t.Fatal("expected damage after write")
132+ }
133+ if d[0].Kind == renderer.DamageFullRedraw {
134+ t.Error("FullRedraw should have been replaced by text damage")
135+ }
136+ },
137+ },
138+ }
139+
140+ for _, tt := range tests {
141+ t.Run(tt.name, tt.run)
142+ }
143+}
144+
145+func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
146+ s := NewScreen(2, 2)
147+ s.ClearDamage()
148+
149+ for i := range 1_100 {
150+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
151+ }
152+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
153+
154+ // Saturation is sticky until the render owner acknowledges or clears it.
155+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
156+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
157+
158+ s.ClearDamage()
159+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
160+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
161+}
162+
163+func TestClearDamage(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ }{
167+ {name: "ClearDamage empties the damage list"},
168+ }
169+
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ s := NewScreen(5, 3)
173+ s.Write([]byte("Hello"))
174+ s.ClearDamage()
175+ d := s.Damage()
176+ if len(d) != 0 {
177+ t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
178+ }
179+ })
180+ }
181+}
182+
183+func TestDamageReturnsReference(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ }{
187+ {name: "Damage() returns a reference to the internal slice"},
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ // Damage() should return a reference to internal slice (no copy).
193+ s := NewScreen(10, 3)
194+ s.Write([]byte("X"))
195+ d1 := s.Damage()
196+ // Internal slice should be the same (or at least reference the same backing).
197+ if len(d1) == 0 {
198+ t.Fatal("expected damage")
199+ }
200+ // Write more to trigger record.
201+ s.Write([]byte("Y"))
202+ d2 := s.Damage()
203+ _ = d2
204+ // Both d1 and d2 reference the internal slice; we just don't crash.
205+ })
206+ }
207+}
208+
209+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
210+ s := NewScreen(8, 2)
211+ s.ClearDamage()
212+ s.Write([]byte("a"))
213+
214+ captured := s.CaptureDamage()
215+ require.NotEmpty(t, captured.Damage)
216+ s.Write([]byte("b"))
217+
218+ require.False(t, s.AcknowledgeDamage(captured.Generation))
219+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
220+}
221+
222+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
223+ s := NewScreen(8, 2)
224+ s.ClearDamage()
225+ s.Write([]byte("a"))
226+
227+ captured := s.CaptureDamage()
228+ require.True(t, s.AcknowledgeDamage(captured.Generation))
229+ require.Empty(t, s.Damage())
230+}
231+
232+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
233+ s := NewScreen(8, 2)
234+ s.ClearDamage()
235+ s.Write([]byte("a"))
236+
237+ captured := s.CaptureDamage()
238+ s.ClearDamage()
239+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
240+}
@@ -0,0 +1,240 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScrollDamage(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "scroll produces both DamageScrollUp and DamageText",
17+ run: func(t *testing.T) {
18+ s := NewScreen(5, 2)
19+ // Fill screen then cause a scroll.
20+ s.Write([]byte("AAAAABBBBB")) // both rows filled
21+ s.ClearDamage()
22+
23+ s.Write([]byte("CCCCC")) // forces a scroll up
24+
25+ d := s.Damage()
26+ if len(d) == 0 {
27+ t.Fatal("expected damage after scroll")
28+ }
29+
30+ if !hasDamageKind(d, renderer.DamageScrollUp) {
31+ t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
32+ }
33+ if !hasDamageKind(d, renderer.DamageText) {
34+ t.Errorf("expected DamageText in %v", damageKinds(d))
35+ }
36+ },
37+ },
38+ {
39+ name: "scroll damage coordinates match the full-width scrolled region",
40+ run: func(t *testing.T) {
41+ s := NewScreen(5, 3)
42+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
43+ s.ClearDamage()
44+
45+ // Write another char to force scroll.
46+ s.Write([]byte("D"))
47+
48+ d := s.Damage()
49+ var scrollDamage *renderer.Damage
50+ for i, dd := range d {
51+ if dd.Kind == renderer.DamageScrollUp {
52+ scrollDamage = &d[i]
53+ break
54+ }
55+ }
56+ if scrollDamage == nil {
57+ t.Fatal("expected scroll damage")
58+ }
59+ if scrollDamage.X != 0 || scrollDamage.Y != 0 {
60+ t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
61+ }
62+ if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
63+ t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
64+ }
65+ if scrollDamage.Count != 1 {
66+ t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
67+ }
68+ },
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, tt.run)
74+ }
75+}
76+
77+func TestDamageCoalescingBehavior(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ run func(t *testing.T)
81+ }{
82+ {
83+ name: "adjacent single-cell writes coalesce into one damage entry",
84+ run: func(t *testing.T) {
85+ s := NewScreen(20, 3)
86+ s.Write([]byte("ABC")) // Three adjacent chars on one line
87+
88+ d := s.Damage()
89+ // Should be coalesced into a single DamageText.
90+ if len(d) != 1 {
91+ t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
92+ }
93+ if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
94+ t.Errorf("unexpected coalesced damage: %+v", d[0])
95+ }
96+ },
97+ },
98+ {
99+ name: "newline breaks coalescing across lines",
100+ run: func(t *testing.T) {
101+ s := NewScreen(10, 3)
102+ s.Write([]byte("AB\nCD"))
103+
104+ d := s.Damage()
105+ // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
106+ textCount := 0
107+ for _, dd := range d {
108+ if dd.Kind == renderer.DamageText {
109+ textCount++
110+ }
111+ }
112+ if textCount < 2 {
113+ t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
114+ }
115+ },
116+ },
117+ {
118+ name: "writing replaces the initial FullRedraw with text damage",
119+ run: func(t *testing.T) {
120+ s := NewScreen(5, 3)
121+ // NewScreen already has FullRedraw in damage.
122+ d := s.Damage()
123+ if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
124+ t.Fatalf("expected single FullRedraw, got %+v", d)
125+ }
126+
127+ // Writing should replace it with text damage.
128+ s.Write([]byte("X"))
129+ d = s.Damage()
130+ if len(d) == 0 {
131+ t.Fatal("expected damage after write")
132+ }
133+ if d[0].Kind == renderer.DamageFullRedraw {
134+ t.Error("FullRedraw should have been replaced by text damage")
135+ }
136+ },
137+ },
138+ }
139+
140+ for _, tt := range tests {
141+ t.Run(tt.name, tt.run)
142+ }
143+}
144+
145+func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
146+ s := NewScreen(2, 2)
147+ s.ClearDamage()
148+
149+ for i := range 1_100 {
150+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
151+ }
152+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
153+
154+ // Saturation is sticky until the render owner acknowledges or clears it.
155+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
156+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
157+
158+ s.ClearDamage()
159+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
160+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
161+}
162+
163+func TestClearDamage(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ }{
167+ {name: "ClearDamage empties the damage list"},
168+ }
169+
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ s := NewScreen(5, 3)
173+ s.Write([]byte("Hello"))
174+ s.ClearDamage()
175+ d := s.Damage()
176+ if len(d) != 0 {
177+ t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
178+ }
179+ })
180+ }
181+}
182+
183+func TestDamageReturnsReference(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ }{
187+ {name: "Damage() returns a reference to the internal slice"},
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ // Damage() should return a reference to internal slice (no copy).
193+ s := NewScreen(10, 3)
194+ s.Write([]byte("X"))
195+ d1 := s.Damage()
196+ // Internal slice should be the same (or at least reference the same backing).
197+ if len(d1) == 0 {
198+ t.Fatal("expected damage")
199+ }
200+ // Write more to trigger record.
201+ s.Write([]byte("Y"))
202+ d2 := s.Damage()
203+ _ = d2
204+ // Both d1 and d2 reference the internal slice; we just don't crash.
205+ })
206+ }
207+}
208+
209+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
210+ s := NewScreen(8, 2)
211+ s.ClearDamage()
212+ s.Write([]byte("a"))
213+
214+ captured := s.CaptureDamage()
215+ require.NotEmpty(t, captured.Damage)
216+ s.Write([]byte("b"))
217+
218+ require.False(t, s.AcknowledgeDamage(captured.Generation))
219+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
220+}
221+
222+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
223+ s := NewScreen(8, 2)
224+ s.ClearDamage()
225+ s.Write([]byte("a"))
226+
227+ captured := s.CaptureDamage()
228+ require.True(t, s.AcknowledgeDamage(captured.Generation))
229+ require.Empty(t, s.Damage())
230+}
231+
232+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
233+ s := NewScreen(8, 2)
234+ s.ClearDamage()
235+ s.Write([]byte("a"))
236+
237+ captured := s.CaptureDamage()
238+ s.ClearDamage()
239+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
240+}
A pkg/vt/screen_helpers_test.go +95 -0
@@ -0,0 +1,95 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func damageKinds(d []renderer.Damage) []renderer.DamageKind {
10+ ks := make([]renderer.DamageKind, len(d))
11+ for i, dd := range d {
12+ ks[i] = dd.Kind
13+ }
14+ return ks
15+}
16+
17+func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
18+ for _, dd := range d {
19+ if dd.Kind == kind {
20+ return true
21+ }
22+ }
23+ return false
24+}
25+
26+func cellAt(s *Screen, x, y int) renderer.Cell {
27+ return s.Frame.At(x, y)
28+}
29+
30+func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
31+ t.Helper()
32+ c := cellAt(s, x, y)
33+ if c.Rune != expected {
34+ t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
35+ }
36+ if c.Continuation {
37+ t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
38+ }
39+}
40+
41+// assertContinuation asserts the cell at (x,y) is the right half of a
42+// wide-character pair (Continuation set, Rune 0).
43+func assertContinuation(t *testing.T, s *Screen, x, y int) {
44+ t.Helper()
45+ c := cellAt(s, x, y)
46+ if !c.Continuation {
47+ t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
48+ }
49+ if c.Rune != 0 {
50+ t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
51+ }
52+}
53+
54+// assertBlank asserts the cell at (x,y) is a blank default cell.
55+func assertBlank(t *testing.T, s *Screen, x, y int) {
56+ t.Helper()
57+ c := cellAt(s, x, y)
58+ if c.Rune != ' ' || c.Continuation {
59+ t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
60+ }
61+}
62+
63+func assertNoOrphanWideCells(t *testing.T, s *Screen) {
64+ t.Helper()
65+ for y := range s.Frame.Height {
66+ for x := range s.Frame.Width {
67+ c := cellAt(s, x, y)
68+ if c.Continuation {
69+ if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
70+ t.Fatalf("orphan continuation at (%d,%d)", x, y)
71+ }
72+ continue
73+ }
74+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
75+ t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
76+ }
77+ }
78+ }
79+}
80+
81+func lineText(s *Screen, y int) string {
82+ out := make([]rune, s.Frame.Width)
83+ for x := range s.Frame.Width {
84+ out[x] = s.Frame.At(x, y).Rune
85+ }
86+ return string(out)
87+}
88+
89+func rowString(row []renderer.Cell) string {
90+ out := make([]rune, len(row))
91+ for i, cell := range row {
92+ out[i] = cell.Rune
93+ }
94+ return string(out)
95+}
@@ -0,0 +1,95 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func damageKinds(d []renderer.Damage) []renderer.DamageKind {
10+ ks := make([]renderer.DamageKind, len(d))
11+ for i, dd := range d {
12+ ks[i] = dd.Kind
13+ }
14+ return ks
15+}
16+
17+func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
18+ for _, dd := range d {
19+ if dd.Kind == kind {
20+ return true
21+ }
22+ }
23+ return false
24+}
25+
26+func cellAt(s *Screen, x, y int) renderer.Cell {
27+ return s.Frame.At(x, y)
28+}
29+
30+func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
31+ t.Helper()
32+ c := cellAt(s, x, y)
33+ if c.Rune != expected {
34+ t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
35+ }
36+ if c.Continuation {
37+ t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
38+ }
39+}
40+
41+// assertContinuation asserts the cell at (x,y) is the right half of a
42+// wide-character pair (Continuation set, Rune 0).
43+func assertContinuation(t *testing.T, s *Screen, x, y int) {
44+ t.Helper()
45+ c := cellAt(s, x, y)
46+ if !c.Continuation {
47+ t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
48+ }
49+ if c.Rune != 0 {
50+ t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
51+ }
52+}
53+
54+// assertBlank asserts the cell at (x,y) is a blank default cell.
55+func assertBlank(t *testing.T, s *Screen, x, y int) {
56+ t.Helper()
57+ c := cellAt(s, x, y)
58+ if c.Rune != ' ' || c.Continuation {
59+ t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
60+ }
61+}
62+
63+func assertNoOrphanWideCells(t *testing.T, s *Screen) {
64+ t.Helper()
65+ for y := range s.Frame.Height {
66+ for x := range s.Frame.Width {
67+ c := cellAt(s, x, y)
68+ if c.Continuation {
69+ if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
70+ t.Fatalf("orphan continuation at (%d,%d)", x, y)
71+ }
72+ continue
73+ }
74+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
75+ t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
76+ }
77+ }
78+ }
79+}
80+
81+func lineText(s *Screen, y int) string {
82+ out := make([]rune, s.Frame.Width)
83+ for x := range s.Frame.Width {
84+ out[x] = s.Frame.At(x, y).Rune
85+ }
86+ return string(out)
87+}
88+
89+func rowString(row []renderer.Cell) string {
90+ out := make([]rune, len(row))
91+ for i, cell := range row {
92+ out[i] = cell.Rune
93+ }
94+ return string(out)
95+}
A pkg/vt/screen_integration_test.go +79 -0
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestIntegrationScenarios(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "renderer stays in sync after VT edit damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(8, 2)
18+ r := renderer.New(renderer.Capabilities{})
19+ s.Write([]byte("abcdef"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
26+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
27+ t.Fatal(err)
28+ }
29+ s.ClearDamage()
30+
31+ out, err := r.Draw(s.Frame, nil)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if len(out) != 0 {
36+ t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
37+ }
38+ },
39+ },
40+ {
41+ name: "DCS sequence terminated by ST is ignored",
42+ run: func(t *testing.T) {
43+ s := NewScreen(20, 2)
44+ s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
45+
46+ assertCell(t, s, 0, 0, 'p')
47+ assertCell(t, s, 1, 0, 'r')
48+ assertCell(t, s, 2, 0, 'o')
49+ assertCell(t, s, 3, 0, 'm')
50+ assertCell(t, s, 4, 0, 'p')
51+ assertCell(t, s, 5, 0, 't')
52+ if s.Col != 6 || s.Row != 0 {
53+ t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
54+ }
55+ },
56+ },
57+ {
58+ name: "fish-like prompt redraw preserves typed characters",
59+ run: func(t *testing.T) {
60+ s := NewScreen(10, 2)
61+ s.Write([]byte("> "))
62+ s.Write([]byte("a\r\x1b[3C"))
63+ s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
64+
65+ assertCell(t, s, 0, 0, '>')
66+ assertCell(t, s, 1, 0, ' ')
67+ assertCell(t, s, 2, 0, 'a')
68+ assertCell(t, s, 3, 0, 'b')
69+ if s.Row != 0 || s.Col != 4 {
70+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
71+ }
72+ },
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, tt.run)
78+ }
79+}
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestIntegrationScenarios(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "renderer stays in sync after VT edit damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(8, 2)
18+ r := renderer.New(renderer.Capabilities{})
19+ s.Write([]byte("abcdef"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
26+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
27+ t.Fatal(err)
28+ }
29+ s.ClearDamage()
30+
31+ out, err := r.Draw(s.Frame, nil)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if len(out) != 0 {
36+ t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
37+ }
38+ },
39+ },
40+ {
41+ name: "DCS sequence terminated by ST is ignored",
42+ run: func(t *testing.T) {
43+ s := NewScreen(20, 2)
44+ s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
45+
46+ assertCell(t, s, 0, 0, 'p')
47+ assertCell(t, s, 1, 0, 'r')
48+ assertCell(t, s, 2, 0, 'o')
49+ assertCell(t, s, 3, 0, 'm')
50+ assertCell(t, s, 4, 0, 'p')
51+ assertCell(t, s, 5, 0, 't')
52+ if s.Col != 6 || s.Row != 0 {
53+ t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
54+ }
55+ },
56+ },
57+ {
58+ name: "fish-like prompt redraw preserves typed characters",
59+ run: func(t *testing.T) {
60+ s := NewScreen(10, 2)
61+ s.Write([]byte("> "))
62+ s.Write([]byte("a\r\x1b[3C"))
63+ s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
64+
65+ assertCell(t, s, 0, 0, '>')
66+ assertCell(t, s, 1, 0, ' ')
67+ assertCell(t, s, 2, 0, 'a')
68+ assertCell(t, s, 3, 0, 'b')
69+ if s.Row != 0 || s.Col != 4 {
70+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
71+ }
72+ },
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, tt.run)
78+ }
79+}
A pkg/vt/screen_modes_test.go +228 -0
@@ -0,0 +1,228 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+func TestESCSequences(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ run func(t *testing.T)
11+ }{
12+ {
13+ name: "ESC = (DECKPAM) is ignored, following text is printable",
14+ run: func(t *testing.T) {
15+ s := NewScreen(10, 2)
16+ s.Write([]byte("\x1b=abc"))
17+
18+ assertCell(t, s, 0, 0, 'a')
19+ assertCell(t, s, 1, 0, 'b')
20+ assertCell(t, s, 2, 0, 'c')
21+ if s.Col != 3 || s.Row != 0 {
22+ t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
23+ }
24+ },
25+ },
26+ {
27+ name: "ESC 7 / ESC 8 save and restore the cursor",
28+ run: func(t *testing.T) {
29+ s := NewScreen(10, 3)
30+ s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
31+
32+ assertCell(t, s, 0, 0, 'a')
33+ assertCell(t, s, 1, 0, 'b')
34+ assertCell(t, s, 2, 0, 'c')
35+ assertCell(t, s, 3, 0, 'X')
36+ assertCell(t, s, 4, 1, 'Z')
37+ assertCell(t, s, 5, 1, 'Z')
38+ if s.Row != 0 || s.Col != 4 {
39+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
40+ }
41+ },
42+ },
43+ {
44+ name: "ESC 7 / ESC 8 restore origin and insert modes",
45+ run: func(t *testing.T) {
46+ s := NewScreen(6, 5)
47+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
48+ s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
49+
50+ if !s.originMode {
51+ t.Fatal("origin mode was not restored")
52+ }
53+ if !s.insertMode {
54+ t.Fatal("insert mode was not restored")
55+ }
56+
57+ s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
58+ if s.Row != 1 || s.Col != 4 {
59+ t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
60+ }
61+ if got := lineText(s, 1); got != "abXYcd" {
62+ t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
63+ }
64+ },
65+ },
66+ {
67+ name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
68+ run: func(t *testing.T) {
69+ s := NewScreen(5, 3)
70+ s.Write([]byte("A\x1bDB\x1bEC"))
71+
72+ assertCell(t, s, 0, 0, 'A')
73+ assertCell(t, s, 1, 1, 'B')
74+ assertCell(t, s, 0, 2, 'C')
75+
76+ s = NewScreen(5, 3)
77+ s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
78+ assertCell(t, s, 0, 0, 't')
79+ assertCell(t, s, 0, 1, 'm')
80+ },
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, tt.run)
86+ }
87+}
88+
89+func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
90+ s := NewScreen(10, 2)
91+ s.Write([]byte("\x1b[?2026h"))
92+ if !s.SyncUpdateActive() {
93+ t.Fatal("sync update mode should be active")
94+ }
95+ s.Write([]byte("\x1b[?2026l"))
96+ if s.SyncUpdateActive() {
97+ t.Fatal("sync update mode should be inactive")
98+ }
99+}
100+
101+func TestForceSyncEnd(t *testing.T) {
102+ s := NewScreen(10, 2)
103+ s.Write([]byte("\x1b[?2026h"))
104+ if !s.SyncUpdateActive() {
105+ t.Fatal("sync update mode should be active before forcing end")
106+ }
107+ s.ForceSyncEnd()
108+ if s.SyncUpdateActive() {
109+ t.Fatal("sync update mode should be inactive after forcing end")
110+ }
111+}
112+
113+func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
114+ tests := []struct {
115+ name string
116+ seq string
117+ check func(t *testing.T, s *Screen)
118+ }{
119+ {
120+ name: "cursor position and visibility are exposed",
121+ seq: "\x1b[2;3H\x1b[?25l",
122+ check: func(t *testing.T, s *Screen) {
123+ if s.CursorRow() != 1 || s.CursorCol() != 2 {
124+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
125+ }
126+ if s.CursorVisible() {
127+ t.Fatal("cursor should be hidden")
128+ }
129+ s.Write([]byte("\x1b[?25h"))
130+ if !s.CursorVisible() {
131+ t.Fatal("cursor should be visible")
132+ }
133+ },
134+ },
135+ {
136+ name: "mouse mode and SGR are exposed",
137+ seq: "\x1b[?1002h\x1b[?1006h",
138+ check: func(t *testing.T, s *Screen) {
139+ mode, sgr := s.MouseMode()
140+ if mode != 1002 || !sgr {
141+ t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
142+ }
143+ },
144+ },
145+ {
146+ name: "reset restores cursor and mouse defaults",
147+ seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
148+ check: func(t *testing.T, s *Screen) {
149+ mode, sgr := s.MouseMode()
150+ if !s.CursorVisible() || mode != 0 || sgr {
151+ t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
152+ }
153+ },
154+ },
155+ }
156+ for _, tt := range tests {
157+ t.Run(tt.name, func(t *testing.T) {
158+ s := NewScreen(10, 4)
159+ s.Write([]byte(tt.seq))
160+ tt.check(t, s)
161+ })
162+ }
163+}
164+
165+func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
166+ s := NewScreen(10, 2)
167+ s.Write([]byte("\x1b[?1002h"))
168+ s.Write([]byte("\x1b[?1000l"))
169+ mode, _ := s.MouseMode()
170+ if mode != 1002 {
171+ t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
172+ }
173+ s.Write([]byte("\x1b[?1002l"))
174+ mode, _ = s.MouseMode()
175+ if mode != 0 {
176+ t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
177+ }
178+}
179+
180+func TestScreenCursorStyleDECSCUSR(t *testing.T) {
181+ tests := []struct {
182+ name string
183+ seq string
184+ wantStyle int
185+ wantSet bool
186+ }{
187+ {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
188+ {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
189+ {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false},
190+ {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false},
191+ {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
192+ }
193+ for _, tt := range tests {
194+ t.Run(tt.name, func(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ s.Write([]byte(tt.seq))
197+ style, set := s.CursorStyle()
198+ if style != tt.wantStyle || set != tt.wantSet {
199+ t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
200+ }
201+ })
202+ }
203+}
204+
205+func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ s.Write([]byte("\x1b[5 q"))
208+ s.Write([]byte("\x1b[99 q"))
209+ style, set := s.CursorStyle()
210+ if style != 5 || !set {
211+ t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set)
212+ }
213+}
214+
215+func TestScreenAltScreenActiveAccessor(t *testing.T) {
216+ s := NewScreen(10, 2)
217+ if s.AltScreenActive() {
218+ t.Fatal("alt screen should start inactive")
219+ }
220+ s.Write([]byte("\x1b[?1049h"))
221+ if !s.AltScreenActive() {
222+ t.Fatal("alt screen should be active")
223+ }
224+ s.Write([]byte("\x1b[?1049l"))
225+ if s.AltScreenActive() {
226+ t.Fatal("alt screen should be inactive after exit")
227+ }
228+}
@@ -0,0 +1,228 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+func TestESCSequences(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ run func(t *testing.T)
11+ }{
12+ {
13+ name: "ESC = (DECKPAM) is ignored, following text is printable",
14+ run: func(t *testing.T) {
15+ s := NewScreen(10, 2)
16+ s.Write([]byte("\x1b=abc"))
17+
18+ assertCell(t, s, 0, 0, 'a')
19+ assertCell(t, s, 1, 0, 'b')
20+ assertCell(t, s, 2, 0, 'c')
21+ if s.Col != 3 || s.Row != 0 {
22+ t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
23+ }
24+ },
25+ },
26+ {
27+ name: "ESC 7 / ESC 8 save and restore the cursor",
28+ run: func(t *testing.T) {
29+ s := NewScreen(10, 3)
30+ s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
31+
32+ assertCell(t, s, 0, 0, 'a')
33+ assertCell(t, s, 1, 0, 'b')
34+ assertCell(t, s, 2, 0, 'c')
35+ assertCell(t, s, 3, 0, 'X')
36+ assertCell(t, s, 4, 1, 'Z')
37+ assertCell(t, s, 5, 1, 'Z')
38+ if s.Row != 0 || s.Col != 4 {
39+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
40+ }
41+ },
42+ },
43+ {
44+ name: "ESC 7 / ESC 8 restore origin and insert modes",
45+ run: func(t *testing.T) {
46+ s := NewScreen(6, 5)
47+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
48+ s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
49+
50+ if !s.originMode {
51+ t.Fatal("origin mode was not restored")
52+ }
53+ if !s.insertMode {
54+ t.Fatal("insert mode was not restored")
55+ }
56+
57+ s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
58+ if s.Row != 1 || s.Col != 4 {
59+ t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
60+ }
61+ if got := lineText(s, 1); got != "abXYcd" {
62+ t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
63+ }
64+ },
65+ },
66+ {
67+ name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
68+ run: func(t *testing.T) {
69+ s := NewScreen(5, 3)
70+ s.Write([]byte("A\x1bDB\x1bEC"))
71+
72+ assertCell(t, s, 0, 0, 'A')
73+ assertCell(t, s, 1, 1, 'B')
74+ assertCell(t, s, 0, 2, 'C')
75+
76+ s = NewScreen(5, 3)
77+ s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
78+ assertCell(t, s, 0, 0, 't')
79+ assertCell(t, s, 0, 1, 'm')
80+ },
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, tt.run)
86+ }
87+}
88+
89+func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
90+ s := NewScreen(10, 2)
91+ s.Write([]byte("\x1b[?2026h"))
92+ if !s.SyncUpdateActive() {
93+ t.Fatal("sync update mode should be active")
94+ }
95+ s.Write([]byte("\x1b[?2026l"))
96+ if s.SyncUpdateActive() {
97+ t.Fatal("sync update mode should be inactive")
98+ }
99+}
100+
101+func TestForceSyncEnd(t *testing.T) {
102+ s := NewScreen(10, 2)
103+ s.Write([]byte("\x1b[?2026h"))
104+ if !s.SyncUpdateActive() {
105+ t.Fatal("sync update mode should be active before forcing end")
106+ }
107+ s.ForceSyncEnd()
108+ if s.SyncUpdateActive() {
109+ t.Fatal("sync update mode should be inactive after forcing end")
110+ }
111+}
112+
113+func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
114+ tests := []struct {
115+ name string
116+ seq string
117+ check func(t *testing.T, s *Screen)
118+ }{
119+ {
120+ name: "cursor position and visibility are exposed",
121+ seq: "\x1b[2;3H\x1b[?25l",
122+ check: func(t *testing.T, s *Screen) {
123+ if s.CursorRow() != 1 || s.CursorCol() != 2 {
124+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
125+ }
126+ if s.CursorVisible() {
127+ t.Fatal("cursor should be hidden")
128+ }
129+ s.Write([]byte("\x1b[?25h"))
130+ if !s.CursorVisible() {
131+ t.Fatal("cursor should be visible")
132+ }
133+ },
134+ },
135+ {
136+ name: "mouse mode and SGR are exposed",
137+ seq: "\x1b[?1002h\x1b[?1006h",
138+ check: func(t *testing.T, s *Screen) {
139+ mode, sgr := s.MouseMode()
140+ if mode != 1002 || !sgr {
141+ t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
142+ }
143+ },
144+ },
145+ {
146+ name: "reset restores cursor and mouse defaults",
147+ seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
148+ check: func(t *testing.T, s *Screen) {
149+ mode, sgr := s.MouseMode()
150+ if !s.CursorVisible() || mode != 0 || sgr {
151+ t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
152+ }
153+ },
154+ },
155+ }
156+ for _, tt := range tests {
157+ t.Run(tt.name, func(t *testing.T) {
158+ s := NewScreen(10, 4)
159+ s.Write([]byte(tt.seq))
160+ tt.check(t, s)
161+ })
162+ }
163+}
164+
165+func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
166+ s := NewScreen(10, 2)
167+ s.Write([]byte("\x1b[?1002h"))
168+ s.Write([]byte("\x1b[?1000l"))
169+ mode, _ := s.MouseMode()
170+ if mode != 1002 {
171+ t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
172+ }
173+ s.Write([]byte("\x1b[?1002l"))
174+ mode, _ = s.MouseMode()
175+ if mode != 0 {
176+ t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
177+ }
178+}
179+
180+func TestScreenCursorStyleDECSCUSR(t *testing.T) {
181+ tests := []struct {
182+ name string
183+ seq string
184+ wantStyle int
185+ wantSet bool
186+ }{
187+ {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
188+ {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
189+ {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false},
190+ {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false},
191+ {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
192+ }
193+ for _, tt := range tests {
194+ t.Run(tt.name, func(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ s.Write([]byte(tt.seq))
197+ style, set := s.CursorStyle()
198+ if style != tt.wantStyle || set != tt.wantSet {
199+ t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
200+ }
201+ })
202+ }
203+}
204+
205+func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ s.Write([]byte("\x1b[5 q"))
208+ s.Write([]byte("\x1b[99 q"))
209+ style, set := s.CursorStyle()
210+ if style != 5 || !set {
211+ t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set)
212+ }
213+}
214+
215+func TestScreenAltScreenActiveAccessor(t *testing.T) {
216+ s := NewScreen(10, 2)
217+ if s.AltScreenActive() {
218+ t.Fatal("alt screen should start inactive")
219+ }
220+ s.Write([]byte("\x1b[?1049h"))
221+ if !s.AltScreenActive() {
222+ t.Fatal("alt screen should be active")
223+ }
224+ s.Write([]byte("\x1b[?1049l"))
225+ if s.AltScreenActive() {
226+ t.Fatal("alt screen should be inactive after exit")
227+ }
228+}
A pkg/vt/screen_osc_test.go +429 -0
@@ -0,0 +1,429 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestOnBellFiresForLoneBEL(t *testing.T) {
11+ s := NewScreen(10, 2)
12+ rang := 0
13+ s.OnBell = func() { rang++ }
14+ s.Write([]byte("hi\ahi\a"))
15+ require.Equal(t, 2, rang)
16+ require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
17+}
18+
19+func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20+ s := NewScreen(10, 2)
21+ rang := 0
22+ s.OnBell = func() { rang++ }
23+ s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24+ require.Equal(t, 0, rang)
25+}
26+
27+func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28+ s := NewScreen(10, 2)
29+ s.Write([]byte("a\ab")) // no OnBell set: must not panic
30+ require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
31+}
32+
33+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ seq string
37+ want string
38+ }{
39+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ s := NewScreen(10, 2)
48+ bells := 0
49+ s.OnBell = func() { bells++ }
50+ s.ClearDamage()
51+
52+ s.Write([]byte(tt.seq))
53+
54+ require.Equal(t, tt.want, s.TerminalTitle())
55+ require.Equal(t, 0, bells)
56+ require.Empty(t, s.Damage())
57+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
58+ })
59+ }
60+}
61+
62+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63+ s := NewScreen(10, 2)
64+
65+ s.Write([]byte("\x1b]0;split"))
66+ s.Write([]byte(" title\x1b"))
67+ require.Empty(t, s.TerminalTitle())
68+ s.Write([]byte("\\"))
69+ require.Equal(t, "split title", s.TerminalTitle())
70+
71+ s.Write([]byte("\x1b]2;replacement\x07"))
72+ require.Equal(t, "replacement", s.TerminalTitle())
73+
74+ s.Write([]byte("\x1b]0;\x07"))
75+ require.Empty(t, s.TerminalTitle())
76+}
77+
78+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79+ s := NewScreen(10, 2)
80+ s.Write([]byte("\x1b]0;retained\x07"))
81+
82+ for _, seq := range []string{
83+ "\x1b]1;icon title\x07",
84+ "\x1b]20;not selector two\x1b\\",
85+ "\x1b]777;other;x;y\x07",
86+ } {
87+ s.Write([]byte(seq))
88+ require.Equal(t, "retained", s.TerminalTitle())
89+ }
90+
91+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
92+ s.Write([]byte("\x1b]2;" + payload))
93+ require.Equal(t, "retained", s.TerminalTitle())
94+ require.Empty(t, s.escapeBuf)
95+}
96+
97+func TestOnNotifyOSC9(t *testing.T) {
98+ s := NewScreen(10, 2)
99+ var gotTitle, gotBody string
100+ calls := 0
101+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102+ s.Write([]byte("\x1b]9;agent done\x07"))
103+ require.Equal(t, 1, calls)
104+ require.Equal(t, "", gotTitle)
105+ require.Equal(t, "agent done", gotBody)
106+}
107+
108+func TestOSC9ProgressTransitions(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ seqs []string
112+ want []bool
113+ }{
114+ {
115+ name: "finish fires once",
116+ seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117+ want: []bool{false},
118+ },
119+ {
120+ name: "error fires once repeat no fire",
121+ seqs: []string{"9;4;2;0", "9;4;2;50"},
122+ want: []bool{true},
123+ },
124+ {
125+ name: "indeterminate clear fires",
126+ seqs: []string{"9;4;3", "9;4;0"},
127+ want: []bool{false},
128+ },
129+ {
130+ name: "paused clear fires",
131+ seqs: []string{"9;4;4;80", "9;4;0;80"},
132+ want: []bool{false},
133+ },
134+ {
135+ name: "ongoing updates silent",
136+ seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137+ },
138+ {
139+ name: "bare clear silent",
140+ seqs: []string{"9;4;0"},
141+ },
142+ {
143+ name: "bare progress ignored",
144+ seqs: []string{"9;4"},
145+ },
146+ {
147+ name: "unparseable ignored",
148+ seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149+ want: []bool{false},
150+ },
151+ {
152+ name: "error active clear fires error then finish",
153+ seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154+ want: []bool{true, false},
155+ },
156+ {
157+ name: "error clear only fires error",
158+ seqs: []string{"9;4;2;0", "9;4;0;100"},
159+ want: []bool{true},
160+ },
161+ }
162+
163+ for _, tt := range tests {
164+ t.Run(tt.name, func(t *testing.T) {
165+ s := NewScreen(10, 2)
166+ var got []bool
167+ s.OnProgress = func(errored bool) { got = append(got, errored) }
168+ for _, seq := range tt.seqs {
169+ s.Write([]byte("\x1b]" + seq + "\x07"))
170+ }
171+ require.Equal(t, tt.want, got)
172+ })
173+ }
174+}
175+
176+func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177+ s := NewScreen(10, 2)
178+ calls := 0
179+ s.OnProgress = func(bool) { calls++ }
180+ s.Write([]byte("\x1b]9;4;1"))
181+ s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182+ require.Equal(t, 1, calls)
183+}
184+
185+func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186+ s := NewScreen(10, 2)
187+ require.NotPanics(t, func() {
188+ s.Write([]byte("\x1b]9;4;1;50\x07"))
189+ s.Write([]byte("\x1b]9;4;0;100\x07"))
190+ s.Write([]byte("\x1b]9;4;2;0\x07"))
191+ })
192+}
193+
194+func TestOSC9ProgressSTTerminated(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ var got []bool
197+ s.OnProgress = func(errored bool) { got = append(got, errored) }
198+ s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199+ require.Equal(t, []bool{false}, got)
200+}
201+
202+func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203+ s := NewScreen(10, 2)
204+ s.Write([]byte("\x1b]9;4;1;50\x07"))
205+ var got []bool
206+ s.OnProgress = func(errored bool) { got = append(got, errored) }
207+ s.Write([]byte("\x1b]9;4;0;100\x07"))
208+ require.Equal(t, []bool{false}, got)
209+}
210+
211+func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212+ s := NewScreen(10, 2)
213+ calls := 0
214+ s.OnNotify = func(string, string) { calls++ }
215+ s.Write([]byte("\x1b]9;4;1;50\x07"))
216+ s.Write([]byte("\x1b]9;4;0;100\x07"))
217+ s.Write([]byte("\x1b]9;4;2;0\x07"))
218+ require.Equal(t, 0, calls)
219+}
220+
221+func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222+ s := NewScreen(10, 2)
223+ var gotTitle, gotBody string
224+ calls := 0
225+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226+ s.Write([]byte("\x1b]9;40;not progress\x07"))
227+ require.Equal(t, 1, calls)
228+ require.Equal(t, "", gotTitle)
229+ require.Equal(t, "40;not progress", gotBody)
230+}
231+
232+func TestOnNotifyOSC777STTerminated(t *testing.T) {
233+ s := NewScreen(10, 2)
234+ var gotTitle, gotBody string
235+ calls := 0
236+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237+ s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238+ require.Equal(t, 1, calls)
239+ require.Equal(t, "Claude", gotTitle)
240+ require.Equal(t, "needs input", gotBody)
241+}
242+
243+func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244+ s := NewScreen(10, 2)
245+ calls := 0
246+ s.OnNotify = func(string, string) { calls++ }
247+ s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
248+ s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249+ require.Equal(t, 0, calls)
250+}
251+
252+func TestOnNotifySplitAcrossWrites(t *testing.T) {
253+ s := NewScreen(10, 2)
254+ calls := 0
255+ s.OnNotify = func(string, string) { calls++ }
256+ s.Write([]byte("\x1b]9;par"))
257+ s.Write([]byte("tial\x07"))
258+ require.Equal(t, 1, calls)
259+}
260+
261+func TestOnClipboardBELTerminated(t *testing.T) {
262+ s := NewScreen(10, 2)
263+ var got string
264+ calls := 0
265+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
266+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267+ require.Equal(t, 1, calls)
268+ require.Equal(t, "aGVsbG8=", got)
269+}
270+
271+func TestOnClipboardSTTerminated(t *testing.T) {
272+ s := NewScreen(10, 2)
273+ var got string
274+ calls := 0
275+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
276+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277+ require.Equal(t, 1, calls)
278+ require.Equal(t, "aGVsbG8=", got)
279+}
280+
281+func TestOnClipboardQueryIgnored(t *testing.T) {
282+ s := NewScreen(10, 2)
283+ calls := 0
284+ s.OnClipboard = func(string) { calls++ }
285+ s.Write([]byte("\x1b]52;c;?\x07"))
286+ require.Equal(t, 0, calls)
287+}
288+
289+func TestOnClipboardEmptySelection(t *testing.T) {
290+ s := NewScreen(10, 2)
291+ var got string
292+ calls := 0
293+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
294+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295+ require.Equal(t, 1, calls)
296+ require.Equal(t, "aGVsbG8=", got)
297+}
298+
299+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300+ s := NewScreen(10, 2)
301+ require.NotPanics(t, func() {
302+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303+ })
304+}
305+
306+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307+ s := NewScreen(10, 2)
308+ var got string
309+ calls := 0
310+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
311+ s.Write([]byte("\x1b]52;c;aGVs"))
312+ s.Write([]byte("bG8=\x07"))
313+ require.Equal(t, 1, calls)
314+ require.Equal(t, "aGVsbG8=", got)
315+}
316+
317+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318+ s := NewScreen(10, 2)
319+ calls := 0
320+ s.OnClipboard = func(string) { calls++ }
321+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322+ require.Equal(t, 0, calls)
323+}
324+
325+func TestOSCSequences(t *testing.T) {
326+ tests := []struct {
327+ name string
328+ run func(t *testing.T)
329+ }{
330+ {
331+ name: "BEL-terminated OSC is ignored",
332+ run: func(t *testing.T) {
333+ s := NewScreen(20, 2)
334+ s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335+
336+ assertCell(t, s, 0, 0, 'f')
337+ assertCell(t, s, 1, 0, 'i')
338+ assertCell(t, s, 2, 0, 's')
339+ assertCell(t, s, 3, 0, 'h')
340+ assertCell(t, s, 4, 0, '$')
341+ assertCell(t, s, 5, 0, ' ')
342+ if s.Col != 6 || s.Row != 0 {
343+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344+ }
345+ },
346+ },
347+ {
348+ name: "ST-terminated OSC is ignored",
349+ run: func(t *testing.T) {
350+ s := NewScreen(20, 2)
351+ s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352+
353+ assertCell(t, s, 0, 0, '>')
354+ assertCell(t, s, 1, 0, ' ')
355+ if s.Col != 2 || s.Row != 0 {
356+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357+ }
358+ },
359+ },
360+ {
361+ name: "multiple OSC sequences in a row are all ignored",
362+ run: func(t *testing.T) {
363+ s := NewScreen(20, 2)
364+ s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365+
366+ assertCell(t, s, 0, 0, 'p')
367+ assertCell(t, s, 1, 0, 'r')
368+ assertCell(t, s, 2, 0, 'o')
369+ assertCell(t, s, 3, 0, 'm')
370+ assertCell(t, s, 4, 0, 'p')
371+ assertCell(t, s, 5, 0, 't')
372+ if s.Col != 6 || s.Row != 0 {
373+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374+ }
375+ },
376+ },
377+ {
378+ name: "BEL-terminated OSC split across writes is ignored",
379+ run: func(t *testing.T) {
380+ s := NewScreen(20, 2)
381+ s.Write([]byte("\x1b]0;~/Projects/ymux"))
382+ s.Write([]byte(" - fish\x07fish$ "))
383+
384+ assertCell(t, s, 0, 0, 'f')
385+ assertCell(t, s, 1, 0, 'i')
386+ assertCell(t, s, 2, 0, 's')
387+ assertCell(t, s, 3, 0, 'h')
388+ assertCell(t, s, 4, 0, '$')
389+ assertCell(t, s, 5, 0, ' ')
390+ if s.Col != 6 || s.Row != 0 {
391+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392+ }
393+ },
394+ },
395+ {
396+ name: "ST-terminated OSC split across writes is ignored",
397+ run: func(t *testing.T) {
398+ s := NewScreen(20, 2)
399+ s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400+ s.Write([]byte("\\> "))
401+
402+ assertCell(t, s, 0, 0, '>')
403+ assertCell(t, s, 1, 0, ' ')
404+ if s.Col != 2 || s.Row != 0 {
405+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406+ }
407+ },
408+ },
409+ {
410+ name: "unterminated OSC sequence is bounded and dropped",
411+ run: func(t *testing.T) {
412+ s := NewScreen(20, 2)
413+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
414+ s.Write([]byte("\x1b]0;" + payload))
415+ s.Write([]byte("OK"))
416+
417+ assertCell(t, s, 0, 0, 'O')
418+ assertCell(t, s, 1, 0, 'K')
419+ if len(s.escapeBuf) != 0 {
420+ t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421+ }
422+ },
423+ },
424+ }
425+
426+ for _, tt := range tests {
427+ t.Run(tt.name, tt.run)
428+ }
429+}
@@ -0,0 +1,429 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestOnBellFiresForLoneBEL(t *testing.T) {
11+ s := NewScreen(10, 2)
12+ rang := 0
13+ s.OnBell = func() { rang++ }
14+ s.Write([]byte("hi\ahi\a"))
15+ require.Equal(t, 2, rang)
16+ require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
17+}
18+
19+func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20+ s := NewScreen(10, 2)
21+ rang := 0
22+ s.OnBell = func() { rang++ }
23+ s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24+ require.Equal(t, 0, rang)
25+}
26+
27+func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28+ s := NewScreen(10, 2)
29+ s.Write([]byte("a\ab")) // no OnBell set: must not panic
30+ require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
31+}
32+
33+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ seq string
37+ want string
38+ }{
39+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ s := NewScreen(10, 2)
48+ bells := 0
49+ s.OnBell = func() { bells++ }
50+ s.ClearDamage()
51+
52+ s.Write([]byte(tt.seq))
53+
54+ require.Equal(t, tt.want, s.TerminalTitle())
55+ require.Equal(t, 0, bells)
56+ require.Empty(t, s.Damage())
57+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
58+ })
59+ }
60+}
61+
62+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63+ s := NewScreen(10, 2)
64+
65+ s.Write([]byte("\x1b]0;split"))
66+ s.Write([]byte(" title\x1b"))
67+ require.Empty(t, s.TerminalTitle())
68+ s.Write([]byte("\\"))
69+ require.Equal(t, "split title", s.TerminalTitle())
70+
71+ s.Write([]byte("\x1b]2;replacement\x07"))
72+ require.Equal(t, "replacement", s.TerminalTitle())
73+
74+ s.Write([]byte("\x1b]0;\x07"))
75+ require.Empty(t, s.TerminalTitle())
76+}
77+
78+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79+ s := NewScreen(10, 2)
80+ s.Write([]byte("\x1b]0;retained\x07"))
81+
82+ for _, seq := range []string{
83+ "\x1b]1;icon title\x07",
84+ "\x1b]20;not selector two\x1b\\",
85+ "\x1b]777;other;x;y\x07",
86+ } {
87+ s.Write([]byte(seq))
88+ require.Equal(t, "retained", s.TerminalTitle())
89+ }
90+
91+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
92+ s.Write([]byte("\x1b]2;" + payload))
93+ require.Equal(t, "retained", s.TerminalTitle())
94+ require.Empty(t, s.escapeBuf)
95+}
96+
97+func TestOnNotifyOSC9(t *testing.T) {
98+ s := NewScreen(10, 2)
99+ var gotTitle, gotBody string
100+ calls := 0
101+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102+ s.Write([]byte("\x1b]9;agent done\x07"))
103+ require.Equal(t, 1, calls)
104+ require.Equal(t, "", gotTitle)
105+ require.Equal(t, "agent done", gotBody)
106+}
107+
108+func TestOSC9ProgressTransitions(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ seqs []string
112+ want []bool
113+ }{
114+ {
115+ name: "finish fires once",
116+ seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117+ want: []bool{false},
118+ },
119+ {
120+ name: "error fires once repeat no fire",
121+ seqs: []string{"9;4;2;0", "9;4;2;50"},
122+ want: []bool{true},
123+ },
124+ {
125+ name: "indeterminate clear fires",
126+ seqs: []string{"9;4;3", "9;4;0"},
127+ want: []bool{false},
128+ },
129+ {
130+ name: "paused clear fires",
131+ seqs: []string{"9;4;4;80", "9;4;0;80"},
132+ want: []bool{false},
133+ },
134+ {
135+ name: "ongoing updates silent",
136+ seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137+ },
138+ {
139+ name: "bare clear silent",
140+ seqs: []string{"9;4;0"},
141+ },
142+ {
143+ name: "bare progress ignored",
144+ seqs: []string{"9;4"},
145+ },
146+ {
147+ name: "unparseable ignored",
148+ seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149+ want: []bool{false},
150+ },
151+ {
152+ name: "error active clear fires error then finish",
153+ seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154+ want: []bool{true, false},
155+ },
156+ {
157+ name: "error clear only fires error",
158+ seqs: []string{"9;4;2;0", "9;4;0;100"},
159+ want: []bool{true},
160+ },
161+ }
162+
163+ for _, tt := range tests {
164+ t.Run(tt.name, func(t *testing.T) {
165+ s := NewScreen(10, 2)
166+ var got []bool
167+ s.OnProgress = func(errored bool) { got = append(got, errored) }
168+ for _, seq := range tt.seqs {
169+ s.Write([]byte("\x1b]" + seq + "\x07"))
170+ }
171+ require.Equal(t, tt.want, got)
172+ })
173+ }
174+}
175+
176+func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177+ s := NewScreen(10, 2)
178+ calls := 0
179+ s.OnProgress = func(bool) { calls++ }
180+ s.Write([]byte("\x1b]9;4;1"))
181+ s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182+ require.Equal(t, 1, calls)
183+}
184+
185+func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186+ s := NewScreen(10, 2)
187+ require.NotPanics(t, func() {
188+ s.Write([]byte("\x1b]9;4;1;50\x07"))
189+ s.Write([]byte("\x1b]9;4;0;100\x07"))
190+ s.Write([]byte("\x1b]9;4;2;0\x07"))
191+ })
192+}
193+
194+func TestOSC9ProgressSTTerminated(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ var got []bool
197+ s.OnProgress = func(errored bool) { got = append(got, errored) }
198+ s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199+ require.Equal(t, []bool{false}, got)
200+}
201+
202+func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203+ s := NewScreen(10, 2)
204+ s.Write([]byte("\x1b]9;4;1;50\x07"))
205+ var got []bool
206+ s.OnProgress = func(errored bool) { got = append(got, errored) }
207+ s.Write([]byte("\x1b]9;4;0;100\x07"))
208+ require.Equal(t, []bool{false}, got)
209+}
210+
211+func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212+ s := NewScreen(10, 2)
213+ calls := 0
214+ s.OnNotify = func(string, string) { calls++ }
215+ s.Write([]byte("\x1b]9;4;1;50\x07"))
216+ s.Write([]byte("\x1b]9;4;0;100\x07"))
217+ s.Write([]byte("\x1b]9;4;2;0\x07"))
218+ require.Equal(t, 0, calls)
219+}
220+
221+func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222+ s := NewScreen(10, 2)
223+ var gotTitle, gotBody string
224+ calls := 0
225+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226+ s.Write([]byte("\x1b]9;40;not progress\x07"))
227+ require.Equal(t, 1, calls)
228+ require.Equal(t, "", gotTitle)
229+ require.Equal(t, "40;not progress", gotBody)
230+}
231+
232+func TestOnNotifyOSC777STTerminated(t *testing.T) {
233+ s := NewScreen(10, 2)
234+ var gotTitle, gotBody string
235+ calls := 0
236+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237+ s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238+ require.Equal(t, 1, calls)
239+ require.Equal(t, "Claude", gotTitle)
240+ require.Equal(t, "needs input", gotBody)
241+}
242+
243+func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244+ s := NewScreen(10, 2)
245+ calls := 0
246+ s.OnNotify = func(string, string) { calls++ }
247+ s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
248+ s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249+ require.Equal(t, 0, calls)
250+}
251+
252+func TestOnNotifySplitAcrossWrites(t *testing.T) {
253+ s := NewScreen(10, 2)
254+ calls := 0
255+ s.OnNotify = func(string, string) { calls++ }
256+ s.Write([]byte("\x1b]9;par"))
257+ s.Write([]byte("tial\x07"))
258+ require.Equal(t, 1, calls)
259+}
260+
261+func TestOnClipboardBELTerminated(t *testing.T) {
262+ s := NewScreen(10, 2)
263+ var got string
264+ calls := 0
265+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
266+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267+ require.Equal(t, 1, calls)
268+ require.Equal(t, "aGVsbG8=", got)
269+}
270+
271+func TestOnClipboardSTTerminated(t *testing.T) {
272+ s := NewScreen(10, 2)
273+ var got string
274+ calls := 0
275+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
276+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277+ require.Equal(t, 1, calls)
278+ require.Equal(t, "aGVsbG8=", got)
279+}
280+
281+func TestOnClipboardQueryIgnored(t *testing.T) {
282+ s := NewScreen(10, 2)
283+ calls := 0
284+ s.OnClipboard = func(string) { calls++ }
285+ s.Write([]byte("\x1b]52;c;?\x07"))
286+ require.Equal(t, 0, calls)
287+}
288+
289+func TestOnClipboardEmptySelection(t *testing.T) {
290+ s := NewScreen(10, 2)
291+ var got string
292+ calls := 0
293+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
294+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295+ require.Equal(t, 1, calls)
296+ require.Equal(t, "aGVsbG8=", got)
297+}
298+
299+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300+ s := NewScreen(10, 2)
301+ require.NotPanics(t, func() {
302+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303+ })
304+}
305+
306+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307+ s := NewScreen(10, 2)
308+ var got string
309+ calls := 0
310+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
311+ s.Write([]byte("\x1b]52;c;aGVs"))
312+ s.Write([]byte("bG8=\x07"))
313+ require.Equal(t, 1, calls)
314+ require.Equal(t, "aGVsbG8=", got)
315+}
316+
317+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318+ s := NewScreen(10, 2)
319+ calls := 0
320+ s.OnClipboard = func(string) { calls++ }
321+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322+ require.Equal(t, 0, calls)
323+}
324+
325+func TestOSCSequences(t *testing.T) {
326+ tests := []struct {
327+ name string
328+ run func(t *testing.T)
329+ }{
330+ {
331+ name: "BEL-terminated OSC is ignored",
332+ run: func(t *testing.T) {
333+ s := NewScreen(20, 2)
334+ s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335+
336+ assertCell(t, s, 0, 0, 'f')
337+ assertCell(t, s, 1, 0, 'i')
338+ assertCell(t, s, 2, 0, 's')
339+ assertCell(t, s, 3, 0, 'h')
340+ assertCell(t, s, 4, 0, '$')
341+ assertCell(t, s, 5, 0, ' ')
342+ if s.Col != 6 || s.Row != 0 {
343+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344+ }
345+ },
346+ },
347+ {
348+ name: "ST-terminated OSC is ignored",
349+ run: func(t *testing.T) {
350+ s := NewScreen(20, 2)
351+ s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352+
353+ assertCell(t, s, 0, 0, '>')
354+ assertCell(t, s, 1, 0, ' ')
355+ if s.Col != 2 || s.Row != 0 {
356+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357+ }
358+ },
359+ },
360+ {
361+ name: "multiple OSC sequences in a row are all ignored",
362+ run: func(t *testing.T) {
363+ s := NewScreen(20, 2)
364+ s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365+
366+ assertCell(t, s, 0, 0, 'p')
367+ assertCell(t, s, 1, 0, 'r')
368+ assertCell(t, s, 2, 0, 'o')
369+ assertCell(t, s, 3, 0, 'm')
370+ assertCell(t, s, 4, 0, 'p')
371+ assertCell(t, s, 5, 0, 't')
372+ if s.Col != 6 || s.Row != 0 {
373+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374+ }
375+ },
376+ },
377+ {
378+ name: "BEL-terminated OSC split across writes is ignored",
379+ run: func(t *testing.T) {
380+ s := NewScreen(20, 2)
381+ s.Write([]byte("\x1b]0;~/Projects/ymux"))
382+ s.Write([]byte(" - fish\x07fish$ "))
383+
384+ assertCell(t, s, 0, 0, 'f')
385+ assertCell(t, s, 1, 0, 'i')
386+ assertCell(t, s, 2, 0, 's')
387+ assertCell(t, s, 3, 0, 'h')
388+ assertCell(t, s, 4, 0, '$')
389+ assertCell(t, s, 5, 0, ' ')
390+ if s.Col != 6 || s.Row != 0 {
391+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392+ }
393+ },
394+ },
395+ {
396+ name: "ST-terminated OSC split across writes is ignored",
397+ run: func(t *testing.T) {
398+ s := NewScreen(20, 2)
399+ s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400+ s.Write([]byte("\\> "))
401+
402+ assertCell(t, s, 0, 0, '>')
403+ assertCell(t, s, 1, 0, ' ')
404+ if s.Col != 2 || s.Row != 0 {
405+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406+ }
407+ },
408+ },
409+ {
410+ name: "unterminated OSC sequence is bounded and dropped",
411+ run: func(t *testing.T) {
412+ s := NewScreen(20, 2)
413+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
414+ s.Write([]byte("\x1b]0;" + payload))
415+ s.Write([]byte("OK"))
416+
417+ assertCell(t, s, 0, 0, 'O')
418+ assertCell(t, s, 1, 0, 'K')
419+ if len(s.escapeBuf) != 0 {
420+ t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421+ }
422+ },
423+ },
424+ }
425+
426+ for _, tt := range tests {
427+ t.Run(tt.name, tt.run)
428+ }
429+}
A pkg/vt/screen_resize_test.go +260 -0
@@ -0,0 +1,260 @@
1+package vt
2+
3+import (
4+ "math/rand"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestResize(t *testing.T) {
12+ setRow := func(s *Screen, y int, text string) {
13+ for x, r := range []rune(text) {
14+ if x >= s.Frame.Width {
15+ break
16+ }
17+ s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
18+ }
19+ }
20+ rowString := func(s *Screen, y int) string {
21+ runes := make([]rune, s.Frame.Width)
22+ for x := range s.Frame.Width {
23+ runes[x] = s.Frame.At(x, y).Rune
24+ }
25+ return string(runes)
26+ }
27+
28+ tests := []struct {
29+ name string
30+ run func(t *testing.T)
31+ }{
32+ {
33+ name: "same size preserves content and skips FullRedraw",
34+ run: func(t *testing.T) {
35+ s := NewScreen(5, 3)
36+ s.ClearDamage()
37+ setRow(s, 0, "Hello")
38+
39+ s.Resize(5, 3)
40+
41+ if got := rowString(s, 0); got != "Hello" {
42+ t.Fatalf("row 0 = %q, want Hello", got)
43+ }
44+ if d := s.Damage(); len(d) != 0 {
45+ t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
46+ }
47+ },
48+ },
49+ {
50+ name: "grow preserves visible content and cursor",
51+ run: func(t *testing.T) {
52+ s := NewScreen(5, 2)
53+ setRow(s, 0, "Hello")
54+ setRow(s, 1, "World")
55+ s.Row, s.Col = 1, 4
56+
57+ s.Resize(8, 4)
58+
59+ if got := rowString(s, 0); got != "Hello " {
60+ t.Fatalf("row 0 = %q", got)
61+ }
62+ if got := rowString(s, 1); got != "World " {
63+ t.Fatalf("row 1 = %q", got)
64+ }
65+ if s.Row != 1 || s.Col != 4 {
66+ t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
67+ }
68+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
69+ t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
70+ }
71+ },
72+ },
73+ {
74+ name: "height shrink evicts top lines and follows cursor",
75+ run: func(t *testing.T) {
76+ s := NewScreen(4, 5)
77+ for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
78+ setRow(s, y, text)
79+ }
80+ s.Row, s.Col = 4, 2
81+ var evicted []string
82+ s.OnLineEvicted = func(row []renderer.Cell) {
83+ runes := make([]rune, len(row))
84+ for i, c := range row {
85+ runes[i] = c.Rune
86+ }
87+ evicted = append(evicted, string(runes))
88+ }
89+
90+ s.Resize(4, 3)
91+
92+ if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
93+ t.Fatalf("evicted = %v, want %v", got, want)
94+ }
95+ if got := rowString(s, 0); got != "2222" {
96+ t.Fatalf("row 0 = %q", got)
97+ }
98+ if s.Row != 2 || s.Col != 2 {
99+ t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
100+ }
101+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
102+ t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
103+ }
104+ },
105+ },
106+ {
107+ name: "width truncates and pads without reflow",
108+ run: func(t *testing.T) {
109+ s := NewScreen(6, 2)
110+ setRow(s, 0, "abcdef")
111+ setRow(s, 1, "ghijkl")
112+
113+ s.Resize(3, 2)
114+ if got := rowString(s, 0); got != "abc" {
115+ t.Fatalf("truncated row = %q", got)
116+ }
117+ s.Resize(5, 2)
118+ if got := rowString(s, 1); got != "ghi " {
119+ t.Fatalf("padded row = %q", got)
120+ }
121+ },
122+ },
123+ {
124+ name: "width shrink repairs truncated wide head",
125+ run: func(t *testing.T) {
126+ s := NewScreen(4, 1)
127+ s.Write([]byte("AB界"))
128+
129+ s.Resize(3, 1)
130+
131+ assertCell(t, s, 0, 0, 'A')
132+ assertCell(t, s, 1, 0, 'B')
133+ assertBlank(t, s, 2, 0)
134+ assertNoOrphanWideCells(t, s)
135+ },
136+ },
137+ {
138+ name: "random wide resize repairs copied rows",
139+ run: func(t *testing.T) {
140+ rng := rand.New(rand.NewSource(1))
141+ for range 200 {
142+ s := NewScreen(8, 4)
143+ s.Write([]byte("a界b好c語d"))
144+ s.Row = rng.Intn(s.Frame.Height)
145+
146+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
147+
148+ assertNoOrphanWideCells(t, s)
149+ }
150+ },
151+ },
152+ {
153+ name: "cursor and saved cursor clamp",
154+ run: func(t *testing.T) {
155+ s := NewScreen(6, 4)
156+ s.Row, s.Col = 3, 5
157+ s.saveCursor()
158+
159+ s.Resize(3, 2)
160+
161+ if s.Row != 1 || s.Col != 2 {
162+ t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
163+ }
164+ s.Row, s.Col = 0, 0
165+ s.restoreCursor()
166+ if s.Row != 1 || s.Col != 2 {
167+ t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
168+ }
169+ },
170+ },
171+ {
172+ name: "alternate resize keeps saved normal content",
173+ run: func(t *testing.T) {
174+ s := NewScreen(5, 3)
175+ setRow(s, 0, "shell")
176+ s.Row = 0
177+ s.Write([]byte("\x1b[?1049h"))
178+ setRow(s, 0, "alt!!")
179+
180+ s.Resize(7, 4)
181+ s.Write([]byte("\x1b[?1049l"))
182+
183+ if got := rowString(s, 0); got != "shell " {
184+ t.Fatalf("normal row after alt resize = %q", got)
185+ }
186+ },
187+ },
188+ {
189+ name: "alternate resize shifts active and saved cursors",
190+ run: func(t *testing.T) {
191+ s := NewScreen(5, 5)
192+ s.Row, s.Col = 3, 4
193+ s.saveCursor()
194+ s.scrollTop, s.scrollBottom = 1, 4
195+ s.Row = 4
196+ s.Write([]byte("\x1b[?1049h"))
197+ s.Row, s.Col = 3, 4
198+ s.saveCursor()
199+ s.Row = 4
200+
201+ // The expected rows account for the resize shift that keeps the
202+ // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
203+ // not just width/height clamping.
204+ s.Resize(3, 3)
205+
206+ if s.Row != 2 || s.Col != 2 {
207+ t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
208+ }
209+ s.Row, s.Col = 0, 0
210+ s.restoreCursor()
211+ if s.Row != 1 || s.Col != 2 {
212+ t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
213+ }
214+
215+ s.Write([]byte("\x1b[?1049l"))
216+ if s.scrollTop != 0 || s.scrollBottom != 2 {
217+ t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
218+ }
219+ s.Row, s.Col = 0, 0
220+ s.restoreCursor()
221+ if s.Row != 1 || s.Col != 2 {
222+ t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
223+ }
224+ },
225+ },
226+ {
227+ name: "style mouse cursor and bracketed paste modes survive",
228+ run: func(t *testing.T) {
229+ s := NewScreen(5, 2)
230+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
231+
232+ s.Resize(6, 3)
233+
234+ if !s.Style.Bold {
235+ t.Fatal("bold style was reset")
236+ }
237+ if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
238+ t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
239+ }
240+ if !s.BracketedPasteMode() {
241+ t.Fatal("bracketed paste mode was reset")
242+ }
243+ if s.CursorVisible() {
244+ t.Fatal("cursor visibility was reset")
245+ }
246+ if style, ok := s.CursorStyle(); style != 3 || !ok {
247+ t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
248+ }
249+ s.Write([]byte("\x1b[?2004l"))
250+ if s.BracketedPasteMode() {
251+ t.Fatal("bracketed paste mode remained enabled after reset")
252+ }
253+ },
254+ },
255+ }
256+
257+ for _, tt := range tests {
258+ t.Run(tt.name, tt.run)
259+ }
260+}
@@ -0,0 +1,260 @@
1+package vt
2+
3+import (
4+ "math/rand"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestResize(t *testing.T) {
12+ setRow := func(s *Screen, y int, text string) {
13+ for x, r := range []rune(text) {
14+ if x >= s.Frame.Width {
15+ break
16+ }
17+ s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
18+ }
19+ }
20+ rowString := func(s *Screen, y int) string {
21+ runes := make([]rune, s.Frame.Width)
22+ for x := range s.Frame.Width {
23+ runes[x] = s.Frame.At(x, y).Rune
24+ }
25+ return string(runes)
26+ }
27+
28+ tests := []struct {
29+ name string
30+ run func(t *testing.T)
31+ }{
32+ {
33+ name: "same size preserves content and skips FullRedraw",
34+ run: func(t *testing.T) {
35+ s := NewScreen(5, 3)
36+ s.ClearDamage()
37+ setRow(s, 0, "Hello")
38+
39+ s.Resize(5, 3)
40+
41+ if got := rowString(s, 0); got != "Hello" {
42+ t.Fatalf("row 0 = %q, want Hello", got)
43+ }
44+ if d := s.Damage(); len(d) != 0 {
45+ t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
46+ }
47+ },
48+ },
49+ {
50+ name: "grow preserves visible content and cursor",
51+ run: func(t *testing.T) {
52+ s := NewScreen(5, 2)
53+ setRow(s, 0, "Hello")
54+ setRow(s, 1, "World")
55+ s.Row, s.Col = 1, 4
56+
57+ s.Resize(8, 4)
58+
59+ if got := rowString(s, 0); got != "Hello " {
60+ t.Fatalf("row 0 = %q", got)
61+ }
62+ if got := rowString(s, 1); got != "World " {
63+ t.Fatalf("row 1 = %q", got)
64+ }
65+ if s.Row != 1 || s.Col != 4 {
66+ t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
67+ }
68+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
69+ t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
70+ }
71+ },
72+ },
73+ {
74+ name: "height shrink evicts top lines and follows cursor",
75+ run: func(t *testing.T) {
76+ s := NewScreen(4, 5)
77+ for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
78+ setRow(s, y, text)
79+ }
80+ s.Row, s.Col = 4, 2
81+ var evicted []string
82+ s.OnLineEvicted = func(row []renderer.Cell) {
83+ runes := make([]rune, len(row))
84+ for i, c := range row {
85+ runes[i] = c.Rune
86+ }
87+ evicted = append(evicted, string(runes))
88+ }
89+
90+ s.Resize(4, 3)
91+
92+ if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
93+ t.Fatalf("evicted = %v, want %v", got, want)
94+ }
95+ if got := rowString(s, 0); got != "2222" {
96+ t.Fatalf("row 0 = %q", got)
97+ }
98+ if s.Row != 2 || s.Col != 2 {
99+ t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
100+ }
101+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
102+ t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
103+ }
104+ },
105+ },
106+ {
107+ name: "width truncates and pads without reflow",
108+ run: func(t *testing.T) {
109+ s := NewScreen(6, 2)
110+ setRow(s, 0, "abcdef")
111+ setRow(s, 1, "ghijkl")
112+
113+ s.Resize(3, 2)
114+ if got := rowString(s, 0); got != "abc" {
115+ t.Fatalf("truncated row = %q", got)
116+ }
117+ s.Resize(5, 2)
118+ if got := rowString(s, 1); got != "ghi " {
119+ t.Fatalf("padded row = %q", got)
120+ }
121+ },
122+ },
123+ {
124+ name: "width shrink repairs truncated wide head",
125+ run: func(t *testing.T) {
126+ s := NewScreen(4, 1)
127+ s.Write([]byte("AB界"))
128+
129+ s.Resize(3, 1)
130+
131+ assertCell(t, s, 0, 0, 'A')
132+ assertCell(t, s, 1, 0, 'B')
133+ assertBlank(t, s, 2, 0)
134+ assertNoOrphanWideCells(t, s)
135+ },
136+ },
137+ {
138+ name: "random wide resize repairs copied rows",
139+ run: func(t *testing.T) {
140+ rng := rand.New(rand.NewSource(1))
141+ for range 200 {
142+ s := NewScreen(8, 4)
143+ s.Write([]byte("a界b好c語d"))
144+ s.Row = rng.Intn(s.Frame.Height)
145+
146+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
147+
148+ assertNoOrphanWideCells(t, s)
149+ }
150+ },
151+ },
152+ {
153+ name: "cursor and saved cursor clamp",
154+ run: func(t *testing.T) {
155+ s := NewScreen(6, 4)
156+ s.Row, s.Col = 3, 5
157+ s.saveCursor()
158+
159+ s.Resize(3, 2)
160+
161+ if s.Row != 1 || s.Col != 2 {
162+ t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
163+ }
164+ s.Row, s.Col = 0, 0
165+ s.restoreCursor()
166+ if s.Row != 1 || s.Col != 2 {
167+ t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
168+ }
169+ },
170+ },
171+ {
172+ name: "alternate resize keeps saved normal content",
173+ run: func(t *testing.T) {
174+ s := NewScreen(5, 3)
175+ setRow(s, 0, "shell")
176+ s.Row = 0
177+ s.Write([]byte("\x1b[?1049h"))
178+ setRow(s, 0, "alt!!")
179+
180+ s.Resize(7, 4)
181+ s.Write([]byte("\x1b[?1049l"))
182+
183+ if got := rowString(s, 0); got != "shell " {
184+ t.Fatalf("normal row after alt resize = %q", got)
185+ }
186+ },
187+ },
188+ {
189+ name: "alternate resize shifts active and saved cursors",
190+ run: func(t *testing.T) {
191+ s := NewScreen(5, 5)
192+ s.Row, s.Col = 3, 4
193+ s.saveCursor()
194+ s.scrollTop, s.scrollBottom = 1, 4
195+ s.Row = 4
196+ s.Write([]byte("\x1b[?1049h"))
197+ s.Row, s.Col = 3, 4
198+ s.saveCursor()
199+ s.Row = 4
200+
201+ // The expected rows account for the resize shift that keeps the
202+ // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
203+ // not just width/height clamping.
204+ s.Resize(3, 3)
205+
206+ if s.Row != 2 || s.Col != 2 {
207+ t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
208+ }
209+ s.Row, s.Col = 0, 0
210+ s.restoreCursor()
211+ if s.Row != 1 || s.Col != 2 {
212+ t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
213+ }
214+
215+ s.Write([]byte("\x1b[?1049l"))
216+ if s.scrollTop != 0 || s.scrollBottom != 2 {
217+ t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
218+ }
219+ s.Row, s.Col = 0, 0
220+ s.restoreCursor()
221+ if s.Row != 1 || s.Col != 2 {
222+ t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
223+ }
224+ },
225+ },
226+ {
227+ name: "style mouse cursor and bracketed paste modes survive",
228+ run: func(t *testing.T) {
229+ s := NewScreen(5, 2)
230+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
231+
232+ s.Resize(6, 3)
233+
234+ if !s.Style.Bold {
235+ t.Fatal("bold style was reset")
236+ }
237+ if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
238+ t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
239+ }
240+ if !s.BracketedPasteMode() {
241+ t.Fatal("bracketed paste mode was reset")
242+ }
243+ if s.CursorVisible() {
244+ t.Fatal("cursor visibility was reset")
245+ }
246+ if style, ok := s.CursorStyle(); style != 3 || !ok {
247+ t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
248+ }
249+ s.Write([]byte("\x1b[?2004l"))
250+ if s.BracketedPasteMode() {
251+ t.Fatal("bracketed paste mode remained enabled after reset")
252+ }
253+ },
254+ },
255+ }
256+
257+ for _, tt := range tests {
258+ t.Run(tt.name, tt.run)
259+ }
260+}
A pkg/vt/screen_scroll_test.go +79 -0
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+// TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact
8+// with its line when a full-width scroll rotates line offsets rather than
9+// copying cells.
10+func TestWideCharSurvivesRotatedScroll(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "wide pair moves up one row on newline scroll",
17+ run: func(t *testing.T) {
18+ s := NewScreen(4, 3)
19+ // Place a wide rune (世, width 2) on the bottom row, then scroll up.
20+ s.Write([]byte("\x1b[3;1H世"))
21+ assertCell(t, s, 0, 2, '世')
22+ assertContinuation(t, s, 1, 2)
23+ // Newline at the bottom row scrolls the region up by one.
24+ s.Write([]byte("\n"))
25+ // The wide pair is now on row 1, intact; bottom row blank.
26+ assertCell(t, s, 0, 1, '世')
27+ assertContinuation(t, s, 1, 1)
28+ assertBlank(t, s, 0, 2)
29+ assertBlank(t, s, 1, 2)
30+ if err := s.Frame.CheckInvariants(); err != nil {
31+ t.Fatalf("invariants: %v", err)
32+ }
33+ },
34+ },
35+ {
36+ name: "wide pair moves down on reverse index",
37+ run: func(t *testing.T) {
38+ s := NewScreen(4, 3)
39+ s.Write([]byte("\x1b[1;1H世")) // top row
40+ assertCell(t, s, 0, 0, '世')
41+ assertContinuation(t, s, 1, 0)
42+ s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down
43+ assertCell(t, s, 0, 1, '世')
44+ assertContinuation(t, s, 1, 1)
45+ assertBlank(t, s, 0, 0)
46+ if err := s.Frame.CheckInvariants(); err != nil {
47+ t.Fatalf("invariants: %v", err)
48+ }
49+ },
50+ },
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, tt.run)
54+ }
55+}
56+
57+// TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls
58+// through the VT layer and asserts the frame's line-offset stays a valid
59+// permutation.
60+func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
61+ s := NewScreen(10, 6)
62+ seqs := [][]byte{
63+ []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"),
64+ []byte("\x1b[2;5r"), // scroll region rows 2..5
65+ []byte("\x1b[5;1Hmore\n"), // scroll within region
66+ []byte("\x1b[1;1H\x1bM"), // reverse index at top
67+ []byte("\x1b[3S"), // scroll up 3
68+ []byte("\x1b[2T"), // scroll down 2
69+ []byte("\x1b[r"), // reset region
70+ []byte("\x1b[6;1Hbottom\n"),
71+ }
72+ for i, seq := range seqs {
73+ s.Write(seq)
74+ s.ClearDamage()
75+ if err := s.Frame.CheckInvariants(); err != nil {
76+ t.Fatalf("after seq %d: invariants broken: %v", i, err)
77+ }
78+ }
79+}
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+// TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact
8+// with its line when a full-width scroll rotates line offsets rather than
9+// copying cells.
10+func TestWideCharSurvivesRotatedScroll(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "wide pair moves up one row on newline scroll",
17+ run: func(t *testing.T) {
18+ s := NewScreen(4, 3)
19+ // Place a wide rune (世, width 2) on the bottom row, then scroll up.
20+ s.Write([]byte("\x1b[3;1H世"))
21+ assertCell(t, s, 0, 2, '世')
22+ assertContinuation(t, s, 1, 2)
23+ // Newline at the bottom row scrolls the region up by one.
24+ s.Write([]byte("\n"))
25+ // The wide pair is now on row 1, intact; bottom row blank.
26+ assertCell(t, s, 0, 1, '世')
27+ assertContinuation(t, s, 1, 1)
28+ assertBlank(t, s, 0, 2)
29+ assertBlank(t, s, 1, 2)
30+ if err := s.Frame.CheckInvariants(); err != nil {
31+ t.Fatalf("invariants: %v", err)
32+ }
33+ },
34+ },
35+ {
36+ name: "wide pair moves down on reverse index",
37+ run: func(t *testing.T) {
38+ s := NewScreen(4, 3)
39+ s.Write([]byte("\x1b[1;1H世")) // top row
40+ assertCell(t, s, 0, 0, '世')
41+ assertContinuation(t, s, 1, 0)
42+ s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down
43+ assertCell(t, s, 0, 1, '世')
44+ assertContinuation(t, s, 1, 1)
45+ assertBlank(t, s, 0, 0)
46+ if err := s.Frame.CheckInvariants(); err != nil {
47+ t.Fatalf("invariants: %v", err)
48+ }
49+ },
50+ },
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, tt.run)
54+ }
55+}
56+
57+// TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls
58+// through the VT layer and asserts the frame's line-offset stays a valid
59+// permutation.
60+func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
61+ s := NewScreen(10, 6)
62+ seqs := [][]byte{
63+ []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"),
64+ []byte("\x1b[2;5r"), // scroll region rows 2..5
65+ []byte("\x1b[5;1Hmore\n"), // scroll within region
66+ []byte("\x1b[1;1H\x1bM"), // reverse index at top
67+ []byte("\x1b[3S"), // scroll up 3
68+ []byte("\x1b[2T"), // scroll down 2
69+ []byte("\x1b[r"), // reset region
70+ []byte("\x1b[6;1Hbottom\n"),
71+ }
72+ for i, seq := range seqs {
73+ s.Write(seq)
74+ s.ClearDamage()
75+ if err := s.Frame.CheckInvariants(); err != nil {
76+ t.Fatalf("after seq %d: invariants broken: %v", i, err)
77+ }
78+ }
79+}
D pkg/vt/screen_test.go +0 -3337 large diff (3337 lines changed): collapsed
A pkg/vt/screen_write_test.go +897 -0
@@ -0,0 +1,897 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestWritePrintableAndUTF8(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "printable ASCII advances cursor and records damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(5, 3)
18+ s.Write([]byte("Hi"))
19+
20+ assertCell(t, s, 0, 0, 'H')
21+ assertCell(t, s, 1, 0, 'i')
22+ if s.Col != 2 || s.Row != 0 {
23+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
24+ }
25+
26+ // Should have damage.
27+ d := s.Damage()
28+ if len(d) == 0 {
29+ t.Fatal("expected damage after writing")
30+ }
31+ },
32+ },
33+ {
34+ name: "UTF-8 multi-byte runes occupy one cell each",
35+ run: func(t *testing.T) {
36+ s := NewScreen(10, 3)
37+ s.Write([]byte("aäéø©"))
38+
39+ assertCell(t, s, 0, 0, 'a')
40+ assertCell(t, s, 1, 0, 'ä')
41+ assertCell(t, s, 2, 0, 'é')
42+ assertCell(t, s, 3, 0, 'ø')
43+ assertCell(t, s, 4, 0, '©')
44+ },
45+ },
46+ {
47+ name: "printable beyond width wraps to next line",
48+ run: func(t *testing.T) {
49+ s := NewScreen(4, 2)
50+ s.Write([]byte("ABCDE"))
51+
52+ assertCell(t, s, 0, 0, 'A')
53+ assertCell(t, s, 1, 0, 'B')
54+ assertCell(t, s, 2, 0, 'C')
55+ assertCell(t, s, 3, 0, 'D')
56+ // E wrapped to next line.
57+ assertCell(t, s, 0, 1, 'E')
58+ },
59+ },
60+ {
61+ name: "printable beyond screen height scrolls",
62+ run: func(t *testing.T) {
63+ s := NewScreen(3, 2)
64+ // Fill both lines.
65+ s.Write([]byte("ABC"))
66+ s.Write([]byte("DEF"))
67+ // Row is now 1 (bottom). One more char should scroll.
68+ s.Write([]byte("G"))
69+
70+ // After scroll: old row 0 = "DEF", new row 1 = "G ".
71+ assertCell(t, s, 0, 0, 'D')
72+ assertCell(t, s, 1, 0, 'E')
73+ assertCell(t, s, 2, 0, 'F')
74+ assertCell(t, s, 0, 1, 'G')
75+ assertCell(t, s, 1, 1, ' ')
76+
77+ // Check scroll damage.
78+ d := s.Damage()
79+ if !hasDamageKind(d, renderer.DamageScrollUp) {
80+ t.Error("expected scroll damage")
81+ }
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, tt.run)
88+ }
89+}
90+
91+func TestCursorControlChars(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ run func(t *testing.T)
95+ }{
96+ {
97+ name: "CR moves column back to 0",
98+ run: func(t *testing.T) {
99+ s := NewScreen(10, 3)
100+ s.Write([]byte("Hello"))
101+ // CR moves column back to 0.
102+ s.Write([]byte("\rWorld"))
103+
104+ assertCell(t, s, 0, 0, 'W')
105+ assertCell(t, s, 1, 0, 'o')
106+ assertCell(t, s, 2, 0, 'r')
107+ assertCell(t, s, 3, 0, 'l')
108+ assertCell(t, s, 4, 0, 'd')
109+ if s.Col != 5 {
110+ t.Errorf("col after CR + World = %d, want 5", s.Col)
111+ }
112+ },
113+ },
114+ {
115+ name: "LF with CR moves to next line column 0",
116+ run: func(t *testing.T) {
117+ s := NewScreen(5, 3)
118+ s.Write([]byte("A\r\nB\r\nC"))
119+
120+ assertCell(t, s, 0, 0, 'A')
121+ assertCell(t, s, 0, 1, 'B')
122+ assertCell(t, s, 0, 2, 'C')
123+ if s.Row != 2 || s.Col != 1 {
124+ t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
125+ }
126+ },
127+ },
128+ {
129+ name: "LF alone does not reset column",
130+ run: func(t *testing.T) {
131+ s := NewScreen(5, 3)
132+ s.Write([]byte("AB\nC"))
133+
134+ assertCell(t, s, 0, 0, 'A')
135+ assertCell(t, s, 1, 0, 'B')
136+ assertCell(t, s, 0, 1, ' ')
137+ assertCell(t, s, 2, 1, 'C')
138+ if s.Row != 1 || s.Col != 3 {
139+ t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
140+ }
141+ },
142+ },
143+ {
144+ name: "LF at pending wrap boundary advances exactly one line",
145+ run: func(t *testing.T) {
146+ s := NewScreen(5, 4)
147+ s.Write([]byte("ABCDE\nZ"))
148+
149+ assertCell(t, s, 4, 0, 'E')
150+ assertCell(t, s, 4, 1, 'Z')
151+ if s.Row != 1 || s.Col != 5 {
152+ t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
153+ }
154+ },
155+ },
156+ {
157+ name: "LF scrolls when both lines are full",
158+ run: func(t *testing.T) {
159+ s := NewScreen(5, 2)
160+ // Fill both lines then CRLF to scroll and return to column 0.
161+ s.Write([]byte("AAAAA"))
162+ s.Write([]byte("BBBBB"))
163+ s.Write([]byte("\r\nCCCCC"))
164+
165+ assertCell(t, s, 0, 0, 'B')
166+ assertCell(t, s, 4, 1, 'C')
167+ },
168+ },
169+ {
170+ name: "BS moves cursor back without touching the cell",
171+ run: func(t *testing.T) {
172+ s := NewScreen(10, 3)
173+ s.Write([]byte("Hello\b"))
174+
175+ if s.Col != 4 {
176+ t.Errorf("col after BS = %d, want 4", s.Col)
177+ }
178+ assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
179+ },
180+ },
181+ {
182+ name: "BS at column zero is a no-op",
183+ run: func(t *testing.T) {
184+ s := NewScreen(10, 3)
185+ s.Write([]byte("\b")) // BS at col 0 should be no-op
186+
187+ if s.Col != 0 {
188+ t.Errorf("col = %d, want 0", s.Col)
189+ }
190+ },
191+ },
192+ {
193+ name: "tab advances to next 8-column boundary",
194+ run: func(t *testing.T) {
195+ s := NewScreen(20, 3)
196+ s.Write([]byte("A\tB"))
197+
198+ assertCell(t, s, 0, 0, 'A')
199+ // Tab advances to next 8-column boundary (col 8).
200+ for i := 1; i < 8; i++ {
201+ assertCell(t, s, i, 0, ' ')
202+ }
203+ assertCell(t, s, 8, 0, 'B')
204+ },
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, tt.run)
210+ }
211+}
212+
213+func TestInvalidInputIgnored(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ input []byte
217+ }{
218+ {
219+ name: "invalid UTF-8 continuation byte without start byte",
220+ input: []byte{0x80, 0x81},
221+ },
222+ {
223+ name: "unhandled control characters below 0x20",
224+ input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
225+ },
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ s := NewScreen(10, 3)
231+ s.Write(tt.input)
232+ // Should not panic, and no cells should be modified.
233+ for y := range 3 {
234+ for x := range 10 {
235+ if c := cellAt(s, x, y); c.Rune != ' ' {
236+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
237+ }
238+ }
239+ }
240+ })
241+ }
242+}
243+
244+func TestWriteAtEdgeNoPanic(t *testing.T) {
245+ tests := []struct {
246+ name string
247+ }{
248+ {name: "writing past a 1x1 screen scrolls instead of panicking"},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ s := NewScreen(1, 1)
254+ // Fill the only cell then try to write more (should scroll).
255+ s.Write([]byte("ABC"))
256+ // Should not panic.
257+ _ = cellAt(s, 0, 0)
258+ })
259+ }
260+}
261+
262+func TestStylePersists(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ }{
266+ {name: "bold persists across separate Write calls"},
267+ }
268+
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ s := NewScreen(10, 3)
272+ s.Write([]byte("\x1b[1mAB"))
273+ s.Write([]byte("CD"))
274+
275+ c1 := cellAt(s, 0, 0)
276+ c2 := cellAt(s, 2, 0)
277+ if !c1.Style.Bold || !c2.Style.Bold {
278+ t.Error("bold should persist across Write calls")
279+ }
280+ })
281+ }
282+}
283+
284+func TestWideAndZeroWidthRunes(t *testing.T) {
285+ tests := []struct {
286+ name string
287+ run func(t *testing.T)
288+ }{
289+ {
290+ name: "CJK writes wide left cell plus continuation",
291+ run: func(t *testing.T) {
292+ s := NewScreen(10, 3)
293+ // Two CJK characters, each width 2: left cell holds the rune,
294+ // right cell is a continuation marker.
295+ s.Write([]byte("你好"))
296+
297+ assertCell(t, s, 0, 0, '你')
298+ assertContinuation(t, s, 1, 0)
299+ assertCell(t, s, 2, 0, '好')
300+ assertContinuation(t, s, 3, 0)
301+ if s.Col != 4 || s.Row != 0 {
302+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
303+ }
304+ },
305+ },
306+ {
307+ name: "CJK mixed with ASCII",
308+ run: func(t *testing.T) {
309+ s := NewScreen(10, 3)
310+ s.Write([]byte("A你B"))
311+
312+ assertCell(t, s, 0, 0, 'A')
313+ assertCell(t, s, 1, 0, '你')
314+ assertContinuation(t, s, 2, 0)
315+ assertCell(t, s, 3, 0, 'B')
316+ if s.Col != 4 {
317+ t.Errorf("cursor at col=%d, want 4", s.Col)
318+ }
319+ },
320+ },
321+ {
322+ name: "emoji writes wide pair",
323+ run: func(t *testing.T) {
324+ s := NewScreen(10, 3)
325+ s.Write([]byte("\U0001f600")) // 😀 grinning face
326+
327+ assertCell(t, s, 0, 0, '\U0001f600')
328+ assertContinuation(t, s, 1, 0)
329+ if s.Col != 2 {
330+ t.Errorf("cursor at col=%d, want 2", s.Col)
331+ }
332+ },
333+ },
334+ {
335+ name: "combining mark is skipped without advancing cursor",
336+ run: func(t *testing.T) {
337+ s := NewScreen(10, 3)
338+ // 'A' followed by combining acute accent U+0301
339+ s.Write([]byte("A\xcc\x81"))
340+
341+ assertCell(t, s, 0, 0, 'A')
342+ // Combining mark should be skipped — no cell written, no cursor advance.
343+ if s.Col != 1 {
344+ t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
345+ }
346+ },
347+ },
348+ {
349+ name: "zero-width characters are skipped without advancing cursor",
350+ run: func(t *testing.T) {
351+ s := NewScreen(10, 3)
352+ // Zero-width space U+200B
353+ s.Write([]byte("A\xe2\x80\x8bB"))
354+
355+ assertCell(t, s, 0, 0, 'A')
356+ assertCell(t, s, 1, 0, 'B')
357+ if s.Col != 2 {
358+ t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
359+ }
360+ },
361+ },
362+ {
363+ name: "CJK surrounded by ASCII on both sides",
364+ run: func(t *testing.T) {
365+ s := NewScreen(10, 3)
366+ s.Write([]byte("a你b好c"))
367+
368+ assertCell(t, s, 0, 0, 'a')
369+ assertCell(t, s, 1, 0, '你')
370+ assertContinuation(t, s, 2, 0)
371+ assertCell(t, s, 3, 0, 'b')
372+ assertCell(t, s, 4, 0, '好')
373+ assertContinuation(t, s, 5, 0)
374+ assertCell(t, s, 6, 0, 'c')
375+ if s.Col != 7 {
376+ t.Errorf("cursor at col=%d, want 7", s.Col)
377+ }
378+ },
379+ },
380+ {
381+ name: "wide char at last column wraps to next line, abandoned cell cleared",
382+ run: func(t *testing.T) {
383+ s := NewScreen(3, 2)
384+ // Fill the first two columns; cursor lands on the last column.
385+ s.Write([]byte("AB")) // cells [A B _], Col=2
386+ // A wide char at the last column cannot straddle the edge: it
387+ // wraps to the next line, clearing the abandoned last cell.
388+ s.Write([]byte("你"))
389+
390+ assertCell(t, s, 0, 0, 'A')
391+ assertCell(t, s, 1, 0, 'B')
392+ assertBlank(t, s, 2, 0) // abandoned last cell cleared
393+ assertCell(t, s, 0, 1, '你')
394+ assertContinuation(t, s, 1, 1)
395+ if s.Col != 2 || s.Row != 1 {
396+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
397+ }
398+ },
399+ },
400+ {
401+ name: "CJK damage width is 2",
402+ run: func(t *testing.T) {
403+ s := NewScreen(10, 3)
404+ s.ClearDamage()
405+ s.Write([]byte("你"))
406+
407+ d := s.Damage()
408+ if len(d) == 0 {
409+ t.Fatal("expected damage")
410+ }
411+ if d[0].Width != 2 {
412+ t.Errorf("damage width = %d, want 2", d[0].Width)
413+ }
414+ },
415+ },
416+ {
417+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
418+ run: func(t *testing.T) {
419+ s := NewScreen(10, 3)
420+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
421+ s.Write([]byte("a你\U0001f600b\xcc\x81"))
422+
423+ assertCell(t, s, 0, 0, 'a')
424+ assertCell(t, s, 1, 0, '你')
425+ assertContinuation(t, s, 2, 0)
426+ assertCell(t, s, 3, 0, '\U0001f600')
427+ assertContinuation(t, s, 4, 0)
428+ assertCell(t, s, 5, 0, 'b')
429+ // Combining mark skipped, no cell at col 6.
430+ if s.Col != 6 || s.Row != 0 {
431+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
432+ }
433+ },
434+ },
435+ {
436+ name: "overwrite left half of wide pair with narrow clears both",
437+ run: func(t *testing.T) {
438+ s := NewScreen(10, 3)
439+ s.Write([]byte("你")) // (0)=你 (1)=cont
440+ s.Write([]byte("\x1b[1;1H")) // cursor home
441+ s.Write([]byte("X")) // overwrite the wide left half
442+
443+ assertCell(t, s, 0, 0, 'X')
444+ assertBlank(t, s, 1, 0) // orphaned continuation cleared
445+ },
446+ },
447+ {
448+ name: "overwrite right half (continuation) with narrow clears both",
449+ run: func(t *testing.T) {
450+ s := NewScreen(10, 3)
451+ s.Write([]byte("你")) // (0)=你 (1)=cont
452+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
453+ s.Write([]byte("X")) // overwrite the continuation
454+
455+ assertBlank(t, s, 0, 0) // orphaned wide left cleared
456+ assertCell(t, s, 1, 0, 'X')
457+ },
458+ },
459+ {
460+ name: "overwrite half of wide pair with a new wide char",
461+ run: func(t *testing.T) {
462+ s := NewScreen(10, 3)
463+ s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
464+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
465+ s.Write([]byte("学")) // wide write at cols 1,2
466+
467+ assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
468+ assertCell(t, s, 1, 0, '学')
469+ assertContinuation(t, s, 2, 0)
470+ assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
471+ },
472+ },
473+ {
474+ name: "erase to end of line covering a continuation clears its wide left half",
475+ run: func(t *testing.T) {
476+ s := NewScreen(10, 3)
477+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
478+ s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
479+ s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
480+
481+ assertCell(t, s, 0, 0, 'A')
482+ assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
483+ assertBlank(t, s, 2, 0)
484+ assertBlank(t, s, 3, 0)
485+ },
486+ },
487+ {
488+ name: "erase to start of line covering a wide left clears its continuation",
489+ run: func(t *testing.T) {
490+ s := NewScreen(10, 3)
491+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
492+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
493+ s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
494+
495+ assertBlank(t, s, 0, 0)
496+ assertBlank(t, s, 1, 0)
497+ assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
498+ assertCell(t, s, 3, 0, 'B')
499+ },
500+ },
501+ {
502+ name: "wrap abandoning a continuation cell clears its pair and extends damage",
503+ run: func(t *testing.T) {
504+ s := NewScreen(4, 2)
505+ s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
506+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
507+ s.ClearDamage()
508+ // A wide rune at the last column wraps; the abandoned last cell
509+ // is a continuation, so its wide left half (col 2) must be
510+ // cleared too and the damage must span both columns.
511+ s.Write([]byte("好"))
512+
513+ assertCell(t, s, 0, 0, 'A')
514+ assertCell(t, s, 1, 0, 'B')
515+ assertBlank(t, s, 2, 0) // orphaned wide left cleared
516+ assertBlank(t, s, 3, 0) // abandoned continuation cleared
517+ assertCell(t, s, 0, 1, '好')
518+ assertContinuation(t, s, 1, 1)
519+ if s.Col != 2 || s.Row != 1 {
520+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
521+ }
522+
523+ d := s.Damage()
524+ if len(d) == 0 {
525+ t.Fatal("expected damage")
526+ }
527+ // First damage item covers the cleared pair on row 0.
528+ if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
529+ t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width)
530+ }
531+ },
532+ },
533+ {
534+ name: "insert chars at a continuation splits the pair and repairs orphans",
535+ run: func(t *testing.T) {
536+ s := NewScreen(6, 2)
537+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
538+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
539+ s.ClearDamage()
540+ s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
541+
542+ // The shift splits 好/cont: 好 stays at col 2, its continuation
543+ // moves to col 4. Both orphans must be repaired to blanks.
544+ assertCell(t, s, 0, 0, '你')
545+ assertContinuation(t, s, 1, 0)
546+ assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
547+ assertBlank(t, s, 3, 0) // inserted blank
548+ assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
549+ assertBlank(t, s, 5, 0)
550+
551+ d := s.Damage()
552+ if len(d) == 0 {
553+ t.Fatal("expected damage")
554+ }
555+ // Damage must extend one column left to cover the repaired 好.
556+ if d[0].X != 2 || d[0].Width != 4 {
557+ t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
558+ }
559+ },
560+ },
561+ {
562+ name: "insert chars shifting a wide pair off the right edge repairs the left half",
563+ run: func(t *testing.T) {
564+ s := NewScreen(4, 2)
565+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
566+ s.Write([]byte("\x1b[1;1H")) // cursor home
567+ s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
568+
569+ // 好 lands on the last column with its continuation pushed off
570+ // the edge; the orphaned wide left must be blanked.
571+ assertBlank(t, s, 0, 0)
572+ assertCell(t, s, 1, 0, '你')
573+ assertContinuation(t, s, 2, 0)
574+ assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
575+ },
576+ },
577+ {
578+ name: "delete char at the left half of a wide pair repairs the orphan",
579+ run: func(t *testing.T) {
580+ s := NewScreen(6, 2)
581+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
582+ s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
583+ s.ClearDamage()
584+ s.Write([]byte("\x1b[1P")) // DCH 1
585+
586+ // 你's continuation shifts to col 0 with no left half → repaired.
587+ assertBlank(t, s, 0, 0)
588+ assertCell(t, s, 1, 0, '好')
589+ assertContinuation(t, s, 2, 0)
590+ assertCell(t, s, 3, 0, 'A')
591+ assertCell(t, s, 4, 0, 'B')
592+ assertBlank(t, s, 5, 0)
593+
594+ d := s.Damage()
595+ if len(d) == 0 {
596+ t.Fatal("expected damage")
597+ }
598+ if d[0].X != 0 || d[0].Width != 6 {
599+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
600+ }
601+ },
602+ },
603+ {
604+ name: "delete char at a continuation repairs the wide left and extends damage",
605+ run: func(t *testing.T) {
606+ s := NewScreen(6, 2)
607+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
608+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
609+ s.ClearDamage()
610+ s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
611+
612+ // 你 at col 0 loses its continuation → repaired to blank.
613+ assertBlank(t, s, 0, 0)
614+ assertCell(t, s, 1, 0, '好')
615+ assertContinuation(t, s, 2, 0)
616+ assertCell(t, s, 3, 0, 'A')
617+ assertCell(t, s, 4, 0, 'B')
618+ assertBlank(t, s, 5, 0)
619+
620+ d := s.Damage()
621+ if len(d) == 0 {
622+ t.Fatal("expected damage")
623+ }
624+ // Damage must extend one column left (to col 0) to cover the
625+ // repaired wide left half.
626+ if d[0].X != 0 || d[0].Width != 6 {
627+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
628+ }
629+ },
630+ },
631+ {
632+ name: "IRM inserts narrow printable cells and reset restores overwrite",
633+ run: func(t *testing.T) {
634+ s := NewScreen(8, 1)
635+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
636+
637+ if got := lineText(s, 0); got != "abXcd " {
638+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
639+ }
640+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
641+ if got := lineText(s, 0); got != "abYcd " {
642+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
643+ }
644+ },
645+ },
646+ {
647+ name: "IRM inserts wide printable cells and repairs evicted wide head",
648+ run: func(t *testing.T) {
649+ s := NewScreen(6, 1)
650+ s.Write([]byte("ab界cd"))
651+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
652+
653+ assertCell(t, s, 0, 0, 'a')
654+ assertCell(t, s, 1, 0, 'b')
655+ assertCell(t, s, 2, 0, '好')
656+ assertContinuation(t, s, 3, 0)
657+ assertCell(t, s, 4, 0, '界')
658+ assertContinuation(t, s, 5, 0)
659+ assertNoOrphanWideCells(t, s)
660+ },
661+ },
662+ {
663+ name: "IRM wide insert at right edge repairs orphan",
664+ run: func(t *testing.T) {
665+ s := NewScreen(5, 1)
666+ s.Write([]byte("abc界"))
667+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
668+
669+ assertCell(t, s, 0, 0, 'a')
670+ assertCell(t, s, 1, 0, '好')
671+ assertContinuation(t, s, 2, 0)
672+ assertCell(t, s, 3, 0, 'b')
673+ assertCell(t, s, 4, 0, 'c')
674+ assertNoOrphanWideCells(t, s)
675+ },
676+ },
677+ {
678+ name: "wide char on a width-1 screen degrades to a single cell",
679+ run: func(t *testing.T) {
680+ s := NewScreen(1, 2)
681+ // A wide rune cannot fit on a 1-column screen; it must not write
682+ // an out-of-bounds continuation cell.
683+ s.Write([]byte("你"))
684+
685+ assertCell(t, s, 0, 0, '你')
686+ if s.Col != 1 {
687+ t.Errorf("cursor at col=%d, want 1", s.Col)
688+ }
689+ },
690+ },
691+ {
692+ name: "wide pair survives a scroll",
693+ run: func(t *testing.T) {
694+ s := NewScreen(4, 2)
695+ s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
696+ s.Write([]byte("\r\n")) // bottom row → scroll up
697+
698+ // After scrolling up, row1's 好 moves to row0 intact.
699+ assertCell(t, s, 0, 0, '好')
700+ assertContinuation(t, s, 1, 0)
701+ },
702+ },
703+ {
704+ name: "combining mark alone writes no cell and produces no damage",
705+ run: func(t *testing.T) {
706+ s := NewScreen(10, 3)
707+ s.ClearDamage()
708+ s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
709+
710+ // No cells should be modified.
711+ for y := range 3 {
712+ for x := range 10 {
713+ if c := cellAt(s, x, y); c.Rune != ' ' {
714+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
715+ }
716+ }
717+ }
718+ if s.Col != 0 || s.Row != 0 {
719+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
720+ }
721+ d := s.Damage()
722+ if len(d) != 0 {
723+ t.Errorf("expected no damage for combining mark alone, got %+v", d)
724+ }
725+ },
726+ },
727+ }
728+
729+ for _, tt := range tests {
730+ t.Run(tt.name, tt.run)
731+ }
732+}
733+
734+func TestRuneWidth(t *testing.T) {
735+ tests := []struct {
736+ name string
737+ r rune
738+ width int
739+ }{
740+ {"space", ' ', 1},
741+ {"lowercase ascii letter", 'a', 1},
742+ {"uppercase ascii letter", 'A', 1},
743+ {"ascii digit", '1', 1},
744+ {"soft hyphen", 0x00AD, 0},
745+ {"combining grapheme joiner", 0x034F, 0},
746+ {"combining acute accent", 0x0301, 0},
747+ {"combining grave accent", 0x0300, 0},
748+ {"zero-width space", 0x200B, 0},
749+ {"zero-width non-joiner", 0x200C, 0},
750+ {"zero-width joiner", 0x200D, 0},
751+ {"BOM / zero-width no-break space", 0xFEFF, 0},
752+ {"CJK Unified Ideograph (一)", 0x4E00, 2},
753+ {"CJK (二)", 0x4E8C, 2},
754+ {"CJK end of BMP", 0x9FFF, 2},
755+ {"CJK Extension A", 0x3400, 2},
756+ {"Hangul Syllable (가)", 0xAC00, 2},
757+ {"Hangul Syllable end", 0xD7AF, 2},
758+ {"CJK Ideographic Space", 0x3000, 2},
759+ {"Fullwidth Exclamation Mark", 0xFF01, 2},
760+ {"Fullwidth Cent Sign", 0xFFE0, 2},
761+ {"grinning face emoji", 0x1F600, 2},
762+ {"rocket emoji", 0x1F680, 2},
763+ {"hiragana a", 0x3042, 2},
764+ {"katakana a", 0x30A2, 2},
765+ {"CJK Extension B", 0x20000, 2},
766+ {"supplemental symbols (robot)", 0x1F916, 2},
767+ {"symbols extended-A (sari)", 0x1FA71, 2},
768+ {"ideographic full stop", 0x3002, 2},
769+ {"NUL", '\x00', 0},
770+ {"SOH", '\x01', 0},
771+ {"ESC", '\x1b', 0},
772+ {"DEL", 0x7F, 0},
773+ }
774+
775+ for _, tt := range tests {
776+ t.Run(tt.name, func(t *testing.T) {
777+ got := renderer.RuneWidth(tt.r)
778+ if got != tt.width {
779+ t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
780+ }
781+ })
782+ }
783+}
784+
785+func TestC1Controls(t *testing.T) {
786+ tests := []struct {
787+ name string
788+ run func(t *testing.T)
789+ }{
790+ {
791+ name: "C1 controls are dropped without moving the cursor",
792+ run: func(t *testing.T) {
793+ s := NewScreen(10, 3)
794+ s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
795+ s.Write([]byte{0xC2, 0x9F}) // U+009F APC
796+
797+ // No cells should be modified.
798+ for y := range 3 {
799+ for x := range 10 {
800+ if c := cellAt(s, x, y); c.Rune != ' ' {
801+ t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
802+ }
803+ }
804+ }
805+ // Cursor should not have moved.
806+ if s.Col != 0 || s.Row != 0 {
807+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
808+ }
809+ },
810+ },
811+ {
812+ name: "the full C1 control range U+0080-U+009F is dropped",
813+ run: func(t *testing.T) {
814+ s := NewScreen(10, 3)
815+ // Send all C1 control runes U+0080 through U+009F.
816+ for r := 0x80; r <= 0x9F; r++ {
817+ s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
818+ }
819+
820+ // No cells should be modified.
821+ for y := range 3 {
822+ for x := range 10 {
823+ if c := cellAt(s, x, y); c.Rune != ' ' {
824+ t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
825+ }
826+ }
827+ }
828+ if s.Col != 0 || s.Row != 0 {
829+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
830+ }
831+ },
832+ },
833+ {
834+ name: "printable characters still work after C1 controls",
835+ run: func(t *testing.T) {
836+ s := NewScreen(10, 3)
837+ // Printable characters should still work after C1 controls.
838+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
839+ s.Write([]byte("AB"))
840+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
841+ s.Write([]byte("CD"))
842+
843+ assertCell(t, s, 0, 0, 'A')
844+ assertCell(t, s, 1, 0, 'B')
845+ assertCell(t, s, 2, 0, 'C')
846+ assertCell(t, s, 3, 0, 'D')
847+ if s.Col != 4 {
848+ t.Errorf("cursor at col=%d, want 4", s.Col)
849+ }
850+ },
851+ },
852+ {
853+ name: "C1 controls mixed with CR preserve CR behavior",
854+ run: func(t *testing.T) {
855+ s := NewScreen(10, 3)
856+ // C1 controls mixed with CR/LF should preserve CR/LF behavior.
857+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
858+ s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
859+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
860+
861+ assertCell(t, s, 0, 0, 'B')
862+ },
863+ },
864+ {
865+ name: "C1 controls mixed with LF preserve LF behavior",
866+ run: func(t *testing.T) {
867+ s := NewScreen(10, 3)
868+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
869+ s.Write([]byte("A\nB"))
870+
871+ assertCell(t, s, 0, 0, 'A')
872+ assertCell(t, s, 1, 1, 'B')
873+ if s.Row != 1 || s.Col != 2 {
874+ t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
875+ }
876+ },
877+ },
878+ {
879+ name: "C1 controls produce no damage",
880+ run: func(t *testing.T) {
881+ s := NewScreen(10, 3)
882+ s.ClearDamage()
883+ // C1 controls should produce no damage.
884+ s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
885+
886+ d := s.Damage()
887+ if len(d) != 0 {
888+ t.Errorf("expected no damage from C1 controls, got %+v", d)
889+ }
890+ },
891+ },
892+ }
893+
894+ for _, tt := range tests {
895+ t.Run(tt.name, tt.run)
896+ }
897+}
@@ -0,0 +1,897 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestWritePrintableAndUTF8(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "printable ASCII advances cursor and records damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(5, 3)
18+ s.Write([]byte("Hi"))
19+
20+ assertCell(t, s, 0, 0, 'H')
21+ assertCell(t, s, 1, 0, 'i')
22+ if s.Col != 2 || s.Row != 0 {
23+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
24+ }
25+
26+ // Should have damage.
27+ d := s.Damage()
28+ if len(d) == 0 {
29+ t.Fatal("expected damage after writing")
30+ }
31+ },
32+ },
33+ {
34+ name: "UTF-8 multi-byte runes occupy one cell each",
35+ run: func(t *testing.T) {
36+ s := NewScreen(10, 3)
37+ s.Write([]byte("aäéø©"))
38+
39+ assertCell(t, s, 0, 0, 'a')
40+ assertCell(t, s, 1, 0, 'ä')
41+ assertCell(t, s, 2, 0, 'é')
42+ assertCell(t, s, 3, 0, 'ø')
43+ assertCell(t, s, 4, 0, '©')
44+ },
45+ },
46+ {
47+ name: "printable beyond width wraps to next line",
48+ run: func(t *testing.T) {
49+ s := NewScreen(4, 2)
50+ s.Write([]byte("ABCDE"))
51+
52+ assertCell(t, s, 0, 0, 'A')
53+ assertCell(t, s, 1, 0, 'B')
54+ assertCell(t, s, 2, 0, 'C')
55+ assertCell(t, s, 3, 0, 'D')
56+ // E wrapped to next line.
57+ assertCell(t, s, 0, 1, 'E')
58+ },
59+ },
60+ {
61+ name: "printable beyond screen height scrolls",
62+ run: func(t *testing.T) {
63+ s := NewScreen(3, 2)
64+ // Fill both lines.
65+ s.Write([]byte("ABC"))
66+ s.Write([]byte("DEF"))
67+ // Row is now 1 (bottom). One more char should scroll.
68+ s.Write([]byte("G"))
69+
70+ // After scroll: old row 0 = "DEF", new row 1 = "G ".
71+ assertCell(t, s, 0, 0, 'D')
72+ assertCell(t, s, 1, 0, 'E')
73+ assertCell(t, s, 2, 0, 'F')
74+ assertCell(t, s, 0, 1, 'G')
75+ assertCell(t, s, 1, 1, ' ')
76+
77+ // Check scroll damage.
78+ d := s.Damage()
79+ if !hasDamageKind(d, renderer.DamageScrollUp) {
80+ t.Error("expected scroll damage")
81+ }
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, tt.run)
88+ }
89+}
90+
91+func TestCursorControlChars(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ run func(t *testing.T)
95+ }{
96+ {
97+ name: "CR moves column back to 0",
98+ run: func(t *testing.T) {
99+ s := NewScreen(10, 3)
100+ s.Write([]byte("Hello"))
101+ // CR moves column back to 0.
102+ s.Write([]byte("\rWorld"))
103+
104+ assertCell(t, s, 0, 0, 'W')
105+ assertCell(t, s, 1, 0, 'o')
106+ assertCell(t, s, 2, 0, 'r')
107+ assertCell(t, s, 3, 0, 'l')
108+ assertCell(t, s, 4, 0, 'd')
109+ if s.Col != 5 {
110+ t.Errorf("col after CR + World = %d, want 5", s.Col)
111+ }
112+ },
113+ },
114+ {
115+ name: "LF with CR moves to next line column 0",
116+ run: func(t *testing.T) {
117+ s := NewScreen(5, 3)
118+ s.Write([]byte("A\r\nB\r\nC"))
119+
120+ assertCell(t, s, 0, 0, 'A')
121+ assertCell(t, s, 0, 1, 'B')
122+ assertCell(t, s, 0, 2, 'C')
123+ if s.Row != 2 || s.Col != 1 {
124+ t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
125+ }
126+ },
127+ },
128+ {
129+ name: "LF alone does not reset column",
130+ run: func(t *testing.T) {
131+ s := NewScreen(5, 3)
132+ s.Write([]byte("AB\nC"))
133+
134+ assertCell(t, s, 0, 0, 'A')
135+ assertCell(t, s, 1, 0, 'B')
136+ assertCell(t, s, 0, 1, ' ')
137+ assertCell(t, s, 2, 1, 'C')
138+ if s.Row != 1 || s.Col != 3 {
139+ t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
140+ }
141+ },
142+ },
143+ {
144+ name: "LF at pending wrap boundary advances exactly one line",
145+ run: func(t *testing.T) {
146+ s := NewScreen(5, 4)
147+ s.Write([]byte("ABCDE\nZ"))
148+
149+ assertCell(t, s, 4, 0, 'E')
150+ assertCell(t, s, 4, 1, 'Z')
151+ if s.Row != 1 || s.Col != 5 {
152+ t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
153+ }
154+ },
155+ },
156+ {
157+ name: "LF scrolls when both lines are full",
158+ run: func(t *testing.T) {
159+ s := NewScreen(5, 2)
160+ // Fill both lines then CRLF to scroll and return to column 0.
161+ s.Write([]byte("AAAAA"))
162+ s.Write([]byte("BBBBB"))
163+ s.Write([]byte("\r\nCCCCC"))
164+
165+ assertCell(t, s, 0, 0, 'B')
166+ assertCell(t, s, 4, 1, 'C')
167+ },
168+ },
169+ {
170+ name: "BS moves cursor back without touching the cell",
171+ run: func(t *testing.T) {
172+ s := NewScreen(10, 3)
173+ s.Write([]byte("Hello\b"))
174+
175+ if s.Col != 4 {
176+ t.Errorf("col after BS = %d, want 4", s.Col)
177+ }
178+ assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
179+ },
180+ },
181+ {
182+ name: "BS at column zero is a no-op",
183+ run: func(t *testing.T) {
184+ s := NewScreen(10, 3)
185+ s.Write([]byte("\b")) // BS at col 0 should be no-op
186+
187+ if s.Col != 0 {
188+ t.Errorf("col = %d, want 0", s.Col)
189+ }
190+ },
191+ },
192+ {
193+ name: "tab advances to next 8-column boundary",
194+ run: func(t *testing.T) {
195+ s := NewScreen(20, 3)
196+ s.Write([]byte("A\tB"))
197+
198+ assertCell(t, s, 0, 0, 'A')
199+ // Tab advances to next 8-column boundary (col 8).
200+ for i := 1; i < 8; i++ {
201+ assertCell(t, s, i, 0, ' ')
202+ }
203+ assertCell(t, s, 8, 0, 'B')
204+ },
205+ },
206+ }
207+
208+ for _, tt := range tests {
209+ t.Run(tt.name, tt.run)
210+ }
211+}
212+
213+func TestInvalidInputIgnored(t *testing.T) {
214+ tests := []struct {
215+ name string
216+ input []byte
217+ }{
218+ {
219+ name: "invalid UTF-8 continuation byte without start byte",
220+ input: []byte{0x80, 0x81},
221+ },
222+ {
223+ name: "unhandled control characters below 0x20",
224+ input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
225+ },
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, func(t *testing.T) {
230+ s := NewScreen(10, 3)
231+ s.Write(tt.input)
232+ // Should not panic, and no cells should be modified.
233+ for y := range 3 {
234+ for x := range 10 {
235+ if c := cellAt(s, x, y); c.Rune != ' ' {
236+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
237+ }
238+ }
239+ }
240+ })
241+ }
242+}
243+
244+func TestWriteAtEdgeNoPanic(t *testing.T) {
245+ tests := []struct {
246+ name string
247+ }{
248+ {name: "writing past a 1x1 screen scrolls instead of panicking"},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ s := NewScreen(1, 1)
254+ // Fill the only cell then try to write more (should scroll).
255+ s.Write([]byte("ABC"))
256+ // Should not panic.
257+ _ = cellAt(s, 0, 0)
258+ })
259+ }
260+}
261+
262+func TestStylePersists(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ }{
266+ {name: "bold persists across separate Write calls"},
267+ }
268+
269+ for _, tt := range tests {
270+ t.Run(tt.name, func(t *testing.T) {
271+ s := NewScreen(10, 3)
272+ s.Write([]byte("\x1b[1mAB"))
273+ s.Write([]byte("CD"))
274+
275+ c1 := cellAt(s, 0, 0)
276+ c2 := cellAt(s, 2, 0)
277+ if !c1.Style.Bold || !c2.Style.Bold {
278+ t.Error("bold should persist across Write calls")
279+ }
280+ })
281+ }
282+}
283+
284+func TestWideAndZeroWidthRunes(t *testing.T) {
285+ tests := []struct {
286+ name string
287+ run func(t *testing.T)
288+ }{
289+ {
290+ name: "CJK writes wide left cell plus continuation",
291+ run: func(t *testing.T) {
292+ s := NewScreen(10, 3)
293+ // Two CJK characters, each width 2: left cell holds the rune,
294+ // right cell is a continuation marker.
295+ s.Write([]byte("你好"))
296+
297+ assertCell(t, s, 0, 0, '你')
298+ assertContinuation(t, s, 1, 0)
299+ assertCell(t, s, 2, 0, '好')
300+ assertContinuation(t, s, 3, 0)
301+ if s.Col != 4 || s.Row != 0 {
302+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
303+ }
304+ },
305+ },
306+ {
307+ name: "CJK mixed with ASCII",
308+ run: func(t *testing.T) {
309+ s := NewScreen(10, 3)
310+ s.Write([]byte("A你B"))
311+
312+ assertCell(t, s, 0, 0, 'A')
313+ assertCell(t, s, 1, 0, '你')
314+ assertContinuation(t, s, 2, 0)
315+ assertCell(t, s, 3, 0, 'B')
316+ if s.Col != 4 {
317+ t.Errorf("cursor at col=%d, want 4", s.Col)
318+ }
319+ },
320+ },
321+ {
322+ name: "emoji writes wide pair",
323+ run: func(t *testing.T) {
324+ s := NewScreen(10, 3)
325+ s.Write([]byte("\U0001f600")) // 😀 grinning face
326+
327+ assertCell(t, s, 0, 0, '\U0001f600')
328+ assertContinuation(t, s, 1, 0)
329+ if s.Col != 2 {
330+ t.Errorf("cursor at col=%d, want 2", s.Col)
331+ }
332+ },
333+ },
334+ {
335+ name: "combining mark is skipped without advancing cursor",
336+ run: func(t *testing.T) {
337+ s := NewScreen(10, 3)
338+ // 'A' followed by combining acute accent U+0301
339+ s.Write([]byte("A\xcc\x81"))
340+
341+ assertCell(t, s, 0, 0, 'A')
342+ // Combining mark should be skipped — no cell written, no cursor advance.
343+ if s.Col != 1 {
344+ t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
345+ }
346+ },
347+ },
348+ {
349+ name: "zero-width characters are skipped without advancing cursor",
350+ run: func(t *testing.T) {
351+ s := NewScreen(10, 3)
352+ // Zero-width space U+200B
353+ s.Write([]byte("A\xe2\x80\x8bB"))
354+
355+ assertCell(t, s, 0, 0, 'A')
356+ assertCell(t, s, 1, 0, 'B')
357+ if s.Col != 2 {
358+ t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
359+ }
360+ },
361+ },
362+ {
363+ name: "CJK surrounded by ASCII on both sides",
364+ run: func(t *testing.T) {
365+ s := NewScreen(10, 3)
366+ s.Write([]byte("a你b好c"))
367+
368+ assertCell(t, s, 0, 0, 'a')
369+ assertCell(t, s, 1, 0, '你')
370+ assertContinuation(t, s, 2, 0)
371+ assertCell(t, s, 3, 0, 'b')
372+ assertCell(t, s, 4, 0, '好')
373+ assertContinuation(t, s, 5, 0)
374+ assertCell(t, s, 6, 0, 'c')
375+ if s.Col != 7 {
376+ t.Errorf("cursor at col=%d, want 7", s.Col)
377+ }
378+ },
379+ },
380+ {
381+ name: "wide char at last column wraps to next line, abandoned cell cleared",
382+ run: func(t *testing.T) {
383+ s := NewScreen(3, 2)
384+ // Fill the first two columns; cursor lands on the last column.
385+ s.Write([]byte("AB")) // cells [A B _], Col=2
386+ // A wide char at the last column cannot straddle the edge: it
387+ // wraps to the next line, clearing the abandoned last cell.
388+ s.Write([]byte("你"))
389+
390+ assertCell(t, s, 0, 0, 'A')
391+ assertCell(t, s, 1, 0, 'B')
392+ assertBlank(t, s, 2, 0) // abandoned last cell cleared
393+ assertCell(t, s, 0, 1, '你')
394+ assertContinuation(t, s, 1, 1)
395+ if s.Col != 2 || s.Row != 1 {
396+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
397+ }
398+ },
399+ },
400+ {
401+ name: "CJK damage width is 2",
402+ run: func(t *testing.T) {
403+ s := NewScreen(10, 3)
404+ s.ClearDamage()
405+ s.Write([]byte("你"))
406+
407+ d := s.Damage()
408+ if len(d) == 0 {
409+ t.Fatal("expected damage")
410+ }
411+ if d[0].Width != 2 {
412+ t.Errorf("damage width = %d, want 2", d[0].Width)
413+ }
414+ },
415+ },
416+ {
417+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
418+ run: func(t *testing.T) {
419+ s := NewScreen(10, 3)
420+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
421+ s.Write([]byte("a你\U0001f600b\xcc\x81"))
422+
423+ assertCell(t, s, 0, 0, 'a')
424+ assertCell(t, s, 1, 0, '你')
425+ assertContinuation(t, s, 2, 0)
426+ assertCell(t, s, 3, 0, '\U0001f600')
427+ assertContinuation(t, s, 4, 0)
428+ assertCell(t, s, 5, 0, 'b')
429+ // Combining mark skipped, no cell at col 6.
430+ if s.Col != 6 || s.Row != 0 {
431+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
432+ }
433+ },
434+ },
435+ {
436+ name: "overwrite left half of wide pair with narrow clears both",
437+ run: func(t *testing.T) {
438+ s := NewScreen(10, 3)
439+ s.Write([]byte("你")) // (0)=你 (1)=cont
440+ s.Write([]byte("\x1b[1;1H")) // cursor home
441+ s.Write([]byte("X")) // overwrite the wide left half
442+
443+ assertCell(t, s, 0, 0, 'X')
444+ assertBlank(t, s, 1, 0) // orphaned continuation cleared
445+ },
446+ },
447+ {
448+ name: "overwrite right half (continuation) with narrow clears both",
449+ run: func(t *testing.T) {
450+ s := NewScreen(10, 3)
451+ s.Write([]byte("你")) // (0)=你 (1)=cont
452+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
453+ s.Write([]byte("X")) // overwrite the continuation
454+
455+ assertBlank(t, s, 0, 0) // orphaned wide left cleared
456+ assertCell(t, s, 1, 0, 'X')
457+ },
458+ },
459+ {
460+ name: "overwrite half of wide pair with a new wide char",
461+ run: func(t *testing.T) {
462+ s := NewScreen(10, 3)
463+ s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
464+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
465+ s.Write([]byte("学")) // wide write at cols 1,2
466+
467+ assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
468+ assertCell(t, s, 1, 0, '学')
469+ assertContinuation(t, s, 2, 0)
470+ assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
471+ },
472+ },
473+ {
474+ name: "erase to end of line covering a continuation clears its wide left half",
475+ run: func(t *testing.T) {
476+ s := NewScreen(10, 3)
477+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
478+ s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
479+ s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
480+
481+ assertCell(t, s, 0, 0, 'A')
482+ assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
483+ assertBlank(t, s, 2, 0)
484+ assertBlank(t, s, 3, 0)
485+ },
486+ },
487+ {
488+ name: "erase to start of line covering a wide left clears its continuation",
489+ run: func(t *testing.T) {
490+ s := NewScreen(10, 3)
491+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
492+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
493+ s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
494+
495+ assertBlank(t, s, 0, 0)
496+ assertBlank(t, s, 1, 0)
497+ assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
498+ assertCell(t, s, 3, 0, 'B')
499+ },
500+ },
501+ {
502+ name: "wrap abandoning a continuation cell clears its pair and extends damage",
503+ run: func(t *testing.T) {
504+ s := NewScreen(4, 2)
505+ s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
506+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
507+ s.ClearDamage()
508+ // A wide rune at the last column wraps; the abandoned last cell
509+ // is a continuation, so its wide left half (col 2) must be
510+ // cleared too and the damage must span both columns.
511+ s.Write([]byte("好"))
512+
513+ assertCell(t, s, 0, 0, 'A')
514+ assertCell(t, s, 1, 0, 'B')
515+ assertBlank(t, s, 2, 0) // orphaned wide left cleared
516+ assertBlank(t, s, 3, 0) // abandoned continuation cleared
517+ assertCell(t, s, 0, 1, '好')
518+ assertContinuation(t, s, 1, 1)
519+ if s.Col != 2 || s.Row != 1 {
520+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
521+ }
522+
523+ d := s.Damage()
524+ if len(d) == 0 {
525+ t.Fatal("expected damage")
526+ }
527+ // First damage item covers the cleared pair on row 0.
528+ if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
529+ t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width)
530+ }
531+ },
532+ },
533+ {
534+ name: "insert chars at a continuation splits the pair and repairs orphans",
535+ run: func(t *testing.T) {
536+ s := NewScreen(6, 2)
537+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
538+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
539+ s.ClearDamage()
540+ s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
541+
542+ // The shift splits 好/cont: 好 stays at col 2, its continuation
543+ // moves to col 4. Both orphans must be repaired to blanks.
544+ assertCell(t, s, 0, 0, '你')
545+ assertContinuation(t, s, 1, 0)
546+ assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
547+ assertBlank(t, s, 3, 0) // inserted blank
548+ assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
549+ assertBlank(t, s, 5, 0)
550+
551+ d := s.Damage()
552+ if len(d) == 0 {
553+ t.Fatal("expected damage")
554+ }
555+ // Damage must extend one column left to cover the repaired 好.
556+ if d[0].X != 2 || d[0].Width != 4 {
557+ t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
558+ }
559+ },
560+ },
561+ {
562+ name: "insert chars shifting a wide pair off the right edge repairs the left half",
563+ run: func(t *testing.T) {
564+ s := NewScreen(4, 2)
565+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
566+ s.Write([]byte("\x1b[1;1H")) // cursor home
567+ s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
568+
569+ // 好 lands on the last column with its continuation pushed off
570+ // the edge; the orphaned wide left must be blanked.
571+ assertBlank(t, s, 0, 0)
572+ assertCell(t, s, 1, 0, '你')
573+ assertContinuation(t, s, 2, 0)
574+ assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
575+ },
576+ },
577+ {
578+ name: "delete char at the left half of a wide pair repairs the orphan",
579+ run: func(t *testing.T) {
580+ s := NewScreen(6, 2)
581+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
582+ s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
583+ s.ClearDamage()
584+ s.Write([]byte("\x1b[1P")) // DCH 1
585+
586+ // 你's continuation shifts to col 0 with no left half → repaired.
587+ assertBlank(t, s, 0, 0)
588+ assertCell(t, s, 1, 0, '好')
589+ assertContinuation(t, s, 2, 0)
590+ assertCell(t, s, 3, 0, 'A')
591+ assertCell(t, s, 4, 0, 'B')
592+ assertBlank(t, s, 5, 0)
593+
594+ d := s.Damage()
595+ if len(d) == 0 {
596+ t.Fatal("expected damage")
597+ }
598+ if d[0].X != 0 || d[0].Width != 6 {
599+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
600+ }
601+ },
602+ },
603+ {
604+ name: "delete char at a continuation repairs the wide left and extends damage",
605+ run: func(t *testing.T) {
606+ s := NewScreen(6, 2)
607+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
608+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
609+ s.ClearDamage()
610+ s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
611+
612+ // 你 at col 0 loses its continuation → repaired to blank.
613+ assertBlank(t, s, 0, 0)
614+ assertCell(t, s, 1, 0, '好')
615+ assertContinuation(t, s, 2, 0)
616+ assertCell(t, s, 3, 0, 'A')
617+ assertCell(t, s, 4, 0, 'B')
618+ assertBlank(t, s, 5, 0)
619+
620+ d := s.Damage()
621+ if len(d) == 0 {
622+ t.Fatal("expected damage")
623+ }
624+ // Damage must extend one column left (to col 0) to cover the
625+ // repaired wide left half.
626+ if d[0].X != 0 || d[0].Width != 6 {
627+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
628+ }
629+ },
630+ },
631+ {
632+ name: "IRM inserts narrow printable cells and reset restores overwrite",
633+ run: func(t *testing.T) {
634+ s := NewScreen(8, 1)
635+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
636+
637+ if got := lineText(s, 0); got != "abXcd " {
638+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
639+ }
640+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
641+ if got := lineText(s, 0); got != "abYcd " {
642+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
643+ }
644+ },
645+ },
646+ {
647+ name: "IRM inserts wide printable cells and repairs evicted wide head",
648+ run: func(t *testing.T) {
649+ s := NewScreen(6, 1)
650+ s.Write([]byte("ab界cd"))
651+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
652+
653+ assertCell(t, s, 0, 0, 'a')
654+ assertCell(t, s, 1, 0, 'b')
655+ assertCell(t, s, 2, 0, '好')
656+ assertContinuation(t, s, 3, 0)
657+ assertCell(t, s, 4, 0, '界')
658+ assertContinuation(t, s, 5, 0)
659+ assertNoOrphanWideCells(t, s)
660+ },
661+ },
662+ {
663+ name: "IRM wide insert at right edge repairs orphan",
664+ run: func(t *testing.T) {
665+ s := NewScreen(5, 1)
666+ s.Write([]byte("abc界"))
667+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
668+
669+ assertCell(t, s, 0, 0, 'a')
670+ assertCell(t, s, 1, 0, '好')
671+ assertContinuation(t, s, 2, 0)
672+ assertCell(t, s, 3, 0, 'b')
673+ assertCell(t, s, 4, 0, 'c')
674+ assertNoOrphanWideCells(t, s)
675+ },
676+ },
677+ {
678+ name: "wide char on a width-1 screen degrades to a single cell",
679+ run: func(t *testing.T) {
680+ s := NewScreen(1, 2)
681+ // A wide rune cannot fit on a 1-column screen; it must not write
682+ // an out-of-bounds continuation cell.
683+ s.Write([]byte("你"))
684+
685+ assertCell(t, s, 0, 0, '你')
686+ if s.Col != 1 {
687+ t.Errorf("cursor at col=%d, want 1", s.Col)
688+ }
689+ },
690+ },
691+ {
692+ name: "wide pair survives a scroll",
693+ run: func(t *testing.T) {
694+ s := NewScreen(4, 2)
695+ s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
696+ s.Write([]byte("\r\n")) // bottom row → scroll up
697+
698+ // After scrolling up, row1's 好 moves to row0 intact.
699+ assertCell(t, s, 0, 0, '好')
700+ assertContinuation(t, s, 1, 0)
701+ },
702+ },
703+ {
704+ name: "combining mark alone writes no cell and produces no damage",
705+ run: func(t *testing.T) {
706+ s := NewScreen(10, 3)
707+ s.ClearDamage()
708+ s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
709+
710+ // No cells should be modified.
711+ for y := range 3 {
712+ for x := range 10 {
713+ if c := cellAt(s, x, y); c.Rune != ' ' {
714+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
715+ }
716+ }
717+ }
718+ if s.Col != 0 || s.Row != 0 {
719+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
720+ }
721+ d := s.Damage()
722+ if len(d) != 0 {
723+ t.Errorf("expected no damage for combining mark alone, got %+v", d)
724+ }
725+ },
726+ },
727+ }
728+
729+ for _, tt := range tests {
730+ t.Run(tt.name, tt.run)
731+ }
732+}
733+
734+func TestRuneWidth(t *testing.T) {
735+ tests := []struct {
736+ name string
737+ r rune
738+ width int
739+ }{
740+ {"space", ' ', 1},
741+ {"lowercase ascii letter", 'a', 1},
742+ {"uppercase ascii letter", 'A', 1},
743+ {"ascii digit", '1', 1},
744+ {"soft hyphen", 0x00AD, 0},
745+ {"combining grapheme joiner", 0x034F, 0},
746+ {"combining acute accent", 0x0301, 0},
747+ {"combining grave accent", 0x0300, 0},
748+ {"zero-width space", 0x200B, 0},
749+ {"zero-width non-joiner", 0x200C, 0},
750+ {"zero-width joiner", 0x200D, 0},
751+ {"BOM / zero-width no-break space", 0xFEFF, 0},
752+ {"CJK Unified Ideograph (一)", 0x4E00, 2},
753+ {"CJK (二)", 0x4E8C, 2},
754+ {"CJK end of BMP", 0x9FFF, 2},
755+ {"CJK Extension A", 0x3400, 2},
756+ {"Hangul Syllable (가)", 0xAC00, 2},
757+ {"Hangul Syllable end", 0xD7AF, 2},
758+ {"CJK Ideographic Space", 0x3000, 2},
759+ {"Fullwidth Exclamation Mark", 0xFF01, 2},
760+ {"Fullwidth Cent Sign", 0xFFE0, 2},
761+ {"grinning face emoji", 0x1F600, 2},
762+ {"rocket emoji", 0x1F680, 2},
763+ {"hiragana a", 0x3042, 2},
764+ {"katakana a", 0x30A2, 2},
765+ {"CJK Extension B", 0x20000, 2},
766+ {"supplemental symbols (robot)", 0x1F916, 2},
767+ {"symbols extended-A (sari)", 0x1FA71, 2},
768+ {"ideographic full stop", 0x3002, 2},
769+ {"NUL", '\x00', 0},
770+ {"SOH", '\x01', 0},
771+ {"ESC", '\x1b', 0},
772+ {"DEL", 0x7F, 0},
773+ }
774+
775+ for _, tt := range tests {
776+ t.Run(tt.name, func(t *testing.T) {
777+ got := renderer.RuneWidth(tt.r)
778+ if got != tt.width {
779+ t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
780+ }
781+ })
782+ }
783+}
784+
785+func TestC1Controls(t *testing.T) {
786+ tests := []struct {
787+ name string
788+ run func(t *testing.T)
789+ }{
790+ {
791+ name: "C1 controls are dropped without moving the cursor",
792+ run: func(t *testing.T) {
793+ s := NewScreen(10, 3)
794+ s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
795+ s.Write([]byte{0xC2, 0x9F}) // U+009F APC
796+
797+ // No cells should be modified.
798+ for y := range 3 {
799+ for x := range 10 {
800+ if c := cellAt(s, x, y); c.Rune != ' ' {
801+ t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
802+ }
803+ }
804+ }
805+ // Cursor should not have moved.
806+ if s.Col != 0 || s.Row != 0 {
807+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
808+ }
809+ },
810+ },
811+ {
812+ name: "the full C1 control range U+0080-U+009F is dropped",
813+ run: func(t *testing.T) {
814+ s := NewScreen(10, 3)
815+ // Send all C1 control runes U+0080 through U+009F.
816+ for r := 0x80; r <= 0x9F; r++ {
817+ s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
818+ }
819+
820+ // No cells should be modified.
821+ for y := range 3 {
822+ for x := range 10 {
823+ if c := cellAt(s, x, y); c.Rune != ' ' {
824+ t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
825+ }
826+ }
827+ }
828+ if s.Col != 0 || s.Row != 0 {
829+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
830+ }
831+ },
832+ },
833+ {
834+ name: "printable characters still work after C1 controls",
835+ run: func(t *testing.T) {
836+ s := NewScreen(10, 3)
837+ // Printable characters should still work after C1 controls.
838+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
839+ s.Write([]byte("AB"))
840+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
841+ s.Write([]byte("CD"))
842+
843+ assertCell(t, s, 0, 0, 'A')
844+ assertCell(t, s, 1, 0, 'B')
845+ assertCell(t, s, 2, 0, 'C')
846+ assertCell(t, s, 3, 0, 'D')
847+ if s.Col != 4 {
848+ t.Errorf("cursor at col=%d, want 4", s.Col)
849+ }
850+ },
851+ },
852+ {
853+ name: "C1 controls mixed with CR preserve CR behavior",
854+ run: func(t *testing.T) {
855+ s := NewScreen(10, 3)
856+ // C1 controls mixed with CR/LF should preserve CR/LF behavior.
857+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
858+ s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
859+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
860+
861+ assertCell(t, s, 0, 0, 'B')
862+ },
863+ },
864+ {
865+ name: "C1 controls mixed with LF preserve LF behavior",
866+ run: func(t *testing.T) {
867+ s := NewScreen(10, 3)
868+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
869+ s.Write([]byte("A\nB"))
870+
871+ assertCell(t, s, 0, 0, 'A')
872+ assertCell(t, s, 1, 1, 'B')
873+ if s.Row != 1 || s.Col != 2 {
874+ t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
875+ }
876+ },
877+ },
878+ {
879+ name: "C1 controls produce no damage",
880+ run: func(t *testing.T) {
881+ s := NewScreen(10, 3)
882+ s.ClearDamage()
883+ // C1 controls should produce no damage.
884+ s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
885+
886+ d := s.Damage()
887+ if len(d) != 0 {
888+ t.Errorf("expected no damage from C1 controls, got %+v", d)
889+ }
890+ },
891+ },
892+ }
893+
894+ for _, tt := range tests {
895+ t.Run(tt.name, tt.run)
896+ }
897+}

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