feat/hints a471fa4

cascade_test.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"testing"
6
7	"github.com/bnema/neferwl/internal/ports"
8)
9
10func cascadeWorkspace(n int) *Workspace {
11	w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
12	w.SetOutput(900, 600)
13	for i := 1; i <= n; i++ {
14		w.AddWindow(WindowID(i))
15	}
16	return w
17}
18
19// With gaps and a width that does not divide evenly, the columns of a
20// partial band share what the gaps leave, and the configured width follows
21// the band as it fills.
22func TestCascadeBandWidthWithGaps(t *testing.T) {
23	w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
24	w.SetOutput(1000, 600)
25	w.SetGaps(8)
26	w.AddWindow(1)
27	if got := w.columnWidth(0); got != 1000-2*8 {
28		t.Fatalf("lone column width %d, want %d", got, 1000-2*8)
29	}
30	w.AddWindow(2)
31	cell := (1000 - 3*8) / 2
32	for i, x := range []int{8, 8 + cell + 8} {
33		r := w.columnRectsFor(false)[i]
34		if r.X != x || r.W != cell || w.columnWidth(i) != cell {
35			t.Fatalf("column %d rect %v width %d, want X %d W %d", i, r, w.columnWidth(i), x, cell)
36		}
37	}
38}
39
40// A partial last band after a full one shares the band width with gaps:
41// each band divides by its own column count.
42func TestCascadePartialLastBandWithGaps(t *testing.T) {
43	w := &Workspace{Overflow: OverflowCascade, MaxColumns: 3}
44	w.SetOutput(1000, 600)
45	w.SetGaps(8)
46	for id := WindowID(1); id <= 5; id++ {
47		w.AddWindow(id)
48	}
49	third, half := (1000-4*8)/3, (1000-3*8)/2
50	rects := w.columnRectsFor(false)
51	for i, want := range []struct{ x, w int }{
52		{8, third}, {8 + third + 8, third}, {8 + 2*(third+8), third},
53		{8, half}, {8 + half + 8, half},
54	} {
55		if r := rects[i]; r.X != want.x || r.W != want.w {
56			t.Fatalf("column %d rect %v, want X %d W %d", i, r, want.x, want.w)
57		}
58	}
59	if rects[3].Y-rects[0].Y != 600 {
60		t.Fatalf("band spacing %d, want 600", rects[3].Y-rects[0].Y)
61	}
62}
63
64// A side panel moves and narrows every band: cells start at the usable
65// area's X and share its width.
66func TestCascadeSidePanel(t *testing.T) {
67	w := &Workspace{Overflow: OverflowCascade, MaxColumns: 2}
68	w.SetOutput(1000, 600)
69	w.SetUsable(Rect{X: 100, W: 900, H: 600})
70	for id := WindowID(1); id <= 3; id++ {
71		w.AddWindow(id)
72	}
73	rects := w.columnRectsFor(false)
74	for i, want := range []Rect{
75		{X: 100, Y: 0, W: 450, H: 600},
76		{X: 550, Y: 0, W: 450, H: 600},
77		{X: 100, Y: 600, W: 900, H: 600},
78	} {
79		if rects[i].X != want.X || rects[i].W != want.W || rects[i].Y-rects[0].Y != want.Y {
80			t.Fatalf("column %d rect %v, want %v", i, rects[i], want)
81		}
82	}
83	// The panel edge at x=100 is no shared line: column 1 has only its
84	// right neighbor.
85	w.FocusID(1)
86	if p := previewOf(t, w.Layout(), 1); p.Neighbors != ports.SideRight {
87		t.Fatalf("neighbors %04b, want right only", p.Neighbors)
88	}
89}
90
91// Each band's columns share its width equally, as in fixed overflow: a
92// lone column fills it, a full band of three has thirds.
93func TestCascadeBandsAndReveal(t *testing.T) {
94	for _, n := range []int{0, 1, 2, 3, 4, 5, 7} {
95		w := cascadeWorkspace(n)
96		ps := w.Layout()
97		if len(ps) != n {
98			t.Fatalf("n=%d placements=%d", n, len(ps))
99		}
100		for i, p := range ps {
101			inBand := min(n-i/3*3, 3)
102			cell := 900 / inBand
103			want := Rect{X: i % 3 * cell, Y: (i/3)*600 - w.View, W: cell, H: 600}
104			if p.Rect != want {
105				t.Fatalf("n=%d window=%d rect=%v want=%v", n, p.ID, p.Rect, want)
106			}
107		}
108		if n > 0 && w.View != ((n-1)/3)*600 {
109			t.Fatalf("n=%d view=%d", n, w.View)
110		}
111	}
112}
113
114func TestCascadeCompactionAndNavigation(t *testing.T) {
115	w := cascadeWorkspace(7)
116	w.FocusID(2)
117	if !w.FocusWindow(1) {
118		t.Fatal("no lower band")
119	}
120	if id, _ := w.Focused(); id != 5 {
121		t.Fatalf("focus=%d", id)
122	}
123	w.RemoveWindow(1)
124	if w.View != 600 {
125		t.Fatalf("compacted view=%d", w.View)
126	}
127	w.SetMaxColumns(2)
128	if w.View != 600 {
129		t.Fatalf("changed max view=%d", w.View)
130	}
131	w.FocusID(7)
132	w.AddWindow(8)
133	if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134		t.Fatal("new column did not join the focused last band")
135	}
136	w.SetUsable(Rect{X: 0, Y: 20, W: 900, H: 580})
137	if w.View != w.band(w.Focus)*580 {
138		t.Fatal("resize did not reveal band")
139	}
140	if !slices.Equal(w.snapPoints(), []float64{0, 580, 1160, 1740}) {
141		t.Fatalf("points=%v", w.snapPoints())
142	}
143}
144
145// A maximized column takes a band of its own: the columns after it pack
146// into the next bands, and unmaximizing packs them back.
147func TestCascadeMaximizedColumnOwnsBand(t *testing.T) {
148	w := cascadeWorkspace(4) // Bands {1 2 3} {4}.
149	w.FocusID(2)
150	w.ToggleFullWidth()
151	bands := func() []int {
152		var b []int
153		for i := range w.Columns {
154			b = append(b, w.band(i))
155		}
156		return b
157	}
158	if got := bands(); !slices.Equal(got, []int{0, 1, 2, 2}) {
159		t.Fatalf("bands %v, want [0 1 2 2]", got)
160	}
161	if w.View != 600 {
162		t.Fatalf("view %d, want the maximized band", w.View)
163	}
164	for _, p := range w.Layout() {
165		if p.ID == 2 && (p.Rect.W != 900 || p.Rect.Y != 0) {
166			t.Fatalf("full width=%v", p.Rect)
167		}
168		if p.ID != 2 && p.Rect.Y == 0 && !p.Hidden {
169			t.Fatalf("column %d shares the maximized band: %v", p.ID, p.Rect)
170		}
171	}
172	// Focus moves between bands without unmaximizing.
173	w.FocusID(1)
174	if !w.Columns[1].FullWidth || w.View != 0 {
175		t.Fatalf("focus left the band: maximized %t view %d", w.Columns[1].FullWidth, w.View)
176	}
177	w.FocusID(2)
178	w.ToggleFullWidth()
179	if got := bands(); !slices.Equal(got, []int{0, 0, 0, 1}) {
180		t.Fatalf("bands after unmaximize %v", got)
181	}
182}
183
184func TestCascadeCovers(t *testing.T) {
185	w := cascadeWorkspace(4)
186	w.FocusID(2)
187	w.ToggleFullscreen()
188	for _, p := range w.Layout() {
189		if p.ID == 2 && p.Rect != w.Output {
190			t.Fatalf("fullscreen=%v", p.Rect)
191		}
192		if p.ID != 2 && !p.Hidden {
193			t.Fatalf("fullscreen sibling %d visible", p.ID)
194		}
195	}
196	w.ToggleFullscreen()
197	w.FocusID(4)
198	if w.View != 600 {
199		t.Fatal("fullscreen restore lost band reveal")
200	}
201}
202
203// Band changes keep a maximized column maximized: it has its own band.
204func TestCascadeKeepsMaximizationOnBandChanges(t *testing.T) {
205	for _, move := range []struct {
206		name string
207		run  func(*Workspace)
208	}{
209		{"focus", func(w *Workspace) { w.FocusWindow(1) }},
210		{"append", func(w *Workspace) { w.AddWindow(5) }},
211		{"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
212	} {
213		t.Run(move.name, func(t *testing.T) {
214			w := cascadeWorkspace(4)
215			w.FocusID(1)
216			w.ToggleFullWidth()
217			move.run(w)
218			if i := w.columnOf(1); !w.Columns[i].FullWidth || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth && c.Windows[0] != 1 }) {
219				t.Fatalf("maximization lost or moved: %+v", w.Columns)
220			}
221		})
222	}
223}
224
225func TestCascadeStashRestoreKeepsReloadClearsMaximization(t *testing.T) {
226	w := cascadeWorkspace(3)
227	w.FocusID(1)
228	w.ToggleFullWidth()
229	w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
230	if !w.Columns[0].FullWidth {
231		t.Fatal("restore into another band dropped the maximization")
232	}
233	m := NewMonitor()
234	m.SetOutput(900, 600)
235	for i := WindowID(1); i <= 3; i++ {
236		m.AddWindow(i)
237	}
238	m.Current().FocusID(1)
239	m.Current().ToggleFullWidth()
240	m.Current().FocusID(3)
241	m.SetOverflow(OverflowCascade)
242	for _, c := range m.Current().Columns {
243		if c.FullWidth {
244			t.Fatal("reload retained maximization")
245		}
246	}
247}
248
249// Leaving cascade drops the band offset: scroll realigns on the focus
250// instead of keeping it as a horizontal offset past the last column.
251func TestReloadCascadeToScrollResetsView(t *testing.T) {
252	m := NewMonitor()
253	m.SetOutput(900, 600)
254	m.SetMaxColumns(3)
255	m.SetOverflow(OverflowCascade)
256	for i := WindowID(1); i <= 4; i++ {
257		m.AddWindow(i)
258	}
259	if m.Current().View != 600 {
260		t.Fatalf("cascade view=%d, want 600", m.Current().View)
261	}
262	m.SetOverflow(OverflowScroll)
263	if got := m.Current().View; got != 300 {
264		t.Fatalf("scroll view=%d, want 300", got)
265	}
266}
267
268// Up/down stop at the first and last band: cascade workspaces sit side by
269// side, so a vertical key never switches workspace.
270func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
271	m := NewMonitor()
272	m.SetOutput(900, 600)
273	m.SetMaxColumns(3)
274	m.SetOverflow(OverflowCascade)
275	for i := WindowID(1); i <= 4; i++ {
276		m.AddWindow(i)
277	}
278	for _, tc := range []struct {
279		focus WindowID
280		a     Action
281	}{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
282		m.Current().FocusID(tc.focus)
283		m.Apply(tc.a)
284		if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
285			t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
286		}
287	}
288}
289
290func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
291	m := NewMonitor()
292	m.SetOutput(900, 600)
293	m.SetOverflow(OverflowCascade)
294	for i := WindowID(1); i <= 7; i++ {
295		m.AddWindow(i)
296	}
297	g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
298	if n := testing.AllocsPerRun(100, func() {
299		if g.columnsChanged(m) {
300			t.Fatal("unchanged gesture invalidated")
301		}
302	}); n != 0 {
303		t.Fatalf("allocations=%v", n)
304	}
305}
306
307func TestCascadeGeometryReusesBuffer(t *testing.T) {
308	w := cascadeWorkspace(7)
309	buf := w.columnRectsInto(nil, false)
310	if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
311		t.Fatalf("allocations=%v", n)
312	}
313	// The overview's lane-encoded tiles reuse their buffer too.
314	tiles, _, _ := w.previewTiles()
315	if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
316		t.Fatalf("preview tile allocations=%v", n)
317	}
318}
319
320// Lanes are the bands of a cascade workspace and the whole row of any other.
321func TestLaneGeometry(t *testing.T) {
322	w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
323	for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
324		if got := w.laneOf(c.i); got != c.lane {
325			t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
326		}
327	}
328	for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
329		if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
330			t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
331		}
332	}
333	for _, lane := range []int{-1, 3} {
334		if first, last := w.laneBounds(lane); first <= last {
335			t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
336		}
337	}
338	// The closest column of the adjacent lane keeps its offset, clamped.
339	for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
340		w.Focus = c.focus
341		if got := w.laneNeighbor(c.d); got != c.want {
342			t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
343		}
344	}
345
346	s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
347	s.SetOutput(900, 600)
348	for i := 1; i <= 5; i++ {
349		s.AddWindow(WindowID(i))
350	}
351	for i := range s.Columns {
352		if s.laneOf(i) != 0 {
353			t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
354		}
355	}
356	if first, last := s.laneBounds(0); first != 0 || last != 4 {
357		t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
358	}
359	for _, lane := range []int{-1, 1} {
360		if first, last := s.laneBounds(lane); first <= last {
361			t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
362		}
363	}
364	for _, d := range []int{-1, 1} {
365		if got := s.laneNeighbor(d); got != -1 {
366			t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
367		}
368	}
369}
370
371func TestSlideLayoutAxisOffset(t *testing.T) {
372	m := NewMonitor()
373	m.SetOutput(900, 600)
374	m.Current().Overflow = OverflowCascade
375	m.AddWindow(1)
376	m.Focus(1)
377	m.AddWindow(2)
378	m.Current().Overflow = OverflowScroll
379	m.Focus(0)
380	m.switchAxis = m.Current().policy().workspace
381	m.switchView.off = .5
382	if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
383		t.Fatalf("cascade source offset=%v", p.Rect)
384	}
385	m.stopSwitch()
386	m.Focus(1)
387	m.switchAxis = m.Current().policy().workspace
388	m.switchView.off = -.5
389	if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
390		t.Fatalf("scroll source offset=%v", p.Rect)
391	}
392}
393
394func TestCascadeViewportSnap(t *testing.T) {
395	w := cascadeWorkspace(7)
396	w.FocusID(3)
397	if w.snapFocus(600, true) != 5 {
398		t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
399	}
400	if w.snapFocus(1200, true) != 6 {
401		t.Fatal("short band neighbour")
402	}
403	w.SetUsable(Rect{})
404	if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
405		t.Fatalf("zero area=%v", got)
406	}
407}

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 25

Tags 10

↑↓ moveenter openesc close