v0.2.0 4859eb8

output_min_test.go RawHistoryFolder

1package ansi
2
3import (
4	"bytes"
5	"strconv"
6	"strings"
7	"testing"
8)
9
10// ---------------------------------------------------------------------------
11// Cursor-motion minimization (drawState.moveTo)
12// ---------------------------------------------------------------------------
13
14func TestMoveToChoosesCheapestSequence(t *testing.T) {
15	tests := []struct {
16		name    string
17		known   bool
18		fromRow int
19		fromCol int
20		toRow   int
21		toCol   int
22		want    string
23	}{
24		{name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"},
25		{name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""},
26		{name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"},
27		{name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"},
28		{name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"},
29		{name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"},
30		{name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"},
31		{name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"},
32		{name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"},
33		{name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"},
34		{name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"},
35	}
36
37	for _, tt := range tests {
38		t.Run(tt.name, func(t *testing.T) {
39			st := newDrawState()
40			if tt.known {
41				st.setCursor(tt.fromRow, tt.fromCol)
42			}
43			var buf bytes.Buffer
44			st.moveTo(&buf, tt.toRow, tt.toCol)
45			if buf.String() != tt.want {
46				t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want)
47			}
48			if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol {
49				t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol)
50			}
51		})
52	}
53}
54
55// ---------------------------------------------------------------------------
56// End-to-end golden byte output (exact bytes emitted by Draw)
57// ---------------------------------------------------------------------------
58
59func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string {
60	t.Helper()
61	out, err := r.Draw(frame, damage)
62	if err != nil {
63		t.Fatal(err)
64	}
65	return string(out)
66}
67
68func setRow(f Frame, y int, s string, st Style) {
69	col := 0
70	for _, ru := range s {
71		f.Set(col, y, Cell{Rune: ru, Style: st})
72		col++
73	}
74}
75
76func hasCUB(out string) bool {
77	for i := 0; i+2 < len(out); i++ {
78		if out[i] != '\x1b' || out[i+1] != '[' {
79			continue
80		}
81		j := i + 2
82		for j < len(out) && out[j] >= '0' && out[j] <= '9' {
83			j++
84		}
85		if j < len(out) && out[j] == 'D' {
86			return true
87		}
88	}
89	return false
90}
91
92func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
93	r := New(Capabilities{})
94	base := NewFrame(10, 1)
95	if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
96		t.Fatal(err)
97	}
98
99	frame := NewFrame(10, 1)
100	setRow(frame, 0, "abcdefghi", DefaultStyle())
101	got := drawGolden(t, r, frame, []Damage{
102		{Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
103		{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
104	})
105	want := "\x1b[1;1Habcdefghi\x1b[0m"
106	if got != want {
107		t.Fatalf("output = %q, want %q", got, want)
108	}
109}
110
111func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
112	tests := []struct {
113		name  string
114		spans []Damage
115	}{
116		{
117			name: "overlapping spans",
118			spans: []Damage{
119				{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
120				{Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
121			},
122		},
123		{
124			name: "adjacent spans",
125			spans: []Damage{
126				{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
127				{Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
128			},
129		},
130	}
131
132	for _, tt := range tests {
133		t.Run(tt.name, func(t *testing.T) {
134			render := func(spans []Damage) string {
135				t.Helper()
136				r := New(Capabilities{})
137				if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
138					t.Fatal(err)
139				}
140				frame := NewFrame(10, 1)
141				setRow(frame, 0, "abcdefghi", DefaultStyle())
142				return drawGolden(t, r, frame, spans)
143			}
144
145			forward := render(tt.spans)
146			reverse := render([]Damage{tt.spans[1], tt.spans[0]})
147			if forward != reverse {
148				t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
149			}
150			if hasCUB(forward) {
151				t.Fatalf("canonical output contains CUB: %q", forward)
152			}
153			want := "\x1b[1;1Habcdefghi\x1b[0m"
154			if forward != want {
155				t.Fatalf("output = %q, want canonical output %q", forward, want)
156			}
157		})
158	}
159}
160
161func TestNativeClearGolden(t *testing.T) {
162	tests := []struct {
163		name  string
164		build func() Frame
165		want  string
166	}{
167		{
168			name: "trailing blank span becomes EL",
169			build: func() Frame {
170				f := NewFrame(10, 1)
171				setRow(f, 0, "Hi", DefaultStyle())
172				return f
173			},
174			// content, then EL to clear the 8 trailing blanks, then reset
175			want: "\x1b[1;1HHi\x1b[K\x1b[0m",
176		},
177		{
178			name: "whole blank line cleared natively",
179			build: func() Frame {
180				f := NewFrame(10, 2)
181				setRow(f, 0, "AB", DefaultStyle())
182				// row 1 stays entirely blank
183				return f
184			},
185			// Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line
186			// clear for row 1 is a one-row CUD in the same column plus 2K
187			// (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes).
188			want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m",
189		},
190		{
191			name: "short trailing blank span kept as spaces",
192			build: func() Frame {
193				f := NewFrame(5, 1)
194				setRow(f, 0, "ABC", DefaultStyle())
195				// two trailing blanks (< 3) → cheaper as spaces
196				return f
197			},
198			want: "\x1b[1;1HABC  \x1b[0m",
199		},
200	}
201
202	for _, tt := range tests {
203		t.Run(tt.name, func(t *testing.T) {
204			r := New(Capabilities{})
205			frame := tt.build()
206			got := drawGolden(t, r, frame, []Damage{FullRedraw()})
207			if got != tt.want {
208				t.Fatalf("output = %q, want %q", got, tt.want)
209			}
210			// Shadow consistency: an identical redraw must be a no-op.
211			out2, err := r.Draw(frame, nil)
212			if err != nil {
213				t.Fatal(err)
214			}
215			if len(out2) != 0 {
216				t.Fatalf("second draw not a no-op: %q", string(out2))
217			}
218		})
219	}
220}
221
222func TestSGRPersistsAcrossDamageRects(t *testing.T) {
223	// Two same-styled rects on different rows: the SGR sequence must be emitted
224	// exactly once (the pen persists across rects for the whole Draw).
225	r := New(Capabilities{})
226	base := NewFrame(4, 3)
227	if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
228		t.Fatal(err)
229	}
230
231	styled := Style{Bold: true, Foreground: 1, Background: -1}
232	frame := NewFrame(4, 3)
233	setRow(frame, 0, "XXXX", styled)
234	setRow(frame, 2, "YYYY", styled)
235	damage := []Damage{
236		{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
237		{Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1},
238	}
239	got := drawGolden(t, r, frame, damage)
240	// Row0 reaches the right edge (no trailing blanks) so tracking invalidates;
241	// row2 is therefore positioned with an absolute CUP. The SGR appears once.
242	want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m"
243	if got != want {
244		t.Fatalf("output = %q, want %q", got, want)
245	}
246}
247
248func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
249	// Adversarial: a run printed up to the terminal's right edge leaves the
250	// cursor in a terminal-dependent "wrap pending" state. The renderer must
251	// NOT assume the cursor wrapped to the next row; the following row must be
252	// positioned with an absolute CUP, never an implicit newline/relative move.
253	r := New(Capabilities{})
254	frame := NewFrame(5, 2)
255	setRow(frame, 0, "ABCDE", DefaultStyle())
256	setRow(frame, 1, "FGHIJ", DefaultStyle())
257	got := drawGolden(t, r, frame, []Damage{FullRedraw()})
258	want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m"
259	if got != want {
260		t.Fatalf("output = %q, want %q", got, want)
261	}
262}
263
264func TestWideRunCursorTrackingAndEL(t *testing.T) {
265	t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266		// Rect A ends with a wide pair (not at the edge); rect B starts one
267		// column later. The gap between them must be crossed with the cursor
268		// position that reflects the terminal advancing two columns for the
269		// wide rune — otherwise the relative move would be wrong.
270		r := New(Capabilities{})
271		base := NewFrame(8, 1)
272		if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
273			t.Fatal(err)
274		}
275		frame := NewFrame(8, 1)
276		frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
277		frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
278		frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
279		frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()})
280		// column 4 stays blank (undamaged gap)
281		frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()})
282		frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()})
283		frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()})
284		damage := []Damage{
285			{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
286			{Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
287		}
288		got := drawGolden(t, r, frame, damage)
289		// After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
290		want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
291		if got != want {
292			t.Fatalf("output = %q, want %q", got, want)
293		}
294	})
295
296	t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
297		// A damage rect that ends on a wide head whose continuation lies
298		// outside the rect makes the terminal advance one column further than
299		// the rect width accounts for. Tracking must be dropped: the next
300		// rect must be positioned with absolute CUP, not a relative move.
301		r := New(Capabilities{})
302		base := NewFrame(8, 1)
303		if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
304			t.Fatal(err)
305		}
306		frame := NewFrame(8, 1)
307		frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
308		frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
309		frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
310		frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
311		damage := []Damage{
312			// Ends on the wide head at col 1; its continuation (col 2) is outside.
313			{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
314			{Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315		}
316		got := drawGolden(t, r, frame, damage)
317		// After A你 the real cursor is at col 3, not col 2: a relative CUF
318		// would be off by one, so the second rect must use absolute CUP.
319		want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
320		if got != want {
321			t.Fatalf("output = %q, want %q", got, want)
322		}
323	})
324
325	t.Run("EL after a wide pair", func(t *testing.T) {
326		r := New(Capabilities{})
327		frame := NewFrame(7, 1)
328		frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
329		frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
330		frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
331		frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
332		// columns 4,5,6 blank → EL
333		got := drawGolden(t, r, frame, []Damage{FullRedraw()})
334		want := "\x1b[1;1H你好\x1b[K\x1b[0m"
335		if got != want {
336			t.Fatalf("output = %q, want %q", got, want)
337		}
338		out2, err := r.Draw(frame, nil)
339		if err != nil {
340			t.Fatal(err)
341		}
342		if len(out2) != 0 {
343			t.Fatalf("second draw not a no-op: %q", string(out2))
344		}
345	})
346}
347
348func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
349	render := func(damage []Damage) string {
350		t.Helper()
351		r := New(Capabilities{})
352		base := NewFrame(10, 3)
353		setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
354		setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
355		setRow(base, 2, "cccccccccc", DefaultStyle())
356		if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
357			t.Fatal(err)
358		}
359
360		frame := NewFrame(10, 3)
361		setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
362		setRow(frame, 1, "cccccccccc", DefaultStyle())
363		setRow(frame, 2, "abcdefghij", DefaultStyle())
364		out, err := r.Draw(frame, damage)
365		if err != nil {
366			t.Fatal(err)
367		}
368		return string(out)
369	}
370
371	scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
372	forward := render([]Damage{
373		scroll,
374		{Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
375		{Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
376	})
377	reverse := render([]Damage{
378		{Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
379		scroll,
380		{Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
381	})
382	if forward != reverse {
383		t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
384	}
385	if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
386		t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
387	}
388}
389
390func TestCanonicalDamageMergesClearAndText(t *testing.T) {
391	r := New(Capabilities{})
392	base := NewFrame(10, 1)
393	if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
394		t.Fatal(err)
395	}
396	frame := NewFrame(10, 1)
397	setRow(frame, 0, "abcdefghi", DefaultStyle())
398	out, err := r.Draw(frame, []Damage{
399		{Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
400		{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
401	})
402	if err != nil {
403		t.Fatal(err)
404	}
405	if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
406		t.Fatalf("output = %q, want %q", got, want)
407	}
408}
409
410func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
411	r := New(Capabilities{})
412	base := NewFrame(8, 1)
413	if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
414		t.Fatal(err)
415	}
416	frame := NewFrame(8, 1)
417	frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
418	frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
419	frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
420	frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
421	out, err := r.Draw(frame, []Damage{
422		{Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
423		{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
424	})
425	if err != nil {
426		t.Fatal(err)
427	}
428	if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
429		t.Fatalf("output = %q, want %q", got, want)
430	}
431}
432
433func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434	r := New(Capabilities{})
435	base := NewFrame(1, maxPlannedDamageSpans+1)
436	if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437		t.Fatal(err)
438	}
439
440	frame := base.Clone()
441	frame.ScrollUp(0, frame.Height-1, 1)
442	frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443	damage := []Damage{
444		{Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445		{Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446	}
447	out, err := r.Draw(frame, damage)
448	if err != nil {
449		t.Fatal(err)
450	}
451	if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452		t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453	}
454	if !strings.Contains(string(out), "X") {
455		t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456	}
457	out, err = r.Draw(frame, nil)
458	if err != nil {
459		t.Fatal(err)
460	}
461	if len(out) != 0 {
462		t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463	}
464}
465
466func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
467	r := New(Capabilities{})
468	frame := NewFrame(1, maxPlannedDamageSpans+1)
469	if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
470		t.Fatal(err)
471	}
472	frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
473	out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
474	if err != nil {
475		t.Fatal(err)
476	}
477	if !strings.Contains(string(out), "X") {
478		t.Fatalf("fallback output did not redraw the frame: %q", string(out))
479	}
480	out, err = r.Draw(frame, nil)
481	if err != nil {
482		t.Fatal(err)
483	}
484	if len(out) != 0 {
485		t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
486	}
487}

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