feat/hints a471fa4

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

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