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}