detached 575f049

fix(core): use one touchpad navigation path in overview

commit
575f049bed5d361c871c17b02a40ae88cdd9217c
author
brice · · 2026-10-06 04:34 UTC
parent
c00cfd6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
fix/overview-touchpad-routing
8 files · +117 -43
M docs/config.md +1 -1
@@ -259,7 +259,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
259259
260260 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; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. 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: 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.
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 belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share 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
@@ -259,7 +259,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
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, `k` / `j` or up/down browse stash cards and stop at either end without changing workspace. Right (`l`) leaves the pile immediately; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. 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.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; left (`h`) stays in the pile. On a stash-only workspace, right deselects the pile so up/down can change workspace. 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: 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.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 belongs to applications and never navigates the overview; previews do not accept application input. Three fingers move the selection during the gesture, following `touchpad.natural-scroll`: horizontally, a short distance selects the next app and further movement repeats at a longer distance; vertically, one step per gesture selects a stack card or changes workspace. Inside a selected stash pile, vertical movement browses its cards and horizontal movement enters or leaves the pile. Lifting resets the gesture without an extra step; cancellation keeps selections already made. The mouse wheel moves one step per notch, following `mouse.natural-scroll`. Three-finger navigation, the wheel and keyboard navigation share 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/gesture.go +37 -10
@@ -15,8 +15,8 @@ import (
1515 // on the closest, and a spring takes the view there (motion.go). Sideways
1616 // over the shown stash the swipe slides the stash the same way, one window
1717 // at most. Where the view cannot slide (fixed overflow, a float, a
18-// fullscreen window, the overview), the swipe runs one focus action
19-// instead.
18+// fullscreen window), the swipe runs one focus action instead.
19+// In the overview, three fingers use distance-based selection during movement.
2020
2121 const (
2222 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -40,6 +40,8 @@ const (
4040 swipeColumns
4141 swipeWorkspaces
4242 swipeDiscrete
43+ // swipeNavigation navigates overview cards during a three-finger gesture.
44+ swipeNavigation
4345 // swipeOverview is a vertical four-finger swipe: up opens the
4446 // overview, down closes it on the selection (niri's gesture). With
4547 // the overview closed, down shows or hides the stash, and up hides
@@ -126,6 +128,10 @@ func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
126128 changed = true
127129 }
128130 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
131+ if b.Fingers == 3 && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
132+ c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
133+ c.swipe.mode = swipeNavigation
134+ }
129135 return changed
130136 }
131137
@@ -141,6 +147,23 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
141147 c.swipe = nil
142148 return false
143149 }
150+ if g.mode == swipeNavigation {
151+ m := g.screen.mon
152+ if !m.ov.open || c.cur() != g.screen || c.overviewKeyboardTaken() {
153+ g.mode = swipeDropped
154+ return false
155+ }
156+ now := c.now()
157+ shots := c.snapshot(now)
158+ changed := m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger,
159+ Horizontal: ports.ScrollAxis{Set: true, Value: u.DX * c.swipeSign()},
160+ Vertical: ports.ScrollAxis{Set: true, Value: u.DY * c.swipeSign()}})
161+ if changed {
162+ c.keyboard.takeBack()
163+ c.transition(shots, now)
164+ }
165+ return changed
166+ }
144167 if g.mode == swipeUndecided {
145168 g.cx += u.DX
146169 g.cy += u.DY
@@ -203,9 +226,11 @@ func (c *Core) decide(g *swipeGesture) {
203226 case g.fingers == 4:
204227 // Four fingers sideways do nothing.
205228 g.mode = swipeDropped
206- case m.ov.open || !c.animOn():
207- // The overview does not slide: the swipe moves its selection.
208- // With animations off nothing slides either: the swipe runs a
229+ case m.ov.open:
230+ // Overview navigation is chosen at begin, never on release.
231+ g.mode = swipeDropped
232+ case !c.animOn():
233+ // With animations off nothing slides: the swipe runs a
209234 // focus action when the fingers lift.
210235 g.mode, g.snap = swipeDiscrete, newStepSwipe()
211236 case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown():
@@ -248,6 +273,9 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
248273 now := c.now()
249274 m := g.screen.mon
250275 switch g.mode {
276+ case swipeNavigation:
277+ c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
278+ return false
251279 case swipeColumns:
252280 w := g.ws
253281 if g.columnsChanged(m) {
@@ -366,14 +394,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
366394 if c.cur() != g.screen {
367395 return false
368396 }
397+ if c.cur().mon.ov.open {
398+ return false
399+ }
369400 before := c.cur().mon.Current()
370401 shots := c.snapshot(now)
371402 c.keyboard.takeBack()
372- if mon := c.cur().mon; mon.ov.open {
373- mon.overviewFocus(a)
374- } else {
375- c.applyAction(a)
376- }
403+ c.applyAction(a)
377404 c.transition(shots, now)
378405 return c.cur().mon.Current() != before
379406 }
@@ -15,8 +15,8 @@ import (
15 // on the closest, and a spring takes the view there (motion.go). Sideways15 // on the closest, and a spring takes the view there (motion.go). Sideways
16 // over the shown stash the swipe slides the stash the same way, one window16 // over the shown stash the swipe slides the stash the same way, one window
17 // at most. Where the view cannot slide (fixed overflow, a float, a17 // at most. Where the view cannot slide (fixed overflow, a float, a
18-// fullscreen window, the overview), the swipe runs one focus action18+// fullscreen window), the swipe runs one focus action instead.
19-// instead.19+// In the overview, three fingers use distance-based selection during movement.
20 20
21 const (21 const (
22 // swipeDecide is the touchpad distance before the swipe picks its axis22 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -40,6 +40,8 @@ const (
40 swipeColumns40 swipeColumns
41 swipeWorkspaces41 swipeWorkspaces
42 swipeDiscrete42 swipeDiscrete
43+ // swipeNavigation navigates overview cards during a three-finger gesture.
44+ swipeNavigation
43 // swipeOverview is a vertical four-finger swipe: up opens the45 // swipeOverview is a vertical four-finger swipe: up opens the
44 // overview, down closes it on the selection (niri's gesture). With46 // overview, down closes it on the selection (niri's gesture). With
45 // the overview closed, down shows or hides the stash, and up hides47 // the overview closed, down shows or hides the stash, and up hides
@@ -126,6 +128,10 @@ func (c *Core) swipeBegin(b ports.SwipeBegin) bool {
126 changed = true128 changed = true
127 }129 }
128 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}130 c.swipe = &swipeGesture{screen: c.cur(), fingers: b.Fingers}
131+ if b.Fingers == 3 && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
132+ c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
133+ c.swipe.mode = swipeNavigation
134+ }
129 return changed135 return changed
130 }136 }
131 137
@@ -141,6 +147,23 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
141 c.swipe = nil147 c.swipe = nil
142 return false148 return false
143 }149 }
150+ if g.mode == swipeNavigation {
151+ m := g.screen.mon
152+ if !m.ov.open || c.cur() != g.screen || c.overviewKeyboardTaken() {
153+ g.mode = swipeDropped
154+ return false
155+ }
156+ now := c.now()
157+ shots := c.snapshot(now)
158+ changed := m.overviewScroll(ports.PointerAxis{Source: ports.AxisFinger,
159+ Horizontal: ports.ScrollAxis{Set: true, Value: u.DX * c.swipeSign()},
160+ Vertical: ports.ScrollAxis{Set: true, Value: u.DY * c.swipeSign()}})
161+ if changed {
162+ c.keyboard.takeBack()
163+ c.transition(shots, now)
164+ }
165+ return changed
166+ }
144 if g.mode == swipeUndecided {167 if g.mode == swipeUndecided {
145 g.cx += u.DX168 g.cx += u.DX
146 g.cy += u.DY169 g.cy += u.DY
@@ -203,9 +226,11 @@ func (c *Core) decide(g *swipeGesture) {
203 case g.fingers == 4:226 case g.fingers == 4:
204 // Four fingers sideways do nothing.227 // Four fingers sideways do nothing.
205 g.mode = swipeDropped228 g.mode = swipeDropped
206- case m.ov.open || !c.animOn():229+ case m.ov.open:
207- // The overview does not slide: the swipe moves its selection.230+ // Overview navigation is chosen at begin, never on release.
208- // With animations off nothing slides either: the swipe runs a231+ g.mode = swipeDropped
232+ case !c.animOn():
233+ // With animations off nothing slides: the swipe runs a
209 // focus action when the fingers lift.234 // focus action when the fingers lift.
210 g.mode, g.snap = swipeDiscrete, newStepSwipe()235 g.mode, g.snap = swipeDiscrete, newStepSwipe()
211 case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown():236 case g.horizontal && w.stashFocused() && !w.floatFocus && w.stashShown():
@@ -248,6 +273,9 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
248 now := c.now()273 now := c.now()
249 m := g.screen.mon274 m := g.screen.mon
250 switch g.mode {275 switch g.mode {
276+ case swipeNavigation:
277+ c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
278+ return false
251 case swipeColumns:279 case swipeColumns:
252 w := g.ws280 w := g.ws
253 if g.columnsChanged(m) {281 if g.columnsChanged(m) {
@@ -366,14 +394,13 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
366 if c.cur() != g.screen {394 if c.cur() != g.screen {
367 return false395 return false
368 }396 }
397+ if c.cur().mon.ov.open {
398+ return false
399+ }
369 before := c.cur().mon.Current()400 before := c.cur().mon.Current()
370 shots := c.snapshot(now)401 shots := c.snapshot(now)
371 c.keyboard.takeBack()402 c.keyboard.takeBack()
372- if mon := c.cur().mon; mon.ov.open {403+ c.applyAction(a)
373- mon.overviewFocus(a)
374- } else {
375- c.applyAction(a)
376- }
377 c.transition(shots, now)404 c.transition(shots, now)
378 return c.cur().mon.Current() != before405 return c.cur().mon.Current() != before
379 }406 }
M internal/core/input.go +5 -3
@@ -141,8 +141,9 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
141141 return nil
142142 case ports.PointerAxis:
143143 c.scrollStop(v)
144- // In the overview, scrolling moves the selection.
145- if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
144+ // Only wheel/continuous scrolling navigates the overview.
145+ // Two-finger scrolling belongs to clients, never to navigation.
146+ if v.Source != ports.AxisFinger && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
146147 // overviewScroll owns which scrolls step; the shot costs no
147148 // allocation once warm.
148149 now := c.now()
@@ -172,8 +173,9 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
172173 }
173174 return nil
174175 case ports.SwipeUpdate:
176+ before := c.cur().mon.Current()
175177 if c.swipeUpdate(v) {
176- return c.slid(ctx, false)
178+ return c.slid(ctx, c.cur().mon.Current() != before)
177179 }
178180 return nil
179181 case ports.SwipeEnd:
@@ -141,8 +141,9 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
141 return nil141 return nil
142 case ports.PointerAxis:142 case ports.PointerAxis:
143 c.scrollStop(v)143 c.scrollStop(v)
144- // In the overview, scrolling moves the selection.144+ // Only wheel/continuous scrolling navigates the overview.
145- if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {145+ // Two-finger scrolling belongs to clients, never to navigation.
146+ if v.Source != ports.AxisFinger && c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
146 // overviewScroll owns which scrolls step; the shot costs no147 // overviewScroll owns which scrolls step; the shot costs no
147 // allocation once warm.148 // allocation once warm.
148 now := c.now()149 now := c.now()
@@ -172,8 +173,9 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
172 }173 }
173 return nil174 return nil
174 case ports.SwipeUpdate:175 case ports.SwipeUpdate:
176+ before := c.cur().mon.Current()
175 if c.swipeUpdate(v) {177 if c.swipeUpdate(v) {
176- return c.slid(ctx, false)178+ return c.slid(ctx, c.cur().mon.Current() != before)
177 }179 }
178 return nil180 return nil
179 case ports.SwipeEnd:181 case ports.SwipeEnd:
M internal/core/overview.go +4 -3
@@ -24,7 +24,7 @@ type overviewState struct {
2424 selected WindowID
2525 selectedAt int
2626 scrollX, scrollY float64
27- // scrolled is set once a two-finger scroll stepped up or down: it
27+ // scrolled is set once a three-finger gesture stepped up or down: it
2828 // waits for the fingers to lift before the next step. sideways is set
2929 // once it stepped to a column: it stays on columns and steps once per
3030 // overviewScrollRepeat.
@@ -931,7 +931,7 @@ const (
931931 // overviewScrollStep is the scroll distance, in libinput's pointer
932932 // units, that moves the selection one column or workspace.
933933 overviewScrollStep = 60
934- // overviewScrollRepeat is the two-finger distance of each further
934+ // overviewScrollRepeat is the three-finger distance of each further
935935 // column step in one scroll: longer than the first, so a short scroll
936936 // reaches the next column exactly and a long one goes on.
937937 overviewScrollRepeat = 150
@@ -939,7 +939,8 @@ const (
939939
940940 // overviewScroll moves the overview selection with a scroll frame: a row
941941 // step when the vertical scroll reaches overviewScrollStep and leads, else
942-// a column step. Two fingers step to the next column after
942+// a column step. Three-finger updates (encoded here as AxisFinger) step
943+// to the next column after
943944 // overviewScrollStep, then once per overviewScrollRepeat, and stay on
944945 // columns until they lift; up or down they step once per scroll, so a card
945946 // in a stack is easy to pick.
@@ -24,7 +24,7 @@ type overviewState struct {
24 selected WindowID24 selected WindowID
25 selectedAt int25 selectedAt int
26 scrollX, scrollY float6426 scrollX, scrollY float64
27- // scrolled is set once a two-finger scroll stepped up or down: it27+ // scrolled is set once a three-finger gesture stepped up or down: it
28 // waits for the fingers to lift before the next step. sideways is set28 // waits for the fingers to lift before the next step. sideways is set
29 // once it stepped to a column: it stays on columns and steps once per29 // once it stepped to a column: it stays on columns and steps once per
30 // overviewScrollRepeat.30 // overviewScrollRepeat.
@@ -931,7 +931,7 @@ const (
931 // overviewScrollStep is the scroll distance, in libinput's pointer931 // overviewScrollStep is the scroll distance, in libinput's pointer
932 // units, that moves the selection one column or workspace.932 // units, that moves the selection one column or workspace.
933 overviewScrollStep = 60933 overviewScrollStep = 60
934- // overviewScrollRepeat is the two-finger distance of each further934+ // overviewScrollRepeat is the three-finger distance of each further
935 // column step in one scroll: longer than the first, so a short scroll935 // column step in one scroll: longer than the first, so a short scroll
936 // reaches the next column exactly and a long one goes on.936 // reaches the next column exactly and a long one goes on.
937 overviewScrollRepeat = 150937 overviewScrollRepeat = 150
@@ -939,7 +939,8 @@ const (
939 939
940 // overviewScroll moves the overview selection with a scroll frame: a row940 // overviewScroll moves the overview selection with a scroll frame: a row
941 // step when the vertical scroll reaches overviewScrollStep and leads, else941 // step when the vertical scroll reaches overviewScrollStep and leads, else
942-// a column step. Two fingers step to the next column after942+// a column step. Three-finger updates (encoded here as AxisFinger) step
943+// to the next column after
943 // overviewScrollStep, then once per overviewScrollRepeat, and stay on944 // overviewScrollStep, then once per overviewScrollRepeat, and stay on
944 // columns until they lift; up or down they step once per scroll, so a card945 // columns until they lift; up or down they step once per scroll, so a card
945 // in a stack is easy to pick.946 // in a stack is easy to pick.
M internal/core/overview_input_test.go +11 -10
@@ -186,11 +186,8 @@ func TestFloatOnlyMonitorDirectionalExit(t *testing.T) {
186186 }
187187 }
188188
189-// Two-finger scrolling moves the overview selection one column per step,
190-// along the axis the fingers move most; a small scroll does nothing and
191-// lifting the fingers starts over. Outside the overview it goes to the
192-// window.
193-func TestOverviewTwoFingerScroll(t *testing.T) {
189+// Three-finger navigation changes selection during movement and resets on lift.
190+func TestOverviewThreeFingerNavigation(t *testing.T) {
194191 cfg := config.Defaults()
195192 client := make(chan ports.ClientEvent, 8)
196193 input := make(chan ports.InputEvent, 8)
@@ -223,25 +220,29 @@ func TestOverviewTwoFingerScroll(t *testing.T) {
223220 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true}
224221 sceneMatch(t, scenes, focused(3))
225222 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper}
226- finger := func(dx, dy float64) ports.PointerAxis {
227- return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}
223+ finger := func(dx, dy float64) ports.SwipeUpdate {
224+ return ports.SwipeUpdate{DX: dx, DY: dy}
228225 }
226+ input <- ports.SwipeBegin{Fingers: 3}
229227 // 50 left, lifted, then 50, 50 and 100 left: one step (to 2), the
230228 // next ones are longer. Without the reset it would be two (to 1), and
231229 // the l below would land on 2.
232230 input <- finger(-50, 5)
233- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
231+ input <- ports.SwipeEnd{}
232+ input <- ports.SwipeBegin{Fingers: 3}
234233 input <- finger(-50, 5)
235234 input <- finger(-50, 0)
236235 input <- finger(-100, 0)
237236 sceneMatch(t, scenes, focused(2))
238- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
237+ input <- ports.SwipeEnd{}
238+ input <- ports.SwipeBegin{Fingers: 3}
239239 input <- ports.KeyEvent{Keysym: "l", Pressed: true}
240240 sceneMatch(t, scenes, focused(3))
241241 input <- ports.KeyEvent{Keysym: "l"}
242242 input <- finger(-60, 0)
243243 sceneMatch(t, scenes, focused(2))
244- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
244+ input <- ports.SwipeEnd{}
245+ input <- ports.SwipeBegin{Fingers: 3}
245246 // A wheel notch down has nowhere to go (no workspace below with
246247 // windows); a finger scroll left moves on.
247248 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
@@ -186,11 +186,8 @@ func TestFloatOnlyMonitorDirectionalExit(t *testing.T) {
186 }186 }
187 }187 }
188 188
189-// Two-finger scrolling moves the overview selection one column per step,189+// Three-finger navigation changes selection during movement and resets on lift.
190-// along the axis the fingers move most; a small scroll does nothing and190+func TestOverviewThreeFingerNavigation(t *testing.T) {
191-// lifting the fingers starts over. Outside the overview it goes to the
192-// window.
193-func TestOverviewTwoFingerScroll(t *testing.T) {
194 cfg := config.Defaults()191 cfg := config.Defaults()
195 client := make(chan ports.ClientEvent, 8)192 client := make(chan ports.ClientEvent, 8)
196 input := make(chan ports.InputEvent, 8)193 input := make(chan ports.InputEvent, 8)
@@ -223,25 +220,29 @@ func TestOverviewTwoFingerScroll(t *testing.T) {
223 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true}220 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper, Pressed: true}
224 sceneMatch(t, scenes, focused(3))221 sceneMatch(t, scenes, focused(3))
225 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper}222 input <- ports.KeyEvent{Keysym: "o", Mods: ports.ModSuper}
226- finger := func(dx, dy float64) ports.PointerAxis {223+ finger := func(dx, dy float64) ports.SwipeUpdate {
227- return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}224+ return ports.SwipeUpdate{DX: dx, DY: dy}
228 }225 }
226+ input <- ports.SwipeBegin{Fingers: 3}
229 // 50 left, lifted, then 50, 50 and 100 left: one step (to 2), the227 // 50 left, lifted, then 50, 50 and 100 left: one step (to 2), the
230 // next ones are longer. Without the reset it would be two (to 1), and228 // next ones are longer. Without the reset it would be two (to 1), and
231 // the l below would land on 2.229 // the l below would land on 2.
232 input <- finger(-50, 5)230 input <- finger(-50, 5)
233- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}231+ input <- ports.SwipeEnd{}
232+ input <- ports.SwipeBegin{Fingers: 3}
234 input <- finger(-50, 5)233 input <- finger(-50, 5)
235 input <- finger(-50, 0)234 input <- finger(-50, 0)
236 input <- finger(-100, 0)235 input <- finger(-100, 0)
237 sceneMatch(t, scenes, focused(2))236 sceneMatch(t, scenes, focused(2))
238- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}237+ input <- ports.SwipeEnd{}
238+ input <- ports.SwipeBegin{Fingers: 3}
239 input <- ports.KeyEvent{Keysym: "l", Pressed: true}239 input <- ports.KeyEvent{Keysym: "l", Pressed: true}
240 sceneMatch(t, scenes, focused(3))240 sceneMatch(t, scenes, focused(3))
241 input <- ports.KeyEvent{Keysym: "l"}241 input <- ports.KeyEvent{Keysym: "l"}
242 input <- finger(-60, 0)242 input <- finger(-60, 0)
243 sceneMatch(t, scenes, focused(2))243 sceneMatch(t, scenes, focused(2))
244- input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}244+ input <- ports.SwipeEnd{}
245+ input <- ports.SwipeBegin{Fingers: 3}
245 // A wheel notch down has nowhere to go (no workspace below with246 // A wheel notch down has nowhere to go (no workspace below with
246 // windows); a finger scroll left moves on.247 // windows); a finger scroll left moves on.
247 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}248 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
M internal/core/overview_stash_input_test.go +3 -13
@@ -9,7 +9,7 @@ import (
99 )
1010
1111 func TestOverviewStashInputNavigation(t *testing.T) {
12- for _, input := range []string{"swipe", "finger", "wheel", "bind"} {
12+ for _, input := range []string{"swipe", "wheel", "bind"} {
1313 t.Run(input, func(t *testing.T) {
1414 c, _, sc, _ := stashRig(t, true)
1515 m := sc.mon
@@ -28,12 +28,8 @@ func TestOverviewStashInputNavigation(t *testing.T) {
2828 c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})
2929 }
3030 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,
31+ case "wheel":
32+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel,
3733 Horizontal: ports.ScrollAxis{Set: dx != 0, Value: float64(dx) * 70, V120: int32(dx * 120)},
3834 Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}})
3935 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})
@@ -47,12 +43,6 @@ func TestOverviewStashInputNavigation(t *testing.T) {
4743 m.overviewFocus(a)
4844 }
4945 }
50- if input == "swipe" {
51- move(0, -1, true)
52- if m.cardAt(w) != 2 {
53- t.Fatal("cancelled swipe changed selection")
54- }
55- }
5646 move(0, -1, false)
5747 if m.cardAt(w) != 1 {
5848 t.Fatalf("up selected card %d, want 1", m.cardAt(w))
@@ -9,7 +9,7 @@ import (
9 )9 )
10 10
11 func TestOverviewStashInputNavigation(t *testing.T) {11 func TestOverviewStashInputNavigation(t *testing.T) {
12- for _, input := range []string{"swipe", "finger", "wheel", "bind"} {12+ for _, input := range []string{"swipe", "wheel", "bind"} {
13 t.Run(input, func(t *testing.T) {13 t.Run(input, func(t *testing.T) {
14 c, _, sc, _ := stashRig(t, true)14 c, _, sc, _ := stashRig(t, true)
15 m := sc.mon15 m := sc.mon
@@ -28,12 +28,8 @@ func TestOverviewStashInputNavigation(t *testing.T) {
28 c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})28 c.swipeUpdate(ports.SwipeUpdate{DX: float64(dx) * 40, DY: float64(dy) * 40, Time: at})
29 }29 }
30 c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at})30 c.swipeEnd(ports.SwipeEnd{Cancelled: cancelled, Time: at})
31- case "finger", "wheel":31+ case "wheel":
32- source := ports.AxisFinger32+ m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel,
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)},33 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)}})34 Vertical: ports.ScrollAxis{Set: dy != 0, Value: float64(dy) * 70, V120: int32(dy * 120)}})
39 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})35 c.scrollStop(ports.PointerAxis{Vertical: ports.ScrollAxis{Stop: true}})
@@ -47,12 +43,6 @@ func TestOverviewStashInputNavigation(t *testing.T) {
47 m.overviewFocus(a)43 m.overviewFocus(a)
48 }44 }
49 }45 }
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)46 move(0, -1, false)
57 if m.cardAt(w) != 1 {47 if m.cardAt(w) != 1 {
58 t.Fatalf("up selected card %d, want 1", m.cardAt(w))48 t.Fatalf("up selected card %d, want 1", m.cardAt(w))
A internal/core/overview_touchpad_internal_test.go +50 -0
@@ -0,0 +1,50 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOverviewTwoFingerDoesNotNavigate(t *testing.T) {
11+ c, _, sc, _ := stashRig(t, true)
12+ m := sc.mon
13+ m.ToggleOverview()
14+ w, card := m.Current(), m.cardAt(m.Current())
15+ for _, axis := range []ports.PointerAxis{
16+ {Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: 1000}},
17+ {Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, Value: 1000}},
18+ {Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Stop: true}},
19+ } {
20+ if err := c.handleInput(context.Background(), axis); err != nil {
21+ t.Fatal(err)
22+ }
23+ }
24+ if m.Current() != w || m.cardAt(w) != card {
25+ t.Fatal("two-finger scroll navigated the overview")
26+ }
27+}
28+
29+func TestOverviewSwipeCancellationResetsDistance(t *testing.T) {
30+ c, _, sc, _ := stashRig(t, true)
31+ m := sc.mon
32+ m.ToggleOverview()
33+ w := m.Current()
34+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
35+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
36+ c.swipeEnd(ports.SwipeEnd{Cancelled: true})
37+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
38+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
39+ if m.cardAt(w) != 2 {
40+ t.Fatal("cancelled gesture left accumulated distance")
41+ }
42+ c.swipeUpdate(ports.SwipeUpdate{DY: -30})
43+ if m.cardAt(w) != 1 {
44+ t.Fatal("selection did not change during movement")
45+ }
46+ c.swipeEnd(ports.SwipeEnd{Cancelled: true})
47+ if m.cardAt(w) != 1 {
48+ t.Fatal("cancellation undid an already selected card")
49+ }
50+}
@@ -0,0 +1,50 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/neferwl/internal/ports"
8+)
9+
10+func TestOverviewTwoFingerDoesNotNavigate(t *testing.T) {
11+ c, _, sc, _ := stashRig(t, true)
12+ m := sc.mon
13+ m.ToggleOverview()
14+ w, card := m.Current(), m.cardAt(m.Current())
15+ for _, axis := range []ports.PointerAxis{
16+ {Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: 1000}},
17+ {Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Set: true, Value: 1000}},
18+ {Source: ports.AxisFinger, Vertical: ports.ScrollAxis{Stop: true}},
19+ } {
20+ if err := c.handleInput(context.Background(), axis); err != nil {
21+ t.Fatal(err)
22+ }
23+ }
24+ if m.Current() != w || m.cardAt(w) != card {
25+ t.Fatal("two-finger scroll navigated the overview")
26+ }
27+}
28+
29+func TestOverviewSwipeCancellationResetsDistance(t *testing.T) {
30+ c, _, sc, _ := stashRig(t, true)
31+ m := sc.mon
32+ m.ToggleOverview()
33+ w := m.Current()
34+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
35+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
36+ c.swipeEnd(ports.SwipeEnd{Cancelled: true})
37+ c.swipeBegin(ports.SwipeBegin{Fingers: 3})
38+ c.swipeUpdate(ports.SwipeUpdate{DY: -40})
39+ if m.cardAt(w) != 2 {
40+ t.Fatal("cancelled gesture left accumulated distance")
41+ }
42+ c.swipeUpdate(ports.SwipeUpdate{DY: -30})
43+ if m.cardAt(w) != 1 {
44+ t.Fatal("selection did not change during movement")
45+ }
46+ c.swipeEnd(ports.SwipeEnd{Cancelled: true})
47+ if m.cardAt(w) != 1 {
48+ t.Fatal("cancellation undid an already selected card")
49+ }
50+}
M internal/core/swipe_test.go +6 -3
@@ -601,6 +601,7 @@ func TestSwipeInOverview(t *testing.T) {
601601 r.at += 8 * time.Millisecond
602602 r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
603603 }
604+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return selected(s) == 2 })
604605 s := r.end(t, false)
605606 if id := selected(s); id != 2 {
606607 t.Fatalf("selected %d after a left swipe, want 2", id)
@@ -945,16 +946,18 @@ func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
945946 on := func(id ports.WindowID) func(ports.Scene) bool {
946947 return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
947948 }
948- finger := func(dx float64) ports.PointerAxis {
949- return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}
949+ finger := func(dx float64) ports.SwipeUpdate {
950+ return ports.SwipeUpdate{DX: dx}
950951 }
951952 r.input <- ports.PointerMotion{X: 100, Y: 300}
953+ r.input <- ports.SwipeBegin{Fingers: 3}
952954 r.input <- finger(-70)
953955 sceneMatch(t, r.scenes, on(2))
954956 // The pointer crosses to DP-2 and the fingers lift there.
955957 r.input <- ports.PointerMotion{X: 1000, Y: 300}
956- r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
958+ r.input <- ports.SwipeEnd{}
957959 r.input <- ports.PointerMotion{X: 100, Y: 300}
960+ r.input <- ports.SwipeBegin{Fingers: 3}
958961 r.input <- finger(-70)
959962 sceneMatch(t, r.scenes, on(1))
960963 }
@@ -601,6 +601,7 @@ func TestSwipeInOverview(t *testing.T) {
601 r.at += 8 * time.Millisecond601 r.at += 8 * time.Millisecond
602 r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}602 r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
603 }603 }
604+ sceneMatch(t, r.scenes, func(s ports.Scene) bool { return selected(s) == 2 })
604 s := r.end(t, false)605 s := r.end(t, false)
605 if id := selected(s); id != 2 {606 if id := selected(s); id != 2 {
606 t.Fatalf("selected %d after a left swipe, want 2", id)607 t.Fatalf("selected %d after a left swipe, want 2", id)
@@ -945,16 +946,18 @@ func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
945 on := func(id ports.WindowID) func(ports.Scene) bool {946 on := func(id ports.WindowID) func(ports.Scene) bool {
946 return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }947 return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
947 }948 }
948- finger := func(dx float64) ports.PointerAxis {949+ finger := func(dx float64) ports.SwipeUpdate {
949- return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}950+ return ports.SwipeUpdate{DX: dx}
950 }951 }
951 r.input <- ports.PointerMotion{X: 100, Y: 300}952 r.input <- ports.PointerMotion{X: 100, Y: 300}
953+ r.input <- ports.SwipeBegin{Fingers: 3}
952 r.input <- finger(-70)954 r.input <- finger(-70)
953 sceneMatch(t, r.scenes, on(2))955 sceneMatch(t, r.scenes, on(2))
954 // The pointer crosses to DP-2 and the fingers lift there.956 // The pointer crosses to DP-2 and the fingers lift there.
955 r.input <- ports.PointerMotion{X: 1000, Y: 300}957 r.input <- ports.PointerMotion{X: 1000, Y: 300}
956- r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}958+ r.input <- ports.SwipeEnd{}
957 r.input <- ports.PointerMotion{X: 100, Y: 300}959 r.input <- ports.PointerMotion{X: 100, Y: 300}
960+ r.input <- ports.SwipeBegin{Fingers: 3}
958 r.input <- finger(-70)961 r.input <- finger(-70)
959 sceneMatch(t, r.scenes, on(1))962 sceneMatch(t, r.scenes, on(1))
960 }963 }

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