v0.7.0 36c429d

screen_write_test.go RawHistoryFolder

1package vt
2
3import (
4	"testing"
5
6	renderer "github.com/bnema/vev-vt/core"
7	"github.com/stretchr/testify/require"
8)
9
10func TestWritePrintableAndUTF8(t *testing.T) {
11	tests := []struct {
12		name string
13		run  func(t *testing.T)
14	}{
15		{
16			name: "printable ASCII advances cursor and records damage",
17			run: func(t *testing.T) {
18				s := NewScreen(5, 3)
19				s.Write([]byte("Hi"))
20
21				assertCell(t, s, 0, 0, 'H')
22				assertCell(t, s, 1, 0, 'i')
23				if s.Col != 2 || s.Row != 0 {
24					t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
25				}
26
27				// Should have damage.
28				d := s.Damage()
29				if len(d) == 0 {
30					t.Fatal("expected damage after writing")
31				}
32			},
33		},
34		{
35			name: "UTF-8 multi-byte runes occupy one cell each",
36			run: func(t *testing.T) {
37				s := NewScreen(10, 3)
38				s.Write([]byte("aäéø©"))
39
40				assertCell(t, s, 0, 0, 'a')
41				assertCell(t, s, 1, 0, 'ä')
42				assertCell(t, s, 2, 0, 'é')
43				assertCell(t, s, 3, 0, 'ø')
44				assertCell(t, s, 4, 0, '©')
45			},
46		},
47		{
48			name: "printable beyond width wraps to next line",
49			run: func(t *testing.T) {
50				s := NewScreen(4, 2)
51				s.Write([]byte("ABCDE"))
52
53				assertCell(t, s, 0, 0, 'A')
54				assertCell(t, s, 1, 0, 'B')
55				assertCell(t, s, 2, 0, 'C')
56				assertCell(t, s, 3, 0, 'D')
57				// E wrapped to next line.
58				assertCell(t, s, 0, 1, 'E')
59			},
60		},
61		{
62			name: "printable beyond screen height scrolls",
63			run: func(t *testing.T) {
64				s := NewScreen(3, 2)
65				// Fill both lines.
66				s.Write([]byte("ABC"))
67				s.Write([]byte("DEF"))
68				// Row is now 1 (bottom). One more char should scroll.
69				s.Write([]byte("G"))
70
71				// After scroll: old row 0 = "DEF", new row 1 = "G  ".
72				assertCell(t, s, 0, 0, 'D')
73				assertCell(t, s, 1, 0, 'E')
74				assertCell(t, s, 2, 0, 'F')
75				assertCell(t, s, 0, 1, 'G')
76				assertCell(t, s, 1, 1, ' ')
77
78				// Check scroll damage.
79				d := s.Damage()
80				if !hasDamageKind(d, renderer.DamageScrollUp) {
81					t.Error("expected scroll damage")
82				}
83			},
84		},
85	}
86
87	for _, tt := range tests {
88		t.Run(tt.name, tt.run)
89	}
90}
91
92func TestCursorControlChars(t *testing.T) {
93	tests := []struct {
94		name string
95		run  func(t *testing.T)
96	}{
97		{
98			name: "CR moves column back to 0",
99			run: func(t *testing.T) {
100				s := NewScreen(10, 3)
101				s.Write([]byte("Hello"))
102				// CR moves column back to 0.
103				s.Write([]byte("\rWorld"))
104
105				assertCell(t, s, 0, 0, 'W')
106				assertCell(t, s, 1, 0, 'o')
107				assertCell(t, s, 2, 0, 'r')
108				assertCell(t, s, 3, 0, 'l')
109				assertCell(t, s, 4, 0, 'd')
110				if s.Col != 5 {
111					t.Errorf("col after CR + World = %d, want 5", s.Col)
112				}
113			},
114		},
115		{
116			name: "LF with CR moves to next line column 0",
117			run: func(t *testing.T) {
118				s := NewScreen(5, 3)
119				s.Write([]byte("A\r\nB\r\nC"))
120
121				assertCell(t, s, 0, 0, 'A')
122				assertCell(t, s, 0, 1, 'B')
123				assertCell(t, s, 0, 2, 'C')
124				if s.Row != 2 || s.Col != 1 {
125					t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
126				}
127			},
128		},
129		{
130			name: "LF alone does not reset column",
131			run: func(t *testing.T) {
132				s := NewScreen(5, 3)
133				s.Write([]byte("AB\nC"))
134
135				assertCell(t, s, 0, 0, 'A')
136				assertCell(t, s, 1, 0, 'B')
137				assertCell(t, s, 0, 1, ' ')
138				assertCell(t, s, 2, 1, 'C')
139				if s.Row != 1 || s.Col != 3 {
140					t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
141				}
142			},
143		},
144		{
145			name: "LF at pending wrap boundary advances exactly one line",
146			run: func(t *testing.T) {
147				s := NewScreen(5, 4)
148				s.Write([]byte("ABCDE\nZ"))
149
150				assertCell(t, s, 4, 0, 'E')
151				assertCell(t, s, 4, 1, 'Z')
152				if s.Row != 1 || s.Col != 5 {
153					t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
154				}
155			},
156		},
157		{
158			name: "LF scrolls when both lines are full",
159			run: func(t *testing.T) {
160				s := NewScreen(5, 2)
161				// Fill both lines then CRLF to scroll and return to column 0.
162				s.Write([]byte("AAAAA"))
163				s.Write([]byte("BBBBB"))
164				s.Write([]byte("\r\nCCCCC"))
165
166				assertCell(t, s, 0, 0, 'B')
167				assertCell(t, s, 4, 1, 'C')
168			},
169		},
170		{
171			name: "BS moves cursor back without touching the cell",
172			run: func(t *testing.T) {
173				s := NewScreen(10, 3)
174				s.Write([]byte("Hello\b"))
175
176				if s.Col != 4 {
177					t.Errorf("col after BS = %d, want 4", s.Col)
178				}
179				assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
180			},
181		},
182		{
183			name: "BS at column zero is a no-op",
184			run: func(t *testing.T) {
185				s := NewScreen(10, 3)
186				s.Write([]byte("\b")) // BS at col 0 should be no-op
187
188				if s.Col != 0 {
189					t.Errorf("col = %d, want 0", s.Col)
190				}
191			},
192		},
193		{
194			name: "tab moves cursor without changing cells or damage",
195			run: func(t *testing.T) {
196				s := NewScreen(10, 2)
197				for y := range s.frame.Height {
198					for x := range s.frame.Width {
199						s.frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.frame.Width + x)})
200					}
201				}
202				before := s.frame.Clone()
203				s.Row, s.Col = 1, 6
204				s.ClearDamage()
205
206				s.Write([]byte("\t"))
207				if s.Row != 1 || s.Col != 8 {
208					t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
209				}
210				assertFramesEqual(t, s.frame, before)
211				if got := s.Damage(); len(got) != 0 {
212					t.Errorf("tab damage = %+v, want none", got)
213				}
214
215				// A tab whose next stop lies past the edge clamps at the last
216				// column; it must not wrap or scroll the bottom row.
217				s.Write([]byte("\t"))
218				if s.Row != 1 || s.Col != 9 {
219					t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
220				}
221				assertFramesEqual(t, s.frame, before)
222				if got := s.Damage(); len(got) != 0 {
223					t.Errorf("clamped tab damage = %+v, want none", got)
224				}
225			},
226		},
227	}
228
229	for _, tt := range tests {
230		t.Run(tt.name, tt.run)
231	}
232}
233
234func TestInvalidInputIgnored(t *testing.T) {
235	tests := []struct {
236		name  string
237		input []byte
238	}{
239		{
240			name:  "invalid UTF-8 continuation byte without start byte",
241			input: []byte{0x80, 0x81},
242		},
243		{
244			name:  "unhandled control characters below 0x20",
245			input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
246		},
247	}
248
249	for _, tt := range tests {
250		t.Run(tt.name, func(t *testing.T) {
251			s := NewScreen(10, 3)
252			s.Write(tt.input)
253			// Should not panic, and no cells should be modified.
254			for y := range 3 {
255				for x := range 10 {
256					if c := cellAt(s, x, y); c.Rune != ' ' {
257						t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
258					}
259				}
260			}
261		})
262	}
263}
264
265func TestWriteAtEdgeNoPanic(t *testing.T) {
266	tests := []struct {
267		name string
268	}{
269		{name: "writing past a 1x1 screen scrolls instead of panicking"},
270	}
271
272	for _, tt := range tests {
273		t.Run(tt.name, func(t *testing.T) {
274			s := NewScreen(1, 1)
275			// Fill the only cell then try to write more (should scroll).
276			s.Write([]byte("ABC"))
277			// Should not panic.
278			_ = cellAt(s, 0, 0)
279		})
280	}
281}
282
283func TestStylePersists(t *testing.T) {
284	tests := []struct {
285		name string
286	}{
287		{name: "bold persists across separate Write calls"},
288	}
289
290	for _, tt := range tests {
291		t.Run(tt.name, func(t *testing.T) {
292			s := NewScreen(10, 3)
293			s.Write([]byte("\x1b[1mAB"))
294			s.Write([]byte("CD"))
295
296			c1 := cellAt(s, 0, 0)
297			c2 := cellAt(s, 2, 0)
298			if !c1.Style.Bold || !c2.Style.Bold {
299				t.Error("bold should persist across Write calls")
300			}
301		})
302	}
303}
304
305func TestWideAndZeroWidthRunes(t *testing.T) {
306	tests := []struct {
307		name string
308		run  func(t *testing.T)
309	}{
310		{
311			name: "CJK writes wide left cell plus continuation",
312			run: func(t *testing.T) {
313				s := NewScreen(10, 3)
314				// Two CJK characters, each width 2: left cell holds the rune,
315				// right cell is a continuation marker.
316				s.Write([]byte("你好"))
317
318				assertCell(t, s, 0, 0, '你')
319				assertContinuation(t, s, 1, 0)
320				assertCell(t, s, 2, 0, '好')
321				assertContinuation(t, s, 3, 0)
322				if s.Col != 4 || s.Row != 0 {
323					t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
324				}
325			},
326		},
327		{
328			name: "CJK mixed with ASCII",
329			run: func(t *testing.T) {
330				s := NewScreen(10, 3)
331				s.Write([]byte("A你B"))
332
333				assertCell(t, s, 0, 0, 'A')
334				assertCell(t, s, 1, 0, '你')
335				assertContinuation(t, s, 2, 0)
336				assertCell(t, s, 3, 0, 'B')
337				if s.Col != 4 {
338					t.Errorf("cursor at col=%d, want 4", s.Col)
339				}
340			},
341		},
342		{
343			name: "emoji writes wide pair",
344			run: func(t *testing.T) {
345				s := NewScreen(10, 3)
346				s.Write([]byte("\U0001f600")) // 😀 grinning face
347
348				assertCell(t, s, 0, 0, '\U0001f600')
349				assertContinuation(t, s, 1, 0)
350				if s.Col != 2 {
351					t.Errorf("cursor at col=%d, want 2", s.Col)
352				}
353			},
354		},
355		{
356			name: "combining mark is skipped without advancing cursor",
357			run: func(t *testing.T) {
358				s := NewScreen(10, 3)
359				// 'A' followed by combining acute accent U+0301
360				s.Write([]byte("A\xcc\x81"))
361
362				assertCell(t, s, 0, 0, 'A')
363				// Combining mark should be skipped — no cell written, no cursor advance.
364				if s.Col != 1 {
365					t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
366				}
367			},
368		},
369		{
370			name: "zero-width characters are skipped without advancing cursor",
371			run: func(t *testing.T) {
372				s := NewScreen(10, 3)
373				// Zero-width space U+200B
374				s.Write([]byte("A\xe2\x80\x8bB"))
375
376				assertCell(t, s, 0, 0, 'A')
377				assertCell(t, s, 1, 0, 'B')
378				if s.Col != 2 {
379					t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
380				}
381			},
382		},
383		{
384			name: "CJK surrounded by ASCII on both sides",
385			run: func(t *testing.T) {
386				s := NewScreen(10, 3)
387				s.Write([]byte("a你b好c"))
388
389				assertCell(t, s, 0, 0, 'a')
390				assertCell(t, s, 1, 0, '你')
391				assertContinuation(t, s, 2, 0)
392				assertCell(t, s, 3, 0, 'b')
393				assertCell(t, s, 4, 0, '好')
394				assertContinuation(t, s, 5, 0)
395				assertCell(t, s, 6, 0, 'c')
396				if s.Col != 7 {
397					t.Errorf("cursor at col=%d, want 7", s.Col)
398				}
399			},
400		},
401		{
402			name: "wide char at last column wraps to next line, abandoned cell cleared",
403			run: func(t *testing.T) {
404				s := NewScreen(3, 2)
405				// Fill the first two columns; cursor lands on the last column.
406				s.Write([]byte("AB")) // cells [A B _], Col=2
407				// A wide char at the last column cannot straddle the edge: it
408				// wraps to the next line, clearing the abandoned last cell.
409				s.Write([]byte("你"))
410
411				assertCell(t, s, 0, 0, 'A')
412				assertCell(t, s, 1, 0, 'B')
413				assertBlank(t, s, 2, 0) // abandoned last cell cleared
414				assertCell(t, s, 0, 1, '你')
415				assertContinuation(t, s, 1, 1)
416				if s.Col != 2 || s.Row != 1 {
417					t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
418				}
419			},
420		},
421		{
422			name: "CJK damage width is 2",
423			run: func(t *testing.T) {
424				s := NewScreen(10, 3)
425				s.ClearDamage()
426				s.Write([]byte("你"))
427
428				d := s.Damage()
429				if len(d) == 0 {
430					t.Fatal("expected damage")
431				}
432				if d[0].Width != 2 {
433					t.Errorf("damage width = %d, want 2", d[0].Width)
434				}
435			},
436		},
437		{
438			name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
439			run: func(t *testing.T) {
440				s := NewScreen(10, 3)
441				// ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
442				s.Write([]byte("a你\U0001f600b\xcc\x81"))
443
444				assertCell(t, s, 0, 0, 'a')
445				assertCell(t, s, 1, 0, '你')
446				assertContinuation(t, s, 2, 0)
447				assertCell(t, s, 3, 0, '\U0001f600')
448				assertContinuation(t, s, 4, 0)
449				assertCell(t, s, 5, 0, 'b')
450				// Combining mark skipped, no cell at col 6.
451				if s.Col != 6 || s.Row != 0 {
452					t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
453				}
454			},
455		},
456		{
457			name: "overwrite left half of wide pair with narrow clears both",
458			run: func(t *testing.T) {
459				s := NewScreen(10, 3)
460				s.Write([]byte("你"))         // (0)=你 (1)=cont
461				s.Write([]byte("\x1b[1;1H")) // cursor home
462				s.Write([]byte("X"))         // overwrite the wide left half
463
464				assertCell(t, s, 0, 0, 'X')
465				assertBlank(t, s, 1, 0) // orphaned continuation cleared
466			},
467		},
468		{
469			name: "overwrite right half (continuation) with narrow clears both",
470			run: func(t *testing.T) {
471				s := NewScreen(10, 3)
472				s.Write([]byte("你"))         // (0)=你 (1)=cont
473				s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
474				s.Write([]byte("X"))         // overwrite the continuation
475
476				assertBlank(t, s, 0, 0) // orphaned wide left cleared
477				assertCell(t, s, 1, 0, 'X')
478			},
479		},
480		{
481			name: "overwrite half of wide pair with a new wide char",
482			run: func(t *testing.T) {
483				s := NewScreen(10, 3)
484				s.Write([]byte("你好"))        // (0)你 (1)cont (2)好 (3)cont
485				s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
486				s.Write([]byte("学"))         // wide write at cols 1,2
487
488				assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
489				assertCell(t, s, 1, 0, '学')
490				assertContinuation(t, s, 2, 0)
491				assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
492			},
493		},
494		{
495			name: "erase to end of line covering a continuation clears its wide left half",
496			run: func(t *testing.T) {
497				s := NewScreen(10, 3)
498				s.Write([]byte("A你B"))       // A(0) 你(1) cont(2) B(3)
499				s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
500				s.Write([]byte("\x1b[K"))    // erase from col 2 to end of line
501
502				assertCell(t, s, 0, 0, 'A')
503				assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
504				assertBlank(t, s, 2, 0)
505				assertBlank(t, s, 3, 0)
506			},
507		},
508		{
509			name: "erase to start of line covering a wide left clears its continuation",
510			run: func(t *testing.T) {
511				s := NewScreen(10, 3)
512				s.Write([]byte("A你B"))       // A(0) 你(1) cont(2) B(3)
513				s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
514				s.Write([]byte("\x1b[1K"))   // erase from start to col 1 inclusive
515
516				assertBlank(t, s, 0, 0)
517				assertBlank(t, s, 1, 0)
518				assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
519				assertCell(t, s, 3, 0, 'B')
520			},
521		},
522		{
523			name: "wrap abandoning a continuation cell clears its pair and extends damage",
524			run: func(t *testing.T) {
525				s := NewScreen(4, 2)
526				s.Write([]byte("AB你"))       // A(0) B(1) 你(2) cont(3)
527				s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
528				s.ClearDamage()
529				// A wide rune at the last column wraps; the abandoned last cell
530				// is a continuation, so its wide left half (col 2) must be
531				// cleared too and the damage must span both columns.
532				s.Write([]byte("好"))
533
534				assertCell(t, s, 0, 0, 'A')
535				assertCell(t, s, 1, 0, 'B')
536				assertBlank(t, s, 2, 0) // orphaned wide left cleared
537				assertBlank(t, s, 3, 0) // abandoned continuation cleared
538				assertCell(t, s, 0, 1, '好')
539				assertContinuation(t, s, 1, 1)
540				if s.Col != 2 || s.Row != 1 {
541					t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
542				}
543
544				d := s.Damage()
545				if len(d) == 0 {
546					t.Fatal("expected damage")
547				}
548				// First damage item covers the cleared pair on row 0.
549				if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
550					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)
551				}
552			},
553		},
554		{
555			name: "insert chars at a continuation splits the pair and repairs orphans",
556			run: func(t *testing.T) {
557				s := NewScreen(6, 2)
558				s.Write([]byte("你好"))        // 你(0) cont(1) 好(2) cont(3)
559				s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
560				s.ClearDamage()
561				s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
562
563				// The shift splits 好/cont: 好 stays at col 2, its continuation
564				// moves to col 4. Both orphans must be repaired to blanks.
565				assertCell(t, s, 0, 0, '你')
566				assertContinuation(t, s, 1, 0)
567				assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
568				assertBlank(t, s, 3, 0) // inserted blank
569				assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
570				assertBlank(t, s, 5, 0)
571
572				d := s.Damage()
573				if len(d) == 0 {
574					t.Fatal("expected damage")
575				}
576				// Damage must extend one column left to cover the repaired 好.
577				if d[0].X != 2 || d[0].Width != 4 {
578					t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
579				}
580			},
581		},
582		{
583			name: "insert chars shifting a wide pair off the right edge repairs the left half",
584			run: func(t *testing.T) {
585				s := NewScreen(4, 2)
586				s.Write([]byte("你好"))        // 你(0) cont(1) 好(2) cont(3)
587				s.Write([]byte("\x1b[1;1H")) // cursor home
588				s.Write([]byte("\x1b[1@"))   // ICH 1: shift the row right by 1
589
590				// 好 lands on the last column with its continuation pushed off
591				// the edge; the orphaned wide left must be blanked.
592				assertBlank(t, s, 0, 0)
593				assertCell(t, s, 1, 0, '你')
594				assertContinuation(t, s, 2, 0)
595				assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
596			},
597		},
598		{
599			name: "delete char at the left half of a wide pair repairs the orphan",
600			run: func(t *testing.T) {
601				s := NewScreen(6, 2)
602				s.Write([]byte("你好AB"))      // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
603				s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
604				s.ClearDamage()
605				s.Write([]byte("\x1b[1P")) // DCH 1
606
607				// 你's continuation shifts to col 0 with no left half → repaired.
608				assertBlank(t, s, 0, 0)
609				assertCell(t, s, 1, 0, '好')
610				assertContinuation(t, s, 2, 0)
611				assertCell(t, s, 3, 0, 'A')
612				assertCell(t, s, 4, 0, 'B')
613				assertBlank(t, s, 5, 0)
614
615				d := s.Damage()
616				if len(d) == 0 {
617					t.Fatal("expected damage")
618				}
619				if d[0].X != 0 || d[0].Width != 6 {
620					t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
621				}
622			},
623		},
624		{
625			name: "delete char at a continuation repairs the wide left and extends damage",
626			run: func(t *testing.T) {
627				s := NewScreen(6, 2)
628				s.Write([]byte("你好AB"))      // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
629				s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
630				s.ClearDamage()
631				s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
632
633				// 你 at col 0 loses its continuation → repaired to blank.
634				assertBlank(t, s, 0, 0)
635				assertCell(t, s, 1, 0, '好')
636				assertContinuation(t, s, 2, 0)
637				assertCell(t, s, 3, 0, 'A')
638				assertCell(t, s, 4, 0, 'B')
639				assertBlank(t, s, 5, 0)
640
641				d := s.Damage()
642				if len(d) == 0 {
643					t.Fatal("expected damage")
644				}
645				// Damage must extend one column left (to col 0) to cover the
646				// repaired wide left half.
647				if d[0].X != 0 || d[0].Width != 6 {
648					t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
649				}
650			},
651		},
652		{
653			name: "IRM inserts narrow printable cells and reset restores overwrite",
654			run: func(t *testing.T) {
655				s := NewScreen(8, 1)
656				s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
657
658				if got := lineText(s, 0); got != "abXcd   " {
659					t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd   ")
660				}
661				s.Write([]byte("\x1b[4l\x1b[1;3HY"))
662				if got := lineText(s, 0); got != "abYcd   " {
663					t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd   ")
664				}
665			},
666		},
667		{
668			name: "IRM inserts wide printable cells and repairs evicted wide head",
669			run: func(t *testing.T) {
670				s := NewScreen(6, 1)
671				s.Write([]byte("ab界cd"))
672				s.Write([]byte("\x1b[1;3H\x1b[4h好"))
673
674				assertCell(t, s, 0, 0, 'a')
675				assertCell(t, s, 1, 0, 'b')
676				assertCell(t, s, 2, 0, '好')
677				assertContinuation(t, s, 3, 0)
678				assertCell(t, s, 4, 0, '界')
679				assertContinuation(t, s, 5, 0)
680				assertNoOrphanWideCells(t, s)
681			},
682		},
683		{
684			name: "IRM wide insert at right edge repairs orphan",
685			run: func(t *testing.T) {
686				s := NewScreen(5, 1)
687				s.Write([]byte("abc界"))
688				s.Write([]byte("\x1b[1;2H\x1b[4h好"))
689
690				assertCell(t, s, 0, 0, 'a')
691				assertCell(t, s, 1, 0, '好')
692				assertContinuation(t, s, 2, 0)
693				assertCell(t, s, 3, 0, 'b')
694				assertCell(t, s, 4, 0, 'c')
695				assertNoOrphanWideCells(t, s)
696			},
697		},
698		{
699			name: "wide char on a width-1 screen becomes a narrow replacement rune",
700			run: func(t *testing.T) {
701				s := NewScreen(1, 2)
702				// A wide rune cannot fit on a 1-column screen. Store a narrow
703				// replacement instead so renderer width matches the cell layout.
704				s.Write([]byte("你"))
705
706				assertCell(t, s, 0, 0, '\uFFFD')
707				if got := renderer.RuneWidth(cellAt(s, 0, 0).Rune); got != 1 {
708					t.Errorf("stored rune width = %d, want 1", got)
709				}
710				if cellAt(s, 0, 0).Continuation {
711					t.Error("width-1 replacement must not be a continuation")
712				}
713				assertNoOrphanWideCells(t, s)
714				if s.Col != 1 {
715					t.Errorf("cursor at col=%d, want 1", s.Col)
716				}
717			},
718		},
719		{
720			name: "wide pair survives a scroll",
721			run: func(t *testing.T) {
722				s := NewScreen(4, 2)
723				s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
724				s.Write([]byte("\r\n"))   // bottom row → scroll up
725
726				// After scrolling up, row1's 好 moves to row0 intact.
727				assertCell(t, s, 0, 0, '好')
728				assertContinuation(t, s, 1, 0)
729			},
730		},
731		{
732			name: "combining mark alone writes no cell and produces no damage",
733			run: func(t *testing.T) {
734				s := NewScreen(10, 3)
735				s.ClearDamage()
736				s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
737
738				// No cells should be modified.
739				for y := range 3 {
740					for x := range 10 {
741						if c := cellAt(s, x, y); c.Rune != ' ' {
742							t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
743						}
744					}
745				}
746				if s.Col != 0 || s.Row != 0 {
747					t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
748				}
749				d := s.Damage()
750				if len(d) != 0 {
751					t.Errorf("expected no damage for combining mark alone, got %+v", d)
752				}
753			},
754		},
755	}
756
757	for _, tt := range tests {
758		t.Run(tt.name, tt.run)
759	}
760}
761
762func TestRuneWidth(t *testing.T) {
763	tests := []struct {
764		name  string
765		r     rune
766		width int
767	}{
768		{"space", ' ', 1},
769		{"lowercase ascii letter", 'a', 1},
770		{"uppercase ascii letter", 'A', 1},
771		{"ascii digit", '1', 1},
772		{"soft hyphen", 0x00AD, 0},
773		{"combining grapheme joiner", 0x034F, 0},
774		{"combining acute accent", 0x0301, 0},
775		{"combining grave accent", 0x0300, 0},
776		{"zero-width space", 0x200B, 0},
777		{"zero-width non-joiner", 0x200C, 0},
778		{"zero-width joiner", 0x200D, 0},
779		{"BOM / zero-width no-break space", 0xFEFF, 0},
780		{"CJK Unified Ideograph (一)", 0x4E00, 2},
781		{"CJK (二)", 0x4E8C, 2},
782		{"CJK end of BMP", 0x9FFF, 2},
783		{"CJK Extension A", 0x3400, 2},
784		{"Hangul Syllable (가)", 0xAC00, 2},
785		{"Hangul Syllable end", 0xD7AF, 2},
786		{"CJK Ideographic Space", 0x3000, 2},
787		{"Fullwidth Exclamation Mark", 0xFF01, 2},
788		{"Fullwidth Cent Sign", 0xFFE0, 2},
789		{"grinning face emoji", 0x1F600, 2},
790		{"rocket emoji", 0x1F680, 2},
791		{"hiragana a", 0x3042, 2},
792		{"katakana a", 0x30A2, 2},
793		{"CJK Extension B", 0x20000, 2},
794		{"supplemental symbols (robot)", 0x1F916, 2},
795		{"symbols extended-A (sari)", 0x1FA71, 2},
796		{"ideographic full stop", 0x3002, 2},
797		{"NUL", '\x00', 0},
798		{"SOH", '\x01', 0},
799		{"ESC", '\x1b', 0},
800		{"DEL", 0x7F, 0},
801	}
802
803	for _, tt := range tests {
804		t.Run(tt.name, func(t *testing.T) {
805			got := renderer.RuneWidth(tt.r)
806			if got != tt.width {
807				t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
808			}
809		})
810	}
811}
812
813func TestC1Controls(t *testing.T) {
814	tests := []struct {
815		name string
816		run  func(t *testing.T)
817	}{
818		{
819			name: "C1 controls are dropped without moving the cursor",
820			run: func(t *testing.T) {
821				s := NewScreen(10, 3)
822				s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
823				s.Write([]byte{0xC2, 0x9F}) // U+009F APC
824
825				// No cells should be modified.
826				for y := range 3 {
827					for x := range 10 {
828						if c := cellAt(s, x, y); c.Rune != ' ' {
829							t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
830						}
831					}
832				}
833				// Cursor should not have moved.
834				if s.Col != 0 || s.Row != 0 {
835					t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
836				}
837			},
838		},
839		{
840			name: "the full C1 control range U+0080-U+009F is dropped",
841			run: func(t *testing.T) {
842				s := NewScreen(10, 3)
843				// Send all C1 control runes U+0080 through U+009F.
844				for r := 0x80; r <= 0x9F; r++ {
845					s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
846				}
847
848				// No cells should be modified.
849				for y := range 3 {
850					for x := range 10 {
851						if c := cellAt(s, x, y); c.Rune != ' ' {
852							t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
853						}
854					}
855				}
856				if s.Col != 0 || s.Row != 0 {
857					t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
858				}
859			},
860		},
861		{
862			name: "printable characters still work after C1 controls",
863			run: func(t *testing.T) {
864				s := NewScreen(10, 3)
865				// Printable characters should still work after C1 controls.
866				s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
867				s.Write([]byte("AB"))
868				s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
869				s.Write([]byte("CD"))
870
871				assertCell(t, s, 0, 0, 'A')
872				assertCell(t, s, 1, 0, 'B')
873				assertCell(t, s, 2, 0, 'C')
874				assertCell(t, s, 3, 0, 'D')
875				if s.Col != 4 {
876					t.Errorf("cursor at col=%d, want 4", s.Col)
877				}
878			},
879		},
880		{
881			name: "C1 controls mixed with CR preserve CR behavior",
882			run: func(t *testing.T) {
883				s := NewScreen(10, 3)
884				// C1 controls mixed with CR/LF should preserve CR/LF behavior.
885				s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
886				s.Write([]byte("A\rB"))     // CR moves cursor back, overwrite A
887				s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
888
889				assertCell(t, s, 0, 0, 'B')
890			},
891		},
892		{
893			name: "C1 controls mixed with LF preserve LF behavior",
894			run: func(t *testing.T) {
895				s := NewScreen(10, 3)
896				s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
897				s.Write([]byte("A\nB"))
898
899				assertCell(t, s, 0, 0, 'A')
900				assertCell(t, s, 1, 1, 'B')
901				if s.Row != 1 || s.Col != 2 {
902					t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
903				}
904			},
905		},
906		{
907			name: "C1 controls produce no damage",
908			run: func(t *testing.T) {
909				s := NewScreen(10, 3)
910				s.ClearDamage()
911				// C1 controls should produce no damage.
912				s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
913
914				d := s.Damage()
915				if len(d) != 0 {
916					t.Errorf("expected no damage from C1 controls, got %+v", d)
917				}
918			},
919		},
920	}
921
922	for _, tt := range tests {
923		t.Run(tt.name, tt.run)
924	}
925}
926
927func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) {
928	tests := []struct {
929		name   string
930		width  int
931		height int
932		write  string
933		want   []LineBound
934		// checkOwned exercises the caller-owned-copy guarantee: it only makes
935		// sense to check once per LineBounds() call, so it lives on the case
936		// whose write actually produces a soft-wrapped row 0 to corrupt.
937		checkOwned bool
938	}{
939		{
940			name:   "wraps after column width",
941			width:  4,
942			height: 3,
943			write:  "abcdef", // wraps after column 4
944			want: []LineBound{
945				{End: 4, Soft: true},  // row 0: filled the row and wrapped
946				{End: 2, Soft: false}, // row 1: "ef", no wrap
947				{End: 0, Soft: false}, // row 2: untouched
948			},
949			checkOwned: true,
950		},
951	}
952
953	for _, tt := range tests {
954		t.Run(tt.name, func(t *testing.T) {
955			s := NewScreen(tt.width, tt.height)
956			s.Write([]byte(tt.write))
957
958			bounds := s.LineBounds()
959			require.Equal(t, tt.want, bounds)
960
961			if tt.checkOwned {
962				// The result is owned by the caller.
963				bounds[0] = LineBound{}
964				require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries")
965			}
966		})
967	}
968}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close