v0.4.0 0b9cfda

screen_resize_test.go RawHistoryFolder

1package vt
2
3import (
4	"math/rand"
5	"strings"
6	"testing"
7
8	renderer "github.com/bnema/vev-vt/core"
9)
10
11func 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: "soft wrapped lines reflow on shrink and growth",
108			run: func(t *testing.T) {
109				s := NewScreen(5, 3)
110				s.Write([]byte("abcdefgh"))
111
112				s.Resize(3, 3)
113				if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " {
114					t.Fatalf("shrink rows = %q", got)
115				}
116
117				s.Resize(5, 3)
118				if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh  " {
119					t.Fatalf("growth rows = %q", got)
120				}
121			},
122		},
123		{
124			name: "erase severs a soft wrap boundary",
125			run: func(t *testing.T) {
126				s := NewScreen(4, 2)
127				s.Write([]byte("abcdef"))
128				s.Write([]byte("\x1b[1;1H\x1b[2K"))
129
130				// Erasing through the right edge ends the logical line: the
131				// blank head must not pull "ef" up a row on reflow.
132				s.Resize(3, 2)
133				if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "   ,ef " {
134					t.Fatalf("erased soft line reflow = %q", got)
135				}
136			},
137		},
138		{
139			name: "in-place repaint does not merge with the row below",
140			run: func(t *testing.T) {
141				// Status UIs (docker buildx, shell prompt redraws) repaint a
142				// previously soft-wrapped row with CUP + text + EL and no
143				// newline. The stale soft link must not glue the repainted row
144				// to the unrelated row below: reflow would merge them, and the
145				// next shrink would truncate the merged line, losing cells.
146				s := NewScreen(40, 8)
147				s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n"))
148				s.Write([]byte("tail-line\r\n"))
149				s.Write([]byte("\x1b[1;1HSHORT\x1b[K"))
150				s.Write([]byte("\x1b[4;1H"))
151
152				s.Resize(60, 8)
153				if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
154					t.Fatalf("grow rows = %q", got)
155				}
156
157				s.Resize(40, 8)
158				if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
159					t.Fatalf("round-trip rows = %q", got)
160				}
161			},
162		},
163		{
164			name: "wide edge padding does not become logical content",
165			run: func(t *testing.T) {
166				s := NewScreen(4, 3)
167				s.Write([]byte("abc界x"))
168
169				s.Resize(3, 3)
170				if got := rowString(s, 0); got != "abc" {
171					t.Fatalf("first reflow row = %q", got)
172				}
173				assertCell(t, s, 0, 1, '界')
174				if !cellAt(s, 1, 1).Continuation {
175					t.Fatal("wide continuation was not retained")
176				}
177				assertCell(t, s, 2, 1, 'x')
178				assertNoOrphanWideCells(t, s)
179			},
180		},
181		{
182			name: "width truncates and pads without reflow",
183			run: func(t *testing.T) {
184				s := NewScreen(6, 2)
185				setRow(s, 0, "abcdef")
186				setRow(s, 1, "ghijkl")
187
188				s.Resize(3, 2)
189				if got := rowString(s, 0); got != "abc" {
190					t.Fatalf("truncated row = %q", got)
191				}
192				s.Resize(5, 2)
193				if got := rowString(s, 1); got != "ghi  " {
194					t.Fatalf("padded row = %q", got)
195				}
196			},
197		},
198		{
199			name: "width shrink repairs truncated wide head",
200			run: func(t *testing.T) {
201				s := NewScreen(4, 1)
202				s.Write([]byte("AB界"))
203
204				s.Resize(3, 1)
205
206				assertCell(t, s, 0, 0, 'A')
207				assertCell(t, s, 1, 0, 'B')
208				assertBlank(t, s, 2, 0)
209				assertNoOrphanWideCells(t, s)
210			},
211		},
212		{
213			name: "random wide resize repairs copied rows",
214			run: func(t *testing.T) {
215				rng := rand.New(rand.NewSource(1))
216				for range 200 {
217					s := NewScreen(8, 4)
218					s.Write([]byte("a界b好c語d"))
219					s.Row = rng.Intn(s.Frame.Height)
220
221					s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
222
223					assertNoOrphanWideCells(t, s)
224				}
225			},
226		},
227		{
228			name: "cursor and saved cursor clamp",
229			run: func(t *testing.T) {
230				s := NewScreen(6, 4)
231				s.Row, s.Col = 3, 5
232				s.saveCursor()
233
234				s.Resize(3, 2)
235
236				if s.Row != 1 || s.Col != 2 {
237					t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
238				}
239				s.Row, s.Col = 0, 0
240				s.restoreCursor()
241				if s.Row != 1 || s.Col != 2 {
242					t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
243				}
244			},
245		},
246		{
247			name: "alternate resize keeps saved normal content",
248			run: func(t *testing.T) {
249				s := NewScreen(5, 3)
250				setRow(s, 0, "shell")
251				s.Row = 0
252				s.Write([]byte("\x1b[?1049h"))
253				setRow(s, 0, "alt!!")
254
255				s.Resize(7, 4)
256				s.Write([]byte("\x1b[?1049l"))
257
258				if got := rowString(s, 0); got != "shell  " {
259					t.Fatalf("normal row after alt resize = %q", got)
260				}
261			},
262		},
263		{
264			name: "alternate resize shifts active and saved cursors",
265			run: func(t *testing.T) {
266				s := NewScreen(5, 5)
267				s.Row, s.Col = 3, 4
268				s.saveCursor()
269				s.scrollTop, s.scrollBottom = 1, 4
270				s.Row = 4
271				s.Write([]byte("\x1b[?1049h"))
272				s.Row, s.Col = 3, 4
273				s.saveCursor()
274				s.Row = 4
275
276				// The expected rows account for the resize shift that keeps the
277				// cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
278				// not just width/height clamping.
279				s.Resize(3, 3)
280
281				if s.Row != 2 || s.Col != 2 {
282					t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
283				}
284				s.Row, s.Col = 0, 0
285				s.restoreCursor()
286				if s.Row != 1 || s.Col != 2 {
287					t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
288				}
289
290				s.Write([]byte("\x1b[?1049l"))
291				if s.scrollTop != 0 || s.scrollBottom != 2 {
292					t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
293				}
294				s.Row, s.Col = 0, 0
295				s.restoreCursor()
296				if s.Row != 1 || s.Col != 2 {
297					t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
298				}
299			},
300		},
301		{
302			name: "style mouse cursor and bracketed paste modes survive",
303			run: func(t *testing.T) {
304				s := NewScreen(5, 2)
305				s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
306
307				s.Resize(6, 3)
308
309				if !s.Style.Bold {
310					t.Fatal("bold style was reset")
311				}
312				if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
313					t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
314				}
315				if !s.BracketedPasteMode() {
316					t.Fatal("bracketed paste mode was reset")
317				}
318				if s.CursorVisible() {
319					t.Fatal("cursor visibility was reset")
320				}
321				if style, ok := s.CursorStyle(); style != 3 || !ok {
322					t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
323				}
324				s.Write([]byte("\x1b[?2004l"))
325				if s.BracketedPasteMode() {
326					t.Fatal("bracketed paste mode remained enabled after reset")
327				}
328			},
329		},
330	}
331
332	for _, tt := range tests {
333		t.Run(tt.name, tt.run)
334	}
335}

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