detached 1c7d6c1

feat(overview): unfold the cascade stash as a carousel centred on the selection

commit
1c7d6c14d42b53abc1b45269bb742f050b19fbab
author
brice · · 2026-10-16 12:02 UTC
parent
af5dc26
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +333 -118
M docs/config.md +2 -2
@@ -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 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+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.
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, then 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 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.
282282
283283 Otherwise, left from the first column unfolds the stash. Up/down walk the cards 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 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+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.
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, then 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 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.
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 and right goes back to the columns.
M internal/core/overview.go +112 -31
@@ -57,8 +57,9 @@ 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 into a strip of every card (unfold), the
61-// selection framed, and the moves along that axis walk it. A vertical
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
6263 // overview pins the pile to the left edge of its row, which takes the width
6364 // the pile leaves. A horizontal one centres it on its row, in a band
6465 // reserved under the selected lane (pileBand): the lane and the band are
@@ -158,6 +159,15 @@ func (m *Monitor) cardAt(w *Workspace) int {
158159 return -1
159160 }
160161
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+
161171 // overviewTarget resolves the active preview to one window for validation
162172 // and window actions. Presentation state never falls back to a covering float.
163173 func (m *Monitor) overviewTarget() WindowID {
@@ -480,18 +490,29 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
480490
481491 // overviewAcross is the horizontal move of an overview whose workspaces sit
482492 // 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
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.
498+// Covering floats fan
485499 // along this axis and are traversed before a workspace: the card on the
486500 // left is the one behind.
487501 func (m *Monitor) overviewAcross(dx int) {
488502 w := m.Current()
489503 if at := m.cardAt(w); at >= 0 {
490- if next := at + dx; next >= 0 && next < len(w.Stash) {
504+ n, next := len(w.Stash), at+dx
505+ if next >= 0 && next < n {
491506 m.selectCard(w, next)
492507 return
493508 }
494- m.overviewStep(dx)
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)
513+ return
514+ }
515+ m.selectCard(w, (next+n)%n)
495516 return
496517 }
497518 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -916,48 +937,108 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
916937 return dst
917938 }
918939
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.
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.
926945 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 {
927957 n := len(w.Stash)
928958 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- }
959+ size, lo, hi := folded.H, u.Y, u.Y+u.H
933960 stride := size + 2*w.cardStep()
934961 total := n*stride - (stride - size)
935- start := origin + size/2 - total/2
962+ start := folded.Y + size/2 - total/2
936963 if total > hi-lo {
937- start = origin + size/2 - at*stride - size/2
964+ start = folded.Y - at*stride
938965 start = min(max(start, hi-total), lo)
939966 } else {
940967 start = min(max(start, lo), hi-total)
941968 }
942969 for i := range w.Stash {
943970 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
971+ r.Y = start + i*stride
972+ dst = w.unfoldedCard(dst, i, r, at, u)
973+ }
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()
987+ // 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
992+ switch {
993+ case s > 0 && at+s >= n:
994+ x += gap
995+ case s < 0 && at+s < 0:
996+ x -= gap
955997 }
956- dst = append(dst, p)
998+ return x
999+ }
1000+ first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n)
1001+ 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
1004+ }
1005+ for i := range w.Stash {
1006+ r := folded
1007+ r.X = x(i) + shift
1008+ dst = w.unfoldedCard(dst, i, r, at, u)
9571009 }
9581010 return dst
9591011 }
9601012
1013+// 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 {
1020+ return i - at
1021+ }
1022+ s := (i - at + n) % n
1023+ if s > (n-1)/2 {
1024+ s -= n
1025+ }
1026+ return s
1027+}
1028+
1029+// unfoldedCard appends stash card i of an unfolded pile at r to dst: framed
1030+// when it is the selected one at, dimmed otherwise, hidden out of the area u.
1031+func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement {
1032+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
1033+ if i != at {
1034+ p.peeking(1)
1035+ }
1036+ if !r.Overlaps(u) {
1037+ p.Hidden, p.Preview, p.Focused = true, 0, false
1038+ }
1039+ return append(dst, p)
1040+}
1041+
9611042 // overviewZoom is the scale that fits every column of w in the usable
9621043 // width, within the overview bounds, in an overview along axis: only a
9631044 // vertical one sets the stash pile beside the row.
@@ -57,8 +57,9 @@ 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 into a strip of every card (unfold), the60+// unfolds along the overview axis (unfold), the selection framed, and the
61-// selection framed, and the moves along that axis walk it. A vertical61+// 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
62 // overview pins the pile to the left edge of its row, which takes the width63 // 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 band64 // 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 are65 // reserved under the selected lane (pileBand): the lane and the band are
@@ -158,6 +159,15 @@ func (m *Monitor) cardAt(w *Workspace) int {
158 return -1159 return -1
159 }160 }
160 161
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+
161 // overviewTarget resolves the active preview to one window for validation171 // overviewTarget resolves the active preview to one window for validation
162 // and window actions. Presentation state never falls back to a covering float.172 // and window actions. Presentation state never falls back to a covering float.
163 func (m *Monitor) overviewTarget() WindowID {173 func (m *Monitor) overviewTarget() WindowID {
@@ -480,18 +490,29 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
480 490
481 // overviewAcross is the horizontal move of an overview whose workspaces sit491 // overviewAcross is the horizontal move of an overview whose workspaces sit
482 // side by side (cascade): a column inside the selected lane, or a card of492 // 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 previous493+// the unfolded stash, which wraps round (carousel). Past the first column
484-// workspace; past the last, to the next workspace. Covering floats fan494+// 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.
498+// Covering floats fan
485 // along this axis and are traversed before a workspace: the card on the499 // along this axis and are traversed before a workspace: the card on the
486 // left is the one behind.500 // left is the one behind.
487 func (m *Monitor) overviewAcross(dx int) {501 func (m *Monitor) overviewAcross(dx int) {
488 w := m.Current()502 w := m.Current()
489 if at := m.cardAt(w); at >= 0 {503 if at := m.cardAt(w); at >= 0 {
490- if next := at + dx; next >= 0 && next < len(w.Stash) {504+ n, next := len(w.Stash), at+dx
505+ if next >= 0 && next < n {
491 m.selectCard(w, next)506 m.selectCard(w, next)
492 return507 return
493 }508 }
494- m.overviewStep(dx)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)
513+ return
514+ }
515+ m.selectCard(w, (next+n)%n)
495 return516 return
496 }517 }
497 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {518 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -916,48 +937,108 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
916 return dst937 return dst
917 }938 }
918 939
919-// unfold lays the stash of w out as a strip of every card along axis, from940+// unfold lays the stash of w out from the folded pile's card rect. A
920-// the folded pile's card rect: a row in a horizontal overview, a column in941+// vertical overview lines every card up in a column (strip). A horizontal
921-// a vertical one. The strip is centred on the pile and kept in the overview942+// one spreads them as a carousel (carousel). Cards other than the selected
922-// area. Longer than the area, it scrolls like a wide row: the selected card943+// one at are dimmed; those out of the overview area are hidden. The cards
923-// at is centred, the strip stopping at the area's edges. The other cards944+// are appended to dst.
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 {945 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 {
927 n := len(w.Stash)957 n := len(w.Stash)
928 u := w.overviewArea()958 u := w.overviewArea()
929- size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W959+ size, lo, hi := folded.H, u.Y, u.Y+u.H
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()960 stride := size + 2*w.cardStep()
934 total := n*stride - (stride - size)961 total := n*stride - (stride - size)
935- start := origin + size/2 - total/2962+ start := folded.Y + size/2 - total/2
936 if total > hi-lo {963 if total > hi-lo {
937- start = origin + size/2 - at*stride - size/2964+ start = folded.Y - at*stride
938 start = min(max(start, hi-total), lo)965 start = min(max(start, hi-total), lo)
939 } else {966 } else {
940 start = min(max(start, lo), hi-total)967 start = min(max(start, lo), hi-total)
941 }968 }
942 for i := range w.Stash {969 for i := range w.Stash {
943 r := folded970 r := folded
944- if axis == verticalAxis {971+ r.Y = start + i*stride
945- r.Y = start + i*stride972+ dst = w.unfoldedCard(dst, i, r, at, u)
946- } else {973+ }
947- r.X = start + i*stride974+ return dst
948- }975+}
949- p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}976+
950- if i != at {977+// carousel lays the stash out as a row centred on the selected card at,
951- p.peeking(1)978+// under the pile, in stash order wrapping round: the cards before it on
952- }979+// the left, the ones after it on the right, the oldest following the
953- if !r.Overlaps(u) {980+// newest (carouselSlot). A wider gap marks that seam. When the row fits
954- p.Hidden, p.Preview, p.Focused = true, 0, false981+// 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.
988+ gap := folded.W / 4
989+ x := func(i int) int {
990+ s := carouselSlot(i, at, n)
991+ x := folded.X + s*stride
992+ switch {
993+ case s > 0 && at+s >= n:
994+ x += gap
995+ case s < 0 && at+s < 0:
996+ x -= gap
955 }997 }
956- dst = append(dst, p)998+ return x
999+ }
1000+ first, last := x((at+n-n/2)%n), x((at+(n-1)/2)%n)
1001+ 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
1004+ }
1005+ for i := range w.Stash {
1006+ r := folded
1007+ r.X = x(i) + shift
1008+ dst = w.unfoldedCard(dst, i, r, at, u)
957 }1009 }
958 return dst1010 return dst
959 }1011 }
960 1012
1013+// 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 {
1020+ return i - at
1021+ }
1022+ s := (i - at + n) % n
1023+ if s > (n-1)/2 {
1024+ s -= n
1025+ }
1026+ return s
1027+}
1028+
1029+// unfoldedCard appends stash card i of an unfolded pile at r to dst: framed
1030+// when it is the selected one at, dimmed otherwise, hidden out of the area u.
1031+func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement {
1032+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
1033+ if i != at {
1034+ p.peeking(1)
1035+ }
1036+ if !r.Overlaps(u) {
1037+ p.Hidden, p.Preview, p.Focused = true, 0, false
1038+ }
1039+ return append(dst, p)
1040+}
1041+
961 // overviewZoom is the scale that fits every column of w in the usable1042 // overviewZoom is the scale that fits every column of w in the usable
962 // width, within the overview bounds, in an overview along axis: only a1043 // width, within the overview bounds, in an overview along axis: only a
963 // vertical one sets the stash pile beside the row.1044 // vertical one sets the stash pile beside the row.
M internal/core/overview_cascade_test.go +7 -7
@@ -344,7 +344,8 @@ func TestOverviewCascadeKeys(t *testing.T) {
344344 // With a stash: left from the first column still goes to the
345345 // previous workspace. Down past the last band enters the
346346 // stash, up comes back to the band, and left past the first
347- // card goes to the previous workspace.
347+ // card goes to the previous workspace: two cards do not
348+ // wrap round.
348349 withStash(m, 2)
349350 w.FocusID(7)
350351 m.ToggleOverview()
@@ -473,10 +474,9 @@ func TestOverviewCascadeStashPile(t *testing.T) {
473474 c.swipeUpdate(ports.SwipeUpdate{DX: 40})
474475 }
475476 c.swipeEnd(ports.SwipeEnd{})
476- // A horizontal three-finger swipe is a workspace step, even over a
477- // stash card.
478- if m.Current() != m.Workspaces[1] {
479- t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
477+ // A horizontal three-finger swipe walks the carousel, wrapping round.
478+ if want := (at + 1) % len(w.Stash); m.Current() != w || m.cardAt(w) != want {
479+ t.Fatalf("horizontal swipe over a stash card: workspace %d, card %d, want %d", indexOf(m.Workspaces, m.Current()), m.cardAt(w), want)
480480 }
481481 if got := stashIDs(w); !slices.Equal(got, before) {
482482 t.Fatal("stash reordered")
@@ -930,8 +930,8 @@ func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
930930 }
931931 }
932932
933-// A stash-only cascade workspace has no columns to select: right from its
934-// pile goes straight to the next workspace.
933+// A stash-only cascade workspace has nothing above its pile: right from its
934+// pile goes straight to the next workspace, left to the previous one.
935935 func TestOverviewCascadeStashOnlyRight(t *testing.T) {
936936 m := overviewMonitor()
937937 m.Focus(1)
@@ -344,7 +344,8 @@ func TestOverviewCascadeKeys(t *testing.T) {
344 // With a stash: left from the first column still goes to the344 // With a stash: left from the first column still goes to the
345 // previous workspace. Down past the last band enters the345 // previous workspace. Down past the last band enters the
346 // stash, up comes back to the band, and left past the first346 // stash, up comes back to the band, and left past the first
347- // card goes to the previous workspace.347+ // card goes to the previous workspace: two cards do not
348+ // wrap round.
348 withStash(m, 2)349 withStash(m, 2)
349 w.FocusID(7)350 w.FocusID(7)
350 m.ToggleOverview()351 m.ToggleOverview()
@@ -473,10 +474,9 @@ func TestOverviewCascadeStashPile(t *testing.T) {
473 c.swipeUpdate(ports.SwipeUpdate{DX: 40})474 c.swipeUpdate(ports.SwipeUpdate{DX: 40})
474 }475 }
475 c.swipeEnd(ports.SwipeEnd{})476 c.swipeEnd(ports.SwipeEnd{})
476- // A horizontal three-finger swipe is a workspace step, even over a477+ // A horizontal three-finger swipe walks the carousel, wrapping round.
477- // stash card.478+ if want := (at + 1) % len(w.Stash); m.Current() != w || m.cardAt(w) != want {
478- if m.Current() != m.Workspaces[1] {479+ t.Fatalf("horizontal swipe over a stash card: workspace %d, card %d, want %d", indexOf(m.Workspaces, m.Current()), m.cardAt(w), want)
479- t.Fatalf("horizontal swipe over a stash card: workspace %d", indexOf(m.Workspaces, m.Current()))
480 }480 }
481 if got := stashIDs(w); !slices.Equal(got, before) {481 if got := stashIDs(w); !slices.Equal(got, before) {
482 t.Fatal("stash reordered")482 t.Fatal("stash reordered")
@@ -930,8 +930,8 @@ func TestOverviewFingerLatchSurvivesAxisChange(t *testing.T) {
930 }930 }
931 }931 }
932 932
933-// A stash-only cascade workspace has no columns to select: right from its933+// A stash-only cascade workspace has nothing above its pile: right from its
934-// pile goes straight to the next workspace.934+// pile goes straight to the next workspace, left to the previous one.
935 func TestOverviewCascadeStashOnlyRight(t *testing.T) {935 func TestOverviewCascadeStashOnlyRight(t *testing.T) {
936 m := overviewMonitor()936 m := overviewMonitor()
937 m.Focus(1)937 m.Focus(1)
M internal/core/overview_unfold_test.go +146 -78
@@ -14,9 +14,10 @@ func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement {
1414 return out
1515 }
1616
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.
17+// Selected, the cascade pile unfolds into a carousel under the lane: every
18+// card side by side, none over another, the selection the only framed one,
19+// in the middle. Left and right walk it, wrapping round, without leaving
20+// the workspace.
2021 func TestOverviewCascadeStashUnfolds(t *testing.T) {
2122 for _, d := range cascadeDrivers {
2223 t.Run(d.name, func(t *testing.T) {
@@ -36,42 +37,88 @@ func TestOverviewCascadeStashUnfolds(t *testing.T) {
3637 t.Fatalf("%d cards shown, want 3", len(cards))
3738 }
3839 lane := previewOf(t, ps, 10).Rect
39- for i, c := range cards {
40+ // Left to right: the card before the selection, the
41+ // selection, the one after it (wrapping round).
42+ row := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]}
43+ for i, c := range row {
4044 if c.Rect.Y <= lane.Y+lane.H {
4145 t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane)
4246 }
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)
47+ if i > 0 && (c.Rect.Y != row[0].Rect.Y || c.Rect.X < row[i-1].Rect.X+row[i-1].Rect.W) {
48+ t.Fatalf("cards not side by side: %v then %v", row[i-1].Rect, c.Rect)
4549 }
46- if c.Focused != (i == at) {
47- t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at)
50+ if c.Focused != (i == 1) {
51+ t.Fatalf("card %d focused %v, selection %d", c.ID, c.Focused, at)
4852 }
4953 }
50- for i := range 2 {
54+ for i := range 4 {
5155 d.move(m, -1, 0)
52- if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want {
56+ if want := (at - i - 1 + 6) % 3; m.Current() != w || m.cardAt(w) != want {
5357 t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want)
5458 }
55- if at-i-1 < 0 {
56- break
57- }
5859 }
59- for m.cardAt(w) < len(w.Stash)-1 {
60+ for i := range 4 {
6061 before := m.cardAt(w)
6162 d.move(m, 1, 0)
62- if m.cardAt(w) != before+1 {
63- t.Fatalf("right: card %d, was %d", m.cardAt(w), before)
63+ if m.Current() != w || m.cardAt(w) != (before+1)%3 {
64+ t.Fatalf("right %d: card %d, was %d", i, m.cardAt(w), before)
6465 }
6566 }
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- }
7167 })
7268 }
7369 }
7470
71+// Fewer than three cards keep the stash order without wrapping round: each
72+// side has the card its move selects, and past the ends the selection
73+// changes workspace, as from a lane.
74+func TestOverviewCascadeSmallCarousel(t *testing.T) {
75+ for n := 1; n <= 2; n++ {
76+ m := cascadeOverview()
77+ withStash(m, n)
78+ w := m.Current()
79+ w.FocusID(10)
80+ m.ToggleOverview()
81+ m.selectCard(w, 0)
82+ if n == 2 {
83+ ps := m.Layout()
84+ if a, b := previewOf(t, ps, w.Stash[0].ID).Rect.X, previewOf(t, ps, w.Stash[1].ID).Rect.X; b <= a {
85+ t.Fatalf("2 cards: the next card at %d, not right of the selection at %d", b, a)
86+ }
87+ m.OverviewMove(1, 0)
88+ if m.Current() != w || m.cardAt(w) != 1 {
89+ t.Fatalf("2 cards: right selected card %d", m.cardAt(w))
90+ }
91+ }
92+ m.OverviewMove(1, 0)
93+ if m.Current() != m.Workspaces[2] {
94+ t.Fatalf("%d cards: right past the last card stayed on workspace %d", n, indexOf(m.Workspaces, m.Current()))
95+ }
96+ }
97+}
98+
99+// The carousel keeps the stash order, wrapping round: the stride between
100+// neighbors is even, wider at the seam between the newest card and the
101+// oldest.
102+func TestOverviewCascadeCarouselSeam(t *testing.T) {
103+ m := cascadeOverview()
104+ withStash(m, 5)
105+ w := m.Current()
106+ m.ToggleOverview()
107+ m.selectCard(w, 4)
108+ ps := m.Layout()
109+ x := func(i int) int { return previewOf(t, ps, w.Stash[i].ID).Rect.X }
110+ stride := x(4) - x(3)
111+ if stride <= 0 || x(3)-x(2) != stride {
112+ t.Fatalf("left of the newest card: %d %d %d", x(2), x(3), x(4))
113+ }
114+ if seam := x(0) - x(4); seam <= stride {
115+ t.Fatalf("seam %d not wider than the stride %d", seam, stride)
116+ }
117+ if x(1)-x(0) != stride {
118+ t.Fatalf("right of the seam: %d %d", x(0), x(1))
119+ }
120+}
121+
75122 // Down past the last lane enters the stash at its own selection; up leaves
76123 // it, and the pile folds again.
77124 func TestOverviewCascadeStashFolds(t *testing.T) {
@@ -97,65 +144,86 @@ func TestOverviewCascadeStashFolds(t *testing.T) {
97144 }
98145 }
99146
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.
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.
104151 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)
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
157219 }
158- })
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)
159227 }
160228 }
161229
@@ -14,9 +14,10 @@ func shownCards(t *testing.T, w *Workspace, ps []Placement) []Placement {
14 return out14 return out
15 }15 }
16 16
17-// Selected, the cascade pile unfolds into a row under the lane: every card17+// Selected, the cascade pile unfolds into a carousel under the lane: every
18-// side by side, in stash order, none over another, the selection the only18+// card side by side, none over another, the selection the only framed one,
19-// framed one. Left and right walk it.19+// in the middle. Left and right walk it, wrapping round, without leaving
20+// the workspace.
20 func TestOverviewCascadeStashUnfolds(t *testing.T) {21 func TestOverviewCascadeStashUnfolds(t *testing.T) {
21 for _, d := range cascadeDrivers {22 for _, d := range cascadeDrivers {
22 t.Run(d.name, func(t *testing.T) {23 t.Run(d.name, func(t *testing.T) {
@@ -36,42 +37,88 @@ func TestOverviewCascadeStashUnfolds(t *testing.T) {
36 t.Fatalf("%d cards shown, want 3", len(cards))37 t.Fatalf("%d cards shown, want 3", len(cards))
37 }38 }
38 lane := previewOf(t, ps, 10).Rect39 lane := previewOf(t, ps, 10).Rect
39- for i, c := range cards {40+ // Left to right: the card before the selection, the
41+ // selection, the one after it (wrapping round).
42+ row := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]}
43+ for i, c := range row {
40 if c.Rect.Y <= lane.Y+lane.H {44 if c.Rect.Y <= lane.Y+lane.H {
41 t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane)45 t.Fatalf("card %d %v not under the lane %v", c.ID, c.Rect, lane)
42 }46 }
43- if i > 0 && (c.Rect.Y != cards[0].Rect.Y || c.Rect.X < cards[i-1].Rect.X+cards[i-1].Rect.W) {47+ if i > 0 && (c.Rect.Y != row[0].Rect.Y || c.Rect.X < row[i-1].Rect.X+row[i-1].Rect.W) {
44- t.Fatalf("cards not side by side: %v then %v", cards[i-1].Rect, c.Rect)48+ t.Fatalf("cards not side by side: %v then %v", row[i-1].Rect, c.Rect)
45 }49 }
46- if c.Focused != (i == at) {50+ if c.Focused != (i == 1) {
47- t.Fatalf("card %d focused %v, selection %d", i, c.Focused, at)51+ t.Fatalf("card %d focused %v, selection %d", c.ID, c.Focused, at)
48 }52 }
49 }53 }
50- for i := range 2 {54+ for i := range 4 {
51 d.move(m, -1, 0)55 d.move(m, -1, 0)
52- if want := max(at-i-1, 0); m.Current() != w || m.cardAt(w) != want {56+ if want := (at - i - 1 + 6) % 3; m.Current() != w || m.cardAt(w) != want {
53 t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want)57 t.Fatalf("left %d: card %d, want %d", i, m.cardAt(w), want)
54 }58 }
55- if at-i-1 < 0 {
56- break
57- }
58 }59 }
59- for m.cardAt(w) < len(w.Stash)-1 {60+ for i := range 4 {
60 before := m.cardAt(w)61 before := m.cardAt(w)
61 d.move(m, 1, 0)62 d.move(m, 1, 0)
62- if m.cardAt(w) != before+1 {63+ if m.Current() != w || m.cardAt(w) != (before+1)%3 {
63- t.Fatalf("right: card %d, was %d", m.cardAt(w), before)64+ t.Fatalf("right %d: card %d, was %d", i, m.cardAt(w), before)
64 }65 }
65 }66 }
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 })67 })
72 }68 }
73 }69 }
74 70
71+// Fewer than three cards keep the stash order without wrapping round: each
72+// side has the card its move selects, and past the ends the selection
73+// changes workspace, as from a lane.
74+func TestOverviewCascadeSmallCarousel(t *testing.T) {
75+ for n := 1; n <= 2; n++ {
76+ m := cascadeOverview()
77+ withStash(m, n)
78+ w := m.Current()
79+ w.FocusID(10)
80+ m.ToggleOverview()
81+ m.selectCard(w, 0)
82+ if n == 2 {
83+ ps := m.Layout()
84+ if a, b := previewOf(t, ps, w.Stash[0].ID).Rect.X, previewOf(t, ps, w.Stash[1].ID).Rect.X; b <= a {
85+ t.Fatalf("2 cards: the next card at %d, not right of the selection at %d", b, a)
86+ }
87+ m.OverviewMove(1, 0)
88+ if m.Current() != w || m.cardAt(w) != 1 {
89+ t.Fatalf("2 cards: right selected card %d", m.cardAt(w))
90+ }
91+ }
92+ m.OverviewMove(1, 0)
93+ if m.Current() != m.Workspaces[2] {
94+ t.Fatalf("%d cards: right past the last card stayed on workspace %d", n, indexOf(m.Workspaces, m.Current()))
95+ }
96+ }
97+}
98+
99+// The carousel keeps the stash order, wrapping round: the stride between
100+// neighbors is even, wider at the seam between the newest card and the
101+// oldest.
102+func TestOverviewCascadeCarouselSeam(t *testing.T) {
103+ m := cascadeOverview()
104+ withStash(m, 5)
105+ w := m.Current()
106+ m.ToggleOverview()
107+ m.selectCard(w, 4)
108+ ps := m.Layout()
109+ x := func(i int) int { return previewOf(t, ps, w.Stash[i].ID).Rect.X }
110+ stride := x(4) - x(3)
111+ if stride <= 0 || x(3)-x(2) != stride {
112+ t.Fatalf("left of the newest card: %d %d %d", x(2), x(3), x(4))
113+ }
114+ if seam := x(0) - x(4); seam <= stride {
115+ t.Fatalf("seam %d not wider than the stride %d", seam, stride)
116+ }
117+ if x(1)-x(0) != stride {
118+ t.Fatalf("right of the seam: %d %d", x(0), x(1))
119+ }
120+}
121+
75 // Down past the last lane enters the stash at its own selection; up leaves122 // Down past the last lane enters the stash at its own selection; up leaves
76 // it, and the pile folds again.123 // it, and the pile folds again.
77 func TestOverviewCascadeStashFolds(t *testing.T) {124 func TestOverviewCascadeStashFolds(t *testing.T) {
@@ -97,65 +144,86 @@ func TestOverviewCascadeStashFolds(t *testing.T) {
97 }144 }
98 }145 }
99 146
100-// A stash longer than the area scrolls like a wide row, on either axis:147+// A vertical overview's stash longer than the area scrolls like a wide
101-// every card is reached in order, the selection stays inside the area and148+// row: every card is reached in order, the selection stays inside the area
102-// is centred unless the strip stops at an edge of it, and cards past the149+// and is centred unless the strip stops at an edge of it, and cards past
103-// area are hidden.150+// the area are hidden.
104 func TestOverviewStashUnfoldScrolls(t *testing.T) {151 func TestOverviewStashUnfoldScrolls(t *testing.T) {
105- for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {152+ m := cascadeOverview()
106- t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {153+ w := m.Current()
107- m := cascadeOverview()154+ w.Overflow = OverflowScroll
108- w := m.Current()155+ withStash(m, 12)
109- if axis == verticalAxis {156+ m.ToggleOverview()
110- w.Overflow = OverflowScroll157+ if m.overviewAxis() != verticalAxis {
111- }158+ t.Fatal("setup: wrong overview axis")
112- withStash(m, 12)159+ }
113- m.ToggleOverview()160+ m.selectCard(w, 0)
114- if m.overviewAxis() != axis {161+ u := w.overviewArea()
115- t.Fatal("setup: wrong overview axis")162+ lo, hi := u.Y, u.Y+u.H
116- }163+ centred, hidden := false, false
117- m.selectCard(w, 0)164+ for i := range len(w.Stash) {
118- u := w.overviewArea()165+ if m.cardAt(w) != i {
119- pos := func(r Rect) (int, int, int, int) {166+ t.Fatalf("step %d: card %d", i, m.cardAt(w))
120- if axis == verticalAxis {167+ }
121- return r.Y, r.H, u.Y, u.Y + u.H168+ ps := m.Layout()
122- }169+ sel := previewOf(t, ps, w.Stash[i].ID)
123- return r.X, r.W, u.X, u.X + u.W170+ at, size := sel.Rect.Y, sel.Rect.H
124- }171+ if sel.Hidden || !sel.Focused || at < lo || at+size > hi {
125- centred, hidden := false, false172+ t.Fatalf("card %d off the area: %+v in %v", i, sel, u)
126- for i := range len(w.Stash) {173+ }
127- if m.cardAt(w) != i {174+ first := previewOf(t, ps, w.Stash[0].ID).Rect.Y
128- t.Fatalf("step %d: card %d", i, m.cardAt(w))175+ last := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect.Y
129- }176+ if first > lo || last+size < hi {
130- ps := m.Layout()177+ t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)
131- sel := previewOf(t, ps, w.Stash[i].ID)178+ }
132- at, size, lo, hi := pos(sel.Rect)179+ if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {
133- if sel.Hidden || !sel.Focused || at < lo || at+size > hi {180+ centred = true
134- t.Fatalf("card %d off the area: %+v in %v", i, sel, u)181+ } else if first != lo && last+size != hi {
135- }182+ t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)
136- first, _, _, _ := pos(previewOf(t, ps, w.Stash[0].ID).Rect)183+ }
137- last, _, _, _ := pos(previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect)184+ for _, e := range w.Stash {
138- if first > lo || last+size < hi {185+ hidden = hidden || previewOf(t, ps, e.ID).Hidden
139- t.Fatalf("card %d: strip %d..%d does not fill the area %d..%d", i, first, last+size, lo, hi)186+ }
140- }187+ m.OverviewMove(0, 1)
141- if mid := at + size/2; abs(mid-(lo+hi)/2) <= 1 {188+ }
142- centred = true189+ if !hidden || !centred {
143- } else if first != lo && last+size != hi {190+ t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
144- t.Fatalf("card %d at %d, neither centred on %d nor clamped", i, mid, (lo+hi)/2)191+ }
145- }192+}
146- for _, e := range w.Stash {193+
147- hidden = hidden || previewOf(t, ps, e.ID).Hidden194+// A carousel wider than the area keeps every selection centred in it, with
148- }195+// cards on both sides; the cards past the area are hidden.
149- if axis == verticalAxis {196+func TestOverviewCascadeCarouselScrolls(t *testing.T) {
150- m.OverviewMove(0, 1)197+ m := cascadeOverview()
151- } else if i < len(w.Stash)-1 {198+ w := m.Current()
152- m.OverviewMove(1, 0)199+ withStash(m, 12)
153- }200+ m.ToggleOverview()
154- }201+ if m.overviewAxis() != horizontalAxis {
155- if !hidden || !centred {202+ t.Fatal("setup: wrong overview axis")
156- t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)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
157 }219 }
158- })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)
159 }227 }
160 }228 }
161 229
M internal/core/transition.go +33 -0
@@ -45,6 +45,9 @@ 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.
50+ card int
4851 // list and pos are the numbered workspaces and the monitor's fractional
4952 // position in them (index of the current one plus switchView.off); ok is
5053 // false when there is none (a shown stash workspace).
@@ -637,6 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
637640 _, s.switchV = m.switchView.motion.sampleAt(now)
638641 }
639642 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)
640644 if w.stashView.motion.on {
641645 _, s.stashV = w.stashView.motion.sampleAt(now)
642646 }
@@ -903,12 +907,22 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
903907 if changed {
904908 sc.stopRects()
905909 }
910+ at := -1
911+ if !changed {
912+ at = carouselStep(b)
913+ }
914+ w := sc.mon.Current()
906915 for _, p := range sc.mon.Layout() {
907916 if p.Hidden {
908917 continue
909918 }
910919 rm := sc.rects[p.ID]
911920 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)) {
922+ // The card went round the carousel to its other end: it
923+ // fades in there instead of crossing the row.
924+ rm, i = rectMotion{}, -1
925+ }
912926 if i >= 0 && changed {
913927 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
914928 }
@@ -934,6 +948,25 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
934948 }
935949 }
936950
951+// carouselStep is the selected card of the stash carousel b's screen shows
952+// now, when it showed the same carousel in b too (the same workspace and
953+// stash size); else -1.
954+func carouselStep(b *viewShot) int {
955+ m := b.sc.mon
956+ w := m.Current()
957+ if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen {
958+ return -1
959+ }
960+ return m.carouselCard(w)
961+}
962+
963+// 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 place
965+// (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
968+}
969+
937970 // movedFrom is the shot of the other screen that showed the workspace b's
938971 // screen shows now (moved to it by an action), or nil.
939972 func movedFrom(b *viewShot, before []viewShot) *viewShot {
@@ -45,6 +45,9 @@ 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 the
49+ // preview (Workspace.carousel), -1 without one.
50+ card int
48 // list and pos are the numbered workspaces and the monitor's fractional51 // list and pos are the numbered workspaces and the monitor's fractional
49 // 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
50 // false when there is none (a shown stash workspace).53 // false when there is none (a shown stash workspace).
@@ -637,6 +640,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
637 _, s.switchV = m.switchView.motion.sampleAt(now)640 _, s.switchV = m.switchView.motion.sampleAt(now)
638 }641 }
639 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)
640 if w.stashView.motion.on {644 if w.stashView.motion.on {
641 _, s.stashV = w.stashView.motion.sampleAt(now)645 _, s.stashV = w.stashView.motion.sampleAt(now)
642 }646 }
@@ -903,12 +907,22 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
903 if changed {907 if changed {
904 sc.stopRects()908 sc.stopRects()
905 }909 }
910+ at := -1
911+ if !changed {
912+ at = carouselStep(b)
913+ }
914+ w := sc.mon.Current()
906 for _, p := range sc.mon.Layout() {915 for _, p := range sc.mon.Layout() {
907 if p.Hidden {916 if p.Hidden {
908 continue917 continue
909 }918 }
910 rm := sc.rects[p.ID]919 rm := sc.rects[p.ID]
911 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)) {
922+ // The card went round the carousel to its other end: it
923+ // fades in there instead of crossing the row.
924+ rm, i = rectMotion{}, -1
925+ }
912 if i >= 0 && changed {926 if i >= 0 && changed {
913 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)927 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
914 }928 }
@@ -934,6 +948,25 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
934 }948 }
935 }949 }
936 950
951+// carouselStep is the selected card of the stash carousel b's screen shows
952+// now, when it showed the same carousel in b too (the same workspace and
953+// stash size); else -1.
954+func carouselStep(b *viewShot) int {
955+ m := b.sc.mon
956+ w := m.Current()
957+ if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen {
958+ return -1
959+ }
960+ return m.carouselCard(w)
961+}
962+
963+// 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 place
965+// (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
968+}
969+
937 // 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
938 // screen shows now (moved to it by an action), or nil.971 // screen shows now (moved to it by an action), or nil.
939 func movedFrom(b *viewShot, before []viewShot) *viewShot {972 func movedFrom(b *viewShot, before []viewShot) *viewShot {
M internal/core/transition_stash_internal_test.go +33 -0
@@ -473,3 +473,36 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) {
473473 }
474474 }
475475 }
476+
477+// Walking the stash carousel slides its cards one place; the card that goes
478+// round to the other end fades in there instead of crossing the row.
479+func TestCarouselWrapFadesIn(t *testing.T) {
480+ c, ic, sc, _ := stashRig(t, true)
481+ m := sc.mon
482+ w := m.Current()
483+ w.Overflow = OverflowCascade
484+ c.applyAction(ActionToggleOverview)
485+ settleShown(t, c)
486+ m.selectCard(w, 0)
487+ settleShown(t, c)
488+ shots := c.snapshot(ic.now)
489+ m.OverviewMove(1, 0)
490+ c.transition(shots, ic.now)
491+ if m.cardAt(w) != 1 {
492+ t.Fatalf("setup: card %d", m.cardAt(w))
493+ }
494+ // Three cards: the selection moved from 0 to 1, card 2 went round
495+ // from the right end to the left one.
496+ for i, e := range w.Stash {
497+ rm := sc.rects[e.ID]
498+ if i == 2 {
499+ if rm.dx != 0 || !rm.fade.on {
500+ t.Fatalf("wrapped card: dx %v, fade %v", rm.dx, rm.fade.on)
501+ }
502+ continue
503+ }
504+ if rm.dx == 0 || rm.fade.on {
505+ t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
506+ }
507+ }
508+}
@@ -473,3 +473,36 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) {
473 }473 }
474 }474 }
475 }475 }
476+
477+// Walking the stash carousel slides its cards one place; the card that goes
478+// round to the other end fades in there instead of crossing the row.
479+func TestCarouselWrapFadesIn(t *testing.T) {
480+ c, ic, sc, _ := stashRig(t, true)
481+ m := sc.mon
482+ w := m.Current()
483+ w.Overflow = OverflowCascade
484+ c.applyAction(ActionToggleOverview)
485+ settleShown(t, c)
486+ m.selectCard(w, 0)
487+ settleShown(t, c)
488+ shots := c.snapshot(ic.now)
489+ m.OverviewMove(1, 0)
490+ c.transition(shots, ic.now)
491+ if m.cardAt(w) != 1 {
492+ t.Fatalf("setup: card %d", m.cardAt(w))
493+ }
494+ // Three cards: the selection moved from 0 to 1, card 2 went round
495+ // from the right end to the left one.
496+ for i, e := range w.Stash {
497+ rm := sc.rects[e.ID]
498+ if i == 2 {
499+ if rm.dx != 0 || !rm.fade.on {
500+ t.Fatalf("wrapped card: dx %v, fade %v", rm.dx, rm.fade.on)
501+ }
502+ continue
503+ }
504+ if rm.dx == 0 || rm.fade.on {
505+ t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
506+ }
507+ }
508+}

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