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+}
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+}
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+}
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+}
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+}
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+}
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+}
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+}
D pkg/vt/screen_test.go +0 -3337
@@ -1,3337 +0,0 @@
1-package vt
2-
3-import (
4- "math/rand"
5- "strings"
6- "testing"
7-
8- "github.com/bnema/vev/pkg/renderer"
9- "github.com/stretchr/testify/require"
10-)
11-
12-// ---------------------------------------------------------------------------
13-// Helpers
14-// ---------------------------------------------------------------------------
15-
16-func damageKinds(d []renderer.Damage) []renderer.DamageKind {
17- ks := make([]renderer.DamageKind, len(d))
18- for i, dd := range d {
19- ks[i] = dd.Kind
20- }
21- return ks
22-}
23-
24-func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
25- for _, dd := range d {
26- if dd.Kind == kind {
27- return true
28- }
29- }
30- return false
31-}
32-
33-func cellAt(s *Screen, x, y int) renderer.Cell {
34- return s.Frame.At(x, y)
35-}
36-
37-func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
38- t.Helper()
39- c := cellAt(s, x, y)
40- if c.Rune != expected {
41- t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
42- }
43- if c.Continuation {
44- t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
45- }
46-}
47-
48-// assertContinuation asserts the cell at (x,y) is the right half of a
49-// wide-character pair (Continuation set, Rune 0).
50-func assertContinuation(t *testing.T, s *Screen, x, y int) {
51- t.Helper()
52- c := cellAt(s, x, y)
53- if !c.Continuation {
54- t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
55- }
56- if c.Rune != 0 {
57- t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
58- }
59-}
60-
61-// assertBlank asserts the cell at (x,y) is a blank default cell.
62-func assertBlank(t *testing.T, s *Screen, x, y int) {
63- t.Helper()
64- c := cellAt(s, x, y)
65- if c.Rune != ' ' || c.Continuation {
66- t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
67- }
68-}
69-
70-func assertNoOrphanWideCells(t *testing.T, s *Screen) {
71- t.Helper()
72- for y := range s.Frame.Height {
73- for x := range s.Frame.Width {
74- c := cellAt(s, x, y)
75- if c.Continuation {
76- if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
77- t.Fatalf("orphan continuation at (%d,%d)", x, y)
78- }
79- continue
80- }
81- if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
82- t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
83- }
84- }
85- }
86-}
87-
88-func lineText(s *Screen, y int) string {
89- out := make([]rune, s.Frame.Width)
90- for x := range s.Frame.Width {
91- out[x] = s.Frame.At(x, y).Rune
92- }
93- return string(out)
94-}
95-
96-func TestParseCSIInts(t *testing.T) {
97- t.Parallel()
98-
99- tests := []struct {
100- name string
101- params string
102- want []int
103- }{
104- {name: "empty", params: "", want: nil},
105- {name: "single", params: "5", want: []int{5}},
106- {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
107- {name: "leading empty token", params: ";5", want: []int{0, 5}},
108- {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
109- {name: "trailing empty token", params: "5;", want: []int{5, 0}},
110- {name: "question prefix", params: "?25", want: []int{25}},
111- {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
112- {name: "prefix order", params: "?>7", want: []int{7}},
113- {name: "junk token", params: "12x;4", want: []int{0, 4}},
114- }
115-
116- for _, tt := range tests {
117- t.Run(tt.name, func(t *testing.T) {
118- require.Equal(t, tt.want, parseCSIInts(tt.params))
119- })
120- }
121-}
122-
123-func TestParseSGRInts(t *testing.T) {
124- t.Parallel()
125-
126- tests := []struct {
127- name string
128- params string
129- want []int
130- }{
131- {name: "empty", params: "", want: nil},
132- {name: "single", params: "1", want: []int{1}},
133- {name: "multiple", params: "1;31", want: []int{1, 31}},
134- {name: "leading empty token", params: ";5", want: []int{0, 5}},
135- {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
136- {name: "trailing empty token", params: "5;", want: []int{5, 0}},
137- {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
138- {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
139- {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
140- {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
141- {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
142- {name: "non color colon group", params: "1:2:3", want: []int{1}},
143- {name: "junk token", params: "abc;31", want: []int{0, 31}},
144- }
145-
146- for _, tt := range tests {
147- t.Run(tt.name, func(t *testing.T) {
148- require.Equal(t, tt.want, parseSGRInts(tt.params))
149- })
150- }
151-}
152-
153-func rowString(row []renderer.Cell) string {
154- out := make([]rune, len(row))
155- for i, cell := range row {
156- out[i] = cell.Rune
157- }
158- return string(out)
159-}
160-
161-// ---------------------------------------------------------------------------
162-// Bell / notifications
163-// ---------------------------------------------------------------------------
164-
165-func TestOnBellFiresForLoneBEL(t *testing.T) {
166- s := NewScreen(10, 2)
167- rang := 0
168- s.OnBell = func() { rang++ }
169- s.Write([]byte("hi\ahi\a"))
170- require.Equal(t, 2, rang)
171- require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
172-}
173-
174-func TestOnBellIgnoresOSCTerminator(t *testing.T) {
175- s := NewScreen(10, 2)
176- rang := 0
177- s.OnBell = func() { rang++ }
178- s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
179- require.Equal(t, 0, rang)
180-}
181-
182-func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
183- s := NewScreen(10, 2)
184- s.Write([]byte("a\ab")) // no OnBell set: must not panic
185- require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
186-}
187-
188-func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
189- tests := []struct {
190- name string
191- seq string
192- want string
193- }{
194- {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
195- {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
196- {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
197- {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
198- }
199-
200- for _, tt := range tests {
201- t.Run(tt.name, func(t *testing.T) {
202- s := NewScreen(10, 2)
203- bells := 0
204- s.OnBell = func() { bells++ }
205- s.ClearDamage()
206-
207- s.Write([]byte(tt.seq))
208-
209- require.Equal(t, tt.want, s.TerminalTitle())
210- require.Equal(t, 0, bells)
211- require.Empty(t, s.Damage())
212- require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
213- })
214- }
215-}
216-
217-func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
218- s := NewScreen(10, 2)
219-
220- s.Write([]byte("\x1b]0;split"))
221- s.Write([]byte(" title\x1b"))
222- require.Empty(t, s.TerminalTitle())
223- s.Write([]byte("\\"))
224- require.Equal(t, "split title", s.TerminalTitle())
225-
226- s.Write([]byte("\x1b]2;replacement\x07"))
227- require.Equal(t, "replacement", s.TerminalTitle())
228-
229- s.Write([]byte("\x1b]0;\x07"))
230- require.Empty(t, s.TerminalTitle())
231-}
232-
233-func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
234- s := NewScreen(10, 2)
235- s.Write([]byte("\x1b]0;retained\x07"))
236-
237- for _, seq := range []string{
238- "\x1b]1;icon title\x07",
239- "\x1b]20;not selector two\x1b\\",
240- "\x1b]777;other;x;y\x07",
241- } {
242- s.Write([]byte(seq))
243- require.Equal(t, "retained", s.TerminalTitle())
244- }
245-
246- payload := strings.Repeat("x", maxEscapeBufferLen+1)
247- s.Write([]byte("\x1b]2;" + payload))
248- require.Equal(t, "retained", s.TerminalTitle())
249- require.Empty(t, s.escapeBuf)
250-}
251-
252-func TestOnNotifyOSC9(t *testing.T) {
253- s := NewScreen(10, 2)
254- var gotTitle, gotBody string
255- calls := 0
256- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
257- s.Write([]byte("\x1b]9;agent done\x07"))
258- require.Equal(t, 1, calls)
259- require.Equal(t, "", gotTitle)
260- require.Equal(t, "agent done", gotBody)
261-}
262-
263-func TestOSC9ProgressTransitions(t *testing.T) {
264- tests := []struct {
265- name string
266- seqs []string
267- want []bool
268- }{
269- {
270- name: "finish fires once",
271- seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
272- want: []bool{false},
273- },
274- {
275- name: "error fires once repeat no fire",
276- seqs: []string{"9;4;2;0", "9;4;2;50"},
277- want: []bool{true},
278- },
279- {
280- name: "indeterminate clear fires",
281- seqs: []string{"9;4;3", "9;4;0"},
282- want: []bool{false},
283- },
284- {
285- name: "paused clear fires",
286- seqs: []string{"9;4;4;80", "9;4;0;80"},
287- want: []bool{false},
288- },
289- {
290- name: "ongoing updates silent",
291- seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
292- },
293- {
294- name: "bare clear silent",
295- seqs: []string{"9;4;0"},
296- },
297- {
298- name: "bare progress ignored",
299- seqs: []string{"9;4"},
300- },
301- {
302- name: "unparseable ignored",
303- seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
304- want: []bool{false},
305- },
306- {
307- name: "error active clear fires error then finish",
308- seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
309- want: []bool{true, false},
310- },
311- {
312- name: "error clear only fires error",
313- seqs: []string{"9;4;2;0", "9;4;0;100"},
314- want: []bool{true},
315- },
316- }
317-
318- for _, tt := range tests {
319- t.Run(tt.name, func(t *testing.T) {
320- s := NewScreen(10, 2)
321- var got []bool
322- s.OnProgress = func(errored bool) { got = append(got, errored) }
323- for _, seq := range tt.seqs {
324- s.Write([]byte("\x1b]" + seq + "\x07"))
325- }
326- require.Equal(t, tt.want, got)
327- })
328- }
329-}
330-
331-func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
332- s := NewScreen(10, 2)
333- calls := 0
334- s.OnProgress = func(bool) { calls++ }
335- s.Write([]byte("\x1b]9;4;1"))
336- s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
337- require.Equal(t, 1, calls)
338-}
339-
340-func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
341- s := NewScreen(10, 2)
342- require.NotPanics(t, func() {
343- s.Write([]byte("\x1b]9;4;1;50\x07"))
344- s.Write([]byte("\x1b]9;4;0;100\x07"))
345- s.Write([]byte("\x1b]9;4;2;0\x07"))
346- })
347-}
348-
349-func TestOSC9ProgressSTTerminated(t *testing.T) {
350- s := NewScreen(10, 2)
351- var got []bool
352- s.OnProgress = func(errored bool) { got = append(got, errored) }
353- s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
354- require.Equal(t, []bool{false}, got)
355-}
356-
357-func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
358- s := NewScreen(10, 2)
359- s.Write([]byte("\x1b]9;4;1;50\x07"))
360- var got []bool
361- s.OnProgress = func(errored bool) { got = append(got, errored) }
362- s.Write([]byte("\x1b]9;4;0;100\x07"))
363- require.Equal(t, []bool{false}, got)
364-}
365-
366-func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
367- s := NewScreen(10, 2)
368- calls := 0
369- s.OnNotify = func(string, string) { calls++ }
370- s.Write([]byte("\x1b]9;4;1;50\x07"))
371- s.Write([]byte("\x1b]9;4;0;100\x07"))
372- s.Write([]byte("\x1b]9;4;2;0\x07"))
373- require.Equal(t, 0, calls)
374-}
375-
376-func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
377- s := NewScreen(10, 2)
378- var gotTitle, gotBody string
379- calls := 0
380- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
381- s.Write([]byte("\x1b]9;40;not progress\x07"))
382- require.Equal(t, 1, calls)
383- require.Equal(t, "", gotTitle)
384- require.Equal(t, "40;not progress", gotBody)
385-}
386-
387-func TestOnNotifyOSC777STTerminated(t *testing.T) {
388- s := NewScreen(10, 2)
389- var gotTitle, gotBody string
390- calls := 0
391- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
392- s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
393- require.Equal(t, 1, calls)
394- require.Equal(t, "Claude", gotTitle)
395- require.Equal(t, "needs input", gotBody)
396-}
397-
398-func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
399- s := NewScreen(10, 2)
400- calls := 0
401- s.OnNotify = func(string, string) { calls++ }
402- s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
403- s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
404- require.Equal(t, 0, calls)
405-}
406-
407-func TestOnNotifySplitAcrossWrites(t *testing.T) {
408- s := NewScreen(10, 2)
409- calls := 0
410- s.OnNotify = func(string, string) { calls++ }
411- s.Write([]byte("\x1b]9;par"))
412- s.Write([]byte("tial\x07"))
413- require.Equal(t, 1, calls)
414-}
415-
416-func TestOnClipboardBELTerminated(t *testing.T) {
417- s := NewScreen(10, 2)
418- var got string
419- calls := 0
420- s.OnClipboard = func(b64 string) { got = b64; calls++ }
421- s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
422- require.Equal(t, 1, calls)
423- require.Equal(t, "aGVsbG8=", got)
424-}
425-
426-func TestOnClipboardSTTerminated(t *testing.T) {
427- s := NewScreen(10, 2)
428- var got string
429- calls := 0
430- s.OnClipboard = func(b64 string) { got = b64; calls++ }
431- s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
432- require.Equal(t, 1, calls)
433- require.Equal(t, "aGVsbG8=", got)
434-}
435-
436-func TestOnClipboardQueryIgnored(t *testing.T) {
437- s := NewScreen(10, 2)
438- calls := 0
439- s.OnClipboard = func(string) { calls++ }
440- s.Write([]byte("\x1b]52;c;?\x07"))
441- require.Equal(t, 0, calls)
442-}
443-
444-func TestOnClipboardEmptySelection(t *testing.T) {
445- s := NewScreen(10, 2)
446- var got string
447- calls := 0
448- s.OnClipboard = func(b64 string) { got = b64; calls++ }
449- s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
450- require.Equal(t, 1, calls)
451- require.Equal(t, "aGVsbG8=", got)
452-}
453-
454-func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
455- s := NewScreen(10, 2)
456- require.NotPanics(t, func() {
457- s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
458- })
459-}
460-
461-func TestOnClipboardSplitAcrossWrites(t *testing.T) {
462- s := NewScreen(10, 2)
463- var got string
464- calls := 0
465- s.OnClipboard = func(b64 string) { got = b64; calls++ }
466- s.Write([]byte("\x1b]52;c;aGVs"))
467- s.Write([]byte("bG8=\x07"))
468- require.Equal(t, 1, calls)
469- require.Equal(t, "aGVsbG8=", got)
470-}
471-
472-func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
473- s := NewScreen(10, 2)
474- calls := 0
475- s.OnClipboard = func(string) { calls++ }
476- s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
477- require.Equal(t, 0, calls)
478-}
479-
480-// ---------------------------------------------------------------------------
481-// Printable ASCII / UTF-8
482-// ---------------------------------------------------------------------------
483-
484-func TestWritePrintableAndUTF8(t *testing.T) {
485- tests := []struct {
486- name string
487- run func(t *testing.T)
488- }{
489- {
490- name: "printable ASCII advances cursor and records damage",
491- run: func(t *testing.T) {
492- s := NewScreen(5, 3)
493- s.Write([]byte("Hi"))
494-
495- assertCell(t, s, 0, 0, 'H')
496- assertCell(t, s, 1, 0, 'i')
497- if s.Col != 2 || s.Row != 0 {
498- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
499- }
500-
501- // Should have damage.
502- d := s.Damage()
503- if len(d) == 0 {
504- t.Fatal("expected damage after writing")
505- }
506- },
507- },
508- {
509- name: "UTF-8 multi-byte runes occupy one cell each",
510- run: func(t *testing.T) {
511- s := NewScreen(10, 3)
512- s.Write([]byte("aäéø©"))
513-
514- assertCell(t, s, 0, 0, 'a')
515- assertCell(t, s, 1, 0, 'ä')
516- assertCell(t, s, 2, 0, 'é')
517- assertCell(t, s, 3, 0, 'ø')
518- assertCell(t, s, 4, 0, '©')
519- },
520- },
521- {
522- name: "printable beyond width wraps to next line",
523- run: func(t *testing.T) {
524- s := NewScreen(4, 2)
525- s.Write([]byte("ABCDE"))
526-
527- assertCell(t, s, 0, 0, 'A')
528- assertCell(t, s, 1, 0, 'B')
529- assertCell(t, s, 2, 0, 'C')
530- assertCell(t, s, 3, 0, 'D')
531- // E wrapped to next line.
532- assertCell(t, s, 0, 1, 'E')
533- },
534- },
535- {
536- name: "printable beyond screen height scrolls",
537- run: func(t *testing.T) {
538- s := NewScreen(3, 2)
539- // Fill both lines.
540- s.Write([]byte("ABC"))
541- s.Write([]byte("DEF"))
542- // Row is now 1 (bottom). One more char should scroll.
543- s.Write([]byte("G"))
544-
545- // After scroll: old row 0 = "DEF", new row 1 = "G ".
546- assertCell(t, s, 0, 0, 'D')
547- assertCell(t, s, 1, 0, 'E')
548- assertCell(t, s, 2, 0, 'F')
549- assertCell(t, s, 0, 1, 'G')
550- assertCell(t, s, 1, 1, ' ')
551-
552- // Check scroll damage.
553- d := s.Damage()
554- if !hasDamageKind(d, renderer.DamageScrollUp) {
555- t.Error("expected scroll damage")
556- }
557- },
558- },
559- }
560-
561- for _, tt := range tests {
562- t.Run(tt.name, tt.run)
563- }
564-}
565-
566-// ---------------------------------------------------------------------------
567-// Carriage Return / Line Feed / Backspace / Tab
568-// ---------------------------------------------------------------------------
569-
570-func TestCursorControlChars(t *testing.T) {
571- tests := []struct {
572- name string
573- run func(t *testing.T)
574- }{
575- {
576- name: "CR moves column back to 0",
577- run: func(t *testing.T) {
578- s := NewScreen(10, 3)
579- s.Write([]byte("Hello"))
580- // CR moves column back to 0.
581- s.Write([]byte("\rWorld"))
582-
583- assertCell(t, s, 0, 0, 'W')
584- assertCell(t, s, 1, 0, 'o')
585- assertCell(t, s, 2, 0, 'r')
586- assertCell(t, s, 3, 0, 'l')
587- assertCell(t, s, 4, 0, 'd')
588- if s.Col != 5 {
589- t.Errorf("col after CR + World = %d, want 5", s.Col)
590- }
591- },
592- },
593- {
594- name: "LF with CR moves to next line column 0",
595- run: func(t *testing.T) {
596- s := NewScreen(5, 3)
597- s.Write([]byte("A\r\nB\r\nC"))
598-
599- assertCell(t, s, 0, 0, 'A')
600- assertCell(t, s, 0, 1, 'B')
601- assertCell(t, s, 0, 2, 'C')
602- if s.Row != 2 || s.Col != 1 {
603- t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
604- }
605- },
606- },
607- {
608- name: "LF alone does not reset column",
609- run: func(t *testing.T) {
610- s := NewScreen(5, 3)
611- s.Write([]byte("AB\nC"))
612-
613- assertCell(t, s, 0, 0, 'A')
614- assertCell(t, s, 1, 0, 'B')
615- assertCell(t, s, 0, 1, ' ')
616- assertCell(t, s, 2, 1, 'C')
617- if s.Row != 1 || s.Col != 3 {
618- t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
619- }
620- },
621- },
622- {
623- name: "LF at pending wrap boundary advances exactly one line",
624- run: func(t *testing.T) {
625- s := NewScreen(5, 4)
626- s.Write([]byte("ABCDE\nZ"))
627-
628- assertCell(t, s, 4, 0, 'E')
629- assertCell(t, s, 4, 1, 'Z')
630- if s.Row != 1 || s.Col != 5 {
631- t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
632- }
633- },
634- },
635- {
636- name: "LF scrolls when both lines are full",
637- run: func(t *testing.T) {
638- s := NewScreen(5, 2)
639- // Fill both lines then CRLF to scroll and return to column 0.
640- s.Write([]byte("AAAAA"))
641- s.Write([]byte("BBBBB"))
642- s.Write([]byte("\r\nCCCCC"))
643-
644- assertCell(t, s, 0, 0, 'B')
645- assertCell(t, s, 4, 1, 'C')
646- },
647- },
648- {
649- name: "BS moves cursor back without touching the cell",
650- run: func(t *testing.T) {
651- s := NewScreen(10, 3)
652- s.Write([]byte("Hello\b"))
653-
654- if s.Col != 4 {
655- t.Errorf("col after BS = %d, want 4", s.Col)
656- }
657- assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
658- },
659- },
660- {
661- name: "BS at column zero is a no-op",
662- run: func(t *testing.T) {
663- s := NewScreen(10, 3)
664- s.Write([]byte("\b")) // BS at col 0 should be no-op
665-
666- if s.Col != 0 {
667- t.Errorf("col = %d, want 0", s.Col)
668- }
669- },
670- },
671- {
672- name: "tab advances to next 8-column boundary",
673- run: func(t *testing.T) {
674- s := NewScreen(20, 3)
675- s.Write([]byte("A\tB"))
676-
677- assertCell(t, s, 0, 0, 'A')
678- // Tab advances to next 8-column boundary (col 8).
679- for i := 1; i < 8; i++ {
680- assertCell(t, s, i, 0, ' ')
681- }
682- assertCell(t, s, 8, 0, 'B')
683- },
684- },
685- }
686-
687- for _, tt := range tests {
688- t.Run(tt.name, tt.run)
689- }
690-}
691-
692-// ---------------------------------------------------------------------------
693-// CSI SGR
694-// ---------------------------------------------------------------------------
695-
696-func TestSGR(t *testing.T) {
697- tests := []struct {
698- name string
699- seq string
700- check func(t *testing.T, s *Screen)
701- }{
702- {
703- name: "SGR 0 resets to default style",
704- seq: "\x1b[0m",
705- check: func(t *testing.T, s *Screen) {
706- if s.Style != (renderer.DefaultStyle()) {
707- t.Errorf("style after SGR 0 = %+v, want default", s.Style)
708- }
709- },
710- },
711- {
712- name: "SGR 1 sets bold",
713- seq: "\x1b[1m",
714- check: func(t *testing.T, s *Screen) {
715- if !s.Style.Bold {
716- t.Error("expected bold")
717- }
718- },
719- },
720- {
721- name: "SGR 7 sets inverse",
722- seq: "\x1b[7m",
723- check: func(t *testing.T, s *Screen) {
724- if !s.Style.Inverse {
725- t.Error("expected inverse")
726- }
727- },
728- },
729- {
730- name: "SGR 31 sets red foreground",
731- seq: "\x1b[31m",
732- check: func(t *testing.T, s *Screen) {
733- if s.Style.Foreground != 1 {
734- t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
735- }
736- },
737- },
738- {
739- name: "SGR 44 sets blue background",
740- seq: "\x1b[44m",
741- check: func(t *testing.T, s *Screen) {
742- if s.Style.Background != 4 {
743- t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
744- }
745- },
746- },
747- {
748- name: "SGR 38;5 sets 256-color foreground",
749- seq: "\x1b[38;5;82m",
750- check: func(t *testing.T, s *Screen) {
751- if s.Style.Foreground != 82 {
752- t.Errorf("foreground = %d, want 82", s.Style.Foreground)
753- }
754- },
755- },
756- {
757- name: "SGR 48;5 sets 256-color background",
758- seq: "\x1b[48;5;200m",
759- check: func(t *testing.T, s *Screen) {
760- if s.Style.Background != 200 {
761- t.Errorf("background = %d, want 200", s.Style.Background)
762- }
763- },
764- },
765- {
766- name: "SGR 91 sets bright red foreground",
767- seq: "\x1b[91m",
768- check: func(t *testing.T, s *Screen) {
769- if s.Style.Foreground != 9 {
770- t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
771- }
772- },
773- },
774- {
775- name: "SGR 107 sets bright white background",
776- seq: "\x1b[107m",
777- check: func(t *testing.T, s *Screen) {
778- if s.Style.Background != 15 {
779- t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
780- }
781- },
782- },
783- {
784- name: "SGR 22 resets bold",
785- seq: "\x1b[1mHello\x1b[22mWorld",
786- check: func(t *testing.T, s *Screen) {
787- if s.Style.Bold {
788- t.Error("bold should be reset after 22")
789- }
790- },
791- },
792- {
793- name: "SGR 27 resets inverse",
794- seq: "\x1b[7m\x1b[27m",
795- check: func(t *testing.T, s *Screen) {
796- if s.Style.Inverse {
797- t.Error("inverse should be reset after 27")
798- }
799- },
800- },
801- {
802- name: "SGR 39 resets foreground to default",
803- seq: "\x1b[31m\x1b[39m",
804- check: func(t *testing.T, s *Screen) {
805- if s.Style.Foreground != -1 {
806- t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
807- }
808- },
809- },
810- {
811- name: "SGR 49 resets background to default",
812- seq: "\x1b[44m\x1b[49m",
813- check: func(t *testing.T, s *Screen) {
814- if s.Style.Background != -1 {
815- t.Errorf("background should be default after 49, got %d", s.Style.Background)
816- }
817- },
818- },
819- {
820- name: "SGR style is applied to written cells",
821- seq: "\x1b[1;31mX",
822- check: func(t *testing.T, s *Screen) {
823- c := cellAt(s, 0, 0)
824- if !c.Style.Bold {
825- t.Error("cell should be bold")
826- }
827- if c.Style.Foreground != 1 {
828- t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
829- }
830- },
831- },
832- {
833- name: "multiple sequential SGR sequences accumulate",
834- seq: "\x1b[31m\x1b[1m\x1b[44mX",
835- check: func(t *testing.T, s *Screen) {
836- c := cellAt(s, 0, 0)
837- if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
838- t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
839- }
840- },
841- },
842- {
843- name: "SGR 3 sets italic on style and cell, SGR 23 clears it",
844- seq: "\x1b[3mX\x1b[23mY",
845- check: func(t *testing.T, s *Screen) {
846- if !cellAt(s, 0, 0).Style.Italic {
847- t.Errorf("first cell italic = false, want true")
848- }
849- if cellAt(s, 1, 0).Style.Italic {
850- t.Errorf("second cell italic = true, want false after SGR 23")
851- }
852- if s.Style.Italic {
853- t.Errorf("current style italic = true, want false after SGR 23")
854- }
855- },
856- },
857- {
858- name: "SGR preserves dim underline blink and strikethrough with resets",
859- seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
860- check: func(t *testing.T, s *Screen) {
861- first := cellAt(s, 0, 0).Style
862- if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
863- t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
864- }
865- second := cellAt(s, 1, 0).Style
866- if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
867- t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
868- }
869- },
870- },
871- {
872- name: "SGR 22 resets both bold and dim",
873- seq: "\x1b[1;2m\x1b[22m",
874- check: func(t *testing.T, s *Screen) {
875- if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
876- t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
877- }
878- },
879- },
880- {
881- name: "SGR underline style and color are preserved and reset",
882- seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
883- check: func(t *testing.T, s *Screen) {
884- first := cellAt(s, 0, 0).Style
885- if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
886- t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
887- }
888- want := renderer.RGB{R: 255, G: 0, B: 0}
889- if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
890- t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
891- }
892- second := cellAt(s, 1, 0).Style
893- if second.HasUnderlineColorRGB || second.HasUnderlineColor {
894- t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
895- }
896- },
897- },
898- {
899- name: "SGR 4 colon zero clears underline",
900- seq: "\x1b[4:3mA\x1b[4:0mB",
901- check: func(t *testing.T, s *Screen) {
902- first := cellAt(s, 0, 0).Style
903- if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
904- t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
905- }
906- second := cellAt(s, 1, 0).Style
907- if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
908- t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
909- }
910- },
911- },
912- {
913- name: "empty SGR params reset style",
914- seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
915- check: func(t *testing.T, s *Screen) {
916- if s.Style != (renderer.DefaultStyle()) {
917- t.Errorf("style after empty SGR = %+v, want default", s.Style)
918- }
919- },
920- },
921- }
922-
923- for _, tt := range tests {
924- t.Run(tt.name, func(t *testing.T) {
925- s := NewScreen(10, 3)
926- s.Write([]byte(tt.seq))
927- tt.check(t, s)
928- })
929- }
930-}
931-
932-func TestSGRTruecolor(t *testing.T) {
933- tests := []struct {
934- name string
935- seq string
936- check func(t *testing.T, s *Screen)
937- }{
938- {
939- name: "SGR 38;2 sets truecolor foreground on style and cell",
940- seq: "\x1b[38;2;12;34;56mX",
941- check: func(t *testing.T, s *Screen) {
942- want := renderer.RGB{R: 12, G: 34, B: 56}
943- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
944- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
945- }
946- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
947- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
948- }
949- },
950- },
951- {
952- name: "SGR 38:2 sets truecolor foreground",
953- seq: "\x1b[38:2:12:34:56mX",
954- check: func(t *testing.T, s *Screen) {
955- want := renderer.RGB{R: 12, G: 34, B: 56}
956- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
957- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
958- }
959- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
960- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
961- }
962- },
963- },
964- {
965- name: "SGR 38:2 empty color space sets truecolor foreground",
966- seq: "\x1b[38:2::12:34:56mX",
967- check: func(t *testing.T, s *Screen) {
968- want := renderer.RGB{R: 12, G: 34, B: 56}
969- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
970- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
971- }
972- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
973- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
974- }
975- },
976- },
977- {
978- name: "SGR 38:2 color space id sets truecolor foreground",
979- seq: "\x1b[38:2:1:12:34:56mX",
980- check: func(t *testing.T, s *Screen) {
981- want := renderer.RGB{R: 12, G: 34, B: 56}
982- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
983- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
984- }
985- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
986- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
987- }
988- },
989- },
990- {
991- name: "mixed semicolon and colon SGR groups",
992- seq: "\x1b[1;38:2:10:20:30;7mX",
993- check: func(t *testing.T, s *Screen) {
994- want := renderer.RGB{R: 10, G: 20, B: 30}
995- if !s.Style.Bold || !s.Style.Inverse {
996- t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
997- }
998- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
999- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
1000- }
1001- },
1002- },
1003- {
1004- name: "truncated colon truecolor group does not consume next parameter",
1005- seq: "\x1b[38:2:1:2;31m",
1006- check: func(t *testing.T, s *Screen) {
1007- if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
1008- t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
1009- }
1010- },
1011- },
1012- {
1013- name: "SGR 48;2 sets truecolor background",
1014- seq: "\x1b[48;2;200;100;50m",
1015- check: func(t *testing.T, s *Screen) {
1016- want := renderer.RGB{R: 200, G: 100, B: 50}
1017- if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
1018- t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
1019- }
1020- },
1021- },
1022- {
1023- name: "SGR 48:2 sets truecolor background",
1024- seq: "\x1b[48:2:200:100:50m",
1025- check: func(t *testing.T, s *Screen) {
1026- want := renderer.RGB{R: 200, G: 100, B: 50}
1027- if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
1028- t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
1029- }
1030- },
1031- },
1032- {
1033- name: "SGR 38:5 sets 256-color foreground",
1034- seq: "\x1b[38:5:82m",
1035- check: func(t *testing.T, s *Screen) {
1036- if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
1037- t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
1038- }
1039- },
1040- },
1041- {
1042- name: "SGR 48:5 sets 256-color background",
1043- seq: "\x1b[48:5:200m",
1044- check: func(t *testing.T, s *Screen) {
1045- if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
1046- t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
1047- }
1048- },
1049- },
1050- {
1051- name: "truncated colon foreground index group does not consume next parameter",
1052- seq: "\x1b[38:5;31m",
1053- check: func(t *testing.T, s *Screen) {
1054- if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
1055- t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
1056- }
1057- },
1058- },
1059- {
1060- name: "truncated colon background index group does not consume next parameter",
1061- seq: "\x1b[48:5;44m",
1062- check: func(t *testing.T, s *Screen) {
1063- if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
1064- t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
1065- }
1066- },
1067- },
1068- {
1069- name: "SGR 39 after truecolor clears truecolor foreground",
1070- seq: "\x1b[38;2;12;34;56m\x1b[39m",
1071- check: func(t *testing.T, s *Screen) {
1072- if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
1073- t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
1074- }
1075- },
1076- },
1077- {
1078- name: "truecolor components are clamped to 0-255",
1079- seq: "\x1b[38;2;-1;300;42m",
1080- check: func(t *testing.T, s *Screen) {
1081- want := renderer.RGB{R: 0, G: 255, B: 42}
1082- if s.Style.ForegroundRGB != want {
1083- t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
1084- }
1085- },
1086- },
1087- }
1088-
1089- for _, tt := range tests {
1090- t.Run(tt.name, func(t *testing.T) {
1091- s := NewScreen(10, 3)
1092- s.Write([]byte(tt.seq))
1093- tt.check(t, s)
1094- })
1095- }
1096-}
1097-
1098-func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
1099- tests := []struct {
1100- name string
1101- seq string
1102- foreground int
1103- background int
1104- hasForeground bool
1105- hasBackground bool
1106- }{
1107- {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1},
1108- {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1},
1109- {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true},
1110- {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15},
1111- {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200},
1112- {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1},
1113- {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true},
1114- }
1115-
1116- for _, tt := range tests {
1117- t.Run(tt.name, func(t *testing.T) {
1118- s := NewScreen(10, 3)
1119- s.Write([]byte(tt.seq))
1120-
1121- if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground {
1122- t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground)
1123- }
1124- if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground {
1125- t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground)
1126- }
1127- })
1128- }
1129-}
1130-
1131-// ---------------------------------------------------------------------------
1132-// Clear screen / clear line / erase modes
1133-// ---------------------------------------------------------------------------
1134-
1135-func TestClearAndErase(t *testing.T) {
1136- tests := []struct {
1137- name string
1138- run func(t *testing.T)
1139- }{
1140- {
1141- name: "CSI 2J clears the whole screen",
1142- run: func(t *testing.T) {
1143- s := NewScreen(5, 3)
1144- s.Write([]byte("HelloWorldABCDE"))
1145- // Clear damage from writing.
1146- s.ClearDamage()
1147-
1148- s.Write([]byte("\x1b[2J"))
1149-
1150- // All cells should be blank.
1151- for y := range 3 {
1152- for x := range 5 {
1153- if c := cellAt(s, x, y); c.Rune != ' ' {
1154- t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
1155- }
1156- }
1157- }
1158-
1159- d := s.Damage()
1160- if !hasDamageKind(d, renderer.DamageClear) {
1161- t.Error("expected DamageClear after CSI 2 J")
1162- }
1163- },
1164- },
1165- {
1166- name: "CSI K clears from cursor to end of line",
1167- run: func(t *testing.T) {
1168- s := NewScreen(10, 3)
1169- s.Write([]byte("HelloWorld"))
1170- s.ClearDamage()
1171-
1172- // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
1173- s.Write([]byte("\x1b[1;6H"))
1174- s.Write([]byte("\x1b[K"))
1175-
1176- for x := 5; x < 10; x++ {
1177- if c := cellAt(s, x, 0); c.Rune != ' ' {
1178- t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
1179- }
1180- }
1181- // First 5 chars should remain.
1182- assertCell(t, s, 0, 0, 'H')
1183-
1184- d := s.Damage()
1185- if !hasDamageKind(d, renderer.DamageClear) {
1186- t.Error("expected DamageClear after CSI K")
1187- }
1188- },
1189- },
1190- {
1191- name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
1192- run: func(t *testing.T) {
1193- s := NewScreen(5, 3)
1194- s.Write([]byte("abcdefghijklmno"))
1195- s.Write([]byte("\x1b[2;3H"))
1196- s.ClearDamage()
1197- s.Write([]byte("\x1b[J"))
1198- assertCell(t, s, 0, 0, 'a')
1199- assertCell(t, s, 1, 1, 'g')
1200- if c := cellAt(s, 2, 1); c.Rune != ' ' {
1201- t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
1202- }
1203-
1204- s = NewScreen(5, 3)
1205- s.Write([]byte("abcdefghijklmno"))
1206- s.Write([]byte("\x1b[2;3H"))
1207- s.Write([]byte("\x1b[1J"))
1208- if c := cellAt(s, 0, 0); c.Rune != ' ' {
1209- t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
1210- }
1211- assertCell(t, s, 3, 1, 'i')
1212- },
1213- },
1214- {
1215- name: "erase line modes 0/1/2",
1216- run: func(t *testing.T) {
1217- s := NewScreen(5, 2)
1218- s.Write([]byte("abcde"))
1219- s.Write([]byte("\x1b[1;3H"))
1220- s.Write([]byte("\x1b[1K"))
1221- if c := cellAt(s, 0, 0); c.Rune != ' ' {
1222- t.Fatalf("line start clear left %q", c.Rune)
1223- }
1224- assertCell(t, s, 3, 0, 'd')
1225-
1226- s = NewScreen(5, 2)
1227- s.Write([]byte("abcde"))
1228- s.Write([]byte("\x1b[1;3H"))
1229- s.Write([]byte("\x1b[2K"))
1230- for x := range 5 {
1231- if c := cellAt(s, x, 0); c.Rune != ' ' {
1232- t.Fatalf("line clear left %q at %d", c.Rune, x)
1233- }
1234- }
1235- },
1236- },
1237- }
1238-
1239- for _, tt := range tests {
1240- t.Run(tt.name, tt.run)
1241- }
1242-}
1243-
1244-func TestBackgroundColorErase(t *testing.T) {
1245- tests := []struct {
1246- name string
1247- width int
1248- height int
1249- seq string
1250- startX int
1251- wantStyle renderer.Style
1252- }{
1253- {
1254- name: "indexed background for EL",
1255- width: 8,
1256- height: 1,
1257- seq: "\x1b[1;31;48;5;236mX\x1b[K",
1258- startX: 1,
1259- wantStyle: renderer.Style{Foreground: -1, Background: 236},
1260- },
1261- {
1262- name: "truecolor background for full ED",
1263- width: 3,
1264- height: 2,
1265- seq: "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J",
1266- wantStyle: renderer.Style{
1267- Foreground: -1,
1268- Background: -1,
1269- HasBackgroundRGB: true,
1270- BackgroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
1271- },
1272- },
1273- {
1274- name: "default background after SGR 49",
1275- width: 2,
1276- height: 1,
1277- seq: "\x1b[48;2;12;34;56m\x1b[49m\x1b[K",
1278- wantStyle: renderer.DefaultStyle(),
1279- },
1280- {
1281- name: "default background after SGR 0",
1282- width: 2,
1283- height: 1,
1284- seq: "\x1b[48;2;12;34;56m\x1b[0m\x1b[K",
1285- wantStyle: renderer.DefaultStyle(),
1286- },
1287- }
1288-
1289- for _, tt := range tests {
1290- t.Run(tt.name, func(t *testing.T) {
1291- s := NewScreen(tt.width, tt.height)
1292- s.Write([]byte(tt.seq))
1293-
1294- for y := range tt.height {
1295- for x := tt.startX; x < tt.width; x++ {
1296- cell := cellAt(s, x, y)
1297- if cell.Rune != ' ' || cell.Style != tt.wantStyle {
1298- t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
1299- }
1300- }
1301- }
1302- })
1303- }
1304-}
1305-
1306-// ---------------------------------------------------------------------------
1307-// Cursor move
1308-// ---------------------------------------------------------------------------
1309-
1310-func TestCursorMove(t *testing.T) {
1311- tests := []struct {
1312- name string
1313- seq string
1314- initRow int
1315- initCol int
1316- wantRow int
1317- wantCol int
1318- }{
1319- {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
1320- {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
1321- {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
1322- {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
1323- {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
1324- {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
1325- }
1326-
1327- for _, tt := range tests {
1328- t.Run(tt.name, func(t *testing.T) {
1329- s := NewScreen(10, 5)
1330- s.Row, s.Col = tt.initRow, tt.initCol
1331- s.Write([]byte(tt.seq))
1332-
1333- if s.Row != tt.wantRow || s.Col != tt.wantCol {
1334- t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
1335- }
1336- })
1337- }
1338-}
1339-
1340-// ---------------------------------------------------------------------------
1341-// ESC sequences
1342-// ---------------------------------------------------------------------------
1343-
1344-func TestESCSequences(t *testing.T) {
1345- tests := []struct {
1346- name string
1347- run func(t *testing.T)
1348- }{
1349- {
1350- name: "ESC = (DECKPAM) is ignored, following text is printable",
1351- run: func(t *testing.T) {
1352- s := NewScreen(10, 2)
1353- s.Write([]byte("\x1b=abc"))
1354-
1355- assertCell(t, s, 0, 0, 'a')
1356- assertCell(t, s, 1, 0, 'b')
1357- assertCell(t, s, 2, 0, 'c')
1358- if s.Col != 3 || s.Row != 0 {
1359- t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
1360- }
1361- },
1362- },
1363- {
1364- name: "ESC 7 / ESC 8 save and restore the cursor",
1365- run: func(t *testing.T) {
1366- s := NewScreen(10, 3)
1367- s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
1368-
1369- assertCell(t, s, 0, 0, 'a')
1370- assertCell(t, s, 1, 0, 'b')
1371- assertCell(t, s, 2, 0, 'c')
1372- assertCell(t, s, 3, 0, 'X')
1373- assertCell(t, s, 4, 1, 'Z')
1374- assertCell(t, s, 5, 1, 'Z')
1375- if s.Row != 0 || s.Col != 4 {
1376- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
1377- }
1378- },
1379- },
1380- {
1381- name: "ESC 7 / ESC 8 restore origin and insert modes",
1382- run: func(t *testing.T) {
1383- s := NewScreen(6, 5)
1384- s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
1385- s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
1386-
1387- if !s.originMode {
1388- t.Fatal("origin mode was not restored")
1389- }
1390- if !s.insertMode {
1391- t.Fatal("insert mode was not restored")
1392- }
1393-
1394- s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
1395- if s.Row != 1 || s.Col != 4 {
1396- t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
1397- }
1398- if got := lineText(s, 1); got != "abXYcd" {
1399- t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
1400- }
1401- },
1402- },
1403- {
1404- name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
1405- run: func(t *testing.T) {
1406- s := NewScreen(5, 3)
1407- s.Write([]byte("A\x1bDB\x1bEC"))
1408-
1409- assertCell(t, s, 0, 0, 'A')
1410- assertCell(t, s, 1, 1, 'B')
1411- assertCell(t, s, 0, 2, 'C')
1412-
1413- s = NewScreen(5, 3)
1414- s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
1415- assertCell(t, s, 0, 0, 't')
1416- assertCell(t, s, 0, 1, 'm')
1417- },
1418- },
1419- }
1420-
1421- for _, tt := range tests {
1422- t.Run(tt.name, tt.run)
1423- }
1424-}
1425-
1426-// ---------------------------------------------------------------------------
1427-// CSI editing sequences (cursor moves, ICH/DCH, IL/DL, scroll region, alt screen)
1428-// ---------------------------------------------------------------------------
1429-
1430-func TestCSIEditingSequences(t *testing.T) {
1431- tests := []struct {
1432- name string
1433- run func(t *testing.T)
1434- }{
1435- {
1436- name: "directional cursor moves (A/B/C/D)",
1437- run: func(t *testing.T) {
1438- s := NewScreen(10, 4)
1439- s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
1440-
1441- assertCell(t, s, 4, 2, 'X')
1442- assertCell(t, s, 3, 2, '<')
1443- assertCell(t, s, 4, 1, '^')
1444- assertCell(t, s, 5, 2, 'v')
1445- assertCell(t, s, 9, 2, '>')
1446- },
1447- },
1448- {
1449- name: "insert and delete characters (@ and P)",
1450- run: func(t *testing.T) {
1451- s := NewScreen(8, 2)
1452- s.Write([]byte("abcdef"))
1453- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
1454- if got := lineText(s, 0); got != "abXYcdef" {
1455- t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
1456- }
1457-
1458- s.Write([]byte("\x1b[1;4H\x1b[3P"))
1459- if got := lineText(s, 0); got != "abXef " {
1460- t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
1461- }
1462- },
1463- },
1464- {
1465- name: "insert and delete lines (L and M)",
1466- run: func(t *testing.T) {
1467- s := NewScreen(5, 4)
1468- s.Write([]byte("11111222223333344444"))
1469- s.Write([]byte("\x1b[2;1H\x1b[L"))
1470- if got := lineText(s, 1); got != " " {
1471- t.Fatalf("inserted line = %q, want blank", got)
1472- }
1473- if got := lineText(s, 2); got != "22222" {
1474- t.Fatalf("shifted line = %q, want 22222", got)
1475- }
1476-
1477- s.Write([]byte("\x1b[2;1H\x1b[M"))
1478- if got := lineText(s, 1); got != "22222" {
1479- t.Fatalf("line after delete = %q, want 22222", got)
1480- }
1481- },
1482- },
1483- {
1484- name: "scroll region (CSI r) confines LF scrolling",
1485- run: func(t *testing.T) {
1486- s := NewScreen(5, 4)
1487- s.Write([]byte("11111222223333344444"))
1488- s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
1489-
1490- if got := lineText(s, 0); got != "11111" {
1491- t.Fatalf("row outside region changed: %q", got)
1492- }
1493- if got := lineText(s, 1); got != "33333" {
1494- t.Fatalf("region did not scroll up, row 1 = %q", got)
1495- }
1496- if got := lineText(s, 2); got != " " {
1497- t.Fatalf("region bottom not blanked, row 2 = %q", got)
1498- }
1499- if got := lineText(s, 3); got != "44444" {
1500- t.Fatalf("row below region changed: %q", got)
1501- }
1502- },
1503- },
1504- {
1505- name: "DECOM homes and addresses relative to scroll region",
1506- run: func(t *testing.T) {
1507- s := NewScreen(6, 5)
1508- s.Write([]byte("\x1b[2;4r\x1b[?6h"))
1509- if s.Row != 1 || s.Col != 0 {
1510- t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
1511- }
1512-
1513- s.Write([]byte("\x1b[2;3H"))
1514- if s.Row != 2 || s.Col != 2 {
1515- t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1516- }
1517- s.Write([]byte("\x1b[99;1H"))
1518- if s.Row != 3 || s.Col != 0 {
1519- t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
1520- }
1521- },
1522- },
1523- {
1524- name: "DECOM already set homes DECSTBM to scroll region",
1525- run: func(t *testing.T) {
1526- s := NewScreen(6, 5)
1527- s.Write([]byte("\x1b[?6h\x1b[2;4r"))
1528- if s.Row != 1 || s.Col != 0 {
1529- t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
1530- }
1531-
1532- s.Write([]byte("\x1b[2;3H"))
1533- if s.Row != 2 || s.Col != 2 {
1534- t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
1535- }
1536- s.Write([]byte("\x1b[99;1H"))
1537- if s.Row != 3 || s.Col != 0 {
1538- t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
1539- }
1540- },
1541- },
1542- {
1543- name: "DECOM reset homes and restores full-frame addressing",
1544- run: func(t *testing.T) {
1545- s := NewScreen(6, 5)
1546- s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
1547- if s.Row != 0 || s.Col != 0 {
1548- t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
1549- }
1550-
1551- s.Write([]byte("\x1b[5;6H"))
1552- if s.Row != 4 || s.Col != 5 {
1553- t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
1554- }
1555- },
1556- },
1557- {
1558- name: "private alternate screen mode restores normal screen on exit",
1559- run: func(t *testing.T) {
1560- s := NewScreen(20, 5)
1561- s.Write([]byte("normal-line"))
1562- s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
1563- s.Write([]byte("\x1b[?1049lafter"))
1564-
1565- if got := lineText(s, 0); got != "normal-lineafter " {
1566- t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
1567- }
1568- if got := lineText(s, 2); got != " " {
1569- t.Fatalf("alternate content leaked into row 2: %q", got)
1570- }
1571- },
1572- },
1573- }
1574-
1575- for _, tt := range tests {
1576- t.Run(tt.name, tt.run)
1577- }
1578-}
1579-
1580-// ---------------------------------------------------------------------------
1581-// Integration scenarios
1582-// ---------------------------------------------------------------------------
1583-
1584-func TestIntegrationScenarios(t *testing.T) {
1585- tests := []struct {
1586- name string
1587- run func(t *testing.T)
1588- }{
1589- {
1590- name: "renderer stays in sync after VT edit damage",
1591- run: func(t *testing.T) {
1592- s := NewScreen(8, 2)
1593- r := renderer.New(renderer.Capabilities{})
1594- s.Write([]byte("abcdef"))
1595- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
1596- t.Fatal(err)
1597- }
1598- s.ClearDamage()
1599-
1600- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
1601- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
1602- t.Fatal(err)
1603- }
1604- s.ClearDamage()
1605-
1606- out, err := r.Draw(s.Frame, nil)
1607- if err != nil {
1608- t.Fatal(err)
1609- }
1610- if len(out) != 0 {
1611- t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
1612- }
1613- },
1614- },
1615- {
1616- name: "DCS sequence terminated by ST is ignored",
1617- run: func(t *testing.T) {
1618- s := NewScreen(20, 2)
1619- s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
1620-
1621- assertCell(t, s, 0, 0, 'p')
1622- assertCell(t, s, 1, 0, 'r')
1623- assertCell(t, s, 2, 0, 'o')
1624- assertCell(t, s, 3, 0, 'm')
1625- assertCell(t, s, 4, 0, 'p')
1626- assertCell(t, s, 5, 0, 't')
1627- if s.Col != 6 || s.Row != 0 {
1628- t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
1629- }
1630- },
1631- },
1632- {
1633- name: "fish-like prompt redraw preserves typed characters",
1634- run: func(t *testing.T) {
1635- s := NewScreen(10, 2)
1636- s.Write([]byte("> "))
1637- s.Write([]byte("a\r\x1b[3C"))
1638- s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
1639-
1640- assertCell(t, s, 0, 0, '>')
1641- assertCell(t, s, 1, 0, ' ')
1642- assertCell(t, s, 2, 0, 'a')
1643- assertCell(t, s, 3, 0, 'b')
1644- if s.Row != 0 || s.Col != 4 {
1645- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
1646- }
1647- },
1648- },
1649- }
1650-
1651- for _, tt := range tests {
1652- t.Run(tt.name, tt.run)
1653- }
1654-}
1655-
1656-// ---------------------------------------------------------------------------
1657-// Scroll damage
1658-// ---------------------------------------------------------------------------
1659-
1660-func TestScrollDamage(t *testing.T) {
1661- tests := []struct {
1662- name string
1663- run func(t *testing.T)
1664- }{
1665- {
1666- name: "scroll produces both DamageScrollUp and DamageText",
1667- run: func(t *testing.T) {
1668- s := NewScreen(5, 2)
1669- // Fill screen then cause a scroll.
1670- s.Write([]byte("AAAAABBBBB")) // both rows filled
1671- s.ClearDamage()
1672-
1673- s.Write([]byte("CCCCC")) // forces a scroll up
1674-
1675- d := s.Damage()
1676- if len(d) == 0 {
1677- t.Fatal("expected damage after scroll")
1678- }
1679-
1680- if !hasDamageKind(d, renderer.DamageScrollUp) {
1681- t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
1682- }
1683- if !hasDamageKind(d, renderer.DamageText) {
1684- t.Errorf("expected DamageText in %v", damageKinds(d))
1685- }
1686- },
1687- },
1688- {
1689- name: "scroll damage coordinates match the full-width scrolled region",
1690- run: func(t *testing.T) {
1691- s := NewScreen(5, 3)
1692- s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
1693- s.ClearDamage()
1694-
1695- // Write another char to force scroll.
1696- s.Write([]byte("D"))
1697-
1698- d := s.Damage()
1699- var scrollDamage *renderer.Damage
1700- for i, dd := range d {
1701- if dd.Kind == renderer.DamageScrollUp {
1702- scrollDamage = &d[i]
1703- break
1704- }
1705- }
1706- if scrollDamage == nil {
1707- t.Fatal("expected scroll damage")
1708- }
1709- if scrollDamage.X != 0 || scrollDamage.Y != 0 {
1710- t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
1711- }
1712- if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
1713- t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
1714- }
1715- if scrollDamage.Count != 1 {
1716- t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
1717- }
1718- },
1719- },
1720- }
1721-
1722- for _, tt := range tests {
1723- t.Run(tt.name, tt.run)
1724- }
1725-}
1726-
1727-// ---------------------------------------------------------------------------
1728-// Damage coalescing
1729-// ---------------------------------------------------------------------------
1730-
1731-func TestDamageCoalescingBehavior(t *testing.T) {
1732- tests := []struct {
1733- name string
1734- run func(t *testing.T)
1735- }{
1736- {
1737- name: "adjacent single-cell writes coalesce into one damage entry",
1738- run: func(t *testing.T) {
1739- s := NewScreen(20, 3)
1740- s.Write([]byte("ABC")) // Three adjacent chars on one line
1741-
1742- d := s.Damage()
1743- // Should be coalesced into a single DamageText.
1744- if len(d) != 1 {
1745- t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
1746- }
1747- if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
1748- t.Errorf("unexpected coalesced damage: %+v", d[0])
1749- }
1750- },
1751- },
1752- {
1753- name: "newline breaks coalescing across lines",
1754- run: func(t *testing.T) {
1755- s := NewScreen(10, 3)
1756- s.Write([]byte("AB\nCD"))
1757-
1758- d := s.Damage()
1759- // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
1760- textCount := 0
1761- for _, dd := range d {
1762- if dd.Kind == renderer.DamageText {
1763- textCount++
1764- }
1765- }
1766- if textCount < 2 {
1767- t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
1768- }
1769- },
1770- },
1771- {
1772- name: "writing replaces the initial FullRedraw with text damage",
1773- run: func(t *testing.T) {
1774- s := NewScreen(5, 3)
1775- // NewScreen already has FullRedraw in damage.
1776- d := s.Damage()
1777- if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
1778- t.Fatalf("expected single FullRedraw, got %+v", d)
1779- }
1780-
1781- // Writing should replace it with text damage.
1782- s.Write([]byte("X"))
1783- d = s.Damage()
1784- if len(d) == 0 {
1785- t.Fatal("expected damage after write")
1786- }
1787- if d[0].Kind == renderer.DamageFullRedraw {
1788- t.Error("FullRedraw should have been replaced by text damage")
1789- }
1790- },
1791- },
1792- }
1793-
1794- for _, tt := range tests {
1795- t.Run(tt.name, tt.run)
1796- }
1797-}
1798-
1799-// ---------------------------------------------------------------------------
1800-// Resize
1801-// ---------------------------------------------------------------------------
1802-
1803-func TestResize(t *testing.T) {
1804- setRow := func(s *Screen, y int, text string) {
1805- for x, r := range []rune(text) {
1806- if x >= s.Frame.Width {
1807- break
1808- }
1809- s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
1810- }
1811- }
1812- rowString := func(s *Screen, y int) string {
1813- runes := make([]rune, s.Frame.Width)
1814- for x := range s.Frame.Width {
1815- runes[x] = s.Frame.At(x, y).Rune
1816- }
1817- return string(runes)
1818- }
1819-
1820- tests := []struct {
1821- name string
1822- run func(t *testing.T)
1823- }{
1824- {
1825- name: "same size preserves content and skips FullRedraw",
1826- run: func(t *testing.T) {
1827- s := NewScreen(5, 3)
1828- s.ClearDamage()
1829- setRow(s, 0, "Hello")
1830-
1831- s.Resize(5, 3)
1832-
1833- if got := rowString(s, 0); got != "Hello" {
1834- t.Fatalf("row 0 = %q, want Hello", got)
1835- }
1836- if d := s.Damage(); len(d) != 0 {
1837- t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
1838- }
1839- },
1840- },
1841- {
1842- name: "grow preserves visible content and cursor",
1843- run: func(t *testing.T) {
1844- s := NewScreen(5, 2)
1845- setRow(s, 0, "Hello")
1846- setRow(s, 1, "World")
1847- s.Row, s.Col = 1, 4
1848-
1849- s.Resize(8, 4)
1850-
1851- if got := rowString(s, 0); got != "Hello " {
1852- t.Fatalf("row 0 = %q", got)
1853- }
1854- if got := rowString(s, 1); got != "World " {
1855- t.Fatalf("row 1 = %q", got)
1856- }
1857- if s.Row != 1 || s.Col != 4 {
1858- t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
1859- }
1860- if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
1861- t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
1862- }
1863- },
1864- },
1865- {
1866- name: "height shrink evicts top lines and follows cursor",
1867- run: func(t *testing.T) {
1868- s := NewScreen(4, 5)
1869- for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
1870- setRow(s, y, text)
1871- }
1872- s.Row, s.Col = 4, 2
1873- var evicted []string
1874- s.OnLineEvicted = func(row []renderer.Cell) {
1875- runes := make([]rune, len(row))
1876- for i, c := range row {
1877- runes[i] = c.Rune
1878- }
1879- evicted = append(evicted, string(runes))
1880- }
1881-
1882- s.Resize(4, 3)
1883-
1884- if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
1885- t.Fatalf("evicted = %v, want %v", got, want)
1886- }
1887- if got := rowString(s, 0); got != "2222" {
1888- t.Fatalf("row 0 = %q", got)
1889- }
1890- if s.Row != 2 || s.Col != 2 {
1891- t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
1892- }
1893- if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
1894- t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
1895- }
1896- },
1897- },
1898- {
1899- name: "width truncates and pads without reflow",
1900- run: func(t *testing.T) {
1901- s := NewScreen(6, 2)
1902- setRow(s, 0, "abcdef")
1903- setRow(s, 1, "ghijkl")
1904-
1905- s.Resize(3, 2)
1906- if got := rowString(s, 0); got != "abc" {
1907- t.Fatalf("truncated row = %q", got)
1908- }
1909- s.Resize(5, 2)
1910- if got := rowString(s, 1); got != "ghi " {
1911- t.Fatalf("padded row = %q", got)
1912- }
1913- },
1914- },
1915- {
1916- name: "width shrink repairs truncated wide head",
1917- run: func(t *testing.T) {
1918- s := NewScreen(4, 1)
1919- s.Write([]byte("AB界"))
1920-
1921- s.Resize(3, 1)
1922-
1923- assertCell(t, s, 0, 0, 'A')
1924- assertCell(t, s, 1, 0, 'B')
1925- assertBlank(t, s, 2, 0)
1926- assertNoOrphanWideCells(t, s)
1927- },
1928- },
1929- {
1930- name: "random wide resize repairs copied rows",
1931- run: func(t *testing.T) {
1932- rng := rand.New(rand.NewSource(1))
1933- for range 200 {
1934- s := NewScreen(8, 4)
1935- s.Write([]byte("a界b好c語d"))
1936- s.Row = rng.Intn(s.Frame.Height)
1937-
1938- s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
1939-
1940- assertNoOrphanWideCells(t, s)
1941- }
1942- },
1943- },
1944- {
1945- name: "cursor and saved cursor clamp",
1946- run: func(t *testing.T) {
1947- s := NewScreen(6, 4)
1948- s.Row, s.Col = 3, 5
1949- s.saveCursor()
1950-
1951- s.Resize(3, 2)
1952-
1953- if s.Row != 1 || s.Col != 2 {
1954- t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
1955- }
1956- s.Row, s.Col = 0, 0
1957- s.restoreCursor()
1958- if s.Row != 1 || s.Col != 2 {
1959- t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
1960- }
1961- },
1962- },
1963- {
1964- name: "alternate resize keeps saved normal content",
1965- run: func(t *testing.T) {
1966- s := NewScreen(5, 3)
1967- setRow(s, 0, "shell")
1968- s.Row = 0
1969- s.Write([]byte("\x1b[?1049h"))
1970- setRow(s, 0, "alt!!")
1971-
1972- s.Resize(7, 4)
1973- s.Write([]byte("\x1b[?1049l"))
1974-
1975- if got := rowString(s, 0); got != "shell " {
1976- t.Fatalf("normal row after alt resize = %q", got)
1977- }
1978- },
1979- },
1980- {
1981- name: "alternate resize shifts active and saved cursors",
1982- run: func(t *testing.T) {
1983- s := NewScreen(5, 5)
1984- s.Row, s.Col = 3, 4
1985- s.saveCursor()
1986- s.scrollTop, s.scrollBottom = 1, 4
1987- s.Row = 4
1988- s.Write([]byte("\x1b[?1049h"))
1989- s.Row, s.Col = 3, 4
1990- s.saveCursor()
1991- s.Row = 4
1992-
1993- // The expected rows account for the resize shift that keeps the
1994- // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
1995- // not just width/height clamping.
1996- s.Resize(3, 3)
1997-
1998- if s.Row != 2 || s.Col != 2 {
1999- t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
2000- }
2001- s.Row, s.Col = 0, 0
2002- s.restoreCursor()
2003- if s.Row != 1 || s.Col != 2 {
2004- t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
2005- }
2006-
2007- s.Write([]byte("\x1b[?1049l"))
2008- if s.scrollTop != 0 || s.scrollBottom != 2 {
2009- t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
2010- }
2011- s.Row, s.Col = 0, 0
2012- s.restoreCursor()
2013- if s.Row != 1 || s.Col != 2 {
2014- t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
2015- }
2016- },
2017- },
2018- {
2019- name: "style mouse cursor and bracketed paste modes survive",
2020- run: func(t *testing.T) {
2021- s := NewScreen(5, 2)
2022- s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
2023-
2024- s.Resize(6, 3)
2025-
2026- if !s.Style.Bold {
2027- t.Fatal("bold style was reset")
2028- }
2029- if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
2030- t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
2031- }
2032- if !s.BracketedPasteMode() {
2033- t.Fatal("bracketed paste mode was reset")
2034- }
2035- if s.CursorVisible() {
2036- t.Fatal("cursor visibility was reset")
2037- }
2038- if style, ok := s.CursorStyle(); style != 3 || !ok {
2039- t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
2040- }
2041- s.Write([]byte("\x1b[?2004l"))
2042- if s.BracketedPasteMode() {
2043- t.Fatal("bracketed paste mode remained enabled after reset")
2044- }
2045- },
2046- },
2047- }
2048-
2049- for _, tt := range tests {
2050- t.Run(tt.name, tt.run)
2051- }
2052-}
2053-
2054-// ---------------------------------------------------------------------------
2055-// ClearDamage
2056-// ---------------------------------------------------------------------------
2057-
2058-func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
2059- s := NewScreen(2, 2)
2060- s.ClearDamage()
2061-
2062- for i := range 1_100 {
2063- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
2064- }
2065- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2066-
2067- // Saturation is sticky until the render owner acknowledges or clears it.
2068- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2069- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2070-
2071- s.ClearDamage()
2072- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2073- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
2074-}
2075-
2076-func TestClearDamage(t *testing.T) {
2077- tests := []struct {
2078- name string
2079- }{
2080- {name: "ClearDamage empties the damage list"},
2081- }
2082-
2083- for _, tt := range tests {
2084- t.Run(tt.name, func(t *testing.T) {
2085- s := NewScreen(5, 3)
2086- s.Write([]byte("Hello"))
2087- s.ClearDamage()
2088- d := s.Damage()
2089- if len(d) != 0 {
2090- t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
2091- }
2092- })
2093- }
2094-}
2095-
2096-// ---------------------------------------------------------------------------
2097-// Non-printable / invalid bytes
2098-// ---------------------------------------------------------------------------
2099-
2100-func TestInvalidInputIgnored(t *testing.T) {
2101- tests := []struct {
2102- name string
2103- input []byte
2104- }{
2105- {
2106- name: "invalid UTF-8 continuation byte without start byte",
2107- input: []byte{0x80, 0x81},
2108- },
2109- {
2110- name: "unhandled control characters below 0x20",
2111- input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
2112- },
2113- }
2114-
2115- for _, tt := range tests {
2116- t.Run(tt.name, func(t *testing.T) {
2117- s := NewScreen(10, 3)
2118- s.Write(tt.input)
2119- // Should not panic, and no cells should be modified.
2120- for y := range 3 {
2121- for x := range 10 {
2122- if c := cellAt(s, x, y); c.Rune != ' ' {
2123- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
2124- }
2125- }
2126- }
2127- })
2128- }
2129-}
2130-
2131-// ---------------------------------------------------------------------------
2132-// No overflow on edge writes
2133-// ---------------------------------------------------------------------------
2134-
2135-func TestWriteAtEdgeNoPanic(t *testing.T) {
2136- tests := []struct {
2137- name string
2138- }{
2139- {name: "writing past a 1x1 screen scrolls instead of panicking"},
2140- }
2141-
2142- for _, tt := range tests {
2143- t.Run(tt.name, func(t *testing.T) {
2144- s := NewScreen(1, 1)
2145- // Fill the only cell then try to write more (should scroll).
2146- s.Write([]byte("ABC"))
2147- // Should not panic.
2148- _ = cellAt(s, 0, 0)
2149- })
2150- }
2151-}
2152-
2153-// ---------------------------------------------------------------------------
2154-// Style persistence across writes
2155-// ---------------------------------------------------------------------------
2156-
2157-func TestStylePersists(t *testing.T) {
2158- tests := []struct {
2159- name string
2160- }{
2161- {name: "bold persists across separate Write calls"},
2162- }
2163-
2164- for _, tt := range tests {
2165- t.Run(tt.name, func(t *testing.T) {
2166- s := NewScreen(10, 3)
2167- s.Write([]byte("\x1b[1mAB"))
2168- s.Write([]byte("CD"))
2169-
2170- c1 := cellAt(s, 0, 0)
2171- c2 := cellAt(s, 2, 0)
2172- if !c1.Style.Bold || !c2.Style.Bold {
2173- t.Error("bold should persist across Write calls")
2174- }
2175- })
2176- }
2177-}
2178-
2179-// ---------------------------------------------------------------------------
2180-// Rune width (CJK, emoji, combining marks, zero-width)
2181-// ---------------------------------------------------------------------------
2182-
2183-func TestWideAndZeroWidthRunes(t *testing.T) {
2184- tests := []struct {
2185- name string
2186- run func(t *testing.T)
2187- }{
2188- {
2189- name: "CJK writes wide left cell plus continuation",
2190- run: func(t *testing.T) {
2191- s := NewScreen(10, 3)
2192- // Two CJK characters, each width 2: left cell holds the rune,
2193- // right cell is a continuation marker.
2194- s.Write([]byte("你好"))
2195-
2196- assertCell(t, s, 0, 0, '你')
2197- assertContinuation(t, s, 1, 0)
2198- assertCell(t, s, 2, 0, '好')
2199- assertContinuation(t, s, 3, 0)
2200- if s.Col != 4 || s.Row != 0 {
2201- t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
2202- }
2203- },
2204- },
2205- {
2206- name: "CJK mixed with ASCII",
2207- run: func(t *testing.T) {
2208- s := NewScreen(10, 3)
2209- s.Write([]byte("A你B"))
2210-
2211- assertCell(t, s, 0, 0, 'A')
2212- assertCell(t, s, 1, 0, '你')
2213- assertContinuation(t, s, 2, 0)
2214- assertCell(t, s, 3, 0, 'B')
2215- if s.Col != 4 {
2216- t.Errorf("cursor at col=%d, want 4", s.Col)
2217- }
2218- },
2219- },
2220- {
2221- name: "emoji writes wide pair",
2222- run: func(t *testing.T) {
2223- s := NewScreen(10, 3)
2224- s.Write([]byte("\U0001f600")) // 😀 grinning face
2225-
2226- assertCell(t, s, 0, 0, '\U0001f600')
2227- assertContinuation(t, s, 1, 0)
2228- if s.Col != 2 {
2229- t.Errorf("cursor at col=%d, want 2", s.Col)
2230- }
2231- },
2232- },
2233- {
2234- name: "combining mark is skipped without advancing cursor",
2235- run: func(t *testing.T) {
2236- s := NewScreen(10, 3)
2237- // 'A' followed by combining acute accent U+0301
2238- s.Write([]byte("A\xcc\x81"))
2239-
2240- assertCell(t, s, 0, 0, 'A')
2241- // Combining mark should be skipped — no cell written, no cursor advance.
2242- if s.Col != 1 {
2243- t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
2244- }
2245- },
2246- },
2247- {
2248- name: "zero-width characters are skipped without advancing cursor",
2249- run: func(t *testing.T) {
2250- s := NewScreen(10, 3)
2251- // Zero-width space U+200B
2252- s.Write([]byte("A\xe2\x80\x8bB"))
2253-
2254- assertCell(t, s, 0, 0, 'A')
2255- assertCell(t, s, 1, 0, 'B')
2256- if s.Col != 2 {
2257- t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
2258- }
2259- },
2260- },
2261- {
2262- name: "CJK surrounded by ASCII on both sides",
2263- run: func(t *testing.T) {
2264- s := NewScreen(10, 3)
2265- s.Write([]byte("a你b好c"))
2266-
2267- assertCell(t, s, 0, 0, 'a')
2268- assertCell(t, s, 1, 0, '你')
2269- assertContinuation(t, s, 2, 0)
2270- assertCell(t, s, 3, 0, 'b')
2271- assertCell(t, s, 4, 0, '好')
2272- assertContinuation(t, s, 5, 0)
2273- assertCell(t, s, 6, 0, 'c')
2274- if s.Col != 7 {
2275- t.Errorf("cursor at col=%d, want 7", s.Col)
2276- }
2277- },
2278- },
2279- {
2280- name: "wide char at last column wraps to next line, abandoned cell cleared",
2281- run: func(t *testing.T) {
2282- s := NewScreen(3, 2)
2283- // Fill the first two columns; cursor lands on the last column.
2284- s.Write([]byte("AB")) // cells [A B _], Col=2
2285- // A wide char at the last column cannot straddle the edge: it
2286- // wraps to the next line, clearing the abandoned last cell.
2287- s.Write([]byte("你"))
2288-
2289- assertCell(t, s, 0, 0, 'A')
2290- assertCell(t, s, 1, 0, 'B')
2291- assertBlank(t, s, 2, 0) // abandoned last cell cleared
2292- assertCell(t, s, 0, 1, '你')
2293- assertContinuation(t, s, 1, 1)
2294- if s.Col != 2 || s.Row != 1 {
2295- t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
2296- }
2297- },
2298- },
2299- {
2300- name: "CJK damage width is 2",
2301- run: func(t *testing.T) {
2302- s := NewScreen(10, 3)
2303- s.ClearDamage()
2304- s.Write([]byte("你"))
2305-
2306- d := s.Damage()
2307- if len(d) == 0 {
2308- t.Fatal("expected damage")
2309- }
2310- if d[0].Width != 2 {
2311- t.Errorf("damage width = %d, want 2", d[0].Width)
2312- }
2313- },
2314- },
2315- {
2316- name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
2317- run: func(t *testing.T) {
2318- s := NewScreen(10, 3)
2319- // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
2320- s.Write([]byte("a你\U0001f600b\xcc\x81"))
2321-
2322- assertCell(t, s, 0, 0, 'a')
2323- assertCell(t, s, 1, 0, '你')
2324- assertContinuation(t, s, 2, 0)
2325- assertCell(t, s, 3, 0, '\U0001f600')
2326- assertContinuation(t, s, 4, 0)
2327- assertCell(t, s, 5, 0, 'b')
2328- // Combining mark skipped, no cell at col 6.
2329- if s.Col != 6 || s.Row != 0 {
2330- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
2331- }
2332- },
2333- },
2334- {
2335- name: "overwrite left half of wide pair with narrow clears both",
2336- run: func(t *testing.T) {
2337- s := NewScreen(10, 3)
2338- s.Write([]byte("你")) // (0)=你 (1)=cont
2339- s.Write([]byte("\x1b[1;1H")) // cursor home
2340- s.Write([]byte("X")) // overwrite the wide left half
2341-
2342- assertCell(t, s, 0, 0, 'X')
2343- assertBlank(t, s, 1, 0) // orphaned continuation cleared
2344- },
2345- },
2346- {
2347- name: "overwrite right half (continuation) with narrow clears both",
2348- run: func(t *testing.T) {
2349- s := NewScreen(10, 3)
2350- s.Write([]byte("你")) // (0)=你 (1)=cont
2351- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
2352- s.Write([]byte("X")) // overwrite the continuation
2353-
2354- assertBlank(t, s, 0, 0) // orphaned wide left cleared
2355- assertCell(t, s, 1, 0, 'X')
2356- },
2357- },
2358- {
2359- name: "overwrite half of wide pair with a new wide char",
2360- run: func(t *testing.T) {
2361- s := NewScreen(10, 3)
2362- s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
2363- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
2364- s.Write([]byte("学")) // wide write at cols 1,2
2365-
2366- assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
2367- assertCell(t, s, 1, 0, '学')
2368- assertContinuation(t, s, 2, 0)
2369- assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
2370- },
2371- },
2372- {
2373- name: "erase to end of line covering a continuation clears its wide left half",
2374- run: func(t *testing.T) {
2375- s := NewScreen(10, 3)
2376- s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
2377- s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
2378- s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
2379-
2380- assertCell(t, s, 0, 0, 'A')
2381- assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
2382- assertBlank(t, s, 2, 0)
2383- assertBlank(t, s, 3, 0)
2384- },
2385- },
2386- {
2387- name: "erase to start of line covering a wide left clears its continuation",
2388- run: func(t *testing.T) {
2389- s := NewScreen(10, 3)
2390- s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
2391- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
2392- s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
2393-
2394- assertBlank(t, s, 0, 0)
2395- assertBlank(t, s, 1, 0)
2396- assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
2397- assertCell(t, s, 3, 0, 'B')
2398- },
2399- },
2400- {
2401- name: "wrap abandoning a continuation cell clears its pair and extends damage",
2402- run: func(t *testing.T) {
2403- s := NewScreen(4, 2)
2404- s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
2405- s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
2406- s.ClearDamage()
2407- // A wide rune at the last column wraps; the abandoned last cell
2408- // is a continuation, so its wide left half (col 2) must be
2409- // cleared too and the damage must span both columns.
2410- s.Write([]byte("好"))
2411-
2412- assertCell(t, s, 0, 0, 'A')
2413- assertCell(t, s, 1, 0, 'B')
2414- assertBlank(t, s, 2, 0) // orphaned wide left cleared
2415- assertBlank(t, s, 3, 0) // abandoned continuation cleared
2416- assertCell(t, s, 0, 1, '好')
2417- assertContinuation(t, s, 1, 1)
2418- if s.Col != 2 || s.Row != 1 {
2419- t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
2420- }
2421-
2422- d := s.Damage()
2423- if len(d) == 0 {
2424- t.Fatal("expected damage")
2425- }
2426- // First damage item covers the cleared pair on row 0.
2427- if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
2428- 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)
2429- }
2430- },
2431- },
2432- {
2433- name: "insert chars at a continuation splits the pair and repairs orphans",
2434- run: func(t *testing.T) {
2435- s := NewScreen(6, 2)
2436- s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
2437- s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
2438- s.ClearDamage()
2439- s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
2440-
2441- // The shift splits 好/cont: 好 stays at col 2, its continuation
2442- // moves to col 4. Both orphans must be repaired to blanks.
2443- assertCell(t, s, 0, 0, '你')
2444- assertContinuation(t, s, 1, 0)
2445- assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
2446- assertBlank(t, s, 3, 0) // inserted blank
2447- assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
2448- assertBlank(t, s, 5, 0)
2449-
2450- d := s.Damage()
2451- if len(d) == 0 {
2452- t.Fatal("expected damage")
2453- }
2454- // Damage must extend one column left to cover the repaired 好.
2455- if d[0].X != 2 || d[0].Width != 4 {
2456- t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
2457- }
2458- },
2459- },
2460- {
2461- name: "insert chars shifting a wide pair off the right edge repairs the left half",
2462- run: func(t *testing.T) {
2463- s := NewScreen(4, 2)
2464- s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
2465- s.Write([]byte("\x1b[1;1H")) // cursor home
2466- s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
2467-
2468- // 好 lands on the last column with its continuation pushed off
2469- // the edge; the orphaned wide left must be blanked.
2470- assertBlank(t, s, 0, 0)
2471- assertCell(t, s, 1, 0, '你')
2472- assertContinuation(t, s, 2, 0)
2473- assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
2474- },
2475- },
2476- {
2477- name: "delete char at the left half of a wide pair repairs the orphan",
2478- run: func(t *testing.T) {
2479- s := NewScreen(6, 2)
2480- s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
2481- s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
2482- s.ClearDamage()
2483- s.Write([]byte("\x1b[1P")) // DCH 1
2484-
2485- // 你's continuation shifts to col 0 with no left half → repaired.
2486- assertBlank(t, s, 0, 0)
2487- assertCell(t, s, 1, 0, '好')
2488- assertContinuation(t, s, 2, 0)
2489- assertCell(t, s, 3, 0, 'A')
2490- assertCell(t, s, 4, 0, 'B')
2491- assertBlank(t, s, 5, 0)
2492-
2493- d := s.Damage()
2494- if len(d) == 0 {
2495- t.Fatal("expected damage")
2496- }
2497- if d[0].X != 0 || d[0].Width != 6 {
2498- t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
2499- }
2500- },
2501- },
2502- {
2503- name: "delete char at a continuation repairs the wide left and extends damage",
2504- run: func(t *testing.T) {
2505- s := NewScreen(6, 2)
2506- s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
2507- s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
2508- s.ClearDamage()
2509- s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
2510-
2511- // 你 at col 0 loses its continuation → repaired to blank.
2512- assertBlank(t, s, 0, 0)
2513- assertCell(t, s, 1, 0, '好')
2514- assertContinuation(t, s, 2, 0)
2515- assertCell(t, s, 3, 0, 'A')
2516- assertCell(t, s, 4, 0, 'B')
2517- assertBlank(t, s, 5, 0)
2518-
2519- d := s.Damage()
2520- if len(d) == 0 {
2521- t.Fatal("expected damage")
2522- }
2523- // Damage must extend one column left (to col 0) to cover the
2524- // repaired wide left half.
2525- if d[0].X != 0 || d[0].Width != 6 {
2526- t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
2527- }
2528- },
2529- },
2530- {
2531- name: "IRM inserts narrow printable cells and reset restores overwrite",
2532- run: func(t *testing.T) {
2533- s := NewScreen(8, 1)
2534- s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
2535-
2536- if got := lineText(s, 0); got != "abXcd " {
2537- t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
2538- }
2539- s.Write([]byte("\x1b[4l\x1b[1;3HY"))
2540- if got := lineText(s, 0); got != "abYcd " {
2541- t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
2542- }
2543- },
2544- },
2545- {
2546- name: "IRM inserts wide printable cells and repairs evicted wide head",
2547- run: func(t *testing.T) {
2548- s := NewScreen(6, 1)
2549- s.Write([]byte("ab界cd"))
2550- s.Write([]byte("\x1b[1;3H\x1b[4h好"))
2551-
2552- assertCell(t, s, 0, 0, 'a')
2553- assertCell(t, s, 1, 0, 'b')
2554- assertCell(t, s, 2, 0, '好')
2555- assertContinuation(t, s, 3, 0)
2556- assertCell(t, s, 4, 0, '界')
2557- assertContinuation(t, s, 5, 0)
2558- assertNoOrphanWideCells(t, s)
2559- },
2560- },
2561- {
2562- name: "IRM wide insert at right edge repairs orphan",
2563- run: func(t *testing.T) {
2564- s := NewScreen(5, 1)
2565- s.Write([]byte("abc界"))
2566- s.Write([]byte("\x1b[1;2H\x1b[4h好"))
2567-
2568- assertCell(t, s, 0, 0, 'a')
2569- assertCell(t, s, 1, 0, '好')
2570- assertContinuation(t, s, 2, 0)
2571- assertCell(t, s, 3, 0, 'b')
2572- assertCell(t, s, 4, 0, 'c')
2573- assertNoOrphanWideCells(t, s)
2574- },
2575- },
2576- {
2577- name: "wide char on a width-1 screen degrades to a single cell",
2578- run: func(t *testing.T) {
2579- s := NewScreen(1, 2)
2580- // A wide rune cannot fit on a 1-column screen; it must not write
2581- // an out-of-bounds continuation cell.
2582- s.Write([]byte("你"))
2583-
2584- assertCell(t, s, 0, 0, '你')
2585- if s.Col != 1 {
2586- t.Errorf("cursor at col=%d, want 1", s.Col)
2587- }
2588- },
2589- },
2590- {
2591- name: "wide pair survives a scroll",
2592- run: func(t *testing.T) {
2593- s := NewScreen(4, 2)
2594- s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
2595- s.Write([]byte("\r\n")) // bottom row → scroll up
2596-
2597- // After scrolling up, row1's 好 moves to row0 intact.
2598- assertCell(t, s, 0, 0, '好')
2599- assertContinuation(t, s, 1, 0)
2600- },
2601- },
2602- {
2603- name: "combining mark alone writes no cell and produces no damage",
2604- run: func(t *testing.T) {
2605- s := NewScreen(10, 3)
2606- s.ClearDamage()
2607- s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
2608-
2609- // No cells should be modified.
2610- for y := range 3 {
2611- for x := range 10 {
2612- if c := cellAt(s, x, y); c.Rune != ' ' {
2613- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
2614- }
2615- }
2616- }
2617- if s.Col != 0 || s.Row != 0 {
2618- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
2619- }
2620- d := s.Damage()
2621- if len(d) != 0 {
2622- t.Errorf("expected no damage for combining mark alone, got %+v", d)
2623- }
2624- },
2625- },
2626- }
2627-
2628- for _, tt := range tests {
2629- t.Run(tt.name, tt.run)
2630- }
2631-}
2632-
2633-func TestRuneWidth(t *testing.T) {
2634- tests := []struct {
2635- name string
2636- r rune
2637- width int
2638- }{
2639- {"space", ' ', 1},
2640- {"lowercase ascii letter", 'a', 1},
2641- {"uppercase ascii letter", 'A', 1},
2642- {"ascii digit", '1', 1},
2643- {"soft hyphen", 0x00AD, 0},
2644- {"combining grapheme joiner", 0x034F, 0},
2645- {"combining acute accent", 0x0301, 0},
2646- {"combining grave accent", 0x0300, 0},
2647- {"zero-width space", 0x200B, 0},
2648- {"zero-width non-joiner", 0x200C, 0},
2649- {"zero-width joiner", 0x200D, 0},
2650- {"BOM / zero-width no-break space", 0xFEFF, 0},
2651- {"CJK Unified Ideograph (一)", 0x4E00, 2},
2652- {"CJK (二)", 0x4E8C, 2},
2653- {"CJK end of BMP", 0x9FFF, 2},
2654- {"CJK Extension A", 0x3400, 2},
2655- {"Hangul Syllable (가)", 0xAC00, 2},
2656- {"Hangul Syllable end", 0xD7AF, 2},
2657- {"CJK Ideographic Space", 0x3000, 2},
2658- {"Fullwidth Exclamation Mark", 0xFF01, 2},
2659- {"Fullwidth Cent Sign", 0xFFE0, 2},
2660- {"grinning face emoji", 0x1F600, 2},
2661- {"rocket emoji", 0x1F680, 2},
2662- {"hiragana a", 0x3042, 2},
2663- {"katakana a", 0x30A2, 2},
2664- {"CJK Extension B", 0x20000, 2},
2665- {"supplemental symbols (robot)", 0x1F916, 2},
2666- {"symbols extended-A (sari)", 0x1FA71, 2},
2667- {"ideographic full stop", 0x3002, 2},
2668- {"NUL", '\x00', 0},
2669- {"SOH", '\x01', 0},
2670- {"ESC", '\x1b', 0},
2671- {"DEL", 0x7F, 0},
2672- }
2673-
2674- for _, tt := range tests {
2675- t.Run(tt.name, func(t *testing.T) {
2676- got := renderer.RuneWidth(tt.r)
2677- if got != tt.width {
2678- t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
2679- }
2680- })
2681- }
2682-}
2683-
2684-func TestDamageReturnsReference(t *testing.T) {
2685- tests := []struct {
2686- name string
2687- }{
2688- {name: "Damage() returns a reference to the internal slice"},
2689- }
2690-
2691- for _, tt := range tests {
2692- t.Run(tt.name, func(t *testing.T) {
2693- // Damage() should return a reference to internal slice (no copy).
2694- s := NewScreen(10, 3)
2695- s.Write([]byte("X"))
2696- d1 := s.Damage()
2697- // Internal slice should be the same (or at least reference the same backing).
2698- if len(d1) == 0 {
2699- t.Fatal("expected damage")
2700- }
2701- // Write more to trigger record.
2702- s.Write([]byte("Y"))
2703- d2 := s.Damage()
2704- _ = d2
2705- // Both d1 and d2 reference the internal slice; we just don't crash.
2706- })
2707- }
2708-}
2709-
2710-// ---------------------------------------------------------------------------
2711-// CSI S / SU — Scroll Up
2712-// ---------------------------------------------------------------------------
2713-
2714-func TestCSIScrollUp(t *testing.T) {
2715- tests := []struct {
2716- name string
2717- run func(t *testing.T)
2718- }{
2719- {
2720- name: "CSI S with no param scrolls up by 1 and preserves cursor",
2721- run: func(t *testing.T) {
2722- s := NewScreen(5, 4)
2723- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
2724- s.ClearDamage()
2725-
2726- // CSI S with no param → scroll up by 1.
2727- s.Write([]byte("\x1b[S"))
2728-
2729- // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
2730- assertCell(t, s, 0, 0, 'B')
2731- assertCell(t, s, 0, 1, 'C')
2732- assertCell(t, s, 0, 2, 'D')
2733- assertCell(t, s, 0, 3, ' ')
2734-
2735- // Cursor should NOT have moved.
2736- if s.Col != 5 || s.Row != 3 {
2737- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
2738- }
2739-
2740- d := s.Damage()
2741- if !hasDamageKind(d, renderer.DamageScrollUp) {
2742- t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
2743- }
2744- },
2745- },
2746- {
2747- name: "CSI 2S scrolls up by an explicit count",
2748- run: func(t *testing.T) {
2749- s := NewScreen(5, 4)
2750- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
2751- s.ClearDamage()
2752-
2753- // CSI 2 S → scroll up by 2.
2754- s.Write([]byte("\x1b[2S"))
2755-
2756- // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
2757- assertCell(t, s, 0, 0, 'C')
2758- assertCell(t, s, 0, 1, 'D')
2759- assertCell(t, s, 0, 2, ' ')
2760- assertCell(t, s, 0, 3, ' ')
2761-
2762- // Cursor preserved.
2763- if s.Col != 5 || s.Row != 3 {
2764- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
2765- }
2766- },
2767- },
2768- {
2769- name: "CSI 100S on a 3-row screen clamps to the full screen",
2770- run: func(t *testing.T) {
2771- s := NewScreen(5, 3)
2772- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
2773- s.ClearDamage()
2774-
2775- // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
2776- s.Write([]byte("\x1b[100S"))
2777-
2778- // All rows should be blank.
2779- for y := range 3 {
2780- for x := range 5 {
2781- assertCell(t, s, x, y, ' ')
2782- }
2783- }
2784- },
2785- },
2786- {
2787- name: "CSI 0S defaults to a scroll count of 1",
2788- run: func(t *testing.T) {
2789- s := NewScreen(5, 3)
2790- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
2791- s.ClearDamage()
2792-
2793- // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
2794- s.Write([]byte("\x1b[0S"))
2795-
2796- // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
2797- assertCell(t, s, 0, 0, 'B')
2798- assertCell(t, s, 0, 1, 'C')
2799- assertCell(t, s, 0, 2, ' ')
2800- },
2801- },
2802- {
2803- name: "CSI 3S reports scroll count 3 and blanked-row text damage",
2804- run: func(t *testing.T) {
2805- s := NewScreen(5, 4)
2806- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
2807- s.ClearDamage()
2808-
2809- s.Write([]byte("\x1b[3S")) // scroll up by 3
2810-
2811- d := s.Damage()
2812- var scrollDamage *renderer.Damage
2813- for i, dd := range d {
2814- if dd.Kind == renderer.DamageScrollUp {
2815- scrollDamage = &d[i]
2816- break
2817- }
2818- }
2819- if scrollDamage == nil {
2820- t.Fatal("expected DamageScrollUp")
2821- }
2822- if scrollDamage.Count != 3 {
2823- t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
2824- }
2825- if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
2826- t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
2827- }
2828-
2829- // Should also have text damage for the blanked rows (bottom 3 rows).
2830- if !hasDamageKind(d, renderer.DamageText) {
2831- t.Error("expected DamageText for blanked rows")
2832- }
2833- },
2834- },
2835- {
2836- name: "cursor position is preserved across a scroll-up",
2837- run: func(t *testing.T) {
2838- s := NewScreen(10, 5)
2839- s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
2840- s.ClearDamage()
2841-
2842- // Move cursor to middle of row 2.
2843- s.Write([]byte("\x1b[3;3H")) // row=3, col=3
2844- s.Write([]byte("\x1b[2S")) // scroll up by 2
2845-
2846- // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
2847- if s.Row != 2 || s.Col != 2 {
2848- t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
2849- }
2850- },
2851- },
2852- }
2853-
2854- for _, tt := range tests {
2855- t.Run(tt.name, tt.run)
2856- }
2857-}
2858-
2859-// ---------------------------------------------------------------------------
2860-// OSC sequences
2861-// ---------------------------------------------------------------------------
2862-
2863-func TestOSCSequences(t *testing.T) {
2864- tests := []struct {
2865- name string
2866- run func(t *testing.T)
2867- }{
2868- {
2869- name: "BEL-terminated OSC is ignored",
2870- run: func(t *testing.T) {
2871- s := NewScreen(20, 2)
2872- s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
2873-
2874- assertCell(t, s, 0, 0, 'f')
2875- assertCell(t, s, 1, 0, 'i')
2876- assertCell(t, s, 2, 0, 's')
2877- assertCell(t, s, 3, 0, 'h')
2878- assertCell(t, s, 4, 0, '$')
2879- assertCell(t, s, 5, 0, ' ')
2880- if s.Col != 6 || s.Row != 0 {
2881- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
2882- }
2883- },
2884- },
2885- {
2886- name: "ST-terminated OSC is ignored",
2887- run: func(t *testing.T) {
2888- s := NewScreen(20, 2)
2889- s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
2890-
2891- assertCell(t, s, 0, 0, '>')
2892- assertCell(t, s, 1, 0, ' ')
2893- if s.Col != 2 || s.Row != 0 {
2894- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
2895- }
2896- },
2897- },
2898- {
2899- name: "multiple OSC sequences in a row are all ignored",
2900- run: func(t *testing.T) {
2901- s := NewScreen(20, 2)
2902- s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
2903-
2904- assertCell(t, s, 0, 0, 'p')
2905- assertCell(t, s, 1, 0, 'r')
2906- assertCell(t, s, 2, 0, 'o')
2907- assertCell(t, s, 3, 0, 'm')
2908- assertCell(t, s, 4, 0, 'p')
2909- assertCell(t, s, 5, 0, 't')
2910- if s.Col != 6 || s.Row != 0 {
2911- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
2912- }
2913- },
2914- },
2915- {
2916- name: "BEL-terminated OSC split across writes is ignored",
2917- run: func(t *testing.T) {
2918- s := NewScreen(20, 2)
2919- s.Write([]byte("\x1b]0;~/Projects/ymux"))
2920- s.Write([]byte(" - fish\x07fish$ "))
2921-
2922- assertCell(t, s, 0, 0, 'f')
2923- assertCell(t, s, 1, 0, 'i')
2924- assertCell(t, s, 2, 0, 's')
2925- assertCell(t, s, 3, 0, 'h')
2926- assertCell(t, s, 4, 0, '$')
2927- assertCell(t, s, 5, 0, ' ')
2928- if s.Col != 6 || s.Row != 0 {
2929- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
2930- }
2931- },
2932- },
2933- {
2934- name: "ST-terminated OSC split across writes is ignored",
2935- run: func(t *testing.T) {
2936- s := NewScreen(20, 2)
2937- s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
2938- s.Write([]byte("\\> "))
2939-
2940- assertCell(t, s, 0, 0, '>')
2941- assertCell(t, s, 1, 0, ' ')
2942- if s.Col != 2 || s.Row != 0 {
2943- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
2944- }
2945- },
2946- },
2947- {
2948- name: "unterminated OSC sequence is bounded and dropped",
2949- run: func(t *testing.T) {
2950- s := NewScreen(20, 2)
2951- payload := strings.Repeat("x", maxEscapeBufferLen+1)
2952- s.Write([]byte("\x1b]0;" + payload))
2953- s.Write([]byte("OK"))
2954-
2955- assertCell(t, s, 0, 0, 'O')
2956- assertCell(t, s, 1, 0, 'K')
2957- if len(s.escapeBuf) != 0 {
2958- t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
2959- }
2960- },
2961- },
2962- }
2963-
2964- for _, tt := range tests {
2965- t.Run(tt.name, tt.run)
2966- }
2967-}
2968-
2969-// ---------------------------------------------------------------------------
2970-// C1 Unicode controls (U+0080-U+009F)
2971-// ---------------------------------------------------------------------------
2972-
2973-func TestC1Controls(t *testing.T) {
2974- tests := []struct {
2975- name string
2976- run func(t *testing.T)
2977- }{
2978- {
2979- name: "C1 controls are dropped without moving the cursor",
2980- run: func(t *testing.T) {
2981- s := NewScreen(10, 3)
2982- s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
2983- s.Write([]byte{0xC2, 0x9F}) // U+009F APC
2984-
2985- // No cells should be modified.
2986- for y := range 3 {
2987- for x := range 10 {
2988- if c := cellAt(s, x, y); c.Rune != ' ' {
2989- t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
2990- }
2991- }
2992- }
2993- // Cursor should not have moved.
2994- if s.Col != 0 || s.Row != 0 {
2995- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
2996- }
2997- },
2998- },
2999- {
3000- name: "the full C1 control range U+0080-U+009F is dropped",
3001- run: func(t *testing.T) {
3002- s := NewScreen(10, 3)
3003- // Send all C1 control runes U+0080 through U+009F.
3004- for r := 0x80; r <= 0x9F; r++ {
3005- s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
3006- }
3007-
3008- // No cells should be modified.
3009- for y := range 3 {
3010- for x := range 10 {
3011- if c := cellAt(s, x, y); c.Rune != ' ' {
3012- t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
3013- }
3014- }
3015- }
3016- if s.Col != 0 || s.Row != 0 {
3017- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
3018- }
3019- },
3020- },
3021- {
3022- name: "printable characters still work after C1 controls",
3023- run: func(t *testing.T) {
3024- s := NewScreen(10, 3)
3025- // Printable characters should still work after C1 controls.
3026- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
3027- s.Write([]byte("AB"))
3028- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
3029- s.Write([]byte("CD"))
3030-
3031- assertCell(t, s, 0, 0, 'A')
3032- assertCell(t, s, 1, 0, 'B')
3033- assertCell(t, s, 2, 0, 'C')
3034- assertCell(t, s, 3, 0, 'D')
3035- if s.Col != 4 {
3036- t.Errorf("cursor at col=%d, want 4", s.Col)
3037- }
3038- },
3039- },
3040- {
3041- name: "C1 controls mixed with CR preserve CR behavior",
3042- run: func(t *testing.T) {
3043- s := NewScreen(10, 3)
3044- // C1 controls mixed with CR/LF should preserve CR/LF behavior.
3045- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
3046- s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
3047- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
3048-
3049- assertCell(t, s, 0, 0, 'B')
3050- },
3051- },
3052- {
3053- name: "C1 controls mixed with LF preserve LF behavior",
3054- run: func(t *testing.T) {
3055- s := NewScreen(10, 3)
3056- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
3057- s.Write([]byte("A\nB"))
3058-
3059- assertCell(t, s, 0, 0, 'A')
3060- assertCell(t, s, 1, 1, 'B')
3061- if s.Row != 1 || s.Col != 2 {
3062- t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
3063- }
3064- },
3065- },
3066- {
3067- name: "C1 controls produce no damage",
3068- run: func(t *testing.T) {
3069- s := NewScreen(10, 3)
3070- s.ClearDamage()
3071- // C1 controls should produce no damage.
3072- s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
3073-
3074- d := s.Damage()
3075- if len(d) != 0 {
3076- t.Errorf("expected no damage from C1 controls, got %+v", d)
3077- }
3078- },
3079- },
3080- }
3081-
3082- for _, tt := range tests {
3083- t.Run(tt.name, tt.run)
3084- }
3085-}
3086-
3087-// ---------------------------------------------------------------------------
3088-// Line-offset rotation scroll (M1c)
3089-// ---------------------------------------------------------------------------
3090-
3091-// TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact
3092-// with its line when a full-width scroll rotates line offsets rather than
3093-// copying cells.
3094-func TestWideCharSurvivesRotatedScroll(t *testing.T) {
3095- tests := []struct {
3096- name string
3097- run func(t *testing.T)
3098- }{
3099- {
3100- name: "wide pair moves up one row on newline scroll",
3101- run: func(t *testing.T) {
3102- s := NewScreen(4, 3)
3103- // Place a wide rune (世, width 2) on the bottom row, then scroll up.
3104- s.Write([]byte("\x1b[3;1H世"))
3105- assertCell(t, s, 0, 2, '世')
3106- assertContinuation(t, s, 1, 2)
3107- // Newline at the bottom row scrolls the region up by one.
3108- s.Write([]byte("\n"))
3109- // The wide pair is now on row 1, intact; bottom row blank.
3110- assertCell(t, s, 0, 1, '世')
3111- assertContinuation(t, s, 1, 1)
3112- assertBlank(t, s, 0, 2)
3113- assertBlank(t, s, 1, 2)
3114- if err := s.Frame.CheckInvariants(); err != nil {
3115- t.Fatalf("invariants: %v", err)
3116- }
3117- },
3118- },
3119- {
3120- name: "wide pair moves down on reverse index",
3121- run: func(t *testing.T) {
3122- s := NewScreen(4, 3)
3123- s.Write([]byte("\x1b[1;1H世")) // top row
3124- assertCell(t, s, 0, 0, '世')
3125- assertContinuation(t, s, 1, 0)
3126- s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down
3127- assertCell(t, s, 0, 1, '世')
3128- assertContinuation(t, s, 1, 1)
3129- assertBlank(t, s, 0, 0)
3130- if err := s.Frame.CheckInvariants(); err != nil {
3131- t.Fatalf("invariants: %v", err)
3132- }
3133- },
3134- },
3135- }
3136- for _, tt := range tests {
3137- t.Run(tt.name, tt.run)
3138- }
3139-}
3140-
3141-// TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls
3142-// through the VT layer and asserts the frame's line-offset stays a valid
3143-// permutation.
3144-func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
3145- s := NewScreen(10, 6)
3146- seqs := [][]byte{
3147- []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"),
3148- []byte("\x1b[2;5r"), // scroll region rows 2..5
3149- []byte("\x1b[5;1Hmore\n"), // scroll within region
3150- []byte("\x1b[1;1H\x1bM"), // reverse index at top
3151- []byte("\x1b[3S"), // scroll up 3
3152- []byte("\x1b[2T"), // scroll down 2
3153- []byte("\x1b[r"), // reset region
3154- []byte("\x1b[6;1Hbottom\n"),
3155- }
3156- for i, seq := range seqs {
3157- s.Write(seq)
3158- s.ClearDamage()
3159- if err := s.Frame.CheckInvariants(); err != nil {
3160- t.Fatalf("after seq %d: invariants broken: %v", i, err)
3161- }
3162- }
3163-}
3164-
3165-func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
3166- s := NewScreen(10, 2)
3167- s.Write([]byte("\x1b[?2026h"))
3168- if !s.SyncUpdateActive() {
3169- t.Fatal("sync update mode should be active")
3170- }
3171- s.Write([]byte("\x1b[?2026l"))
3172- if s.SyncUpdateActive() {
3173- t.Fatal("sync update mode should be inactive")
3174- }
3175-}
3176-
3177-func TestForceSyncEnd(t *testing.T) {
3178- s := NewScreen(10, 2)
3179- s.Write([]byte("\x1b[?2026h"))
3180- if !s.SyncUpdateActive() {
3181- t.Fatal("sync update mode should be active before forcing end")
3182- }
3183- s.ForceSyncEnd()
3184- if s.SyncUpdateActive() {
3185- t.Fatal("sync update mode should be inactive after forcing end")
3186- }
3187-}
3188-
3189-func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
3190- tests := []struct {
3191- name string
3192- seq string
3193- check func(t *testing.T, s *Screen)
3194- }{
3195- {
3196- name: "cursor position and visibility are exposed",
3197- seq: "\x1b[2;3H\x1b[?25l",
3198- check: func(t *testing.T, s *Screen) {
3199- if s.CursorRow() != 1 || s.CursorCol() != 2 {
3200- t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
3201- }
3202- if s.CursorVisible() {
3203- t.Fatal("cursor should be hidden")
3204- }
3205- s.Write([]byte("\x1b[?25h"))
3206- if !s.CursorVisible() {
3207- t.Fatal("cursor should be visible")
3208- }
3209- },
3210- },
3211- {
3212- name: "mouse mode and SGR are exposed",
3213- seq: "\x1b[?1002h\x1b[?1006h",
3214- check: func(t *testing.T, s *Screen) {
3215- mode, sgr := s.MouseMode()
3216- if mode != 1002 || !sgr {
3217- t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
3218- }
3219- },
3220- },
3221- {
3222- name: "reset restores cursor and mouse defaults",
3223- seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
3224- check: func(t *testing.T, s *Screen) {
3225- mode, sgr := s.MouseMode()
3226- if !s.CursorVisible() || mode != 0 || sgr {
3227- t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
3228- }
3229- },
3230- },
3231- }
3232- for _, tt := range tests {
3233- t.Run(tt.name, func(t *testing.T) {
3234- s := NewScreen(10, 4)
3235- s.Write([]byte(tt.seq))
3236- tt.check(t, s)
3237- })
3238- }
3239-}
3240-
3241-func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
3242- s := NewScreen(10, 2)
3243- s.Write([]byte("\x1b[?1002h"))
3244- s.Write([]byte("\x1b[?1000l"))
3245- mode, _ := s.MouseMode()
3246- if mode != 1002 {
3247- t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
3248- }
3249- s.Write([]byte("\x1b[?1002l"))
3250- mode, _ = s.MouseMode()
3251- if mode != 0 {
3252- t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
3253- }
3254-}
3255-
3256-func TestScreenCursorStyleDECSCUSR(t *testing.T) {
3257- tests := []struct {
3258- name string
3259- seq string
3260- wantStyle int
3261- wantSet bool
3262- }{
3263- {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
3264- {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
3265- {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false},
3266- {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false},
3267- {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
3268- }
3269- for _, tt := range tests {
3270- t.Run(tt.name, func(t *testing.T) {
3271- s := NewScreen(10, 2)
3272- s.Write([]byte(tt.seq))
3273- style, set := s.CursorStyle()
3274- if style != tt.wantStyle || set != tt.wantSet {
3275- t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
3276- }
3277- })
3278- }
3279-}
3280-
3281-func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) {
3282- s := NewScreen(10, 2)
3283- s.Write([]byte("\x1b[5 q"))
3284- s.Write([]byte("\x1b[99 q"))
3285- style, set := s.CursorStyle()
3286- if style != 5 || !set {
3287- t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set)
3288- }
3289-}
3290-
3291-func TestScreenAltScreenActiveAccessor(t *testing.T) {
3292- s := NewScreen(10, 2)
3293- if s.AltScreenActive() {
3294- t.Fatal("alt screen should start inactive")
3295- }
3296- s.Write([]byte("\x1b[?1049h"))
3297- if !s.AltScreenActive() {
3298- t.Fatal("alt screen should be active")
3299- }
3300- s.Write([]byte("\x1b[?1049l"))
3301- if s.AltScreenActive() {
3302- t.Fatal("alt screen should be inactive after exit")
3303- }
3304-}
3305-
3306-func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
3307- s := NewScreen(8, 2)
3308- s.ClearDamage()
3309- s.Write([]byte("a"))
3310-
3311- captured := s.CaptureDamage()
3312- require.NotEmpty(t, captured.Damage)
3313- s.Write([]byte("b"))
3314-
3315- require.False(t, s.AcknowledgeDamage(captured.Generation))
3316- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
3317-}
3318-
3319-func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
3320- s := NewScreen(8, 2)
3321- s.ClearDamage()
3322- s.Write([]byte("a"))
3323-
3324- captured := s.CaptureDamage()
3325- require.True(t, s.AcknowledgeDamage(captured.Generation))
3326- require.Empty(t, s.Damage())
3327-}
3328-
3329-func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
3330- s := NewScreen(8, 2)
3331- s.ClearDamage()
3332- s.Write([]byte("a"))
3333-
3334- captured := s.CaptureDamage()
3335- s.ClearDamage()
3336- require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
3337-}
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+}

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