detached 4665d1c

fix(overview): leave the cascade stash pile upward

In a cascade overview, up from a selected stash card cycled the pile
and never returned to the lanes. Down now walks the pile and up leaves
it for the last lane, at the column it was entered from.
commit
4665d1c4cd7b8bd926c274ecc57e58d4ef3a26d4
author
brice · · 2026-10-15 04:09 UTC
committer
brice · · 2026-10-15 04:09 UTC
parent
6aca404
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +86 -24
M docs/config.md +2 -0
@@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
274274 ### Overview
275275
276276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277+
278+With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.
@@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that
274 ### Overview274 ### Overview
275 275
276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.276 `toggle-overview` (or a four-finger swipe up) shows previews of your workspaces. Arrows, `h/j/k/l` or three-finger swipes move the selection; `Return` or a click opens it; `Escape` goes back. `close-window` and the move-to-workspace binds work on the selected preview.
277+
278+With `layout.overflow = cascade`, workspaces sit side by side and each band of columns is a lane. Down (or fingers down on the touchpad, with natural scroll off) moves to the lane below; past the last lane it selects the stash pile under it, and down again brings the next stash card to the front. Up from a stash card leaves the pile for the last lane, at the column you came from. `focus-workspace-prev/next` also walk the pile in both directions, and left/right leave it.
M internal/core/overview.go +33 -6
@@ -423,14 +423,21 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423423 }
424424
425425 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade): a selected stash card cycles the pile; else the
427-// selection goes to the previous or next lane (band), at the closest column,
428-// stopping at the first. Down past the last lane, or from a card that is not
429-// the columns, enters the stash, which sits below. Covering floats stack
430-// along the horizontal axis, not this one.
426+// by side (cascade). From a selected stash card, down brings the next card
427+// of the pile to the front (stopping at the last) and up leaves the pile for
428+// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.
429+// Else the selection goes to the previous or next lane (band), at the
430+// closest column, stopping at the first. Down past the last lane, or from a
431+// card that is not the columns, enters the stash, which sits below. Covering
432+// floats stack along the horizontal axis, not this one.
431433 func (m *Monitor) overviewLane(dy int) {
432434 w := m.Current()
433- if m.cycleCard(w, dy) {
435+ if m.cardAt(w) >= 0 {
436+ if dy > 0 {
437+ m.cycleCard(w, dy)
438+ } else {
439+ m.leavePile(w)
440+ }
434441 return
435442 }
436443 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
@@ -444,6 +451,26 @@ func (m *Monitor) overviewLane(dy int) {
444451 }
445452 }
446453
454+// leavePile moves the selection from a stash card back up to where the pile
455+// sits below: the last lane, at the column the pile was entered from (the
456+// focused column's offset in its lane, or the lane's last column when it is
457+// shorter). With a covering float in front it only deselects the card; a
458+// stash-only workspace has nothing above the pile, so the card stays.
459+func (m *Monitor) leavePile(w *Workspace) {
460+ if len(w.stack()) == 0 {
461+ return
462+ }
463+ front := m.stackFront(w)
464+ m.clearCard()
465+ m.ov.selected = 0
466+ if front.kind != stackColumns || len(w.Columns) == 0 {
467+ return
468+ }
469+ first, _ := w.laneBounds(w.laneOf(w.Focus))
470+ nfirst, nlast := w.laneBounds(w.laneOf(len(w.Columns) - 1))
471+ m.selectLaneColumn(w, min(nfirst+w.Focus-first, nlast))
472+}
473+
447474 // selectLaneColumn selects column i of w.
448475 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
449476 w.selectOverviewColumn(i)
@@ -423,14 +423,21 @@ func (m *Monitor) cycleCard(w *Workspace, d int) bool {
423 }423 }
424 424
425 // overviewLane is the vertical move of an overview whose workspaces sit side425 // overviewLane is the vertical move of an overview whose workspaces sit side
426-// by side (cascade): a selected stash card cycles the pile; else the426+// by side (cascade). From a selected stash card, down brings the next card
427-// selection goes to the previous or next lane (band), at the closest column,427+// of the pile to the front (stopping at the last) and up leaves the pile for
428-// stopping at the first. Down past the last lane, or from a card that is not428+// the last lane (leavePile); focus-workspace-prev/next cycle it both ways.
429-// the columns, enters the stash, which sits below. Covering floats stack429+// Else the selection goes to the previous or next lane (band), at the
430-// along the horizontal axis, not this one.430+// closest column, stopping at the first. Down past the last lane, or from a
431+// card that is not the columns, enters the stash, which sits below. Covering
432+// floats stack along the horizontal axis, not this one.
431 func (m *Monitor) overviewLane(dy int) {433 func (m *Monitor) overviewLane(dy int) {
432 w := m.Current()434 w := m.Current()
433- if m.cycleCard(w, dy) {435+ if m.cardAt(w) >= 0 {
436+ if dy > 0 {
437+ m.cycleCard(w, dy)
438+ } else {
439+ m.leavePile(w)
440+ }
434 return441 return
435 }442 }
436 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {443 if m.stackFront(w).kind == stackColumns && len(w.Columns) > 0 {
@@ -444,6 +451,26 @@ func (m *Monitor) overviewLane(dy int) {
444 }451 }
445 }452 }
446 453
454+// leavePile moves the selection from a stash card back up to where the pile
455+// sits below: the last lane, at the column the pile was entered from (the
456+// focused column's offset in its lane, or the lane's last column when it is
457+// shorter). With a covering float in front it only deselects the card; a
458+// stash-only workspace has nothing above the pile, so the card stays.
459+func (m *Monitor) leavePile(w *Workspace) {
460+ if len(w.stack()) == 0 {
461+ return
462+ }
463+ front := m.stackFront(w)
464+ m.clearCard()
465+ m.ov.selected = 0
466+ if front.kind != stackColumns || len(w.Columns) == 0 {
467+ return
468+ }
469+ first, _ := w.laneBounds(w.laneOf(w.Focus))
470+ nfirst, nlast := w.laneBounds(w.laneOf(len(w.Columns) - 1))
471+ m.selectLaneColumn(w, min(nfirst+w.Focus-first, nlast))
472+}
473+
447 // selectLaneColumn selects column i of w.474 // selectLaneColumn selects column i of w.
448 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {475 func (m *Monitor) selectLaneColumn(w *Workspace, i int) {
449 w.selectOverviewColumn(i)476 w.selectOverviewColumn(i)
M internal/core/overview_cascade_test.go +51 -18
@@ -377,8 +377,9 @@ func TestOverviewCascadeKeys(t *testing.T) {
377377 }
378378 }
379379
380-// T7b: with a stash card selected, up/down and the three-finger vertical
381-// swipe cycle the pile as in a vertical overview; confirming shows the picked window.
380+// T7b: with a stash card selected in a cascade overview, down walks the pile
381+// (stopping at its last card) and up leaves it for the last band, at the
382+// column it was entered from; confirming shows the picked window.
382383 func TestOverviewCascadeStashPile(t *testing.T) {
383384 for _, d := range cascadeDrivers {
384385 t.Run(d.name, func(t *testing.T) {
@@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) {
392393 t.Fatalf("entered at %d", m.cardAt(w))
393394 }
394395 at := m.cardAt(w)
395- d.move(m, 0, -1)
396- if m.Current() != w || m.cardAt(w) != at-1 {
397- t.Fatalf("up: card %d, was %d", m.cardAt(w), at)
398- }
399- d.move(m, 0, 1)
400- d.move(m, 0, 1)
401- d.move(m, 0, 1)
402- if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
403- t.Fatalf("down: card %d", m.cardAt(w))
396+ for i := range len(w.Stash) {
397+ d.move(m, 0, 1)
398+ if want := min(at+i+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
399+ t.Fatalf("down %d: card %d, want %d", i, m.cardAt(w), want)
400+ }
404401 }
405- d.move(m, 0, -1)
406402 picked := w.Stash[m.cardAt(w)].ID
407403 m.ToggleOverview()
408404 if focusedID(m) != picked || w.stashHidden {
@@ -411,6 +407,31 @@ func TestOverviewCascadeStashPile(t *testing.T) {
411407 })
412408 }
413409
410+ // Up from any card, whichever it was cycled to, leaves the pile at once.
411+ for _, d := range cascadeDrivers {
412+ t.Run(d.name+"/up", func(t *testing.T) {
413+ m := cascadeOverview()
414+ withStash(m, 3)
415+ w := m.Current()
416+ w.FocusID(10)
417+ m.ToggleOverview()
418+ d.move(m, 0, 1)
419+ d.move(m, 0, 1)
420+ if m.cardAt(w) < 0 {
421+ t.Fatal("setup: no card selected")
422+ }
423+ d.move(m, 0, -1)
424+ if m.Current() != w || m.cardAt(w) >= 0 || focusedID(m) != 10 || m.ov.selected != 10 {
425+ t.Fatalf("up: card %d, focus %d, selected %d", m.cardAt(w), focusedID(m), m.ov.selected)
426+ }
427+ // The pile is entered again from there.
428+ d.move(m, 0, 1)
429+ if m.cardAt(w) < 0 {
430+ t.Fatal("down after leaving did not enter the stash")
431+ }
432+ })
433+ }
434+
414435 c, _, sc, _ := stashRig(t, true)
415436 m := sc.mon
416437 w := m.Current()
@@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) {
423444 t.Fatal("setup: no card selected")
424445 }
425446 at, before := m.cardAt(w), stashIDs(w)
426- c.swipeBegin(ports.SwipeBegin{Fingers: 3})
427- for range 3 {
428- c.swipeUpdate(ports.SwipeUpdate{DY: -40})
447+ swipeY := func(dy float64) {
448+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
449+ for range 3 {
450+ c.swipeUpdate(ports.SwipeUpdate{DY: dy})
451+ }
452+ c.swipeEnd(ports.SwipeEnd{})
429453 }
430- c.swipeEnd(ports.SwipeEnd{})
431- if m.Current() != w || m.cardAt(w) != at-1 {
432- t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)
454+ // Fingers down walk the pile; fingers up leave it for the columns.
455+ swipeY(40)
456+ if want := min(at+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
457+ t.Fatalf("swipe down: card %d, want %d", m.cardAt(w), want)
458+ }
459+ swipeY(-40)
460+ if m.Current() != w || m.cardAt(w) >= 0 || m.ov.selected == 0 {
461+ t.Fatalf("swipe up: card %d, selected %d", m.cardAt(w), m.ov.selected)
462+ }
463+ swipeY(40)
464+ if m.cardAt(w) < 0 {
465+ t.Fatal("swipe down after leaving did not enter the stash")
433466 }
434467 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
435468 for range 3 {
@@ -377,8 +377,9 @@ func TestOverviewCascadeKeys(t *testing.T) {
377 }377 }
378 }378 }
379 379
380-// T7b: with a stash card selected, up/down and the three-finger vertical380+// T7b: with a stash card selected in a cascade overview, down walks the pile
381-// swipe cycle the pile as in a vertical overview; confirming shows the picked window.381+// (stopping at its last card) and up leaves it for the last band, at the
382+// column it was entered from; confirming shows the picked window.
382 func TestOverviewCascadeStashPile(t *testing.T) {383 func TestOverviewCascadeStashPile(t *testing.T) {
383 for _, d := range cascadeDrivers {384 for _, d := range cascadeDrivers {
384 t.Run(d.name, func(t *testing.T) {385 t.Run(d.name, func(t *testing.T) {
@@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) {
392 t.Fatalf("entered at %d", m.cardAt(w))393 t.Fatalf("entered at %d", m.cardAt(w))
393 }394 }
394 at := m.cardAt(w)395 at := m.cardAt(w)
395- d.move(m, 0, -1)396+ for i := range len(w.Stash) {
396- if m.Current() != w || m.cardAt(w) != at-1 {397+ d.move(m, 0, 1)
397- t.Fatalf("up: card %d, was %d", m.cardAt(w), at)398+ if want := min(at+i+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
398- }399+ t.Fatalf("down %d: card %d, want %d", i, m.cardAt(w), want)
399- d.move(m, 0, 1)400+ }
400- d.move(m, 0, 1)
401- d.move(m, 0, 1)
402- if m.Current() != w || m.cardAt(w) != len(w.Stash)-1 {
403- t.Fatalf("down: card %d", m.cardAt(w))
404 }401 }
405- d.move(m, 0, -1)
406 picked := w.Stash[m.cardAt(w)].ID402 picked := w.Stash[m.cardAt(w)].ID
407 m.ToggleOverview()403 m.ToggleOverview()
408 if focusedID(m) != picked || w.stashHidden {404 if focusedID(m) != picked || w.stashHidden {
@@ -411,6 +407,31 @@ func TestOverviewCascadeStashPile(t *testing.T) {
411 })407 })
412 }408 }
413 409
410+ // Up from any card, whichever it was cycled to, leaves the pile at once.
411+ for _, d := range cascadeDrivers {
412+ t.Run(d.name+"/up", func(t *testing.T) {
413+ m := cascadeOverview()
414+ withStash(m, 3)
415+ w := m.Current()
416+ w.FocusID(10)
417+ m.ToggleOverview()
418+ d.move(m, 0, 1)
419+ d.move(m, 0, 1)
420+ if m.cardAt(w) < 0 {
421+ t.Fatal("setup: no card selected")
422+ }
423+ d.move(m, 0, -1)
424+ if m.Current() != w || m.cardAt(w) >= 0 || focusedID(m) != 10 || m.ov.selected != 10 {
425+ t.Fatalf("up: card %d, focus %d, selected %d", m.cardAt(w), focusedID(m), m.ov.selected)
426+ }
427+ // The pile is entered again from there.
428+ d.move(m, 0, 1)
429+ if m.cardAt(w) < 0 {
430+ t.Fatal("down after leaving did not enter the stash")
431+ }
432+ })
433+ }
434+
414 c, _, sc, _ := stashRig(t, true)435 c, _, sc, _ := stashRig(t, true)
415 m := sc.mon436 m := sc.mon
416 w := m.Current()437 w := m.Current()
@@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) {
423 t.Fatal("setup: no card selected")444 t.Fatal("setup: no card selected")
424 }445 }
425 at, before := m.cardAt(w), stashIDs(w)446 at, before := m.cardAt(w), stashIDs(w)
426- c.swipeBegin(ports.SwipeBegin{Fingers: 3})447+ swipeY := func(dy float64) {
427- for range 3 {448+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
428- c.swipeUpdate(ports.SwipeUpdate{DY: -40})449+ for range 3 {
450+ c.swipeUpdate(ports.SwipeUpdate{DY: dy})
451+ }
452+ c.swipeEnd(ports.SwipeEnd{})
429 }453 }
430- c.swipeEnd(ports.SwipeEnd{})454+ // Fingers down walk the pile; fingers up leave it for the columns.
431- if m.Current() != w || m.cardAt(w) != at-1 {455+ swipeY(40)
432- t.Fatalf("swipe up: card %d, was %d", m.cardAt(w), at)456+ if want := min(at+1, len(w.Stash)-1); m.Current() != w || m.cardAt(w) != want {
457+ t.Fatalf("swipe down: card %d, want %d", m.cardAt(w), want)
458+ }
459+ swipeY(-40)
460+ if m.Current() != w || m.cardAt(w) >= 0 || m.ov.selected == 0 {
461+ t.Fatalf("swipe up: card %d, selected %d", m.cardAt(w), m.ov.selected)
462+ }
463+ swipeY(40)
464+ if m.cardAt(w) < 0 {
465+ t.Fatal("swipe down after leaving did not enter the stash")
433 }466 }
434 c.swipeBegin(ports.SwipeBegin{Fingers: 3})467 c.swipeBegin(ports.SwipeBegin{Fingers: 3})
435 for range 3 {468 for range 3 {

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