detached fc79ddf

feat(overview): unfold the selected stash pile

The overview showed a stash as a folded pile even when selected, so the
stashed windows behind the front card were hard to see and count. The
pile now unfolds when selected into a strip of every card: a row under
the lanes in a cascade overview, a column left of the row otherwise.
Moves along the strip pick a card, and a strip longer than the area
scrolls to keep the selection in view. Leaving it folds the pile again.

In a cascade overview the pile now sits under the selected lane instead
of over its bottom edge: the lane and the pile are centred together.
commit
fc79ddfb17dadbbe018cc88f96a52b66f9fb7412
author
brice · · 2026-10-15 05:16 UTC
parent
8193e80
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +394 -112
M docs/config.md +5 -1
@@ -275,4 +275,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
275275
276276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277277
278-With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.
278+A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again.
279+
280+With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards, then go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
281+
282+Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns.
@@ -275,4 +275,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
275 275
276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277 277
278-With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.278+A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again.
279+
280+With `layout.overflow = cascade`, workspaces sit side by side, each band of columns is a lane, and the stash sits under the lanes. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it unfolds the stash. Left/right walk the cards, then go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
281+
282+Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns.
M internal/core/overview.go +141 -71
@@ -53,12 +53,15 @@ type rowSnapshot struct {
5353 // workspace. A pinned fullscreen window is the front card of its row, the
5454 // windows it hides behind it; choosing one of them leaves fullscreen.
5555
56-// A workspace's stash shows as a pile of cards: the selected stashed window
57-// in front at overviewCardZoom, up to overviewCards-1 others behind it, each
58-// overviewCardStep further up and right, dimmed. A vertical overview pins
59-// the pile to the left edge of its row, which takes the width the pile
60-// leaves. A horizontal one centres it on its row, straddling the bottom edge
61-// of the selected lane, over the tiles.
56+// A workspace's stash shows as a pile of cards, folded or unfolded. Folded
57+// (not selected), the stash's own selection is in front at
58+// overviewCardZoom, up to overviewCards-1 others behind it, each
59+// overviewCardStep further up and right, dimmed. Selected, the pile
60+// unfolds along the overview axis into a strip of every card (unfold), the
61+// selection framed, and the moves along that axis walk it. A vertical
62+// overview pins the pile to the left edge of its row, which takes the width
63+// the pile leaves. A horizontal one centres it on its row, straddling the
64+// bottom edge of the selected lane, over the tiles.
6265 const (
6366 overviewCardZoom = 0.2
6467 overviewCards = 4
@@ -398,21 +401,20 @@ func (m *Monitor) OverviewMove(dx, dy int) {
398401 }
399402
400403 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
401-// walks the stack cards before changing workspace (overviewVertical). A
402-// side-by-side one always changes workspace, since its stack fans left and
403-// right. A selected stash card cycles the pile first, in every layout.
404+// walks the unfolded stash and the stack cards before changing workspace
405+// (overviewVertical). A side-by-side one always changes workspace, since
406+// its stack and unfolded stash run left and right.
404407 func (m *Monitor) overviewWorkspaceStep(d int) {
405408 if m.overviewAxis() == verticalAxis {
406409 m.overviewVertical(d)
407410 return
408411 }
409- if !m.cycleCard(m.Current(), d) {
410- m.overviewStep(d)
411- }
412+ m.overviewStep(d)
412413 }
413414
414-// cycleCard brings the stash card d entries from the selected one of w to
415-// the front, stopping at either end. It reports whether a card was selected.
415+// cycleCard selects the stash card d entries from the selected one of w in
416+// the unfolded pile, stopping at either end. It reports whether a card was
417+// selected.
416418 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
417419 at := m.cardAt(w)
418420 if at < 0 {
@@ -423,19 +425,16 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423425 }
424426
425427 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade). From a selected stash card, down brings the next card
427-// of the pile to the front (stopping at the last) and up leaves the pile for
428-// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.
429-// Else the selection goes to the previous or next lane (band), at the
430-// closest column, stopping at the first. Down past the last lane, or from a
431-// card that is not the columns, enters the stash, which sits below. Covering
432-// floats stack along the horizontal axis, not this one.
428+// by side (cascade). From the unfolded stash, up leaves it for the last lane
429+// (leavePile) and down stays: the stash is the bottom. Else the selection
430+// goes to the previous or next lane (band), at the closest column, stopping
431+// at the first. Down past the last lane, or from a card that is not the
432+// columns, enters the stash, which sits below. Covering floats stack along
433+// the horizontal axis, not this one.
433434 func (m *Monitor) overviewLane(dy int) {
434435 w := m.Current()
435436 if m.cardAt(w) >= 0 {
436- if dy > 0 {
437- m.cycleCard(w, dy)
438- } else {
437+ if dy < 0 {
439438 m.leavePile(w)
440439 }
441440 return
@@ -479,31 +478,19 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
479478 }
480479
481480 // overviewAcross is the horizontal move of an overview whose workspaces sit
482-// side by side (cascade): a column inside the selected lane. Past the
483-// lane's first column it goes to the previous workspace; past the last
484-// column, to the next workspace. From a stash card, left goes to the
485-// previous workspace and right back to the columns. Covering floats fan
481+// side by side (cascade): a column inside the selected lane, or a card of
482+// the unfolded stash. Past the first column or card it goes to the previous
483+// workspace; past the last, to the next workspace. Covering floats fan
486484 // along this axis and are traversed before a workspace: the card on the
487485 // left is the one behind.
488486 func (m *Monitor) overviewAcross(dx int) {
489487 w := m.Current()
490- if m.cardAt(w) >= 0 {
491- if dx < 0 || len(w.stack()) == 0 {
492- // Left leaves the pile; a stash-only workspace has nothing
493- // to its right to select.
494- m.overviewStep(dx)
488+ if at := m.cardAt(w); at >= 0 {
489+ if next := at + dx; next >= 0 && next < len(w.Stash) {
490+ m.selectCard(w, next)
495491 return
496492 }
497- m.clearCard()
498- m.ov.selected = 0
499- if len(w.Columns) > 0 {
500- // The columns come to the front, even when down entered the
501- // stash from a covering float: at the first column of the
502- // focused lane.
503- m.setFront(w, stackItem{kind: stackColumns})
504- i, _ := w.laneBounds(w.laneOf(w.Focus))
505- m.selectLaneColumn(w, i)
506- }
493+ m.overviewStep(dx)
507494 return
508495 }
509496 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -744,13 +731,23 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
744731 cur := m.Current()
745732 rows, _ := m.overviewRows()
746733 axis := m.overviewAxis()
734+ // The unfolded stash goes last: its strip may run over the neighbor
735+ // rows, which must not cover it.
736+ var unfolded []Placement
747737 for w := range m.all() {
748738 slot, shown := rows[w]
749739 if shown {
740+ // The pile band goes under the row: the row is centred
741+ // with it.
742+ slot.H -= w.pileBand(axis)
750743 at := m.cardAt(w)
751744 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
752745 if len(w.Stash) > 0 {
753- result = append(result, w.pile(axis, slot, w != cur, at)...)
746+ if cards := w.pile(axis, slot, w != cur, at); at >= 0 {
747+ unfolded = cards
748+ } else {
749+ result = append(result, cards...)
750+ }
754751 }
755752 }
756753 for _, f := range w.Floats {
@@ -763,7 +760,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
763760 result = w.appendHidden(result, false)
764761 }
765762 }
766- return result
763+ return append(result, unfolded...)
767764 }
768765
769766 // fan places front and neighboring cards, farthest visible first and front
@@ -846,6 +843,24 @@ func (w *Workspace) pileWidth() int {
846843 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
847844 }
848845
846+// cardSize is the size of a stash card in the overview.
847+func (w *Workspace) cardSize() (int, int) {
848+ r := w.stashRect()
849+ return int(math.Round(float64(r.W) * overviewCardZoom)), int(math.Round(float64(r.H) * overviewCardZoom))
850+}
851+
852+// pileBand is the height a horizontal overview gives the stash of w under
853+// its selected lane: the spacing, then the folded pile with its cards
854+// behind. 0 without a stash, or in a vertical overview, where the pile
855+// sits left of the row. The lane and the band are centred together.
856+func (w *Workspace) pileBand(axis layoutAxis) int {
857+ if axis != horizontalAxis || len(w.Stash) == 0 {
858+ return 0
859+ }
860+ _, ch := w.cardSize()
861+ return max(1, w.overviewArea().H*3/100) + ch + (min(len(w.Stash), overviewCards)-1)*w.cardStep()
862+}
863+
849864 // cardStep is the offset between stacked cards, smaller on tiny outputs.
850865 func (w *Workspace) cardStep() int {
851866 a := w.overviewArea()
@@ -853,46 +868,87 @@ func (w *Workspace) cardStep() int {
853868 }
854869
855870 // pile places the stash of w as cards in its slot along axis (see the
856-// overview's stash comment): entry front (the stash selection when -1) in
857-// front, the next ones behind it, dimmed, the rest hidden. Only a front card
858-// of the current row (dim false) with front set is selected.
859-func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Placement {
871+// overview's stash comment). With entry at selected (at >= 0, on the
872+// current row: dim false) the pile unfolds; else it is folded, the stash
873+// selection in front, the next ones behind it, dimmed, the rest hidden.
874+func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, at int) []Placement {
860875 n := len(w.Stash)
861876 if n == 0 {
862877 return nil
863878 }
864- selected := front >= 0 && !dim
865- if front < 0 {
866- front = w.stashAt
867- }
868- front = min(max(front, 0), n-1)
869- r := w.stashRect()
870879 u := w.overviewArea()
871- cw := int(math.Round(float64(r.W) * overviewCardZoom))
872- ch := int(math.Round(float64(r.H) * overviewCardZoom))
880+ cw, ch := w.cardSize()
873881 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
874882 var x0, y0 int
875883 if axis == horizontalAxis {
876- // Centred on the row, across the bottom edge of the selected
877- // lane, which is centred in the slot. Both scale with the area
878- // (the lane at most overviewMaxZoom of it), so the pile stays
879- // inside whatever its size.
884+ // Centred on the row, under the selected lane: slot is the
885+ // lane's, the pile band (pileBand) taken off its height, so the
886+ // band starts below the lane. The cards behind rise from the
887+ // front one, which goes at the bottom of the band. Both scale
888+ // with the area (the lane at most overviewMaxZoom of it), so
889+ // the pile stays inside whatever its size.
880890 x0 = slot.X + (slot.W-cw)/2
881- y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2
891+ y0 = slot.Y + (slot.H-rowH)/2 + rowH + w.pileBand(axis) - ch
882892 } else {
883893 // Stack rows reserve two peek steps above the front card. Align
884894 // the stash with that front band, not with the top of its envelope.
885895 band := w.frontSlot(axis, slot)
886896 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
887897 }
898+ folded := Rect{X: x0, Y: y0, W: cw, H: ch}
899+ if at >= 0 && !dim {
900+ return w.unfold(axis, folded, min(at, n-1))
901+ }
888902 cards := make([][]Placement, n)
889903 for i := range w.Stash {
890- cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected, Card: true}}
904+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: folded, Preview: overviewCardZoom, Card: true}}
891905 }
892906 step := w.cardStep()
893- return fan(cards, front, min(n, overviewCards)-1, 0, true,
907+ return fan(cards, min(max(w.stashAt, 0), n-1), min(n, overviewCards)-1, 0, true,
894908 func(d int) (int, int) { return d * step, -d * step },
895- func(int) (int, int) { return 0, 0 }, dim, selected)
909+ func(int) (int, int) { return 0, 0 }, dim, false)
910+}
911+
912+// unfold lays the stash of w out as a strip of every card along axis, from
913+// the folded pile's card rect: a row in a horizontal overview, a column in
914+// a vertical one. The strip is centred on the pile and kept in the overview
915+// area. Longer than the area, it scrolls like a wide row: the selected card
916+// at is centred, the strip stopping at the area's edges. The other cards
917+// are dimmed; those out of the area are hidden.
918+func (w *Workspace) unfold(axis layoutAxis, folded Rect, at int) []Placement {
919+ n := len(w.Stash)
920+ u := w.overviewArea()
921+ size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W
922+ if axis == verticalAxis {
923+ size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H
924+ }
925+ stride := size + 2*w.cardStep()
926+ total := n*stride - (stride - size)
927+ start := origin + size/2 - total/2
928+ if total > hi-lo {
929+ start = origin + size/2 - at*stride - size/2
930+ start = min(max(start, hi-total), lo)
931+ } else {
932+ start = min(max(start, lo), hi-total)
933+ }
934+ out := make([]Placement, n)
935+ for i := range w.Stash {
936+ r := folded
937+ if axis == verticalAxis {
938+ r.Y = start + i*stride
939+ } else {
940+ r.X = start + i*stride
941+ }
942+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
943+ if i != at {
944+ p.peeking(1)
945+ }
946+ if !r.Overlaps(u) {
947+ p.Hidden, p.Preview, p.Focused = true, 0, false
948+ }
949+ out[i] = p
950+ }
951+ return out
896952 }
897953
898954 // overviewZoom is the scale that fits every column of w in the usable
@@ -1098,6 +1154,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
10981154 // Lanes along the overview axis would overlap the neighbor workspaces.
10991155 otherLanes := w.policy().content != axis
11001156 laneStep := scale(stride) + max(1, u.H*3/100)
1157+ // The lanes below the selected one leave room for the stash pile.
1158+ below := w.pileBand(axis)
11011159 selLane := 0
11021160 if lanes {
11031161 selLane = max(sel.Y, 0) / stride
@@ -1113,7 +1171,11 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
11131171 lane, ly = l-selLane, r.Y-l*stride
11141172 }
11151173 x0, y0 := scale(r.X), scale(ly)
1116- 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}
1174+ y := top + y0 + lane*laneStep
1175+ if lanes && lane > 0 {
1176+ y += below
1177+ }
1178+ tiles[i].Rect = Rect{X: x + x0, Y: y, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
11171179 tiles[i].Preview = z
11181180 if dim || lane != 0 {
11191181 tiles[i].peeking(1)
@@ -1408,9 +1470,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
14081470 if finger {
14091471 before = m.overviewSelection()
14101472 }
1411- if cascade && !finger && m.cardAt(m.Current()) < 0 {
1473+ switch {
1474+ case cascade && !finger && m.cardAt(m.Current()) >= 0:
1475+ // A wheel walks the unfolded stash, a row.
1476+ m.OverviewMove(d, 0)
1477+ case cascade && !finger:
14121478 m.overviewStep(d)
1413- } else {
1479+ default:
14141480 m.OverviewMove(0, d)
14151481 }
14161482 if m.overviewTurned(axis, finger) {
@@ -1424,9 +1490,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
14241490 d := sign(m.ov.scrollX)
14251491 if cascade && finger {
14261492 before = m.overviewSelection()
1427- // One step per gesture: one workspace, even over a
1428- // stash card.
1429- m.overviewStep(d)
1493+ // One step per gesture: one card of the unfolded
1494+ // stash, else one workspace.
1495+ if m.cardAt(m.Current()) >= 0 {
1496+ m.OverviewMove(d, 0)
1497+ } else {
1498+ m.overviewStep(d)
1499+ }
14301500 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
14311501 return m.overviewSelection() != before
14321502 }
@@ -53,12 +53,15 @@ type rowSnapshot struct {
53 // workspace. A pinned fullscreen window is the front card of its row, the53 // workspace. A pinned fullscreen window is the front card of its row, the
54 // windows it hides behind it; choosing one of them leaves fullscreen.54 // windows it hides behind it; choosing one of them leaves fullscreen.
55 55
56-// A workspace's stash shows as a pile of cards: the selected stashed window56+// A workspace's stash shows as a pile of cards, folded or unfolded. Folded
57-// in front at overviewCardZoom, up to overviewCards-1 others behind it, each57+// (not selected), the stash's own selection is in front at
58-// overviewCardStep further up and right, dimmed. A vertical overview pins58+// overviewCardZoom, up to overviewCards-1 others behind it, each
59-// the pile to the left edge of its row, which takes the width the pile59+// overviewCardStep further up and right, dimmed. Selected, the pile
60-// leaves. A horizontal one centres it on its row, straddling the bottom edge60+// unfolds along the overview axis into a strip of every card (unfold), the
61-// of the selected lane, over the tiles.61+// selection framed, and the moves along that axis walk it. A vertical
62+// overview pins the pile to the left edge of its row, which takes the width
63+// the pile leaves. A horizontal one centres it on its row, straddling the
64+// bottom edge of the selected lane, over the tiles.
62 const (65 const (
63 overviewCardZoom = 0.266 overviewCardZoom = 0.2
64 overviewCards = 467 overviewCards = 4
@@ -398,21 +401,20 @@ func (m *Monitor) OverviewMove(dx, dy int) {
398 }401 }
399 402
400 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview403 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
401-// walks the stack cards before changing workspace (overviewVertical). A404+// walks the unfolded stash and the stack cards before changing workspace
402-// side-by-side one always changes workspace, since its stack fans left and405+// (overviewVertical). A side-by-side one always changes workspace, since
403-// right. A selected stash card cycles the pile first, in every layout.406+// its stack and unfolded stash run left and right.
404 func (m *Monitor) overviewWorkspaceStep(d int) {407 func (m *Monitor) overviewWorkspaceStep(d int) {
405 if m.overviewAxis() == verticalAxis {408 if m.overviewAxis() == verticalAxis {
406 m.overviewVertical(d)409 m.overviewVertical(d)
407 return410 return
408 }411 }
409- if !m.cycleCard(m.Current(), d) {412+ m.overviewStep(d)
410- m.overviewStep(d)
411- }
412 }413 }
413 414
414-// cycleCard brings the stash card d entries from the selected one of w to415+// cycleCard selects the stash card d entries from the selected one of w in
415-// the front, stopping at either end. It reports whether a card was selected.416+// the unfolded pile, stopping at either end. It reports whether a card was
417+// selected.
416 func (m *Monitor) cycleCard(w *Workspace, d int) bool {418 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
417 at := m.cardAt(w)419 at := m.cardAt(w)
418 if at < 0 {420 if at < 0 {
@@ -423,19 +425,16 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423 }425 }
424 426
425 // overviewLane is the vertical move of an overview whose workspaces sit side427 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade). From a selected stash card, down brings the next card428+// by side (cascade). From the unfolded stash, up leaves it for the last lane
427-// of the pile to the front (stopping at the last) and up leaves the pile for429+// (leavePile) and down stays: the stash is the bottom. Else the selection
428-// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.430+// goes to the previous or next lane (band), at the closest column, stopping
429-// Else the selection goes to the previous or next lane (band), at the431+// at the first. Down past the last lane, or from a card that is not the
430-// closest column, stopping at the first. Down past the last lane, or from a432+// columns, enters the stash, which sits below. Covering floats stack along
431-// card that is not the columns, enters the stash, which sits below. Covering433+// the horizontal axis, not this one.
432-// floats stack along the horizontal axis, not this one.
433 func (m *Monitor) overviewLane(dy int) {434 func (m *Monitor) overviewLane(dy int) {
434 w := m.Current()435 w := m.Current()
435 if m.cardAt(w) >= 0 {436 if m.cardAt(w) >= 0 {
436- if dy > 0 {437+ if dy < 0 {
437- m.cycleCard(w, dy)
438- } else {
439 m.leavePile(w)438 m.leavePile(w)
440 }439 }
441 return440 return
@@ -479,31 +478,19 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
479 }478 }
480 479
481 // overviewAcross is the horizontal move of an overview whose workspaces sit480 // overviewAcross is the horizontal move of an overview whose workspaces sit
482-// side by side (cascade): a column inside the selected lane. Past the481+// side by side (cascade): a column inside the selected lane, or a card of
483-// lane's first column it goes to the previous workspace; past the last482+// the unfolded stash. Past the first column or card it goes to the previous
484-// column, to the next workspace. From a stash card, left goes to the483+// workspace; past the last, to the next workspace. Covering floats fan
485-// previous workspace and right back to the columns. Covering floats fan
486 // along this axis and are traversed before a workspace: the card on the484 // along this axis and are traversed before a workspace: the card on the
487 // left is the one behind.485 // left is the one behind.
488 func (m *Monitor) overviewAcross(dx int) {486 func (m *Monitor) overviewAcross(dx int) {
489 w := m.Current()487 w := m.Current()
490- if m.cardAt(w) >= 0 {488+ if at := m.cardAt(w); at >= 0 {
491- if dx < 0 || len(w.stack()) == 0 {489+ if next := at + dx; next >= 0 && next < len(w.Stash) {
492- // Left leaves the pile; a stash-only workspace has nothing490+ m.selectCard(w, next)
493- // to its right to select.
494- m.overviewStep(dx)
495 return491 return
496 }492 }
497- m.clearCard()493+ m.overviewStep(dx)
498- m.ov.selected = 0
499- if len(w.Columns) > 0 {
500- // The columns come to the front, even when down entered the
501- // stash from a covering float: at the first column of the
502- // focused lane.
503- m.setFront(w, stackItem{kind: stackColumns})
504- i, _ := w.laneBounds(w.laneOf(w.Focus))
505- m.selectLaneColumn(w, i)
506- }
507 return494 return
508 }495 }
509 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {496 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -744,13 +731,23 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
744 cur := m.Current()731 cur := m.Current()
745 rows, _ := m.overviewRows()732 rows, _ := m.overviewRows()
746 axis := m.overviewAxis()733 axis := m.overviewAxis()
734+ // The unfolded stash goes last: its strip may run over the neighbor
735+ // rows, which must not cover it.
736+ var unfolded []Placement
747 for w := range m.all() {737 for w := range m.all() {
748 slot, shown := rows[w]738 slot, shown := rows[w]
749 if shown {739 if shown {
740+ // The pile band goes under the row: the row is centred
741+ // with it.
742+ slot.H -= w.pileBand(axis)
750 at := m.cardAt(w)743 at := m.cardAt(w)
751 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)744 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
752 if len(w.Stash) > 0 {745 if len(w.Stash) > 0 {
753- result = append(result, w.pile(axis, slot, w != cur, at)...)746+ if cards := w.pile(axis, slot, w != cur, at); at >= 0 {
747+ unfolded = cards
748+ } else {
749+ result = append(result, cards...)
750+ }
754 }751 }
755 }752 }
756 for _, f := range w.Floats {753 for _, f := range w.Floats {
@@ -763,7 +760,7 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
763 result = w.appendHidden(result, false)760 result = w.appendHidden(result, false)
764 }761 }
765 }762 }
766- return result763+ return append(result, unfolded...)
767 }764 }
768 765
769 // fan places front and neighboring cards, farthest visible first and front766 // fan places front and neighboring cards, farthest visible first and front
@@ -846,6 +843,24 @@ func (w *Workspace) pileWidth() int {
846 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)843 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
847 }844 }
848 845
846+// cardSize is the size of a stash card in the overview.
847+func (w *Workspace) cardSize() (int, int) {
848+ r := w.stashRect()
849+ return int(math.Round(float64(r.W) * overviewCardZoom)), int(math.Round(float64(r.H) * overviewCardZoom))
850+}
851+
852+// pileBand is the height a horizontal overview gives the stash of w under
853+// its selected lane: the spacing, then the folded pile with its cards
854+// behind. 0 without a stash, or in a vertical overview, where the pile
855+// sits left of the row. The lane and the band are centred together.
856+func (w *Workspace) pileBand(axis layoutAxis) int {
857+ if axis != horizontalAxis || len(w.Stash) == 0 {
858+ return 0
859+ }
860+ _, ch := w.cardSize()
861+ return max(1, w.overviewArea().H*3/100) + ch + (min(len(w.Stash), overviewCards)-1)*w.cardStep()
862+}
863+
849 // cardStep is the offset between stacked cards, smaller on tiny outputs.864 // cardStep is the offset between stacked cards, smaller on tiny outputs.
850 func (w *Workspace) cardStep() int {865 func (w *Workspace) cardStep() int {
851 a := w.overviewArea()866 a := w.overviewArea()
@@ -853,46 +868,87 @@ func (w *Workspace) cardStep() int {
853 }868 }
854 869
855 // pile places the stash of w as cards in its slot along axis (see the870 // pile places the stash of w as cards in its slot along axis (see the
856-// overview's stash comment): entry front (the stash selection when -1) in871+// overview's stash comment). With entry at selected (at >= 0, on the
857-// front, the next ones behind it, dimmed, the rest hidden. Only a front card872+// current row: dim false) the pile unfolds; else it is folded, the stash
858-// of the current row (dim false) with front set is selected.873+// selection in front, the next ones behind it, dimmed, the rest hidden.
859-func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Placement {874+func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, at int) []Placement {
860 n := len(w.Stash)875 n := len(w.Stash)
861 if n == 0 {876 if n == 0 {
862 return nil877 return nil
863 }878 }
864- selected := front >= 0 && !dim
865- if front < 0 {
866- front = w.stashAt
867- }
868- front = min(max(front, 0), n-1)
869- r := w.stashRect()
870 u := w.overviewArea()879 u := w.overviewArea()
871- cw := int(math.Round(float64(r.W) * overviewCardZoom))880+ cw, ch := w.cardSize()
872- ch := int(math.Round(float64(r.H) * overviewCardZoom))
873 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))881 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
874 var x0, y0 int882 var x0, y0 int
875 if axis == horizontalAxis {883 if axis == horizontalAxis {
876- // Centred on the row, across the bottom edge of the selected884+ // Centred on the row, under the selected lane: slot is the
877- // lane, which is centred in the slot. Both scale with the area885+ // lane's, the pile band (pileBand) taken off its height, so the
878- // (the lane at most overviewMaxZoom of it), so the pile stays886+ // band starts below the lane. The cards behind rise from the
879- // inside whatever its size.887+ // front one, which goes at the bottom of the band. Both scale
888+ // with the area (the lane at most overviewMaxZoom of it), so
889+ // the pile stays inside whatever its size.
880 x0 = slot.X + (slot.W-cw)/2890 x0 = slot.X + (slot.W-cw)/2
881- y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2891+ y0 = slot.Y + (slot.H-rowH)/2 + rowH + w.pileBand(axis) - ch
882 } else {892 } else {
883 // Stack rows reserve two peek steps above the front card. Align893 // Stack rows reserve two peek steps above the front card. Align
884 // the stash with that front band, not with the top of its envelope.894 // the stash with that front band, not with the top of its envelope.
885 band := w.frontSlot(axis, slot)895 band := w.frontSlot(axis, slot)
886 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2896 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
887 }897 }
898+ folded := Rect{X: x0, Y: y0, W: cw, H: ch}
899+ if at >= 0 && !dim {
900+ return w.unfold(axis, folded, min(at, n-1))
901+ }
888 cards := make([][]Placement, n)902 cards := make([][]Placement, n)
889 for i := range w.Stash {903 for i := range w.Stash {
890- cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: Rect{X: x0, Y: y0, W: cw, H: ch}, Preview: overviewCardZoom, Focused: selected, Card: true}}904+ cards[i] = []Placement{{ID: w.Stash[i].ID, Rect: folded, Preview: overviewCardZoom, Card: true}}
891 }905 }
892 step := w.cardStep()906 step := w.cardStep()
893- return fan(cards, front, min(n, overviewCards)-1, 0, true,907+ return fan(cards, min(max(w.stashAt, 0), n-1), min(n, overviewCards)-1, 0, true,
894 func(d int) (int, int) { return d * step, -d * step },908 func(d int) (int, int) { return d * step, -d * step },
895- func(int) (int, int) { return 0, 0 }, dim, selected)909+ func(int) (int, int) { return 0, 0 }, dim, false)
910+}
911+
912+// unfold lays the stash of w out as a strip of every card along axis, from
913+// the folded pile's card rect: a row in a horizontal overview, a column in
914+// a vertical one. The strip is centred on the pile and kept in the overview
915+// area. Longer than the area, it scrolls like a wide row: the selected card
916+// at is centred, the strip stopping at the area's edges. The other cards
917+// are dimmed; those out of the area are hidden.
918+func (w *Workspace) unfold(axis layoutAxis, folded Rect, at int) []Placement {
919+ n := len(w.Stash)
920+ u := w.overviewArea()
921+ size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W
922+ if axis == verticalAxis {
923+ size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H
924+ }
925+ stride := size + 2*w.cardStep()
926+ total := n*stride - (stride - size)
927+ start := origin + size/2 - total/2
928+ if total > hi-lo {
929+ start = origin + size/2 - at*stride - size/2
930+ start = min(max(start, hi-total), lo)
931+ } else {
932+ start = min(max(start, lo), hi-total)
933+ }
934+ out := make([]Placement, n)
935+ for i := range w.Stash {
936+ r := folded
937+ if axis == verticalAxis {
938+ r.Y = start + i*stride
939+ } else {
940+ r.X = start + i*stride
941+ }
942+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
943+ if i != at {
944+ p.peeking(1)
945+ }
946+ if !r.Overlaps(u) {
947+ p.Hidden, p.Preview, p.Focused = true, 0, false
948+ }
949+ out[i] = p
950+ }
951+ return out
896 }952 }
897 953
898 // overviewZoom is the scale that fits every column of w in the usable954 // overviewZoom is the scale that fits every column of w in the usable
@@ -1098,6 +1154,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1098 // Lanes along the overview axis would overlap the neighbor workspaces.1154 // Lanes along the overview axis would overlap the neighbor workspaces.
1099 otherLanes := w.policy().content != axis1155 otherLanes := w.policy().content != axis
1100 laneStep := scale(stride) + max(1, u.H*3/100)1156 laneStep := scale(stride) + max(1, u.H*3/100)
1157+ // The lanes below the selected one leave room for the stash pile.
1158+ below := w.pileBand(axis)
1101 selLane := 01159 selLane := 0
1102 if lanes {1160 if lanes {
1103 selLane = max(sel.Y, 0) / stride1161 selLane = max(sel.Y, 0) / stride
@@ -1113,7 +1171,11 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1113 lane, ly = l-selLane, r.Y-l*stride1171 lane, ly = l-selLane, r.Y-l*stride
1114 }1172 }
1115 x0, y0 := scale(r.X), scale(ly)1173 x0, y0 := scale(r.X), scale(ly)
1116- 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}1174+ y := top + y0 + lane*laneStep
1175+ if lanes && lane > 0 {
1176+ y += below
1177+ }
1178+ tiles[i].Rect = Rect{X: x + x0, Y: y, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
1117 tiles[i].Preview = z1179 tiles[i].Preview = z
1118 if dim || lane != 0 {1180 if dim || lane != 0 {
1119 tiles[i].peeking(1)1181 tiles[i].peeking(1)
@@ -1408,9 +1470,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1408 if finger {1470 if finger {
1409 before = m.overviewSelection()1471 before = m.overviewSelection()
1410 }1472 }
1411- if cascade && !finger && m.cardAt(m.Current()) < 0 {1473+ switch {
1474+ case cascade && !finger && m.cardAt(m.Current()) >= 0:
1475+ // A wheel walks the unfolded stash, a row.
1476+ m.OverviewMove(d, 0)
1477+ case cascade && !finger:
1412 m.overviewStep(d)1478 m.overviewStep(d)
1413- } else {1479+ default:
1414 m.OverviewMove(0, d)1480 m.OverviewMove(0, d)
1415 }1481 }
1416 if m.overviewTurned(axis, finger) {1482 if m.overviewTurned(axis, finger) {
@@ -1424,9 +1490,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1424 d := sign(m.ov.scrollX)1490 d := sign(m.ov.scrollX)
1425 if cascade && finger {1491 if cascade && finger {
1426 before = m.overviewSelection()1492 before = m.overviewSelection()
1427- // One step per gesture: one workspace, even over a1493+ // One step per gesture: one card of the unfolded
1428- // stash card.1494+ // stash, else one workspace.
1429- m.overviewStep(d)1495+ if m.cardAt(m.Current()) >= 0 {
1496+ m.OverviewMove(d, 0)
1497+ } else {
1498+ m.overviewStep(d)
1499+ }
1430 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1500 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1431 return m.overviewSelection() != before1501 return m.overviewSelection() != before
1432 }1502 }
M internal/core/overview_cascade_test.go +45 -40
@@ -2,7 +2,6 @@ package core
22
33 import (
44 "fmt"
5- "math"
65 "slices"
76 "testing"
87
@@ -245,12 +244,15 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
245244 if !within1(front.Rect.X+front.Rect.W/2, cur.X+cur.W/2) {
246245 t.Fatalf("pile not centred on the row %v: %+v", cur, front.Rect)
247246 }
248- // The lane is centred in the slot; its tiles stop a gap short of its
249- // bottom edge.
250- rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(horizontalAxis)))
251- bottom := cur.Y + (cur.H-rowH)/2 + rowH
252- if !within1(centreY(front.Rect), bottom) || front.Rect.Y >= lane.Y+lane.H {
253- t.Fatalf("pile not across the lane's bottom edge %d (lane %v): %+v", bottom, lane, front.Rect)
247+ // The pile sits under the lane, every card of it: the lane and the
248+ // pile are centred together in the slot.
249+ for _, id := range []WindowID{40, 41} {
250+ if c := previewOf(t, ps, id).Rect; c.Y <= lane.Y+lane.H {
251+ t.Fatalf("card %d %v not under the lane %v", id, c, lane)
252+ }
253+ }
254+ if top, bottom := lane.Y-cur.Y, cur.Y+cur.H-(front.Rect.Y+front.Rect.H); !within1(top, bottom) && abs(top-bottom) > lane.H/10 {
255+ t.Fatalf("lane and pile not centred together: %d above, %d below", top, bottom)
254256 }
255257 // The pile does not shift the band: its row stays centred on the area.
256258 u := m.Current().overviewArea()
@@ -341,8 +343,8 @@ func TestOverviewCascadeKeys(t *testing.T) {
341343
342344 // With a stash: left from the first column still goes to the
343345 // previous workspace. Down past the last band enters the
344- // stash, right comes back to the band's first column, and
345- // left from the stash goes to the previous workspace.
346+ // stash, up comes back to the band, and left past the first
347+ // card goes to the previous workspace.
346348 withStash(m, 2)
347349 w.FocusID(7)
348350 m.ToggleOverview()
@@ -360,14 +362,16 @@ func TestOverviewCascadeKeys(t *testing.T) {
360362 if m.cardAt(w) < 0 {
361363 t.Fatal("down past the last band did not select the stash")
362364 }
363- d.move(m, 1, 0)
365+ d.move(m, 0, -1)
364366 if m.cardAt(w) >= 0 || sel() != 10 {
365- t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())
367+ t.Fatalf("up from the stash: card %d, column %d", m.cardAt(w), sel())
366368 }
367369 d.move(m, 0, 1)
368- d.move(m, -1, 0)
370+ for range len(w.Stash) {
371+ d.move(m, -1, 0)
372+ }
369373 if m.Current() != prev {
370- t.Fatal("left from the stash did not reach the previous workspace")
374+ t.Fatal("left past the first card did not reach the previous workspace")
371375 }
372376 m.CancelOverview()
373377 if m.Current() != w || focusedID(m) != 7 {
@@ -533,22 +537,22 @@ func TestOverviewCascadeThreeFingerDownEntersStash(t *testing.T) {
533537 }
534538 }
535539
536-// Right from a stash card lands on the maximized column, which keeps its
537-// maximization: it is alone in its band.
540+// Up from a stash card lands on the maximized column of the last band,
541+// which keeps its maximization: it is alone in its band.
538542 func TestOverviewCascadeStashToMaximized(t *testing.T) {
539543 m := cascadeOverview()
540544 withStash(m, 1)
541545 w := m.Current()
542- w.FocusID(9)
546+ w.FocusID(10)
543547 w.ToggleFullWidth()
544548 m.ToggleOverview()
545549 m.selectCard(w, w.stashAt) // As a click on the pile does.
546550 if m.cardAt(w) < 0 {
547551 t.Fatalf("setup: no stash card, target %d", m.overviewTarget())
548552 }
549- m.OverviewMove(1, 0)
550- if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 {
551- t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized)
553+ m.OverviewMove(0, -1)
554+ if m.overviewTarget() != 10 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 10 {
555+ t.Fatalf("target %d, maximized %v; want 10", m.overviewTarget(), w.maximized)
552556 }
553557 }
554558
@@ -571,22 +575,19 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
571575 }
572576 }
573577
574-// A card behind the front one of the pile is clicked where it shows,
575-// over the selected lane's tiles.
578+// A card behind the front one of the folded pile is clicked where it
579+// shows, above the front card.
576580 func TestOverviewCascadeStashPeekClick(t *testing.T) {
577581 m := cascadeOverview()
578582 withStash(m, 2)
579583 m.ToggleOverview()
580584 ps := m.Layout()
581585 front, back := previewOf(t, ps, 41).Rect, previewOf(t, ps, 40).Rect
582- // The strip of 40 above the front card, inside the lane's tiles.
586+ // The strip of 40 above the front card.
583587 x, y := float64(back.X+back.W-1), float64(back.Y)
584- if back.Y >= front.Y || back.Y < previewOf(t, ps, 8).Rect.Y {
588+ if back.Y >= front.Y {
585589 t.Fatalf("setup: back %v front %v", back, front)
586590 }
587- if id := overviewIn(ps, x, y); id == 40 {
588- t.Fatal("setup: the tiles do not cover the strip")
589- }
590591 if id := m.overviewHit(ps, x, y); id != 40 {
591592 t.Fatalf("clicked %d, want 40", id)
592593 }
@@ -645,8 +646,8 @@ func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
645646 }
646647 }
647648
648-// Down from a covering float enters the stash; right brings the columns
649-// back to the front.
649+// Down from a covering float enters the stash; up comes back to the
650+// float.
650651 func TestOverviewCascadeFloatStashRight(t *testing.T) {
651652 m := cascadeOverview()
652653 withStash(m, 1)
@@ -661,9 +662,9 @@ func TestOverviewCascadeFloatStashRight(t *testing.T) {
661662 if m.cardAt(w) < 0 {
662663 t.Fatal("down from the float did not select the stash")
663664 }
664- m.OverviewMove(1, 0)
665- if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns || m.overviewTarget() == 0 {
666- t.Fatalf("right from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())
665+ m.OverviewMove(0, -1)
666+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackFloat || m.overviewTarget() != 90 {
667+ t.Fatalf("up from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())
667668 }
668669 }
669670
@@ -777,19 +778,18 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
777778 }
778779 m.CancelOverview()
779780
780- // A selected stash card keeps the pile cycle.
781+ // Over the unfolded stash they still change workspace: left and
782+ // right walk the cards.
781783 withStash(m, 3)
782784 w.FocusID(10)
783785 m.ToggleOverview()
784786 m.OverviewMove(0, 1)
785- at := m.cardAt(w)
786- m.overviewFocus(ActionFocusWorkspacePrev)
787- if m.Current() != w || m.cardAt(w) != at-1 {
788- t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
787+ if m.cardAt(w) < 0 {
788+ t.Fatal("setup: no card selected")
789789 }
790- m.overviewFocus(ActionFocusWorkspaceNext)
791- if m.Current() != w || m.cardAt(w) != at {
792- t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))
790+ m.overviewFocus(ActionFocusWorkspacePrev)
791+ if m.Current() != m.Workspaces[0] {
792+ t.Fatal("focus-workspace-prev over a card did not change workspace")
793793 }
794794 }
795795
@@ -1089,7 +1089,12 @@ func TestOverviewCascadePileVisible(t *testing.T) {
10891089 }
10901090 }
10911091 }
1092- if want := min(n, overviewCards); shown != want {
1092+ want := min(n, overviewCards)
1093+ if m.cardAt(w) >= 0 {
1094+ // Unfolded: every card.
1095+ want = n
1096+ }
1097+ if shown != want {
10931098 t.Fatalf("n=%d hidden=%v down=%d: %d cards shown, want %d", n, hidden, down, shown, want)
10941099 }
10951100 }
@@ -2,7 +2,6 @@ package core
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "math"
6 "slices"5 "slices"
7 "testing"6 "testing"
8 7
@@ -245,12 +244,15 @@ func TestOverviewCascadeStashInSlot(t *testing.T) {
245 if !within1(front.Rect.X+front.Rect.W/2, cur.X+cur.W/2) {244 if !within1(front.Rect.X+front.Rect.W/2, cur.X+cur.W/2) {
246 t.Fatalf("pile not centred on the row %v: %+v", cur, front.Rect)245 t.Fatalf("pile not centred on the row %v: %+v", cur, front.Rect)
247 }246 }
248- // The lane is centred in the slot; its tiles stop a gap short of its247+ // The pile sits under the lane, every card of it: the lane and the
249- // bottom edge.248+ // pile are centred together in the slot.
250- rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(horizontalAxis)))249+ for _, id := range []WindowID{40, 41} {
251- bottom := cur.Y + (cur.H-rowH)/2 + rowH250+ if c := previewOf(t, ps, id).Rect; c.Y <= lane.Y+lane.H {
252- if !within1(centreY(front.Rect), bottom) || front.Rect.Y >= lane.Y+lane.H {251+ t.Fatalf("card %d %v not under the lane %v", id, c, lane)
253- t.Fatalf("pile not across the lane's bottom edge %d (lane %v): %+v", bottom, lane, front.Rect)252+ }
253+ }
254+ if top, bottom := lane.Y-cur.Y, cur.Y+cur.H-(front.Rect.Y+front.Rect.H); !within1(top, bottom) && abs(top-bottom) > lane.H/10 {
255+ t.Fatalf("lane and pile not centred together: %d above, %d below", top, bottom)
254 }256 }
255 // The pile does not shift the band: its row stays centred on the area.257 // The pile does not shift the band: its row stays centred on the area.
256 u := m.Current().overviewArea()258 u := m.Current().overviewArea()
@@ -341,8 +343,8 @@ func TestOverviewCascadeKeys(t *testing.T) {
341 343
342 // With a stash: left from the first column still goes to the344 // With a stash: left from the first column still goes to the
343 // previous workspace. Down past the last band enters the345 // previous workspace. Down past the last band enters the
344- // stash, right comes back to the band's first column, and346+ // stash, up comes back to the band, and left past the first
345- // left from the stash goes to the previous workspace.347+ // card goes to the previous workspace.
346 withStash(m, 2)348 withStash(m, 2)
347 w.FocusID(7)349 w.FocusID(7)
348 m.ToggleOverview()350 m.ToggleOverview()
@@ -360,14 +362,16 @@ func TestOverviewCascadeKeys(t *testing.T) {
360 if m.cardAt(w) < 0 {362 if m.cardAt(w) < 0 {
361 t.Fatal("down past the last band did not select the stash")363 t.Fatal("down past the last band did not select the stash")
362 }364 }
363- d.move(m, 1, 0)365+ d.move(m, 0, -1)
364 if m.cardAt(w) >= 0 || sel() != 10 {366 if m.cardAt(w) >= 0 || sel() != 10 {
365- t.Fatalf("right from the stash: card %d, column %d", m.cardAt(w), sel())367+ t.Fatalf("up from the stash: card %d, column %d", m.cardAt(w), sel())
366 }368 }
367 d.move(m, 0, 1)369 d.move(m, 0, 1)
368- d.move(m, -1, 0)370+ for range len(w.Stash) {
371+ d.move(m, -1, 0)
372+ }
369 if m.Current() != prev {373 if m.Current() != prev {
370- t.Fatal("left from the stash did not reach the previous workspace")374+ t.Fatal("left past the first card did not reach the previous workspace")
371 }375 }
372 m.CancelOverview()376 m.CancelOverview()
373 if m.Current() != w || focusedID(m) != 7 {377 if m.Current() != w || focusedID(m) != 7 {
@@ -533,22 +537,22 @@ func TestOverviewCascadeThreeFingerDownEntersStash(t *testing.T) {
533 }537 }
534 }538 }
535 539
536-// Right from a stash card lands on the maximized column, which keeps its540+// Up from a stash card lands on the maximized column of the last band,
537-// maximization: it is alone in its band.541+// which keeps its maximization: it is alone in its band.
538 func TestOverviewCascadeStashToMaximized(t *testing.T) {542 func TestOverviewCascadeStashToMaximized(t *testing.T) {
539 m := cascadeOverview()543 m := cascadeOverview()
540 withStash(m, 1)544 withStash(m, 1)
541 w := m.Current()545 w := m.Current()
542- w.FocusID(9)546+ w.FocusID(10)
543 w.ToggleFullWidth()547 w.ToggleFullWidth()
544 m.ToggleOverview()548 m.ToggleOverview()
545 m.selectCard(w, w.stashAt) // As a click on the pile does.549 m.selectCard(w, w.stashAt) // As a click on the pile does.
546 if m.cardAt(w) < 0 {550 if m.cardAt(w) < 0 {
547 t.Fatalf("setup: no stash card, target %d", m.overviewTarget())551 t.Fatalf("setup: no stash card, target %d", m.overviewTarget())
548 }552 }
549- m.OverviewMove(1, 0)553+ m.OverviewMove(0, -1)
550- if m.overviewTarget() != 9 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 9 {554+ if m.overviewTarget() != 10 || !w.Columns[w.Focus].FullWidth || len(w.maximized) == 0 || w.maximized[0] != 10 {
551- t.Fatalf("target %d, maximized %v; want 9", m.overviewTarget(), w.maximized)555+ t.Fatalf("target %d, maximized %v; want 10", m.overviewTarget(), w.maximized)
552 }556 }
553 }557 }
554 558
@@ -571,22 +575,19 @@ func TestOverviewCascadeNamedDivider(t *testing.T) {
571 }575 }
572 }576 }
573 577
574-// A card behind the front one of the pile is clicked where it shows,578+// A card behind the front one of the folded pile is clicked where it
575-// over the selected lane's tiles.579+// shows, above the front card.
576 func TestOverviewCascadeStashPeekClick(t *testing.T) {580 func TestOverviewCascadeStashPeekClick(t *testing.T) {
577 m := cascadeOverview()581 m := cascadeOverview()
578 withStash(m, 2)582 withStash(m, 2)
579 m.ToggleOverview()583 m.ToggleOverview()
580 ps := m.Layout()584 ps := m.Layout()
581 front, back := previewOf(t, ps, 41).Rect, previewOf(t, ps, 40).Rect585 front, back := previewOf(t, ps, 41).Rect, previewOf(t, ps, 40).Rect
582- // The strip of 40 above the front card, inside the lane's tiles.586+ // The strip of 40 above the front card.
583 x, y := float64(back.X+back.W-1), float64(back.Y)587 x, y := float64(back.X+back.W-1), float64(back.Y)
584- if back.Y >= front.Y || back.Y < previewOf(t, ps, 8).Rect.Y {588+ if back.Y >= front.Y {
585 t.Fatalf("setup: back %v front %v", back, front)589 t.Fatalf("setup: back %v front %v", back, front)
586 }590 }
587- if id := overviewIn(ps, x, y); id == 40 {
588- t.Fatal("setup: the tiles do not cover the strip")
589- }
590 if id := m.overviewHit(ps, x, y); id != 40 {591 if id := m.overviewHit(ps, x, y); id != 40 {
591 t.Fatalf("clicked %d, want 40", id)592 t.Fatalf("clicked %d, want 40", id)
592 }593 }
@@ -645,8 +646,8 @@ func TestOverviewCascadeNeighborStashKeepsSlot(t *testing.T) {
645 }646 }
646 }647 }
647 648
648-// Down from a covering float enters the stash; right brings the columns649+// Down from a covering float enters the stash; up comes back to the
649-// back to the front.650+// float.
650 func TestOverviewCascadeFloatStashRight(t *testing.T) {651 func TestOverviewCascadeFloatStashRight(t *testing.T) {
651 m := cascadeOverview()652 m := cascadeOverview()
652 withStash(m, 1)653 withStash(m, 1)
@@ -661,9 +662,9 @@ func TestOverviewCascadeFloatStashRight(t *testing.T) {
661 if m.cardAt(w) < 0 {662 if m.cardAt(w) < 0 {
662 t.Fatal("down from the float did not select the stash")663 t.Fatal("down from the float did not select the stash")
663 }664 }
664- m.OverviewMove(1, 0)665+ m.OverviewMove(0, -1)
665- if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackColumns || m.overviewTarget() == 0 {666+ if m.cardAt(w) >= 0 || m.stackFront(w).kind != stackFloat || m.overviewTarget() != 90 {
666- t.Fatalf("right from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())667+ t.Fatalf("up from the stash: card %d, front %+v, target %d", m.cardAt(w), m.stackFront(w), m.overviewTarget())
667 }668 }
668 }669 }
669 670
@@ -777,19 +778,18 @@ func TestOverviewCascadeFocusBinds(t *testing.T) {
777 }778 }
778 m.CancelOverview()779 m.CancelOverview()
779 780
780- // A selected stash card keeps the pile cycle.781+ // Over the unfolded stash they still change workspace: left and
782+ // right walk the cards.
781 withStash(m, 3)783 withStash(m, 3)
782 w.FocusID(10)784 w.FocusID(10)
783 m.ToggleOverview()785 m.ToggleOverview()
784 m.OverviewMove(0, 1)786 m.OverviewMove(0, 1)
785- at := m.cardAt(w)787+ if m.cardAt(w) < 0 {
786- m.overviewFocus(ActionFocusWorkspacePrev)788+ t.Fatal("setup: no card selected")
787- if m.Current() != w || m.cardAt(w) != at-1 {
788- t.Fatalf("focus-workspace-prev over a card: card %d, was %d", m.cardAt(w), at)
789 }789 }
790- m.overviewFocus(ActionFocusWorkspaceNext)790+ m.overviewFocus(ActionFocusWorkspacePrev)
791- if m.Current() != w || m.cardAt(w) != at {791+ if m.Current() != m.Workspaces[0] {
792- t.Fatalf("focus-workspace-next over a card: card %d", m.cardAt(w))792+ t.Fatal("focus-workspace-prev over a card did not change workspace")
793 }793 }
794 }794 }
795 795
@@ -1089,7 +1089,12 @@ func TestOverviewCascadePileVisible(t *testing.T) {
1089 }1089 }
1090 }1090 }
1091 }1091 }
1092- if want := min(n, overviewCards); shown != want {1092+ want := min(n, overviewCards)
1093+ if m.cardAt(w) >= 0 {
1094+ // Unfolded: every card.
1095+ want = n
1096+ }
1097+ if shown != want {
1093 t.Fatalf("n=%d hidden=%v down=%d: %d cards shown, want %d", n, hidden, down, shown, want)1098 t.Fatalf("n=%d hidden=%v down=%d: %d cards shown, want %d", n, hidden, down, shown, want)
1094 }1099 }
1095 }1100 }
A internal/core/overview_unfold_test.go +203 -0
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+// cards are the shown stash placements of w in ps, in stash order.
6+func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement {
7+ t.Helper()
8+ var out []Placement
9+ for _, e := range w.Stash {
10+ if p := previewOf(t, ps, e.ID); !p.Hidden {
11+ out = append(out, p)
12+ }
13+ }
14+ return out
15+}
16+
17+// Selected, the cascade pile unfolds into a row under the lane: every card
18+// side by side, in stash order, none over another, the selection the only
19+// framed one. Left and right walk it.
20+func TestOverviewCascadeStashUnfolds(t *testing.T) {
21+ for _, d := range cascadeDrivers {
22+ t.Run(d.name, func(t *testing.T) {
23+ m := cascadeOverview()
24+ withStash(m, 3)
25+ w := m.Current()
26+ w.FocusID(10)
27+ m.ToggleOverview()
28+ d.move(m, 0, 1)
29+ at := m.cardAt(w)
30+ if at < 0 {
31+ t.Fatal("setup: no card selected")
32+ }
33+ ps := m.Layout()
34+ cards := shownCards(t, w, ps)
35+ if len(cards) != 3 {
36+ t.Fatalf("%d cards shown, want 3", len(cards))
37+ }
38+ lane := previewOf(t, ps, 10).Rect
39+ for i, c := range cards {
40+ if c.Rect.Y <= lane.Y+lane.H {
41+ t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane)
42+ }
43+ if i > 0 && (c.Rect.Y != cards[0].Rect.Y || c.Rect.X < cards[i-1].Rect.X+cards[i-1].Rect.W) {
44+ t.Fatalf("cards not side by side: %v then %v", cards[i-1].Rect, c.Rect)
45+ }
46+ if c.Focused != (i == at) {
47+ t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at)
48+ }
49+ }
50+ for i := range 2 {
51+ d.move(m, -1, 0)
52+ if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want {
53+ t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want)
54+ }
55+ if at-i-1 < 0 {
56+ break
57+ }
58+ }
59+ for m.cardAt(w) < len(w.Stash)-1 {
60+ before := m.cardAt(w)
61+ d.move(m, 1, 0)
62+ if m.cardAt(w) != before+1 {
63+ t.Fatalf("right: card %d, was %d", m.cardAt(w), before)
64+ }
65+ }
66+ // Right past the last card goes to the next workspace.
67+ d.move(m, 1, 0)
68+ if m.Current() != m.Workspaces[2] {
69+ t.Fatal("right past the last card did not change workspace")
70+ }
71+ })
72+ }
73+}
74+
75+// Down past the last lane enters the stash at its own selection; up leaves
76+// it, and the pile folds again.
77+func TestOverviewCascadeStashFolds(t *testing.T) {
78+ m := cascadeOverview()
79+ withStash(m, 3)
80+ w := m.Current()
81+ w.FocusID(10)
82+ m.ToggleOverview()
83+ folded := shownCards(t, w, m.Layout())
84+ m.OverviewMove(0, 1)
85+ m.OverviewMove(0, -1)
86+ if m.cardAt(w) >= 0 {
87+ t.Fatal("up did not leave the stash")
88+ }
89+ again := shownCards(t, w, m.Layout())
90+ if len(again) != len(folded) {
91+ t.Fatalf("folded again: %d cards, was %d", len(again), len(folded))
92+ }
93+ for i := range again {
94+ if again[i].Rect != folded[i].Rect || again[i].Focused {
95+ t.Fatalf("card %d folded at %v (focused %v), was %v", again[i].ID, again[i].Rect, again[i].Focused, folded[i].Rect)
96+ }
97+ }
98+}
99+
100+// A stash longer than the area scrolls like a wide row, on either axis:
101+// every card is reached in order, the selection stays inside the area and
102+// is centred unless the strip stops at an edge of it, and cards past the
103+// area are hidden.
104+func TestOverviewStashUnfoldScrolls(t *testing.T) {
105+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
106+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
107+ m := cascadeOverview()
108+ w := m.Current()
109+ if axis == verticalAxis {
110+ w.Overflow = OverflowScroll
111+ }
112+ withStash(m, 12)
113+ m.ToggleOverview()
114+ if m.overviewAxis() != axis {
115+ t.Fatal("setup: wrong overview axis")
116+ }
117+ m.selectCard(w, 0)
118+ u := w.overviewArea()
119+ pos := func(r Rect) (int, int, int, int) {
120+ if axis == verticalAxis {
121+ return r.Y, r.H, u.Y, u.Y + u.H
122+ }
123+ return r.X, r.W, u.X, u.X + u.W
124+ }
125+ centred, hidden := false, false
126+ for i := range len(w.Stash) {
127+ if m.cardAt(w) != i {
128+ t.Fatalf("step %d: card %d", i, m.cardAt(w))
129+ }
130+ ps := m.Layout()
131+ sel := previewOf(t, ps, w.Stash[i].ID)
132+ at, size, lo, hi := pos(sel.Rect)
133+ if sel.Hidden || !sel.Focused || at < lo || at+size > hi {
134+ t.Fatalf("card %d off the area: %+v in %v", i, sel, u)
135+ }
136+ first, _, _, _ := pos(previewOf(t, ps, w.Stash[0].ID).Rect)
137+ last, _, _, _ := pos(previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect)
138+ if first > lo || last+size < hi {
139+ t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)
140+ }
141+ if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {
142+ centred = true
143+ } else if first != lo && last+size != hi {
144+ t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)
145+ }
146+ for _, e := range w.Stash {
147+ hidden = hidden || previewOf(t, ps, e.ID).Hidden
148+ }
149+ if axis == verticalAxis {
150+ m.OverviewMove(0, 1)
151+ } else if i < len(w.Stash)-1 {
152+ m.OverviewMove(1, 0)
153+ }
154+ }
155+ if !hidden || !centred {
156+ t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
157+ }
158+ })
159+ }
160+}
161+
162+// A scroll overview unfolds its pile into a column on the left of the row;
163+// up and down walk it, right goes back to the columns.
164+func TestOverviewScrollStashUnfolds(t *testing.T) {
165+ m := overviewMonitor()
166+ w := m.Current()
167+ for id := WindowID(40); id < 43; id++ {
168+ w.AddWindow(id)
169+ w.FocusID(id)
170+ w.ToggleWindowStash()
171+ }
172+ w.FocusID(1)
173+ m.ToggleOverview()
174+ if m.overviewAxis() != verticalAxis {
175+ t.Fatal("setup: not a vertical overview")
176+ }
177+ m.OverviewMove(-1, 0)
178+ if m.cardAt(w) < 0 {
179+ t.Fatal("left from the first column did not select the stash")
180+ }
181+ cards := shownCards(t, w, m.Layout())
182+ if len(cards) != 3 {
183+ t.Fatalf("%d cards shown, want 3", len(cards))
184+ }
185+ for i := 1; i < len(cards); i++ {
186+ if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
187+ t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
188+ }
189+ }
190+ m.OverviewMove(0, -1)
191+ m.OverviewMove(0, -1)
192+ if m.cardAt(w) != 0 {
193+ t.Fatalf("up: card %d, want 0", m.cardAt(w))
194+ }
195+ m.OverviewMove(0, 1)
196+ if m.cardAt(w) != 1 {
197+ t.Fatalf("down: card %d, want 1", m.cardAt(w))
198+ }
199+ m.OverviewMove(1, 0)
200+ if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
201+ t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
202+ }
203+}
@@ -0,0 +1,203 @@
1+package core
2+
3+import "testing"
4+
5+// cards are the shown stash placements of w in ps, in stash order.
6+func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement {
7+ t.Helper()
8+ var out []Placement
9+ for _, e := range w.Stash {
10+ if p := previewOf(t, ps, e.ID); !p.Hidden {
11+ out = append(out, p)
12+ }
13+ }
14+ return out
15+}
16+
17+// Selected, the cascade pile unfolds into a row under the lane: every card
18+// side by side, in stash order, none over another, the selection the only
19+// framed one. Left and right walk it.
20+func TestOverviewCascadeStashUnfolds(t *testing.T) {
21+ for _, d := range cascadeDrivers {
22+ t.Run(d.name, func(t *testing.T) {
23+ m := cascadeOverview()
24+ withStash(m, 3)
25+ w := m.Current()
26+ w.FocusID(10)
27+ m.ToggleOverview()
28+ d.move(m, 0, 1)
29+ at := m.cardAt(w)
30+ if at < 0 {
31+ t.Fatal("setup: no card selected")
32+ }
33+ ps := m.Layout()
34+ cards := shownCards(t, w, ps)
35+ if len(cards) != 3 {
36+ t.Fatalf("%d cards shown, want 3", len(cards))
37+ }
38+ lane := previewOf(t, ps, 10).Rect
39+ for i, c := range cards {
40+ if c.Rect.Y <= lane.Y+lane.H {
41+ t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane)
42+ }
43+ if i > 0 && (c.Rect.Y != cards[0].Rect.Y || c.Rect.X < cards[i-1].Rect.X+cards[i-1].Rect.W) {
44+ t.Fatalf("cards not side by side: %v then %v", cards[i-1].Rect, c.Rect)
45+ }
46+ if c.Focused != (i == at) {
47+ t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at)
48+ }
49+ }
50+ for i := range 2 {
51+ d.move(m, -1, 0)
52+ if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want {
53+ t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want)
54+ }
55+ if at-i-1 < 0 {
56+ break
57+ }
58+ }
59+ for m.cardAt(w) < len(w.Stash)-1 {
60+ before := m.cardAt(w)
61+ d.move(m, 1, 0)
62+ if m.cardAt(w) != before+1 {
63+ t.Fatalf("right: card %d, was %d", m.cardAt(w), before)
64+ }
65+ }
66+ // Right past the last card goes to the next workspace.
67+ d.move(m, 1, 0)
68+ if m.Current() != m.Workspaces[2] {
69+ t.Fatal("right past the last card did not change workspace")
70+ }
71+ })
72+ }
73+}
74+
75+// Down past the last lane enters the stash at its own selection; up leaves
76+// it, and the pile folds again.
77+func TestOverviewCascadeStashFolds(t *testing.T) {
78+ m := cascadeOverview()
79+ withStash(m, 3)
80+ w := m.Current()
81+ w.FocusID(10)
82+ m.ToggleOverview()
83+ folded := shownCards(t, w, m.Layout())
84+ m.OverviewMove(0, 1)
85+ m.OverviewMove(0, -1)
86+ if m.cardAt(w) >= 0 {
87+ t.Fatal("up did not leave the stash")
88+ }
89+ again := shownCards(t, w, m.Layout())
90+ if len(again) != len(folded) {
91+ t.Fatalf("folded again: %d cards, was %d", len(again), len(folded))
92+ }
93+ for i := range again {
94+ if again[i].Rect != folded[i].Rect || again[i].Focused {
95+ t.Fatalf("card %d folded at %v (focused %v), was %v", again[i].ID, again[i].Rect, again[i].Focused, folded[i].Rect)
96+ }
97+ }
98+}
99+
100+// A stash longer than the area scrolls like a wide row, on either axis:
101+// every card is reached in order, the selection stays inside the area and
102+// is centred unless the strip stops at an edge of it, and cards past the
103+// area are hidden.
104+func TestOverviewStashUnfoldScrolls(t *testing.T) {
105+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
106+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
107+ m := cascadeOverview()
108+ w := m.Current()
109+ if axis == verticalAxis {
110+ w.Overflow = OverflowScroll
111+ }
112+ withStash(m, 12)
113+ m.ToggleOverview()
114+ if m.overviewAxis() != axis {
115+ t.Fatal("setup: wrong overview axis")
116+ }
117+ m.selectCard(w, 0)
118+ u := w.overviewArea()
119+ pos := func(r Rect) (int, int, int, int) {
120+ if axis == verticalAxis {
121+ return r.Y, r.H, u.Y, u.Y + u.H
122+ }
123+ return r.X, r.W, u.X, u.X + u.W
124+ }
125+ centred, hidden := false, false
126+ for i := range len(w.Stash) {
127+ if m.cardAt(w) != i {
128+ t.Fatalf("step %d: card %d", i, m.cardAt(w))
129+ }
130+ ps := m.Layout()
131+ sel := previewOf(t, ps, w.Stash[i].ID)
132+ at, size, lo, hi := pos(sel.Rect)
133+ if sel.Hidden || !sel.Focused || at < lo || at+size > hi {
134+ t.Fatalf("card %d off the area: %+v in %v", i, sel, u)
135+ }
136+ first, _, _, _ := pos(previewOf(t, ps, w.Stash[0].ID).Rect)
137+ last, _, _, _ := pos(previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect)
138+ if first > lo || last+size < hi {
139+ t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)
140+ }
141+ if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {
142+ centred = true
143+ } else if first != lo && last+size != hi {
144+ t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)
145+ }
146+ for _, e := range w.Stash {
147+ hidden = hidden || previewOf(t, ps, e.ID).Hidden
148+ }
149+ if axis == verticalAxis {
150+ m.OverviewMove(0, 1)
151+ } else if i < len(w.Stash)-1 {
152+ m.OverviewMove(1, 0)
153+ }
154+ }
155+ if !hidden || !centred {
156+ t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
157+ }
158+ })
159+ }
160+}
161+
162+// A scroll overview unfolds its pile into a column on the left of the row;
163+// up and down walk it, right goes back to the columns.
164+func TestOverviewScrollStashUnfolds(t *testing.T) {
165+ m := overviewMonitor()
166+ w := m.Current()
167+ for id := WindowID(40); id < 43; id++ {
168+ w.AddWindow(id)
169+ w.FocusID(id)
170+ w.ToggleWindowStash()
171+ }
172+ w.FocusID(1)
173+ m.ToggleOverview()
174+ if m.overviewAxis() != verticalAxis {
175+ t.Fatal("setup: not a vertical overview")
176+ }
177+ m.OverviewMove(-1, 0)
178+ if m.cardAt(w) < 0 {
179+ t.Fatal("left from the first column did not select the stash")
180+ }
181+ cards := shownCards(t, w, m.Layout())
182+ if len(cards) != 3 {
183+ t.Fatalf("%d cards shown, want 3", len(cards))
184+ }
185+ for i := 1; i < len(cards); i++ {
186+ if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
187+ t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
188+ }
189+ }
190+ m.OverviewMove(0, -1)
191+ m.OverviewMove(0, -1)
192+ if m.cardAt(w) != 0 {
193+ t.Fatalf("up: card %d, want 0", m.cardAt(w))
194+ }
195+ m.OverviewMove(0, 1)
196+ if m.cardAt(w) != 1 {
197+ t.Fatalf("down: card %d, want 1", m.cardAt(w))
198+ }
199+ m.OverviewMove(1, 0)
200+ if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
201+ t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
202+ }
203+}

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