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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 274 | 274 | ### Overview | |
| 275 | 275 | ||
| 276 | 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. | |
| @@ -274,3 +274,5 @@ The window must appear within 5 seconds, otherwise it opens normally. Apps that | |||||
| 274 | ### Overview | 274 | ### 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 { | |||
| 423 | 423 | } | |
| 424 | 424 | ||
| 425 | 425 | // 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. | |
| 431 | 433 | func (m *Monitor) overviewLane(dy int) { | |
| 432 | 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 | 441 | return | |
| 435 | 442 | } | |
| 436 | 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 | 474 | // selectLaneColumn selects column i of w. | |
| 448 | 475 | func (m *Monitor) selectLaneColumn(w *Workspace, i int) { | |
| 449 | 476 | 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 side | 425 | // 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 | 426 | + | // 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 not | 428 | + | // the last lane (leavePile); focus-workspace-prev/next cycle it both ways. |
| 429 | - | // the columns, enters the stash, which sits below. Covering floats stack | 429 | + | // 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 | return | 441 | 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) { | |||
| 377 | 377 | } | |
| 378 | 378 | } | |
| 379 | 379 | ||
| 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. | |
| 382 | 383 | func TestOverviewCascadeStashPile(t *testing.T) { | |
| 383 | 384 | for _, d := range cascadeDrivers { | |
| 384 | 385 | t.Run(d.name, func(t *testing.T) { | |
| @@ -392,17 +393,12 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 392 | 393 | t.Fatalf("entered at %d", m.cardAt(w)) | |
| 393 | 394 | } | |
| 394 | 395 | 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 | + | } | |
| 404 | 401 | } | |
| 405 | - | d.move(m, 0, -1) | |
| 406 | 402 | picked := w.Stash[m.cardAt(w)].ID | |
| 407 | 403 | m.ToggleOverview() | |
| 408 | 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 | 435 | c, _, sc, _ := stashRig(t, true) | |
| 415 | 436 | m := sc.mon | |
| 416 | 437 | w := m.Current() | |
| @@ -423,13 +444,25 @@ func TestOverviewCascadeStashPile(t *testing.T) { | |||
| 423 | 444 | t.Fatal("setup: no card selected") | |
| 424 | 445 | } | |
| 425 | 446 | 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{}) | |
| 429 | 453 | } | |
| 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") | |
| 433 | 466 | } | |
| 434 | 467 | c.swipeBegin(ports.SwipeBegin{Fingers: 3}) | |
| 435 | 468 | 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 vertical | 380 | + | // 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)].ID | 402 | 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.mon | 436 | 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 { | ||