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// T1: 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 fills its band in the preview as on screen, and hides
79// only the other columns of its band.
80func TestOverviewCascadeMaximizedColumnFillsBand(t *testing.T) {
81	// Four columns, three per band: band 0 holds 0..2, band 1 holds 3.
82	for _, tc := range []struct {
83		n, max int
84		hidden []bool
85	}{
86		{n: 1, max: 0, hidden: []bool{false}},
87		{n: 4, max: 0, hidden: []bool{false, true, true, false}},
88		{n: 4, max: 3, hidden: []bool{false, false, false, false}},
89	} {
90		w := cascadeWorkspace(tc.n)
91		w.Focus = tc.max
92		w.maximize(tc.max)
93		tiles, _, _ := w.previewTiles()
94		g, h := w.gap(), w.Usable.H-2*w.gap()
95		for i, p := range tiles {
96			if p.Hidden != tc.hidden[i] {
97				t.Fatalf("n=%d max=%d column %d hidden %v, want %v", tc.n, tc.max, i, p.Hidden, tc.hidden[i])
98			}
99			if p.Hidden {
100				continue
101			}
102			band := i / 3
103			want := Rect{X: w.cellX(i), Y: g + band*w.Usable.H, W: w.cellWidth(), H: h}
104			if i == tc.max {
105				want.X, want.W = g, w.Usable.W-2*g
106			}
107			if p.Rect != want {
108				t.Fatalf("n=%d max=%d column %d rect %+v, want %+v", tc.n, tc.max, i, p.Rect, want)
109			}
110		}
111	}
112}
113
114// T2: the selected band is the centre lane: undimmed, centred, selected tile
115// focused.
116func TestOverviewCascadeCentreLane(t *testing.T) {
117	m := cascadeOverview()
118	m.ToggleOverview()
119	ps := m.Layout()
120	u := m.Current().overviewArea()
121	for _, id := range []WindowID{7, 8, 9} {
122		p := previewOf(t, ps, id)
123		if p.Hidden || p.Peek || p.Preview != overviewMaxZoom || p.Focused != (id == 8) {
124			t.Fatalf("band tile %d: %+v", id, p)
125		}
126		if !within1(centreY(p.Rect), u.Y+u.H/2) {
127			t.Fatalf("band tile %d centre %d, area centre %d", id, centreY(p.Rect), u.Y+u.H/2)
128		}
129	}
130}
131
132// T3: the other bands are dimmed lanes a screen (and a gap) away; those
133// outside the area are hidden.
134func TestOverviewCascadeOtherLanes(t *testing.T) {
135	m := cascadeOverview()
136	m.ToggleOverview()
137	ps := m.Layout()
138	sel := previewOf(t, ps, 8).Rect
139	above, below := previewOf(t, ps, 5), previewOf(t, ps, 10)
140	if above.Hidden || !above.Peek || above.Focused || below.Hidden || !below.Peek || below.Focused {
141		t.Fatalf("adjacent bands: above %+v, below %+v", above, below)
142	}
143	// The lanes sit evenly around the selected one, apart from it (a gap),
144	// and the area still shows part of each.
145	up, down := sel.Y-above.Rect.Y, below.Rect.Y-sel.Y
146	if !within1(up, down) {
147		t.Fatalf("lanes not evenly spaced: %d above, %d below", up, down)
148	}
149	if gap := up - sel.H; gap <= 0 {
150		t.Fatalf("lane above overlaps the selected one: %v over %v", above.Rect, sel)
151	}
152	u := m.Current().overviewArea()
153	if above.Rect.Y+above.Rect.H <= u.Y || below.Rect.Y >= u.Y+u.H {
154		t.Fatalf("adjacent lanes not visible in %v: %v, %v", u, above.Rect, below.Rect)
155	}
156
157	// Five bands (13 columns): the ones two lanes away are off the area.
158	m = cascadeOverview()
159	w := m.Current()
160	for id := WindowID(11); id <= 16; id++ {
161		w.AddWindow(id)
162	}
163	w.FocusID(8)
164	m.ToggleOverview()
165	// Columns 4..16: bands {4,5,6} {7,8,9} {10,11,12} {13,14,15} {16}.
166	w.FocusID(11)
167	ps = m.Layout()
168	for _, id := range []WindowID{4, 5, 6, 16} {
169		if p := previewOf(t, ps, id); !hiddenOff(p) {
170			t.Fatalf("band two lanes away %d not hidden: %+v", id, p)
171		}
172	}
173	for _, id := range []WindowID{7, 13} {
174		if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
175			t.Fatalf("adjacent band %d: %+v", id, p)
176		}
177	}
178}
179
180// T4: neighbor workspaces sit left and right of the current slot, dimmed,
181// their selected lane level with the centre lane.
182func TestOverviewCascadeNeighbors(t *testing.T) {
183	m := cascadeOverview()
184	m.ToggleOverview()
185	rows, _ := m.overviewRows()
186	cur, left, right := rows[m.Workspaces[1]], rows[m.Workspaces[0]], rows[m.Workspaces[2]]
187	if left.X+left.W > cur.X || right.X < cur.X+cur.W {
188		t.Fatalf("slots overlap: %v %v %v", left, cur, right)
189	}
190	ps := m.Layout()
191	centre := centreY(previewOf(t, ps, 8).Rect)
192	shown := 0
193	for _, n := range []struct {
194		id   WindowID
195		side int
196	}{{1, -1}, {2, -1}, {3, -1}, {30, 1}} {
197		id, side := n.id, n.side
198		p := previewOf(t, ps, id)
199		if p.Hidden {
200			continue
201		}
202		shown++
203		if !p.Peek || p.Focused || p.Preview == 0 {
204			t.Fatalf("neighbor %d not dimmed: %+v", id, p)
205		}
206		if side < 0 && p.Rect.X+p.Rect.W > cur.X || side > 0 && p.Rect.X < cur.X+cur.W {
207			t.Fatalf("neighbor %d overlaps the current slot %v: %+v", id, cur, p.Rect)
208		}
209		if !within1(centreY(p.Rect), centre) {
210			t.Fatalf("neighbor %d centre %d, centre lane %d", id, centreY(p.Rect), centre)
211		}
212	}
213	if shown < 2 {
214		t.Fatalf("only %d neighbor tiles drawn", shown)
215	}
216	for _, p := range ps {
217		if p.Hidden || p.Peek {
218			continue
219		}
220		if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > cur.X+cur.W {
221			t.Fatalf("current tile %d outside its slot %v: %+v", p.ID, cur, p.Rect)
222		}
223	}
224}
225
226// T5: lanes along the overview axis would overlap the other workspaces: a
227// cascade neighbor of a scroll workspace shows its selected band only.
228func TestOverviewCascadeNeighborBelowScrollShowsOneBand(t *testing.T) {
229	m := cascadeOverview()
230	m.Focus(0)
231	m.ToggleOverview()
232	if m.overviewAxis() != verticalAxis {
233		t.Fatal("setup: scroll overview is not vertical")
234	}
235	ps := m.Layout()
236	for _, id := range []WindowID{4, 5, 6} {
237		if p := previewOf(t, ps, id); !hiddenOff(p) {
238			t.Fatalf("band %d of the neighbor drawn: %+v", id, p)
239		}
240	}
241	if p := previewOf(t, ps, 8); p.Hidden {
242		// The neighbor's selected column is 8.
243		t.Fatalf("selected band hidden: %+v", p)
244	}
245	for _, id := range []WindowID{7, 9} {
246		if p := previewOf(t, ps, id); p.Hidden || !p.Peek {
247			t.Fatalf("selected band tile %d: %+v", id, p)
248		}
249	}
250	if p := previewOf(t, ps, 10); !hiddenOff(p) {
251		t.Fatalf("band 3 drawn: %+v", p)
252	}
253}
254
255// T6: the stash pile is inside the current slot, left of the band; the
256// previous workspace stays clear of it.
257func TestOverviewCascadeStashInSlot(t *testing.T) {
258	m := cascadeOverview()
259	withStash(m, 2)
260	m.ToggleOverview()
261	rows, _ := m.overviewRows()
262	cur, prev := rows[m.Current()], rows[m.Workspaces[0]]
263	ps := m.Layout()
264	left := cur.X + cur.W
265	for _, id := range []WindowID{7, 8, 9} {
266		left = min(left, previewOf(t, ps, id).Rect.X)
267	}
268	for _, id := range []WindowID{40, 41} {
269		p := previewOf(t, ps, id)
270		if p.Hidden || p.Preview != overviewCardZoom {
271			t.Fatalf("pile card %d: %+v", id, p)
272		}
273		if p.Rect.X < cur.X || p.Rect.X+p.Rect.W > left {
274			t.Fatalf("pile card %d not inside the slot, left of the band (%d..%d): %+v", id, cur.X, left, p.Rect)
275		}
276		// 41 is the front card of the pile.
277		if id == 41 && !within1(centreY(p.Rect), centreY(previewOf(t, ps, 8).Rect)) {
278			t.Fatalf("pile not level with the selected lane: %+v", p.Rect)
279		}
280	}
281	// The pile does not shift the band: its row stays centred on the area.
282	u := m.Current().overviewArea()
283	lo, hi := previewOf(t, ps, 7).Rect, previewOf(t, ps, 9).Rect
284	if mid := (lo.X + hi.X + hi.W) / 2; !within1(mid, u.X+u.W/2) {
285		t.Fatalf("band centre %d with a stash, area centre %d", mid, u.X+u.W/2)
286	}
287	for _, id := range []WindowID{1, 2, 3} {
288		if p := previewOf(t, ps, id); !p.Hidden && p.Rect.X+p.Rect.W > cur.X {
289			t.Fatalf("previous workspace overlaps the slot with the pile: %+v", p.Rect)
290		}
291	}
292	if prev.X+prev.W > cur.X {
293		t.Fatalf("slots overlap: %v %v", prev, cur)
294	}
295}
296
297type cascadeDriver struct {
298	name string
299	move func(m *Monitor, dx, dy int)
300}
301
302func keyDriver(left, right, up, down string) func(m *Monitor, dx, dy int) {
303	return func(m *Monitor, dx, dy int) {
304		name := map[[2]int]string{{-1, 0}: left, {1, 0}: right, {0, -1}: up, {0, 1}: down}[[2]int{dx, dy}]
305		m.overviewKey(ports.KeyEvent{Keysym: name, Pressed: true})
306	}
307}
308
309var cascadeDrivers = []cascadeDriver{
310	{"move", func(m *Monitor, dx, dy int) { m.OverviewMove(dx, dy) }},
311	{"hjkl", keyDriver("h", "l", "k", "j")},
312	{"arrows", keyDriver("Left", "Right", "Up", "Down")},
313}
314
315func focusedID(m *Monitor) WindowID {
316	id, _ := m.Focused()
317	return id
318}
319
320// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
321// for the stash or the neighbor workspaces; Escape restores everything.
322func TestOverviewCascadeKeys(t *testing.T) {
323	for _, d := range cascadeDrivers {
324		t.Run(d.name, func(t *testing.T) {
325			m := cascadeOverview()
326			w, prev, next := m.Current(), m.Workspaces[0], m.Workspaces[2]
327			w.FocusID(5)
328			m.ToggleOverview()
329			sel := func() WindowID { return w.Columns[w.Focus].Windows[0] }
330			d.move(m, 0, 1)
331			if sel() != 8 {
332				t.Fatalf("down: %d, want 8", sel())
333			}
334			d.move(m, 0, 1)
335			d.move(m, 0, 1)
336			if m.Current() != w || sel() != 10 {
337				t.Fatalf("down at the last band: %d on the current workspace %v", sel(), m.Current() == w)
338			}
339			d.move(m, 0, -1)
340			if sel() != 7 {
341				t.Fatalf("up: %d, want 7", sel())
342			}
343			d.move(m, 0, -1)
344			d.move(m, 0, -1)
345			if m.Current() != w || sel() != 4 {
346				t.Fatalf("up at the first band: %d", sel())
347			}
348			d.move(m, 1, 0)
349			d.move(m, 1, 0)
350			if sel() != 6 || m.Current() != w {
351				t.Fatalf("right in the band: %d", sel())
352			}
353			d.move(m, 1, 0)
354			if m.Current() != next {
355				t.Fatal("right past the last column did not reach the next workspace")
356			}
357			m.CancelOverview()
358			if m.Current() != w || focusedID(m) != 5 {
359				t.Fatalf("cancel left %d", focusedID(m))
360			}
361
362			m.ToggleOverview()
363			d.move(m, -1, 0)
364			if sel() != 4 || m.Current() != w {
365				t.Fatal("left within the band")
366			}
367			d.move(m, -1, 0)
368			if m.Current() != prev {
369				t.Fatal("left from the first column did not reach the previous workspace")
370			}
371			m.CancelOverview()
372
373			// With a stash: left from the first column enters it, right
374			// comes back to the band's first column, left twice goes to
375			// the previous workspace.
376			withStash(m, 2)
377			w.FocusID(7)
378			m.ToggleOverview()
379			d.move(m, -1, 0)
380			if m.cardAt(w) < 0 {
381				t.Fatal("left from the first column did not select the stash")
382			}
383			d.move(m, 1, 0)
384			if m.cardAt(w) >= 0 || sel() != 7 {
385				t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
386			}
387			d.move(m, -1, 0)
388			d.move(m, -1, 0)
389			if m.Current() != prev {
390				t.Fatal("left from the stash did not reach the previous workspace")
391			}
392			m.CancelOverview()
393			if m.Current() != w || focusedID(m) != 7 {
394				t.Fatalf("cancel left %d", focusedID(m))
395			}
396		})
397	}
398}
399
400// T7b: with a stash card selected, up/down and the three-finger vertical
401// swipe cycle the pile as on main; confirming shows the picked window.
402func TestOverviewCascadeStashPile(t *testing.T) {
403	for _, d := range cascadeDrivers {
404		t.Run(d.name, func(t *testing.T) {
405			m := cascadeOverview()
406			withStash(m, 3)
407			w := m.Current()
408			w.FocusID(7)
409			m.ToggleOverview()
410			d.move(m, -1, 0)
411			if m.cardAt(w) != w.stashAt {
412				t.Fatalf("entered at %d", m.cardAt(w))
413			}
414			at := m.cardAt(w)
415			d.move(m, 0, -1)
416			if m.Current() != w || m.cardAt(w) != at-1 {
417				t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
418			}
419			d.move(m, 0, 1)
420			d.move(m, 0, 1)
421			d.move(m, 0, 1)
422			if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
423				t.Fatalf("down: card %d", m.cardAt(w))
424			}
425			d.move(m, 0, -1)
426			picked := w.Stash[m.cardAt(w)].ID
427			m.ToggleOverview()
428			if focusedID(m) != picked || w.stashHidden {
429				t.Fatalf("confirm on %d, want %d (hidden %v)", focusedID(m), picked, w.stashHidden)
430			}
431		})
432	}
433
434	c, _, sc, _ := stashRig(t, true)
435	m := sc.mon
436	w := m.Current()
437	w.Overflow = OverflowCascade
438	m.Focus(1)
439	m.AddWindow(50)
440	m.Focus(0)
441	m.ToggleOverview()
442	if m.cardAt(w) < 0 {
443		t.Fatal("setup: no card selected")
444	}
445	at, before := m.cardAt(w), stashIDs(w)
446	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
447	for range 3 {
448		c.swipeUpdate(ports.SwipeUpdate{DY: -40})
449	}
450	c.swipeEnd(ports.SwipeEnd{})
451	if m.Current() != w || m.cardAt(w) != at-1 {
452		t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
453	}
454	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
455	for range 3 {
456		c.swipeUpdate(ports.SwipeUpdate{DX: 40})
457	}
458	c.swipeEnd(ports.SwipeEnd{})
459	// A horizontal three-finger swipe is a workspace step, even over a
460	// stash card.
461	if m.Current() != m.Workspaces[1] {
462		t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
463	}
464	if got := stashIDs(w); !slices.Equal(got, before) {
465		t.Fatal("stash reordered")
466	}
467}
468
469// T7c: three fingers follow the physical axes, once per gesture.
470func TestOverviewCascadeThreeFinger(t *testing.T) {
471	c, m := cascadeCore(t)
472	w := m.Current()
473	m.ToggleOverview()
474	swipe := func(dx, dy float64) {
475		t.Helper()
476		c.swipeBegin(ports.SwipeBegin{Fingers: 3})
477		for range 4 {
478			c.swipeUpdate(ports.SwipeUpdate{DX: dx, DY: dy})
479		}
480		c.swipeEnd(ports.SwipeEnd{})
481	}
482	swipe(0, 40)
483	if m.Current() != w || w.band(w.Focus) != 1 {
484		t.Fatalf("one swipe down: band %d", w.band(w.Focus))
485	}
486	swipe(0, -40)
487	if w.band(w.Focus) != 0 {
488		t.Fatalf("one swipe up: band %d", w.band(w.Focus))
489	}
490	swipe(40, 0)
491	if m.Current() != m.Workspaces[2] {
492		t.Fatalf("one swipe right: workspace %d", indexOf(m.Workspaces, m.Current()))
493	}
494	// Onto a scroll workspace and back: the vertical swipe is the scroll
495	// overview's workspace step; neither bounces during the gesture.
496	swipe(0, -40)
497	if m.Current() != w {
498		t.Fatalf("swipe up from the scroll workspace: workspace %d", indexOf(m.Workspaces, m.Current()))
499	}
500	swipe(-40, 0)
501	if m.Current() != m.Workspaces[0] {
502		t.Fatalf("one swipe left: workspace %d", indexOf(m.Workspaces, m.Current()))
503	}
504}
505
506// T8: a named boundary of a cascade overview is a vertical rule strictly
507// between the two slots.
508func TestOverviewCascadeNamedDivider(t *testing.T) {
509	m := namedOverviewMonitor()
510	m.Workspaces[1].Overflow = OverflowCascade
511	m.ToggleOverview()
512	rows, dividers := m.overviewRows()
513	if len(dividers) != 1 {
514		t.Fatalf("dividers: %+v", dividers)
515	}
516	d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
517	if d.W != 1 || d.H <= 1 || dividers[0].Active {
518		t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
519	}
520	if d.X < cur.X+cur.W || d.X+d.W > dev.X {
521		t.Fatalf("divider %v not between %v and %v", d, cur, dev)
522	}
523}
524
525// T10: covering floats fan along the horizontal axis: left goes deeper in the
526// stack (the cards behind show on the left), right comes back to the front.
527func TestOverviewCascadeCoveringFloat(t *testing.T) {
528	for _, d := range cascadeDrivers {
529		t.Run(d.name, func(t *testing.T) {
530			m := cascadeOverview()
531			m.Current().AddFloating(90, 300, 200)
532			w := m.Current()
533			w.FocusID(10)
534			m.ToggleOverview()
535			if m.stackFront(w).kind != stackColumns {
536				t.Fatal("setup: columns not in front")
537			}
538			d.move(m, 0, -1) // columns: up is a band
539			d.move(m, -1, 0)
540			if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
541				t.Fatalf("left from the first column: front %+v", f)
542			}
543			if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
544				t.Fatalf("float not in front: %+v", p)
545			}
546			d.move(m, 0, 1) // a float front takes no band move
547			if f := m.stackFront(w); f.kind != stackFloat {
548				t.Fatalf("down left the float: %+v", f)
549			}
550			d.move(m, 1, 0)
551			if m.stackFront(w).kind != stackColumns || m.Current() != w {
552				t.Fatalf("right from the float: front %+v", m.stackFront(w))
553			}
554
555			// The float in front of the columns: they fan to its left.
556			m.CancelOverview()
557			w.FocusID(90)
558			m.ToggleOverview()
559			if f := m.stackFront(w); f.kind != stackFloat {
560				t.Fatalf("setup: front %+v", f)
561			}
562			behind := previewOf(t, m.Layout(), 10)
563			if !behind.Peek {
564				t.Fatalf("columns not behind the float: %+v", behind)
565			}
566			d.move(m, -1, 0)
567			if m.stackFront(w).kind != stackColumns || m.Current() != w {
568				t.Fatalf("left from the front float: front %+v", m.stackFront(w))
569			}
570			// The cards behind fan to the left: coming to the front, the
571			// columns move right by one peek step.
572			front := previewOf(t, m.Layout(), 10)
573			if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
574				t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
575			}
576			d.move(m, 1, 0)
577			if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
578				t.Fatalf("right from the columns behind the float: front %+v", f)
579			}
580		})
581	}
582}
583
584// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
585func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
586	m := cascadeOverview()
587	w := m.Current()
588	w.FocusID(8)
589	m.ToggleFullscreen()
590	if !w.pinned() {
591		t.Fatal("setup: not pinned")
592	}
593	m.ToggleOverview()
594	ps := m.Layout()
595	fs := previewOf(t, ps, 8)
596	if !fs.Fullscreen || fs.Hidden {
597		t.Fatalf("fullscreen tile: %+v", fs)
598	}
599	for _, id := range []WindowID{7, 9} {
600		if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
601			t.Fatalf("band tile %d: %+v", id, p)
602		}
603	}
604	u := w.overviewArea()
605	if !within1(centreY(fs.Rect), u.Y+u.H/2) {
606		t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
607	}
608}
609
610// T11: the focus binds keep their meaning in a cascade overview.
611func TestOverviewCascadeFocusBinds(t *testing.T) {
612	m := cascadeOverview()
613	w := m.Current()
614	m.ToggleOverview()
615	if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
616		t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
617	}
618	if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
619		t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
620	}
621	m.overviewFocus(ActionFocusWorkspaceNext)
622	if m.Current() != m.Workspaces[2] {
623		t.Fatal("focus-workspace-next did not change workspace")
624	}
625	m.overviewFocus(ActionFocusWorkspacePrev)
626	if m.Current() != w {
627		t.Fatal("focus-workspace-prev did not come back")
628	}
629	m.overviewFocus(ActionFocusWorkspacePrev)
630	if m.Current() != m.Workspaces[0] {
631		t.Fatal("focus-workspace-prev did not change workspace")
632	}
633	m.CancelOverview()
634
635	// A selected stash card keeps the pile cycle.
636	withStash(m, 3)
637	w.FocusID(7)
638	m.ToggleOverview()
639	m.OverviewMove(-1, 0)
640	at := m.cardAt(w)
641	m.overviewFocus(ActionFocusWorkspacePrev)
642	if m.Current() != w || m.cardAt(w) != at-1 {
643		t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
644	}
645	m.overviewFocus(ActionFocusWorkspaceNext)
646	if m.Current() != w || m.cardAt(w) != at {
647		t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
648	}
649}
650
651func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
652	m := overviewMonitor()
653	m.SetOverflow(OverflowFixed)
654	m.Current().Overflow = OverflowCascade
655	from := m.Current()
656	m.ToggleOverview()
657	rows, _ := m.overviewRows()
658	if rows[m.Workspaces[1]].X <= rows[from].X {
659		t.Fatal("cascade neighbor not horizontal")
660	}
661	m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
662	if m.Current() == from {
663		t.Fatal("horizontal gesture did not change workspace")
664	}
665	if m.Current().Overflow != OverflowFixed {
666		t.Fatal("destination lost fixed policy")
667	}
668	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
669		t.Fatalf("scroll state after the axis change: %+v", m.ov)
670	}
671	m.CancelOverview()
672	if m.Current() != from {
673		t.Fatal("cancel lost source workspace")
674	}
675}
676
677// Showing a workspace with another overview axis drops the scroll leftovers
678// of the old one.
679func TestOverviewAxisChangeResetsScroll(t *testing.T) {
680	m := cascadeOverview()
681	m.ToggleOverview()
682	m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
683	m.showOverview(m.Workspaces[2])
684	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
685		t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
686	}
687	m.ov.scrollX = 20
688	m.showOverview(m.Workspaces[0])
689	if m.ov.scrollX != 20 {
690		t.Fatal("scroll reset between workspaces of the same axis")
691	}
692}
693
694// A reload that changes the current workspace's axes drops the scroll state;
695// one that does not, keeps it.
696func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
697	c, _ := indicatorCore(t)
698	m := c.cur().mon
699	m.AddWindow(1)
700	m.ToggleOverview()
701	w := m.Current()
702	set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
703	set()
704	cfg := c.cfg
705	cfg.Layout.Gaps++
706	if err := c.apply(cfg); err != nil {
707		t.Fatal(err)
708	}
709	if m.ov.scrollX != 20 || !m.ov.scrolled {
710		t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
711	}
712	cfg.Layout.Overflow = string(OverflowCascade)
713	if err := c.apply(cfg); err != nil {
714		t.Fatal(err)
715	}
716	if m.Current().policy().workspace != horizontalAxis {
717		t.Fatal("setup: not cascade")
718	}
719	// The reload keeps the overview open on its selection, and cancelling
720	// still gives the original focus back.
721	if id, _ := w.Focused(); !m.ov.open || id != 1 {
722		t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
723	}
724	// The scroll is dropped, but not the latch that waits for the fingers
725	// to lift.
726	if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
727		t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
728	}
729	set()
730	cfg.Layout.Overflow = string(OverflowFixed)
731	if err := c.apply(cfg); err != nil {
732		t.Fatal(err)
733	}
734	if m.ov.scrollX != 0 || !m.ov.scrolled {
735		t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
736	}
737	m.CancelOverview()
738	if id, _ := w.Focused(); id != 1 {
739		t.Fatalf("cancel left the focus on %d", id)
740	}
741}
742
743// The finger latch outlives an axis change: a gesture that turned the
744// overview, then was turned back by a key, does not step again.
745func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
746	c, m := cascadeCore(t)
747	w := m.Current()
748	m.ToggleOverview()
749	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
750	c.swipeUpdate(ports.SwipeUpdate{DX: 70})
751	if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
752		t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
753	}
754	// Back to the cascade workspace by a key, with the fingers still down.
755	m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
756	if m.Current() != w || m.overviewAxis() != horizontalAxis {
757		t.Fatal("setup: the key did not turn the overview back")
758	}
759	for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
760		c.swipeUpdate(u)
761	}
762	c.swipeEnd(ports.SwipeEnd{})
763	if m.Current() != w || w.Focus != 0 {
764		t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
765	}
766	// The next gesture steps again.
767	c.swipeBegin(ports.SwipeBegin{Fingers: 3})
768	c.swipeUpdate(ports.SwipeUpdate{DY: 70})
769	c.swipeEnd(ports.SwipeEnd{})
770	if w.band(w.Focus) != 1 {
771		t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
772	}
773}
774
775// A stash-only cascade workspace has no columns to select: right from its
776// pile goes straight to the next workspace.
777func TestOverviewCascadeStashOnlyRight(t *testing.T) {
778	m := overviewMonitor()
779	m.Focus(1)
780	w := m.Current()
781	w.Overflow = OverflowCascade
782	w.ToggleWindowStash()
783	m.Focus(2)
784	m.AddWindow(30)
785	m.Focus(1)
786	m.ToggleOverview()
787	if len(w.Columns) != 0 || m.cardAt(w) < 0 {
788		t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
789	}
790	m.OverviewMove(1, 0)
791	if m.Current() != m.Workspaces[2] {
792		t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
793	}
794	m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
795	if m.Current() != w || m.cardAt(w) < 0 {
796		t.Fatal("setup: back on the stash-only workspace without its card")
797	}
798	m.OverviewMove(-1, 0)
799	if m.Current() != m.Workspaces[0] {
800		t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
801	}
802}
803
804// A cascade neighbor beside a cascade workspace draws its other lanes
805// dimmed, its selected lane level with the centre lane.
806func TestOverviewCascadeNeighborLanes(t *testing.T) {
807	m := cascadeOverview()
808	prev := m.Workspaces[0]
809	prev.Overflow = OverflowCascade
810	for id := WindowID(60); id <= 65; id++ {
811		prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
812	}
813	m.ToggleOverview()
814	ps := m.Layout()
815	centre := centreY(previewOf(t, ps, 8).Rect)
816	sel := previewOf(t, ps, 65)
817	if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
818		t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
819	}
820	above := previewOf(t, ps, 62)
821	if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
822		t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
823	}
824	if p := previewOf(t, ps, 3); !hiddenOff(p) {
825		t.Fatalf("lane two away in the neighbor: %+v", p)
826	}
827}
828
829// A scroll neighbor wider than its slot scrolls inside it: what leaves the
830// slot is hidden, so it never sits on the current slot.
831func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
832	m := cascadeOverview()
833	prev := m.Workspaces[0]
834	for id := WindowID(60); id <= 67; id++ {
835		prev.AddWindow(id)
836	}
837	m.ToggleOverview()
838	rows, _ := m.overviewRows()
839	cur, slot := rows[m.Current()], rows[prev]
840	hidden, shown := 0, 0
841	for _, p := range m.Layout() {
842		if !prev.has(p.ID) {
843			continue
844		}
845		if p.Hidden {
846			hidden++
847			continue
848		}
849		shown++
850		if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
851			t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
852		}
853	}
854	if hidden == 0 || shown == 0 {
855		t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
856	}
857}
858
859// A wheel and a continuous scroll in a cascade overview: vertical changes
860// workspace (and does not bounce back on a workspace with another axis),
861// horizontal moves along the band.
862func TestOverviewCascadeScroll(t *testing.T) {
863	vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
864	for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
865		t.Run(fmt.Sprint(source), func(t *testing.T) {
866			m := cascadeOverview()
867			w := m.Current()
868			m.ToggleOverview()
869			m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
870			if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
871				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)
872			}
873
874			m = cascadeOverview()
875			w = m.Current()
876			w.FocusID(7)
877			m.ToggleOverview()
878			m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
879			if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
880				t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
881			}
882
883			// Onto a workspace with another axis: one step, no bounce.
884			m = overviewMonitor()
885			m.SetOverflow(OverflowFixed)
886			m.Workspaces[1].Overflow = OverflowCascade
887			m.ToggleOverview()
888			m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
889			if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
890				t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
891			}
892		})
893	}
894}

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