detached 194a80b

feat(overview): present cascade bands as dimmed lanes

The overview is the scroll overview rotated by the current workspace's
layout policy. A cascade workspace centres its selected band at the normal
zoom, dims its other bands above and below (hidden off screen) and puts its
neighbors left and right, selected lane level, the stash pile left of the
band in its slot. Up/down change band, left/right walk the band then cross
to the stash or a neighbor workspace; three-finger swipes follow the
fingers. Scroll and fixed overviews are unchanged.
commit
194a80beb56a965c2ee32b53e641130dc42d2571
author
brice · · 2026-10-07 06:08 UTC
committer
brice · · 2026-10-07 07:12 UTC
parent
82d6128
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1410 -107
M internal/core/cascade.go +36 -10
@@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int {
2222 return min(b*k+w.Focus%k, len(w.Columns)-1)
2323 }
2424 func (w *Workspace) focusBand(dir int) bool {
25- i := w.bandNeighbor(dir)
25+ i := w.laneNeighbor(dir)
2626 if i < 0 {
2727 return false
2828 }
@@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool {
3636 w.scroll()
3737 return true
3838 }
39-func (w *Workspace) bandNeighbor(dir int) int {
40- k := max(w.MaxColumns, 1)
41- b := w.band(w.Focus) + dir
42- if b < 0 || b*k >= len(w.Columns) {
43- return -1
44- }
45- return w.cellInBand(b)
46-}
47-
4839 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
4940 g := w.gap()
5041 width := w.cellWidth()
@@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int {
7768 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
7869 return w.cellInBand(b)
7970 }
71+
72+// laneOf is the lane of column i in the overview: its band, or the only
73+// lane of a layout that does not wrap.
74+func (w *Workspace) laneOf(i int) int {
75+ if w.policy().wraps {
76+ return w.band(i)
77+ }
78+ return 0
79+}
80+
81+// laneBounds is the range of columns of a lane, first > last when the lane
82+// does not exist. A layout that does not wrap has the whole row as lane 0.
83+func (w *Workspace) laneBounds(lane int) (first, last int) {
84+ k := len(w.Columns)
85+ if w.policy().wraps {
86+ k = max(w.MaxColumns, 1)
87+ }
88+ first = lane * k
89+ if lane < 0 || first >= len(w.Columns) {
90+ return 0, -1
91+ }
92+ return first, min(first+k, len(w.Columns)) - 1
93+}
94+
95+// laneNeighbor is the closest column to the focus in the lane d lanes away,
96+// the focus's offset in its lane kept; -1 past the first or last lane.
97+func (w *Workspace) laneNeighbor(d int) int {
98+ lane := w.laneOf(w.Focus)
99+ first, _ := w.laneBounds(lane)
100+ nfirst, nlast := w.laneBounds(lane + d)
101+ if nfirst > nlast {
102+ return -1
103+ }
104+ return min(nfirst+w.Focus-first, nlast)
105+}
@@ -22,7 +22,7 @@ func (w *Workspace) cellInBand(b int) int {
22 return min(b*k+w.Focus%k, len(w.Columns)-1)22 return min(b*k+w.Focus%k, len(w.Columns)-1)
23 }23 }
24 func (w *Workspace) focusBand(dir int) bool {24 func (w *Workspace) focusBand(dir int) bool {
25- i := w.bandNeighbor(dir)25+ i := w.laneNeighbor(dir)
26 if i < 0 {26 if i < 0 {
27 return false27 return false
28 }28 }
@@ -36,15 +36,6 @@ func (w *Workspace) focusBand(dir int) bool {
36 w.scroll()36 w.scroll()
37 return true37 return true
38 }38 }
39-func (w *Workspace) bandNeighbor(dir int) int {
40- k := max(w.MaxColumns, 1)
41- b := w.band(w.Focus) + dir
42- if b < 0 || b*k >= len(w.Columns) {
43- return -1
44- }
45- return w.cellInBand(b)
46-}
47-
48 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {39 func (w *Workspace) cascadeRectsInto(dst []Rect, ignoreFullWidth bool) []Rect {
49 g := w.gap()40 g := w.gap()
50 width := w.cellWidth()41 width := w.cellWidth()
@@ -77,3 +68,38 @@ func (w *Workspace) cascadeFocus(view int) int {
77 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))68 b := min(max(view/max(w.Usable.H, 1), 0), w.band(len(w.Columns)-1))
78 return w.cellInBand(b)69 return w.cellInBand(b)
79 }70 }
71+
72+// laneOf is the lane of column i in the overview: its band, or the only
73+// lane of a layout that does not wrap.
74+func (w *Workspace) laneOf(i int) int {
75+ if w.policy().wraps {
76+ return w.band(i)
77+ }
78+ return 0
79+}
80+
81+// laneBounds is the range of columns of a lane, first > last when the lane
82+// does not exist. A layout that does not wrap has the whole row as lane 0.
83+func (w *Workspace) laneBounds(lane int) (first, last int) {
84+ k := len(w.Columns)
85+ if w.policy().wraps {
86+ k = max(w.MaxColumns, 1)
87+ }
88+ first = lane * k
89+ if lane < 0 || first >= len(w.Columns) {
90+ return 0, -1
91+ }
92+ return first, min(first+k, len(w.Columns)) - 1
93+}
94+
95+// laneNeighbor is the closest column to the focus in the lane d lanes away,
96+// the focus's offset in its lane kept; -1 past the first or last lane.
97+func (w *Workspace) laneNeighbor(d int) int {
98+ lane := w.laneOf(w.Focus)
99+ first, _ := w.laneBounds(lane)
100+ nfirst, nlast := w.laneBounds(lane + d)
101+ if nfirst > nlast {
102+ return -1
103+ }
104+ return min(nfirst+w.Focus-first, nlast)
105+}
M internal/core/cascade_test.go +56 -0
@@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) {
166166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167167 t.Fatalf("allocations=%v", n)
168168 }
169+ // The overview's lane-encoded tiles reuse their buffer too.
170+ tiles, _, _ := w.previewTiles()
171+ if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
172+ t.Fatalf("preview tile allocations=%v", n)
173+ }
174+}
175+
176+// Lanes are the bands of a cascade workspace and the whole row of any other.
177+func TestLaneGeometry(t *testing.T) {
178+ w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
179+ for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
180+ if got := w.laneOf(c.i); got != c.lane {
181+ t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
182+ }
183+ }
184+ for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
185+ if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
186+ t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
187+ }
188+ }
189+ for _, lane := range []int{-1, 3} {
190+ if first, last := w.laneBounds(lane); first <= last {
191+ t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
192+ }
193+ }
194+ // The closest column of the adjacent lane keeps its offset, clamped.
195+ for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
196+ w.Focus = c.focus
197+ if got := w.laneNeighbor(c.d); got != c.want {
198+ t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
199+ }
200+ }
201+
202+ s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
203+ s.SetOutput(900, 600)
204+ for i := 1; i <= 5; i++ {
205+ s.AddWindow(WindowID(i))
206+ }
207+ for i := range s.Columns {
208+ if s.laneOf(i) != 0 {
209+ t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
210+ }
211+ }
212+ if first, last := s.laneBounds(0); first != 0 || last != 4 {
213+ t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
214+ }
215+ for _, lane := range []int{-1, 1} {
216+ if first, last := s.laneBounds(lane); first <= last {
217+ t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
218+ }
219+ }
220+ for _, d := range []int{-1, 1} {
221+ if got := s.laneNeighbor(d); got != -1 {
222+ t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
223+ }
224+ }
169225 }
170226
171227 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
@@ -166,6 +166,62 @@ func TestCascadeGeometryReusesBuffer(t *testing.T) {
166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {166 if n := testing.AllocsPerRun(100, func() { buf = w.columnRectsInto(buf, false) }); n != 0 {
167 t.Fatalf("allocations=%v", n)167 t.Fatalf("allocations=%v", n)
168 }168 }
169+ // The overview's lane-encoded tiles reuse their buffer too.
170+ tiles, _, _ := w.previewTiles()
171+ if n := testing.AllocsPerRun(100, func() { tiles, _, _ = w.previewTilesInto(tiles[:0]) }); n != 0 {
172+ t.Fatalf("preview tile allocations=%v", n)
173+ }
174+}
175+
176+// Lanes are the bands of a cascade workspace and the whole row of any other.
177+func TestLaneGeometry(t *testing.T) {
178+ w := cascadeWorkspace(7) // bands {0,1,2} {3,4,5} {6}
179+ for _, c := range []struct{ i, lane int }{{0, 0}, {2, 0}, {3, 1}, {6, 2}} {
180+ if got := w.laneOf(c.i); got != c.lane {
181+ t.Fatalf("cascade laneOf(%d)=%d, want %d", c.i, got, c.lane)
182+ }
183+ }
184+ for lane, want := range [][2]int{{0, 2}, {3, 5}, {6, 6}} {
185+ if first, last := w.laneBounds(lane); first != want[0] || last != want[1] {
186+ t.Fatalf("cascade laneBounds(%d)=%d,%d, want %v", lane, first, last, want)
187+ }
188+ }
189+ for _, lane := range []int{-1, 3} {
190+ if first, last := w.laneBounds(lane); first <= last {
191+ t.Fatalf("cascade laneBounds(%d)=%d,%d exists", lane, first, last)
192+ }
193+ }
194+ // The closest column of the adjacent lane keeps its offset, clamped.
195+ for _, c := range []struct{ focus, d, want int }{{1, 1, 4}, {5, 1, 6}, {4, -1, 1}, {2, -1, -1}, {6, 1, -1}, {6, -1, 3}} {
196+ w.Focus = c.focus
197+ if got := w.laneNeighbor(c.d); got != c.want {
198+ t.Fatalf("cascade laneNeighbor from %d by %d = %d, want %d", c.focus, c.d, got, c.want)
199+ }
200+ }
201+
202+ s := &Workspace{Overflow: OverflowScroll, MaxColumns: 3}
203+ s.SetOutput(900, 600)
204+ for i := 1; i <= 5; i++ {
205+ s.AddWindow(WindowID(i))
206+ }
207+ for i := range s.Columns {
208+ if s.laneOf(i) != 0 {
209+ t.Fatalf("scroll laneOf(%d)=%d", i, s.laneOf(i))
210+ }
211+ }
212+ if first, last := s.laneBounds(0); first != 0 || last != 4 {
213+ t.Fatalf("scroll laneBounds(0)=%d,%d", first, last)
214+ }
215+ for _, lane := range []int{-1, 1} {
216+ if first, last := s.laneBounds(lane); first <= last {
217+ t.Fatalf("scroll laneBounds(%d)=%d,%d exists", lane, first, last)
218+ }
219+ }
220+ for _, d := range []int{-1, 1} {
221+ if got := s.laneNeighbor(d); got != -1 {
222+ t.Fatalf("scroll laneNeighbor(%d)=%d", d, got)
223+ }
224+ }
169 }225 }
170 226
171 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {227 func TestMixedLayoutSwitchKeepsSourceAxis(t *testing.T) {
M internal/core/core.go +11 -0
@@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error {
287287 if err != nil {
288288 return err
289289 }
290+ // Reload is cold-path work; unrelated changes keep a scroll or gesture.
291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292+ for _, s := range c.screens {
293+ axes[s.mon] = s.mon.Current().policy()
294+ }
290295 c.cfg = cfg
291296 c.binds = binds
292297 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
@@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error {
297302 c.settings(s.mon)
298303 }
299304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old, now := axes[s.mon], s.mon.Current().policy()
307+ if old.content != now.content || old.workspace != now.workspace {
308+ s.mon.ov.dropScroll()
309+ }
310+ }
300311 return nil
301312 }
302313
@@ -287,6 +287,11 @@ func (c *Core) apply(cfg ports.Config) error {
287 if err != nil {287 if err != nil {
288 return err288 return err
289 }289 }
290+ // Reload is cold-path work; unrelated changes keep a scroll or gesture.
291+ axes := make(map[*Monitor]layoutPolicy, len(c.screens))
292+ for _, s := range c.screens {
293+ axes[s.mon] = s.mon.Current().policy()
294+ }
290 c.cfg = cfg295 c.cfg = cfg
291 c.binds = binds296 c.binds = binds
292 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]297 c.cmdMod = map[string]ports.Mods{"super": ports.ModSuper, "alt": ports.ModAlt, "ctrl": ports.ModCtrl}[cfg.Keyboard.CmdKey]
@@ -297,6 +302,12 @@ func (c *Core) apply(cfg ports.Config) error {
297 c.settings(s.mon)302 c.settings(s.mon)
298 }303 }
299 c.applyOutputConfig()304 c.applyOutputConfig()
305+ for _, s := range c.screens {
306+ old, now := axes[s.mon], s.mon.Current().policy()
307+ if old.content != now.content || old.workspace != now.workspace {
308+ s.mon.ov.dropScroll()
309+ }
310+ }
300 return nil311 return nil
301 }312 }
302 313
M internal/core/gesture.go +1 -1
@@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
418418 }
419419 } else if p.wraps {
420420 // A content swipe cannot escape the last band to another workspace.
421- if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {
421+ if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
422422 return false
423423 }
424424 }
@@ -418,7 +418,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
418 }418 }
419 } else if p.wraps {419 } else if p.wraps {
420 // A content swipe cannot escape the last band to another workspace.420 // A content swipe cannot escape the last band to another workspace.
421- if w.onFloat() || w.fullscreen != 0 || w.bandNeighbor(step) < 0 {421+ if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
422 return false422 return false
423 }423 }
424 }424 }
M internal/core/overview.go +334 -53
@@ -31,6 +31,11 @@ type overviewState struct {
3131 scrolled, sideways bool
3232 }
3333
34+// dropScroll forgets the scroll an overview axis accumulated, which belongs
35+// to the old axis once it turns. The finger latch (scrolled) stays: it waits
36+// for the fingers to lift.
37+func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false }
38+
3439 // rowSnapshot is a row as the overview first showed it: browsing moves its
3540 // focus and scroll, closing puts them back.
3641 type rowSnapshot struct {
@@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353358 // a named group uses its invocation anchor as the toggle return target, not
354359 // rows merely browsed in the overview.
355360 func (m *Monitor) showOverview(w *Workspace) {
361+ if w.policy().workspace != m.overviewAxis() {
362+ m.ov.dropScroll()
363+ }
356364 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357365 m.back = m.Current()
358366 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -366,9 +374,113 @@ func (m *Monitor) showOverview(w *Workspace) {
366374 // OverviewMove shares navigation rules across keys, scrolls and swipes. It
367375 // is the physical entry point: it maps dx and dy to the overview's moves.
368376 func (m *Monitor) OverviewMove(dx, dy int) {
377+ if m.overviewAxis() == horizontalAxis {
378+ if dy != 0 {
379+ m.overviewLane(dy)
380+ } else if dx != 0 {
381+ m.overviewAcross(dx)
382+ }
383+ return
384+ }
369385 m.overviewMoveVertical(dx, dy)
370386 }
371387
388+// overviewWorkspaceStep is a focus-workspace bind. A vertical overview
389+// walks the stack cards before changing workspace (overviewVertical). A
390+// side-by-side one always changes workspace, since its stack fans left and
391+// right. A selected stash card cycles the pile first, in every layout.
392+func (m *Monitor) overviewWorkspaceStep(d int) {
393+ if m.overviewAxis() == verticalAxis {
394+ m.overviewVertical(d)
395+ return
396+ }
397+ if !m.cycleCard(m.Current(), d) {
398+ m.overviewStep(d)
399+ }
400+}
401+
402+// cycleCard brings the stash card d entries from the selected one of w to
403+// the front, stopping at either end. It reports whether a card was selected.
404+func (m *Monitor) cycleCard(w *Workspace, d int) bool {
405+ at := m.cardAt(w)
406+ if at < 0 {
407+ return false
408+ }
409+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
410+ return true
411+}
412+
413+// overviewLane is the vertical move of an overview whose workspaces sit side
414+// by side (cascade): a selected stash card cycles the pile; else the
415+// selection goes to the previous or next lane (band), at the closest column,
416+// stopping at the first and last. Covering floats stack along the horizontal
417+// axis, not this one.
418+func (m *Monitor) overviewLane(dy int) {
419+ w := m.Current()
420+ if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
421+ return
422+ }
423+ if i := w.laneNeighbor(dy); i >= 0 {
424+ m.selectLaneColumn(w, i)
425+ }
426+}
427+
428+// selectLaneColumn selects column i of w.
429+func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
430+ w.selectOverviewColumn(i)
431+ m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus]
432+ m.ov.selectedAt = i
433+}
434+
435+// overviewAcross is the horizontal move of an overview whose workspaces sit
436+// side by side (cascade): a column inside the selected lane. Past the
437+// lane's first column it goes to the stash, else to the previous workspace;
438+// past the last column, to the next workspace. Covering floats fan along
439+// this axis and are traversed before the stash or a workspace: the card on
440+// the left is the one behind.
441+func (m *Monitor) overviewAcross(dx int) {
442+ w := m.Current()
443+ if m.cardAt(w) >= 0 {
444+ if dx < 0 || len(w.stack()) == 0 {
445+ // Left leaves the pile; a stash-only workspace has nothing
446+ // to its right to select.
447+ m.overviewStep(dx)
448+ return
449+ }
450+ m.ov.card, m.ov.cardOf = 0, nil
451+ m.ov.selected = 0
452+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453+ first, _ := w.laneBounds(w.laneOf(w.Focus))
454+ m.selectLaneColumn(w, first)
455+ }
456+ return
457+ }
458+ if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
459+ m.laneEdge(w, dx)
460+ return
461+ }
462+ first, last := w.laneBounds(w.laneOf(w.Focus))
463+ if next := w.Focus + dx; next >= first && next <= last {
464+ m.selectLaneColumn(w, next)
465+ return
466+ }
467+ m.laneEdge(w, dx)
468+}
469+
470+// laneEdge is a horizontal move off the edge of the selected lane, or off a
471+// card that is not the columns: the stack first, then the stash on the left
472+// or a workspace.
473+func (m *Monitor) laneEdge(w *Workspace, dx int) {
474+ if m.moveStack(-dx) {
475+ return
476+ }
477+ if dx < 0 && len(w.Stash) > 0 {
478+ m.selectCard(w, w.stashAt)
479+ return
480+ }
481+ m.overviewStep(dx)
482+}
483+
372484 // overviewMoveVertical is the move of an overview whose workspaces stack
373485 // vertically. Vertical moves stay in a selected stash pile; left stays, and
374486 // right deselects it, even when the row has no main card.
@@ -381,9 +493,7 @@ func (m *Monitor) overviewMoveVertical(dx, dy int) {
381493 }
382494
383495 func (m *Monitor) overviewVertical(dy int) {
384- w := m.Current()
385- if at := m.cardAt(w); at >= 0 {
386- m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1))
496+ if m.cycleCard(m.Current(), dy) {
387497 return
388498 }
389499 if m.moveStack(-dy) {
@@ -402,6 +512,8 @@ func (m *Monitor) overviewStep(d int) {
402512 }
403513 }
404514
515+// overviewHorizontal is the horizontal move of an overview whose workspaces
516+// stack vertically: it never changes workspace.
405517 func (m *Monitor) overviewHorizontal(dx int) {
406518 w := m.Current()
407519 if m.cardAt(w) >= 0 {
@@ -500,21 +612,36 @@ func (m *Monitor) OverviewPick(id WindowID) {
500612 }
501613
502614 // overviewAxis is the axis the overview lays workspaces out along, and the
503-// stacked cards of a row fan along.
504-func (m *Monitor) overviewAxis() layoutAxis { return verticalAxis }
505-
506-// overviewRows centers the selected row with its neighbors above and below.
507-// Each shown workspace gets a slot, in output-local pixels, to place its
508-// previews in. The numbered/named boundary gets extra spacing and an
509-// inactive horizontal rule. Row widths, stash piles and preview scales
510-// remain unchanged.
615+// stacked cards of a row fan along: the navigation axis of the current
616+// workspace's layout.
617+func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
618+
619+// overviewRows places the current workspace and its overview neighbors in
620+// slots, in output-local pixels, along the overview axis; each workspace
621+// places its previews in its slot. The numbered/named boundary gets extra
622+// spacing and an inactive rule across the axis.
623+//
624+// Vertical axis: full-width slots, the current one centred; the rule is
625+// horizontal. Horizontal axis: full-height slots as wide as their content,
626+// stash pile and stack peeks included, the current one centred; the rule is
627+// vertical, in the spacing between two slots.
511628 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
512629 cur := m.Current()
513630 u := cur.overviewArea()
514631 gap := max(1, u.H*3/100)
515- hc := cur.rowHeight()
516- y := u.Y + (u.H-hc)/2
517- rows := map[*Workspace]Rect{cur: {X: u.X, Y: y, W: u.W, H: hc}}
632+ axis := m.overviewAxis()
633+ var cs Rect
634+ if axis == horizontalAxis {
635+ // The row stays centred on the area; the pile extends the slot to
636+ // the left, pushing the previous workspace, unless it would leave
637+ // the area.
638+ pile, row := cur.pileWidth(), cur.rowWidth()
639+ cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
640+ } else {
641+ hc := cur.rowHeight()
642+ cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
643+ }
644+ rows := map[*Workspace]Rect{cur: cs}
518645 var dividers []ports.Separator
519646 list := m.overviewWorkspaces()
520647 i := indexOf(list, cur)
@@ -528,23 +655,53 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
528655 if boundary {
529656 spacing *= 2
530657 }
531- top, h := y+hc, w.rowHeight()
532- if next < i {
533- rows[w] = Rect{X: u.X, Y: y - spacing - h, W: u.W, H: h}
534- top = y - spacing
535- } else {
536- rows[w] = Rect{X: u.X, Y: y + hc + spacing, W: u.W, H: h}
537- }
658+ slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
659+ rows[w] = slot
538660 if boundary {
539- margin := u.W * 4 / 100
540- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})
661+ dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))
541662 }
542663 }
543664 return rows, dividers
544665 }
545666
667+// neighborSlot is the slot of w, the neighbor before or after the current
668+// slot cs along axis, spacing away; gapAt is where the spacing begins.
669+func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
670+ if axis == horizontalAxis {
671+ slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}
672+ if before {
673+ gapAt = cs.X - spacing
674+ slot.X = gapAt - slot.W
675+ } else {
676+ gapAt = cs.X + cs.W
677+ slot.X = gapAt + spacing
678+ }
679+ return slot, gapAt
680+ }
681+ h := w.rowHeight()
682+ slot = Rect{X: u.X, W: u.W, H: h}
683+ if before {
684+ gapAt = cs.Y - spacing
685+ slot.Y = gapAt - h
686+ } else {
687+ gapAt = cs.Y + cs.H
688+ slot.Y = gapAt + spacing
689+ }
690+ return slot, gapAt
691+}
692+
693+// overviewDivider is the inactive rule across axis at pos, inset from the
694+// edges of u.
695+func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator {
696+ if axis == horizontalAxis {
697+ return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}}
698+ }
699+ margin := u.W * 4 / 100
700+ return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}}
701+}
702+
546703 // overviewLayout places the current workspace and its overview neighbors,
547-// each with its full-width stash pile and covering-float stack. Other rows
704+// each with its stash pile and covering-float stack. Other rows
548705 // stay hidden; numbered and named workspaces share the same preview geometry.
549706 func (m *Monitor) overviewLayout(result []Placement) []Placement {
550707 cur := m.Current()
@@ -556,7 +713,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
556713 at := m.cardAt(w)
557714 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
558715 if len(w.Stash) > 0 {
559- result = append(result, w.pile(w.frontSlot(axis, slot), w != cur, at)...)
716+ result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)
560717 }
561718 }
562719 for _, f := range w.Floats {
@@ -658,8 +815,8 @@ func (w *Workspace) cardStep() int {
658815 return min(overviewCardStep, a.W/100, a.H/50)
659816 }
660817
661-// pile places the stash of w as cards on the left of band, the front band
662-// of its row (see frontSlot): entry front (the stash selection when -1) in
818+// pile places the stash of w as cards on the left of band (see pileBand):
819+// entry front (the stash selection when -1) in
663820 // front, the next ones behind it, dimmed, the rest hidden. Only a front card
664821 // of the current row (dim false) with front set is selected.
665822 func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
@@ -693,19 +850,48 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
693850 // overviewZoom is the scale that fits every column of w in the usable
694851 // width, within the overview bounds.
695852 func (w *Workspace) overviewZoom() float64 {
853+ z, _ := w.zoomSpan()
854+ return z
855+}
856+
857+// zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
858+// cascade workspace spans one screen whatever its band count.
859+func (w *Workspace) zoomSpan() (float64, int) {
696860 // Only the span is read: the tiles go in the zoom scratch, dead on return.
697861 var span int
698862 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
699863 if span <= 0 {
700- return overviewMaxZoom
864+ return overviewMaxZoom, span
701865 }
702866 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
703867 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
704- if len(w.stack()) > 1 {
868+ if w.stacked() {
705869 // Reserve two clear peeks on either side of the front row.
706870 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
707871 }
708- return z
872+ return z, span
873+}
874+
875+// slotWidth is the width of the slot of w in a horizontal overview: its
876+// stash pile and its row.
877+func (w *Workspace) slotWidth() int {
878+ return w.pileWidth() + w.rowWidth()
879+}
880+
881+// rowWidth is the width of the row of w in a horizontal overview: what its
882+// tiles take (at most what the pile leaves of the area), plus the room for
883+// the covering floats peeking on either side of the front card.
884+func (w *Workspace) rowWidth() int {
885+ z, span := w.zoomSpan()
886+ if span <= 0 {
887+ // An empty workspace keeps the room of a screen.
888+ span = w.Usable.W
889+ }
890+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())
891+ if w.stacked() {
892+ width += 4 * w.peekStep()
893+ }
894+ return width
709895 }
710896
711897 // peekStep is the shared step for a stack row and its front-card inset.
@@ -714,20 +900,37 @@ func (w *Workspace) peekStep() int {
714900 }
715901
716902 // frontSlot is the part of slot the front card of w takes: slot shifted
717-// along axis past the room reserved for two cards peeking before it. A
718-// single-card row keeps slot.
903+// along axis past the room reserved for two cards peeking before it, and
904+// (along a horizontal axis, where the slot is as wide as its cards) without
905+// the room of the two after it. A single-card row keeps slot.
719906 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
720- if len(w.stack()) > 1 {
721- axis.offset(&slot, 2*w.peekStep())
907+ if w.stacked() {
908+ p := w.peekStep()
909+ axis.offset(&slot, 2*p)
910+ if axis == horizontalAxis {
911+ slot.W -= 4 * p
912+ }
722913 }
723914 return slot
724915 }
725916
917+// pileBand is the band the stash pile of w aligns with in its slot. A
918+// vertical overview aligns it with the front card (see frontSlot). A
919+// horizontal one puts it level with the selected lane, left of the whole
920+// stack of cards.
921+func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
922+ if axis == verticalAxis {
923+ return w.frontSlot(axis, slot)
924+ }
925+ slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
926+ return slot
927+}
928+
726929 // rowHeight includes the largest possible stack envelope (two peeks above
727930 // and two below). Neighbor rows use the same measure as the current row.
728931 func (w *Workspace) rowHeight() int {
729932 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
730- if len(w.stack()) > 1 {
933+ if w.stacked() {
731934 h += 4 * w.peekStep()
732935 }
733936 return h
@@ -771,9 +974,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
771974 for i, c := range w.Columns {
772975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
773976 if w.policy().wraps {
774- width := w.cellWidth()
775- r.X = w.band(i)*w.Usable.W + w.cellX(i)
776- r.W = width
977+ // The band is the lane: it is encoded in Y, one screen apart.
978+ r.X = w.cellX(i)
979+ r.Y = g + w.band(i)*w.Usable.H
980+ r.W = w.cellWidth()
777981 } else if w.Overflow == OverflowFixed {
778982 r = rects[i]
779983 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
@@ -794,13 +998,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
794998 for j, t := range w.rowBuf {
795999 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
7961000 }
797- if w.Overflow != OverflowFixed {
1001+ if w.Overflow != OverflowFixed && !w.policy().wraps {
7981002 span = max(span, r.X+r.W+g)
7991003 }
8001004 if i == w.Focus {
8011005 sel = r
8021006 }
8031007 }
1008+ if w.policy().wraps && len(w.Columns) > 0 {
1009+ span = w.Usable.W
1010+ }
8041011 return tiles, span, sel
8051012 }
8061013
@@ -809,17 +1016,30 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
8091016 // row is wider, right of the stash pile. Dimmed rows (neighbor
8101017 // workspaces) have no focus; neither has a row whose pile has it (lit
8111018 // false).
812-func (w *Workspace) previewRow(slot Rect, dim, lit bool) []Placement {
1019+func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement {
8131020 tiles, span, sel := w.previewTiles()
814- return w.previewRowTiles(slot, dim, lit, tiles, span, sel)
1021+ return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel)
8151022 }
8161023
8171024 // previewRowTiles scales tiles, laid out unscrolled over span with sel the
818-// focused one, into slot. Placement stays inside slot.
819-func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {
1025+// focused one, into slot of an overview along axis. Placement stays inside
1026+// slot.
1027+//
1028+// With lanes set (the tiles of a cascade workspace), the tiles carry their
1029+// band in Y, one Usable.H apart. The band of sel is the selected lane,
1030+// centred across the axis; the other bands sit a lane away from it, dimmed,
1031+// and are hidden when they leave the area. A workspace whose lanes run along
1032+// the overview axis shows only its selected lane. In a horizontal overview,
1033+// tiles entirely outside the area are hidden.
1034+func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
8201035 z := w.overviewZoom()
8211036 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
8221037 width := scale(span)
1038+ u := w.overviewArea()
1039+ top := slot.Y
1040+ if axis == horizontalAxis {
1041+ top = slot.Y + (slot.H-scale(w.Usable.H))/2
1042+ }
8231043 // Rows stay centred on the output like rows without a stash; the pile
8241044 // only pushes a row right when they would overlap.
8251045 left := w.pileWidth()
@@ -829,18 +1049,41 @@ func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement,
8291049 x = ux + uw/2 - scale(sel.X+sel.W/2)
8301050 x = min(max(x, ux+uw-width), ux)
8311051 }
1052+ stride := w.policy().content.span(w.Usable)
1053+ lanes = lanes && stride > 0
1054+ // Lanes along the overview axis would overlap the neighbor workspaces.
1055+ otherLanes := w.policy().content != axis
1056+ laneStep := scale(stride) + max(1, u.H*3/100)
1057+ selLane := 0
1058+ if lanes {
1059+ selLane = max(sel.Y, 0) / stride
1060+ }
8321061 for i := range tiles {
8331062 if tiles[i].Hidden {
8341063 continue
8351064 }
8361065 r := tiles[i].Rect
837- x0, y0 := scale(r.X), scale(r.Y)
838- tiles[i].Rect = Rect{X: x + x0, Y: slot.Y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}
1066+ lane, ly := 0, r.Y
1067+ if lanes {
1068+ l := r.Y / stride
1069+ lane, ly = l-selLane, r.Y-l*stride
1070+ }
1071+ x0, y0 := scale(r.X), scale(ly)
1072+ tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
8391073 tiles[i].Preview = z
840- if dim {
1074+ if dim || lane != 0 {
8411075 tiles[i].peeking(1)
8421076 }
843- tiles[i].Focused = tiles[i].Focused && !dim && lit
1077+ tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0
1078+ out := false
1079+ if axis == horizontalAxis {
1080+ // A row wider than its slot scrolls: what leaves the slot
1081+ // would sit on the neighbor workspaces.
1082+ out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw
1083+ }
1084+ if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) {
1085+ tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false
1086+ }
8441087 }
8451088 return tiles
8461089 }
@@ -944,10 +1187,14 @@ func (m *Monitor) overviewFocus(a Action) bool {
9441187 m.OverviewMove(-1, 0)
9451188 case ActionFocusColumnRight:
9461189 m.OverviewMove(1, 0)
947- case ActionFocusWindowUp, ActionFocusWorkspacePrev:
1190+ case ActionFocusWindowUp:
9481191 m.OverviewMove(0, -1)
949- case ActionFocusWindowDown, ActionFocusWorkspaceNext:
1192+ case ActionFocusWindowDown:
9501193 m.OverviewMove(0, 1)
1194+ case ActionFocusWorkspacePrev:
1195+ m.overviewWorkspaceStep(-1)
1196+ case ActionFocusWorkspaceNext:
1197+ m.overviewWorkspaceStep(1)
9511198 default:
9521199 return false
9531200 }
@@ -1007,20 +1254,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
10071254 add(&m.ov.scrollY, a.Vertical)
10081255 }
10091256 add(&m.ov.scrollX, a.Horizontal)
1257+ // A step that turns the overview returns, so the axis holds for the loop.
1258+ axis := m.overviewAxis()
1259+ cascade := axis == horizontalAxis
10101260 for {
10111261 switch {
10121262 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
1013- m.OverviewMove(0, sign(m.ov.scrollY))
1014- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
1263+ d := sign(m.ov.scrollY)
1264+ // The step leaves before the move: a move that turns the
1265+ // overview drops the scroll left (showOverview).
1266+ m.ov.scrollY -= float64(d) * overviewScrollStep
10151267 m.ov.scrollX = 0
1268+ if cascade && !finger && m.cardAt(m.Current()) < 0 {
1269+ m.overviewStep(d)
1270+ } else {
1271+ m.OverviewMove(0, d)
1272+ }
1273+ if m.overviewTurned(axis, finger) {
1274+ return true
1275+ }
10161276 if finger {
10171277 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
10181278 return true
10191279 }
10201280 case math.Abs(m.ov.scrollX) >= stepX:
1021- m.OverviewMove(sign(m.ov.scrollX), 0)
1022- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX
1281+ d := sign(m.ov.scrollX)
1282+ if cascade && finger {
1283+ // One workspace per gesture, even over a stash card.
1284+ m.overviewStep(d)
1285+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1286+ return true
1287+ }
1288+ m.ov.scrollX -= float64(d) * stepX
10231289 m.ov.scrollY = 0
1290+ m.OverviewMove(d, 0)
1291+ if m.overviewTurned(axis, finger) {
1292+ return true
1293+ }
10241294 if finger {
10251295 m.ov.sideways, stepX = true, overviewScrollRepeat
10261296 }
@@ -1031,6 +1301,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
10311301 }
10321302 }
10331303
1304+// overviewTurned reports whether a scroll step turned the overview from the
1305+// axis before. A finger scroll then waits for the fingers to lift, so it
1306+// cannot bounce back.
1307+func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool {
1308+ if m.overviewAxis() == before {
1309+ return false
1310+ }
1311+ m.ov.scrolled = m.ov.scrolled || finger
1312+ return true
1313+}
1314+
10341315 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe
10351316 // after the pointer took the focus to another output, so the next scroll
10361317 // starts from zero wherever it goes.
@@ -31,6 +31,11 @@ type overviewState struct {
31 scrolled, sideways bool31 scrolled, sideways bool
32 }32 }
33 33
34+// dropScroll forgets the scroll an overview axis accumulated, which belongs
35+// to the old axis once it turns. The finger latch (scrolled) stays: it waits
36+// for the fingers to lift.
37+func (o *overviewState) dropScroll() { o.scrollX, o.scrollY, o.sideways = 0, 0, false }
38+
34 // rowSnapshot is a row as the overview first showed it: browsing moves its39 // rowSnapshot is a row as the overview first showed it: browsing moves its
35 // focus and scroll, closing puts them back.40 // focus and scroll, closing puts them back.
36 type rowSnapshot struct {41 type rowSnapshot struct {
@@ -353,6 +358,9 @@ func (m *Monitor) overviewWorkspaces() []*Workspace {
353 // a named group uses its invocation anchor as the toggle return target, not358 // a named group uses its invocation anchor as the toggle return target, not
354 // rows merely browsed in the overview.359 // rows merely browsed in the overview.
355 func (m *Monitor) showOverview(w *Workspace) {360 func (m *Monitor) showOverview(w *Workspace) {
361+ if w.policy().workspace != m.overviewAxis() {
362+ m.ov.dropScroll()
363+ }
356 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {364 if w != m.Current() && m.isHidden(w) && !m.isHidden(m.Current()) {
357 m.back = m.Current()365 m.back = m.Current()
358 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {366 if indexOf(m.Workspaces, w.overviewAfter) >= 0 {
@@ -366,9 +374,113 @@ func (m *Monitor) showOverview(w *Workspace) {
366 // OverviewMove shares navigation rules across keys, scrolls and swipes. It374 // OverviewMove shares navigation rules across keys, scrolls and swipes. It
367 // is the physical entry point: it maps dx and dy to the overview's moves.375 // is the physical entry point: it maps dx and dy to the overview's moves.
368 func (m *Monitor) OverviewMove(dx, dy int) {376 func (m *Monitor) OverviewMove(dx, dy int) {
377+ if m.overviewAxis() == horizontalAxis {
378+ if dy != 0 {
379+ m.overviewLane(dy)
380+ } else if dx != 0 {
381+ m.overviewAcross(dx)
382+ }
383+ return
384+ }
369 m.overviewMoveVertical(dx, dy)385 m.overviewMoveVertical(dx, dy)
370 }386 }
371 387
388+// overviewWorkspaceStep is a focus-workspace bind. A vertical overview
389+// walks the stack cards before changing workspace (overviewVertical). A
390+// side-by-side one always changes workspace, since its stack fans left and
391+// right. A selected stash card cycles the pile first, in every layout.
392+func (m *Monitor) overviewWorkspaceStep(d int) {
393+ if m.overviewAxis() == verticalAxis {
394+ m.overviewVertical(d)
395+ return
396+ }
397+ if !m.cycleCard(m.Current(), d) {
398+ m.overviewStep(d)
399+ }
400+}
401+
402+// cycleCard brings the stash card d entries from the selected one of w to
403+// the front, stopping at either end. It reports whether a card was selected.
404+func (m *Monitor) cycleCard(w *Workspace, d int) bool {
405+ at := m.cardAt(w)
406+ if at < 0 {
407+ return false
408+ }
409+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
410+ return true
411+}
412+
413+// overviewLane is the vertical move of an overview whose workspaces sit side
414+// by side (cascade): a selected stash card cycles the pile; else the
415+// selection goes to the previous or next lane (band), at the closest column,
416+// stopping at the first and last. Covering floats stack along the horizontal
417+// axis, not this one.
418+func (m *Monitor) overviewLane(dy int) {
419+ w := m.Current()
420+ if m.cycleCard(w, dy) || m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
421+ return
422+ }
423+ if i := w.laneNeighbor(dy); i >= 0 {
424+ m.selectLaneColumn(w, i)
425+ }
426+}
427+
428+// selectLaneColumn selects column i of w.
429+func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
430+ w.selectOverviewColumn(i)
431+ m.ov.selected = w.Columns[i].Windows[w.Columns[i].Focus]
432+ m.ov.selectedAt = i
433+}
434+
435+// overviewAcross is the horizontal move of an overview whose workspaces sit
436+// side by side (cascade): a column inside the selected lane. Past the
437+// lane's first column it goes to the stash, else to the previous workspace;
438+// past the last column, to the next workspace. Covering floats fan along
439+// this axis and are traversed before the stash or a workspace: the card on
440+// the left is the one behind.
441+func (m *Monitor) overviewAcross(dx int) {
442+ w := m.Current()
443+ if m.cardAt(w) >= 0 {
444+ if dx < 0 || len(w.stack()) == 0 {
445+ // Left leaves the pile; a stash-only workspace has nothing
446+ // to its right to select.
447+ m.overviewStep(dx)
448+ return
449+ }
450+ m.ov.card, m.ov.cardOf = 0, nil
451+ m.ov.selected = 0
452+ if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
453+ first, _ := w.laneBounds(w.laneOf(w.Focus))
454+ m.selectLaneColumn(w, first)
455+ }
456+ return
457+ }
458+ if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
459+ m.laneEdge(w, dx)
460+ return
461+ }
462+ first, last := w.laneBounds(w.laneOf(w.Focus))
463+ if next := w.Focus + dx; next >= first && next <= last {
464+ m.selectLaneColumn(w, next)
465+ return
466+ }
467+ m.laneEdge(w, dx)
468+}
469+
470+// laneEdge is a horizontal move off the edge of the selected lane, or off a
471+// card that is not the columns: the stack first, then the stash on the left
472+// or a workspace.
473+func (m *Monitor) laneEdge(w *Workspace, dx int) {
474+ if m.moveStack(-dx) {
475+ return
476+ }
477+ if dx < 0 && len(w.Stash) > 0 {
478+ m.selectCard(w, w.stashAt)
479+ return
480+ }
481+ m.overviewStep(dx)
482+}
483+
372 // overviewMoveVertical is the move of an overview whose workspaces stack484 // overviewMoveVertical is the move of an overview whose workspaces stack
373 // vertically. Vertical moves stay in a selected stash pile; left stays, and485 // vertically. Vertical moves stay in a selected stash pile; left stays, and
374 // right deselects it, even when the row has no main card.486 // right deselects it, even when the row has no main card.
@@ -381,9 +493,7 @@ func (m *Monitor) overviewMoveVertical(dx, dy int) {
381 }493 }
382 494
383 func (m *Monitor) overviewVertical(dy int) {495 func (m *Monitor) overviewVertical(dy int) {
384- w := m.Current()496+ if m.cycleCard(m.Current(), dy) {
385- if at := m.cardAt(w); at >= 0 {
386- m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1))
387 return497 return
388 }498 }
389 if m.moveStack(-dy) {499 if m.moveStack(-dy) {
@@ -402,6 +512,8 @@ func (m *Monitor) overviewStep(d int) {
402 }512 }
403 }513 }
404 514
515+// overviewHorizontal is the horizontal move of an overview whose workspaces
516+// stack vertically: it never changes workspace.
405 func (m *Monitor) overviewHorizontal(dx int) {517 func (m *Monitor) overviewHorizontal(dx int) {
406 w := m.Current()518 w := m.Current()
407 if m.cardAt(w) >= 0 {519 if m.cardAt(w) >= 0 {
@@ -500,21 +612,36 @@ func (m *Monitor) OverviewPick(id WindowID) {
500 }612 }
501 613
502 // overviewAxis is the axis the overview lays workspaces out along, and the614 // overviewAxis is the axis the overview lays workspaces out along, and the
503-// stacked cards of a row fan along.615+// stacked cards of a row fan along: the navigation axis of the current
504-func (m *Monitor) overviewAxis() layoutAxis { return verticalAxis }616+// workspace's layout.
505-617+func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().workspace }
506-// overviewRows centers the selected row with its neighbors above and below.618+
507-// Each shown workspace gets a slot, in output-local pixels, to place its619+// overviewRows places the current workspace and its overview neighbors in
508-// previews in. The numbered/named boundary gets extra spacing and an620+// slots, in output-local pixels, along the overview axis; each workspace
509-// inactive horizontal rule. Row widths, stash piles and preview scales621+// places its previews in its slot. The numbered/named boundary gets extra
510-// remain unchanged.622+// spacing and an inactive rule across the axis.
623+//
624+// Vertical axis: full-width slots, the current one centred; the rule is
625+// horizontal. Horizontal axis: full-height slots as wide as their content,
626+// stash pile and stack peeks included, the current one centred; the rule is
627+// vertical, in the spacing between two slots.
511 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {628 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
512 cur := m.Current()629 cur := m.Current()
513 u := cur.overviewArea()630 u := cur.overviewArea()
514 gap := max(1, u.H*3/100)631 gap := max(1, u.H*3/100)
515- hc := cur.rowHeight()632+ axis := m.overviewAxis()
516- y := u.Y + (u.H-hc)/2633+ var cs Rect
517- rows := map[*Workspace]Rect{cur: {X: u.X, Y: y, W: u.W, H: hc}}634+ if axis == horizontalAxis {
635+ // The row stays centred on the area; the pile extends the slot to
636+ // the left, pushing the previous workspace, unless it would leave
637+ // the area.
638+ pile, row := cur.pileWidth(), cur.rowWidth()
639+ cs = Rect{X: max(u.X+(u.W-row)/2-pile, u.X), Y: u.Y, W: pile + row, H: u.H}
640+ } else {
641+ hc := cur.rowHeight()
642+ cs = Rect{X: u.X, Y: u.Y + (u.H-hc)/2, W: u.W, H: hc}
643+ }
644+ rows := map[*Workspace]Rect{cur: cs}
518 var dividers []ports.Separator645 var dividers []ports.Separator
519 list := m.overviewWorkspaces()646 list := m.overviewWorkspaces()
520 i := indexOf(list, cur)647 i := indexOf(list, cur)
@@ -528,23 +655,53 @@ func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
528 if boundary {655 if boundary {
529 spacing *= 2656 spacing *= 2
530 }657 }
531- top, h := y+hc, w.rowHeight()658+ slot, gapAt := neighborSlot(axis, u, cs, w, next < i, spacing)
532- if next < i {659+ rows[w] = slot
533- rows[w] = Rect{X: u.X, Y: y - spacing - h, W: u.W, H: h}
534- top = y - spacing
535- } else {
536- rows[w] = Rect{X: u.X, Y: y + hc + spacing, W: u.W, H: h}
537- }
538 if boundary {660 if boundary {
539- margin := u.W * 4 / 100661+ dividers = append(dividers, overviewDivider(axis, u, gapAt+spacing/2))
540- dividers = append(dividers, ports.Separator{Rect: Rect{X: u.X + margin, Y: top + spacing/2, W: u.W - 2*margin, H: 1}})
541 }662 }
542 }663 }
543 return rows, dividers664 return rows, dividers
544 }665 }
545 666
667+// neighborSlot is the slot of w, the neighbor before or after the current
668+// slot cs along axis, spacing away; gapAt is where the spacing begins.
669+func neighborSlot(axis layoutAxis, u, cs Rect, w *Workspace, before bool, spacing int) (slot Rect, gapAt int) {
670+ if axis == horizontalAxis {
671+ slot = Rect{Y: u.Y, W: w.slotWidth(), H: u.H}
672+ if before {
673+ gapAt = cs.X - spacing
674+ slot.X = gapAt - slot.W
675+ } else {
676+ gapAt = cs.X + cs.W
677+ slot.X = gapAt + spacing
678+ }
679+ return slot, gapAt
680+ }
681+ h := w.rowHeight()
682+ slot = Rect{X: u.X, W: u.W, H: h}
683+ if before {
684+ gapAt = cs.Y - spacing
685+ slot.Y = gapAt - h
686+ } else {
687+ gapAt = cs.Y + cs.H
688+ slot.Y = gapAt + spacing
689+ }
690+ return slot, gapAt
691+}
692+
693+// overviewDivider is the inactive rule across axis at pos, inset from the
694+// edges of u.
695+func overviewDivider(axis layoutAxis, u Rect, pos int) ports.Separator {
696+ if axis == horizontalAxis {
697+ return ports.Separator{Rect: Rect{X: pos, Y: u.Y + u.H*4/100, W: 1, H: u.H * 92 / 100}}
698+ }
699+ margin := u.W * 4 / 100
700+ return ports.Separator{Rect: Rect{X: u.X + margin, Y: pos, W: u.W - 2*margin, H: 1}}
701+}
702+
546 // overviewLayout places the current workspace and its overview neighbors,703 // overviewLayout places the current workspace and its overview neighbors,
547-// each with its full-width stash pile and covering-float stack. Other rows704+// each with its stash pile and covering-float stack. Other rows
548 // stay hidden; numbered and named workspaces share the same preview geometry.705 // stay hidden; numbered and named workspaces share the same preview geometry.
549 func (m *Monitor) overviewLayout(result []Placement) []Placement {706 func (m *Monitor) overviewLayout(result []Placement) []Placement {
550 cur := m.Current()707 cur := m.Current()
@@ -556,7 +713,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
556 at := m.cardAt(w)713 at := m.cardAt(w)
557 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)714 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
558 if len(w.Stash) > 0 {715 if len(w.Stash) > 0 {
559- result = append(result, w.pile(w.frontSlot(axis, slot), w != cur, at)...)716+ result = append(result, w.pile(w.pileBand(axis, slot), w != cur, at)...)
560 }717 }
561 }718 }
562 for _, f := range w.Floats {719 for _, f := range w.Floats {
@@ -658,8 +815,8 @@ func (w *Workspace) cardStep() int {
658 return min(overviewCardStep, a.W/100, a.H/50)815 return min(overviewCardStep, a.W/100, a.H/50)
659 }816 }
660 817
661-// pile places the stash of w as cards on the left of band, the front band818+// pile places the stash of w as cards on the left of band (see pileBand):
662-// of its row (see frontSlot): entry front (the stash selection when -1) in819+// entry front (the stash selection when -1) in
663 // front, the next ones behind it, dimmed, the rest hidden. Only a front card820 // front, the next ones behind it, dimmed, the rest hidden. Only a front card
664 // of the current row (dim false) with front set is selected.821 // of the current row (dim false) with front set is selected.
665 func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {822 func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
@@ -693,19 +850,48 @@ func (w *Workspace) pile(band Rect, dim bool, front int) []Placement {
693 // overviewZoom is the scale that fits every column of w in the usable850 // overviewZoom is the scale that fits every column of w in the usable
694 // width, within the overview bounds.851 // width, within the overview bounds.
695 func (w *Workspace) overviewZoom() float64 {852 func (w *Workspace) overviewZoom() float64 {
853+ z, _ := w.zoomSpan()
854+ return z
855+}
856+
857+// zoomSpan is overviewZoom with the unscaled span of the tiles it fits. A
858+// cascade workspace spans one screen whatever its band count.
859+func (w *Workspace) zoomSpan() (float64, int) {
696 // Only the span is read: the tiles go in the zoom scratch, dead on return.860 // Only the span is read: the tiles go in the zoom scratch, dead on return.
697 var span int861 var span int
698 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])862 w.zoomBuf, span, _ = w.previewTilesInto(w.zoomBuf[:0])
699 if span <= 0 {863 if span <= 0 {
700- return overviewMaxZoom864+ return overviewMaxZoom, span
701 }865 }
702 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96866 room := float64(w.overviewArea().W-w.pileWidth()) * 0.96
703 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)867 z := min(max(room/float64(span), overviewMinZoom), overviewMaxZoom)
704- if len(w.stack()) > 1 {868+ if w.stacked() {
705 // Reserve two clear peeks on either side of the front row.869 // Reserve two clear peeks on either side of the front row.
706 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))870 z = min(z, overviewMaxZoom/(1+4*overviewPeekStep))
707 }871 }
708- return z872+ return z, span
873+}
874+
875+// slotWidth is the width of the slot of w in a horizontal overview: its
876+// stash pile and its row.
877+func (w *Workspace) slotWidth() int {
878+ return w.pileWidth() + w.rowWidth()
879+}
880+
881+// rowWidth is the width of the row of w in a horizontal overview: what its
882+// tiles take (at most what the pile leaves of the area), plus the room for
883+// the covering floats peeking on either side of the front card.
884+func (w *Workspace) rowWidth() int {
885+ z, span := w.zoomSpan()
886+ if span <= 0 {
887+ // An empty workspace keeps the room of a screen.
888+ span = w.Usable.W
889+ }
890+ width := min(int(math.Round(float64(span)*z)), w.overviewArea().W-w.pileWidth())
891+ if w.stacked() {
892+ width += 4 * w.peekStep()
893+ }
894+ return width
709 }895 }
710 896
711 // peekStep is the shared step for a stack row and its front-card inset.897 // peekStep is the shared step for a stack row and its front-card inset.
@@ -714,20 +900,37 @@ func (w *Workspace) peekStep() int {
714 }900 }
715 901
716 // frontSlot is the part of slot the front card of w takes: slot shifted902 // frontSlot is the part of slot the front card of w takes: slot shifted
717-// along axis past the room reserved for two cards peeking before it. A903+// along axis past the room reserved for two cards peeking before it, and
718-// single-card row keeps slot.904+// (along a horizontal axis, where the slot is as wide as its cards) without
905+// the room of the two after it. A single-card row keeps slot.
719 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {906 func (w *Workspace) frontSlot(axis layoutAxis, slot Rect) Rect {
720- if len(w.stack()) > 1 {907+ if w.stacked() {
721- axis.offset(&slot, 2*w.peekStep())908+ p := w.peekStep()
909+ axis.offset(&slot, 2*p)
910+ if axis == horizontalAxis {
911+ slot.W -= 4 * p
912+ }
722 }913 }
723 return slot914 return slot
724 }915 }
725 916
917+// pileBand is the band the stash pile of w aligns with in its slot. A
918+// vertical overview aligns it with the front card (see frontSlot). A
919+// horizontal one puts it level with the selected lane, left of the whole
920+// stack of cards.
921+func (w *Workspace) pileBand(axis layoutAxis, slot Rect) Rect {
922+ if axis == verticalAxis {
923+ return w.frontSlot(axis, slot)
924+ }
925+ slot.Y += (slot.H - int(math.Round(float64(w.Usable.H)*w.overviewZoom()))) / 2
926+ return slot
927+}
928+
726 // rowHeight includes the largest possible stack envelope (two peeks above929 // rowHeight includes the largest possible stack envelope (two peeks above
727 // and two below). Neighbor rows use the same measure as the current row.930 // and two below). Neighbor rows use the same measure as the current row.
728 func (w *Workspace) rowHeight() int {931 func (w *Workspace) rowHeight() int {
729 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))932 h := int(math.Round(float64(w.Usable.H) * w.overviewZoom()))
730- if len(w.stack()) > 1 {933+ if w.stacked() {
731 h += 4 * w.peekStep()934 h += 4 * w.peekStep()
732 }935 }
733 return h936 return h
@@ -771,9 +974,10 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
771 for i, c := range w.Columns {974 for i, c := range w.Columns {
772 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}975 r := Rect{X: w.columnX(i) - w.Usable.X, Y: g, W: w.columnWidth(i), H: h}
773 if w.policy().wraps {976 if w.policy().wraps {
774- width := w.cellWidth()977+ // The band is the lane: it is encoded in Y, one screen apart.
775- r.X = w.band(i)*w.Usable.W + w.cellX(i)978+ r.X = w.cellX(i)
776- r.W = width979+ r.Y = g + w.band(i)*w.Usable.H
980+ r.W = w.cellWidth()
777 } else if w.Overflow == OverflowFixed {981 } else if w.Overflow == OverflowFixed {
778 r = rects[i]982 r = rects[i]
779 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y983 r.X, r.Y = r.X-w.Usable.X, r.Y-w.Usable.Y
@@ -794,13 +998,16 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
794 for j, t := range w.rowBuf {998 for j, t := range w.rowBuf {
795 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})999 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: i == w.Focus && j == c.Focus})
796 }1000 }
797- if w.Overflow != OverflowFixed {1001+ if w.Overflow != OverflowFixed && !w.policy().wraps {
798 span = max(span, r.X+r.W+g)1002 span = max(span, r.X+r.W+g)
799 }1003 }
800 if i == w.Focus {1004 if i == w.Focus {
801 sel = r1005 sel = r
802 }1006 }
803 }1007 }
1008+ if w.policy().wraps && len(w.Columns) > 0 {
1009+ span = w.Usable.W
1010+ }
804 return tiles, span, sel1011 return tiles, span, sel
805 }1012 }
806 1013
@@ -809,17 +1016,30 @@ func (w *Workspace) previewTilesInto(tiles []Placement) ([]Placement, int, Rect)
809 // row is wider, right of the stash pile. Dimmed rows (neighbor1016 // row is wider, right of the stash pile. Dimmed rows (neighbor
810 // workspaces) have no focus; neither has a row whose pile has it (lit1017 // workspaces) have no focus; neither has a row whose pile has it (lit
811 // false).1018 // false).
812-func (w *Workspace) previewRow(slot Rect, dim, lit bool) []Placement {1019+func (w *Workspace) previewRow(axis layoutAxis, slot Rect, dim, lit bool) []Placement {
813 tiles, span, sel := w.previewTiles()1020 tiles, span, sel := w.previewTiles()
814- return w.previewRowTiles(slot, dim, lit, tiles, span, sel)1021+ return w.previewRowTiles(axis, slot, dim, lit, w.policy().wraps, tiles, span, sel)
815 }1022 }
816 1023
817 // previewRowTiles scales tiles, laid out unscrolled over span with sel the1024 // previewRowTiles scales tiles, laid out unscrolled over span with sel the
818-// focused one, into slot. Placement stays inside slot.1025+// focused one, into slot of an overview along axis. Placement stays inside
819-func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement, span int, sel Rect) []Placement {1026+// slot.
1027+//
1028+// With lanes set (the tiles of a cascade workspace), the tiles carry their
1029+// band in Y, one Usable.H apart. The band of sel is the selected lane,
1030+// centred across the axis; the other bands sit a lane away from it, dimmed,
1031+// and are hidden when they leave the area. A workspace whose lanes run along
1032+// the overview axis shows only its selected lane. In a horizontal overview,
1033+// tiles entirely outside the area are hidden.
1034+func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes bool, tiles []Placement, span int, sel Rect) []Placement {
820 z := w.overviewZoom()1035 z := w.overviewZoom()
821 scale := func(v int) int { return int(math.Round(float64(v) * z)) }1036 scale := func(v int) int { return int(math.Round(float64(v) * z)) }
822 width := scale(span)1037 width := scale(span)
1038+ u := w.overviewArea()
1039+ top := slot.Y
1040+ if axis == horizontalAxis {
1041+ top = slot.Y + (slot.H-scale(w.Usable.H))/2
1042+ }
823 // Rows stay centred on the output like rows without a stash; the pile1043 // Rows stay centred on the output like rows without a stash; the pile
824 // only pushes a row right when they would overlap.1044 // only pushes a row right when they would overlap.
825 left := w.pileWidth()1045 left := w.pileWidth()
@@ -829,18 +1049,41 @@ func (w *Workspace) previewRowTiles(slot Rect, dim, lit bool, tiles []Placement,
829 x = ux + uw/2 - scale(sel.X+sel.W/2)1049 x = ux + uw/2 - scale(sel.X+sel.W/2)
830 x = min(max(x, ux+uw-width), ux)1050 x = min(max(x, ux+uw-width), ux)
831 }1051 }
1052+ stride := w.policy().content.span(w.Usable)
1053+ lanes = lanes && stride > 0
1054+ // Lanes along the overview axis would overlap the neighbor workspaces.
1055+ otherLanes := w.policy().content != axis
1056+ laneStep := scale(stride) + max(1, u.H*3/100)
1057+ selLane := 0
1058+ if lanes {
1059+ selLane = max(sel.Y, 0) / stride
1060+ }
832 for i := range tiles {1061 for i := range tiles {
833 if tiles[i].Hidden {1062 if tiles[i].Hidden {
834 continue1063 continue
835 }1064 }
836 r := tiles[i].Rect1065 r := tiles[i].Rect
837- x0, y0 := scale(r.X), scale(r.Y)1066+ lane, ly := 0, r.Y
838- tiles[i].Rect = Rect{X: x + x0, Y: slot.Y + y0, W: scale(r.X+r.W) - x0, H: scale(r.Y+r.H) - y0}1067+ if lanes {
1068+ l := r.Y / stride
1069+ lane, ly = l-selLane, r.Y-l*stride
1070+ }
1071+ x0, y0 := scale(r.X), scale(ly)
1072+ tiles[i].Rect = Rect{X: x + x0, Y: top + y0 + lane*laneStep, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
839 tiles[i].Preview = z1073 tiles[i].Preview = z
840- if dim {1074+ if dim || lane != 0 {
841 tiles[i].peeking(1)1075 tiles[i].peeking(1)
842 }1076 }
843- tiles[i].Focused = tiles[i].Focused && !dim && lit1077+ tiles[i].Focused = tiles[i].Focused && !dim && lit && lane == 0
1078+ out := false
1079+ if axis == horizontalAxis {
1080+ // A row wider than its slot scrolls: what leaves the slot
1081+ // would sit on the neighbor workspaces.
1082+ out = tiles[i].Rect.X < ux || tiles[i].Rect.X+tiles[i].Rect.W > ux+uw
1083+ }
1084+ if lane != 0 && !otherLanes || out || (lanes || axis == horizontalAxis) && !tiles[i].Rect.Overlaps(u) {
1085+ tiles[i].Hidden, tiles[i].Preview, tiles[i].Focused = true, 0, false
1086+ }
844 }1087 }
845 return tiles1088 return tiles
846 }1089 }
@@ -944,10 +1187,14 @@ func (m *Monitor) overviewFocus(a Action) bool {
944 m.OverviewMove(-1, 0)1187 m.OverviewMove(-1, 0)
945 case ActionFocusColumnRight:1188 case ActionFocusColumnRight:
946 m.OverviewMove(1, 0)1189 m.OverviewMove(1, 0)
947- case ActionFocusWindowUp, ActionFocusWorkspacePrev:1190+ case ActionFocusWindowUp:
948 m.OverviewMove(0, -1)1191 m.OverviewMove(0, -1)
949- case ActionFocusWindowDown, ActionFocusWorkspaceNext:1192+ case ActionFocusWindowDown:
950 m.OverviewMove(0, 1)1193 m.OverviewMove(0, 1)
1194+ case ActionFocusWorkspacePrev:
1195+ m.overviewWorkspaceStep(-1)
1196+ case ActionFocusWorkspaceNext:
1197+ m.overviewWorkspaceStep(1)
951 default:1198 default:
952 return false1199 return false
953 }1200 }
@@ -1007,20 +1254,43 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1007 add(&m.ov.scrollY, a.Vertical)1254 add(&m.ov.scrollY, a.Vertical)
1008 }1255 }
1009 add(&m.ov.scrollX, a.Horizontal)1256 add(&m.ov.scrollX, a.Horizontal)
1257+ // A step that turns the overview returns, so the axis holds for the loop.
1258+ axis := m.overviewAxis()
1259+ cascade := axis == horizontalAxis
1010 for {1260 for {
1011 switch {1261 switch {
1012 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):1262 case math.Abs(m.ov.scrollY) >= overviewScrollStep && math.Abs(m.ov.scrollY) >= math.Abs(m.ov.scrollX):
1013- m.OverviewMove(0, sign(m.ov.scrollY))1263+ d := sign(m.ov.scrollY)
1014- m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep1264+ // The step leaves before the move: a move that turns the
1265+ // overview drops the scroll left (showOverview).
1266+ m.ov.scrollY -= float64(d) * overviewScrollStep
1015 m.ov.scrollX = 01267 m.ov.scrollX = 0
1268+ if cascade && !finger && m.cardAt(m.Current()) < 0 {
1269+ m.overviewStep(d)
1270+ } else {
1271+ m.OverviewMove(0, d)
1272+ }
1273+ if m.overviewTurned(axis, finger) {
1274+ return true
1275+ }
1016 if finger {1276 if finger {
1017 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1277 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1018 return true1278 return true
1019 }1279 }
1020 case math.Abs(m.ov.scrollX) >= stepX:1280 case math.Abs(m.ov.scrollX) >= stepX:
1021- m.OverviewMove(sign(m.ov.scrollX), 0)1281+ d := sign(m.ov.scrollX)
1022- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX1282+ if cascade && finger {
1283+ // One workspace per gesture, even over a stash card.
1284+ m.overviewStep(d)
1285+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1286+ return true
1287+ }
1288+ m.ov.scrollX -= float64(d) * stepX
1023 m.ov.scrollY = 01289 m.ov.scrollY = 0
1290+ m.OverviewMove(d, 0)
1291+ if m.overviewTurned(axis, finger) {
1292+ return true
1293+ }
1024 if finger {1294 if finger {
1025 m.ov.sideways, stepX = true, overviewScrollRepeat1295 m.ov.sideways, stepX = true, overviewScrollRepeat
1026 }1296 }
@@ -1031,6 +1301,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1031 }1301 }
1032 }1302 }
1033 1303
1304+// overviewTurned reports whether a scroll step turned the overview from the
1305+// axis before. A finger scroll then waits for the fingers to lift, so it
1306+// cannot bounce back.
1307+func (m *Monitor) overviewTurned(before layoutAxis, finger bool) bool {
1308+ if m.overviewAxis() == before {
1309+ return false
1310+ }
1311+ m.ov.scrolled = m.ov.scrolled || finger
1312+ return true
1313+}
1314+
1034 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe1315 // scrollStop ends a scroll on every monitor: the fingers lifted, maybe
1035 // after the pointer took the focus to another output, so the next scroll1316 // after the pointer took the focus to another output, so the next scroll
1036 // starts from zero wherever it goes.1317 // starts from zero wherever it goes.
A internal/core/overview_cascade_test.go +858 -0
@@ -0,0 +1,858 @@
1+package core
2+
3+import (
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.
15+func 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.
33+func 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.
46+func 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+
65+func centreY(r Rect) int { return r.Y + r.H/2 }
66+
67+func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68+
69+func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70+
71+// T1: the zoom does not depend on the number of bands.
72+func 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+// T2: the selected band is the centre lane: undimmed, centred, selected tile
79+// focused.
80+func 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+// T3: the other bands are dimmed lanes a screen (and a gap) away; those
97+// outside the area are hidden.
98+func 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+// T4: neighbor workspaces sit left and right of the current slot, dimmed,
145+// their selected lane level with the centre lane.
146+func 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+// T5: lanes along the overview axis would overlap the other workspaces: a
191+// cascade neighbor of a scroll workspace shows its selected band only.
192+func 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+// T6: the stash pile is inside the current slot, left of the band; the
220+// previous workspace stays clear of it.
221+func 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+
261+type cascadeDriver struct {
262+ name string
263+ move func(m *Monitor, dx, dy int)
264+}
265+
266+func 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+
273+var 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+
279+func focusedID(m *Monitor) WindowID {
280+ id, _ := m.Focused()
281+ return id
282+}
283+
284+// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
285+// for the stash or the neighbor workspaces; Escape restores everything.
286+func 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 on main; confirming shows the picked window.
366+func 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.
434+func 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+// T8: a named boundary of a cascade overview is a vertical rule strictly
471+// between the two slots.
472+func TestOverviewCascadeNamedDivider(t *testing.T) {
473+ m := namedOverviewMonitor()
474+ m.Workspaces[1].Overflow = OverflowCascade
475+ m.ToggleOverview()
476+ rows, dividers := m.overviewRows()
477+ if len(dividers) != 1 {
478+ t.Fatalf("dividers: %+v", dividers)
479+ }
480+ d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
481+ if d.W != 1 || d.H <= 1 || dividers[0].Active {
482+ t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
483+ }
484+ if d.X < cur.X+cur.W || d.X+d.W > dev.X {
485+ t.Fatalf("divider %v not between %v and %v", d, cur, dev)
486+ }
487+}
488+
489+// T10: covering floats fan along the horizontal axis: left goes deeper in the
490+// stack (the cards behind show on the left), right comes back to the front.
491+func TestOverviewCascadeCoveringFloat(t *testing.T) {
492+ for _, d := range cascadeDrivers {
493+ t.Run(d.name, func(t *testing.T) {
494+ m := cascadeOverview()
495+ m.Current().AddFloating(90, 300, 200)
496+ w := m.Current()
497+ w.FocusID(10)
498+ m.ToggleOverview()
499+ if m.stackFront(w).kind != stackColumns {
500+ t.Fatal("setup: columns not in front")
501+ }
502+ d.move(m, 0, -1) // columns: up is a band
503+ d.move(m, -1, 0)
504+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
505+ t.Fatalf("left from the first column: front %+v", f)
506+ }
507+ if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
508+ t.Fatalf("float not in front: %+v", p)
509+ }
510+ d.move(m, 0, 1) // a float front takes no band move
511+ if f := m.stackFront(w); f.kind != stackFloat {
512+ t.Fatalf("down left the float: %+v", f)
513+ }
514+ d.move(m, 1, 0)
515+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
516+ t.Fatalf("right from the float: front %+v", m.stackFront(w))
517+ }
518+
519+ // The float in front of the columns: they fan to its left.
520+ m.CancelOverview()
521+ w.FocusID(90)
522+ m.ToggleOverview()
523+ if f := m.stackFront(w); f.kind != stackFloat {
524+ t.Fatalf("setup: front %+v", f)
525+ }
526+ behind := previewOf(t, m.Layout(), 10)
527+ if !behind.Peek {
528+ t.Fatalf("columns not behind the float: %+v", behind)
529+ }
530+ d.move(m, -1, 0)
531+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
532+ t.Fatalf("left from the front float: front %+v", m.stackFront(w))
533+ }
534+ // The cards behind fan to the left: coming to the front, the
535+ // columns move right by one peek step.
536+ front := previewOf(t, m.Layout(), 10)
537+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
538+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
539+ }
540+ d.move(m, 1, 0)
541+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
542+ t.Fatalf("right from the columns behind the float: front %+v", f)
543+ }
544+ })
545+ }
546+}
547+
548+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
549+func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
550+ m := cascadeOverview()
551+ w := m.Current()
552+ w.FocusID(8)
553+ m.ToggleFullscreen()
554+ if !w.pinned() {
555+ t.Fatal("setup: not pinned")
556+ }
557+ m.ToggleOverview()
558+ ps := m.Layout()
559+ fs := previewOf(t, ps, 8)
560+ if !fs.Fullscreen || fs.Hidden {
561+ t.Fatalf("fullscreen tile: %+v", fs)
562+ }
563+ for _, id := range []WindowID{7, 9} {
564+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
565+ t.Fatalf("band tile %d: %+v", id, p)
566+ }
567+ }
568+ u := w.overviewArea()
569+ if !within1(centreY(fs.Rect), u.Y+u.H/2) {
570+ t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
571+ }
572+}
573+
574+// T11: the focus binds keep their meaning in a cascade overview.
575+func TestOverviewCascadeFocusBinds(t *testing.T) {
576+ m := cascadeOverview()
577+ w := m.Current()
578+ m.ToggleOverview()
579+ if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
580+ t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
581+ }
582+ if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
583+ t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
584+ }
585+ m.overviewFocus(ActionFocusWorkspaceNext)
586+ if m.Current() != m.Workspaces[2] {
587+ t.Fatal("focus-workspace-next did not change workspace")
588+ }
589+ m.overviewFocus(ActionFocusWorkspacePrev)
590+ if m.Current() != w {
591+ t.Fatal("focus-workspace-prev did not come back")
592+ }
593+ m.overviewFocus(ActionFocusWorkspacePrev)
594+ if m.Current() != m.Workspaces[0] {
595+ t.Fatal("focus-workspace-prev did not change workspace")
596+ }
597+ m.CancelOverview()
598+
599+ // A selected stash card keeps the pile cycle.
600+ withStash(m, 3)
601+ w.FocusID(7)
602+ m.ToggleOverview()
603+ m.OverviewMove(-1, 0)
604+ at := m.cardAt(w)
605+ m.overviewFocus(ActionFocusWorkspacePrev)
606+ if m.Current() != w || m.cardAt(w) != at-1 {
607+ t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
608+ }
609+ m.overviewFocus(ActionFocusWorkspaceNext)
610+ if m.Current() != w || m.cardAt(w) != at {
611+ t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
612+ }
613+}
614+
615+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
616+ m := overviewMonitor()
617+ m.SetOverflow(OverflowFixed)
618+ m.Current().Overflow = OverflowCascade
619+ from := m.Current()
620+ m.ToggleOverview()
621+ rows, _ := m.overviewRows()
622+ if rows[m.Workspaces[1]].X <= rows[from].X {
623+ t.Fatal("cascade neighbor not horizontal")
624+ }
625+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
626+ if m.Current() == from {
627+ t.Fatal("horizontal gesture did not change workspace")
628+ }
629+ if m.Current().Overflow != OverflowFixed {
630+ t.Fatal("destination lost fixed policy")
631+ }
632+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
633+ t.Fatalf("scroll state after the axis change: %+v", m.ov)
634+ }
635+ m.CancelOverview()
636+ if m.Current() != from {
637+ t.Fatal("cancel lost source workspace")
638+ }
639+}
640+
641+// Showing a workspace with another overview axis drops the scroll leftovers
642+// of the old one.
643+func TestOverviewAxisChangeResetsScroll(t *testing.T) {
644+ m := cascadeOverview()
645+ m.ToggleOverview()
646+ m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
647+ m.showOverview(m.Workspaces[2])
648+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
649+ t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
650+ }
651+ m.ov.scrollX = 20
652+ m.showOverview(m.Workspaces[0])
653+ if m.ov.scrollX != 20 {
654+ t.Fatal("scroll reset between workspaces of the same axis")
655+ }
656+}
657+
658+// A reload that changes the current workspace's axes drops the scroll state;
659+// one that does not, keeps it.
660+func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
661+ c, _ := indicatorCore(t)
662+ m := c.cur().mon
663+ m.AddWindow(1)
664+ m.ToggleOverview()
665+ w := m.Current()
666+ set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
667+ set()
668+ cfg := c.cfg
669+ cfg.Layout.Gaps++
670+ if err := c.apply(cfg); err != nil {
671+ t.Fatal(err)
672+ }
673+ if m.ov.scrollX != 20 || !m.ov.scrolled {
674+ t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
675+ }
676+ cfg.Layout.Overflow = string(OverflowCascade)
677+ if err := c.apply(cfg); err != nil {
678+ t.Fatal(err)
679+ }
680+ if m.Current().policy().workspace != horizontalAxis {
681+ t.Fatal("setup: not cascade")
682+ }
683+ // The reload keeps the overview open on its selection, and cancelling
684+ // still gives the original focus back.
685+ if id, _ := w.Focused(); !m.ov.open || id != 1 {
686+ t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
687+ }
688+ // The scroll is dropped, but not the latch that waits for the fingers
689+ // to lift.
690+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
691+ t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
692+ }
693+ set()
694+ cfg.Layout.Overflow = string(OverflowFixed)
695+ if err := c.apply(cfg); err != nil {
696+ t.Fatal(err)
697+ }
698+ if m.ov.scrollX != 0 || !m.ov.scrolled {
699+ t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
700+ }
701+ m.CancelOverview()
702+ if id, _ := w.Focused(); id != 1 {
703+ t.Fatalf("cancel left the focus on %d", id)
704+ }
705+}
706+
707+// The finger latch outlives an axis change: a gesture that turned the
708+// overview, then was turned back by a key, does not step again.
709+func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
710+ c, m := cascadeCore(t)
711+ w := m.Current()
712+ m.ToggleOverview()
713+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
714+ c.swipeUpdate(ports.SwipeUpdate{DX: 70})
715+ if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
716+ t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
717+ }
718+ // Back to the cascade workspace by a key, with the fingers still down.
719+ m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
720+ if m.Current() != w || m.overviewAxis() != horizontalAxis {
721+ t.Fatal("setup: the key did not turn the overview back")
722+ }
723+ for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
724+ c.swipeUpdate(u)
725+ }
726+ c.swipeEnd(ports.SwipeEnd{})
727+ if m.Current() != w || w.Focus != 0 {
728+ t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
729+ }
730+ // The next gesture steps again.
731+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
732+ c.swipeUpdate(ports.SwipeUpdate{DY: 70})
733+ c.swipeEnd(ports.SwipeEnd{})
734+ if w.band(w.Focus) != 1 {
735+ t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
736+ }
737+}
738+
739+// A stash-only cascade workspace has no columns to select: right from its
740+// pile goes straight to the next workspace.
741+func TestOverviewCascadeStashOnlyRight(t *testing.T) {
742+ m := overviewMonitor()
743+ m.Focus(1)
744+ w := m.Current()
745+ w.Overflow = OverflowCascade
746+ w.ToggleWindowStash()
747+ m.Focus(2)
748+ m.AddWindow(30)
749+ m.Focus(1)
750+ m.ToggleOverview()
751+ if len(w.Columns) != 0 || m.cardAt(w) < 0 {
752+ t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
753+ }
754+ m.OverviewMove(1, 0)
755+ if m.Current() != m.Workspaces[2] {
756+ t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
757+ }
758+ m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
759+ if m.Current() != w || m.cardAt(w) < 0 {
760+ t.Fatal("setup: back on the stash-only workspace without its card")
761+ }
762+ m.OverviewMove(-1, 0)
763+ if m.Current() != m.Workspaces[0] {
764+ t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
765+ }
766+}
767+
768+// A cascade neighbor beside a cascade workspace draws its other lanes
769+// dimmed, its selected lane level with the centre lane.
770+func TestOverviewCascadeNeighborLanes(t *testing.T) {
771+ m := cascadeOverview()
772+ prev := m.Workspaces[0]
773+ prev.Overflow = OverflowCascade
774+ for id := WindowID(60); id <= 65; id++ {
775+ prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
776+ }
777+ m.ToggleOverview()
778+ ps := m.Layout()
779+ centre := centreY(previewOf(t, ps, 8).Rect)
780+ sel := previewOf(t, ps, 65)
781+ if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
782+ t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
783+ }
784+ above := previewOf(t, ps, 62)
785+ if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
786+ t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
787+ }
788+ if p := previewOf(t, ps, 3); !hiddenOff(p) {
789+ t.Fatalf("lane two away in the neighbor: %+v", p)
790+ }
791+}
792+
793+// A scroll neighbor wider than its slot scrolls inside it: what leaves the
794+// slot is hidden, so it never sits on the current slot.
795+func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
796+ m := cascadeOverview()
797+ prev := m.Workspaces[0]
798+ for id := WindowID(60); id <= 67; id++ {
799+ prev.AddWindow(id)
800+ }
801+ m.ToggleOverview()
802+ rows, _ := m.overviewRows()
803+ cur, slot := rows[m.Current()], rows[prev]
804+ hidden, shown := 0, 0
805+ for _, p := range m.Layout() {
806+ if !prev.has(p.ID) {
807+ continue
808+ }
809+ if p.Hidden {
810+ hidden++
811+ continue
812+ }
813+ shown++
814+ if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
815+ t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
816+ }
817+ }
818+ if hidden == 0 || shown == 0 {
819+ t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
820+ }
821+}
822+
823+// A wheel and a continuous scroll in a cascade overview: vertical changes
824+// workspace (and does not bounce back on a workspace with another axis),
825+// horizontal moves along the band.
826+func TestOverviewCascadeScroll(t *testing.T) {
827+ vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
828+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
829+ t.Run(fmt.Sprint(source), func(t *testing.T) {
830+ m := cascadeOverview()
831+ w := m.Current()
832+ m.ToggleOverview()
833+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
834+ if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
835+ 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)
836+ }
837+
838+ m = cascadeOverview()
839+ w = m.Current()
840+ w.FocusID(7)
841+ m.ToggleOverview()
842+ m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
843+ if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
844+ t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
845+ }
846+
847+ // Onto a workspace with another axis: one step, no bounce.
848+ m = overviewMonitor()
849+ m.SetOverflow(OverflowFixed)
850+ m.Workspaces[1].Overflow = OverflowCascade
851+ m.ToggleOverview()
852+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
853+ if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
854+ t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
855+ }
856+ })
857+ }
858+}
@@ -0,0 +1,858 @@
1+package core
2+
3+import (
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.
15+func 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.
33+func 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.
46+func 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+
65+func centreY(r Rect) int { return r.Y + r.H/2 }
66+
67+func within1(a, b int) bool { return a-b <= 1 && b-a <= 1 }
68+
69+func hiddenOff(p Placement) bool { return p.Hidden && p.Preview == 0 && !p.Focused }
70+
71+// T1: the zoom does not depend on the number of bands.
72+func 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+// T2: the selected band is the centre lane: undimmed, centred, selected tile
79+// focused.
80+func 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+// T3: the other bands are dimmed lanes a screen (and a gap) away; those
97+// outside the area are hidden.
98+func 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+// T4: neighbor workspaces sit left and right of the current slot, dimmed,
145+// their selected lane level with the centre lane.
146+func 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+// T5: lanes along the overview axis would overlap the other workspaces: a
191+// cascade neighbor of a scroll workspace shows its selected band only.
192+func 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+// T6: the stash pile is inside the current slot, left of the band; the
220+// previous workspace stays clear of it.
221+func 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+
261+type cascadeDriver struct {
262+ name string
263+ move func(m *Monitor, dx, dy int)
264+}
265+
266+func 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+
273+var 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+
279+func focusedID(m *Monitor) WindowID {
280+ id, _ := m.Focused()
281+ return id
282+}
283+
284+// T7: ↑/↓ change band and stop at the ends; ←/→ walk the band, then leave it
285+// for the stash or the neighbor workspaces; Escape restores everything.
286+func 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 on main; confirming shows the picked window.
366+func 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.
434+func 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+// T8: a named boundary of a cascade overview is a vertical rule strictly
471+// between the two slots.
472+func TestOverviewCascadeNamedDivider(t *testing.T) {
473+ m := namedOverviewMonitor()
474+ m.Workspaces[1].Overflow = OverflowCascade
475+ m.ToggleOverview()
476+ rows, dividers := m.overviewRows()
477+ if len(dividers) != 1 {
478+ t.Fatalf("dividers: %+v", dividers)
479+ }
480+ d, cur, dev := dividers[0].Rect, rows[m.Workspaces[1]], rows[m.byName("dev")]
481+ if d.W != 1 || d.H <= 1 || dividers[0].Active {
482+ t.Fatalf("divider is not an inactive vertical rule: %+v", dividers[0])
483+ }
484+ if d.X < cur.X+cur.W || d.X+d.W > dev.X {
485+ t.Fatalf("divider %v not between %v and %v", d, cur, dev)
486+ }
487+}
488+
489+// T10: covering floats fan along the horizontal axis: left goes deeper in the
490+// stack (the cards behind show on the left), right comes back to the front.
491+func TestOverviewCascadeCoveringFloat(t *testing.T) {
492+ for _, d := range cascadeDrivers {
493+ t.Run(d.name, func(t *testing.T) {
494+ m := cascadeOverview()
495+ m.Current().AddFloating(90, 300, 200)
496+ w := m.Current()
497+ w.FocusID(10)
498+ m.ToggleOverview()
499+ if m.stackFront(w).kind != stackColumns {
500+ t.Fatal("setup: columns not in front")
501+ }
502+ d.move(m, 0, -1) // columns: up is a band
503+ d.move(m, -1, 0)
504+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
505+ t.Fatalf("left from the first column: front %+v", f)
506+ }
507+ if p := previewOf(t, m.Layout(), 90); p.Peek || !p.Focused {
508+ t.Fatalf("float not in front: %+v", p)
509+ }
510+ d.move(m, 0, 1) // a float front takes no band move
511+ if f := m.stackFront(w); f.kind != stackFloat {
512+ t.Fatalf("down left the float: %+v", f)
513+ }
514+ d.move(m, 1, 0)
515+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
516+ t.Fatalf("right from the float: front %+v", m.stackFront(w))
517+ }
518+
519+ // The float in front of the columns: they fan to its left.
520+ m.CancelOverview()
521+ w.FocusID(90)
522+ m.ToggleOverview()
523+ if f := m.stackFront(w); f.kind != stackFloat {
524+ t.Fatalf("setup: front %+v", f)
525+ }
526+ behind := previewOf(t, m.Layout(), 10)
527+ if !behind.Peek {
528+ t.Fatalf("columns not behind the float: %+v", behind)
529+ }
530+ d.move(m, -1, 0)
531+ if m.stackFront(w).kind != stackColumns || m.Current() != w {
532+ t.Fatalf("left from the front float: front %+v", m.stackFront(w))
533+ }
534+ // The cards behind fan to the left: coming to the front, the
535+ // columns move right by one peek step.
536+ front := previewOf(t, m.Layout(), 10)
537+ if front.Peek || front.Rect.X-behind.Rect.X != w.peekStep() {
538+ t.Fatalf("columns behind %v, in front %v, peek step %d", behind.Rect, front.Rect, w.peekStep())
539+ }
540+ d.move(m, 1, 0)
541+ if f := m.stackFront(w); f.kind != stackFloat || f.id != 90 {
542+ t.Fatalf("right from the columns behind the float: front %+v", f)
543+ }
544+ })
545+ }
546+}
547+
548+// T10b: a pinned fullscreen column keeps its Fullscreen tile in the band.
549+func TestOverviewCascadePinnedFullscreenTile(t *testing.T) {
550+ m := cascadeOverview()
551+ w := m.Current()
552+ w.FocusID(8)
553+ m.ToggleFullscreen()
554+ if !w.pinned() {
555+ t.Fatal("setup: not pinned")
556+ }
557+ m.ToggleOverview()
558+ ps := m.Layout()
559+ fs := previewOf(t, ps, 8)
560+ if !fs.Fullscreen || fs.Hidden {
561+ t.Fatalf("fullscreen tile: %+v", fs)
562+ }
563+ for _, id := range []WindowID{7, 9} {
564+ if p := previewOf(t, ps, id); p.Hidden || p.Fullscreen || p.Peek != (fs.Peek) {
565+ t.Fatalf("band tile %d: %+v", id, p)
566+ }
567+ }
568+ u := w.overviewArea()
569+ if !within1(centreY(fs.Rect), u.Y+u.H/2) {
570+ t.Fatalf("fullscreen tile not on the centre lane: %+v", fs.Rect)
571+ }
572+}
573+
574+// T11: the focus binds keep their meaning in a cascade overview.
575+func TestOverviewCascadeFocusBinds(t *testing.T) {
576+ m := cascadeOverview()
577+ w := m.Current()
578+ m.ToggleOverview()
579+ if !m.overviewFocus(ActionFocusWindowDown) || w.band(w.Focus) != 2 || m.Current() != w {
580+ t.Fatalf("focus-window-down: band %d", w.band(w.Focus))
581+ }
582+ if !m.overviewFocus(ActionFocusWindowUp) || w.band(w.Focus) != 1 {
583+ t.Fatalf("focus-window-up: band %d", w.band(w.Focus))
584+ }
585+ m.overviewFocus(ActionFocusWorkspaceNext)
586+ if m.Current() != m.Workspaces[2] {
587+ t.Fatal("focus-workspace-next did not change workspace")
588+ }
589+ m.overviewFocus(ActionFocusWorkspacePrev)
590+ if m.Current() != w {
591+ t.Fatal("focus-workspace-prev did not come back")
592+ }
593+ m.overviewFocus(ActionFocusWorkspacePrev)
594+ if m.Current() != m.Workspaces[0] {
595+ t.Fatal("focus-workspace-prev did not change workspace")
596+ }
597+ m.CancelOverview()
598+
599+ // A selected stash card keeps the pile cycle.
600+ withStash(m, 3)
601+ w.FocusID(7)
602+ m.ToggleOverview()
603+ m.OverviewMove(-1, 0)
604+ at := m.cardAt(w)
605+ m.overviewFocus(ActionFocusWorkspacePrev)
606+ if m.Current() != w || m.cardAt(w) != at-1 {
607+ t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
608+ }
609+ m.overviewFocus(ActionFocusWorkspaceNext)
610+ if m.Current() != w || m.cardAt(w) != at {
611+ t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
612+ }
613+}
614+
615+func TestOverviewMixedLayoutGestureAndCancel(t *testing.T) {
616+ m := overviewMonitor()
617+ m.SetOverflow(OverflowFixed)
618+ m.Current().Overflow = OverflowCascade
619+ from := m.Current()
620+ m.ToggleOverview()
621+ rows, _ := m.overviewRows()
622+ if rows[m.Workspaces[1]].X <= rows[from].X {
623+ t.Fatal("cascade neighbor not horizontal")
624+ }
625+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: overviewScrollStep}})
626+ if m.Current() == from {
627+ t.Fatal("horizontal gesture did not change workspace")
628+ }
629+ if m.Current().Overflow != OverflowFixed {
630+ t.Fatal("destination lost fixed policy")
631+ }
632+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled {
633+ t.Fatalf("scroll state after the axis change: %+v", m.ov)
634+ }
635+ m.CancelOverview()
636+ if m.Current() != from {
637+ t.Fatal("cancel lost source workspace")
638+ }
639+}
640+
641+// Showing a workspace with another overview axis drops the scroll leftovers
642+// of the old one.
643+func TestOverviewAxisChangeResetsScroll(t *testing.T) {
644+ m := cascadeOverview()
645+ m.ToggleOverview()
646+ m.ov.scrollX, m.ov.scrollY, m.ov.sideways, m.ov.scrolled = 20, 20, true, true
647+ m.showOverview(m.Workspaces[2])
648+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || m.ov.sideways || !m.ov.scrolled {
649+ t.Fatalf("scroll kept or latch dropped: %+v", m.ov)
650+ }
651+ m.ov.scrollX = 20
652+ m.showOverview(m.Workspaces[0])
653+ if m.ov.scrollX != 20 {
654+ t.Fatal("scroll reset between workspaces of the same axis")
655+ }
656+}
657+
658+// A reload that changes the current workspace's axes drops the scroll state;
659+// one that does not, keeps it.
660+func TestOverviewReloadResetsScrollOnAxisChange(t *testing.T) {
661+ c, _ := indicatorCore(t)
662+ m := c.cur().mon
663+ m.AddWindow(1)
664+ m.ToggleOverview()
665+ w := m.Current()
666+ set := func() { m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 20, 10, true, true }
667+ set()
668+ cfg := c.cfg
669+ cfg.Layout.Gaps++
670+ if err := c.apply(cfg); err != nil {
671+ t.Fatal(err)
672+ }
673+ if m.ov.scrollX != 20 || !m.ov.scrolled {
674+ t.Fatalf("unrelated reload reset the scroll: %+v", m.ov)
675+ }
676+ cfg.Layout.Overflow = string(OverflowCascade)
677+ if err := c.apply(cfg); err != nil {
678+ t.Fatal(err)
679+ }
680+ if m.Current().policy().workspace != horizontalAxis {
681+ t.Fatal("setup: not cascade")
682+ }
683+ // The reload keeps the overview open on its selection, and cancelling
684+ // still gives the original focus back.
685+ if id, _ := w.Focused(); !m.ov.open || id != 1 {
686+ t.Fatalf("reload lost the overview selection: open %v, focus %d", m.ov.open, id)
687+ }
688+ // The scroll is dropped, but not the latch that waits for the fingers
689+ // to lift.
690+ if m.ov.scrollX != 0 || m.ov.scrollY != 0 || !m.ov.scrolled || m.ov.sideways {
691+ t.Fatalf("axis change kept the scroll or dropped the latch: %+v", m.ov)
692+ }
693+ set()
694+ cfg.Layout.Overflow = string(OverflowFixed)
695+ if err := c.apply(cfg); err != nil {
696+ t.Fatal(err)
697+ }
698+ if m.ov.scrollX != 0 || !m.ov.scrolled {
699+ t.Fatalf("workspace axis change kept the scroll or dropped the latch: %+v", m.ov)
700+ }
701+ m.CancelOverview()
702+ if id, _ := w.Focused(); id != 1 {
703+ t.Fatalf("cancel left the focus on %d", id)
704+ }
705+}
706+
707+// The finger latch outlives an axis change: a gesture that turned the
708+// overview, then was turned back by a key, does not step again.
709+func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
710+ c, m := cascadeCore(t)
711+ w := m.Current()
712+ m.ToggleOverview()
713+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
714+ c.swipeUpdate(ports.SwipeUpdate{DX: 70})
715+ if m.Current() != m.Workspaces[2] || m.overviewAxis() != verticalAxis {
716+ t.Fatalf("setup: workspace %d", indexOf(m.Workspaces, m.Current()))
717+ }
718+ // Back to the cascade workspace by a key, with the fingers still down.
719+ m.overviewKey(ports.KeyEvent{Keysym: "k", Pressed: true})
720+ if m.Current() != w || m.overviewAxis() != horizontalAxis {
721+ t.Fatal("setup: the key did not turn the overview back")
722+ }
723+ for _, u := range []ports.SwipeUpdate{{DY: 70}, {DX: 70}, {DY: -70}, {DX: -70}} {
724+ c.swipeUpdate(u)
725+ }
726+ c.swipeEnd(ports.SwipeEnd{})
727+ if m.Current() != w || w.Focus != 0 {
728+ t.Fatalf("the gesture stepped again: workspace %d, column %d", indexOf(m.Workspaces, m.Current()), w.Focus)
729+ }
730+ // The next gesture steps again.
731+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
732+ c.swipeUpdate(ports.SwipeUpdate{DY: 70})
733+ c.swipeEnd(ports.SwipeEnd{})
734+ if w.band(w.Focus) != 1 {
735+ t.Fatalf("the next gesture did not step: band %d", w.band(w.Focus))
736+ }
737+}
738+
739+// A stash-only cascade workspace has no columns to select: right from its
740+// pile goes straight to the next workspace.
741+func TestOverviewCascadeStashOnlyRight(t *testing.T) {
742+ m := overviewMonitor()
743+ m.Focus(1)
744+ w := m.Current()
745+ w.Overflow = OverflowCascade
746+ w.ToggleWindowStash()
747+ m.Focus(2)
748+ m.AddWindow(30)
749+ m.Focus(1)
750+ m.ToggleOverview()
751+ if len(w.Columns) != 0 || m.cardAt(w) < 0 {
752+ t.Fatalf("setup: %d columns, card %d", len(w.Columns), m.cardAt(w))
753+ }
754+ m.OverviewMove(1, 0)
755+ if m.Current() != m.Workspaces[2] {
756+ t.Fatalf("right from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
757+ }
758+ m.OverviewMove(0, -1) // the next workspace is a scroll one: up goes back
759+ if m.Current() != w || m.cardAt(w) < 0 {
760+ t.Fatal("setup: back on the stash-only workspace without its card")
761+ }
762+ m.OverviewMove(-1, 0)
763+ if m.Current() != m.Workspaces[0] {
764+ t.Fatalf("left from the pile: workspace %d", indexOf(m.Workspaces, m.Current()))
765+ }
766+}
767+
768+// A cascade neighbor beside a cascade workspace draws its other lanes
769+// dimmed, its selected lane level with the centre lane.
770+func TestOverviewCascadeNeighborLanes(t *testing.T) {
771+ m := cascadeOverview()
772+ prev := m.Workspaces[0]
773+ prev.Overflow = OverflowCascade
774+ for id := WindowID(60); id <= 65; id++ {
775+ prev.AddWindow(id) // bands {1,2,3} {60,61,62} {63,64,65}, 65 selected
776+ }
777+ m.ToggleOverview()
778+ ps := m.Layout()
779+ centre := centreY(previewOf(t, ps, 8).Rect)
780+ sel := previewOf(t, ps, 65)
781+ if sel.Hidden || !sel.Peek || sel.Focused || !within1(centreY(sel.Rect), centre) {
782+ t.Fatalf("selected lane of the neighbor: %+v, centre lane %d", sel, centre)
783+ }
784+ above := previewOf(t, ps, 62)
785+ if above.Hidden || !above.Peek || above.Rect.Y+above.Rect.H > sel.Rect.Y {
786+ t.Fatalf("lane above in the neighbor: %+v, selected %v", above, sel.Rect)
787+ }
788+ if p := previewOf(t, ps, 3); !hiddenOff(p) {
789+ t.Fatalf("lane two away in the neighbor: %+v", p)
790+ }
791+}
792+
793+// A scroll neighbor wider than its slot scrolls inside it: what leaves the
794+// slot is hidden, so it never sits on the current slot.
795+func TestOverviewCascadeWideScrollNeighborIsClipped(t *testing.T) {
796+ m := cascadeOverview()
797+ prev := m.Workspaces[0]
798+ for id := WindowID(60); id <= 67; id++ {
799+ prev.AddWindow(id)
800+ }
801+ m.ToggleOverview()
802+ rows, _ := m.overviewRows()
803+ cur, slot := rows[m.Current()], rows[prev]
804+ hidden, shown := 0, 0
805+ for _, p := range m.Layout() {
806+ if !prev.has(p.ID) {
807+ continue
808+ }
809+ if p.Hidden {
810+ hidden++
811+ continue
812+ }
813+ shown++
814+ if p.Rect.X < slot.X || p.Rect.X+p.Rect.W > slot.X+slot.W || p.Rect.X+p.Rect.W > cur.X {
815+ t.Fatalf("tile %d leaves its slot %v or reaches the current slot %v: %v", p.ID, slot, cur, p.Rect)
816+ }
817+ }
818+ if hidden == 0 || shown == 0 {
819+ t.Fatalf("setup: %d tiles shown, %d hidden", shown, hidden)
820+ }
821+}
822+
823+// A wheel and a continuous scroll in a cascade overview: vertical changes
824+// workspace (and does not bounce back on a workspace with another axis),
825+// horizontal moves along the band.
826+func TestOverviewCascadeScroll(t *testing.T) {
827+ vertical := ports.ScrollAxis{Set: true, Value: overviewScrollStep, V120: 120}
828+ for _, source := range []ports.AxisSource{ports.AxisWheel, ports.AxisContinuous} {
829+ t.Run(fmt.Sprint(source), func(t *testing.T) {
830+ m := cascadeOverview()
831+ w := m.Current()
832+ m.ToggleOverview()
833+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
834+ if m.Current() != m.Workspaces[2] || w.band(w.Focus) != 1 || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
835+ 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)
836+ }
837+
838+ m = cascadeOverview()
839+ w = m.Current()
840+ w.FocusID(7)
841+ m.ToggleOverview()
842+ m.overviewScroll(ports.PointerAxis{Source: source, Horizontal: vertical})
843+ if m.Current() != w || w.Columns[w.Focus].Windows[0] != 8 {
844+ t.Fatalf("horizontal: column %d", w.Columns[w.Focus].Windows[0])
845+ }
846+
847+ // Onto a workspace with another axis: one step, no bounce.
848+ m = overviewMonitor()
849+ m.SetOverflow(OverflowFixed)
850+ m.Workspaces[1].Overflow = OverflowCascade
851+ m.ToggleOverview()
852+ m.overviewScroll(ports.PointerAxis{Source: source, Vertical: vertical})
853+ if m.Current() != m.Workspaces[1] || m.ov.scrollX != 0 || m.ov.scrollY != 0 {
854+ t.Fatalf("mixed: workspace %d, scroll %v,%v", indexOf(m.Workspaces, m.Current()), m.ov.scrollX, m.ov.scrollY)
855+ }
856+ })
857+ }
858+}
M internal/core/overview_stack.go +59 -24
@@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int {
4242 // stashed one is its card in the pile.
4343 func (w *Workspace) stack() []stackItem {
4444 var items []stackItem
45+ w.walkStack(func(item stackItem) bool {
46+ items = append(items, item)
47+ return true
48+ })
49+ return items
50+}
51+
52+// stacked reports whether the stack has several cards, without building it:
53+// the render path asks for every workspace of every frame.
54+func (w *Workspace) stacked() bool {
55+ _, n := w.stackHead()
56+ return n > 1
57+}
58+
59+// stackHead is the first item of the stack and the number of items, counted
60+// up to 2, without building the stack.
61+func (w *Workspace) stackHead() (first stackItem, n int) {
62+ w.walkStack(func(item stackItem) bool {
63+ if n == 0 {
64+ first = item
65+ }
66+ n++
67+ return n < 2
68+ })
69+ return first, n
70+}
71+
72+// walkStack yields the items of stack in order until yield returns false.
73+func (w *Workspace) walkStack(yield func(stackItem) bool) {
4574 c, columns := WindowID(0), len(w.Columns) > 0
4675 if w.pinned() {
4776 c = w.cover()
@@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem {
4978 switch {
5079 case c == 0, w.stashIndex(c) >= 0:
5180 case w.floatIndex(c) >= 0:
52- items = append(items, stackItem{stackFloat, c})
81+ if !yield(stackItem{stackFloat, c}) {
82+ return
83+ }
5384 case w.overviewMaximized():
54- items = append(items, stackItem{stackColumn, c})
85+ if !yield(stackItem{stackColumn, c}) {
86+ return
87+ }
5588 default:
5689 // One card of all columns, its own marked fullscreen.
57- items = append(items, stackItem{kind: stackColumns})
90+ if !yield(stackItem{kind: stackColumns}) {
91+ return
92+ }
5893 columns = false
5994 }
6095 for i := len(w.Floats) - 1; i >= 0; i-- {
6196 f := w.Floats[i]
62- if !f.below && w.coversFloat(f) {
63- items = append(items, stackItem{stackFloat, f.ID})
97+ if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
98+ return
6499 }
65100 }
66- if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {
67- items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})
101+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) {
102+ return
68103 }
69- if columns {
70- items = append(items, stackItem{kind: stackColumns})
104+ if columns && !yield(stackItem{kind: stackColumns}) {
105+ return
71106 }
72107 for i := len(w.Floats) - 1; i >= 0; i-- {
73108 f := w.Floats[i]
74- if f.below && w.coversFloat(f) {
75- items = append(items, stackItem{stackFloat, f.ID})
109+ if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
110+ return
76111 }
77112 }
78- return items
79113 }
80114
81115 // itemOf resolves a clicked tile to the card currently containing it.
@@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) {
234268
235269 // previewItem draws a group using screen geometry and a single card at its
236270 // configured size. A prior maximized column retains its full-width buffer.
237-func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit bool) []Placement {
271+func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
238272 if item.kind == stackColumns {
239273 if !w.overviewMaximized() {
240- return w.previewRow(slot, dim, lit)
274+ return w.previewRow(axis, slot, dim, lit)
241275 }
242276 // The hidden columns fill the screen as if the maximized column,
243277 // shown on its own card, were not there. In particular, an earlier
@@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit
260294 }
261295 sel := rects[selected]
262296 sel.X -= w.Usable.X
263- return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, sel)
297+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel)
264298 }
265299 g := w.gap()
266300 if item.kind == stackFloat && item.id == w.cover() {
267301 // A fullscreen float shows at the size of a fullscreen column.
268302 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
269- return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
303+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
270304 }
271305 if item.kind == stackFloat {
272306 f := w.Floats[w.floatIndex(item.id)]
273307 r := w.floatRect(f)
274308 r.X -= w.Usable.X
275309 r.Y -= w.Usable.Y
276- return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
310+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
277311 }
278312 i := w.columnOf(item.id)
279313 if i < 0 {
@@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit
288322 for j, t := range w.rowBuf {
289323 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
290324 }
291- return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, r)
325+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r)
292326 }
293327
294328 // hiddenColumnRects lays out the columns behind the maximized one with the
@@ -304,13 +338,14 @@ func (w *Workspace) hiddenColumnRects() []Rect {
304338 // stackRow draws up to two cards on each side of the provisional front,
305339 // fanned along axis.
306340 func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
307- items := w.stack()
308- if len(items) == 0 {
309- return w.previewRow(slot, dim, lit)
341+ first, n := w.stackHead()
342+ if n == 0 {
343+ return w.previewRow(axis, slot, dim, lit)
310344 }
311- if len(items) == 1 {
312- return m.previewItem(w, items[0], slot, dim, lit)
345+ if n == 1 {
346+ return m.previewItem(w, first, axis, slot, dim, lit)
313347 }
348+ items := w.stack()
314349 at := 0
315350 if w == m.Current() {
316351 at = slices.Index(items, m.stackFront(w))
@@ -324,7 +359,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
324359 return r.X, r.Y
325360 }
326361 for i, item := range items {
327- cards[i] = m.previewItem(w, item, w.frontSlot(axis, slot), dim, lit)
362+ cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit)
328363 }
329364
330365 // fan's behind side advances through the linear stack.
@@ -42,6 +42,35 @@ func (w *Workspace) columnOf(id WindowID) int {
42 // stashed one is its card in the pile.42 // stashed one is its card in the pile.
43 func (w *Workspace) stack() []stackItem {43 func (w *Workspace) stack() []stackItem {
44 var items []stackItem44 var items []stackItem
45+ w.walkStack(func(item stackItem) bool {
46+ items = append(items, item)
47+ return true
48+ })
49+ return items
50+}
51+
52+// stacked reports whether the stack has several cards, without building it:
53+// the render path asks for every workspace of every frame.
54+func (w *Workspace) stacked() bool {
55+ _, n := w.stackHead()
56+ return n > 1
57+}
58+
59+// stackHead is the first item of the stack and the number of items, counted
60+// up to 2, without building the stack.
61+func (w *Workspace) stackHead() (first stackItem, n int) {
62+ w.walkStack(func(item stackItem) bool {
63+ if n == 0 {
64+ first = item
65+ }
66+ n++
67+ return n < 2
68+ })
69+ return first, n
70+}
71+
72+// walkStack yields the items of stack in order until yield returns false.
73+func (w *Workspace) walkStack(yield func(stackItem) bool) {
45 c, columns := WindowID(0), len(w.Columns) > 074 c, columns := WindowID(0), len(w.Columns) > 0
46 if w.pinned() {75 if w.pinned() {
47 c = w.cover()76 c = w.cover()
@@ -49,33 +78,38 @@ func (w *Workspace) stack() []stackItem {
49 switch {78 switch {
50 case c == 0, w.stashIndex(c) >= 0:79 case c == 0, w.stashIndex(c) >= 0:
51 case w.floatIndex(c) >= 0:80 case w.floatIndex(c) >= 0:
52- items = append(items, stackItem{stackFloat, c})81+ if !yield(stackItem{stackFloat, c}) {
82+ return
83+ }
53 case w.overviewMaximized():84 case w.overviewMaximized():
54- items = append(items, stackItem{stackColumn, c})85+ if !yield(stackItem{stackColumn, c}) {
86+ return
87+ }
55 default:88 default:
56 // One card of all columns, its own marked fullscreen.89 // One card of all columns, its own marked fullscreen.
57- items = append(items, stackItem{kind: stackColumns})90+ if !yield(stackItem{kind: stackColumns}) {
91+ return
92+ }
58 columns = false93 columns = false
59 }94 }
60 for i := len(w.Floats) - 1; i >= 0; i-- {95 for i := len(w.Floats) - 1; i >= 0; i-- {
61 f := w.Floats[i]96 f := w.Floats[i]
62- if !f.below && w.coversFloat(f) {97+ if !f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
63- items = append(items, stackItem{stackFloat, f.ID})98+ return
64 }99 }
65 }100 }
66- if (c == 0 || w.isFloat(c)) && w.overviewMaximized() {101+ if (c == 0 || w.isFloat(c)) && w.overviewMaximized() && !yield(stackItem{stackColumn, w.Columns[w.Focus].Windows[0]}) {
67- items = append(items, stackItem{stackColumn, w.Columns[w.Focus].Windows[0]})102+ return
68 }103 }
69- if columns {104+ if columns && !yield(stackItem{kind: stackColumns}) {
70- items = append(items, stackItem{kind: stackColumns})105+ return
71 }106 }
72 for i := len(w.Floats) - 1; i >= 0; i-- {107 for i := len(w.Floats) - 1; i >= 0; i-- {
73 f := w.Floats[i]108 f := w.Floats[i]
74- if f.below && w.coversFloat(f) {109+ if f.below && w.coversFloat(f) && !yield(stackItem{stackFloat, f.ID}) {
75- items = append(items, stackItem{stackFloat, f.ID})110+ return
76 }111 }
77 }112 }
78- return items
79 }113 }
80 114
81 // itemOf resolves a clicked tile to the card currently containing it.115 // itemOf resolves a clicked tile to the card currently containing it.
@@ -234,10 +268,10 @@ func (w *Workspace) apply(item stackItem, selected WindowID) {
234 268
235 // previewItem draws a group using screen geometry and a single card at its269 // previewItem draws a group using screen geometry and a single card at its
236 // configured size. A prior maximized column retains its full-width buffer.270 // configured size. A prior maximized column retains its full-width buffer.
237-func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit bool) []Placement {271+func (m *Monitor) previewItem(w *Workspace, item stackItem, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
238 if item.kind == stackColumns {272 if item.kind == stackColumns {
239 if !w.overviewMaximized() {273 if !w.overviewMaximized() {
240- return w.previewRow(slot, dim, lit)274+ return w.previewRow(axis, slot, dim, lit)
241 }275 }
242 // The hidden columns fill the screen as if the maximized column,276 // The hidden columns fill the screen as if the maximized column,
243 // shown on its own card, were not there. In particular, an earlier277 // shown on its own card, were not there. In particular, an earlier
@@ -260,20 +294,20 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit
260 }294 }
261 sel := rects[selected]295 sel := rects[selected]
262 sel.X -= w.Usable.X296 sel.X -= w.Usable.X
263- return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, sel)297+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, sel)
264 }298 }
265 g := w.gap()299 g := w.gap()
266 if item.kind == stackFloat && item.id == w.cover() {300 if item.kind == stackFloat && item.id == w.cover() {
267 // A fullscreen float shows at the size of a fullscreen column.301 // A fullscreen float shows at the size of a fullscreen column.
268 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}302 r := Rect{X: g, Y: g, W: max(w.Usable.W-2*g, 0), H: max(w.Usable.H-2*g, 0)}
269- return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)303+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Focused: true, Fullscreen: true}}, w.Usable.W, r)
270 }304 }
271 if item.kind == stackFloat {305 if item.kind == stackFloat {
272 f := w.Floats[w.floatIndex(item.id)]306 f := w.Floats[w.floatIndex(item.id)]
273 r := w.floatRect(f)307 r := w.floatRect(f)
274 r.X -= w.Usable.X308 r.X -= w.Usable.X
275 r.Y -= w.Usable.Y309 r.Y -= w.Usable.Y
276- return w.previewRowTiles(slot, dim, lit, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)310+ return w.previewRowTiles(axis, slot, dim, lit, false, []Placement{{ID: item.id, Rect: r, Floating: true, Focused: true}}, w.Usable.W, r)
277 }311 }
278 i := w.columnOf(item.id)312 i := w.columnOf(item.id)
279 if i < 0 {313 if i < 0 {
@@ -288,7 +322,7 @@ func (m *Monitor) previewItem(w *Workspace, item stackItem, slot Rect, dim, lit
288 for j, t := range w.rowBuf {322 for j, t := range w.rowBuf {
289 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})323 tiles = append(tiles, Placement{ID: c.Windows[j], Rect: t, Focused: j == c.Focus, Fullscreen: c.Windows[j] == w.cover()})
290 }324 }
291- return w.previewRowTiles(slot, dim, lit, tiles, w.Usable.W, r)325+ return w.previewRowTiles(axis, slot, dim, lit, false, tiles, w.Usable.W, r)
292 }326 }
293 327
294 // hiddenColumnRects lays out the columns behind the maximized one with the328 // hiddenColumnRects lays out the columns behind the maximized one with the
@@ -304,13 +338,14 @@ func (w *Workspace) hiddenColumnRects() []Rect {
304 // stackRow draws up to two cards on each side of the provisional front,338 // stackRow draws up to two cards on each side of the provisional front,
305 // fanned along axis.339 // fanned along axis.
306 func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement {340 func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bool) []Placement {
307- items := w.stack()341+ first, n := w.stackHead()
308- if len(items) == 0 {342+ if n == 0 {
309- return w.previewRow(slot, dim, lit)343+ return w.previewRow(axis, slot, dim, lit)
310 }344 }
311- if len(items) == 1 {345+ if n == 1 {
312- return m.previewItem(w, items[0], slot, dim, lit)346+ return m.previewItem(w, first, axis, slot, dim, lit)
313 }347 }
348+ items := w.stack()
314 at := 0349 at := 0
315 if w == m.Current() {350 if w == m.Current() {
316 at = slices.Index(items, m.stackFront(w))351 at = slices.Index(items, m.stackFront(w))
@@ -324,7 +359,7 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
324 return r.X, r.Y359 return r.X, r.Y
325 }360 }
326 for i, item := range items {361 for i, item := range items {
327- cards[i] = m.previewItem(w, item, w.frontSlot(axis, slot), dim, lit)362+ cards[i] = m.previewItem(w, item, axis, w.frontSlot(axis, slot), dim, lit)
328 }363 }
329 364
330 // fan's behind side advances through the linear stack.365 // fan's behind side advances through the linear stack.
M internal/core/publish_alloc_test.go +55 -19
@@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) {
187187
188188 // TestPublishOverviewAllocations pins the cost of publishing while the
189189 // overview opens: its cards fly from the full rects (scale and fade motions
190-// on every card), which guards the reuse of Core.overviewReal.
190+// on every card), which guards the reuse of Core.overviewReal. The current
191+// workspace is scroll or cascade, alone or between two workspaces with
192+// windows (their slots and stacks are laid out every frame too).
191193 func TestPublishOverviewAllocations(t *testing.T) {
192- c := publishRig(t, 1)
193- sc := c.cur()
194- now := time.Now()
195- shots := c.snapshot(now)
196- sc.mon.ToggleOverview()
197- c.transition(shots, now)
198- step := publishStep(t, c, nil)
199- step()
200- if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() {
201- t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating())
202- }
203- n := testing.AllocsPerRun(50, step)
204- t.Logf("overview allocs per step = %v", n)
205- if len(sc.rects) == 0 {
206- t.Fatal("card motions settled during the measure")
207- }
208- if n > publishOverviewAllocBudget {
209- t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
194+ for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} {
195+ for _, neighbors := range []bool{false, true} {
196+ name := string(overflow) + "/alone"
197+ if neighbors {
198+ name = string(overflow) + "/neighbors"
199+ }
200+ t.Run(name, func(t *testing.T) {
201+ c := publishRig(t, 1)
202+ sc := c.cur()
203+ m := sc.mon
204+ if neighbors {
205+ // Windows 1..6 are on workspace 0: move to 1 with a
206+ // workspace on each side.
207+ m.Focus(1)
208+ for id := WindowID(10); id < 16; id++ {
209+ m.AddWindow(id)
210+ }
211+ m.Focus(2)
212+ for id := WindowID(20); id < 26; id++ {
213+ m.AddWindow(id)
214+ }
215+ m.Focus(1)
216+ }
217+ m.Current().Overflow = overflow
218+ sc.arrange()
219+ c.refreshShown()
220+ now := time.Now()
221+ shots := c.snapshot(now)
222+ m.ToggleOverview()
223+ c.transition(shots, now)
224+ step := publishStep(t, c, nil)
225+ step()
226+ want, slots := 6, 1
227+ if neighbors {
228+ want, slots = 18, 3
229+ }
230+ if rows, _ := m.overviewRows(); len(rows) != slots {
231+ t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
232+ }
233+ if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
234+ t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
235+ }
236+ n := testing.AllocsPerRun(50, step)
237+ t.Logf("%s overview allocs per step = %v", name, n)
238+ if len(sc.rects) == 0 {
239+ t.Fatal("card motions settled during the measure")
240+ }
241+ if n > publishOverviewAllocBudget {
242+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
243+ }
244+ })
245+ }
210246 }
211247 }
212248
@@ -187,26 +187,62 @@ func TestPublishStashSlideAllocations(t *testing.T) {
187 187
188 // TestPublishOverviewAllocations pins the cost of publishing while the188 // TestPublishOverviewAllocations pins the cost of publishing while the
189 // overview opens: its cards fly from the full rects (scale and fade motions189 // overview opens: its cards fly from the full rects (scale and fade motions
190-// on every card), which guards the reuse of Core.overviewReal.190+// on every card), which guards the reuse of Core.overviewReal. The current
191+// workspace is scroll or cascade, alone or between two workspaces with
192+// windows (their slots and stacks are laid out every frame too).
191 func TestPublishOverviewAllocations(t *testing.T) {193 func TestPublishOverviewAllocations(t *testing.T) {
192- c := publishRig(t, 1)194+ for _, overflow := range []Overflow{OverflowScroll, OverflowCascade} {
193- sc := c.cur()195+ for _, neighbors := range []bool{false, true} {
194- now := time.Now()196+ name := string(overflow) + "/alone"
195- shots := c.snapshot(now)197+ if neighbors {
196- sc.mon.ToggleOverview()198+ name = string(overflow) + "/neighbors"
197- c.transition(shots, now)199+ }
198- step := publishStep(t, c, nil)200+ t.Run(name, func(t *testing.T) {
199- step()201+ c := publishRig(t, 1)
200- if !sc.mon.ov.open || len(c.overviewReal) != 6 || len(sc.rects) == 0 || !c.animating() {202+ sc := c.cur()
201- t.Fatalf("setup: overview %v, real layouts %d, rects %d, animating %v", sc.mon.ov.open, len(c.overviewReal), len(sc.rects), c.animating())203+ m := sc.mon
202- }204+ if neighbors {
203- n := testing.AllocsPerRun(50, step)205+ // Windows 1..6 are on workspace 0: move to 1 with a
204- t.Logf("overview allocs per step = %v", n)206+ // workspace on each side.
205- if len(sc.rects) == 0 {207+ m.Focus(1)
206- t.Fatal("card motions settled during the measure")208+ for id := WindowID(10); id < 16; id++ {
207- }209+ m.AddWindow(id)
208- if n > publishOverviewAllocBudget {210+ }
209- t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)211+ m.Focus(2)
212+ for id := WindowID(20); id < 26; id++ {
213+ m.AddWindow(id)
214+ }
215+ m.Focus(1)
216+ }
217+ m.Current().Overflow = overflow
218+ sc.arrange()
219+ c.refreshShown()
220+ now := time.Now()
221+ shots := c.snapshot(now)
222+ m.ToggleOverview()
223+ c.transition(shots, now)
224+ step := publishStep(t, c, nil)
225+ step()
226+ want, slots := 6, 1
227+ if neighbors {
228+ want, slots = 18, 3
229+ }
230+ if rows, _ := m.overviewRows(); len(rows) != slots {
231+ t.Fatalf("setup: %d overview slots, want %d", len(rows), slots)
232+ }
233+ if !m.ov.open || m.overviewAxis() != m.Current().policy().workspace || len(c.overviewReal) != want || len(sc.rects) == 0 || !c.animating() {
234+ t.Fatalf("setup: overview %v, real layouts %d (want %d), rects %d, animating %v", m.ov.open, len(c.overviewReal), want, len(sc.rects), c.animating())
235+ }
236+ n := testing.AllocsPerRun(50, step)
237+ t.Logf("%s overview allocs per step = %v", name, n)
238+ if len(sc.rects) == 0 {
239+ t.Fatal("card motions settled during the measure")
240+ }
241+ if n > publishOverviewAllocBudget {
242+ t.Errorf("overview publish allocs per frame = %v, budget %d", n, publishOverviewAllocBudget)
243+ }
244+ })
245+ }
210 }246 }
211 }247 }
212 248

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