detached a82be5b

feat(overview): unfold the vertical overview stash as a carousel too

commit
a82be5b47b901cb81ecae00734ad7fc3e4bc7b30
author
brice · · 2026-10-16 12:37 UTC
parent
1c7d6c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +245 -209
M docs/config.md +3 -3
@@ -276,8 +276,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
276276
277277 `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.
278278
279-A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a carousel under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The carousel keeps the selected card in the middle, the earlier cards on its left and the later ones on its right, wrapping round; a wider gap separates the newest card from the oldest. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again.
279+A workspace's stash shows as a pile of cards. Selecting the pile unfolds it into a carousel of every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The selected card stays in the middle, the earlier cards before it and the later ones after it, wrapping round; a wider gap separates the newest card from the oldest. Moves along the carousel pick a card. With fewer than three cards, or on a workspace holding only stashed windows, it does not wrap round. Leaving it folds it again.
280280
281-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 round the carousel; with fewer than three cards, or on a workspace holding only stashed windows, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
281+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; where the carousel does not wrap round, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
282282
283-Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns.
283+Otherwise, left from the first column unfolds the stash. Up/down walk the cards, the focus-workspace binds change workspace, and right goes back to the columns.
@@ -276,8 +276,8 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
276 276
277 `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 `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.
278 278
279-A workspace's stash shows as a pile of cards. Selecting the pile unfolds it, so you see every stashed window: a carousel under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The carousel keeps the selected card in the middle, the earlier cards on its left and the later ones on its right, wrapping round; a wider gap separates the newest card from the oldest. Moves along the unfolded pile pick a card; a pile longer than the screen scrolls with the selection. Leaving it folds it again.279+A workspace's stash shows as a pile of cards. Selecting the pile unfolds it into a carousel of every stashed window: a row under the workspace with `layout.overflow = cascade`, a column on its left otherwise. The selected card stays in the middle, the earlier cards before it and the later ones after it, wrapping round; a wider gap separates the newest card from the oldest. Moves along the carousel pick a card. With fewer than three cards, or on a workspace holding only stashed windows, it does not wrap round. Leaving it folds it again.
280 280
281-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 round the carousel; with fewer than three cards, or on a workspace holding only stashed windows, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.281+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; where the carousel does not wrap round, they go to the neighbor workspace past either end. Up leaves the stash for the last lane, at the column you came from.
282 282
283-Otherwise, left from the first column unfolds the stash. Up/down walk the cards and right goes back to the columns.283+Otherwise, left from the first column unfolds the stash. Up/down walk the cards, the focus-workspace binds change workspace, and right goes back to the columns.
M internal/core/overview.go +77 -94
@@ -57,9 +57,8 @@ type rowSnapshot struct {
5757 // (not selected), the stash's own selection is in front at
5858 // overviewCardZoom, up to overviewCards-1 others behind it, each
5959 // overviewCardStep further up and right, dimmed. Selected, the pile
60-// unfolds along the overview axis (unfold), the selection framed, and the
61-// moves along that axis walk it: a column of every card in a vertical
62-// overview, a carousel centred on the selection in a horizontal one. A vertical
60+// unfolds along the overview axis into a carousel centred on the selection
61+// (unfold), framed, and the moves along that axis walk it round. A vertical
6362 // overview pins the pile to the left edge of its row, which takes the width
6463 // the pile leaves. A horizontal one centres it on its row, in a band
6564 // reserved under the selected lane (pileBand): the lane and the band are
@@ -159,15 +158,6 @@ func (m *Monitor) cardAt(w *Workspace) int {
159158 return -1
160159 }
161160
162-// carouselCard is the selected card of w's stash when it unfolds as a
163-// carousel (a horizontal overview), -1 otherwise.
164-func (m *Monitor) carouselCard(w *Workspace) int {
165- if !m.previewing() || m.overviewAxis() != horizontalAxis {
166- return -1
167- }
168- return m.cardAt(w)
169-}
170-
171161 // overviewTarget resolves the active preview to one window for validation
172162 // and window actions. Presentation state never falls back to a covering float.
173163 func (m *Monitor) overviewTarget() WindowID {
@@ -412,11 +402,11 @@ func (m *Monitor) OverviewMove(dx, dy int) {
412402 }
413403
414404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
415-// walks the unfolded stash and the stack cards before changing workspace
416-// (overviewVertical). A side-by-side one always changes workspace, since
417-// its stack and unfolded stash run left and right.
405+// walks the stack cards before changing workspace (overviewVertical), not
406+// the unfolded stash, which wraps round. A side-by-side one always changes
407+// workspace, since its stack and unfolded stash run left and right.
418408 func (m *Monitor) overviewWorkspaceStep(d int) {
419- if m.overviewAxis() == verticalAxis {
409+ if m.overviewAxis() == verticalAxis && m.cardAt(m.Current()) < 0 {
420410 m.overviewVertical(d)
421411 return
422412 }
@@ -424,17 +414,45 @@ func (m *Monitor) overviewWorkspaceStep(d int) {
424414 }
425415
426416 // cycleCard selects the stash card d entries from the selected one of w in
427-// the unfolded pile, stopping at either end. It reports whether a card was
428-// selected.
417+// the unfolded pile, wrapping round (wrapsCards), else stopping at either
418+// end. It reports whether a card was selected.
429419 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
430420 at := m.cardAt(w)
431421 if at < 0 {
432422 return false
433423 }
434- m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
424+ if !m.stepCard(w, at, d) {
425+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
426+ }
427+ return true
428+}
429+
430+// stepCard selects the stash card d entries from card at of w, wrapping
431+// round when the carousel does (wrapsCards). It reports false, selecting
432+// nothing, past an end of one that does not.
433+func (m *Monitor) stepCard(w *Workspace, at, d int) bool {
434+ n, next := len(w.Stash), at+d
435+ if next < 0 || next >= n {
436+ if !w.wrapsCards() {
437+ return false
438+ }
439+ next = (next%n + n) % n
440+ }
441+ m.selectCard(w, next)
435442 return true
436443 }
437444
445+// wrapsCards reports whether the unfolded stash of w wraps round, drawn
446+// (unfold) and walked (stepCard). A carousel of fewer than three cards does
447+// not, so that each side has the card its move selects; nor does the one
448+// of a stash-only workspace, which has nothing beside its pile to leave it
449+// for. Past the ends of one that does not, a move goes on as from a lane
450+// (cascade) or stops (a vertical overview).
451+func (w *Workspace) wrapsCards() bool {
452+ _, k := w.stackHead()
453+ return k > 0 && len(w.Stash) >= 3
454+}
455+
438456 // overviewLane is the vertical move of an overview whose workspaces sit side
439457 // by side (cascade). From the unfolded stash, up leaves it for the last lane
440458 // (leavePile) and down stays: the stash is the bottom. Else the selection
@@ -490,29 +508,18 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
490508
491509 // overviewAcross is the horizontal move of an overview whose workspaces sit
492510 // side by side (cascade): a column inside the selected lane, or a card of
493-// the unfolded stash, which wraps round (carousel). Past the first column
494-// it goes to the previous workspace; past the last, to the next workspace.
495-// A carousel of fewer than three cards does not wrap, nor does the one of a
496-// stash-only workspace, which has nothing above its pile: there the ends
497-// change workspace like a lane's.
511+// the unfolded stash, which wraps round (stepCard). Past the first column
512+// or a card that does not wrap, it goes to the previous workspace; past
513+// the last, to the next workspace.
498514 // Covering floats fan
499515 // along this axis and are traversed before a workspace: the card on the
500516 // left is the one behind.
501517 func (m *Monitor) overviewAcross(dx int) {
502518 w := m.Current()
503519 if at := m.cardAt(w); at >= 0 {
504- n, next := len(w.Stash), at+dx
505- if next >= 0 && next < n {
506- m.selectCard(w, next)
507- return
508- }
509- if _, k := w.stackHead(); k == 0 || n < 3 {
510- // Nothing above the pile to leave it for, or too few
511- // cards to wrap round: the ends are the way out.
520+ if !m.stepCard(w, at, dx) {
512521 m.overviewStep(dx)
513- return
514522 }
515- m.selectCard(w, (next+n)%n)
516523 return
517524 }
518525 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -937,86 +944,62 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
937944 return dst
938945 }
939946
940-// unfold lays the stash of w out from the folded pile's card rect. A
941-// vertical overview lines every card up in a column (strip). A horizontal
942-// one spreads them as a carousel (carousel). Cards other than the selected
943-// one at are dimmed; those out of the overview area are hidden. The cards
944-// are appended to dst.
947+// unfold lays the stash of w out from the folded pile's card rect as a
948+// carousel along axis: a row in a horizontal overview, a column in a
949+// vertical one. The selected card at sits on the pile, in the middle; the
950+// cards before it come on the left (above), the ones after it on the right
951+// (below), in stash order, wrapping round (wrapsCards) with the oldest
952+// following the newest (carouselSlot). A wider gap marks that seam. When the carousel fits the
953+// overview area it is shifted into it; else the cards past the area are
954+// hidden. Cards other than at are dimmed. The cards are appended to dst.
945955 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
946- if axis == horizontalAxis {
947- return w.carousel(dst, folded, at)
948- }
949- return w.strip(dst, folded, at)
950-}
951-
952-// strip lays the stash out as a column of every card, in stash order,
953-// centred on the pile and kept in the overview area. Longer than the area,
954-// it scrolls like a wide row: the selected card at is centred, the strip
955-// stopping at the area's edges.
956-func (w *Workspace) strip(dst []Placement, folded Rect, at int) []Placement {
957956 n := len(w.Stash)
958957 u := w.overviewArea()
959- size, lo, hi := folded.H, u.Y, u.Y+u.H
960- stride := size + 2*w.cardStep()
961- total := n*stride - (stride - size)
962- start := folded.Y + size/2 - total/2
963- if total > hi-lo {
964- start = folded.Y - at*stride
965- start = min(max(start, hi-total), lo)
966- } else {
967- start = min(max(start, lo), hi-total)
968- }
969- for i := range w.Stash {
970- r := folded
971- r.Y = start + i*stride
972- dst = w.unfoldedCard(dst, i, r, at, u)
958+ size, origin, lo, room := folded.W, folded.X, u.X, u.W
959+ if axis == verticalAxis {
960+ size, origin, lo, room = folded.H, folded.Y, u.Y, u.H
973961 }
974- return dst
975-}
976-
977-// carousel lays the stash out as a row centred on the selected card at,
978-// under the pile, in stash order wrapping round: the cards before it on
979-// the left, the ones after it on the right, the oldest following the
980-// newest (carouselSlot). A wider gap marks that seam. When the row fits
981-// the overview area it is shifted into it; else the cards past the area
982-// are hidden.
983-func (w *Workspace) carousel(dst []Placement, folded Rect, at int) []Placement {
984- n := len(w.Stash)
985- u := w.overviewArea()
986- stride := folded.W + 2*w.cardStep()
962+ stride := size + 2*w.cardStep()
987963 // The extra room at the seam, on top of the stride.
988- gap := folded.W / 4
989- x := func(i int) int {
990- s := carouselSlot(i, at, n)
991- x := folded.X + s*stride
964+ gap := size / 4
965+ wraps := w.wrapsCards()
966+ pos := func(i int) int {
967+ s := carouselSlot(i, at, n, wraps)
968+ p := origin + s*stride
992969 switch {
993970 case s > 0 && at+s >= n:
994- x += gap
971+ p += gap
995972 case s < 0 && at+s < 0:
996- x -= gap
973+ p -= gap
997974 }
998- return x
975+ return p
976+ }
977+ first, last := pos(0), pos(n-1)
978+ if wraps {
979+ first, last = pos((at+n-n/2)%n), pos((at+(n-1)/2)%n)
999980 }
1000- first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n)
1001981 shift := 0
1002- if total := last + folded.W - first; total <= u.W {
1003- shift = min(max(first, u.X), u.X+u.W-total) - first
982+ if total := last + size - first; total <= room {
983+ shift = min(max(first, lo), lo+room-total) - first
1004984 }
1005985 for i := range w.Stash {
1006986 r := folded
1007- r.X = x(i) + shift
987+ if axis == verticalAxis {
988+ r.Y = pos(i) + shift
989+ } else {
990+ r.X = pos(i) + shift
991+ }
1008992 dst = w.unfoldedCard(dst, i, r, at, u)
1009993 }
1010994 return dst
1011995 }
1012996
1013997 // carouselSlot is the place of stash card i in a carousel of n cards
1014-// centred on card at: 0 for at, negative on the left, positive on the
1015-// right. The left side takes the extra card of an even count. Fewer than
1016-// three cards keep the stash order without wrapping round, so that each
1017-// side has the card its move selects.
1018-func carouselSlot(i, at, n int) int {
1019- if n < 3 {
998+// centred on card at: 0 for at, negative on the left (above), positive on
999+// the right (below). Wrapping round, the left side takes the extra card of
1000+// an even count; else the cards keep the stash order.
1001+func carouselSlot(i, at, n int, wraps bool) int {
1002+ if !wraps {
10201003 return i - at
10211004 }
10221005 s := (i - at + n) % n
@@ -57,9 +57,8 @@ type rowSnapshot struct {
57 // (not selected), the stash's own selection is in front at57 // (not selected), the stash's own selection is in front at
58 // overviewCardZoom, up to overviewCards-1 others behind it, each58 // overviewCardZoom, up to overviewCards-1 others behind it, each
59 // overviewCardStep further up and right, dimmed. Selected, the pile59 // overviewCardStep further up and right, dimmed. Selected, the pile
60-// unfolds along the overview axis (unfold), the selection framed, and the60+// unfolds along the overview axis into a carousel centred on the selection
61-// moves along that axis walk it: a column of every card in a vertical61+// (unfold), framed, and the moves along that axis walk it round. A vertical
62-// overview, a carousel centred on the selection in a horizontal one. A vertical
63 // overview pins the pile to the left edge of its row, which takes the width62 // overview pins the pile to the left edge of its row, which takes the width
64 // the pile leaves. A horizontal one centres it on its row, in a band63 // the pile leaves. A horizontal one centres it on its row, in a band
65 // reserved under the selected lane (pileBand): the lane and the band are64 // reserved under the selected lane (pileBand): the lane and the band are
@@ -159,15 +158,6 @@ func (m *Monitor) cardAt(w *Workspace) int {
159 return -1158 return -1
160 }159 }
161 160
162-// carouselCard is the selected card of w's stash when it unfolds as a
163-// carousel (a horizontal overview), -1 otherwise.
164-func (m *Monitor) carouselCard(w *Workspace) int {
165- if !m.previewing() || m.overviewAxis() != horizontalAxis {
166- return -1
167- }
168- return m.cardAt(w)
169-}
170-
171 // overviewTarget resolves the active preview to one window for validation161 // overviewTarget resolves the active preview to one window for validation
172 // and window actions. Presentation state never falls back to a covering float.162 // and window actions. Presentation state never falls back to a covering float.
173 func (m *Monitor) overviewTarget() WindowID {163 func (m *Monitor) overviewTarget() WindowID {
@@ -412,11 +402,11 @@ func (m *Monitor) OverviewMove(dx, dy int) {
412 }402 }
413 403
414 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
415-// walks the unfolded stash and the stack cards before changing workspace405+// walks the stack cards before changing workspace (overviewVertical), not
416-// (overviewVertical). A side-by-side one always changes workspace, since406+// the unfolded stash, which wraps round. A side-by-side one always changes
417-// its stack and unfolded stash run left and right.407+// workspace, since its stack and unfolded stash run left and right.
418 func (m *Monitor) overviewWorkspaceStep(d int) {408 func (m *Monitor) overviewWorkspaceStep(d int) {
419- if m.overviewAxis() == verticalAxis {409+ if m.overviewAxis() == verticalAxis && m.cardAt(m.Current()) < 0 {
420 m.overviewVertical(d)410 m.overviewVertical(d)
421 return411 return
422 }412 }
@@ -424,17 +414,45 @@ func (m *Monitor) overviewWorkspaceStep(d int) {
424 }414 }
425 415
426 // cycleCard selects the stash card d entries from the selected one of w in416 // cycleCard selects the stash card d entries from the selected one of w in
427-// the unfolded pile, stopping at either end. It reports whether a card was417+// the unfolded pile, wrapping round (wrapsCards), else stopping at either
428-// selected.418+// end. It reports whether a card was selected.
429 func (m *Monitor) cycleCard(w *Workspace, d int) bool {419 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
430 at := m.cardAt(w)420 at := m.cardAt(w)
431 if at < 0 {421 if at < 0 {
432 return false422 return false
433 }423 }
434- m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))424+ if !m.stepCard(w, at, d) {
425+ m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
426+ }
427+ return true
428+}
429+
430+// stepCard selects the stash card d entries from card at of w, wrapping
431+// round when the carousel does (wrapsCards). It reports false, selecting
432+// nothing, past an end of one that does not.
433+func (m *Monitor) stepCard(w *Workspace, at, d int) bool {
434+ n, next := len(w.Stash), at+d
435+ if next < 0 || next >= n {
436+ if !w.wrapsCards() {
437+ return false
438+ }
439+ next = (next%n + n) % n
440+ }
441+ m.selectCard(w, next)
435 return true442 return true
436 }443 }
437 444
445+// wrapsCards reports whether the unfolded stash of w wraps round, drawn
446+// (unfold) and walked (stepCard). A carousel of fewer than three cards does
447+// not, so that each side has the card its move selects; nor does the one
448+// of a stash-only workspace, which has nothing beside its pile to leave it
449+// for. Past the ends of one that does not, a move goes on as from a lane
450+// (cascade) or stops (a vertical overview).
451+func (w *Workspace) wrapsCards() bool {
452+ _, k := w.stackHead()
453+ return k > 0 && len(w.Stash) >= 3
454+}
455+
438 // overviewLane is the vertical move of an overview whose workspaces sit side456 // overviewLane is the vertical move of an overview whose workspaces sit side
439 // by side (cascade). From the unfolded stash, up leaves it for the last lane457 // by side (cascade). From the unfolded stash, up leaves it for the last lane
440 // (leavePile) and down stays: the stash is the bottom. Else the selection458 // (leavePile) and down stays: the stash is the bottom. Else the selection
@@ -490,29 +508,18 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
490 508
491 // overviewAcross is the horizontal move of an overview whose workspaces sit509 // overviewAcross is the horizontal move of an overview whose workspaces sit
492 // side by side (cascade): a column inside the selected lane, or a card of510 // side by side (cascade): a column inside the selected lane, or a card of
493-// the unfolded stash, which wraps round (carousel). Past the first column511+// the unfolded stash, which wraps round (stepCard). Past the first column
494-// it goes to the previous workspace; past the last, to the next workspace.512+// or a card that does not wrap, it goes to the previous workspace; past
495-// A carousel of fewer than three cards does not wrap, nor does the one of a513+// the last, to the next workspace.
496-// stash-only workspace, which has nothing above its pile: there the ends
497-// change workspace like a lane's.
498 // Covering floats fan514 // Covering floats fan
499 // along this axis and are traversed before a workspace: the card on the515 // along this axis and are traversed before a workspace: the card on the
500 // left is the one behind.516 // left is the one behind.
501 func (m *Monitor) overviewAcross(dx int) {517 func (m *Monitor) overviewAcross(dx int) {
502 w := m.Current()518 w := m.Current()
503 if at := m.cardAt(w); at >= 0 {519 if at := m.cardAt(w); at >= 0 {
504- n, next := len(w.Stash), at+dx520+ if !m.stepCard(w, at, dx) {
505- if next >= 0 && next < n {
506- m.selectCard(w, next)
507- return
508- }
509- if _, k := w.stackHead(); k == 0 || n < 3 {
510- // Nothing above the pile to leave it for, or too few
511- // cards to wrap round: the ends are the way out.
512 m.overviewStep(dx)521 m.overviewStep(dx)
513- return
514 }522 }
515- m.selectCard(w, (next+n)%n)
516 return523 return
517 }524 }
518 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {525 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -937,86 +944,62 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
937 return dst944 return dst
938 }945 }
939 946
940-// unfold lays the stash of w out from the folded pile's card rect. A947+// unfold lays the stash of w out from the folded pile's card rect as a
941-// vertical overview lines every card up in a column (strip). A horizontal948+// carousel along axis: a row in a horizontal overview, a column in a
942-// one spreads them as a carousel (carousel). Cards other than the selected949+// vertical one. The selected card at sits on the pile, in the middle; the
943-// one at are dimmed; those out of the overview area are hidden. The cards950+// cards before it come on the left (above), the ones after it on the right
944-// are appended to dst.951+// (below), in stash order, wrapping round (wrapsCards) with the oldest
952+// following the newest (carouselSlot). A wider gap marks that seam. When the carousel fits the
953+// overview area it is shifted into it; else the cards past the area are
954+// hidden. Cards other than at are dimmed. The cards are appended to dst.
945 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {955 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
946- if axis == horizontalAxis {
947- return w.carousel(dst, folded, at)
948- }
949- return w.strip(dst, folded, at)
950-}
951-
952-// strip lays the stash out as a column of every card, in stash order,
953-// centred on the pile and kept in the overview area. Longer than the area,
954-// it scrolls like a wide row: the selected card at is centred, the strip
955-// stopping at the area's edges.
956-func (w *Workspace) strip(dst []Placement, folded Rect, at int) []Placement {
957 n := len(w.Stash)956 n := len(w.Stash)
958 u := w.overviewArea()957 u := w.overviewArea()
959- size, lo, hi := folded.H, u.Y, u.Y+u.H958+ size, origin, lo, room := folded.W, folded.X, u.X, u.W
960- stride := size + 2*w.cardStep()959+ if axis == verticalAxis {
961- total := n*stride - (stride - size)960+ size, origin, lo, room = folded.H, folded.Y, u.Y, u.H
962- start := folded.Y + size/2 - total/2
963- if total > hi-lo {
964- start = folded.Y - at*stride
965- start = min(max(start, hi-total), lo)
966- } else {
967- start = min(max(start, lo), hi-total)
968- }
969- for i := range w.Stash {
970- r := folded
971- r.Y = start + i*stride
972- dst = w.unfoldedCard(dst, i, r, at, u)
973 }961 }
974- return dst962+ stride := size + 2*w.cardStep()
975-}
976-
977-// carousel lays the stash out as a row centred on the selected card at,
978-// under the pile, in stash order wrapping round: the cards before it on
979-// the left, the ones after it on the right, the oldest following the
980-// newest (carouselSlot). A wider gap marks that seam. When the row fits
981-// the overview area it is shifted into it; else the cards past the area
982-// are hidden.
983-func (w *Workspace) carousel(dst []Placement, folded Rect, at int) []Placement {
984- n := len(w.Stash)
985- u := w.overviewArea()
986- stride := folded.W + 2*w.cardStep()
987 // The extra room at the seam, on top of the stride.963 // The extra room at the seam, on top of the stride.
988- gap := folded.W / 4964+ gap := size / 4
989- x := func(i int) int {965+ wraps := w.wrapsCards()
990- s := carouselSlot(i, at, n)966+ pos := func(i int) int {
991- x := folded.X + s*stride967+ s := carouselSlot(i, at, n, wraps)
968+ p := origin + s*stride
992 switch {969 switch {
993 case s > 0 && at+s >= n:970 case s > 0 && at+s >= n:
994- x += gap971+ p += gap
995 case s < 0 && at+s < 0:972 case s < 0 && at+s < 0:
996- x -= gap973+ p -= gap
997 }974 }
998- return x975+ return p
976+ }
977+ first, last := pos(0), pos(n-1)
978+ if wraps {
979+ first, last = pos((at+n-n/2)%n), pos((at+(n-1)/2)%n)
999 }980 }
1000- first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n)
1001 shift := 0981 shift := 0
1002- if total := last + folded.W - first; total <= u.W {982+ if total := last + size - first; total <= room {
1003- shift = min(max(first, u.X), u.X+u.W-total) - first983+ shift = min(max(first, lo), lo+room-total) - first
1004 }984 }
1005 for i := range w.Stash {985 for i := range w.Stash {
1006 r := folded986 r := folded
1007- r.X = x(i) + shift987+ if axis == verticalAxis {
988+ r.Y = pos(i) + shift
989+ } else {
990+ r.X = pos(i) + shift
991+ }
1008 dst = w.unfoldedCard(dst, i, r, at, u)992 dst = w.unfoldedCard(dst, i, r, at, u)
1009 }993 }
1010 return dst994 return dst
1011 }995 }
1012 996
1013 // carouselSlot is the place of stash card i in a carousel of n cards997 // carouselSlot is the place of stash card i in a carousel of n cards
1014-// centred on card at: 0 for at, negative on the left, positive on the998+// centred on card at: 0 for at, negative on the left (above), positive on
1015-// right. The left side takes the extra card of an even count. Fewer than999+// the right (below). Wrapping round, the left side takes the extra card of
1016-// three cards keep the stash order without wrapping round, so that each1000+// an even count; else the cards keep the stash order.
1017-// side has the card its move selects.1001+func carouselSlot(i, at, n int, wraps bool) int {
1018-func carouselSlot(i, at, n int) int {1002+ if !wraps {
1019- if n < 3 {
1020 return i - at1003 return i - at
1021 }1004 }
1022 s := (i - at + n) % n1005 s := (i - at + n) % n
M internal/core/overview_stash_input_test.go +24 -4
@@ -43,9 +43,9 @@ func TestOverviewStashInputNavigation(t *testing.T) {
4343 var a Action
4444 switch {
4545 case dx == 0 && dy < 0:
46- a = ActionFocusWorkspacePrev
46+ a = ActionFocusWindowUp
4747 case dx == 0 && dy > 0:
48- a = ActionFocusWorkspaceNext
48+ a = ActionFocusWindowDown
4949 case dx > 0 && dy == 0:
5050 a = ActionFocusColumnRight
5151 case dx < 0 && dy == 0:
@@ -62,8 +62,8 @@ func TestOverviewStashInputNavigation(t *testing.T) {
6262 }
6363 move(0, 1)
6464 move(0, 1)
65- if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) {
66- t.Fatal("down escaped or reordered the stash")
65+ if m.Current() != w || m.cardAt(w) != 0 || !slices.Equal(stashIDs(w), before) {
66+ t.Fatalf("down past the last card: card %d, want it to wrap round to 0 in order", m.cardAt(w))
6767 }
6868 move(1, 0)
6969 if m.cardAt(w) >= 0 {
@@ -72,3 +72,23 @@ func TestOverviewStashInputNavigation(t *testing.T) {
7272 })
7373 }
7474 }
75+
76+// In a vertical overview, a focus-workspace bind over the unfolded stash
77+// changes workspace: the carousel wraps round, so walking it would never
78+// get there.
79+func TestOverviewWorkspaceBindLeavesCarousel(t *testing.T) {
80+ m := pileMonitor()
81+ w := m.Current()
82+ m.Focus(1)
83+ m.AddWindow(30)
84+ m.Focus(0)
85+ m.ToggleOverview()
86+ m.OverviewMove(-1, 0)
87+ if m.overviewAxis() != verticalAxis || m.cardAt(w) < 0 {
88+ t.Fatal("setup: no card selected in a vertical overview")
89+ }
90+ m.overviewFocus(ActionFocusWorkspaceNext)
91+ if m.Current() == w {
92+ t.Fatal("focus-workspace-next over a card stayed on the workspace")
93+ }
94+}
@@ -43,9 +43,9 @@ func TestOverviewStashInputNavigation(t *testing.T) {
43 var a Action43 var a Action
44 switch {44 switch {
45 case dx == 0 && dy < 0:45 case dx == 0 && dy < 0:
46- a = ActionFocusWorkspacePrev46+ a = ActionFocusWindowUp
47 case dx == 0 && dy > 0:47 case dx == 0 && dy > 0:
48- a = ActionFocusWorkspaceNext48+ a = ActionFocusWindowDown
49 case dx > 0 && dy == 0:49 case dx > 0 && dy == 0:
50 a = ActionFocusColumnRight50 a = ActionFocusColumnRight
51 case dx < 0 && dy == 0:51 case dx < 0 && dy == 0:
@@ -62,8 +62,8 @@ func TestOverviewStashInputNavigation(t *testing.T) {
62 }62 }
63 move(0, 1)63 move(0, 1)
64 move(0, 1)64 move(0, 1)
65- if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) {65+ if m.Current() != w || m.cardAt(w) != 0 || !slices.Equal(stashIDs(w), before) {
66- t.Fatal("down escaped or reordered the stash")66+ t.Fatalf("down past the last card: card %d, want it to wrap round to 0 in order", m.cardAt(w))
67 }67 }
68 move(1, 0)68 move(1, 0)
69 if m.cardAt(w) >= 0 {69 if m.cardAt(w) >= 0 {
@@ -72,3 +72,23 @@ func TestOverviewStashInputNavigation(t *testing.T) {
72 })72 })
73 }73 }
74 }74 }
75+
76+// In a vertical overview, a focus-workspace bind over the unfolded stash
77+// changes workspace: the carousel wraps round, so walking it would never
78+// get there.
79+func TestOverviewWorkspaceBindLeavesCarousel(t *testing.T) {
80+ m := pileMonitor()
81+ w := m.Current()
82+ m.Focus(1)
83+ m.AddWindow(30)
84+ m.Focus(0)
85+ m.ToggleOverview()
86+ m.OverviewMove(-1, 0)
87+ if m.overviewAxis() != verticalAxis || m.cardAt(w) < 0 {
88+ t.Fatal("setup: no card selected in a vertical overview")
89+ }
90+ m.overviewFocus(ActionFocusWorkspaceNext)
91+ if m.Current() == w {
92+ t.Fatal("focus-workspace-next over a card stayed on the workspace")
93+ }
94+}
M internal/core/overview_test.go +8 -10
@@ -355,7 +355,7 @@ func TestOverviewPileKeepsRowCentred(t *testing.T) {
355355 }
356356 }
357357
358-// Left enters the stash pile, up/down browse it without changing rows,
358+// Left enters the stash pile, up/down browse it round without changing rows,
359359 // and right leaves it immediately. Confirm shows the chosen stash window.
360360 func TestOverviewPileNavigation(t *testing.T) {
361361 m := pileMonitor()
@@ -375,17 +375,15 @@ func TestOverviewPileNavigation(t *testing.T) {
375375 if at := m.cardAt(w); at != 2 {
376376 t.Fatalf("left changed stash selection to %d", at)
377377 }
378- m.OverviewMove(0, 1) // stops at the last entry, not the next workspace
379- if m.Current() != w || m.cardAt(w) != 2 {
380- t.Fatal("down escaped the stash pile")
378+ m.OverviewMove(0, 1) // wraps round to the first entry, not the next workspace
379+ if m.Current() != w || m.cardAt(w) != 0 {
380+ t.Fatalf("down past the last entry: card %d, workspace changed %v", m.cardAt(w), m.Current() != w)
381381 }
382- m.OverviewMove(0, -1)
383- m.OverviewMove(0, -1)
384- m.OverviewMove(0, -1) // stops at the first entry
385- if at := m.cardAt(w); at != 0 || m.Current() != w {
386- t.Fatalf("up end %d, workspace changed %v", at, m.Current() != w)
382+ m.OverviewMove(0, -1) // and back round to the last
383+ if at := m.cardAt(w); at != 2 || m.Current() != w {
384+ t.Fatalf("up past the first entry: card %d, workspace changed %v", at, m.Current() != w)
387385 }
388- m.OverviewMove(0, 1)
386+ m.OverviewMove(0, -1)
389387 m.ToggleOverview()
390388 if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {
391389 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)
@@ -355,7 +355,7 @@ func TestOverviewPileKeepsRowCentred(t *testing.T) {
355 }355 }
356 }356 }
357 357
358-// Left enters the stash pile, up/down browse it without changing rows,358+// Left enters the stash pile, up/down browse it round without changing rows,
359 // and right leaves it immediately. Confirm shows the chosen stash window.359 // and right leaves it immediately. Confirm shows the chosen stash window.
360 func TestOverviewPileNavigation(t *testing.T) {360 func TestOverviewPileNavigation(t *testing.T) {
361 m := pileMonitor()361 m := pileMonitor()
@@ -375,17 +375,15 @@ func TestOverviewPileNavigation(t *testing.T) {
375 if at := m.cardAt(w); at != 2 {375 if at := m.cardAt(w); at != 2 {
376 t.Fatalf("left changed stash selection to %d", at)376 t.Fatalf("left changed stash selection to %d", at)
377 }377 }
378- m.OverviewMove(0, 1) // stops at the last entry, not the next workspace378+ m.OverviewMove(0, 1) // wraps round to the first entry, not the next workspace
379- if m.Current() != w || m.cardAt(w) != 2 {379+ if m.Current() != w || m.cardAt(w) != 0 {
380- t.Fatal("down escaped the stash pile")380+ t.Fatalf("down past the last entry: card %d, workspace changed %v", m.cardAt(w), m.Current() != w)
381 }381 }
382- m.OverviewMove(0, -1)382+ m.OverviewMove(0, -1) // and back round to the last
383- m.OverviewMove(0, -1)383+ if at := m.cardAt(w); at != 2 || m.Current() != w {
384- m.OverviewMove(0, -1) // stops at the first entry384+ t.Fatalf("up past the first entry: card %d, workspace changed %v", at, m.Current() != w)
385- if at := m.cardAt(w); at != 0 || m.Current() != w {
386- t.Fatalf("up end %d, workspace changed %v", at, m.Current() != w)
387 }385 }
388- m.OverviewMove(0, 1)386+ m.OverviewMove(0, -1)
389 m.ToggleOverview()387 m.ToggleOverview()
390 if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {388 if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {
391 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)389 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)
M internal/core/overview_unfold_test.go +126 -91
@@ -144,86 +144,58 @@ func TestOverviewCascadeStashFolds(t *testing.T) {
144144 }
145145 }
146146
147-// A vertical overview's stash longer than the area scrolls like a wide
148-// row: every card is reached in order, the selection stays inside the area
149-// and is centred unless the strip stops at an edge of it, and cards past
150-// the area are hidden.
151-func TestOverviewStashUnfoldScrolls(t *testing.T) {
152- m := cascadeOverview()
153- w := m.Current()
154- w.Overflow = OverflowScroll
155- withStash(m, 12)
156- m.ToggleOverview()
157- if m.overviewAxis() != verticalAxis {
158- t.Fatal("setup: wrong overview axis")
159- }
160- m.selectCard(w, 0)
161- u := w.overviewArea()
162- lo, hi := u.Y, u.Y+u.H
163- centred, hidden := false, false
164- for i := range len(w.Stash) {
165- if m.cardAt(w) != i {
166- t.Fatalf("step %d: card %d", i, m.cardAt(w))
167- }
168- ps := m.Layout()
169- sel := previewOf(t, ps, w.Stash[i].ID)
170- at, size := sel.Rect.Y, sel.Rect.H
171- if sel.Hidden || !sel.Focused || at < lo || at+size > hi {
172- t.Fatalf("card %d off the area: %+v in %v", i, sel, u)
173- }
174- first := previewOf(t, ps, w.Stash[0].ID).Rect.Y
175- last := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect.Y
176- if first > lo || last+size < hi {
177- t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)
178- }
179- if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {
180- centred = true
181- } else if first != lo && last+size != hi {
182- t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)
183- }
184- for _, e := range w.Stash {
185- hidden = hidden || previewOf(t, ps, e.ID).Hidden
186- }
187- m.OverviewMove(0, 1)
188- }
189- if !hidden || !centred {
190- t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
191- }
192-}
193-
194-// A carousel wider than the area keeps every selection centred in it, with
195-// cards on both sides; the cards past the area are hidden.
196-func TestOverviewCascadeCarouselScrolls(t *testing.T) {
197- m := cascadeOverview()
198- w := m.Current()
199- withStash(m, 12)
200- m.ToggleOverview()
201- if m.overviewAxis() != horizontalAxis {
202- t.Fatal("setup: wrong overview axis")
203- }
204- m.selectCard(w, 11)
205- u := w.overviewArea()
206- for step := range len(w.Stash) + 1 {
207- i := m.cardAt(w)
208- ps := m.Layout()
209- sel := previewOf(t, ps, w.Stash[i].ID)
210- if sel.Hidden || !sel.Focused || abs(sel.Rect.X+sel.Rect.W/2-(u.X+u.W/2)) > 1 {
211- t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u)
212- }
213- left, right, hidden := false, false, false
214- for _, e := range w.Stash {
215- p := previewOf(t, ps, e.ID)
216- if p.Hidden {
217- hidden = true
218- continue
147+// A carousel longer than the area keeps every selection centred in it,
148+// with cards on both sides, on either axis; the cards past the area are
149+// hidden.
150+func TestOverviewCarouselScrolls(t *testing.T) {
151+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
152+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
153+ m := cascadeOverview()
154+ w := m.Current()
155+ if axis == verticalAxis {
156+ w.Overflow = OverflowScroll
219157 }
220- left = left || p.Rect.X < sel.Rect.X
221- right = right || p.Rect.X > sel.Rect.X
222- }
223- if !left || !right || !hidden {
224- t.Fatalf("step %d: cards left %v, right %v, hidden %v", step, left, right, hidden)
225- }
226- m.OverviewMove(1, 0)
158+ withStash(m, 12)
159+ m.ToggleOverview()
160+ if m.overviewAxis() != axis {
161+ t.Fatal("setup: wrong overview axis")
162+ }
163+ m.selectCard(w, 11)
164+ u := w.overviewArea()
165+ pos := func(r Rect) (int, int) {
166+ if axis == verticalAxis {
167+ return r.Y, r.Y + r.H/2 - (u.Y + u.H/2)
168+ }
169+ return r.X, r.X + r.W/2 - (u.X + u.W/2)
170+ }
171+ for step := range len(w.Stash) + 1 {
172+ i := m.cardAt(w)
173+ ps := m.Layout()
174+ sel := previewOf(t, ps, w.Stash[i].ID)
175+ at, off := pos(sel.Rect)
176+ if sel.Hidden || !sel.Focused || abs(off) > 1 {
177+ t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u)
178+ }
179+ before, after, hidden := false, false, false
180+ for _, e := range w.Stash {
181+ p := previewOf(t, ps, e.ID)
182+ if p.Hidden {
183+ hidden = true
184+ continue
185+ }
186+ q, _ := pos(p.Rect)
187+ before, after = before || q < at, after || q > at
188+ }
189+ if !before || !after || !hidden {
190+ t.Fatalf("step %d: cards before %v, after %v, hidden %v", step, before, after, hidden)
191+ }
192+ if axis == verticalAxis {
193+ m.OverviewMove(0, 1)
194+ } else {
195+ m.OverviewMove(1, 0)
196+ }
197+ }
198+ })
227199 }
228200 }
229201
@@ -251,8 +223,10 @@ func TestOverviewStashLayoutAllocations(t *testing.T) {
251223 }
252224 }
253225
254-// A scroll overview unfolds its pile into a column on the left of the row;
255-// up and down walk it, right goes back to the columns.
226+// A scroll overview unfolds its pile into a carousel on the left of the
227+// row: a column centred on the selection, the card before it above and the
228+// one after it below, wrapping round. Up and down walk it round, right goes
229+// back to the columns.
256230 func TestOverviewScrollStashUnfolds(t *testing.T) {
257231 m := overviewMonitor()
258232 w := m.Current()
@@ -267,29 +241,90 @@ func TestOverviewScrollStashUnfolds(t *testing.T) {
267241 t.Fatal("setup: not a vertical overview")
268242 }
269243 m.OverviewMove(-1, 0)
270- if m.cardAt(w) < 0 {
244+ at := m.cardAt(w)
245+ if at < 0 {
271246 t.Fatal("left from the first column did not select the stash")
272247 }
273248 cards := shownCards(t, w, m.Layout())
274249 if len(cards) != 3 {
275250 t.Fatalf("%d cards shown, want 3", len(cards))
276251 }
277- for i := 1; i < len(cards); i++ {
278- if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
279- t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
252+ col := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]}
253+ for i := 1; i < len(col); i++ {
254+ if col[i].Rect.X != col[0].Rect.X || col[i].Rect.Y < col[i-1].Rect.Y+col[i-1].Rect.H {
255+ t.Fatalf("cards not stacked down: %v then %v", col[i-1].Rect, col[i].Rect)
280256 }
281257 }
282- m.OverviewMove(0, -1)
283- m.OverviewMove(0, -1)
284- if m.cardAt(w) != 0 {
285- t.Fatalf("up: card %d, want 0", m.cardAt(w))
258+ if !col[1].Focused {
259+ t.Fatal("the selection is not in the middle")
286260 }
287261 m.OverviewMove(0, 1)
288- if m.cardAt(w) != 1 {
289- t.Fatalf("down: card %d, want 1", m.cardAt(w))
262+ if want := (at + 1) % 3; m.cardAt(w) != want {
263+ t.Fatalf("down: card %d, want %d", m.cardAt(w), want)
264+ }
265+ m.OverviewMove(0, -1)
266+ m.OverviewMove(0, -1)
267+ if want := (at + 2) % 3; m.cardAt(w) != want {
268+ t.Fatalf("up twice: card %d, want %d", m.cardAt(w), want)
290269 }
291270 m.OverviewMove(1, 0)
292271 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
293272 t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
294273 }
295274 }
275+
276+// A stash-only workspace's carousel does not wrap round, drawn or walked:
277+// the selection stays in the middle, the cards in stash order on either
278+// side of it. Past the ends a vertical overview stops; a cascade one
279+// changes workspace.
280+func TestOverviewStashOnlyCarousel(t *testing.T) {
281+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
282+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
283+ m := overviewMonitor()
284+ m.Focus(2)
285+ m.AddWindow(30)
286+ m.Focus(1)
287+ w := m.Current()
288+ w.Overflow = OverflowCascade
289+ if axis == verticalAxis {
290+ w.Overflow = OverflowScroll
291+ }
292+ w.ToggleWindowStash() // its own window
293+ for id := WindowID(40); id < 44; id++ {
294+ w.AddWindow(id)
295+ w.FocusID(id)
296+ w.ToggleWindowStash()
297+ }
298+ m.ToggleOverview()
299+ if m.overviewAxis() != axis || len(w.Columns) != 0 || m.cardAt(w) < 0 {
300+ t.Fatalf("setup: axis %d, %d columns, card %d", m.overviewAxis(), len(w.Columns), m.cardAt(w))
301+ }
302+ last := len(w.Stash) - 1
303+ m.selectCard(w, last)
304+ ps := m.Layout()
305+ pos := func(i int) int {
306+ if r := previewOf(t, ps, w.Stash[i].ID).Rect; axis == verticalAxis {
307+ return r.Y
308+ } else {
309+ return r.X
310+ }
311+ }
312+ for i := 1; i <= last; i++ {
313+ if pos(i) <= pos(i-1) {
314+ t.Fatalf("card %d at %d, not after card %d at %d", i, pos(i), i-1, pos(i-1))
315+ }
316+ }
317+ if axis == verticalAxis {
318+ m.OverviewMove(0, 1)
319+ if m.Current() != w || m.cardAt(w) != last {
320+ t.Fatalf("down past the last card: card %d", m.cardAt(w))
321+ }
322+ return
323+ }
324+ m.OverviewMove(1, 0)
325+ if m.Current() == w {
326+ t.Fatal("right past the last card stayed on the workspace")
327+ }
328+ })
329+ }
330+}
@@ -144,86 +144,58 @@ func TestOverviewCascadeStashFolds(t *testing.T) {
144 }144 }
145 }145 }
146 146
147-// A vertical overview's stash longer than the area scrolls like a wide147+// A carousel longer than the area keeps every selection centred in it,
148-// row: every card is reached in order, the selection stays inside the area148+// with cards on both sides, on either axis; the cards past the area are
149-// and is centred unless the strip stops at an edge of it, and cards past149+// hidden.
150-// the area are hidden.150+func TestOverviewCarouselScrolls(t *testing.T) {
151-func TestOverviewStashUnfoldScrolls(t *testing.T) {151+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
152- m := cascadeOverview()152+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
153- w := m.Current()153+ m := cascadeOverview()
154- w.Overflow = OverflowScroll154+ w := m.Current()
155- withStash(m, 12)155+ if axis == verticalAxis {
156- m.ToggleOverview()156+ w.Overflow = OverflowScroll
157- if m.overviewAxis() != verticalAxis {
158- t.Fatal("setup: wrong overview axis")
159- }
160- m.selectCard(w, 0)
161- u := w.overviewArea()
162- lo, hi := u.Y, u.Y+u.H
163- centred, hidden := false, false
164- for i := range len(w.Stash) {
165- if m.cardAt(w) != i {
166- t.Fatalf("step %d: card %d", i, m.cardAt(w))
167- }
168- ps := m.Layout()
169- sel := previewOf(t, ps, w.Stash[i].ID)
170- at, size := sel.Rect.Y, sel.Rect.H
171- if sel.Hidden || !sel.Focused || at < lo || at+size > hi {
172- t.Fatalf("card %d off the area: %+v in %v", i, sel, u)
173- }
174- first := previewOf(t, ps, w.Stash[0].ID).Rect.Y
175- last := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect.Y
176- if first > lo || last+size < hi {
177- t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)
178- }
179- if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {
180- centred = true
181- } else if first != lo && last+size != hi {
182- t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)
183- }
184- for _, e := range w.Stash {
185- hidden = hidden || previewOf(t, ps, e.ID).Hidden
186- }
187- m.OverviewMove(0, 1)
188- }
189- if !hidden || !centred {
190- t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
191- }
192-}
193-
194-// A carousel wider than the area keeps every selection centred in it, with
195-// cards on both sides; the cards past the area are hidden.
196-func TestOverviewCascadeCarouselScrolls(t *testing.T) {
197- m := cascadeOverview()
198- w := m.Current()
199- withStash(m, 12)
200- m.ToggleOverview()
201- if m.overviewAxis() != horizontalAxis {
202- t.Fatal("setup: wrong overview axis")
203- }
204- m.selectCard(w, 11)
205- u := w.overviewArea()
206- for step := range len(w.Stash) + 1 {
207- i := m.cardAt(w)
208- ps := m.Layout()
209- sel := previewOf(t, ps, w.Stash[i].ID)
210- if sel.Hidden || !sel.Focused || abs(sel.Rect.X+sel.Rect.W/2-(u.X+u.W/2)) > 1 {
211- t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u)
212- }
213- left, right, hidden := false, false, false
214- for _, e := range w.Stash {
215- p := previewOf(t, ps, e.ID)
216- if p.Hidden {
217- hidden = true
218- continue
219 }157 }
220- left = left || p.Rect.X < sel.Rect.X158+ withStash(m, 12)
221- right = right || p.Rect.X > sel.Rect.X159+ m.ToggleOverview()
222- }160+ if m.overviewAxis() != axis {
223- if !left || !right || !hidden {161+ t.Fatal("setup: wrong overview axis")
224- t.Fatalf("step %d: cards left %v, right %v, hidden %v", step, left, right, hidden)162+ }
225- }163+ m.selectCard(w, 11)
226- m.OverviewMove(1, 0)164+ u := w.overviewArea()
165+ pos := func(r Rect) (int, int) {
166+ if axis == verticalAxis {
167+ return r.Y, r.Y + r.H/2 - (u.Y + u.H/2)
168+ }
169+ return r.X, r.X + r.W/2 - (u.X + u.W/2)
170+ }
171+ for step := range len(w.Stash) + 1 {
172+ i := m.cardAt(w)
173+ ps := m.Layout()
174+ sel := previewOf(t, ps, w.Stash[i].ID)
175+ at, off := pos(sel.Rect)
176+ if sel.Hidden || !sel.Focused || abs(off) > 1 {
177+ t.Fatalf("step %d: card %d not centred: %+v in %v", step, i, sel, u)
178+ }
179+ before, after, hidden := false, false, false
180+ for _, e := range w.Stash {
181+ p := previewOf(t, ps, e.ID)
182+ if p.Hidden {
183+ hidden = true
184+ continue
185+ }
186+ q, _ := pos(p.Rect)
187+ before, after = before || q < at, after || q > at
188+ }
189+ if !before || !after || !hidden {
190+ t.Fatalf("step %d: cards before %v, after %v, hidden %v", step, before, after, hidden)
191+ }
192+ if axis == verticalAxis {
193+ m.OverviewMove(0, 1)
194+ } else {
195+ m.OverviewMove(1, 0)
196+ }
197+ }
198+ })
227 }199 }
228 }200 }
229 201
@@ -251,8 +223,10 @@ func TestOverviewStashLayoutAllocations(t *testing.T) {
251 }223 }
252 }224 }
253 225
254-// A scroll overview unfolds its pile into a column on the left of the row;226+// A scroll overview unfolds its pile into a carousel on the left of the
255-// up and down walk it, right goes back to the columns.227+// row: a column centred on the selection, the card before it above and the
228+// one after it below, wrapping round. Up and down walk it round, right goes
229+// back to the columns.
256 func TestOverviewScrollStashUnfolds(t *testing.T) {230 func TestOverviewScrollStashUnfolds(t *testing.T) {
257 m := overviewMonitor()231 m := overviewMonitor()
258 w := m.Current()232 w := m.Current()
@@ -267,29 +241,90 @@ func TestOverviewScrollStashUnfolds(t *testing.T) {
267 t.Fatal("setup: not a vertical overview")241 t.Fatal("setup: not a vertical overview")
268 }242 }
269 m.OverviewMove(-1, 0)243 m.OverviewMove(-1, 0)
270- if m.cardAt(w) < 0 {244+ at := m.cardAt(w)
245+ if at < 0 {
271 t.Fatal("left from the first column did not select the stash")246 t.Fatal("left from the first column did not select the stash")
272 }247 }
273 cards := shownCards(t, w, m.Layout())248 cards := shownCards(t, w, m.Layout())
274 if len(cards) != 3 {249 if len(cards) != 3 {
275 t.Fatalf("%d cards shown, want 3", len(cards))250 t.Fatalf("%d cards shown, want 3", len(cards))
276 }251 }
277- for i := 1; i < len(cards); i++ {252+ col := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]}
278- if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {253+ for i := 1; i < len(col); i++ {
279- t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)254+ if col[i].Rect.X != col[0].Rect.X || col[i].Rect.Y < col[i-1].Rect.Y+col[i-1].Rect.H {
255+ t.Fatalf("cards not stacked down: %v then %v", col[i-1].Rect, col[i].Rect)
280 }256 }
281 }257 }
282- m.OverviewMove(0, -1)258+ if !col[1].Focused {
283- m.OverviewMove(0, -1)259+ t.Fatal("the selection is not in the middle")
284- if m.cardAt(w) != 0 {
285- t.Fatalf("up: card %d, want 0", m.cardAt(w))
286 }260 }
287 m.OverviewMove(0, 1)261 m.OverviewMove(0, 1)
288- if m.cardAt(w) != 1 {262+ if want := (at + 1) % 3; m.cardAt(w) != want {
289- t.Fatalf("down: card %d, want 1", m.cardAt(w))263+ t.Fatalf("down: card %d, want %d", m.cardAt(w), want)
264+ }
265+ m.OverviewMove(0, -1)
266+ m.OverviewMove(0, -1)
267+ if want := (at + 2) % 3; m.cardAt(w) != want {
268+ t.Fatalf("up twice: card %d, want %d", m.cardAt(w), want)
290 }269 }
291 m.OverviewMove(1, 0)270 m.OverviewMove(1, 0)
292 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {271 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
293 t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())272 t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
294 }273 }
295 }274 }
275+
276+// A stash-only workspace's carousel does not wrap round, drawn or walked:
277+// the selection stays in the middle, the cards in stash order on either
278+// side of it. Past the ends a vertical overview stops; a cascade one
279+// changes workspace.
280+func TestOverviewStashOnlyCarousel(t *testing.T) {
281+ for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
282+ t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
283+ m := overviewMonitor()
284+ m.Focus(2)
285+ m.AddWindow(30)
286+ m.Focus(1)
287+ w := m.Current()
288+ w.Overflow = OverflowCascade
289+ if axis == verticalAxis {
290+ w.Overflow = OverflowScroll
291+ }
292+ w.ToggleWindowStash() // its own window
293+ for id := WindowID(40); id < 44; id++ {
294+ w.AddWindow(id)
295+ w.FocusID(id)
296+ w.ToggleWindowStash()
297+ }
298+ m.ToggleOverview()
299+ if m.overviewAxis() != axis || len(w.Columns) != 0 || m.cardAt(w) < 0 {
300+ t.Fatalf("setup: axis %d, %d columns, card %d", m.overviewAxis(), len(w.Columns), m.cardAt(w))
301+ }
302+ last := len(w.Stash) - 1
303+ m.selectCard(w, last)
304+ ps := m.Layout()
305+ pos := func(i int) int {
306+ if r := previewOf(t, ps, w.Stash[i].ID).Rect; axis == verticalAxis {
307+ return r.Y
308+ } else {
309+ return r.X
310+ }
311+ }
312+ for i := 1; i <= last; i++ {
313+ if pos(i) <= pos(i-1) {
314+ t.Fatalf("card %d at %d, not after card %d at %d", i, pos(i), i-1, pos(i-1))
315+ }
316+ }
317+ if axis == verticalAxis {
318+ m.OverviewMove(0, 1)
319+ if m.Current() != w || m.cardAt(w) != last {
320+ t.Fatalf("down past the last card: card %d", m.cardAt(w))
321+ }
322+ return
323+ }
324+ m.OverviewMove(1, 0)
325+ if m.Current() == w {
326+ t.Fatal("right past the last card stayed on the workspace")
327+ }
328+ })
329+ }
330+}
M internal/core/transition.go +7 -7
@@ -45,8 +45,8 @@ type viewShot struct {
4545 stashShown bool
4646 stashAt, stashLen int
4747 stashPos, stashV float64
48- // card is the selected card of an unfolded stash carousel in the
49- // preview (Workspace.carousel), -1 without one.
48+ // card is the selected card of the unfolded stash carousel in the
49+ // overview (Workspace.unfold), -1 without one.
5050 card int
5151 // list and pos are the numbered workspaces and the monitor's fractional
5252 // position in them (index of the current one plus switchView.off); ok is
@@ -640,7 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
640640 _, s.switchV = m.switchView.motion.sampleAt(now)
641641 }
642642 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off
643- s.card = m.carouselCard(w)
643+ s.card = m.cardAt(w)
644644 if w.stashView.motion.on {
645645 _, s.stashV = w.stashView.motion.sampleAt(now)
646646 }
@@ -918,7 +918,7 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
918918 }
919919 rm := sc.rects[p.ID]
920920 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
921- if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash)) {
921+ if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash), w.wrapsCards()) {
922922 // The card went round the carousel to its other end: it
923923 // fades in there instead of crossing the row.
924924 rm, i = rectMotion{}, -1
@@ -957,14 +957,14 @@ func carouselStep(b *viewShot) int {
957957 if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen {
958958 return -1
959959 }
960- return m.carouselCard(w)
960+ return m.cardAt(w)
961961 }
962962
963963 // wrapped reports whether stash card i of a carousel of n cards went round
964964 // it, from one end to the other: it moved more than one place
965965 // (carouselSlot) when the selection went from card from to card to.
966-func wrapped(i, from, to, n int) bool {
967- return i >= 0 && abs(carouselSlot(i, to, n)-carouselSlot(i, from, n)) > 1
966+func wrapped(i, from, to, n int, wraps bool) bool {
967+ return i >= 0 && abs(carouselSlot(i, to, n, wraps)-carouselSlot(i, from, n, wraps)) > 1
968968 }
969969
970970 // movedFrom is the shot of the other screen that showed the workspace b's
@@ -45,8 +45,8 @@ type viewShot struct {
45 stashShown bool45 stashShown bool
46 stashAt, stashLen int46 stashAt, stashLen int
47 stashPos, stashV float6447 stashPos, stashV float64
48- // card is the selected card of an unfolded stash carousel in the48+ // card is the selected card of the unfolded stash carousel in the
49- // preview (Workspace.carousel), -1 without one.49+ // overview (Workspace.unfold), -1 without one.
50 card int50 card int
51 // list and pos are the numbered workspaces and the monitor's fractional51 // list and pos are the numbered workspaces and the monitor's fractional
52 // position in them (index of the current one plus switchView.off); ok is52 // position in them (index of the current one plus switchView.off); ok is
@@ -640,7 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
640 _, s.switchV = m.switchView.motion.sampleAt(now)640 _, s.switchV = m.switchView.motion.sampleAt(now)
641 }641 }
642 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off642 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off
643- s.card = m.carouselCard(w)643+ s.card = m.cardAt(w)
644 if w.stashView.motion.on {644 if w.stashView.motion.on {
645 _, s.stashV = w.stashView.motion.sampleAt(now)645 _, s.stashV = w.stashView.motion.sampleAt(now)
646 }646 }
@@ -918,7 +918,7 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
918 }918 }
919 rm := sc.rects[p.ID]919 rm := sc.rects[p.ID]
920 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })920 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
921- if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash)) {921+ if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, len(w.Stash), w.wrapsCards()) {
922 // The card went round the carousel to its other end: it922 // The card went round the carousel to its other end: it
923 // fades in there instead of crossing the row.923 // fades in there instead of crossing the row.
924 rm, i = rectMotion{}, -1924 rm, i = rectMotion{}, -1
@@ -957,14 +957,14 @@ func carouselStep(b *viewShot) int {
957 if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen {957 if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen {
958 return -1958 return -1
959 }959 }
960- return m.carouselCard(w)960+ return m.cardAt(w)
961 }961 }
962 962
963 // wrapped reports whether stash card i of a carousel of n cards went round963 // wrapped reports whether stash card i of a carousel of n cards went round
964 // it, from one end to the other: it moved more than one place964 // it, from one end to the other: it moved more than one place
965 // (carouselSlot) when the selection went from card from to card to.965 // (carouselSlot) when the selection went from card from to card to.
966-func wrapped(i, from, to, n int) bool {966+func wrapped(i, from, to, n int, wraps bool) bool {
967- return i >= 0 && abs(carouselSlot(i, to, n)-carouselSlot(i, from, n)) > 1967+ return i >= 0 && abs(carouselSlot(i, to, n, wraps)-carouselSlot(i, from, n, wraps)) > 1
968 }968 }
969 969
970 // movedFrom is the shot of the other screen that showed the workspace b's970 // movedFrom is the shot of the other screen that showed the workspace b's

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