refactor/kvconf 3462c76

overview_cascade_test.go RawHistoryFolder

1package core
2
3import (
4	"fmt"
5	"slices"
6	"testing"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// cascadeOverview is overviewMonitor with workspace 2 (current) a cascade of
12// seven columns, 4..10, in three bands of three (the last holds 10 alone),
13// the focus on column 8 (band 2, middle). Workspace 1 (columns 1..3) is a
14// scroll workspace on its left, workspace 3 (column 30) on its right.
15func cascadeOverview() *Monitor {
16	m := overviewMonitor()
17	m.SetBorder(2)
18	m.Focus(1)
19	w := m.Current()
20	w.Overflow = OverflowCascade
21	for id := WindowID(5); id <= 10; id++ {
22		w.AddWindow(id)
23	}
24	m.Focus(2)
25	m.AddWindow(30)
26	m.Focus(1)
27	w.FocusID(8)
28	return m
29}
30
31// withStash stashes windows 40..40+n-1 of the current workspace, with the
32// focus back on column 8.
33func withStash(m *Monitor, n int) {
34	w := m.Current()
35	for id := WindowID(40); id < WindowID(40+n); id++ {
36		w.AddWindow(id)
37		w.FocusID(id)
38		w.ToggleWindowStash()
39	}
40	w.FocusID(8)
41}
42
43// cascadeCore is a Core on one output with workspace 0 holding window 1,
44// workspace 1 (current) a cascade of windows 2..6 in bands of two ({2,3}
45// {4,5} {6}), workspace 2 window 30, and window 2 focused.
46func cascadeCore(t *testing.T) (*Core, *Monitor) {
47	t.Helper()
48	c, _ := indicatorCore(t)
49	m := c.cur().mon
50	m.AddWindow(1)
51	m.Focus(1)
52	w := m.Current()
53	w.Overflow = OverflowCascade
54	for id := WindowID(2); id <= 6; id++ {
55		w.AddWindow(id)
56	}
57	m.normalize()
58	m.Focus(2)
59	m.AddWindow(30)
60	m.Focus(1)
61	w.FocusID(2)
62	return c, m
63}
64
65func centreY(r Rect) int { return r.Y + r.H/2 }
66
67func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68
69func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70
71// The zoom does not depend on the number of bands.
72func TestOverviewCascadeZoomIgnoresBandCount(t *testing.T) {
73	if one, three := cascadeWorkspace(3).overviewZoom(), cascadeWorkspace(7).overviewZoom(); one != three || one != overviewMaxZoom {
74		t.Fatalf("zoom with one band %v, with three %v", one, three)
75	}
76}
77
78// A maximized column keeps its full size in the preview (the workspace's
79// ratio); the columns it hides are dimmed cards at their cell size behind
80// it, peeking out on their side, the nearest one step, the farther ones two.
81// The row widens by that room; other bands keep their cells.
82func TestOverviewCascadeMaximizedColumnCards(t *testing.T) {
83	// Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
84	for _, tc := range []struct {
85		n, max int
86		cards  []int // per column: 0 not a card, -k/+k k steps left/right
87		order  []int // paint order of band 0's columns
88	}{
89		{n: 1, max: 0, cards: []int{0}, order: []int{0}},
90		{n: 4, max: 0, cards: []int{0, 1, 2, 0}, order: []int{2, 1, 0}},
91		{n: 4, max: 1, cards: []int{-1, 0, 1, 0}, order: []int{0, 2, 1}},
92		{n: 4, max: 3, cards: []int{0, 0, 0, 0}},
93		// Partial bands: the card has its band's half width.
94		{n: 2, max: 0, cards: []int{0, 1}, order: []int{1, 0}},
95		{n: 5, max: 4, cards: []int{0, 0, 0, -1, 0}},
96	} {
97		w := cascadeWorkspace(tc.n)
98		w.Focus = tc.max
99		w.maximize(tc.max)
100		tiles, span, sel := w.previewTiles()
101		g, h, s := w.gap(), w.Usable.H-2*w.gap(), w.bandCardStep()
102		off := 0
103		if slices.ContainsFunc(tc.cards, func(k int) bool { return k != 0 }) {
104			off = 2 * s
105		}
106		if span != w.Usable.W+2*off {
107			t.Fatalf("n=%d max=%d span %d, want %d", tc.n, tc.max, span, w.Usable.W+2*off)
108		}
109		var order []int
110		for _, p := range tiles {
111			i := int(p.ID) - 1
112			if i/3 == 0 && tc.order != nil {
113				order = append(order, i)
114			}
115			band := i / 3
116			want := Rect{X: off + w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(i), H: h}
117			switch k := tc.cards[i]; {
118			case i == tc.max:
119				// Full size, as on screen: the workspace's ratio.
120				want.X, want.W = off+g, w.Usable.W-2*g
121			case k < 0:
122				want.X = g + (2+k)*s
123			case k > 0:
124				want.X = w.Usable.W - g + (2+k)*s - want.W
125			}
126			if p.Rect.X < 0 || p.Rect.X+p.Rect.W > span {
127				t.Fatalf("n=%d max=%d column %d %+v outside span %d", tc.n, tc.max, i, p.Rect, span)
128			}
129			if p.Hidden || p.Peek != (tc.cards[i] != 0) || p.Focused != (i == tc.max) || p.Rect != want {
130				t.Fatalf("n=%d max=%d column %d: %+v, want rect %+v card %v", tc.n, tc.max, i, p, want, tc.cards[i] != 0)
131			}
132			// The 900-wide band split among its columns, no gaps.
133			if inBand := min(tc.n-band*3, 3); i != tc.max && p.Rect.W != 900/inBand {
134				t.Fatalf("n=%d max=%d column %d width %d, want %d", tc.n, tc.max, i, p.Rect.W, 900/inBand)
135			}
136			if i == tc.max && sel != want {
137				t.Fatalf("n=%d max=%d sel %+v, want %+v", tc.n, tc.max, sel, want)
138			}
139		}
140		if tc.order != nil && !slices.Equal(order, tc.order) {
141			t.Fatalf("n=%d max=%d paint order %v, want %v", tc.n, tc.max, order, tc.order)
142		}
143	}
144}
145
146// Moving onto a card behind a maximized column hands it the maximization;
147// Return keeps it there, Escape gives it back. Moving to another band
148// unmaximizes as before.
149func TestOverviewCascadeMaximizedCardSelection(t *testing.T) {
150	for _, d := range cascadeDrivers {
151		t.Run(d.name, func(t *testing.T) {
152			m := cascadeOverview()
153			w := m.Current()
154			w.FocusID(9) // band 2 holds 7, 8, 9
155			w.ToggleFullWidth()
156			m.ToggleOverview()
157			for _, id := range []WindowID{7, 8} {
158				if p := previewOf(t, m.Layout(), id); p.Hidden || !p.Peek {
159					t.Fatalf("card %d: %+v", id, p)
160				}
161			}
162			d.move(m, -1, 0)
163			if focusedID(m) != 8 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
164				t.Fatalf("left: focus %d, maximization not moved", focusedID(m))
165			}
166			if p := previewOf(t, m.Layout(), 9); !p.Peek {
167				t.Fatalf("former maximized column not a card: %+v", p)
168			}
169			m.CancelOverview()
170			if focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(8)].FullWidth {
171				t.Fatalf("escape: focus %d, maximization not restored", focusedID(m))
172			}
173
174			m.ToggleOverview()
175			d.move(m, -1, 0)
176			d.move(m, -1, 0)
177			m.ToggleOverview() // return
178			if focusedID(m) != 7 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(9)].FullWidth {
179				t.Fatalf("return: focus %d, maximization not on 7", focusedID(m))
180			}
181
182			m.ToggleOverview()
183			d.move(m, 0, -1)
184			if focusedID(m) != 4 || slices.ContainsFunc(w.Columns, func(c Column) bool { return c.FullWidth }) {
185				t.Fatalf("up: focus %d, a column still maximized", focusedID(m))
186			}
187		})
188	}
189}
190
191// A click on a window of a card's column records that window in the
192// maximize history, not the column's former focus.
193func TestOverviewCascadeMaximizedCardPickRecordsWindow(t *testing.T) {
194	m := cascadeOverview()
195	w := m.Current()
196	// Column 8 stacks 8 over 50, 8 focused.
197	w.Columns[w.columnOf(8)].Windows = append(w.Columns[w.columnOf(8)].Windows, 50)
198	w.FocusID(7)
199	w.ToggleFullWidth()
200	m.ToggleOverview()
201	m.OverviewPick(50)
202	if len(w.maximized) == 0 || w.maximized[0] != 50 || focusedID(m) != 50 {
203		t.Fatalf("history %v, focus %d; want 50 first", w.maximized, focusedID(m))
204	}
205}
206
207// A FullWidth column outside the focused band fills its band, shifted like
208// every tile by the room of the focused column's cards.
209func TestOverviewCascadeUnfocusedMaximizedFillsBand(t *testing.T) {
210	w := cascadeWorkspace(6)
211	w.SetGaps(8)
212	w.Focus = 4
213	w.maximize(4)
214	w.Columns[0].FullWidth = true
215	tiles, _, _ := w.previewTiles()
216	// The row keeps room for the focused column's cards on its left.
217	g, off := w.gap(), 2*w.bandCardStep()
218	p := previewOf(t, tiles, 1)
219	if p.Hidden || p.Rect.X != off+g || p.Rect.W != w.Usable.W-2*g {
220		t.Fatalf("unfocused maximized column %+v, want X %d W %d", p, off+g, w.Usable.W-2*g)
221	}
222}
223
224// A click on a card behind a maximized cascade column hands it the
225// maximization.
226func TestOverviewCascadeMaximizedCardPick(t *testing.T) {
227	m := cascadeOverview()
228	w := m.Current()
229	w.FocusID(7)
230	w.ToggleFullWidth()
231	m.ToggleOverview()
232	// Each card's strip right of the maximized column hits it: 8 next to
233	// it, 9 at the band's edge.
234	ps := m.Layout()
235	front, near, far := previewOf(t, ps, 7).Rect, previewOf(t, ps, 8).Rect, previewOf(t, ps, 9).Rect
236	for _, c := range []struct {
237		x0, x1 int
238		want   WindowID
239	}{{front.X + front.W, near.X + near.W, 8}, {near.X + near.W, far.X + far.W, 9}} {
240		x := (c.x0 + c.x1) / 2
241		if c.x0 >= c.x1 {
242			t.Fatalf("no strip for %d: front %v near %v far %v", c.want, front, near, far)
243		}
244		if got := overviewIn(ps, float64(x), float64(centreY(far))); got != c.want {
245			t.Fatalf("strip at x=%d hits %d, want %d", x, got, c.want)
246		}
247	}
248	m.OverviewPick(9)
249	if m.ov.open || focusedID(m) != 9 || !w.Columns[w.Focus].FullWidth || w.Columns[w.columnOf(7)].FullWidth {
250		t.Fatalf("pick: open %v, focus %d, maximization not moved", m.ov.open, focusedID(m))
251	}
252}
253
254// The selected band is the centre lane: undimmed, centred, selected tile
255// focused.
256func TestOverviewCascadeCentreLane(t *testing.T) {
257	m := cascadeOverview()
258	m.ToggleOverview()
259	ps := m.Layout()
260	u := m.Current().overviewArea()
261	for _, id := range []WindowID{7, 8, 9} {
262		p := previewOf(t, ps, id)
263		if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) {
264			t.Fatalf("band tile %d: %+v", id, p)
265		}
266		if !within1(centreY(p.Rect), u.Y+u.H/2) {
267			t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2)
268		}
269	}
270}
271
272// The other bands are dimmed lanes a screen (and a gap) away; those
273// outside the area are hidden.
274func TestOverviewCascadeOtherLanes(t *testing.T) {
275	m := cascadeOverview()
276	m.ToggleOverview()
277	ps := m.Layout()
278	sel := previewOf(t, ps, 8).Rect
279	above, below := previewOf(t, ps, 5), previewOf(t, ps, 10)
280	if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused {
281		t.Fatalf("adjacent bands: above %+v, below %+v", above, below)
282	}
283	// The lanes sit evenly around the selected one, apart from it (a gap),
284	// and the area still shows part of each.
285	up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y
286	if !within1(up, down) {
287		t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down)
288	}
289	if gap := up - sel.H; gap <= 0 {
290		t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel)
291	}
292	u := m.Current().overviewArea()
293	if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H {
294		t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect)
295	}
296
297	// Five bands (13 columns): the ones two lanes away are off the area.
298	m = cascadeOverview()
299	w := m.Current()
300	for id := WindowID(11); id <= 16; id++ {
301		w.AddWindow(id)
302	}
303	w.FocusID(8)
304	m.ToggleOverview()
305	// Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}.
306	w.FocusID(11)
307	ps = m.Layout()
308	for _, id := range []WindowID{4, 5, 6, 16} {
309		if p := previewOf(t, ps, id); !hiddenOff(p) {
310			t.Fatalf("band two lanes away %d not hidden: %+v", id, p)
311		}
312	}
313	for _, id := range []WindowID{7, 13} {
314		if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
315			t.Fatalf("adjacent band %d: %+v", id, p)
316		}
317	}
318}
319
320// Neighbor workspaces sit left and right of the current slot, dimmed,
321// their selected lane level with the centre lane.
322func TestOverviewCascadeNeighbors(t *testing.T) {
323	m := cascadeOverview()
324	m.ToggleOverview()
325	rows, _ := m.overviewRows()
326	cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]
327	if left.X+left.W > cur.X || right.X < cur.X+cur.W {
328		t.Fatalf("slots overlap: %v %v %v", left, cur, right)
329	}
330	ps := m.Layout()
331	centre := centreY(previewOf(t, ps, 8).Rect)
332	shown := 0
333	for _, n := range []struct {
334		id   WindowID
335		side int
336	}{{1, -1}, {2, -1}, {3, -1}, {30, 1}} {
337		id, side := n.id, n.side
338		p := previewOf(t, ps, id)
339		if p.Hidden {
340			continue
341		}
342		shown++
343		if !p.Peek || p.Focused || p.Preview == 0 {
344			t.Fatalf("neighbor %d not dimmed: %+v", id, p)
345		}
346		if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W {
347			t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect)
348		}
349		if !within1(centreY(p.Rect), centre) {
350			t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre)
351		}
352	}
353	if shown < 2 {
354		t.Fatalf("only %d neighbor tiles drawn", shown)
355	}
356	for _, p := range ps {
357		if p.Hidden || p.Peek {
358			continue
359		}
360		if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W {
361			t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect)
362		}
363	}
364}
365
366// Lanes along the overview axis would overlap the other workspaces: a
367// cascade neighbor of a scroll workspace shows its selected band only.
368func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
369	m := cascadeOverview()
370	m.Focus(0)
371	m.ToggleOverview()
372	if m.overviewAxis() != verticalAxis {
373		t.Fatal("setup: scroll overview is not vertical")
374	}
375	ps := m.Layout()
376	for _, id := range []WindowID{4, 5, 6} {
377		if p := previewOf(t, ps, id); !hiddenOff(p) {
378			t.Fatalf("band %d of the neighbor drawn: %+v", id, p)
379		}
380	}
381	if p := previewOf(t, ps, 8); p.Hidden {
382		// The neighbor's selected column is 8.
383		t.Fatalf("selected band hidden: %+v", p)
384	}
385	for _, id := range []WindowID{7, 9} {
386		if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
387			t.Fatalf("selected band tile %d: %+v", id, p)
388		}
389	}
390	if p := previewOf(t, ps, 10); !hiddenOff(p) {
391		t.Fatalf("band 3 drawn: %+v", p)
392	}
393}
394
395// The stash pile is inside the current slot, left of the band; the
396// previous workspace stays clear of it.
397func TestOverviewCascadeStashInSlot(t *testing.T) {
398	m := cascadeOverview()
399	withStash(m, 2)
400	m.ToggleOverview()
401	rows, _ := m.overviewRows()
402	cur, prev := rows[m.Current()], rows[m.Workspaces[0]]
403	ps := m.Layout()
404	left := cur.X + cur.W
405	for _, id := range []WindowID{7, 8, 9} {
406		left = min(left, previewOf(t, ps, id).Rect.X)
407	}
408	for _, id := range []WindowID{40, 41} {
409		p := previewOf(t, ps, id)
410		if p.Hidden || p.Preview != overviewCardZoom {
411			t.Fatalf("pile card %d: %+v", id, p)
412		}
413		if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {
414			t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)
415		}
416		// 41 is the front card of the pile.
417		if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {
418			t.Fatalf("pile not level with the selected lane: %+v", p.Rect)
419		}
420	}
421	// The pile does not shift the band: its row stays centred on the area.
422	u := m.Current().overviewArea()
423	lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect
424	if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
425		t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
426	}
427	for _, id := range []WindowID{1, 2, 3} {
428		if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
429			t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
430		}
431	}
432	if prev.X+prev.W > cur.X {
433		t.Fatalf("slots overlap: %v %v", prev, cur)
434	}
435}
436
437type cascadeDriver struct {
438	name string
439	move func(m *Monitor, dx, dy int)
440}
441
442func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) {
443	return func(m *Monitor, dx, dy int) {
444		name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}]
445		m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true})
446	}
447}
448
449var cascadeDrivers = []cascadeDriver{
450	{"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }},
451	{"hjkl", keyDriver("h", "l", "k", "j")},
452	{"arrows", keyDriver("Left", "Right", "Up", "Down")},
453}
454
455func focusedID(m *Monitor) WindowID {
456	id, _ := m.Focused()
457	return id
458}
459
460// ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
461// for the stash or the neighbor workspaces; Escape restores everything.
462func TestOverviewCascadeKeys(t *testing.T) {
463	for _, d := range cascadeDrivers {
464		t.Run(d.name, func(t *testing.T) {
465			m := cascadeOverview()
466			w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2]
467			w.FocusID(5)
468			m.ToggleOverview()
469			sel := func() WindowID { return w.Columns[w.Focus].Windows[0] }
470			d.move(m, 0, 1)
471			if sel() != 8 {
472				t.Fatalf("down: %d, want 8", sel())
473			}
474			d.move(m, 0, 1)
475			d.move(m, 0, 1)
476			if m.Current() != w || sel() != 10 {
477				t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w)
478			}
479			d.move(m, 0, -1)
480			if sel() != 7 {
481				t.Fatalf("up: %d, want 7", sel())
482			}
483			d.move(m, 0, -1)
484			d.move(m, 0, -1)
485			if m.Current() != w || sel() != 4 {
486				t.Fatalf("up at the first band: %d", sel())
487			}
488			d.move(m, 1, 0)
489			d.move(m, 1, 0)
490			if sel() != 6 || m.Current() != w {
491				t.Fatalf("right in the band: %d", sel())
492			}
493			d.move(m, 1, 0)
494			if m.Current() != next {
495				t.Fatal("right past the last column did not reach the next workspace")
496			}
497			m.CancelOverview()
498			if m.Current() != w || focusedID(m) != 5 {
499				t.Fatalf("cancel left %d", focusedID(m))
500			}
501
502			m.ToggleOverview()
503			d.move(m, -1, 0)
504			if sel() != 4 || m.Current() != w {
505				t.Fatal("left within the band")
506			}
507			d.move(m, -1, 0)
508			if m.Current() != prev {
509				t.Fatal("left from the first column did not reach the previous workspace")
510			}
511			m.CancelOverview()
512
513			// With a stash: left from the first column enters it, right
514			// comes back to the band's first column, left twice goes to
515			// the previous workspace.
516			withStash(m, 2)
517			w.FocusID(7)
518			m.ToggleOverview()
519			d.move(m, -1, 0)
520			if m.cardAt(w) < 0 {
521				t.Fatal("left from the first column did not select the stash")
522			}
523			d.move(m, 1, 0)
524			if m.cardAt(w) >= 0 || sel() != 7 {
525				t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
526			}
527			d.move(m, -1, 0)
528			d.move(m, -1, 0)
529			if m.Current() != prev {
530				t.Fatal("left from the stash did not reach the previous workspace")
531			}
532			m.CancelOverview()
533			if m.Current() != w || focusedID(m) != 7 {
534				t.Fatalf("cancel left %d", focusedID(m))
535			}
536		})
537	}
538}
539
540// T7b: with a stash card selected, up/down and the three-finger vertical
541// swipe cycle the pile as in a vertical overview; confirming shows the picked window.
542func TestOverviewCascadeStashPile(t *testing.T) {
543	for _, d := range cascadeDrivers {
544		t.Run(d.name, func(t *testing.T) {
545			m := cascadeOverview()
546			withStash(m, 3)
547			w := m.Current()
548			w.FocusID(7)
549			m.ToggleOverview()
550			d.move(m, -1, 0)
551			if m.cardAt(w) != w.stashAt {
552				t.Fatalf("entered at %d", m.cardAt(w))
553			}
554			at := m.cardAt(w)
555			d.move(m, 0, -1)
556			if m.Current() != w || m.cardAt(w) != at-1 {
557				t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
558			}
559			d.move(m, 0, 1)
560			d.move(m, 0, 1)
561			d.move(m, 0, 1)
562			if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
563				t.Fatalf("down: card %d", m.cardAt(w))
564			}
565			d.move(m, 0, -1)
566			picked := w.Stash[m.cardAt(w)].ID
567			m.ToggleOverview()
568			if focusedID(m) != picked || w.stashHidden {
569				t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden)
570			}
571		})
572	}
573
574	c, _, sc, _ := stashRig(t, true)
575	m := sc.mon
576	w := m.Current()
577	w.Overflow = OverflowCascade
578	m.Focus(1)
579	m.AddWindow(50)
580	m.Focus(0)
581	m.ToggleOverview()
582	if m.cardAt(w) < 0 {
583		t.Fatal("setup: no card selected")
584	}
585	at, before := m.cardAt(w), stashIDs(w)
586	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
587	for range 3 {
588		c.swipeUpdate(ports.SwipeUpdate{DY: -40})
589	}
590	c.swipeEnd(ports.SwipeEnd{})
591	if m.Current() != w || m.cardAt(w) != at-1 {
592		t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
593	}
594	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
595	for range 3 {
596		c.swipeUpdate(ports.SwipeUpdate{DX: 40})
597	}
598	c.swipeEnd(ports.SwipeEnd{})
599	// A horizontal three-finger swipe is a workspace step, even over a
600	// stash card.
601	if m.Current() != m.Workspaces[1] {
602		t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
603	}
604	if got := stashIDs(w); !slices.Equal(got, before) {
605		t.Fatal("stash reordered")
606	}
607}
608
609// T7c: three fingers follow the physical axes, once per gesture.
610func TestOverviewCascadeThreeFinger(t *testing.T) {
611	c, m := cascadeCore(t)
612	w := m.Current()
613	m.ToggleOverview()
614	swipe := func(dx, dy float64) {
615		t.Helper()
616		c.swipeBegin(ports.SwipeBegin{Fingers: 3})
617		for range 4 {
618			c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy})
619		}
620		c.swipeEnd(ports.SwipeEnd{})
621	}
622	swipe(0, 40)
623	if m.Current() != w || w.band(w.Focus) != 1 {
624		t.Fatalf("one swipe down: band %d", w.band(w.Focus))
625	}
626	swipe(0, -40)
627	if w.band(w.Focus) != 0 {
628		t.Fatalf("one swipe up: band %d", w.band(w.Focus))
629	}
630	swipe(40, 0)
631	if m.Current() != m.Workspaces[2] {
632		t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current()))
633	}
634	// Onto a scroll workspace and back: the vertical swipe is the scroll
635	// overview's workspace step; neither bounces during the gesture.
636	swipe(0, -40)
637	if m.Current() != w {
638		t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current()))
639	}
640	swipe(-40, 0)
641	if m.Current() != m.Workspaces[0] {
642		t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current()))
643	}
644}
645
646// Three fingers sideways walk the cards behind a maximized column one per
647// gesture, like the arrows, then change workspace past the band's edge.
648func TestOverviewCascadeThreeFingerMaximizedCards(t *testing.T) {
649	c, m := cascadeCore(t)
650	w := m.Current()
651	w.ToggleFullWidth() // Column 2 hides 3, the other column of its band.
652	m.ToggleOverview()
653	swipe := func(dx float64) {
654		t.Helper()
655		c.swipeBegin(ports.SwipeBegin{Fingers: 3})
656		for range 4 {
657			c.swipeUpdate(ports.SwipeUpdate{DX: dx})
658		}
659		c.swipeEnd(ports.SwipeEnd{})
660	}
661	swipe(40)
662	if m.Current() != w || m.overviewTarget() != 3 || !w.Columns[w.Focus].FullWidth {
663		t.Fatalf("swipe right: workspace %d, target %d, want maximized 3", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
664	}
665	swipe(-40)
666	if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
667		t.Fatalf("swipe left: workspace %d, target %d, want maximized 2", indexOf(m.Workspaces, m.Current()), m.overviewTarget())
668	}
669	swipe(40)
670	swipe(40)
671	if m.Current() != m.Workspaces[2] {
672		t.Fatalf("swipe right past the band: workspace %d", indexOf(m.Workspaces, m.Current()))
673	}
674
675	// A covering float in front: the swipe changes workspace, as without
676	// a maximized column.
677	m.CancelOverview()
678	w.AddFloating(90, 300, 200)
679	m.ToggleOverview()
680	if f := m.stackFront(w); f.kind != stackFloat || !w.Columns[w.Focus].FullWidth {
681		t.Fatalf("setup: front %+v", f)
682	}
683	swipe(-40)
684	if m.Current() != m.Workspaces[0] {
685		t.Fatalf("swipe left over a float: workspace %d", indexOf(m.Workspaces, m.Current()))
686	}
687}
688
689// Left from the first column of a band whose maximized column hides it:
690// the arrows and three fingers walk the cards, then enter the stash, or
691// change workspace without one.
692func TestOverviewCascadeMaximizedLeftEdge(t *testing.T) {
693	for _, stash := range []bool{false, true} {
694		c, m := cascadeCore(t)
695		w := m.Current()
696		if stash {
697			w.AddWindow(40)
698			w.FocusID(40)
699			w.ToggleWindowStash()
700		}
701		w.FocusID(3) // Band {2, 3}: 3 hides 2.
702		w.ToggleFullWidth()
703		m.ToggleOverview()
704		swipe := func() {
705			c.swipeBegin(ports.SwipeBegin{Fingers: 3})
706			for range 4 {
707				c.swipeUpdate(ports.SwipeUpdate{DX: -40})
708			}
709			c.swipeEnd(ports.SwipeEnd{})
710		}
711		swipe()
712		if m.Current() != w || m.overviewTarget() != 2 || !w.Columns[w.Focus].FullWidth {
713			t.Fatalf("stash %v: first swipe: target %d", stash, m.overviewTarget())
714		}
715		swipe()
716		if stash {
717			if m.Current() != w || m.cardAt(w) < 0 {
718				t.Fatalf("past the band: workspace %d, card %d; want the stash", indexOf(m.Workspaces, m.Current()), m.cardAt(w))
719			}
720		} else if m.Current() != m.Workspaces[0] {
721			t.Fatalf("past the band: workspace %d, want 0", indexOf(m.Workspaces, m.Current()))
722		}
723	}
724}
725
726// With four or more columns in a band, the cards three or more columns
727// away sit under the two-step card: arrows still reach every one, in
728// order, each taking the maximization.
729func TestOverviewCascadeFarCardsByArrows(t *testing.T) {
730	m := newMonitor("", "")
731	m.SetOutput(1200, 600)
732	m.SetMaxColumns(5)
733	w := m.Current()
734	w.Overflow = OverflowCascade
735	for id := WindowID(1); id <= 5; id++ {
736		m.AddWindow(id)
737	}
738	w.FocusID(1)
739	w.ToggleFullWidth()
740	m.ToggleOverview()
741	ps := m.Layout()
742	edge := previewOf(t, ps, 3).Rect
743	for _, id := range []WindowID{4, 5} {
744		if p := previewOf(t, ps, id); p.Hidden || !p.Peek || p.Rect.X != edge.X {
745			t.Fatalf("far card %d: %+v, want a card under 3 at x %d", id, p, edge.X)
746		}
747	}
748	for _, want := range []WindowID{2, 3, 4, 5} {
749		m.OverviewMove(1, 0)
750		if m.Current() != w || m.overviewTarget() != want || !w.Columns[w.Focus].FullWidth {
751			t.Fatalf("right: target %d, want maximized %d", m.overviewTarget(), want)
752		}
753	}
754	m.CancelOverview()
755	if focusedID(m) != 1 || !w.Columns[w.Focus].FullWidth {
756		t.Fatalf("escape: focus %d, want maximized 1", focusedID(m))
757	}
758}
759
760// Right from a stash card lands on the maximized column, which keeps its
761// maximization, not on the first card of its band.
762func TestOverviewCascadeStashToMaximized(t *testing.T) {
763	m := cascadeOverview()
764	withStash(m, 1)
765	w := m.Current()
766	w.FocusID(9)
767	w.ToggleFullWidth()
768	m.ToggleOverview()
769	m.selectCard(w, w.stashAt) // As a click on the pile does.
770	if m.cardAt(w) < 0 {
771		t.Fatalf("setup: no stash card, target %d", m.overviewTarget())
772	}
773	m.OverviewMove(1, 0)
774	if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 {
775		t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized)
776	}
777}
778
779// A named boundary of a cascade overview is a vertical rule strictly
780// between the two slots.
781func TestOverviewCascadeNamedDivider(t *testing.T) {
782	m := namedOverviewMonitor()
783	m.Workspaces[1].Overflow = OverflowCascade
784	m.ToggleOverview()
785	rows, dividers := m.overviewRows()
786	if len(dividers) != 1 {
787		t.Fatalf("dividers: %+v", dividers)
788	}
789	d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
790	if d.W != 1 || d.H <= 1 || dividers[0].Active {
791		t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
792	}
793	if d.X < cur.X+cur.W || d.X+d.W > dev.X {
794		t.Fatalf("divider %v not between %v and %v", d, cur, dev)
795	}
796}
797
798// Covering floats fan along the horizontal axis: left goes deeper in the
799// stack (the cards behind show on the left), right comes back to the front.
800func TestOverviewCascadeCoveringFloat(t *testing.T) {
801	for _, d := range cascadeDrivers {
802		t.Run(d.name, func(t *testing.T) {
803			m := cascadeOverview()
804			m.Current().AddFloating(90, 300, 200)
805			w := m.Current()
806			w.FocusID(10)
807			m.ToggleOverview()
808			if m.stackFront(w).kind != stackColumns {
809				t.Fatal("setup: columns not in front")
810			}
811			d.move(m, 0, -1) // columns: up is a band
812			d.move(m, -1, 0)
813			if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
814				t.Fatalf("left from the first column: front %+v", f)
815			}
816			if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
817				t.Fatalf("float not in front: %+v", p)
818			}
819			d.move(m, 0, 1) // a float front takes no band move
820			if f := m.stackFront(w); f.kind != stackFloat {
821				t.Fatalf("down left the float: %+v", f)
822			}
823			d.move(m, 1, 0)
824			if m.stackFront(w).kind != stackColumns || m.Current() != w {
825				t.Fatalf("right from the float: front %+v", m.stackFront(w))
826			}
827
828			// The float in front of the columns: they fan to its left.
829			m.CancelOverview()
830			w.FocusID(90)
831			m.ToggleOverview()
832			if f := m.stackFront(w); f.kind != stackFloat {
833				t.Fatalf("setup: front %+v", f)
834			}
835			behind := previewOf(t, m.Layout(), 10)
836			if !behind.Peek {
837				t.Fatalf("columns not behind the float: %+v", behind)
838			}
839			d.move(m, -1, 0)
840			if m.stackFront(w).kind != stackColumns || m.Current() != w {
841				t.Fatalf("left from the front float: front %+v", m.stackFront(w))
842			}
843			// The cards behind fan to the left: coming to the front, the
844			// columns move right by one peek step.
845			front := previewOf(t, m.Layout(), 10)
846			if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
847				t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
848			}
849			d.move(m, 1, 0)
850			if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
851				t.Fatalf("right from the columns behind the float: front %+v", f)
852			}
853		})
854	}
855}
856
857// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
858func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
859	m := cascadeOverview()
860	w := m.Current()
861	w.FocusID(8)
862	m.ToggleFullscreen()
863	if !w.pinned() {
864		t.Fatal("setup: not pinned")
865	}
866	m.ToggleOverview()
867	ps := m.Layout()
868	fs := previewOf(t, ps, 8)
869	if !fs.Fullscreen || fs.Hidden {
870		t.Fatalf("fullscreen tile: %+v", fs)
871	}
872	for _, id := range []WindowID{7, 9} {
873		if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
874			t.Fatalf("band tile %d: %+v", id, p)
875		}
876	}
877	u := w.overviewArea()
878	if !within1(centreY(fs.Rect), u.Y+u.H/2) {
879		t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
880	}
881}
882
883// The focus binds keep their meaning in a cascade overview.
884func TestOverviewCascadeFocusBinds(t *testing.T) {
885	m := cascadeOverview()
886	w := m.Current()
887	m.ToggleOverview()
888	if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
889		t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
890	}
891	if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
892		t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
893	}
894	m.overviewFocus(ActionFocusWorkspaceNext)
895	if m.Current() != m.Workspaces[2] {
896		t.Fatal("focus-workspace-next did not change workspace")
897	}
898	m.overviewFocus(ActionFocusWorkspacePrev)
899	if m.Current() != w {
900		t.Fatal("focus-workspace-prev did not come back")
901	}
902	m.overviewFocus(ActionFocusWorkspacePrev)
903	if m.Current() != m.Workspaces[0] {
904		t.Fatal("focus-workspace-prev did not change workspace")
905	}
906	m.CancelOverview()
907
908	// A selected stash card keeps the pile cycle.
909	withStash(m, 3)
910	w.FocusID(7)
911	m.ToggleOverview()
912	m.OverviewMove(-1, 0)
913	at := m.cardAt(w)
914	m.overviewFocus(ActionFocusWorkspacePrev)
915	if m.Current() != w || m.cardAt(w) != at-1 {
916		t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
917	}
918	m.overviewFocus(ActionFocusWorkspaceNext)
919	if m.Current() != w || m.cardAt(w) != at {
920		t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
921	}
922}
923
924// A three-finger step that moves nothing reports no change: no scene
925// transition, no keyboard grab. Here the first band of a cascade has no
926// band above it.
927func TestOverviewCascadeFingerNoopReportsNoChange(t *testing.T) {
928	m := cascadeOverview()
929	m.Current().FocusID(5)
930	m.ToggleOverview()
931	up := ports.PointerAxis{Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, Value: -overviewScrollStep}}
932	if m.overviewScroll(up) {
933		t.Fatal("a step past the first band reported a change")
934	}
935}
936
937func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
938	m := overviewMonitor()
939	m.SetOverflow(OverflowFixed)
940	m.Current().Overflow = OverflowCascade
941	from := m.Current()
942	m.ToggleOverview()
943	rows, _ := m.overviewRows()
944	if rows[m.Workspaces[1]].X <= rows[from].X {
945		t.Fatal("cascade neighbor not horizontal")
946	}
947	m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
948	if m.Current() == from {
949		t.Fatal("horizontal gesture did not change workspace")
950	}
951	if m.Current().Overflow != OverflowFixed {
952		t.Fatal("destination lost fixed policy")
953	}
954	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
955		t.Fatalf("scroll state after the axis change: %+v", m.ov)
956	}
957	m.CancelOverview()
958	if m.Current() != from {
959		t.Fatal("cancel lost source workspace")
960	}
961}
962
963// Showing a workspace with another overview axis drops the scroll leftovers
964// of the old one.
965func TestOverviewAxisChangeResetsScroll(t *testing.T) {
966	m := cascadeOverview()
967	m.ToggleOverview()
968	m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
969	m.showOverview(m.Workspaces[2])
970	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
971		t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
972	}
973	m.ov.scrollX = 20
974	m.showOverview(m.Workspaces[0])
975	if m.ov.scrollX != 20 {
976		t.Fatal("scroll reset between workspaces of the same axis")
977	}
978}
979
980// A reload that changes the current workspace's axes drops the scroll state;
981// one that does not, keeps it.
982func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
983	c, _ := indicatorCore(t)
984	m := c.cur().mon
985	m.AddWindow(1)
986	m.ToggleOverview()
987	w := m.Current()
988	set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
989	set()
990	cfg := c.cfg
991	cfg.Layout.Gaps++
992	if err := c.apply(cfg); err != nil {
993		t.Fatal(err)
994	}
995	if m.ov.scrollX != 20 || !m.ov.scrolled {
996		t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
997	}
998	cfg.Layout.Overflow = string(OverflowCascade)
999	if err := c.apply(cfg); err != nil {
1000		t.Fatal(err)
1001	}
1002	if m.Current().policy().workspace != horizontalAxis {
1003		t.Fatal("setup: not cascade")
1004	}
1005	// The reload keeps the overview open on its selection, and cancelling
1006	// still gives the original focus back.
1007	if id, _ := w.Focused(); !m.ov.open || id != 1 {
1008		t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
1009	}
1010	// The scroll is dropped, but not the latch that waits for the fingers
1011	// to lift.
1012	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
1013		t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
1014	}
1015	set()
1016	cfg.Layout.Overflow = string(OverflowFixed)
1017	if err := c.apply(cfg); err != nil {
1018		t.Fatal(err)
1019	}
1020	if m.ov.scrollX != 0 || !m.ov.scrolled {
1021		t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
1022	}
1023	m.CancelOverview()
1024	if id, _ := w.Focused(); id != 1 {
1025		t.Fatalf("cancel left the focus on %d", id)
1026	}
1027}
1028
1029// The finger latch outlives an axis change: a gesture that turned the
1030// overview, then was turned back by a key, does not step again.
1031func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
1032	c, m := cascadeCore(t)
1033	w := m.Current()
1034	m.ToggleOverview()
1035	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
1036	c.swipeUpdate(ports.SwipeUpdate{DX: 70})
1037	if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
1038		t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
1039	}
1040	// Back to the cascade workspace by a key, with the fingers still down.
1041	m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
1042	if m.Current() != w || m.overviewAxis() != horizontalAxis {
1043		t.Fatal("setup: the key did not turn the overview back")
1044	}
1045	for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
1046		c.swipeUpdate(u)
1047	}
1048	c.swipeEnd(ports.SwipeEnd{})
1049	if m.Current() != w || w.Focus != 0 {
1050		t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
1051	}
1052	// The next gesture steps again.
1053	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
1054	c.swipeUpdate(ports.SwipeUpdate{DY: 70})
1055	c.swipeEnd(ports.SwipeEnd{})
1056	if w.band(w.Focus) != 1 {
1057		t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
1058	}
1059}
1060
1061// A stash-only cascade workspace has no columns to select: right from its
1062// pile goes straight to the next workspace.
1063func TestOverviewCascadeStashOnlyRight(t *testing.T) {
1064	m := overviewMonitor()
1065	m.Focus(1)
1066	w := m.Current()
1067	w.Overflow = OverflowCascade
1068	w.ToggleWindowStash()
1069	m.Focus(2)
1070	m.AddWindow(30)
1071	m.Focus(1)
1072	m.ToggleOverview()
1073	if len(w.Columns) != 0 || m.cardAt(w) < 0 {
1074		t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
1075	}
1076	m.OverviewMove(1, 0)
1077	if m.Current() != m.Workspaces[2] {
1078		t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
1079	}
1080	m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
1081	if m.Current() != w || m.cardAt(w) < 0 {
1082		t.Fatal("setup: back on the stash-only workspace without its card")
1083	}
1084	m.OverviewMove(-1, 0)
1085	if m.Current() != m.Workspaces[0] {
1086		t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
1087	}
1088}
1089
1090// A cascade neighbor beside a cascade workspace draws its other lanes
1091// dimmed, its selected lane level with the centre lane.
1092func TestOverviewCascadeNeighborLanes(t *testing.T) {
1093	m := cascadeOverview()
1094	prev := m.Workspaces[0]
1095	prev.Overflow = OverflowCascade
1096	for id := WindowID(60); id <= 65; id++ {
1097		prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
1098	}
1099	m.ToggleOverview()
1100	ps := m.Layout()
1101	centre := centreY(previewOf(t, ps, 8).Rect)
1102	sel := previewOf(t, ps, 65)
1103	if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
1104		t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
1105	}
1106	above := previewOf(t, ps, 62)
1107	if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
1108		t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
1109	}
1110	if p := previewOf(t, ps, 3); !hiddenOff(p) {
1111		t.Fatalf("lane two away in the neighbor: %+v", p)
1112	}
1113}
1114
1115// A scroll neighbor wider than its slot scrolls inside it: what leaves the
1116// slot is hidden, so it never sits on the current slot.
1117func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
1118	m := cascadeOverview()
1119	prev := m.Workspaces[0]
1120	for id := WindowID(60); id <= 67; id++ {
1121		prev.AddWindow(id)
1122	}
1123	m.ToggleOverview()
1124	rows, _ := m.overviewRows()
1125	cur, slot := rows[m.Current()], rows[prev]
1126	hidden, shown := 0, 0
1127	for _, p := range m.Layout() {
1128		if !prev.has(p.ID) {
1129			continue
1130		}
1131		if p.Hidden {
1132			hidden++
1133			continue
1134		}
1135		shown++
1136		if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
1137			t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
1138		}
1139	}
1140	if hidden == 0 || shown == 0 {
1141		t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
1142	}
1143}
1144
1145// A wheel and a continuous scroll in a cascade overview: vertical changes
1146// workspace (and does not bounce back on a workspace with another axis),
1147// horizontal moves along the band.
1148func TestOverviewCascadeScroll(t *testing.T) {
1149	vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
1150	for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
1151		t.Run(fmt.Sprint(source), func(t *testing.T) {
1152			m := cascadeOverview()
1153			w := m.Current()
1154			m.ToggleOverview()
1155			m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
1156			if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
1157				t.Fatalf("vertical: workspace %d, band %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), w.band(w.Focus), m.ov.scrollX, m.ov.scrollY)
1158			}
1159
1160			m = cascadeOverview()
1161			w = m.Current()
1162			w.FocusID(7)
1163			m.ToggleOverview()
1164			m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
1165			if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
1166				t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
1167			}
1168
1169			// Onto a workspace with another axis: one step, no bounce.
1170			m = overviewMonitor()
1171			m.SetOverflow(OverflowFixed)
1172			m.Workspaces[1].Overflow = OverflowCascade
1173			m.ToggleOverview()
1174			m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
1175			if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
1176				t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
1177			}
1178		})
1179	}
1180}

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 23

Tags 10

↑↓ moveenter openesc close