v0.7.0 4c16dd8

modal_test.go RawHistoryFolder

1package ui
2
3import (
4	"testing"
5
6	renderer "github.com/bnema/vev-vt"
7	"github.com/bnema/vev/internal/domain"
8	"github.com/stretchr/testify/require"
9)
10
11func TestModalResolveBounds(t *testing.T) {
12	tests := []struct {
13		name string
14		base domain.Size
15		m    Modal
16		want domain.Rect
17	}{
18		{
19			name: "eighty percent centered",
20			base: domain.Size{Cols: 100, Rows: 40},
21			m:    Modal{WidthPct: 80, HeightPct: 80},
22			want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
23		},
24		{
25			name: "minimum clamps to drawer availability",
26			base: domain.Size{Cols: 20, Rows: 6},
27			m:    Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28			want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
29		},
30		{
31			name: "narrow preferred height becomes drawer height",
32			base: domain.Size{Cols: 11, Rows: 7},
33			m:    Modal{WidthPct: 50, HeightPct: 50},
34			want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
35		},
36		{
37			name: "zero value fills available drawer area",
38			base: domain.Size{Cols: 12, Rows: 8},
39			m:    Modal{},
40			want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
41		},
42		{
43			name: "bottom anchor honors bottom margin",
44			base: domain.Size{Cols: 100, Rows: 40},
45			m:    Modal{WidthPct: 50, HeightPct: 25, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 3}},
46			want: domain.Rect{X: 25, Y: 27, Width: 50, Height: 10},
47		},
48		{
49			name: "fixed dimensions override percentages",
50			base: domain.Size{Cols: 100, Rows: 40},
51			m:    Modal{WidthPct: 80, HeightPct: 80, FixedWidth: 30, FixedHeight: 12},
52			want: domain.Rect{X: 35, Y: 14, Width: 30, Height: 12},
53		},
54		{
55			name: "right anchor honors right margin",
56			base: domain.Size{Cols: 100, Rows: 40},
57			m: Modal{
58				FixedWidth:  30,
59				FixedHeight: 12,
60				Anchor:      domain.AnchorRight,
61				Margins:     Margins{Right: 3},
62			},
63			want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
64		},
65		{
66			name: "drawer ignores right margin",
67			base: domain.Size{Cols: 20, Rows: 10},
68			m: Modal{
69				FixedWidth:  8,
70				FixedHeight: 4,
71				Anchor:      domain.AnchorRight,
72				Margins:     Margins{Right: 20},
73			},
74			want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
75		},
76		{
77			name: "right anchored drawer can have zero height",
78			base: domain.Size{Cols: 10, Rows: 4},
79			m: Modal{
80				FixedWidth:  30,
81				FixedHeight: 12,
82				Anchor:      domain.AnchorRight,
83				Margins:     Margins{Right: 3},
84			},
85			want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
86		},
87		{
88			name: "fixed drawer dimensions clamp on small terminal",
89			base: domain.Size{Cols: 10, Rows: 4},
90			m:    Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91			want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
92		},
93		{
94			name: "drawer ignores bottom margin",
95			base: domain.Size{Cols: 20, Rows: 10},
96			m:    Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97			want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
98		},
99		{
100			name: "bottom right anchor honors combined margins",
101			base: domain.Size{Cols: 100, Rows: 40},
102			m: Modal{
103				FixedWidth:  30,
104				FixedHeight: 12,
105				Anchor:      domain.AnchorBottomRight,
106				Margins:     Margins{Bottom: 3, Right: 5},
107			},
108			want: domain.Rect{X: 65, Y: 25, Width: 30, Height: 12},
109		},
110	}
111	for _, tt := range tests {
112		t.Run(tt.name, func(t *testing.T) {
113			if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114				t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115			}
116		})
117	}
118}
119
120func TestModalResolveInnerDegenerate(t *testing.T) {
121	got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122	require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
123}
124
125func TestModalResolve(t *testing.T) {
126	tests := []struct {
127		name string
128		base domain.Size
129		want Presentation
130	}{
131		{
132			name: "narrow modal resolves to drawer",
133			base: domain.Size{Cols: 20, Rows: 10},
134			want: Presentation{
135				Mode:    PresentationDrawer,
136				Bounds:  domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
137				Inner:   domain.Rect{X: 0, Y: 6, Width: 20, Height: 3},
138				Borders: BorderTop,
139			},
140		},
141		{
142			name: "complete frame preserves floating modal geometry",
143			base: domain.Size{Cols: 80, Rows: 10},
144			want: Presentation{
145				Mode:    PresentationFloating,
146				Bounds:  domain.Rect{X: 20, Y: 3, Width: 40, Height: 4},
147				Inner:   domain.Rect{X: 21, Y: 4, Width: 38, Height: 2},
148				Borders: BorderAll,
149			},
150		},
151	}
152
153	modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
154	for _, tt := range tests {
155		t.Run(tt.name, func(t *testing.T) {
156			require.Equal(t, tt.want, modal.Resolve(tt.base))
157		})
158	}
159}
160
161func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
162	modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
163	presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
164	frame := renderer.NewFrame(80, 10)
165	exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
166	FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
167	border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
168	interior := renderer.Style{Foreground: 3, Background: 4}
169
170	inner := modal.CompositePresentation(frame, presentation, border, interior)
171	require.Equal(t, presentation.Inner, inner)
172	require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
173	require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
174	require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
175	require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
176	require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
177	require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
178	assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
179}
180
181func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
182	border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
183	exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
184	bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
185	inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
186
187	tests := []struct {
188		name         string
189		presentation Presentation
190		assert       func(*testing.T, renderer.Frame)
191	}{
192		{
193			name: "all edges render for drawer mode",
194			presentation: Presentation{
195				Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
196			},
197			assert: func(t *testing.T, frame renderer.Frame) {
198				require.Equal(t, '┌', frame.At(1, 1).Rune)
199				require.Equal(t, '│', frame.At(1, 2).Rune)
200				require.Equal(t, '┘', frame.At(8, 4).Rune)
201				require.Equal(t, 'T', frame.At(2, 1).Rune)
202			},
203		},
204		{
205			name: "top edge alone renders for floating mode",
206			presentation: Presentation{
207				Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
208			},
209			assert: func(t *testing.T, frame renderer.Frame) {
210				require.Equal(t, '─', frame.At(1, 1).Rune)
211				require.Equal(t, 'T', frame.At(2, 1).Rune)
212				assertCell(t, frame, 1, 2, exterior)
213				assertCell(t, frame, 8, 4, exterior)
214			},
215		},
216		{
217			name: "title is hidden without top edge",
218			presentation: Presentation{
219				Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
220			},
221			assert: func(t *testing.T, frame renderer.Frame) {
222				require.Equal(t, '│', frame.At(1, 1).Rune)
223				require.Equal(t, '│', frame.At(1, 4).Rune)
224				assertCell(t, frame, 4, 1, exterior)
225				assertCell(t, frame, 8, 4, exterior)
226			},
227		},
228	}
229
230	for _, tt := range tests {
231		t.Run(tt.name, func(t *testing.T) {
232			frame := renderer.NewFrame(10, 6)
233			FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
234			(Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
235			tt.assert(t, frame)
236		})
237	}
238}
239
240func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
241	modal := Modal{FixedHeight: 4, Title: " Rename "}
242	presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
243	frame := renderer.NewFrame(20, 10)
244	exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
245	FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior)
246	border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
247	interior := renderer.Style{Foreground: 3, Background: 4}
248
249	inner := modal.CompositePresentation(frame, presentation, border, interior)
250	require.Equal(t, presentation.Inner, inner)
251	require.Equal(t, '─', frame.At(0, 5).Rune)
252	require.True(t, frame.At(0, 5).Style.Equal(border))
253	require.Equal(t, 'R', frame.At(7, 5).Rune)
254	require.NotEqual(t, '│', frame.At(0, 6).Rune)
255	require.Equal(t, ' ', frame.At(0, 6).Rune)
256	require.NotEqual(t, '─', frame.At(0, 8).Rune)
257	require.Equal(t, ' ', frame.At(0, 8).Rune)
258
259	for _, y := range []int{0, 1, 2, 3, 4, 9} {
260		for x := range frame.Width {
261			assertCell(t, frame, x, y, exterior)
262		}
263	}
264}
265
266func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) {
267	modal := Modal{FixedHeight: 4, Title: "你你"}
268	presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10})
269	frame := renderer.NewFrame(4, 10)
270	border := renderer.Style{Foreground: 2, Background: -1}
271
272	modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle())
273
274	require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune)
275	require.True(t, frame.At(2, presentation.Bounds.Y).Continuation)
276	require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune)
277	require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip")
278}
279
280func TestFillRectClips(t *testing.T) {
281	f := renderer.NewFrame(3, 2)
282	cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}
283	FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell)
284	assertCell(t, f, 0, 0, cell)
285	assertCell(t, f, 1, 1, cell)
286	assertCell(t, f, 2, 1, renderer.BlankCell())
287}
288
289func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) {
290	style := renderer.Style{Inverse: true, Foreground: -1, Background: -1}
291	t.Run("clips ascii", func(t *testing.T) {
292		f := renderer.NewFrame(5, 1)
293		next := DrawText(f, 1, 0, 3, "abcd", style)
294		if next != 3 {
295			t.Fatalf("DrawText next = %d, want 3", next)
296		}
297		assertRune(t, f, 1, 0, 'a')
298		assertRune(t, f, 2, 0, 'b')
299		assertCell(t, f, 3, 0, renderer.BlankCell())
300	})
301	t.Run("draws wide rune when it fits", func(t *testing.T) {
302		f := renderer.NewFrame(5, 1)
303		next := DrawText(f, 1, 0, 3, "你", style)
304		if next != 3 {
305			t.Fatalf("DrawText next = %d, want 3", next)
306		}
307		assertRune(t, f, 1, 0, '你')
308		if got := f.At(2, 0); !got.Continuation {
309			t.Fatalf("wide continuation = %+v, want continuation", got)
310		}
311	})
312	t.Run("drops wide rune crossing clip", func(t *testing.T) {
313		f := renderer.NewFrame(5, 1)
314		next := DrawText(f, 2, 0, 3, "你A", style)
315		if next != 2 {
316			t.Fatalf("DrawText next = %d, want 2", next)
317		}
318		assertCell(t, f, 2, 0, renderer.BlankCell())
319		assertCell(t, f, 3, 0, renderer.BlankCell())
320	})
321}
322
323func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) {
324	t.Helper()
325	if got := f.At(x, y).Rune; got != want {
326		t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want)
327	}
328}
329
330func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
331	t.Helper()
332	if got := f.At(x, y); !got.Equal(want) {
333		t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
334	}
335}
336
337func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
338	border := renderer.Style{Foreground: 1, Background: 2}
339	interior := renderer.Style{Foreground: 3, Background: 4}
340	frame := renderer.NewFrame(2, 2)
341	modal := Modal{WidthPct: 100, HeightPct: 100}
342	presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
343
344	got := modal.CompositePresentation(frame, presentation, border, interior)
345
346	require.Equal(t, presentation.Inner, got)
347	for y := range frame.Height {
348		for x := range frame.Width {
349			require.Equal(t, renderer.BlankCell(), frame.At(x, y))
350		}
351	}
352}

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