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

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