v0.8.0 446d1e3

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(2)
132	w.AddWindow(8)
133	if w.Columns[len(w.Columns)-1].Windows[0] != 8 {
134		t.Fatal("new column did not append")
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
145func TestCascadeCovers(t *testing.T) {
146	w := cascadeWorkspace(4)
147	w.FocusID(2)
148	w.ToggleFullWidth()
149	ps := w.Layout()
150	for _, p := range ps {
151		if p.ID == 2 && p.Rect.W != 900 {
152			t.Fatalf("full width=%v", p.Rect)
153		}
154		if (p.ID == 1 || p.ID == 3) && !p.Hidden {
155			t.Fatalf("sibling %d visible", p.ID)
156		}
157	}
158	w.ToggleFullWidth()
159	w.ToggleFullscreen()
160	for _, p := range w.Layout() {
161		if p.ID == 2 && p.Rect != w.Output {
162			t.Fatalf("fullscreen=%v", p.Rect)
163		}
164		if p.ID != 2 && !p.Hidden {
165			t.Fatalf("fullscreen sibling %d visible", p.ID)
166		}
167	}
168	w.ToggleFullscreen()
169	w.FocusID(4)
170	if w.View != 600 {
171		t.Fatal("fullscreen restore lost band reveal")
172	}
173}
174
175func TestCascadeClearsMaximizationOnBandChanges(t *testing.T) {
176	for _, move := range []struct {
177		name string
178		run  func(*Workspace)
179	}{
180		{"focus", func(w *Workspace) { w.FocusWindow(1) }},
181		{"append", func(w *Workspace) { w.AddWindow(5) }},
182		{"insert", func(w *Workspace) { w.insertColumn(4, Column{Windows: []WindowID{5}}) }},
183	} {
184		t.Run(move.name, func(t *testing.T) {
185			w := cascadeWorkspace(4)
186			w.FocusID(1)
187			w.ToggleFullWidth()
188			move.run(w)
189			for _, col := range w.Columns {
190				if col.FullWidth {
191					t.Fatal("stale maximization")
192				}
193			}
194		})
195	}
196}
197
198func TestCascadeReloadAndStashRestoreClearMaximization(t *testing.T) {
199	w := cascadeWorkspace(3)
200	w.FocusID(1)
201	w.ToggleFullWidth()
202	w.restore(4, &origPlace{stacked: []WindowID{2}, col: 1})
203	for _, c := range w.Columns {
204		if c.FullWidth {
205			t.Fatal("restore retained maximization")
206		}
207	}
208	m := NewMonitor()
209	m.SetOutput(900, 600)
210	for i := WindowID(1); i <= 3; i++ {
211		m.AddWindow(i)
212	}
213	m.Current().FocusID(1)
214	m.Current().ToggleFullWidth()
215	m.Current().FocusID(3)
216	m.SetOverflow(OverflowCascade)
217	for _, c := range m.Current().Columns {
218		if c.FullWidth {
219			t.Fatal("reload retained maximization")
220		}
221	}
222}
223
224// Leaving cascade drops the band offset: scroll realigns on the focus
225// instead of keeping it as a horizontal offset past the last column.
226func TestReloadCascadeToScrollResetsView(t *testing.T) {
227	m := NewMonitor()
228	m.SetOutput(900, 600)
229	m.SetMaxColumns(3)
230	m.SetOverflow(OverflowCascade)
231	for i := WindowID(1); i <= 4; i++ {
232		m.AddWindow(i)
233	}
234	if m.Current().View != 600 {
235		t.Fatalf("cascade view=%d, want 600", m.Current().View)
236	}
237	m.SetOverflow(OverflowScroll)
238	if got := m.Current().View; got != 300 {
239		t.Fatalf("scroll view=%d, want 300", got)
240	}
241}
242
243// Up/down stop at the first and last band: cascade workspaces sit side by
244// side, so a vertical key never switches workspace.
245func TestCascadeFocusWindowStopsAtEdgeBands(t *testing.T) {
246	m := NewMonitor()
247	m.SetOutput(900, 600)
248	m.SetMaxColumns(3)
249	m.SetOverflow(OverflowCascade)
250	for i := WindowID(1); i <= 4; i++ {
251		m.AddWindow(i)
252	}
253	for _, tc := range []struct {
254		focus WindowID
255		a     Action
256	}{{4, ActionFocusWindowDown}, {1, ActionFocusWindowUp}} {
257		m.Current().FocusID(tc.focus)
258		m.Apply(tc.a)
259		if id, _ := m.Current().Focused(); m.Active != 0 || id != tc.focus {
260			t.Fatalf("%s from %d: active=%d focus=%d", tc.a, tc.focus, m.Active, id)
261		}
262	}
263}
264
265func TestCascadeGestureValidityDoesNotAllocate(t *testing.T) {
266	m := NewMonitor()
267	m.SetOutput(900, 600)
268	m.SetOverflow(OverflowCascade)
269	for i := WindowID(1); i <= 7; i++ {
270		m.AddWindow(i)
271	}
272	g := &swipeGesture{ws: m.Current(), points: m.Current().snapPoints(), horizontal: false, opens: m.overviewOpens}
273	if n := testing.AllocsPerRun(100, func() {
274		if g.columnsChanged(m) {
275			t.Fatal("unchanged gesture invalidated")
276		}
277	}); n != 0 {
278		t.Fatalf("allocations=%v", n)
279	}
280}
281
282func TestCascadeGeometryReusesBuffer(t *testing.T) {
283	w := cascadeWorkspace(7)
284	buf := w.columnRectsInto(nil, false)
285	if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
286		t.Fatalf("allocations=%v", n)
287	}
288	// The overview's lane-encoded tiles reuse their buffer too.
289	tiles, _, _ := w.previewTiles()
290	if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
291		t.Fatalf("preview tile allocations=%v", n)
292	}
293}
294
295// Lanes are the bands of a cascade workspace and the whole row of any other.
296func TestLaneGeometry(t *testing.T) {
297	w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
298	for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
299		if got := w.laneOf(c.i); got != c.lane {
300			t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
301		}
302	}
303	for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
304		if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
305			t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
306		}
307	}
308	for _, lane := range []int{-1, 3} {
309		if first, last := w.laneBounds(lane); first <= last {
310			t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
311		}
312	}
313	// The closest column of the adjacent lane keeps its offset, clamped.
314	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}} {
315		w.Focus = c.focus
316		if got := w.laneNeighbor(c.d); got != c.want {
317			t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
318		}
319	}
320
321	s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
322	s.SetOutput(900, 600)
323	for i := 1; i <= 5; i++ {
324		s.AddWindow(WindowID(i))
325	}
326	for i := range s.Columns {
327		if s.laneOf(i) != 0 {
328			t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
329		}
330	}
331	if first, last := s.laneBounds(0); first != 0 || last != 4 {
332		t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
333	}
334	for _, lane := range []int{-1, 1} {
335		if first, last := s.laneBounds(lane); first <= last {
336			t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
337		}
338	}
339	for _, d := range []int{-1, 1} {
340		if got := s.laneNeighbor(d); got != -1 {
341			t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
342		}
343	}
344}
345
346func TestSlideLayoutAxisOffset(t *testing.T) {
347	m := NewMonitor()
348	m.SetOutput(900, 600)
349	m.Current().Overflow = OverflowCascade
350	m.AddWindow(1)
351	m.Focus(1)
352	m.AddWindow(2)
353	m.Current().Overflow = OverflowScroll
354	m.Focus(0)
355	m.switchAxis = m.Current().policy().workspace
356	m.switchView.off = .5
357	if p := previewOf(t, m.layoutInto(nil), 1); p.Rect.X != -450 || p.Rect.Y != 0 {
358		t.Fatalf("cascade source offset=%v", p.Rect)
359	}
360	m.stopSwitch()
361	m.Focus(1)
362	m.switchAxis = m.Current().policy().workspace
363	m.switchView.off = -.5
364	if p := previewOf(t, m.layoutInto(nil), 2); p.Rect.X != 0 || p.Rect.Y != 300 {
365		t.Fatalf("scroll source offset=%v", p.Rect)
366	}
367}
368
369func TestCascadeViewportSnap(t *testing.T) {
370	w := cascadeWorkspace(7)
371	w.FocusID(3)
372	if w.snapFocus(600, true) != 5 {
373		t.Fatalf("nearest cell=%d", w.snapFocus(600, true))
374	}
375	if w.snapFocus(1200, true) != 6 {
376		t.Fatal("short band neighbour")
377	}
378	w.SetUsable(Rect{})
379	if got := w.snapPoints(); len(got) != 1 || got[0] != 0 {
380		t.Fatalf("zero area=%v", got)
381	}
382}

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 26

Tags 10

↑↓ moveenter openesc close