detached 9dcad8d

Merge pull request 'feat(overview): unfold the stash as a carousel centred on the selection' (#58) from feat/stash-carousel into main

Reviewed-on: https://git.bnema.dev/bnema/neferwl/pulls/58
commit
9dcad8db0cb489ec9ec72a1f923fc4d3946308a1
author
bnema · · 2026-10-16 13:45 UTC
merge of
af5dc26 + 3a090b4 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
8 files · +500 -134
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 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 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. Moving to another workspace selects its windows rather than a carousel that wraps round, even when its stash has the focus, so browsing workspaces never stops on one.
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; 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 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 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. Moving to another workspace selects its windows rather than a carousel that wraps round, even when its stash has the focus, so browsing workspaces never stops on one.
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; 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 +117 -45
@@ -57,8 +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 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 into a carousel centred on the selection
61+// (unfold), framed, and the moves along that axis walk it round. A vertical
6262 // overview pins the pile to the left edge of its row, which takes the width
6363 // the pile leaves. A horizontal one centres it on its row, in a band
6464 // reserved under the selected lane (pileBand): the lane and the band are
@@ -402,11 +402,12 @@ func (m *Monitor) OverviewMove(dx, dy int) {
402402 }
403403
404404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
405-// walks the unfolded stash and the stack cards before changing workspace
406-// (overviewVertical). A side-by-side one always changes workspace, since
407-// its stack and unfolded stash run left and right.
405+// walks the stack cards before changing workspace (overviewVertical); from
406+// a selected stash card, which the move keys walk, it changes workspace
407+// directly. A side-by-side one always changes workspace, since its stack
408+// and unfolded stash run left and right.
408409 func (m *Monitor) overviewWorkspaceStep(d int) {
409- if m.overviewAxis() == verticalAxis {
410+ if m.overviewAxis() == verticalAxis && m.cardAt(m.Current()) < 0 {
410411 m.overviewVertical(d)
411412 return
412413 }
@@ -414,17 +415,43 @@ func (m *Monitor) overviewWorkspaceStep(d int) {
414415 }
415416
416417 // cycleCard selects the stash card d entries from the selected one of w in
417-// the unfolded pile, stopping at either end. It reports whether a card was
418-// selected.
418+// the unfolded pile, wrapping round (wrapsCards), else staying at either
419+// end. It reports whether a card was selected.
419420 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
420421 at := m.cardAt(w)
421422 if at < 0 {
422423 return false
423424 }
424- m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))
425+ m.stepCard(w, at, d)
425426 return true
426427 }
427428
429+// stepCard selects the stash card d entries from card at of w, wrapping
430+// round when the carousel does (wrapsCards). It reports false, selecting
431+// nothing, past an end of one that does not.
432+func (m *Monitor) stepCard(w *Workspace, at, d int) bool {
433+ n, next := len(w.Stash), at+d
434+ if next < 0 || next >= n {
435+ if !w.wrapsCards() {
436+ return false
437+ }
438+ next = (next%n + n) % n
439+ }
440+ m.selectCard(w, next)
441+ return true
442+}
443+
444+// wrapsCards reports whether the unfolded stash of w wraps round, drawn
445+// (unfold) and walked (stepCard). A carousel of fewer than three cards does
446+// not, so that each side has the card its move selects; nor does the one
447+// of a stash-only workspace, which has nothing beside its pile to leave it
448+// for. Past the ends of one that does not, a move goes on as from a lane
449+// (cascade) or stops (a vertical overview).
450+func (w *Workspace) wrapsCards() bool {
451+ _, k := w.stackHead()
452+ return k > 0 && len(w.Stash) >= 3
453+}
454+
428455 // overviewLane is the vertical move of an overview whose workspaces sit side
429456 // by side (cascade). From the unfolded stash, up leaves it for the last lane
430457 // (leavePile) and down stays: the stash is the bottom. Else the selection
@@ -480,18 +507,16 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
480507
481508 // overviewAcross is the horizontal move of an overview whose workspaces sit
482509 // 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
485-// along this axis and are traversed before a workspace: the card on the
486-// left is the one behind.
510+// the unfolded stash, which wraps round (stepCard). Past the first column
511+// or a card that does not wrap, it goes to the previous workspace; past
512+// the last, to the next workspace. Covering floats fan along this axis and
513+// are traversed before a workspace: the card on the left is the one behind.
487514 func (m *Monitor) overviewAcross(dx int) {
488515 w := m.Current()
489516 if at := m.cardAt(w); at >= 0 {
490- if next := at + dx; next >= 0 && next < len(w.Stash) {
491- m.selectCard(w, next)
492- return
517+ if !m.stepCard(w, at, dx) {
518+ m.overviewStep(dx)
493519 }
494- m.overviewStep(dx)
495520 return
496521 }
497522 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -529,12 +554,17 @@ func (m *Monitor) overviewVertical(dy int) {
529554 }
530555
531556 // overviewStep shows the workspace d rows from the current one; it stays
532-// on the first or last row.
557+// on the first or last row. The row selects its stack even when its stash
558+// has the focus, if the stash wraps round (wrapsCards): the moves that
559+// stepped there would only walk the carousel, never reaching the next row.
533560 func (m *Monitor) overviewStep(d int) {
534561 rows := m.overviewWorkspaces()
535562 i := indexOf(rows, m.Current()) + d
536563 if i >= 0 && i < len(rows) {
537564 m.showOverview(rows[i])
565+ if w := m.Current(); m.cardAt(w) >= 0 && w.wrapsCards() {
566+ m.clearCard()
567+ }
538568 }
539569 }
540570
@@ -916,48 +946,90 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
916946 return dst
917947 }
918948
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.
949+// unfold lays the stash of w out from the folded pile's card rect as a
950+// carousel along axis: a row in a horizontal overview, a column in a
951+// vertical one. The selected card at sits on the pile, in the middle; the
952+// cards before it come on the left (above), the ones after it on the right
953+// (below), in stash order, wrapping round (wrapsCards) with the oldest
954+// following the newest (carouselSlot). A wider gap marks that seam. A
955+// carousel that fits the overview area is shifted into it. Longer, one
956+// that wraps keeps the selection in the middle; one that does not stops at
957+// the area's edges, like a wide row. The cards past the area are hidden,
958+// the ones other than at dimmed. The cards are appended to dst.
926959 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
927960 n := len(w.Stash)
928961 u := w.overviewArea()
929- size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W
962+ size, origin, lo, room := folded.W, folded.X, u.X, u.W
930963 if axis == verticalAxis {
931- size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H
964+ size, origin, lo, room = folded.H, folded.Y, u.Y, u.H
932965 }
933966 stride := size + 2*w.cardStep()
934- total := n*stride - (stride - size)
935- start := origin + size/2 - total/2
936- if total > hi-lo {
937- start = origin + size/2 - at*stride - size/2
938- start = min(max(start, hi-total), lo)
939- } else {
940- start = min(max(start, lo), hi-total)
967+ // The extra room at the seam, on top of the stride.
968+ gap := size / 4
969+ wraps := w.wrapsCards()
970+ pos := func(s int) int {
971+ p := origin + s*stride
972+ switch {
973+ case s > 0 && at+s >= n:
974+ p += gap
975+ case s < 0 && at+s < 0:
976+ p -= gap
977+ }
978+ return p
979+ }
980+ // The slots of the ends.
981+ from, to := -at, n-1-at
982+ if wraps {
983+ from, to = -(n / 2), (n-1)/2
984+ }
985+ first, total := pos(from), pos(to)+size-pos(from)
986+ var shift int
987+ switch {
988+ case total <= room:
989+ shift = min(max(first, lo), lo+room-total) - first
990+ case !wraps:
991+ shift = min(max(first, lo+room-total), lo) - first
941992 }
942993 for i := range w.Stash {
943994 r := folded
944- if axis == verticalAxis {
945- r.Y = start + i*stride
995+ if p := pos(carouselSlot(i, at, n, wraps)) + shift; axis == verticalAxis {
996+ r.Y = p
946997 } 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)
998+ r.X = p
952999 }
953- if !r.Overlaps(u) {
954- p.Hidden, p.Preview, p.Focused = true, 0, false
955- }
956- dst = append(dst, p)
1000+ dst = w.unfoldedCard(dst, i, r, at, u)
9571001 }
9581002 return dst
9591003 }
9601004
1005+// carouselSlot is the place of stash card i in a carousel of n cards
1006+// centred on card at: 0 for at, negative on the left (above), positive on
1007+// the right (below). Wrapping round, the left side takes the extra card of
1008+// an even count; else the cards keep the stash order.
1009+func carouselSlot(i, at, n int, wraps bool) int {
1010+ if !wraps {
1011+ return i - at
1012+ }
1013+ s := (i - at + n) % n
1014+ if s > (n-1)/2 {
1015+ s -= n
1016+ }
1017+ return s
1018+}
1019+
1020+// unfoldedCard appends stash card i of an unfolded pile at r to dst: framed
1021+// when it is the selected one at, dimmed otherwise, hidden out of the area u.
1022+func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement {
1023+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
1024+ if i != at {
1025+ p.peeking(1)
1026+ }
1027+ if !r.Overlaps(u) {
1028+ p.Hidden, p.Preview, p.Focused = true, 0, false
1029+ }
1030+ return append(dst, p)
1031+}
1032+
9611033 // overviewZoom is the scale that fits every column of w in the usable
9621034 // width, within the overview bounds, in an overview along axis: only a
9631035 // vertical one sets the stash pile beside the row.
@@ -57,8 +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 into a strip of every card (unfold), the60+// unfolds along the overview axis into a carousel centred on the selection
61-// selection framed, and the moves along that axis walk it. A vertical61+// (unfold), framed, and the moves along that axis walk it round. A vertical
62 // 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
63 // 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
64 // reserved under the selected lane (pileBand): the lane and the band are64 // reserved under the selected lane (pileBand): the lane and the band are
@@ -402,11 +402,12 @@ func (m *Monitor) OverviewMove(dx, dy int) {
402 }402 }
403 403
404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview404 // overviewWorkspaceStep is a focus-workspace bind. A vertical overview
405-// walks the unfolded stash and the stack cards before changing workspace405+// walks the stack cards before changing workspace (overviewVertical); from
406-// (overviewVertical). A side-by-side one always changes workspace, since406+// a selected stash card, which the move keys walk, it changes workspace
407-// its stack and unfolded stash run left and right.407+// directly. A side-by-side one always changes workspace, since its stack
408+// and unfolded stash run left and right.
408 func (m *Monitor) overviewWorkspaceStep(d int) {409 func (m *Monitor) overviewWorkspaceStep(d int) {
409- if m.overviewAxis() == verticalAxis {410+ if m.overviewAxis() == verticalAxis && m.cardAt(m.Current()) < 0 {
410 m.overviewVertical(d)411 m.overviewVertical(d)
411 return412 return
412 }413 }
@@ -414,17 +415,43 @@ func (m *Monitor) overviewWorkspaceStep(d int) {
414 }415 }
415 416
416 // cycleCard selects the stash card d entries from the selected one of w in417 // cycleCard selects the stash card d entries from the selected one of w in
417-// the unfolded pile, stopping at either end. It reports whether a card was418+// the unfolded pile, wrapping round (wrapsCards), else staying at either
418-// selected.419+// end. It reports whether a card was selected.
419 func (m *Monitor) cycleCard(w *Workspace, d int) bool {420 func (m *Monitor) cycleCard(w *Workspace, d int) bool {
420 at := m.cardAt(w)421 at := m.cardAt(w)
421 if at < 0 {422 if at < 0 {
422 return false423 return false
423 }424 }
424- m.selectCard(w, min(max(at+d, 0), len(w.Stash)-1))425+ m.stepCard(w, at, d)
425 return true426 return true
426 }427 }
427 428
429+// stepCard selects the stash card d entries from card at of w, wrapping
430+// round when the carousel does (wrapsCards). It reports false, selecting
431+// nothing, past an end of one that does not.
432+func (m *Monitor) stepCard(w *Workspace, at, d int) bool {
433+ n, next := len(w.Stash), at+d
434+ if next < 0 || next >= n {
435+ if !w.wrapsCards() {
436+ return false
437+ }
438+ next = (next%n + n) % n
439+ }
440+ m.selectCard(w, next)
441+ return true
442+}
443+
444+// wrapsCards reports whether the unfolded stash of w wraps round, drawn
445+// (unfold) and walked (stepCard). A carousel of fewer than three cards does
446+// not, so that each side has the card its move selects; nor does the one
447+// of a stash-only workspace, which has nothing beside its pile to leave it
448+// for. Past the ends of one that does not, a move goes on as from a lane
449+// (cascade) or stops (a vertical overview).
450+func (w *Workspace) wrapsCards() bool {
451+ _, k := w.stackHead()
452+ return k > 0 && len(w.Stash) >= 3
453+}
454+
428 // overviewLane is the vertical move of an overview whose workspaces sit side455 // overviewLane is the vertical move of an overview whose workspaces sit side
429 // by side (cascade). From the unfolded stash, up leaves it for the last lane456 // by side (cascade). From the unfolded stash, up leaves it for the last lane
430 // (leavePile) and down stays: the stash is the bottom. Else the selection457 // (leavePile) and down stays: the stash is the bottom. Else the selection
@@ -480,18 +507,16 @@ func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
480 507
481 // overviewAcross is the horizontal move of an overview whose workspaces sit508 // 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 of509 // 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 previous510+// the unfolded stash, which wraps round (stepCard). Past the first column
484-// workspace; past the last, to the next workspace. Covering floats fan511+// or a card that does not wrap, it goes to the previous workspace; past
485-// along this axis and are traversed before a workspace: the card on the512+// the last, to the next workspace. Covering floats fan along this axis and
486-// left is the one behind.513+// are traversed before a workspace: the card on the left is the one behind.
487 func (m *Monitor) overviewAcross(dx int) {514 func (m *Monitor) overviewAcross(dx int) {
488 w := m.Current()515 w := m.Current()
489 if at := m.cardAt(w); at >= 0 {516 if at := m.cardAt(w); at >= 0 {
490- if next := at + dx; next >= 0 && next < len(w.Stash) {517+ if !m.stepCard(w, at, dx) {
491- m.selectCard(w, next)518+ m.overviewStep(dx)
492- return
493 }519 }
494- m.overviewStep(dx)
495 return520 return
496 }521 }
497 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {522 if m.stackFront(w).kind != stackColumns || len(w.Columns) == 0 {
@@ -529,12 +554,17 @@ func (m *Monitor) overviewVertical(dy int) {
529 }554 }
530 555
531 // overviewStep shows the workspace d rows from the current one; it stays556 // overviewStep shows the workspace d rows from the current one; it stays
532-// on the first or last row.557+// on the first or last row. The row selects its stack even when its stash
558+// has the focus, if the stash wraps round (wrapsCards): the moves that
559+// stepped there would only walk the carousel, never reaching the next row.
533 func (m *Monitor) overviewStep(d int) {560 func (m *Monitor) overviewStep(d int) {
534 rows := m.overviewWorkspaces()561 rows := m.overviewWorkspaces()
535 i := indexOf(rows, m.Current()) + d562 i := indexOf(rows, m.Current()) + d
536 if i >= 0 && i < len(rows) {563 if i >= 0 && i < len(rows) {
537 m.showOverview(rows[i])564 m.showOverview(rows[i])
565+ if w := m.Current(); m.cardAt(w) >= 0 && w.wrapsCards() {
566+ m.clearCard()
567+ }
538 }568 }
539 }569 }
540 570
@@ -916,48 +946,90 @@ func (w *Workspace) pile(dst []Placement, axis layoutAxis, slot Rect, dim bool,
916 return dst946 return dst
917 }947 }
918 948
919-// unfold lays the stash of w out as a strip of every card along axis, from949+// unfold lays the stash of w out from the folded pile's card rect as a
920-// the folded pile's card rect: a row in a horizontal overview, a column in950+// carousel along axis: a row in a horizontal overview, a column in a
921-// a vertical one. The strip is centred on the pile and kept in the overview951+// vertical one. The selected card at sits on the pile, in the middle; the
922-// area. Longer than the area, it scrolls like a wide row: the selected card952+// cards before it come on the left (above), the ones after it on the right
923-// at is centred, the strip stopping at the area's edges. The other cards953+// (below), in stash order, wrapping round (wrapsCards) with the oldest
924-// are dimmed; those out of the area are hidden. The cards are appended to954+// following the newest (carouselSlot). A wider gap marks that seam. A
925-// dst.955+// carousel that fits the overview area is shifted into it. Longer, one
956+// that wraps keeps the selection in the middle; one that does not stops at
957+// the area's edges, like a wide row. The cards past the area are hidden,
958+// the ones other than at dimmed. The cards are appended to dst.
926 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {959 func (w *Workspace) unfold(dst []Placement, axis layoutAxis, folded Rect, at int) []Placement {
927 n := len(w.Stash)960 n := len(w.Stash)
928 u := w.overviewArea()961 u := w.overviewArea()
929- size, origin, lo, hi := folded.W, folded.X, u.X, u.X+u.W962+ size, origin, lo, room := folded.W, folded.X, u.X, u.W
930 if axis == verticalAxis {963 if axis == verticalAxis {
931- size, origin, lo, hi = folded.H, folded.Y, u.Y, u.Y+u.H964+ size, origin, lo, room = folded.H, folded.Y, u.Y, u.H
932 }965 }
933 stride := size + 2*w.cardStep()966 stride := size + 2*w.cardStep()
934- total := n*stride - (stride - size)967+ // The extra room at the seam, on top of the stride.
935- start := origin + size/2 - total/2968+ gap := size / 4
936- if total > hi-lo {969+ wraps := w.wrapsCards()
937- start = origin + size/2 - at*stride - size/2970+ pos := func(s int) int {
938- start = min(max(start, hi-total), lo)971+ p := origin + s*stride
939- } else {972+ switch {
940- start = min(max(start, lo), hi-total)973+ case s > 0 && at+s >= n:
974+ p += gap
975+ case s < 0 && at+s < 0:
976+ p -= gap
977+ }
978+ return p
979+ }
980+ // The slots of the ends.
981+ from, to := -at, n-1-at
982+ if wraps {
983+ from, to = -(n / 2), (n-1)/2
984+ }
985+ first, total := pos(from), pos(to)+size-pos(from)
986+ var shift int
987+ switch {
988+ case total <= room:
989+ shift = min(max(first, lo), lo+room-total) - first
990+ case !wraps:
991+ shift = min(max(first, lo+room-total), lo) - first
941 }992 }
942 for i := range w.Stash {993 for i := range w.Stash {
943 r := folded994 r := folded
944- if axis == verticalAxis {995+ if p := pos(carouselSlot(i, at, n, wraps)) + shift; axis == verticalAxis {
945- r.Y = start + i*stride996+ r.Y = p
946 } else {997 } else {
947- r.X = start + i*stride998+ r.X = p
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 }999 }
953- if !r.Overlaps(u) {1000+ dst = w.unfoldedCard(dst, i, r, at, u)
954- p.Hidden, p.Preview, p.Focused = true, 0, false
955- }
956- dst = append(dst, p)
957 }1001 }
958 return dst1002 return dst
959 }1003 }
960 1004
1005+// carouselSlot is the place of stash card i in a carousel of n cards
1006+// centred on card at: 0 for at, negative on the left (above), positive on
1007+// the right (below). Wrapping round, the left side takes the extra card of
1008+// an even count; else the cards keep the stash order.
1009+func carouselSlot(i, at, n int, wraps bool) int {
1010+ if !wraps {
1011+ return i - at
1012+ }
1013+ s := (i - at + n) % n
1014+ if s > (n-1)/2 {
1015+ s -= n
1016+ }
1017+ return s
1018+}
1019+
1020+// unfoldedCard appends stash card i of an unfolded pile at r to dst: framed
1021+// when it is the selected one at, dimmed otherwise, hidden out of the area u.
1022+func (w *Workspace) unfoldedCard(dst []Placement, i int, r Rect, at int, u Rect) []Placement {
1023+ p := Placement{ID: w.Stash[i].ID, Rect: r, Preview: overviewCardZoom, Card: true, Focused: i == at}
1024+ if i != at {
1025+ p.peeking(1)
1026+ }
1027+ if !r.Overlaps(u) {
1028+ p.Hidden, p.Preview, p.Focused = true, 0, false
1029+ }
1030+ return append(dst, p)
1031+}
1032+
961 // overviewZoom is the scale that fits every column of w in the usable1033 // 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 a1034 // width, within the overview bounds, in an overview along axis: only a
963 // vertical one sets the stash pile beside the row.1035 // 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_stash_input_test.go +32 -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,31 @@ 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, in its direction, wrapping round or not: the move
78+// keys walk the cards.
79+func TestOverviewWorkspaceBindLeavesCarousel(t *testing.T) {
80+ for _, n := range []int{2, 3} {
81+ m := overviewMonitor()
82+ w := m.Current()
83+ for id := WindowID(5); id < WindowID(5+n); id++ {
84+ w.AddWindow(id)
85+ w.FocusID(id)
86+ w.ToggleWindowStash()
87+ }
88+ w.FocusID(1)
89+ m.Focus(1)
90+ m.AddWindow(30)
91+ m.Focus(0)
92+ m.ToggleOverview()
93+ m.OverviewMove(-1, 0)
94+ if m.overviewAxis() != verticalAxis || m.cardAt(w) < 0 {
95+ t.Fatalf("%d cards, setup: no card selected in a vertical overview", n)
96+ }
97+ m.overviewFocus(ActionFocusWorkspaceNext)
98+ if m.Current() != m.Workspaces[1] || m.cardAt(w) >= 0 {
99+ t.Fatalf("%d cards: focus-workspace-next went to workspace %d, card %d", n, indexOf(m.Workspaces, m.Current()), m.cardAt(w))
100+ }
101+ }
102+}
@@ -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,31 @@ 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, in its direction, wrapping round or not: the move
78+// keys walk the cards.
79+func TestOverviewWorkspaceBindLeavesCarousel(t *testing.T) {
80+ for _, n := range []int{2, 3} {
81+ m := overviewMonitor()
82+ w := m.Current()
83+ for id := WindowID(5); id < WindowID(5+n); id++ {
84+ w.AddWindow(id)
85+ w.FocusID(id)
86+ w.ToggleWindowStash()
87+ }
88+ w.FocusID(1)
89+ m.Focus(1)
90+ m.AddWindow(30)
91+ m.Focus(0)
92+ m.ToggleOverview()
93+ m.OverviewMove(-1, 0)
94+ if m.overviewAxis() != verticalAxis || m.cardAt(w) < 0 {
95+ t.Fatalf("%d cards, setup: no card selected in a vertical overview", n)
96+ }
97+ m.overviewFocus(ActionFocusWorkspaceNext)
98+ if m.Current() != m.Workspaces[1] || m.cardAt(w) >= 0 {
99+ t.Fatalf("%d cards: focus-workspace-next went to workspace %d, card %d", n, indexOf(m.Workspaces, m.Current()), m.cardAt(w))
100+ }
101+ }
102+}
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 +241 -65
@@ -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,11 +144,10 @@ 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.
104-func TestOverviewStashUnfoldScrolls(t *testing.T) {
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) {
105151 for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
106152 t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
107153 m := cascadeOverview()
@@ -114,47 +160,41 @@ func TestOverviewStashUnfoldScrolls(t *testing.T) {
114160 if m.overviewAxis() != axis {
115161 t.Fatal("setup: wrong overview axis")
116162 }
117- m.selectCard(w, 0)
163+ m.selectCard(w, 11)
118164 u := w.overviewArea()
119- pos := func(r Rect) (int, int, int, int) {
165+ pos := func(r Rect) (int, int) {
120166 if axis == verticalAxis {
121- return r.Y, r.H, u.Y, u.Y + u.H
167+ return r.Y, r.Y + r.H/2 - (u.Y + u.H/2)
122168 }
123- return r.X, r.W, u.X, u.X + u.W
169+ return r.X, r.X + r.W/2 - (u.X + u.W/2)
124170 }
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- }
171+ for step := range len(w.Stash) + 1 {
172+ i := m.cardAt(w)
130173 ps := m.Layout()
131174 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)
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)
145178 }
179+ before, after, hidden := false, false, false
146180 for _, e := range w.Stash {
147- hidden = hidden || previewOf(t, ps, e.ID).Hidden
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)
148191 }
149192 if axis == verticalAxis {
150193 m.OverviewMove(0, 1)
151- } else if i < len(w.Stash)-1 {
194+ } else {
152195 m.OverviewMove(1, 0)
153196 }
154197 }
155- if !hidden || !centred {
156- t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
157- }
158198 })
159199 }
160200 }
@@ -183,8 +223,10 @@ func TestOverviewStashLayoutAllocations(t *testing.T) {
183223 }
184224 }
185225
186-// A scroll overview unfolds its pile into a column on the left of the row;
187-// up and down walk it, right goes back to the columns.
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.
188230 func TestOverviewScrollStashUnfolds(t *testing.T) {
189231 m := overviewMonitor()
190232 w := m.Current()
@@ -199,29 +241,163 @@ func TestOverviewScrollStashUnfolds(t *testing.T) {
199241 t.Fatal("setup: not a vertical overview")
200242 }
201243 m.OverviewMove(-1, 0)
202- if m.cardAt(w) < 0 {
244+ at := m.cardAt(w)
245+ if at < 0 {
203246 t.Fatal("left from the first column did not select the stash")
204247 }
205248 cards := shownCards(t, w, m.Layout())
206249 if len(cards) != 3 {
207250 t.Fatalf("%d cards shown, want 3", len(cards))
208251 }
209- for i := 1; i < len(cards); i++ {
210- if cards[i].Rect.X != cards[0].Rect.X || cards[i].Rect.Y < cards[i-1].Rect.Y+cards[i-1].Rect.H {
211- t.Fatalf("cards not stacked down: %v then %v", cards[i-1].Rect, cards[i].Rect)
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)
212256 }
213257 }
214- m.OverviewMove(0, -1)
215- m.OverviewMove(0, -1)
216- if m.cardAt(w) != 0 {
217- t.Fatalf("up: card %d, want 0", m.cardAt(w))
258+ if !col[1].Focused {
259+ t.Fatal("the selection is not in the middle")
218260 }
219261 m.OverviewMove(0, 1)
220- if m.cardAt(w) != 1 {
221- 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)
222269 }
223270 m.OverviewMove(1, 0)
224271 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
225272 t.Fatalf("right: card %d, target %d", m.cardAt(w), m.overviewTarget())
226273 }
227274 }
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+}
331+
332+// A carousel that does not wrap round and is longer than the area stops at
333+// its edges, like a wide row: the first card at the start of the area, the
334+// selection inside it.
335+func TestOverviewStashOnlyCarouselScrolls(t *testing.T) {
336+ m := overviewMonitor()
337+ m.Focus(1)
338+ w := m.Current()
339+ w.Overflow = OverflowCascade
340+ w.ToggleWindowStash() // its own window
341+ for id := WindowID(40); id < 52; id++ {
342+ w.AddWindow(id)
343+ w.FocusID(id)
344+ w.ToggleWindowStash()
345+ }
346+ m.ToggleOverview()
347+ if w.wrapsCards() || m.cardAt(w) < 0 {
348+ t.Fatalf("setup: wraps %v, card %d", w.wrapsCards(), m.cardAt(w))
349+ }
350+ u := w.overviewArea()
351+ m.selectCard(w, 0)
352+ ps := m.Layout()
353+ if r := previewOf(t, ps, w.Stash[0].ID).Rect; r.X != u.X {
354+ t.Fatalf("first card at %d, area starts at %d", r.X, u.X)
355+ }
356+ m.selectCard(w, len(w.Stash)-1)
357+ ps = m.Layout()
358+ if r := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect; r.X+r.W != u.X+u.W {
359+ t.Fatalf("last card ends at %d, area at %d", r.X+r.W, u.X+u.W)
360+ }
361+}
362+
363+// Stepping to a workspace whose stash has the focus selects its stack when
364+// the carousel wraps round, so the next step goes on to the next workspace;
365+// a carousel that does not wrap is still selected.
366+func TestOverviewStepSkipsWrappingCarousel(t *testing.T) {
367+ for _, overflow := range []Overflow{OverflowCascade, OverflowScroll} {
368+ for _, n := range []int{2, 3} {
369+ m := overviewMonitor()
370+ m.Focus(2)
371+ m.AddWindow(30)
372+ m.Focus(1)
373+ w := m.Current()
374+ for id := WindowID(40); id < WindowID(40+n); id++ {
375+ w.AddWindow(id)
376+ w.FocusID(id)
377+ w.ToggleWindowStash()
378+ }
379+ for _, ws := range m.Workspaces[:3] {
380+ ws.Overflow = overflow
381+ }
382+ m.Focus(0)
383+ if !w.stashFocused() {
384+ t.Fatal("setup: the stash does not have the focus")
385+ }
386+ m.ToggleOverview()
387+ m.overviewFocus(ActionFocusWorkspaceNext)
388+ if m.Current() != w {
389+ t.Fatalf("%s, %d cards: on workspace %d", overflow, n, indexOf(m.Workspaces, m.Current()))
390+ }
391+ if got, want := m.cardAt(w) >= 0, n < 3; got != want {
392+ t.Fatalf("%s, %d cards: card selected %v, want %v", overflow, n, got, want)
393+ }
394+ if n < 3 {
395+ continue
396+ }
397+ m.overviewFocus(ActionFocusWorkspaceNext)
398+ if m.Current() != m.Workspaces[2] {
399+ t.Fatalf("%s: the next step stayed on workspace %d", overflow, indexOf(m.Workspaces, m.Current()))
400+ }
401+ }
402+ }
403+}
@@ -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,11 +144,10 @@ 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 carousel longer than the area keeps every selection centred in it,
101-// every card is reached in order, the selection stays inside the area and148+// with cards on both sides, on either axis; the cards past the area are
102-// is centred unless the strip stops at an edge of it, and cards past the149+// hidden.
103-// area are hidden.150+func TestOverviewCarouselScrolls(t *testing.T) {
104-func TestOverviewStashUnfoldScrolls(t *testing.T) {
105 for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {151 for _, axis := range []layoutAxis{horizontalAxis, verticalAxis} {
106 t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {152 t.Run(map[layoutAxis]string{horizontalAxis: "cascade", verticalAxis: "scroll"}[axis], func(t *testing.T) {
107 m := cascadeOverview()153 m := cascadeOverview()
@@ -114,47 +160,41 @@ func TestOverviewStashUnfoldScrolls(t *testing.T) {
114 if m.overviewAxis() != axis {160 if m.overviewAxis() != axis {
115 t.Fatal("setup: wrong overview axis")161 t.Fatal("setup: wrong overview axis")
116 }162 }
117- m.selectCard(w, 0)163+ m.selectCard(w, 11)
118 u := w.overviewArea()164 u := w.overviewArea()
119- pos := func(r Rect) (int, int, int, int) {165+ pos := func(r Rect) (int, int) {
120 if axis == verticalAxis {166 if axis == verticalAxis {
121- return r.Y, r.H, u.Y, u.Y + u.H167+ return r.Y, r.Y + r.H/2 - (u.Y + u.H/2)
122 }168 }
123- return r.X, r.W, u.X, u.X + u.W169+ return r.X, r.X + r.W/2 - (u.X + u.W/2)
124 }170 }
125- centred, hidden := false, false171+ for step := range len(w.Stash) + 1 {
126- for i := range len(w.Stash) {172+ i := m.cardAt(w)
127- if m.cardAt(w) != i {
128- t.Fatalf("step %d: card %d", i, m.cardAt(w))
129- }
130 ps := m.Layout()173 ps := m.Layout()
131 sel := previewOf(t, ps, w.Stash[i].ID)174 sel := previewOf(t, ps, w.Stash[i].ID)
132- at, size, lo, hi := pos(sel.Rect)175+ at, off := pos(sel.Rect)
133- if sel.Hidden || !sel.Focused || at < lo || at+size > hi {176+ if sel.Hidden || !sel.Focused || abs(off) > 1 {
134- t.Fatalf("card %d off the area: %+v in %v", i, sel, u)177+ t.Fatalf("step %d: card %d not centred: %+v in %v", step, 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 }178 }
179+ before, after, hidden := false, false, false
146 for _, e := range w.Stash {180 for _, e := range w.Stash {
147- hidden = hidden || previewOf(t, ps, e.ID).Hidden181+ 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)
148 }191 }
149 if axis == verticalAxis {192 if axis == verticalAxis {
150 m.OverviewMove(0, 1)193 m.OverviewMove(0, 1)
151- } else if i < len(w.Stash)-1 {194+ } else {
152 m.OverviewMove(1, 0)195 m.OverviewMove(1, 0)
153 }196 }
154 }197 }
155- if !hidden || !centred {
156- t.Fatalf("12 cards: hidden %v, centred %v; the test does not scroll", hidden, centred)
157- }
158 })198 })
159 }199 }
160 }200 }
@@ -183,8 +223,10 @@ func TestOverviewStashLayoutAllocations(t *testing.T) {
183 }223 }
184 }224 }
185 225
186-// 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
187-// 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.
188 func TestOverviewScrollStashUnfolds(t *testing.T) {230 func TestOverviewScrollStashUnfolds(t *testing.T) {
189 m := overviewMonitor()231 m := overviewMonitor()
190 w := m.Current()232 w := m.Current()
@@ -199,29 +241,163 @@ func TestOverviewScrollStashUnfolds(t *testing.T) {
199 t.Fatal("setup: not a vertical overview")241 t.Fatal("setup: not a vertical overview")
200 }242 }
201 m.OverviewMove(-1, 0)243 m.OverviewMove(-1, 0)
202- if m.cardAt(w) < 0 {244+ at := m.cardAt(w)
245+ if at < 0 {
203 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")
204 }247 }
205 cards := shownCards(t, w, m.Layout())248 cards := shownCards(t, w, m.Layout())
206 if len(cards) != 3 {249 if len(cards) != 3 {
207 t.Fatalf("%d cards shown, want 3", len(cards))250 t.Fatalf("%d cards shown, want 3", len(cards))
208 }251 }
209- for i := 1; i < len(cards); i++ {252+ col := []Placement{cards[(at+2)%3], cards[at], cards[(at+1)%3]}
210- 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++ {
211- 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)
212 }256 }
213 }257 }
214- m.OverviewMove(0, -1)258+ if !col[1].Focused {
215- m.OverviewMove(0, -1)259+ t.Fatal("the selection is not in the middle")
216- if m.cardAt(w) != 0 {
217- t.Fatalf("up: card %d, want 0", m.cardAt(w))
218 }260 }
219 m.OverviewMove(0, 1)261 m.OverviewMove(0, 1)
220- if m.cardAt(w) != 1 {262+ if want := (at + 1) % 3; m.cardAt(w) != want {
221- 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)
222 }269 }
223 m.OverviewMove(1, 0)270 m.OverviewMove(1, 0)
224 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {271 if m.cardAt(w) >= 0 || m.overviewTarget() == 0 {
225 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())
226 }273 }
227 }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+}
331+
332+// A carousel that does not wrap round and is longer than the area stops at
333+// its edges, like a wide row: the first card at the start of the area, the
334+// selection inside it.
335+func TestOverviewStashOnlyCarouselScrolls(t *testing.T) {
336+ m := overviewMonitor()
337+ m.Focus(1)
338+ w := m.Current()
339+ w.Overflow = OverflowCascade
340+ w.ToggleWindowStash() // its own window
341+ for id := WindowID(40); id < 52; id++ {
342+ w.AddWindow(id)
343+ w.FocusID(id)
344+ w.ToggleWindowStash()
345+ }
346+ m.ToggleOverview()
347+ if w.wrapsCards() || m.cardAt(w) < 0 {
348+ t.Fatalf("setup: wraps %v, card %d", w.wrapsCards(), m.cardAt(w))
349+ }
350+ u := w.overviewArea()
351+ m.selectCard(w, 0)
352+ ps := m.Layout()
353+ if r := previewOf(t, ps, w.Stash[0].ID).Rect; r.X != u.X {
354+ t.Fatalf("first card at %d, area starts at %d", r.X, u.X)
355+ }
356+ m.selectCard(w, len(w.Stash)-1)
357+ ps = m.Layout()
358+ if r := previewOf(t, ps, w.Stash[len(w.Stash)-1].ID).Rect; r.X+r.W != u.X+u.W {
359+ t.Fatalf("last card ends at %d, area at %d", r.X+r.W, u.X+u.W)
360+ }
361+}
362+
363+// Stepping to a workspace whose stash has the focus selects its stack when
364+// the carousel wraps round, so the next step goes on to the next workspace;
365+// a carousel that does not wrap is still selected.
366+func TestOverviewStepSkipsWrappingCarousel(t *testing.T) {
367+ for _, overflow := range []Overflow{OverflowCascade, OverflowScroll} {
368+ for _, n := range []int{2, 3} {
369+ m := overviewMonitor()
370+ m.Focus(2)
371+ m.AddWindow(30)
372+ m.Focus(1)
373+ w := m.Current()
374+ for id := WindowID(40); id < WindowID(40+n); id++ {
375+ w.AddWindow(id)
376+ w.FocusID(id)
377+ w.ToggleWindowStash()
378+ }
379+ for _, ws := range m.Workspaces[:3] {
380+ ws.Overflow = overflow
381+ }
382+ m.Focus(0)
383+ if !w.stashFocused() {
384+ t.Fatal("setup: the stash does not have the focus")
385+ }
386+ m.ToggleOverview()
387+ m.overviewFocus(ActionFocusWorkspaceNext)
388+ if m.Current() != w {
389+ t.Fatalf("%s, %d cards: on workspace %d", overflow, n, indexOf(m.Workspaces, m.Current()))
390+ }
391+ if got, want := m.cardAt(w) >= 0, n < 3; got != want {
392+ t.Fatalf("%s, %d cards: card selected %v, want %v", overflow, n, got, want)
393+ }
394+ if n < 3 {
395+ continue
396+ }
397+ m.overviewFocus(ActionFocusWorkspaceNext)
398+ if m.Current() != m.Workspaces[2] {
399+ t.Fatalf("%s: the next step stayed on workspace %d", overflow, indexOf(m.Workspaces, m.Current()))
400+ }
401+ }
402+ }
403+}
M internal/core/transition.go +37 -0
@@ -45,6 +45,11 @@ type viewShot struct {
4545 stashShown bool
4646 stashAt, stashLen int
4747 stashPos, stashV float64
48+ // card is the selected card of the unfolded stash carousel in the
49+ // overview (Workspace.unfold), -1 without one; wraps is whether that
50+ // carousel wrapped round (Workspace.wrapsCards).
51+ card int
52+ wraps bool
4853 // list and pos are the numbered workspaces and the monitor's fractional
4954 // position in them (index of the current one plus switchView.off); ok is
5055 // false when there is none (a shown stash workspace).
@@ -637,6 +642,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
637642 _, s.switchV = m.switchView.motion.sampleAt(now)
638643 }
639644 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off
645+ s.card, s.wraps = m.cardAt(w), w.wrapsCards()
640646 if w.stashView.motion.on {
641647 _, s.stashV = w.stashView.motion.sampleAt(now)
642648 }
@@ -903,12 +909,23 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
903909 if changed {
904910 sc.stopRects()
905911 }
912+ at := -1
913+ if !changed {
914+ at = carouselStep(b)
915+ }
916+ w := sc.mon.Current()
917+ n := len(w.Stash)
906918 for _, p := range sc.mon.Layout() {
907919 if p.Hidden {
908920 continue
909921 }
910922 rm := sc.rects[p.ID]
911923 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
924+ if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, n, b.wraps) {
925+ // The card went round the carousel to its other end: it
926+ // fades in there instead of crossing the row.
927+ rm, i = rectMotion{}, -1
928+ }
912929 if i >= 0 && changed {
913930 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
914931 }
@@ -934,6 +951,26 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
934951 }
935952 }
936953
954+// carouselStep is the selected card of the stash carousel b's screen shows
955+// now, when it showed the same carousel in b too (the same workspace, stash
956+// size and wrapping); else -1.
957+func carouselStep(b *viewShot) int {
958+ m := b.sc.mon
959+ w := m.Current()
960+ if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen || w.wrapsCards() != b.wraps {
961+ return -1
962+ }
963+ return m.cardAt(w)
964+}
965+
966+// wrapped reports whether stash card i of a carousel of n cards went round
967+// it, from one end to the other, when the selection went from card from to
968+// card to: it moved other than the cards that stayed on their side, which
969+// all shift by the old selection's move (carouselSlot).
970+func wrapped(i, from, to, n int, wraps bool) bool {
971+ return i >= 0 && carouselSlot(i, to, n, wraps)-carouselSlot(i, from, n, wraps) != carouselSlot(from, to, n, wraps)
972+}
973+
937974 // movedFrom is the shot of the other screen that showed the workspace b's
938975 // screen shows now (moved to it by an action), or nil.
939976 func movedFrom(b *viewShot, before []viewShot) *viewShot {
@@ -45,6 +45,11 @@ 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 the unfolded stash carousel in the
49+ // overview (Workspace.unfold), -1 without one; wraps is whether that
50+ // carousel wrapped round (Workspace.wrapsCards).
51+ card int
52+ wraps bool
48 // list and pos are the numbered workspaces and the monitor's fractional53 // 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 is54 // position in them (index of the current one plus switchView.off); ok is
50 // false when there is none (a shown stash workspace).55 // false when there is none (a shown stash workspace).
@@ -637,6 +642,7 @@ func (c *Core) snapshot(now time.Time) []viewShot {
637 _, s.switchV = m.switchView.motion.sampleAt(now)642 _, s.switchV = m.switchView.motion.sampleAt(now)
638 }643 }
639 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off644 s.stashShown, s.stashAt, s.stashLen, s.stashPos = w.stashShown(), w.stashAt, len(w.Stash), float64(w.stashAt)+w.stashView.off
645+ s.card, s.wraps = m.cardAt(w), w.wrapsCards()
640 if w.stashView.motion.on {646 if w.stashView.motion.on {
641 _, s.stashV = w.stashView.motion.sampleAt(now)647 _, s.stashV = w.stashView.motion.sampleAt(now)
642 }648 }
@@ -903,12 +909,23 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
903 if changed {909 if changed {
904 sc.stopRects()910 sc.stopRects()
905 }911 }
912+ at := -1
913+ if !changed {
914+ at = carouselStep(b)
915+ }
916+ w := sc.mon.Current()
917+ n := len(w.Stash)
906 for _, p := range sc.mon.Layout() {918 for _, p := range sc.mon.Layout() {
907 if p.Hidden {919 if p.Hidden {
908 continue920 continue
909 }921 }
910 rm := sc.rects[p.ID]922 rm := sc.rects[p.ID]
911 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })923 i := slices.IndexFunc(b.rects, func(r rectShot) bool { return r.id == p.ID })
924+ if i >= 0 && at >= 0 && wrapped(w.stashIndex(p.ID), b.card, at, n, b.wraps) {
925+ // The card went round the carousel to its other end: it
926+ // fades in there instead of crossing the row.
927+ rm, i = rectMotion{}, -1
928+ }
912 if i >= 0 && changed {929 if i >= 0 && changed {
913 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)930 c.retargetLevel(&rm.fade, &rm.df, b.rects[i].fade, b.rects[i].fadeV, now)
914 }931 }
@@ -934,6 +951,26 @@ func (c *Core) transitionOverview(b *viewShot, now time.Time) {
934 }951 }
935 }952 }
936 953
954+// carouselStep is the selected card of the stash carousel b's screen shows
955+// now, when it showed the same carousel in b too (the same workspace, stash
956+// size and wrapping); else -1.
957+func carouselStep(b *viewShot) int {
958+ m := b.sc.mon
959+ w := m.Current()
960+ if b.card < 0 || w != b.ws || len(w.Stash) != b.stashLen || w.wrapsCards() != b.wraps {
961+ return -1
962+ }
963+ return m.cardAt(w)
964+}
965+
966+// wrapped reports whether stash card i of a carousel of n cards went round
967+// it, from one end to the other, when the selection went from card from to
968+// card to: it moved other than the cards that stayed on their side, which
969+// all shift by the old selection's move (carouselSlot).
970+func wrapped(i, from, to, n int, wraps bool) bool {
971+ return i >= 0 && carouselSlot(i, to, n, wraps)-carouselSlot(i, from, n, wraps) != carouselSlot(from, to, n, wraps)
972+}
973+
937 // movedFrom is the shot of the other screen that showed the workspace b's974 // 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.975 // screen shows now (moved to it by an action), or nil.
939 func movedFrom(b *viewShot, before []viewShot) *viewShot {976 func movedFrom(b *viewShot, before []viewShot) *viewShot {
M internal/core/transition_stash_internal_test.go +55 -0
@@ -473,3 +473,58 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) {
473473 }
474474 }
475475 }
476+
477+// Walking the stash carousel slides its cards; a card that goes round to
478+// the other end fades in there instead of crossing the row, whatever the
479+// direction, the count or the size of the step (a fast wheel). A carousel
480+// that does not wrap round slides every card.
481+func TestCarouselWrapFadesIn(t *testing.T) {
482+ for _, tc := range []struct {
483+ name string
484+ n, from, to int
485+ wrapped []int
486+ }{
487+ {"right", 3, 0, 1, []int{2}},
488+ {"left", 3, 0, 2, []int{1}},
489+ {"even", 4, 3, 0, []int{1}},
490+ {"jump", 5, 0, 2, []int{3, 4}},
491+ {"two cards", 2, 0, 1, nil},
492+ } {
493+ t.Run(tc.name, func(t *testing.T) {
494+ c, ic, _ := fadeCore(t)
495+ c.cfg.Animations.On = true
496+ sc := c.cur()
497+ m := sc.mon
498+ w := m.Current()
499+ w.Overflow = OverflowCascade
500+ m.AddWindow(1)
501+ for id := WindowID(2); id < WindowID(2+tc.n); id++ {
502+ m.AddWindow(id)
503+ w.FocusID(id)
504+ c.applyAction(ActionToggleWindowStash)
505+ }
506+ w.FocusID(1)
507+ c.applyAction(ActionToggleOverview)
508+ m.selectCard(w, tc.from)
509+ settleShown(t, c)
510+ shots := c.snapshot(ic.now)
511+ m.selectCard(w, tc.to)
512+ c.transition(shots, ic.now)
513+ for i, e := range w.Stash {
514+ if previewOf(t, m.Layout(), e.ID).Hidden {
515+ continue
516+ }
517+ rm := sc.rects[e.ID]
518+ if slices.Contains(tc.wrapped, i) {
519+ if rm.dx != 0 || !rm.fade.on {
520+ t.Fatalf("wrapped card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
521+ }
522+ continue
523+ }
524+ if rm.dx == 0 || rm.fade.on {
525+ t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
526+ }
527+ }
528+ })
529+ }
530+}
@@ -473,3 +473,58 @@ func TestWithLeavingOrderIsDeterministic(t *testing.T) {
473 }473 }
474 }474 }
475 }475 }
476+
477+// Walking the stash carousel slides its cards; a card that goes round to
478+// the other end fades in there instead of crossing the row, whatever the
479+// direction, the count or the size of the step (a fast wheel). A carousel
480+// that does not wrap round slides every card.
481+func TestCarouselWrapFadesIn(t *testing.T) {
482+ for _, tc := range []struct {
483+ name string
484+ n, from, to int
485+ wrapped []int
486+ }{
487+ {"right", 3, 0, 1, []int{2}},
488+ {"left", 3, 0, 2, []int{1}},
489+ {"even", 4, 3, 0, []int{1}},
490+ {"jump", 5, 0, 2, []int{3, 4}},
491+ {"two cards", 2, 0, 1, nil},
492+ } {
493+ t.Run(tc.name, func(t *testing.T) {
494+ c, ic, _ := fadeCore(t)
495+ c.cfg.Animations.On = true
496+ sc := c.cur()
497+ m := sc.mon
498+ w := m.Current()
499+ w.Overflow = OverflowCascade
500+ m.AddWindow(1)
501+ for id := WindowID(2); id < WindowID(2+tc.n); id++ {
502+ m.AddWindow(id)
503+ w.FocusID(id)
504+ c.applyAction(ActionToggleWindowStash)
505+ }
506+ w.FocusID(1)
507+ c.applyAction(ActionToggleOverview)
508+ m.selectCard(w, tc.from)
509+ settleShown(t, c)
510+ shots := c.snapshot(ic.now)
511+ m.selectCard(w, tc.to)
512+ c.transition(shots, ic.now)
513+ for i, e := range w.Stash {
514+ if previewOf(t, m.Layout(), e.ID).Hidden {
515+ continue
516+ }
517+ rm := sc.rects[e.ID]
518+ if slices.Contains(tc.wrapped, i) {
519+ if rm.dx != 0 || !rm.fade.on {
520+ t.Fatalf("wrapped card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
521+ }
522+ continue
523+ }
524+ if rm.dx == 0 || rm.fade.on {
525+ t.Fatalf("card %d: dx %v, fade %v", i, rm.dx, rm.fade.on)
526+ }
527+ }
528+ })
529+ }
530+}

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