detached ad3ecff

feat(core): unify overview stash pile navigation

commit
ad3ecffb06766890606d38a07cf526ff9ecd87c3
author
brice · · 2026-10-05 20:17 UTC
parent
ccce658
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
251251 | Key | Action |
252252 | --- | --- |
253253 | `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 |
255255 | `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 |
256256 | `escape` | Return to the original workspace; every row shown keeps its focus, maximization and float order |
257257
258258 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.
259259
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.
261261
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.
263263
264264 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.
265265
@@ -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) {
363363 m.selectRow()
364364 }
365365
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.
369368 func (m *Monitor) OverviewMove(dx, dy int) {
370- w := m.Current()
371369 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)
382373 }
374+}
375+
376+func (m *Monitor) overviewVertical(dy int) {
377+ w := m.Current()
383378 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 {
388396 m.ov.card, m.ov.cardOf = 0, nil
389397 item := m.stackFront(w)
390398 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 falls366+// OverviewMove shares navigation rules across keys, scrolls and swipes.
367-// through to workspace navigation at the stack boundary. Horizontal moves367+// 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- return371+ } 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, nil396 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) {
3636 if m.Current().Name != "dev" {
3737 t.Fatal("down from workspace 1 did not reach invoked named workspace")
3838 }
39+ m.OverviewMove(1, 0) // leave the named workspace's stash pile
3940 m.OverviewMove(0, 1)
4041 if m.Current() != numbered[1] {
4142 t.Fatal("down from named did not resume numbered sequence")
@@ -181,6 +182,7 @@ func TestOverviewNamedNavigation(t *testing.T) {
181182 if id := m.card(); id != 6 {
182183 t.Fatalf("named stash selection: %d", id)
183184 }
185+ m.OverviewMove(1, 0)
184186 m.OverviewMove(0, 1)
185187 if m.Current().Name != "game" || m.card() != 0 {
186188 t.Fatalf("next named workspace: %q, card %d", m.Current().Name, m.card())
@@ -190,6 +192,7 @@ func TestOverviewNamedNavigation(t *testing.T) {
190192 t.Fatal("past last named workspace")
191193 }
192194 m.OverviewMove(0, -1)
195+ m.OverviewMove(1, 0)
193196 m.OverviewMove(0, -1)
194197 if m.Current() != numbered[1] || !slices.Equal(m.Workspaces, numbered) {
195198 t.Fatal("return to numbered workspaces changed numbering")
@@ -266,6 +269,7 @@ func TestOverviewNamedBrowseKeepsNumberedReturn(t *testing.T) {
266269 if click {
267270 m.OverviewPick(7)
268271 } else {
272+ m.OverviewMove(1, 0)
269273 m.OverviewMove(0, 1)
270274 m.ToggleOverview()
271275 }
@@ -414,6 +418,7 @@ func TestOverviewNamedScrollAndSwipe(t *testing.T) {
414418 if m.Current().Name != "dev" {
415419 t.Fatal("swipe did not cross groups")
416420 }
421+ m.overviewFocus(ActionFocusColumnRight)
417422 m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}})
418423 if m.Current().Name != "game" {
419424 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
22
33 import "testing"
44
5-func TestOverviewStashVerticalSelectsFront(t *testing.T) {
5+func TestOverviewLeaveStashThenVerticalSelectsFront(t *testing.T) {
66 for _, accept := range []bool{false, true} {
77 m := stackMonitor()
88 w := m.Current()
@@ -15,6 +15,7 @@ func TestOverviewStashVerticalSelectsFront(t *testing.T) {
1515 if m.cardAt(w) < 0 {
1616 t.Fatal("not in stash")
1717 }
18+ m.OverviewMove(1, 0) // leave stash before cycling the main stack
1819 m.OverviewMove(0, 1)
1920 p := previewOf(t, m.Layout(), 9)
2021 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) {
355355 }
356356 }
357357
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.
361360 func TestOverviewPileNavigation(t *testing.T) {
362361 m := pileMonitor()
363362 w := m.Current()
@@ -372,19 +371,26 @@ func TestOverviewPileNavigation(t *testing.T) {
372371 if previewOf(t, m.Layout(), 1).Focused {
373372 t.Fatal("column still selected")
374373 }
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)
380377 }
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)
382389 m.ToggleOverview()
383390 if f, _ := m.Focused(); f != 6 || w.stashHidden || m.ov.open {
384391 t.Fatalf("confirm on %d, hidden %v", f, w.stashHidden)
385392 }
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.
388394 w.FocusID(1)
389395 m.ToggleOverview()
390396 m.OverviewMove(-1, 0)
@@ -392,7 +398,6 @@ func TestOverviewPileNavigation(t *testing.T) {
392398 t.Fatalf("entered at %d", at)
393399 }
394400 m.OverviewMove(1, 0)
395- m.OverviewMove(1, 0)
396401 if m.cardAt(w) >= 0 {
397402 t.Fatal("still in the pile")
398403 }
@@ -440,6 +445,12 @@ func TestOverviewPileOnly(t *testing.T) {
440445 m.Focus(0)
441446 m.ToggleOverview()
442447 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()
443454 if p := previewOf(t, m.Layout(), 1); p.Focused || !p.Peek || p.Hidden {
444455 t.Fatalf("neighbor pile %+v", p)
445456 }
@@ -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 browse358+// 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 the359+// 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 entry376+ 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 entry393+ // 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 }

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