feat(core): unify overview stash pile navigation
- commit
- ad3ecffb06766890606d38a07cf526ff9ecd87c3
- author
- brice · · 2026-10-05 20:17 UTC
- parent
- ccce658
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +130 -34
M docs/config.md +3 -3
| @@ -251,15 +251,15 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||
| 251 | 251 | | Key | Action | | |
| 252 | 252 | | --- | --- | | |
| 253 | 253 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | |
| 254 | - | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | |
| 254 | + | | `k` / `j`, `up` / `down` | Browse cards within the selected stash pile; otherwise bring the next card above / previous card below to the front, then change workspace at the stack boundary | | |
| 255 | 255 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | |
| 256 | 256 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | |
| 257 | 257 | ||
| 258 | 258 | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones. | |
| 259 | 259 | ||
| 260 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 260 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately when the workspace has a main card; left (`h`) stays in the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. | |
| 261 | 261 | ||
| 262 | - | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift. | |
| 262 | + | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift: left/right enters or leaves the stash pile, and up/down browses its cards while it is selected. Two-finger scrolling, the wheel and keyboard navigation follow the same pile boundaries. | |
| 263 | 263 | ||
| 264 | 264 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | |
| 265 | 265 | ||
| @@ -251,15 +251,15 @@ The drop can be on another monitor; focus follows the window. There, the middle | |||||
| 251 | | Key | Action | | 251 | | Key | Action | | ||
| 252 | | --- | --- | | 252 | | --- | --- | | ||
| 253 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | 253 | | `h` / `l`, `left` / `right` | Move within a front column-group card; left of its first tile enters the stash. Left on a single-window card sends it behind, or enters the stash when none is behind; right does nothing on a single card. | | ||
| 254 | - | | `k` / `j`, `up` / `down` | Bring the next card above / previous card below to the front, then change workspace at the stack boundary | | 254 | + | | `k` / `j`, `up` / `down` | Browse cards within the selected stash pile; otherwise bring the next card above / previous card below to the front, then change workspace at the stack boundary | |
| 255 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | 255 | | `return` | Show the selected front card; picking a hidden column moves the maximization to it. Other rows shown keep their focus, maximization and float order | | ||
| 256 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | 256 | | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order | | ||
| 257 | 257 | ||||
| 258 | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones. | 258 | The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows` by default) move the selection like these keys. In the overview, `focus-workspace-up/down` also traverse cards before crossing workspace rows, including named ones. | ||
| 259 | 259 | ||||
| 260 | - | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `h` / `l` browse the stash and `l` past its last window returns to the front card. A successful up/down move through the stack leaves the stash and selects the new front card. `return` or a click on a card closes the overview with the stash shown on that window. | 260 | + | Each workspace's [stash](#stash), hidden or not, shows as a pile of cards on the left of its row, which stays centred unless it would overlap the pile: its selected window in front, up to three others behind it, dimmed. In the pile, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately when the workspace has a main card; left (`h`) stays in the pile. Browsing brings the selected card to the front without reordering the stash. `return` or a click on a card closes the overview with the stash shown on that window. |
| 261 | 261 | ||||
| 262 | - | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift. | 262 | + | A four-finger swipe up opens the overview, or hides the [stash](#stash) when it is shown, and a swipe down closes the overview on the selection (with the overview closed, a swipe down shows or hides the stash), whatever `touchpad.natural-scroll` says. Two-finger scrolling and the mouse wheel move the selection: left and right through the columns, up and down through stack cards before crossing workspaces, following `touchpad.natural-scroll` for two-finger scrolling and `mouse.natural-scroll` for the wheel. A two-finger scroll sideways moves it to the next column after a short distance, then one column per longer distance, and stays on the columns until the fingers lift; up or down it moves one step per scroll, so a card in a stack is easy to pick. The wheel moves one step per notch. A three-finger swipe moves it one step when the fingers lift: left/right enters or leaves the stash pile, and up/down browses its cards while it is selected. Two-finger scrolling, the wheel and keyboard navigation follow the same pile boundaries. |
| 263 | 263 | ||||
| 264 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | 264 | Return or a click on a front tile or peeking card commits it; card changes remain provisional until then. `close-window` targets the selected preview. Move-to-workspace binds (`move-column-to-workspace*`, `move-window-to-workspace*`) move the selected preview at once and keep the overview open; with `focus.follow-move`, the selection follows it. Escape does not undo a move, and the row that received the window keeps it focused. A selected stash card, or a row with no preview, does not move. Other window mutation binds (moving within the row, resizing, maximizing, fullscreen and stash toggles) and moving a workspace to another monitor are disabled while the overview is open. Workspace and monitor navigation, launch and quit binds remain active. | ||
| 265 | 265 | ||||
M internal/core/overview.go +26 -18
| @@ -363,28 +363,36 @@ func (m *Monitor) showOverview(w *Workspace) { | |||
| 363 | 363 | m.selectRow() | |
| 364 | 364 | } | |
| 365 | 365 | ||
| 366 | - | // OverviewMove routes every vertical input through moveStack, then falls | |
| 367 | - | // through to workspace navigation at the stack boundary. Horizontal moves | |
| 368 | - | // stay inside a column group; left on a single card sends it behind. | |
| 366 | + | // OverviewMove shares navigation rules across keys, scrolls and swipes. | |
| 367 | + | // Vertical moves stay in a selected stash pile; horizontal moves leave it. | |
| 369 | 368 | func (m *Monitor) OverviewMove(dx, dy int) { | |
| 370 | - | w := m.Current() | |
| 371 | 369 | if dy != 0 { | |
| 372 | - | if m.moveStack(-dy) { | |
| 373 | - | return | |
| 374 | - | } | |
| 375 | - | rows := m.overviewWorkspaces() | |
| 376 | - | i := indexOf(rows, w) + dy | |
| 377 | - | if i < 0 || i >= len(rows) { | |
| 378 | - | return | |
| 379 | - | } | |
| 380 | - | m.showOverview(rows[i]) | |
| 381 | - | return | |
| 370 | + | m.overviewVertical(dy) | |
| 371 | + | } else if dx != 0 { | |
| 372 | + | m.overviewHorizontal(dx) | |
| 382 | 373 | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (m *Monitor) overviewVertical(dy int) { | |
| 377 | + | w := m.Current() | |
| 383 | 378 | if at := m.cardAt(w); at >= 0 { | |
| 384 | - | switch { | |
| 385 | - | case at+dx >= 0 && at+dx < len(w.Stash): | |
| 386 | - | m.selectCard(w, at+dx) | |
| 387 | - | case dx > 0 && len(w.stack()) > 0: | |
| 379 | + | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) | |
| 380 | + | return | |
| 381 | + | } | |
| 382 | + | if m.moveStack(-dy) { | |
| 383 | + | return | |
| 384 | + | } | |
| 385 | + | rows := m.overviewWorkspaces() | |
| 386 | + | i := indexOf(rows, w) + dy | |
| 387 | + | if i >= 0 && i < len(rows) { | |
| 388 | + | m.showOverview(rows[i]) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (m *Monitor) overviewHorizontal(dx int) { | |
| 393 | + | w := m.Current() | |
| 394 | + | if m.cardAt(w) >= 0 { | |
| 395 | + | if dx > 0 && len(w.stack()) > 0 { | |
| 388 | 396 | m.ov.card, m.ov.cardOf = 0, nil | |
| 389 | 397 | item := m.stackFront(w) | |
| 390 | 398 | if item.kind == stackColumns { | |
| @@ -363,28 +363,36 @@ func (m *Monitor) showOverview(w *Workspace) { | |||||
| 363 | m.selectRow() | 363 | m.selectRow() | ||
| 364 | } | 364 | } | ||
| 365 | 365 | ||||
| 366 | - | // OverviewMove routes every vertical input through moveStack, then falls | 366 | + | // OverviewMove shares navigation rules across keys, scrolls and swipes. |
| 367 | - | // through to workspace navigation at the stack boundary. Horizontal moves | 367 | + | // Vertical moves stay in a selected stash pile; horizontal moves leave it. |
| 368 | - | // stay inside a column group; left on a single card sends it behind. | |||
| 369 | func (m *Monitor) OverviewMove(dx, dy int) { | 368 | func (m *Monitor) OverviewMove(dx, dy int) { | ||
| 370 | - | w := m.Current() | |||
| 371 | if dy != 0 { | 369 | if dy != 0 { | ||
| 372 | - | if m.moveStack(-dy) { | 370 | + | m.overviewVertical(dy) |
| 373 | - | return | 371 | + | } else if dx != 0 { |
| 374 | - | } | 372 | + | m.overviewHorizontal(dx) |
| 375 | - | rows := m.overviewWorkspaces() | |||
| 376 | - | i := indexOf(rows, w) + dy | |||
| 377 | - | if i < 0 || i >= len(rows) { | |||
| 378 | - | return | |||
| 379 | - | } | |||
| 380 | - | m.showOverview(rows[i]) | |||
| 381 | - | return | |||
| 382 | } | 373 | } | ||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func (m *Monitor) overviewVertical(dy int) { | |||
| 377 | + | w := m.Current() | |||
| 383 | if at := m.cardAt(w); at >= 0 { | 378 | if at := m.cardAt(w); at >= 0 { | ||
| 384 | - | switch { | 379 | + | m.selectCard(w, min(max(at+dy, 0), len(w.Stash)-1)) |
| 385 | - | case at+dx >= 0 && at+dx < len(w.Stash): | 380 | + | return |
| 386 | - | m.selectCard(w, at+dx) | 381 | + | } |
| 387 | - | case dx > 0 && len(w.stack()) > 0: | 382 | + | if m.moveStack(-dy) { |
| 383 | + | return | |||
| 384 | + | } | |||
| 385 | + | rows := m.overviewWorkspaces() | |||
| 386 | + | i := indexOf(rows, w) + dy | |||
| 387 | + | if i >= 0 && i < len(rows) { | |||
| 388 | + | m.showOverview(rows[i]) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func (m *Monitor) overviewHorizontal(dx int) { | |||
| 393 | + | w := m.Current() | |||
| 394 | + | if m.cardAt(w) >= 0 { | |||
| 395 | + | if dx > 0 && len(w.stack()) > 0 { | |||
| 388 | m.ov.card, m.ov.cardOf = 0, nil | 396 | m.ov.card, m.ov.cardOf = 0, nil | ||
| 389 | item := m.stackFront(w) | 397 | item := m.stackFront(w) | ||
| 390 | if item.kind == stackColumns { | 398 | if item.kind == stackColumns { | ||
M internal/core/overview_named_test.go +5 -0
| @@ -36,6 +36,7 @@ func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) { | |||
| 36 | 36 | if m.Current().Name != "dev" { | |
| 37 | 37 | t.Fatal("down from workspace 1 did not reach invoked named workspace") | |
| 38 | 38 | } | |
| 39 | + | m.OverviewMove(1, 0) // leave the named workspace's stash pile | |
| 39 | 40 | m.OverviewMove(0, 1) | |
| 40 | 41 | if m.Current() != numbered[1] { | |
| 41 | 42 | t.Fatal("down from named did not resume numbered sequence") | |
| @@ -181,6 +182,7 @@ func TestOverviewNamedNavigation(t *testing.T) { | |||
| 181 | 182 | if id := m.card(); id != 6 { | |
| 182 | 183 | t.Fatalf("named stash selection: %d", id) | |
| 183 | 184 | } | |
| 185 | + | m.OverviewMove(1, 0) | |
| 184 | 186 | m.OverviewMove(0, 1) | |
| 185 | 187 | if m.Current().Name != "game" || m.card() != 0 { | |
| 186 | 188 | t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card()) | |
| @@ -190,6 +192,7 @@ func TestOverviewNamedNavigation(t *testing.T) { | |||
| 190 | 192 | t.Fatal("past last named workspace") | |
| 191 | 193 | } | |
| 192 | 194 | m.OverviewMove(0, -1) | |
| 195 | + | m.OverviewMove(1, 0) | |
| 193 | 196 | m.OverviewMove(0, -1) | |
| 194 | 197 | if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) { | |
| 195 | 198 | t.Fatal("return to numbered workspaces changed numbering") | |
| @@ -266,6 +269,7 @@ func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) { | |||
| 266 | 269 | if click { | |
| 267 | 270 | m.OverviewPick(7) | |
| 268 | 271 | } else { | |
| 272 | + | m.OverviewMove(1, 0) | |
| 269 | 273 | m.OverviewMove(0, 1) | |
| 270 | 274 | m.ToggleOverview() | |
| 271 | 275 | } | |
| @@ -414,6 +418,7 @@ func TestOverviewNamedScrollAndSwipe(t *testing.T) { | |||
| 414 | 418 | if m.Current().Name != "dev" { | |
| 415 | 419 | t.Fatal("swipe did not cross groups") | |
| 416 | 420 | } | |
| 421 | + | m.overviewFocus(ActionFocusColumnRight) | |
| 417 | 422 | m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) | |
| 418 | 423 | if m.Current().Name != "game" { | |
| 419 | 424 | t.Fatal("scroll did not reach next named workspace") | |
| @@ -36,6 +36,7 @@ func TestOverviewNamedFollowsInvocationWorkspace(t *testing.T) { | |||||
| 36 | if m.Current().Name != "dev" { | 36 | if m.Current().Name != "dev" { | ||
| 37 | t.Fatal("down from workspace 1 did not reach invoked named workspace") | 37 | t.Fatal("down from workspace 1 did not reach invoked named workspace") | ||
| 38 | } | 38 | } | ||
| 39 | + | m.OverviewMove(1, 0) // leave the named workspace's stash pile | |||
| 39 | m.OverviewMove(0, 1) | 40 | m.OverviewMove(0, 1) | ||
| 40 | if m.Current() != numbered[1] { | 41 | if m.Current() != numbered[1] { | ||
| 41 | t.Fatal("down from named did not resume numbered sequence") | 42 | t.Fatal("down from named did not resume numbered sequence") | ||
| @@ -181,6 +182,7 @@ func TestOverviewNamedNavigation(t *testing.T) { | |||||
| 181 | if id := m.card(); id != 6 { | 182 | if id := m.card(); id != 6 { | ||
| 182 | t.Fatalf("named stash selection: %d", id) | 183 | t.Fatalf("named stash selection: %d", id) | ||
| 183 | } | 184 | } | ||
| 185 | + | m.OverviewMove(1, 0) | |||
| 184 | m.OverviewMove(0, 1) | 186 | m.OverviewMove(0, 1) | ||
| 185 | if m.Current().Name != "game" || m.card() != 0 { | 187 | if m.Current().Name != "game" || m.card() != 0 { | ||
| 186 | t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card()) | 188 | t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card()) | ||
| @@ -190,6 +192,7 @@ func TestOverviewNamedNavigation(t *testing.T) { | |||||
| 190 | t.Fatal("past last named workspace") | 192 | t.Fatal("past last named workspace") | ||
| 191 | } | 193 | } | ||
| 192 | m.OverviewMove(0, -1) | 194 | m.OverviewMove(0, -1) | ||
| 195 | + | m.OverviewMove(1, 0) | |||
| 193 | m.OverviewMove(0, -1) | 196 | m.OverviewMove(0, -1) | ||
| 194 | if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) { | 197 | if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) { | ||
| 195 | t.Fatal("return to numbered workspaces changed numbering") | 198 | t.Fatal("return to numbered workspaces changed numbering") | ||
| @@ -266,6 +269,7 @@ func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) { | |||||
| 266 | if click { | 269 | if click { | ||
| 267 | m.OverviewPick(7) | 270 | m.OverviewPick(7) | ||
| 268 | } else { | 271 | } else { | ||
| 272 | + | m.OverviewMove(1, 0) | |||
| 269 | m.OverviewMove(0, 1) | 273 | m.OverviewMove(0, 1) | ||
| 270 | m.ToggleOverview() | 274 | m.ToggleOverview() | ||
| 271 | } | 275 | } | ||
| @@ -414,6 +418,7 @@ func TestOverviewNamedScrollAndSwipe(t *testing.T) { | |||||
| 414 | if m.Current().Name != "dev" { | 418 | if m.Current().Name != "dev" { | ||
| 415 | t.Fatal("swipe did not cross groups") | 419 | t.Fatal("swipe did not cross groups") | ||
| 416 | } | 420 | } | ||
| 421 | + | m.overviewFocus(ActionFocusColumnRight) | |||
| 417 | m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) | 422 | m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) | ||
| 418 | if m.Current().Name != "game" { | 423 | if m.Current().Name != "game" { | ||
| 419 | t.Fatal("scroll did not reach next named workspace") | 424 | t.Fatal("scroll did not reach next named workspace") | ||
M internal/core/overview_regression_test.go +2 -1
| @@ -2,7 +2,7 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | - | func TestOverviewStashVerticalSelectsFront(t *testing.T) { | |
| 5 | + | func TestOverviewLeaveStashThenVerticalSelectsFront(t *testing.T) { | |
| 6 | 6 | for _, accept := range []bool{false, true} { | |
| 7 | 7 | m := stackMonitor() | |
| 8 | 8 | w := m.Current() | |
| @@ -15,6 +15,7 @@ func TestOverviewStashVerticalSelectsFront(t *testing.T) { | |||
| 15 | 15 | if m.cardAt(w) < 0 { | |
| 16 | 16 | t.Fatal("not in stash") | |
| 17 | 17 | } | |
| 18 | + | m.OverviewMove(1, 0) // leave stash before cycling the main stack | |
| 18 | 19 | m.OverviewMove(0, 1) | |
| 19 | 20 | p := previewOf(t, m.Layout(), 9) | |
| 20 | 21 | if !p.Focused || p.Peek || m.cardAt(w) >= 0 { | |
| @@ -2,7 +2,7 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | - | func TestOverviewStashVerticalSelectsFront(t *testing.T) { | 5 | + | func TestOverviewLeaveStashThenVerticalSelectsFront(t *testing.T) { |
| 6 | for _, accept := range []bool{false, true} { | 6 | for _, accept := range []bool{false, true} { | ||
| 7 | m := stackMonitor() | 7 | m := stackMonitor() | ||
| 8 | w := m.Current() | 8 | w := m.Current() | ||
| @@ -15,6 +15,7 @@ func TestOverviewStashVerticalSelectsFront(t *testing.T) { | |||||
| 15 | if m.cardAt(w) < 0 { | 15 | if m.cardAt(w) < 0 { | ||
| 16 | t.Fatal("not in stash") | 16 | t.Fatal("not in stash") | ||
| 17 | } | 17 | } | ||
| 18 | + | m.OverviewMove(1, 0) // leave stash before cycling the main stack | |||
| 18 | m.OverviewMove(0, 1) | 19 | m.OverviewMove(0, 1) | ||
| 19 | p := previewOf(t, m.Layout(), 9) | 20 | p := previewOf(t, m.Layout(), 9) | ||
| 20 | if !p.Focused || p.Peek || m.cardAt(w) >= 0 { | 21 | if !p.Focused || p.Peek || m.cardAt(w) >= 0 { | ||
A internal/core/overview_stash_input_test.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestOverviewStashInputNavigation(t *testing.T) { | |
| 12 | + | for _, input := range []string{"swipe", "finger", "wheel", "bind"} { | |
| 13 | + | t.Run(input, func(t *testing.T) { | |
| 14 | + | c, _, sc, _ := stashRig(t, true) | |
| 15 | + | m := sc.mon | |
| 16 | + | w := m.Current() | |
| 17 | + | before := stashIDs(w) | |
| 18 | + | m.ToggleOverview() | |
| 19 | + | var at time.Duration | |
| 20 | + | move := func(dx, dy int, cancelled bool) { | |
| 21 | + | t.Helper() | |
| 22 | + | switch input { | |
| 23 | + | case "swipe": | |
| 24 | + | at += time.Second | |
| 25 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: at}) | |
| 26 | + | for range 3 { | |
| 27 | + | at += 8 * time.Millisecond | |
| 28 | + | c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at}) | |
| 29 | + | } | |
| 30 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at}) | |
| 31 | + | case "finger", "wheel": | |
| 32 | + | source := ports.AxisFinger | |
| 33 | + | if input == "wheel" { | |
| 34 | + | source = ports.AxisWheel | |
| 35 | + | } | |
| 36 | + | m.overviewScroll(ports.PointerAxis{Source: source, | |
| 37 | + | Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)}, | |
| 38 | + | Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}}) | |
| 39 | + | c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}}) | |
| 40 | + | case "bind": | |
| 41 | + | a := ActionFocusWorkspaceDown | |
| 42 | + | if dy < 0 { | |
| 43 | + | a = ActionFocusWorkspaceUp | |
| 44 | + | } else if dx > 0 { | |
| 45 | + | a = ActionFocusColumnRight | |
| 46 | + | } | |
| 47 | + | m.overviewFocus(a) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | if input == "swipe" { | |
| 51 | + | move(0, -1, true) | |
| 52 | + | if m.cardAt(w) != 2 { | |
| 53 | + | t.Fatal("cancelled swipe changed selection") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | move(0, -1, false) | |
| 57 | + | if m.cardAt(w) != 1 { | |
| 58 | + | t.Fatalf("up selected card %d, want 1", m.cardAt(w)) | |
| 59 | + | } | |
| 60 | + | move(0, 1, false) | |
| 61 | + | move(0, 1, false) | |
| 62 | + | if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) { | |
| 63 | + | t.Fatal("down escaped or reordered the stash") | |
| 64 | + | } | |
| 65 | + | move(1, 0, false) | |
| 66 | + | if m.cardAt(w) >= 0 { | |
| 67 | + | t.Fatal("right did not leave the stash") | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "slices" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestOverviewStashInputNavigation(t *testing.T) { | |||
| 12 | + | for _, input := range []string{"swipe", "finger", "wheel", "bind"} { | |||
| 13 | + | t.Run(input, func(t *testing.T) { | |||
| 14 | + | c, _, sc, _ := stashRig(t, true) | |||
| 15 | + | m := sc.mon | |||
| 16 | + | w := m.Current() | |||
| 17 | + | before := stashIDs(w) | |||
| 18 | + | m.ToggleOverview() | |||
| 19 | + | var at time.Duration | |||
| 20 | + | move := func(dx, dy int, cancelled bool) { | |||
| 21 | + | t.Helper() | |||
| 22 | + | switch input { | |||
| 23 | + | case "swipe": | |||
| 24 | + | at += time.Second | |||
| 25 | + | c.swipeBegin(ports.SwipeBegin{Fingers: 3, Time: at}) | |||
| 26 | + | for range 3 { | |||
| 27 | + | at += 8 * time.Millisecond | |||
| 28 | + | c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at}) | |||
| 29 | + | } | |||
| 30 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at}) | |||
| 31 | + | case "finger", "wheel": | |||
| 32 | + | source := ports.AxisFinger | |||
| 33 | + | if input == "wheel" { | |||
| 34 | + | source = ports.AxisWheel | |||
| 35 | + | } | |||
| 36 | + | m.overviewScroll(ports.PointerAxis{Source: source, | |||
| 37 | + | Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)}, | |||
| 38 | + | Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}}) | |||
| 39 | + | c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}}) | |||
| 40 | + | case "bind": | |||
| 41 | + | a := ActionFocusWorkspaceDown | |||
| 42 | + | if dy < 0 { | |||
| 43 | + | a = ActionFocusWorkspaceUp | |||
| 44 | + | } else if dx > 0 { | |||
| 45 | + | a = ActionFocusColumnRight | |||
| 46 | + | } | |||
| 47 | + | m.overviewFocus(a) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | if input == "swipe" { | |||
| 51 | + | move(0, -1, true) | |||
| 52 | + | if m.cardAt(w) != 2 { | |||
| 53 | + | t.Fatal("cancelled swipe changed selection") | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | move(0, -1, false) | |||
| 57 | + | if m.cardAt(w) != 1 { | |||
| 58 | + | t.Fatalf("up selected card %d, want 1", m.cardAt(w)) | |||
| 59 | + | } | |||
| 60 | + | move(0, 1, false) | |||
| 61 | + | move(0, 1, false) | |||
| 62 | + | if m.Current() != w || m.cardAt(w) != 2 || !slices.Equal(stashIDs(w), before) { | |||
| 63 | + | t.Fatal("down escaped or reordered the stash") | |||
| 64 | + | } | |||
| 65 | + | move(1, 0, false) | |||
| 66 | + | if m.cardAt(w) >= 0 { | |||
| 67 | + | t.Fatal("right did not leave the stash") | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
M internal/core/overview_test.go +23 -12
| @@ -355,9 +355,8 @@ func TestOverviewPileKeepsRowCentred(t *testing.T) { | |||
| 355 | 355 | } | |
| 356 | 356 | } | |
| 357 | 357 | ||
| 358 | - | // h from column 1 enters the pile on the stash's selection, h/l browse | |
| 359 | - | // it, l past its end returns to column 1; Return shows the stash on the | |
| 360 | - | // chosen window. | |
| 358 | + | // Left enters the stash pile, up/down browse it without changing rows, | |
| 359 | + | // and right leaves it immediately. Confirm shows the chosen stash window. | |
| 361 | 360 | func TestOverviewPileNavigation(t *testing.T) { | |
| 362 | 361 | m := pileMonitor() | |
| 363 | 362 | w := m.Current() | |
| @@ -372,19 +371,26 @@ func TestOverviewPileNavigation(t *testing.T) { | |||
| 372 | 371 | if previewOf(t, m.Layout(), 1).Focused { | |
| 373 | 372 | t.Fatal("column still selected") | |
| 374 | 373 | } | |
| 375 | - | m.OverviewMove(-1, 0) | |
| 376 | - | m.OverviewMove(-1, 0) | |
| 377 | - | m.OverviewMove(-1, 0) // stops at the first entry | |
| 378 | - | if at := m.cardAt(w); at != 0 { | |
| 379 | - | t.Fatalf("left end %d", at) | |
| 374 | + | m.OverviewMove(-1, 0) // left stays on the selected stash card | |
| 375 | + | if at := m.cardAt(w); at != 2 { | |
| 376 | + | t.Fatalf("left changed stash selection to %d", at) | |
| 380 | 377 | } | |
| 381 | - | m.OverviewMove(1, 0) | |
| 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") | |
| 381 | + | } | |
| 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) | |
| 387 | + | } | |
| 388 | + | m.OverviewMove(0, 1) | |
| 382 | 389 | m.ToggleOverview() | |
| 383 | 390 | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open { | |
| 384 | 391 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | |
| 385 | 392 | } | |
| 386 | - | // Back in on the stash's selection (6), then l past the last entry | |
| 387 | - | // (7) returns to column 1. | |
| 393 | + | // Right returns to column 1 from any stash card. | |
| 388 | 394 | w.FocusID(1) | |
| 389 | 395 | m.ToggleOverview() | |
| 390 | 396 | m.OverviewMove(-1, 0) | |
| @@ -392,7 +398,6 @@ func TestOverviewPileNavigation(t *testing.T) { | |||
| 392 | 398 | t.Fatalf("entered at %d", at) | |
| 393 | 399 | } | |
| 394 | 400 | m.OverviewMove(1, 0) | |
| 395 | - | m.OverviewMove(1, 0) | |
| 396 | 401 | if m.cardAt(w) >= 0 { | |
| 397 | 402 | t.Fatal("still in the pile") | |
| 398 | 403 | } | |
| @@ -440,6 +445,12 @@ func TestOverviewPileOnly(t *testing.T) { | |||
| 440 | 445 | m.Focus(0) | |
| 441 | 446 | m.ToggleOverview() | |
| 442 | 447 | m.OverviewMove(0, 1) | |
| 448 | + | if m.Current() != w || m.cardAt(w) != 0 { | |
| 449 | + | t.Fatal("vertical navigation escaped a stash-only workspace") | |
| 450 | + | } | |
| 451 | + | m.CancelOverview() | |
| 452 | + | m.Focus(1) | |
| 453 | + | m.ToggleOverview() | |
| 443 | 454 | if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden { | |
| 444 | 455 | t.Fatalf("neighbor pile %+v", p) | |
| 445 | 456 | } | |
| @@ -355,9 +355,8 @@ func TestOverviewPileKeepsRowCentred(t *testing.T) { | |||||
| 355 | } | 355 | } | ||
| 356 | } | 356 | } | ||
| 357 | 357 | ||||
| 358 | - | // h from column 1 enters the pile on the stash's selection, h/l browse | 358 | + | // Left enters the stash pile, up/down browse it without changing rows, |
| 359 | - | // it, l past its end returns to column 1; Return shows the stash on the | 359 | + | // and right leaves it immediately. Confirm shows the chosen stash window. |
| 360 | - | // chosen window. | |||
| 361 | func TestOverviewPileNavigation(t *testing.T) { | 360 | func TestOverviewPileNavigation(t *testing.T) { | ||
| 362 | m := pileMonitor() | 361 | m := pileMonitor() | ||
| 363 | w := m.Current() | 362 | w := m.Current() | ||
| @@ -372,19 +371,26 @@ func TestOverviewPileNavigation(t *testing.T) { | |||||
| 372 | if previewOf(t, m.Layout(), 1).Focused { | 371 | if previewOf(t, m.Layout(), 1).Focused { | ||
| 373 | t.Fatal("column still selected") | 372 | t.Fatal("column still selected") | ||
| 374 | } | 373 | } | ||
| 375 | - | m.OverviewMove(-1, 0) | 374 | + | m.OverviewMove(-1, 0) // left stays on the selected stash card |
| 376 | - | m.OverviewMove(-1, 0) | 375 | + | if at := m.cardAt(w); at != 2 { |
| 377 | - | m.OverviewMove(-1, 0) // stops at the first entry | 376 | + | t.Fatalf("left changed stash selection to %d", at) |
| 378 | - | if at := m.cardAt(w); at != 0 { | |||
| 379 | - | t.Fatalf("left end %d", at) | |||
| 380 | } | 377 | } | ||
| 381 | - | m.OverviewMove(1, 0) | 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") | |||
| 381 | + | } | |||
| 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) | |||
| 387 | + | } | |||
| 388 | + | m.OverviewMove(0, 1) | |||
| 382 | m.ToggleOverview() | 389 | m.ToggleOverview() | ||
| 383 | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open { | 390 | if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open { | ||
| 384 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | 391 | t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden) | ||
| 385 | } | 392 | } | ||
| 386 | - | // Back in on the stash's selection (6), then l past the last entry | 393 | + | // Right returns to column 1 from any stash card. |
| 387 | - | // (7) returns to column 1. | |||
| 388 | w.FocusID(1) | 394 | w.FocusID(1) | ||
| 389 | m.ToggleOverview() | 395 | m.ToggleOverview() | ||
| 390 | m.OverviewMove(-1, 0) | 396 | m.OverviewMove(-1, 0) | ||
| @@ -392,7 +398,6 @@ func TestOverviewPileNavigation(t *testing.T) { | |||||
| 392 | t.Fatalf("entered at %d", at) | 398 | t.Fatalf("entered at %d", at) | ||
| 393 | } | 399 | } | ||
| 394 | m.OverviewMove(1, 0) | 400 | m.OverviewMove(1, 0) | ||
| 395 | - | m.OverviewMove(1, 0) | |||
| 396 | if m.cardAt(w) >= 0 { | 401 | if m.cardAt(w) >= 0 { | ||
| 397 | t.Fatal("still in the pile") | 402 | t.Fatal("still in the pile") | ||
| 398 | } | 403 | } | ||
| @@ -440,6 +445,12 @@ func TestOverviewPileOnly(t *testing.T) { | |||||
| 440 | m.Focus(0) | 445 | m.Focus(0) | ||
| 441 | m.ToggleOverview() | 446 | m.ToggleOverview() | ||
| 442 | m.OverviewMove(0, 1) | 447 | m.OverviewMove(0, 1) | ||
| 448 | + | if m.Current() != w || m.cardAt(w) != 0 { | |||
| 449 | + | t.Fatal("vertical navigation escaped a stash-only workspace") | |||
| 450 | + | } | |||
| 451 | + | m.CancelOverview() | |||
| 452 | + | m.Focus(1) | |||
| 453 | + | m.ToggleOverview() | |||
| 443 | if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden { | 454 | if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden { | ||
| 444 | t.Fatalf("neighbor pile %+v", p) | 455 | t.Fatalf("neighbor pile %+v", p) | ||
| 445 | } | 456 | } | ||