detached f8b3a18

Merge pull request 'feat(overview): unfold the selected stash pile' (#56) from feat/overview-stash-unfold into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/56
commit
f8b3a189ac691cbedb05235a7b1cada483e41300
author
bnema · · 2026-10-15 05:35 UTC
merge of
8193e80 + d1a6612 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
5 files · +448 -135
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 +170 -93
@@ -53,12 +53,16 @@ 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, in a band
64+// reserved under the selected lane (pileBand): the lane and the band are
65+// centred together.
6266 const (
6367 overviewCardZoom = 0.2
6468 overviewCards = 4
@@ -398,21 +402,20 @@ func (m *Monitor) OverviewMove(dx, dy int) {
398402 }
399403
400404 // 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.
405+// walks the unfolded stash and the stack cards before changing workspace
406+// (overviewVertical). A side-by-side one always changes workspace, since
407+// its stack and unfolded stash run left and right.
404408 func (m *Monitor) overviewWorkspaceStep(d int) {
405409 if m.overviewAxis() == verticalAxis {
406410 m.overviewVertical(d)
407411 return
408412 }
409- if !m.cycleCard(m.Current(), d) {
410- m.overviewStep(d)
411- }
413+ m.overviewStep(d)
412414 }
413415
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.
416+// cycleCard selects the stash card d entries from the selected one of w in
417+// the unfolded pile, stopping at either end. It reports whether a card was
418+// selected.
416419 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
417420 at := m.cardAt(w)
418421 if at < 0 {
@@ -423,19 +426,16 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423426 }
424427
425428 // 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.
429+// by side (cascade). From the unfolded stash, up leaves it for the last lane
430+// (leavePile) and down stays: the stash is the bottom. Else the selection
431+// goes to the previous or next lane (band), at the closest column, stopping
432+// at the first. Down past the last lane, or from a card that is not the
433+// columns, enters the stash, which sits below. Covering floats stack along
434+// the horizontal axis, not this one.
433435 func (m *Monitor) overviewLane(dy int) {
434436 w := m.Current()
435437 if m.cardAt(w) >= 0 {
436- if dy > 0 {
437- m.cycleCard(w, dy)
438- } else {
438+ if dy < 0 {
439439 m.leavePile(w)
440440 }
441441 return
@@ -479,31 +479,19 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
479479 }
480480
481481 // 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
482+// side by side (cascade): a column inside the selected lane, or a card of
483+// the unfolded stash. Past the first column or card it goes to the previous
484+// workspace; past the last, to the next workspace. Covering floats fan
486485 // along this axis and are traversed before a workspace: the card on the
487486 // left is the one behind.
488487 func (m *Monitor) overviewAcross(dx int) {
489488 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)
489+ if at := m.cardAt(w); at >= 0 {
490+ if next := at + dx; next >= 0 && next < len(w.Stash) {
491+ m.selectCard(w, next)
495492 return
496493 }
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- }
494+ m.overviewStep(dx)
507495 return
508496 }
509497 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -663,7 +651,7 @@ func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().worksp
663651 //
664652 // Vertical axis: full-width slots, the current one centred; the rule is
665653 // horizontal. Horizontal axis: full-height slots as wide as their content,
666-// stack peeks included (the stash pile sits over the row), the current one
654+// stack peeks included (the stash pile takes a band of the slot), the current one
667655 // centred; the rule is vertical, in the spacing between two slots.
668656 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
669657 cur := m.Current()
@@ -744,13 +732,22 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
744732 cur := m.Current()
745733 rows, _ := m.overviewRows()
746734 axis := m.overviewAxis()
735+ // The unfolded stash goes last: its strip may run over the neighbor
736+ // rows, which must not cover it.
737+ var unfolded *Workspace
738+ var unfoldedSlot Rect
747739 for w := range m.all() {
748740 slot, shown := rows[w]
749741 if shown {
742+ // The pile band goes under the row: the row is centred
743+ // with it.
744+ slot.H -= w.pileBand(axis)
750745 at := m.cardAt(w)
751746 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
752- if len(w.Stash) > 0 {
753- result = append(result, w.pile(axis, slot, w != cur, at)...)
747+ if at >= 0 {
748+ unfolded, unfoldedSlot = w, slot
749+ } else {
750+ result = w.pile(result, axis, slot, w != cur, -1)
754751 }
755752 }
756753 for _, f := range w.Floats {
@@ -763,13 +760,16 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
763760 result = w.appendHidden(result, false)
764761 }
765762 }
763+ if unfolded != nil {
764+ result = unfolded.pile(result, axis, unfoldedSlot, false, m.cardAt(unfolded))
765+ }
766766 return result
767767 }
768768
769769 // fan places front and neighboring cards, farthest visible first and front
770770 // last. A card may contain several tiles; omitted cards stay in the layout
771771 // hidden so their clients are not left showing on screen.
772-func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
772+func fan(cards [][]Placement, front, maxBehind, maxBefore int,
773773 behind, before func(int) (int, int), dim, lit bool) []Placement {
774774 n := len(cards)
775775 if n == 0 {
@@ -795,9 +795,6 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
795795 // Behind and before are in nearest-first order; paint farthest first.
796796 for d := min(maxBehind, n-1); d >= 1; d-- {
797797 i := front + d
798- if circular {
799- i = (front + d) % n
800- }
801798 if i >= n {
802799 continue
803800 }
@@ -806,13 +803,7 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
806803 }
807804 for d := min(maxBefore, n-1); d >= 1; d-- {
808805 i := front - d
809- if circular {
810- i = (front - d + n) % n
811- }
812- if i < 0 {
813- continue
814- }
815- if shown[i] {
806+ if i < 0 || shown[i] {
816807 continue
817808 }
818809 add(i, d, before, true)
@@ -840,59 +831,131 @@ func (w *Workspace) pileWidth() int {
840831 if len(w.Stash) == 0 {
841832 return 0
842833 }
843- card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))
834+ card, _ := w.cardSize()
844835 // Never more than a third of the width: the columns keep the rest.
845836 a := w.overviewArea()
846837 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
847838 }
848839
840+// cardSize is the size of a stash card in the overview.
841+func (w *Workspace) cardSize() (int, int) {
842+ r := w.stashRect()
843+ return int(math.Round(float64(r.W) * overviewCardZoom)), int(math.Round(float64(r.H) * overviewCardZoom))
844+}
845+
846+// pileBand is the height a horizontal overview gives the stash of w under
847+// its selected lane: the spacing, then the folded pile with its cards
848+// behind. 0 without a stash, or in a vertical overview, where the pile
849+// sits left of the row. The lane and the band are centred together.
850+func (w *Workspace) pileBand(axis layoutAxis) int {
851+ if axis != horizontalAxis || len(w.Stash) == 0 {
852+ return 0
853+ }
854+ _, ch := w.cardSize()
855+ return max(1, w.overviewArea().H*3/100) + ch + (min(len(w.Stash), overviewCards)-1)*w.cardStep()
856+}
857+
849858 // cardStep is the offset between stacked cards, smaller on tiny outputs.
850859 func (w *Workspace) cardStep() int {
851860 a := w.overviewArea()
852861 return min(overviewCardStep, a.W/100, a.H/50)
853862 }
854863
855-// 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 {
864+// pile appends the stash of w to dst as cards in its slot along axis (see
865+// the overview's stash comment). With entry at selected (at >= 0, on the
866+// current row: dim false) the pile unfolds; else it is folded: the stash
867+// selection in front, painted last, up to overviewCards-1 next ones
868+// (wrapping round) behind it, farthest first, dimmed; the rest hidden.
869+func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool, at int) []Placement {
860870 n := len(w.Stash)
861871 if n == 0 {
862- return nil
863- }
864- selected := front >= 0 && !dim
865- if front < 0 {
866- front = w.stashAt
872+ return dst
867873 }
868- front = min(max(front, 0), n-1)
869- r := w.stashRect()
870874 u := w.overviewArea()
871- cw := int(math.Round(float64(r.W) * overviewCardZoom))
872- ch := int(math.Round(float64(r.H) * overviewCardZoom))
875+ cw, ch := w.cardSize()
873876 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
874877 var x0, y0 int
875878 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.
879+ // Centred on the row, under the selected lane: slot is the
880+ // lane's, the pile band (pileBand) taken off its height, so the
881+ // band starts below the lane. The cards behind rise from the
882+ // front one, which goes at the bottom of the band. Both scale
883+ // with the area (the lane at most overviewMaxZoom of it), so
884+ // the pile stays inside whatever its size.
880885 x0 = slot.X + (slot.W-cw)/2
881- y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2
886+ y0 = slot.Y + (slot.H-rowH)/2 + rowH + w.pileBand(axis) - ch
882887 } else {
883888 // Stack rows reserve two peek steps above the front card. Align
884889 // the stash with that front band, not with the top of its envelope.
885890 band := w.frontSlot(axis, slot)
886891 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
887892 }
888- cards := make([][]Placement, n)
893+ folded := Rect{X: x0, Y: y0, W: cw, H: ch}
894+ if at >= 0 && !dim {
895+ return w.unfold(dst, axis, folded, min(at, n-1))
896+ }
897+ front, behind, step := min(max(w.stashAt, 0), n-1), min(n, overviewCards)-1, w.cardStep()
898+ card := func(i, d int) Placement {
899+ p := Placement{ID: w.Stash[i].ID, Rect: folded, Preview: overviewCardZoom, Card: true}
900+ p.Rect.X, p.Rect.Y = p.Rect.X+d*step, p.Rect.Y-d*step
901+ if d > 0 || dim {
902+ p.peeking(1)
903+ }
904+ return p
905+ }
906+ for d := behind; d >= 0; d-- {
907+ dst = append(dst, card((front+d)%n, d))
908+ }
889909 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}}
910+ // The cards neither in front nor behind it: hidden, so their
911+ // clients are not left showing.
912+ if (i-front+n)%n > behind {
913+ dst = append(dst, Placement{ID: w.Stash[i].ID, Rect: folded, Card: true, Hidden: true})
914+ }
891915 }
892- step := w.cardStep()
893- return fan(cards, front, min(n, overviewCards)-1, 0, true,
894- func(d int) (int, int) { return d * step, -d * step },
895- func(int) (int, int) { return 0, 0 }, dim, selected)
916+ return dst
917+}
918+
919+// unfold lays the stash of w out as a strip of every card along axis, from
920+// the folded pile's card rect: a row in a horizontal overview, a column in
921+// a vertical one. The strip is centred on the pile and kept in the overview
922+// area. Longer than the area, it scrolls like a wide row: the selected card
923+// at is centred, the strip stopping at the area's edges. The other cards
924+// are dimmed; those out of the area are hidden. The cards are appended to
925+// dst.
926+func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
927+ n := len(w.Stash)
928+ u := w.overviewArea()
929+ size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W
930+ if axis == verticalAxis {
931+ size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H
932+ }
933+ stride := size + 2*w.cardStep()
934+ total := n*stride - (stride - size)
935+ start := origin + size/2 - total/2
936+ if total > hi-lo {
937+ start = origin + size/2 - at*stride - size/2
938+ start = min(max(start, hi-total), lo)
939+ } else {
940+ start = min(max(start, lo), hi-total)
941+ }
942+ for i := range w.Stash {
943+ r := folded
944+ if axis == verticalAxis {
945+ r.Y = start + i*stride
946+ } else {
947+ r.X = start + i*stride
948+ }
949+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
950+ if i != at {
951+ p.peeking(1)
952+ }
953+ if !r.Overlaps(u) {
954+ p.Hidden, p.Preview, p.Focused = true, 0, false
955+ }
956+ dst = append(dst, p)
957+ }
958+ return dst
896959 }
897960
898961 // overviewZoom is the scale that fits every column of w in the usable
@@ -1082,7 +1145,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
10821145 }
10831146 // Rows stay centred on the output like rows without a stash; in a
10841147 // vertical overview the pile only pushes a row right when they would
1085- // overlap. A horizontal one draws the pile over the row.
1148+ // overlap. A horizontal one gives the pile a band under the selected
1149+ // lane (pileBand).
10861150 left := 0
10871151 if axis == verticalAxis {
10881152 left = w.pileWidth()
@@ -1098,6 +1162,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
10981162 // Lanes along the overview axis would overlap the neighbor workspaces.
10991163 otherLanes := w.policy().content != axis
11001164 laneStep := scale(stride) + max(1, u.H*3/100)
1165+ // The lanes below the selected one leave room for the stash pile.
1166+ below := w.pileBand(axis)
11011167 selLane := 0
11021168 if lanes {
11031169 selLane = max(sel.Y, 0) / stride
@@ -1113,7 +1179,11 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
11131179 lane, ly = l-selLane, r.Y-l*stride
11141180 }
11151181 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}
1182+ y := top + y0 + lane*laneStep
1183+ if lanes && lane > 0 {
1184+ y += below
1185+ }
1186+ tiles[i].Rect = Rect{X: x + x0, Y: y, W: scale(r.X+r.W) - x0, H: scale(ly+r.H) - y0}
11171187 tiles[i].Preview = z
11181188 if dim || lane != 0 {
11191189 tiles[i].peeking(1)
@@ -1250,9 +1320,8 @@ func overviewIn(layout []Placement, x, y float64) WindowID {
12501320 return 0
12511321 }
12521322
1253-// overviewHit is overviewIn with the stash piles first: a pile is drawn
1254-// over its row (a horizontal overview), the cards behind its front one
1255-// included.
1323+// overviewHit is overviewIn with the stash piles first: the cards behind a
1324+// folded pile's front one peek out where tiles may be drawn over them.
12561325 func (m *Monitor) overviewHit(layout []Placement, x, y float64) WindowID {
12571326 var piles []Placement
12581327 for _, p := range layout {
@@ -1408,9 +1477,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
14081477 if finger {
14091478 before = m.overviewSelection()
14101479 }
1411- if cascade && !finger && m.cardAt(m.Current()) < 0 {
1480+ switch {
1481+ case cascade && !finger && m.cardAt(m.Current()) >= 0:
1482+ // A wheel walks the unfolded stash, a row.
1483+ m.OverviewMove(d, 0)
1484+ case cascade && !finger:
14121485 m.overviewStep(d)
1413- } else {
1486+ default:
14141487 m.OverviewMove(0, d)
14151488 }
14161489 if m.overviewTurned(axis, finger) {
@@ -1424,9 +1497,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
14241497 d := sign(m.ov.scrollX)
14251498 if cascade && finger {
14261499 before = m.overviewSelection()
1427- // One step per gesture: one workspace, even over a
1428- // stash card.
1429- m.overviewStep(d)
1500+ // One step per gesture: one card of the unfolded
1501+ // stash, else one workspace.
1502+ if m.cardAt(m.Current()) >= 0 {
1503+ m.OverviewMove(d, 0)
1504+ } else {
1505+ m.overviewStep(d)
1506+ }
14301507 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
14311508 return m.overviewSelection() != before
14321509 }
@@ -53,12 +53,16 @@ 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, in a band
64+// reserved under the selected lane (pileBand): the lane and the band are
65+// centred together.
62 const (66 const (
63 overviewCardZoom = 0.267 overviewCardZoom = 0.2
64 overviewCards = 468 overviewCards = 4
@@ -398,21 +402,20 @@ func (m *Monitor) OverviewMove(dx, dy int) {
398 }402 }
399 403
400 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
401-// walks the stack cards before changing workspace (overviewVertical). A405+// walks the unfolded stash and the stack cards before changing workspace
402-// side-by-side one always changes workspace, since its stack fans left and406+// (overviewVertical). A side-by-side one always changes workspace, since
403-// right. A selected stash card cycles the pile first, in every layout.407+// its stack and unfolded stash run left and right.
404 func (m *Monitor) overviewWorkspaceStep(d int) {408 func (m *Monitor) overviewWorkspaceStep(d int) {
405 if m.overviewAxis() == verticalAxis {409 if m.overviewAxis() == verticalAxis {
406 m.overviewVertical(d)410 m.overviewVertical(d)
407 return411 return
408 }412 }
409- if !m.cycleCard(m.Current(), d) {413+ m.overviewStep(d)
410- m.overviewStep(d)
411- }
412 }414 }
413 415
414-// cycleCard brings the stash card d entries from the selected one of w to416+// 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.417+// the unfolded pile, stopping at either end. It reports whether a card was
418+// selected.
416 func (m *Monitor) cycleCard(w *Workspace, d int) bool {419 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
417 at := m.cardAt(w)420 at := m.cardAt(w)
418 if at < 0 {421 if at < 0 {
@@ -423,19 +426,16 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423 }426 }
424 427
425 // overviewLane is the vertical move of an overview whose workspaces sit side428 // 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 card429+// 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 for430+// (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.431+// 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 the432+// 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 a433+// columns, enters the stash, which sits below. Covering floats stack along
431-// card that is not the columns, enters the stash, which sits below. Covering434+// the horizontal axis, not this one.
432-// floats stack along the horizontal axis, not this one.
433 func (m *Monitor) overviewLane(dy int) {435 func (m *Monitor) overviewLane(dy int) {
434 w := m.Current()436 w := m.Current()
435 if m.cardAt(w) >= 0 {437 if m.cardAt(w) >= 0 {
436- if dy > 0 {438+ if dy < 0 {
437- m.cycleCard(w, dy)
438- } else {
439 m.leavePile(w)439 m.leavePile(w)
440 }440 }
441 return441 return
@@ -479,31 +479,19 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
479 }479 }
480 480
481 // overviewAcross is the horizontal move of an overview whose workspaces sit481 // overviewAcross is the horizontal move of an overview whose workspaces sit
482-// side by side (cascade): a column inside the selected lane. Past the482+// 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 last483+// 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 the484+// 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 the485 // along this axis and are traversed before a workspace: the card on the
487 // left is the one behind.486 // left is the one behind.
488 func (m *Monitor) overviewAcross(dx int) {487 func (m *Monitor) overviewAcross(dx int) {
489 w := m.Current()488 w := m.Current()
490- if m.cardAt(w) >= 0 {489+ if at := m.cardAt(w); at >= 0 {
491- if dx < 0 || len(w.stack()) == 0 {490+ if next := at + dx; next >= 0 && next < len(w.Stash) {
492- // Left leaves the pile; a stash-only workspace has nothing491+ m.selectCard(w, next)
493- // to its right to select.
494- m.overviewStep(dx)
495 return492 return
496 }493 }
497- m.clearCard()494+ 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 return495 return
508 }496 }
509 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {497 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -663,7 +651,7 @@ func (m *Monitor) overviewAxis() layoutAxis { return m.Current().policy().worksp
663 //651 //
664 // Vertical axis: full-width slots, the current one centred; the rule is652 // Vertical axis: full-width slots, the current one centred; the rule is
665 // horizontal. Horizontal axis: full-height slots as wide as their content,653 // horizontal. Horizontal axis: full-height slots as wide as their content,
666-// stack peeks included (the stash pile sits over the row), the current one654+// stack peeks included (the stash pile takes a band of the slot), the current one
667 // centred; the rule is vertical, in the spacing between two slots.655 // centred; the rule is vertical, in the spacing between two slots.
668 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {656 func (m *Monitor) overviewRows() (map[*Workspace]Rect, []ports.Separator) {
669 cur := m.Current()657 cur := m.Current()
@@ -744,13 +732,22 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
744 cur := m.Current()732 cur := m.Current()
745 rows, _ := m.overviewRows()733 rows, _ := m.overviewRows()
746 axis := m.overviewAxis()734 axis := m.overviewAxis()
735+ // The unfolded stash goes last: its strip may run over the neighbor
736+ // rows, which must not cover it.
737+ var unfolded *Workspace
738+ var unfoldedSlot Rect
747 for w := range m.all() {739 for w := range m.all() {
748 slot, shown := rows[w]740 slot, shown := rows[w]
749 if shown {741 if shown {
742+ // The pile band goes under the row: the row is centred
743+ // with it.
744+ slot.H -= w.pileBand(axis)
750 at := m.cardAt(w)745 at := m.cardAt(w)
751 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)746 result = append(result, m.stackRow(w, axis, slot, w != cur, at < 0)...)
752- if len(w.Stash) > 0 {747+ if at >= 0 {
753- result = append(result, w.pile(axis, slot, w != cur, at)...)748+ unfolded, unfoldedSlot = w, slot
749+ } else {
750+ result = w.pile(result, axis, slot, w != cur, -1)
754 }751 }
755 }752 }
756 for _, f := range w.Floats {753 for _, f := range w.Floats {
@@ -763,13 +760,16 @@ func (m *Monitor) overviewLayout(result []Placement) []Placement {
763 result = w.appendHidden(result, false)760 result = w.appendHidden(result, false)
764 }761 }
765 }762 }
763+ if unfolded != nil {
764+ result = unfolded.pile(result, axis, unfoldedSlot, false, m.cardAt(unfolded))
765+ }
766 return result766 return result
767 }767 }
768 768
769 // fan places front and neighboring cards, farthest visible first and front769 // fan places front and neighboring cards, farthest visible first and front
770 // last. A card may contain several tiles; omitted cards stay in the layout770 // last. A card may contain several tiles; omitted cards stay in the layout
771 // hidden so their clients are not left showing on screen.771 // hidden so their clients are not left showing on screen.
772-func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,772+func fan(cards [][]Placement, front, maxBehind, maxBefore int,
773 behind, before func(int) (int, int), dim, lit bool) []Placement {773 behind, before func(int) (int, int), dim, lit bool) []Placement {
774 n := len(cards)774 n := len(cards)
775 if n == 0 {775 if n == 0 {
@@ -795,9 +795,6 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
795 // Behind and before are in nearest-first order; paint farthest first.795 // Behind and before are in nearest-first order; paint farthest first.
796 for d := min(maxBehind, n-1); d >= 1; d-- {796 for d := min(maxBehind, n-1); d >= 1; d-- {
797 i := front + d797 i := front + d
798- if circular {
799- i = (front + d) % n
800- }
801 if i >= n {798 if i >= n {
802 continue799 continue
803 }800 }
@@ -806,13 +803,7 @@ func fan(cards [][]Placement, front, maxBehind, maxBefore int, circular bool,
806 }803 }
807 for d := min(maxBefore, n-1); d >= 1; d-- {804 for d := min(maxBefore, n-1); d >= 1; d-- {
808 i := front - d805 i := front - d
809- if circular {806+ if i < 0 || shown[i] {
810- i = (front - d + n) % n
811- }
812- if i < 0 {
813- continue
814- }
815- if shown[i] {
816 continue807 continue
817 }808 }
818 add(i, d, before, true)809 add(i, d, before, true)
@@ -840,59 +831,131 @@ func (w *Workspace) pileWidth() int {
840 if len(w.Stash) == 0 {831 if len(w.Stash) == 0 {
841 return 0832 return 0
842 }833 }
843- card := int(math.Round(float64(w.stashRect().W) * overviewCardZoom))834+ card, _ := w.cardSize()
844 // Never more than a third of the width: the columns keep the rest.835 // Never more than a third of the width: the columns keep the rest.
845 a := w.overviewArea()836 a := w.overviewArea()
846 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)837 return min(card+(min(len(w.Stash), overviewCards)-1)*w.cardStep()+a.W*2/100, a.W/3)
847 }838 }
848 839
840+// cardSize is the size of a stash card in the overview.
841+func (w *Workspace) cardSize() (int, int) {
842+ r := w.stashRect()
843+ return int(math.Round(float64(r.W) * overviewCardZoom)), int(math.Round(float64(r.H) * overviewCardZoom))
844+}
845+
846+// pileBand is the height a horizontal overview gives the stash of w under
847+// its selected lane: the spacing, then the folded pile with its cards
848+// behind. 0 without a stash, or in a vertical overview, where the pile
849+// sits left of the row. The lane and the band are centred together.
850+func (w *Workspace) pileBand(axis layoutAxis) int {
851+ if axis != horizontalAxis || len(w.Stash) == 0 {
852+ return 0
853+ }
854+ _, ch := w.cardSize()
855+ return max(1, w.overviewArea().H*3/100) + ch + (min(len(w.Stash), overviewCards)-1)*w.cardStep()
856+}
857+
849 // cardStep is the offset between stacked cards, smaller on tiny outputs.858 // cardStep is the offset between stacked cards, smaller on tiny outputs.
850 func (w *Workspace) cardStep() int {859 func (w *Workspace) cardStep() int {
851 a := w.overviewArea()860 a := w.overviewArea()
852 return min(overviewCardStep, a.W/100, a.H/50)861 return min(overviewCardStep, a.W/100, a.H/50)
853 }862 }
854 863
855-// pile places the stash of w as cards in its slot along axis (see the864+// pile appends the stash of w to dst as cards in its slot along axis (see
856-// overview's stash comment): entry front (the stash selection when -1) in865+// the 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 card866+// 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.867+// selection in front, painted last, up to overviewCards-1 next ones
859-func (w *Workspace) pile(axis layoutAxis, slot Rect, dim bool, front int) []Placement {868+// (wrapping round) behind it, farthest first, dimmed; the rest hidden.
869+func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool, at int) []Placement {
860 n := len(w.Stash)870 n := len(w.Stash)
861 if n == 0 {871 if n == 0 {
862- return nil872+ return dst
863- }
864- selected := front >= 0 && !dim
865- if front < 0 {
866- front = w.stashAt
867 }873 }
868- front = min(max(front, 0), n-1)
869- r := w.stashRect()
870 u := w.overviewArea()874 u := w.overviewArea()
871- cw := int(math.Round(float64(r.W) * overviewCardZoom))875+ 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)))876 rowH := int(math.Round(float64(w.Usable.H) * w.overviewZoom(axis)))
874 var x0, y0 int877 var x0, y0 int
875 if axis == horizontalAxis {878 if axis == horizontalAxis {
876- // Centred on the row, across the bottom edge of the selected879+ // Centred on the row, under the selected lane: slot is the
877- // lane, which is centred in the slot. Both scale with the area880+ // lane's, the pile band (pileBand) taken off its height, so the
878- // (the lane at most overviewMaxZoom of it), so the pile stays881+ // band starts below the lane. The cards behind rise from the
879- // inside whatever its size.882+ // front one, which goes at the bottom of the band. Both scale
883+ // with the area (the lane at most overviewMaxZoom of it), so
884+ // the pile stays inside whatever its size.
880 x0 = slot.X + (slot.W-cw)/2885 x0 = slot.X + (slot.W-cw)/2
881- y0 = slot.Y + (slot.H-rowH)/2 + rowH - ch/2886+ y0 = slot.Y + (slot.H-rowH)/2 + rowH + w.pileBand(axis) - ch
882 } else {887 } else {
883 // Stack rows reserve two peek steps above the front card. Align888 // 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.889 // the stash with that front band, not with the top of its envelope.
885 band := w.frontSlot(axis, slot)890 band := w.frontSlot(axis, slot)
886 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2891 x0, y0 = band.X+u.W/100, band.Y+(rowH-ch)/2
887 }892 }
888- cards := make([][]Placement, n)893+ folded := Rect{X: x0, Y: y0, W: cw, H: ch}
894+ if at >= 0 && !dim {
895+ return w.unfold(dst, axis, folded, min(at, n-1))
896+ }
897+ front, behind, step := min(max(w.stashAt, 0), n-1), min(n, overviewCards)-1, w.cardStep()
898+ card := func(i, d int) Placement {
899+ p := Placement{ID: w.Stash[i].ID, Rect: folded, Preview: overviewCardZoom, Card: true}
900+ p.Rect.X, p.Rect.Y = p.Rect.X+d*step, p.Rect.Y-d*step
901+ if d > 0 || dim {
902+ p.peeking(1)
903+ }
904+ return p
905+ }
906+ for d := behind; d >= 0; d-- {
907+ dst = append(dst, card((front+d)%n, d))
908+ }
889 for i := range w.Stash {909 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}}910+ // The cards neither in front nor behind it: hidden, so their
911+ // clients are not left showing.
912+ if (i-front+n)%n > behind {
913+ dst = append(dst, Placement{ID: w.Stash[i].ID, Rect: folded, Card: true, Hidden: true})
914+ }
891 }915 }
892- step := w.cardStep()916+ return dst
893- return fan(cards, front, min(n, overviewCards)-1, 0, true,917+}
894- func(d int) (int, int) { return d * step, -d * step },918+
895- func(int) (int, int) { return 0, 0 }, dim, selected)919+// unfold lays the stash of w out as a strip of every card along axis, from
920+// the folded pile's card rect: a row in a horizontal overview, a column in
921+// a vertical one. The strip is centred on the pile and kept in the overview
922+// area. Longer than the area, it scrolls like a wide row: the selected card
923+// at is centred, the strip stopping at the area's edges. The other cards
924+// are dimmed; those out of the area are hidden. The cards are appended to
925+// dst.
926+func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
927+ n := len(w.Stash)
928+ u := w.overviewArea()
929+ size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W
930+ if axis == verticalAxis {
931+ size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H
932+ }
933+ stride := size + 2*w.cardStep()
934+ total := n*stride - (stride - size)
935+ start := origin + size/2 - total/2
936+ if total > hi-lo {
937+ start = origin + size/2 - at*stride - size/2
938+ start = min(max(start, hi-total), lo)
939+ } else {
940+ start = min(max(start, lo), hi-total)
941+ }
942+ for i := range w.Stash {
943+ r := folded
944+ if axis == verticalAxis {
945+ r.Y = start + i*stride
946+ } else {
947+ r.X = start + i*stride
948+ }
949+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
950+ if i != at {
951+ p.peeking(1)
952+ }
953+ if !r.Overlaps(u) {
954+ p.Hidden, p.Preview, p.Focused = true, 0, false
955+ }
956+ dst = append(dst, p)
957+ }
958+ return dst
896 }959 }
897 960
898 // overviewZoom is the scale that fits every column of w in the usable961 // overviewZoom is the scale that fits every column of w in the usable
@@ -1082,7 +1145,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1082 }1145 }
1083 // Rows stay centred on the output like rows without a stash; in a1146 // Rows stay centred on the output like rows without a stash; in a
1084 // vertical overview the pile only pushes a row right when they would1147 // vertical overview the pile only pushes a row right when they would
1085- // overlap. A horizontal one draws the pile over the row.1148+ // overlap. A horizontal one gives the pile a band under the selected
1149+ // lane (pileBand).
1086 left := 01150 left := 0
1087 if axis == verticalAxis {1151 if axis == verticalAxis {
1088 left = w.pileWidth()1152 left = w.pileWidth()
@@ -1098,6 +1162,8 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1098 // Lanes along the overview axis would overlap the neighbor workspaces.1162 // Lanes along the overview axis would overlap the neighbor workspaces.
1099 otherLanes := w.policy().content != axis1163 otherLanes := w.policy().content != axis
1100 laneStep := scale(stride) + max(1, u.H*3/100)1164 laneStep := scale(stride) + max(1, u.H*3/100)
1165+ // The lanes below the selected one leave room for the stash pile.
1166+ below := w.pileBand(axis)
1101 selLane := 01167 selLane := 0
1102 if lanes {1168 if lanes {
1103 selLane = max(sel.Y, 0) / stride1169 selLane = max(sel.Y, 0) / stride
@@ -1113,7 +1179,11 @@ func (w *Workspace) previewRowTiles(axis layoutAxis, slot Rect, dim, lit, lanes
1113 lane, ly = l-selLane, r.Y-l*stride1179 lane, ly = l-selLane, r.Y-l*stride
1114 }1180 }
1115 x0, y0 := scale(r.X), scale(ly)1181 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}1182+ y := top + y0 + lane*laneStep
1183+ if lanes && lane > 0 {
1184+ y += below
1185+ }
1186+ 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 = z1187 tiles[i].Preview = z
1118 if dim || lane != 0 {1188 if dim || lane != 0 {
1119 tiles[i].peeking(1)1189 tiles[i].peeking(1)
@@ -1250,9 +1320,8 @@ func overviewIn(layout []Placement, x, y float64) WindowID {
1250 return 01320 return 0
1251 }1321 }
1252 1322
1253-// overviewHit is overviewIn with the stash piles first: a pile is drawn1323+// overviewHit is overviewIn with the stash piles first: the cards behind a
1254-// over its row (a horizontal overview), the cards behind its front one1324+// folded pile's front one peek out where tiles may be drawn over them.
1255-// included.
1256 func (m *Monitor) overviewHit(layout []Placement, x, y float64) WindowID {1325 func (m *Monitor) overviewHit(layout []Placement, x, y float64) WindowID {
1257 var piles []Placement1326 var piles []Placement
1258 for _, p := range layout {1327 for _, p := range layout {
@@ -1408,9 +1477,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1408 if finger {1477 if finger {
1409 before = m.overviewSelection()1478 before = m.overviewSelection()
1410 }1479 }
1411- if cascade && !finger && m.cardAt(m.Current()) < 0 {1480+ switch {
1481+ case cascade && !finger && m.cardAt(m.Current()) >= 0:
1482+ // A wheel walks the unfolded stash, a row.
1483+ m.OverviewMove(d, 0)
1484+ case cascade && !finger:
1412 m.overviewStep(d)1485 m.overviewStep(d)
1413- } else {1486+ default:
1414 m.OverviewMove(0, d)1487 m.OverviewMove(0, d)
1415 }1488 }
1416 if m.overviewTurned(axis, finger) {1489 if m.overviewTurned(axis, finger) {
@@ -1424,9 +1497,13 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
1424 d := sign(m.ov.scrollX)1497 d := sign(m.ov.scrollX)
1425 if cascade && finger {1498 if cascade && finger {
1426 before = m.overviewSelection()1499 before = m.overviewSelection()
1427- // One step per gesture: one workspace, even over a1500+ // One step per gesture: one card of the unfolded
1428- // stash card.1501+ // stash, else one workspace.
1429- m.overviewStep(d)1502+ if m.cardAt(m.Current()) >= 0 {
1503+ m.OverviewMove(d, 0)
1504+ } else {
1505+ m.overviewStep(d)
1506+ }
1430 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true1507 m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
1431 return m.overviewSelection() != before1508 return m.overviewSelection() != before
1432 }1509 }
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 }
M internal/core/overview_stack.go +1 -1
@@ -366,6 +366,6 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
366366 }
367367
368368 // fan's behind side advances through the linear stack.
369- return fan(cards, at, 2, 2, false,
369+ return fan(cards, at, 2, 2,
370370 func(d int) (int, int) { return along(-d) }, along, dim, lit)
371371 }
@@ -366,6 +366,6 @@ func (m *Monitor) stackRow(w *Workspace, axis layoutAxis, slot Rect, dim, lit bo
366 }366 }
367 367
368 // fan's behind side advances through the linear stack.368 // fan's behind side advances through the linear stack.
369- return fan(cards, at, 2, 2, false,369+ return fan(cards, at, 2, 2,
370 func(d int) (int, int) { return along(-d) }, along, dim, lit)370 func(d int) (int, int) { return along(-d) }, along, dim, lit)
371 }371 }
A internal/core/overview_unfold_test.go +227 -0
@@ -0,0 +1,227 @@
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+// The stash pile, folded or unfolded, is laid out straight into the
163+// overview layout every frame: it adds no allocation to the same layout
164+// without a stash.
165+func TestOverviewStashLayoutAllocations(t *testing.T) {
166+ allocs := func(m *Monitor) float64 {
167+ buf := m.layoutInto(nil)
168+ return testing.AllocsPerRun(50, func() { buf = m.layoutInto(buf) })
169+ }
170+ bare := cascadeOverview()
171+ bare.ToggleOverview()
172+ want := allocs(bare)
173+ for _, unfolded := range []bool{false, true} {
174+ m := cascadeOverview()
175+ withStash(m, 6)
176+ m.ToggleOverview()
177+ if unfolded {
178+ m.selectCard(m.Current(), 0)
179+ }
180+ if got := allocs(m); got > want {
181+ t.Errorf("unfolded %v: %v allocs per layout, %v without a stash", unfolded, got, want)
182+ }
183+ }
184+}
185+
186+// A scroll overview unfolds its pile into a column on the left of the row;
187+// up and down walk it, right goes back to the columns.
188+func TestOverviewScrollStashUnfolds(t *testing.T) {
189+ m := overviewMonitor()
190+ w := m.Current()
191+ for id := WindowID(40); id < 43; id++ {
192+ w.AddWindow(id)
193+ w.FocusID(id)
194+ w.ToggleWindowStash()
195+ }
196+ w.FocusID(1)
197+ m.ToggleOverview()
198+ if m.overviewAxis() != verticalAxis {
199+ t.Fatal("setup: not a vertical overview")
200+ }
201+ m.OverviewMove(-1, 0)
202+ if m.cardAt(w) < 0 {
203+ t.Fatal("left from the first column did not select the stash")
204+ }
205+ cards := shownCards(t, w, m.Layout())
206+ if len(cards) != 3 {
207+ t.Fatalf("%d cards shown, want 3", len(cards))
208+ }
209+ for i := 1; i < len(cards); i++ {
210+ if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
211+ t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
212+ }
213+ }
214+ m.OverviewMove(0, -1)
215+ m.OverviewMove(0, -1)
216+ if m.cardAt(w) != 0 {
217+ t.Fatalf("up: card %d, want 0", m.cardAt(w))
218+ }
219+ m.OverviewMove(0, 1)
220+ if m.cardAt(w) != 1 {
221+ t.Fatalf("down: card %d, want 1", m.cardAt(w))
222+ }
223+ m.OverviewMove(1, 0)
224+ if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
225+ t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
226+ }
227+}
@@ -0,0 +1,227 @@
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+// The stash pile, folded or unfolded, is laid out straight into the
163+// overview layout every frame: it adds no allocation to the same layout
164+// without a stash.
165+func TestOverviewStashLayoutAllocations(t *testing.T) {
166+ allocs := func(m *Monitor) float64 {
167+ buf := m.layoutInto(nil)
168+ return testing.AllocsPerRun(50, func() { buf = m.layoutInto(buf) })
169+ }
170+ bare := cascadeOverview()
171+ bare.ToggleOverview()
172+ want := allocs(bare)
173+ for _, unfolded := range []bool{false, true} {
174+ m := cascadeOverview()
175+ withStash(m, 6)
176+ m.ToggleOverview()
177+ if unfolded {
178+ m.selectCard(m.Current(), 0)
179+ }
180+ if got := allocs(m); got > want {
181+ t.Errorf("unfolded %v: %v allocs per layout, %v without a stash", unfolded, got, want)
182+ }
183+ }
184+}
185+
186+// A scroll overview unfolds its pile into a column on the left of the row;
187+// up and down walk it, right goes back to the columns.
188+func TestOverviewScrollStashUnfolds(t *testing.T) {
189+ m := overviewMonitor()
190+ w := m.Current()
191+ for id := WindowID(40); id < 43; id++ {
192+ w.AddWindow(id)
193+ w.FocusID(id)
194+ w.ToggleWindowStash()
195+ }
196+ w.FocusID(1)
197+ m.ToggleOverview()
198+ if m.overviewAxis() != verticalAxis {
199+ t.Fatal("setup: not a vertical overview")
200+ }
201+ m.OverviewMove(-1, 0)
202+ if m.cardAt(w) < 0 {
203+ t.Fatal("left from the first column did not select the stash")
204+ }
205+ cards := shownCards(t, w, m.Layout())
206+ if len(cards) != 3 {
207+ t.Fatalf("%d cards shown, want 3", len(cards))
208+ }
209+ for i := 1; i < len(cards); i++ {
210+ if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
211+ t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
212+ }
213+ }
214+ m.OverviewMove(0, -1)
215+ m.OverviewMove(0, -1)
216+ if m.cardAt(w) != 0 {
217+ t.Fatalf("up: card %d, want 0", m.cardAt(w))
218+ }
219+ m.OverviewMove(0, 1)
220+ if m.cardAt(w) != 1 {
221+ t.Fatalf("down: card %d, want 1", m.cardAt(w))
222+ }
223+ m.OverviewMove(1, 0)
224+ if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
225+ t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
226+ }
227+}

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