detached 9a9b02b

feat(core): snap touchpad gestures one step per gesture (#86)

* feat(core): snap touchpad swipes one step per gesture

Workspace and column swipes share a snap tracker modeled on libadwaita:
a swipe reaches the next workspace or column edge at most, resists past
it, sticks near each step while dragging, and on lift goes on to the next
step when quick or settles on the closest when slow. Column snap points
closer than a tenth of the view merge. Discrete and four-finger swipes
use the same tracker for their single step, and a two-finger scroll in
the overview moves the selection one step per scroll.

* fix(core): keep snap swipes continuous and scroll locks per gesture

The snap tracker precomputes its detent stops, so a swipe caught past an
edge starts without a jump and updates no longer allocate. A rest view
between merged column edges becomes a point, so a slow swipe never
settles behind it. Step swipes use a real rubber band instead of a zero
one that produced NaN. The overview's one-step lock applies to two-finger
scrolling only and every scroll stop clears it on all monitors.

* fix(core): drop a column swipe when its usable area changes

A column swipe measures its snap points in the usable area it began
with. An output resize or a panel change mid-swipe now lets the swipe go,
as a workspace change does, instead of landing on stale points. Tests
cover the resize, focus with no fully shown column, and the snap point
merge at the last column's end.

* test(core): cover quick swipes from between points and far landings

Step swipes get their own points instead of sharing a package slice, and
the step test checks the target and velocity of one landing.

* fix(core): drop a column swipe when its snap points move

A column swipe now keeps the snap points it began with and lets go when
the layout no longer has them: a window mapped or resized, other gaps,
an output resize or a panel. Tests check the drop on the first update
and reach the wide-column focus through a full-width column.

* feat(core): repeat column steps in one overview finger scroll

A two-finger scroll sideways in the overview steps to the next column
after a short distance, then once per longer distance, and stays on
columns. Up or down it still steps once per scroll, so a stacked card is
easy to pick.

* fix(core): keep the overview's sideways scroll lock to two fingers

A sideways two-finger scroll still on the pad no longer slows wheel or
continuous column steps nor drops their vertical steps; they start from
zero. Tests cover the wheel after a sideways scroll, the stop, a large
frame and reopening.
commit
9a9b02bced4b580db4ca308ac1f6aef3224721b2
author
bnema · · 2026-10-02 02:04 UTC
committer
GitHub · · 2026-10-02 02:04 UTC
parent
6f718b0
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +825 -101
M docs/config.md +6 -4
@@ -66,10 +66,12 @@ bind.Cmd+p = workspace presentation
6666
6767 ## Touchpad
6868
69-A three-finger swipe follows the fingers:
69+A three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
7070
71-- Left or right scrolls the columns (`scroll` overflow). When the fingers lift, the view keeps the swipe's speed, slows down and settles on a column edge; the focus moves to a column fully on screen.
72-- Up or down slides between numbered workspaces and settles on the nearest one the swipe's speed reaches. It stops with some resistance at the first and last workspace.
71+- Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
72+- Up or down slides to the next numbered workspace.
73+
74+The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
7375
7476 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
7577
@@ -182,7 +184,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
182184
183185 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.
184186
185-A four-finger swipe up opens the overview and a swipe down closes it on the selection, 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`. A three-finger swipe moves it one step when the fingers lift.
187+A four-finger swipe up opens the overview and a swipe down closes it on the selection, 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`. 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.
186188
187189 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.
188190
@@ -66,10 +66,12 @@ bind.Cmd+p = workspace presentation
66 66
67 ## Touchpad67 ## Touchpad
68 68
69-A three-finger swipe follows the fingers:69+A three-finger swipe follows the fingers and moves one step at most, so a quick swipe never skips a column or a workspace:
70 70
71-- Left or right scrolls the columns (`scroll` overflow). When the fingers lift, the view keeps the swipe's speed, slows down and settles on a column edge; the focus moves to a column fully on screen.71+- Left or right scrolls the columns (`scroll` overflow) to the next column edge; the focus moves to a column fully on screen.
72-- Up or down slides between numbered workspaces and settles on the nearest one the swipe's speed reaches. It stops with some resistance at the first and last workspace.72+- Up or down slides to the next numbered workspace.
73+
74+The view sticks a little near each step and catches up between them. Past the next step it resists; lift and swipe again to go further. When the fingers lift, a quick swipe goes on to the next step and a slow one settles on the closest.
73 75
74 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.76 With `natural-scroll = off`, a swipe left shows the columns to the left and a swipe up shows the workspace above. `natural-scroll = on` moves the content with the fingers, so both are reversed.
75 77
@@ -182,7 +184,7 @@ The focus binds (`focus-column-left/right`, `focus-window-up/down`, `cmd+arrows`
182 184
183 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.185 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.
184 186
185-A four-finger swipe up opens the overview and a swipe down closes it on the selection, 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`. A three-finger swipe moves it one step when the fingers lift.187+A four-finger swipe up opens the overview and a swipe down closes it on the selection, 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`. 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.
186 188
187 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.189 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.
188 190
M internal/core/gesture.go +52 -47
@@ -9,10 +9,12 @@ import (
99 )
1010
1111 // A three-finger swipe follows the fingers: sideways it scrolls the columns,
12-// up or down it slides between workspaces. When the fingers lift, the view
13-// lands where the swipe's speed would carry it and a spring takes it there
14-// (motion.go). Where the view cannot slide (fixed overflow, the stash, a
15-// float, a fullscreen window), the swipe runs one focus action instead.
12+// up or down it slides between workspaces. Each swipe moves one step at
13+// most (snapswipe.go): the next column edge or the next workspace. When
14+// the fingers lift, a quick swipe goes on to that step, a slow one settles
15+// on the closest, and a spring takes the view there (motion.go). Where the
16+// view cannot slide (fixed overflow, the stash, a float, a fullscreen
17+// window, the overview), the swipe runs one focus action instead.
1618
1719 const (
1820 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -24,10 +26,6 @@ const (
2426 // workspaceSwipeMovement is the touchpad distance that slides one
2527 // workspace.
2628 workspaceSwipeMovement = 300.0
27- // discreteSwipeMin is the projected distance a swipe needs to run a
28- // focus action where the view cannot slide, or a four-finger swipe to
29- // open or close the overview (half of niri's 300).
30- discreteSwipeMin = 150.0
3129 )
3230
3331 type swipeMode uint8
@@ -51,18 +49,20 @@ type swipeGesture struct {
5149 mode swipeMode
5250 horizontal bool
5351 cx, cy float64
54- // tracker adds up the movement along the chosen axis, natural scroll
55- // applied: positive scrolls right or down.
56- tracker swipeTracker
52+ // snap follows the movement along the chosen axis, natural scroll
53+ // applied: positive scrolls right or down. Its view is absolute: the
54+ // columns' x in pixels or the monitor's fractional workspace index,
55+ // so a focus change during the swipe does not move the fingers' view.
56+ // A discrete or overview swipe counts steps of -1, 0 or 1.
57+ snap snapSwipe
5758 // ws is the workspace whose columns scroll, or the active one when a
58- // workspace slide began; start is where the view was when the swipe
59- // took over: its x in pixels (columns) or the monitor's fractional
60- // workspace index (workspaces). Both are absolute, so a focus change
61- // during the swipe does not move the fingers' view.
62- ws *Workspace
63- start float64
64- // list is the numbered workspaces when a workspace slide began: start
65- // indexes it, so the slide ends if the list changes.
59+ // workspace slide began.
60+ ws *Workspace
61+ // points are ws's column snap points when a column swipe began: the
62+ // swipe ends if the layout no longer has them.
63+ points []float64
64+ // list is the numbered workspaces when a workspace slide began: the
65+ // slide indexes it, so it ends if the list changes.
6666 list []*Workspace
6767 // opens is the monitor's overviewOpens when the swipe picked its
6868 // mode: a slide the overview opened over is dropped, even once the
@@ -76,6 +76,14 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
7676 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
7777 }
7878
79+// columnsChanged reports whether a column swipe lost its workspace: it is
80+// no longer shown or slidable, its snap points moved (an output resize, a
81+// panel, a window mapped or resized, other gaps), or the overview opened
82+// over it.
83+func (g *swipeGesture) columnsChanged(m *Monitor) bool {
84+ return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
85+}
86+
7987 // swipeSign turns finger movement into view movement: natural scroll moves
8088 // the content with the fingers.
8189 func (c *Core) swipeSign() float64 {
@@ -119,19 +127,19 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
119127 if g.mode == swipeOverview {
120128 // Like niri, the overview gesture ignores natural scroll: the
121129 // fingers going up open it.
122- g.tracker.push(d, u.Time)
130+ g.snap.push(d, u.Time)
123131 return false
124132 }
125- g.tracker.push(d*c.swipeSign(), u.Time)
133+ g.snap.push(d*c.swipeSign(), u.Time)
126134 m := g.screen.mon
127135 switch g.mode {
128136 case swipeColumns:
129- if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() {
137+ if g.columnsChanged(m) {
130138 g.ws.stopSlide()
131139 g.mode = swipeDropped
132140 return true
133141 }
134- g.ws.shift = g.start + g.tracker.pos*g.ws.swipeScale() - float64(g.ws.ViewX)
142+ g.ws.shift = g.snap.pos() - float64(g.ws.ViewX)
135143 return true
136144 case swipeWorkspaces:
137145 if g.listChanged(m) {
@@ -139,8 +147,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
139147 g.mode = swipeDropped
140148 return true
141149 }
142- f := workspaceBand.clamp(0, float64(len(m.Workspaces)-1), g.start+g.tracker.pos/workspaceSwipeMovement)
143- m.switchOff = f - float64(m.Active)
150+ m.switchOff = g.snap.pos() - float64(m.Active)
144151 return true
145152 }
146153 return false
@@ -155,17 +162,17 @@ func (c *Core) decide(g *swipeGesture) {
155162 w := m.Current()
156163 switch {
157164 case g.fingers == 4 && !g.horizontal:
158- g.mode = swipeOverview
165+ g.mode, g.snap = swipeOverview, newStepSwipe()
159166 case g.fingers == 4:
160167 // Four fingers sideways do nothing.
161168 g.mode = swipeDropped
162169 case m.ov.open:
163170 // The overview does not slide: the swipe moves its selection.
164- g.mode = swipeDiscrete
171+ g.mode, g.snap = swipeDiscrete, newStepSwipe()
165172 case g.horizontal && w.slidable():
166- g.mode, g.ws = swipeColumns, w
173+ g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
167174 w.motion = nil
168- g.start = float64(w.ViewX) + w.shift
175+ g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
169176 case !g.horizontal && m.shown == nil:
170177 // A landing slide measured in an older list lands at once first.
171178 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
@@ -174,9 +181,9 @@ func (c *Core) decide(g *swipeGesture) {
174181 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
175182 g.list = slices.Clone(m.Workspaces)
176183 m.switchMotion, m.switchList = nil, nil
177- g.start = float64(m.Active) + m.switchOff
184+ g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
178185 default:
179- g.mode = swipeDiscrete
186+ g.mode, g.snap = swipeDiscrete, newStepSwipe()
180187 }
181188 }
182189
@@ -188,40 +195,38 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
188195 if g == nil || !c.hasScreen(g.screen) {
189196 return false
190197 }
191- // Idle time before the lift slows the swipe down.
192- g.tracker.push(0, e.Time)
193198 now := c.now()
194199 m := g.screen.mon
195200 switch g.mode {
196201 case swipeColumns:
197202 w := g.ws
198- if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() {
203+ if g.columnsChanged(m) {
199204 w.stopSlide()
200205 return false
201206 }
202- scale := w.swipeScale()
203207 shown := float64(w.ViewX) + w.shift
208+ target, velocity := g.snap.end(e.Cancelled, e.Time)
204209 if !e.Cancelled {
205- target := g.start + g.tracker.projectedEnd()*scale
206- w.ViewX, w.Focus = w.snap(target, target >= shown)
210+ view := int(math.Round(target))
211+ w.Focus = w.snapFocus(view, target >= shown)
212+ w.ViewX = view
207213 // A column wider than the view aligns as focus moves align it.
208214 w.scroll()
209215 c.focusScreen = c.screenIndex(g.screen.name())
210216 c.keyboard.takeBack()
211217 }
212218 w.shift = shown - float64(w.ViewX)
213- w.motion = newMotion(viewSpring(w.shift, g.tracker.velocity()*scale), now)
219+ w.motion = newMotion(viewSpring(w.shift, velocity), now)
214220 case swipeWorkspaces:
215221 if g.listChanged(m) {
216222 m.stopSwitch()
217223 return false
218224 }
219- last := float64(len(m.Workspaces) - 1)
220225 cur := float64(m.Active) + m.switchOff
221- velocity := g.tracker.velocity() / workspaceSwipeMovement * workspaceBand.clampDerivative(0, last, g.start+g.tracker.pos/workspaceSwipeMovement)
226+ target, velocity := g.snap.end(e.Cancelled, e.Time)
222227 idx := m.Active
223228 if !e.Cancelled {
224- idx = int(math.Round(min(max(g.start+g.tracker.projectedEnd()/workspaceSwipeMovement, 0), last)))
229+ idx = int(math.Round(target))
225230 }
226231 // Focus may drop the empty workspace left behind and renumber the
227232 // list: the slide keeps measuring in the list it began on.
@@ -237,8 +242,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
237242 m.switchOff, m.switchList = off, g.list
238243 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
239244 case swipeOverview:
240- p := g.tracker.projectedEnd()
241- if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open {
245+ step := g.snap.step(e.Cancelled, e.Time)
246+ if step == 0 || (step < 0) == m.ov.open {
242247 return false
243248 }
244249 before := m.Current()
@@ -247,17 +252,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
247252 c.keyboard.takeBack()
248253 return m.Current() != before
249254 case swipeDiscrete:
250- p := g.tracker.projectedEnd()
251- if e.Cancelled || math.Abs(p) < discreteSwipeMin {
255+ step := g.snap.step(e.Cancelled, e.Time)
256+ if step == 0 {
252257 return false
253258 }
254259 a := ActionFocusWorkspaceDown
255260 switch {
256- case g.horizontal && p < 0:
261+ case g.horizontal && step < 0:
257262 a = ActionFocusColumnLeft
258263 case g.horizontal:
259264 a = ActionFocusColumnRight
260- case p < 0:
265+ case step < 0:
261266 a = ActionFocusWorkspaceUp
262267 }
263268 // Focus actions act on the focused screen: the swipe's, unless the
@@ -9,10 +9,12 @@ import (
9 )9 )
10 10
11 // A three-finger swipe follows the fingers: sideways it scrolls the columns,11 // A three-finger swipe follows the fingers: sideways it scrolls the columns,
12-// up or down it slides between workspaces. When the fingers lift, the view12+// up or down it slides between workspaces. Each swipe moves one step at
13-// lands where the swipe's speed would carry it and a spring takes it there13+// most (snapswipe.go): the next column edge or the next workspace. When
14-// (motion.go). Where the view cannot slide (fixed overflow, the stash, a14+// the fingers lift, a quick swipe goes on to that step, a slow one settles
15-// float, a fullscreen window), the swipe runs one focus action instead.15+// on the closest, and a spring takes the view there (motion.go). Where the
16+// view cannot slide (fixed overflow, the stash, a float, a fullscreen
17+// window, the overview), the swipe runs one focus action instead.
16 18
17 const (19 const (
18 // swipeDecide is the touchpad distance before the swipe picks its axis20 // swipeDecide is the touchpad distance before the swipe picks its axis
@@ -24,10 +26,6 @@ const (
24 // workspaceSwipeMovement is the touchpad distance that slides one26 // workspaceSwipeMovement is the touchpad distance that slides one
25 // workspace.27 // workspace.
26 workspaceSwipeMovement = 300.028 workspaceSwipeMovement = 300.0
27- // discreteSwipeMin is the projected distance a swipe needs to run a
28- // focus action where the view cannot slide, or a four-finger swipe to
29- // open or close the overview (half of niri's 300).
30- discreteSwipeMin = 150.0
31 )29 )
32 30
33 type swipeMode uint831 type swipeMode uint8
@@ -51,18 +49,20 @@ type swipeGesture struct {
51 mode swipeMode49 mode swipeMode
52 horizontal bool50 horizontal bool
53 cx, cy float6451 cx, cy float64
54- // tracker adds up the movement along the chosen axis, natural scroll52+ // snap follows the movement along the chosen axis, natural scroll
55- // applied: positive scrolls right or down.53+ // applied: positive scrolls right or down. Its view is absolute: the
56- tracker swipeTracker54+ // columns' x in pixels or the monitor's fractional workspace index,
55+ // so a focus change during the swipe does not move the fingers' view.
56+ // A discrete or overview swipe counts steps of -1, 0 or 1.
57+ snap snapSwipe
57 // ws is the workspace whose columns scroll, or the active one when a58 // ws is the workspace whose columns scroll, or the active one when a
58- // workspace slide began; start is where the view was when the swipe59+ // workspace slide began.
59- // took over: its x in pixels (columns) or the monitor's fractional60+ ws *Workspace
60- // workspace index (workspaces). Both are absolute, so a focus change61+ // points are ws's column snap points when a column swipe began: the
61- // during the swipe does not move the fingers' view.62+ // swipe ends if the layout no longer has them.
62- ws *Workspace63+ points []float64
63- start float6464+ // list is the numbered workspaces when a workspace slide began: the
64- // list is the numbered workspaces when a workspace slide began: start65+ // slide indexes it, so it ends if the list changes.
65- // indexes it, so the slide ends if the list changes.
66 list []*Workspace66 list []*Workspace
67 // opens is the monitor's overviewOpens when the swipe picked its67 // opens is the monitor's overviewOpens when the swipe picked its
68 // mode: a slide the overview opened over is dropped, even once the68 // mode: a slide the overview opened over is dropped, even once the
@@ -76,6 +76,14 @@ func (g *swipeGesture) listChanged(m *Monitor) bool {
76 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)76 return m.overviewOpens != g.opens || m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list)
77 }77 }
78 78
79+// columnsChanged reports whether a column swipe lost its workspace: it is
80+// no longer shown or slidable, its snap points moved (an output resize, a
81+// panel, a window mapped or resized, other gaps), or the overview opened
82+// over it.
83+func (g *swipeGesture) columnsChanged(m *Monitor) bool {
84+ return m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() || !slices.Equal(g.ws.snapPoints(), g.points)
85+}
86+
79 // swipeSign turns finger movement into view movement: natural scroll moves87 // swipeSign turns finger movement into view movement: natural scroll moves
80 // the content with the fingers.88 // the content with the fingers.
81 func (c *Core) swipeSign() float64 {89 func (c *Core) swipeSign() float64 {
@@ -119,19 +127,19 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
119 if g.mode == swipeOverview {127 if g.mode == swipeOverview {
120 // Like niri, the overview gesture ignores natural scroll: the128 // Like niri, the overview gesture ignores natural scroll: the
121 // fingers going up open it.129 // fingers going up open it.
122- g.tracker.push(d, u.Time)130+ g.snap.push(d, u.Time)
123 return false131 return false
124 }132 }
125- g.tracker.push(d*c.swipeSign(), u.Time)133+ g.snap.push(d*c.swipeSign(), u.Time)
126 m := g.screen.mon134 m := g.screen.mon
127 switch g.mode {135 switch g.mode {
128 case swipeColumns:136 case swipeColumns:
129- if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() {137+ if g.columnsChanged(m) {
130 g.ws.stopSlide()138 g.ws.stopSlide()
131 g.mode = swipeDropped139 g.mode = swipeDropped
132 return true140 return true
133 }141 }
134- g.ws.shift = g.start + g.tracker.pos*g.ws.swipeScale() - float64(g.ws.ViewX)142+ g.ws.shift = g.snap.pos() - float64(g.ws.ViewX)
135 return true143 return true
136 case swipeWorkspaces:144 case swipeWorkspaces:
137 if g.listChanged(m) {145 if g.listChanged(m) {
@@ -139,8 +147,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool {
139 g.mode = swipeDropped147 g.mode = swipeDropped
140 return true148 return true
141 }149 }
142- f := workspaceBand.clamp(0, float64(len(m.Workspaces)-1), g.start+g.tracker.pos/workspaceSwipeMovement)150+ m.switchOff = g.snap.pos() - float64(m.Active)
143- m.switchOff = f - float64(m.Active)
144 return true151 return true
145 }152 }
146 return false153 return false
@@ -155,17 +162,17 @@ func (c *Core) decide(g *swipeGesture) {
155 w := m.Current()162 w := m.Current()
156 switch {163 switch {
157 case g.fingers == 4 && !g.horizontal:164 case g.fingers == 4 && !g.horizontal:
158- g.mode = swipeOverview165+ g.mode, g.snap = swipeOverview, newStepSwipe()
159 case g.fingers == 4:166 case g.fingers == 4:
160 // Four fingers sideways do nothing.167 // Four fingers sideways do nothing.
161 g.mode = swipeDropped168 g.mode = swipeDropped
162 case m.ov.open:169 case m.ov.open:
163 // The overview does not slide: the swipe moves its selection.170 // The overview does not slide: the swipe moves its selection.
164- g.mode = swipeDiscrete171+ g.mode, g.snap = swipeDiscrete, newStepSwipe()
165 case g.horizontal && w.slidable():172 case g.horizontal && w.slidable():
166- g.mode, g.ws = swipeColumns, w173+ g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints()
167 w.motion = nil174 w.motion = nil
168- g.start = float64(w.ViewX) + w.shift175+ g.snap = newSnapSwipe(float64(w.ViewX)+w.shift, float64(w.ViewX), w.swipeScale(), g.points, workspaceBand.scaled(float64(w.Usable.W)))
169 case !g.horizontal && m.shown == nil:176 case !g.horizontal && m.shown == nil:
170 // A landing slide measured in an older list lands at once first.177 // A landing slide measured in an older list lands at once first.
171 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {178 if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) {
@@ -174,9 +181,9 @@ func (c *Core) decide(g *swipeGesture) {
174 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]181 g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active]
175 g.list = slices.Clone(m.Workspaces)182 g.list = slices.Clone(m.Workspaces)
176 m.switchMotion, m.switchList = nil, nil183 m.switchMotion, m.switchList = nil, nil
177- g.start = float64(m.Active) + m.switchOff184+ g.snap = newSnapSwipe(float64(m.Active)+m.switchOff, float64(m.Active), 1/workspaceSwipeMovement, indexPoints(len(m.Workspaces)), workspaceBand)
178 default:185 default:
179- g.mode = swipeDiscrete186+ g.mode, g.snap = swipeDiscrete, newStepSwipe()
180 }187 }
181 }188 }
182 189
@@ -188,40 +195,38 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
188 if g == nil || !c.hasScreen(g.screen) {195 if g == nil || !c.hasScreen(g.screen) {
189 return false196 return false
190 }197 }
191- // Idle time before the lift slows the swipe down.
192- g.tracker.push(0, e.Time)
193 now := c.now()198 now := c.now()
194 m := g.screen.mon199 m := g.screen.mon
195 switch g.mode {200 switch g.mode {
196 case swipeColumns:201 case swipeColumns:
197 w := g.ws202 w := g.ws
198- if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() {203+ if g.columnsChanged(m) {
199 w.stopSlide()204 w.stopSlide()
200 return false205 return false
201 }206 }
202- scale := w.swipeScale()
203 shown := float64(w.ViewX) + w.shift207 shown := float64(w.ViewX) + w.shift
208+ target, velocity := g.snap.end(e.Cancelled, e.Time)
204 if !e.Cancelled {209 if !e.Cancelled {
205- target := g.start + g.tracker.projectedEnd()*scale210+ view := int(math.Round(target))
206- w.ViewX, w.Focus = w.snap(target, target >= shown)211+ w.Focus = w.snapFocus(view, target >= shown)
212+ w.ViewX = view
207 // A column wider than the view aligns as focus moves align it.213 // A column wider than the view aligns as focus moves align it.
208 w.scroll()214 w.scroll()
209 c.focusScreen = c.screenIndex(g.screen.name())215 c.focusScreen = c.screenIndex(g.screen.name())
210 c.keyboard.takeBack()216 c.keyboard.takeBack()
211 }217 }
212 w.shift = shown - float64(w.ViewX)218 w.shift = shown - float64(w.ViewX)
213- w.motion = newMotion(viewSpring(w.shift, g.tracker.velocity()*scale), now)219+ w.motion = newMotion(viewSpring(w.shift, velocity), now)
214 case swipeWorkspaces:220 case swipeWorkspaces:
215 if g.listChanged(m) {221 if g.listChanged(m) {
216 m.stopSwitch()222 m.stopSwitch()
217 return false223 return false
218 }224 }
219- last := float64(len(m.Workspaces) - 1)
220 cur := float64(m.Active) + m.switchOff225 cur := float64(m.Active) + m.switchOff
221- velocity := g.tracker.velocity() / workspaceSwipeMovement * workspaceBand.clampDerivative(0, last, g.start+g.tracker.pos/workspaceSwipeMovement)226+ target, velocity := g.snap.end(e.Cancelled, e.Time)
222 idx := m.Active227 idx := m.Active
223 if !e.Cancelled {228 if !e.Cancelled {
224- idx = int(math.Round(min(max(g.start+g.tracker.projectedEnd()/workspaceSwipeMovement, 0), last)))229+ idx = int(math.Round(target))
225 }230 }
226 // Focus may drop the empty workspace left behind and renumber the231 // Focus may drop the empty workspace left behind and renumber the
227 // list: the slide keeps measuring in the list it began on.232 // list: the slide keeps measuring in the list it began on.
@@ -237,8 +242,8 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
237 m.switchOff, m.switchList = off, g.list242 m.switchOff, m.switchList = off, g.list
238 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)243 m.switchMotion = newMotion(workspaceSpring(off, velocity), now)
239 case swipeOverview:244 case swipeOverview:
240- p := g.tracker.projectedEnd()245+ step := g.snap.step(e.Cancelled, e.Time)
241- if e.Cancelled || math.Abs(p) < discreteSwipeMin || (p < 0) == m.ov.open {246+ if step == 0 || (step < 0) == m.ov.open {
242 return false247 return false
243 }248 }
244 before := m.Current()249 before := m.Current()
@@ -247,17 +252,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
247 c.keyboard.takeBack()252 c.keyboard.takeBack()
248 return m.Current() != before253 return m.Current() != before
249 case swipeDiscrete:254 case swipeDiscrete:
250- p := g.tracker.projectedEnd()255+ step := g.snap.step(e.Cancelled, e.Time)
251- if e.Cancelled || math.Abs(p) < discreteSwipeMin {256+ if step == 0 {
252 return false257 return false
253 }258 }
254 a := ActionFocusWorkspaceDown259 a := ActionFocusWorkspaceDown
255 switch {260 switch {
256- case g.horizontal && p < 0:261+ case g.horizontal && step < 0:
257 a = ActionFocusColumnLeft262 a = ActionFocusColumnLeft
258 case g.horizontal:263 case g.horizontal:
259 a = ActionFocusColumnRight264 a = ActionFocusColumnRight
260- case p < 0:265+ case step < 0:
261 a = ActionFocusWorkspaceUp266 a = ActionFocusWorkspaceUp
262 }267 }
263 // Focus actions act on the focused screen: the swipe's, unless the268 // Focus actions act on the focused screen: the swipe's, unless the
M internal/core/input.go +1 -0
@@ -136,6 +136,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
136136 }
137137 return nil
138138 case ports.PointerAxis:
139+ c.scrollStop(v)
139140 // In the overview, scrolling moves the selection.
140141 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
141142 if c.cur().mon.overviewScroll(v) {
@@ -136,6 +136,7 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
136 }136 }
137 return nil137 return nil
138 case ports.PointerAxis:138 case ports.PointerAxis:
139+ c.scrollStop(v)
139 // In the overview, scrolling moves the selection.140 // In the overview, scrolling moves the selection.
140 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {141 if c.cur().mon.ov.open && !c.overviewKeyboardTaken() {
141 if c.cur().mon.overviewScroll(v) {142 if c.cur().mon.overviewScroll(v) {
M internal/core/motion.go +7 -1
@@ -178,9 +178,15 @@ func (m *motion) at(now time.Time) (float64, bool) {
178178 // it moves, never more than limit.
179179 type rubberBand struct{ stiffness, limit float64 }
180180
181-// workspaceBand holds a workspace swipe past the first or last workspace.
181+// workspaceBand holds a swipe past the steps it may reach, in steps: a
182+// twentieth of a workspace at most. Column swipes scale it to pixels.
182183 var workspaceBand = rubberBand{stiffness: 0.5, limit: 0.05}
183184
185+// scaled is the band measured in units k times smaller.
186+func (r rubberBand) scaled(k float64) rubberBand {
187+ return rubberBand{stiffness: r.stiffness, limit: r.limit * k}
188+}
189+
184190 func (r rubberBand) band(x float64) float64 {
185191 return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit
186192 }
@@ -178,9 +178,15 @@ func (m *motion) at(now time.Time) (float64, bool) {
178 // it moves, never more than limit.178 // it moves, never more than limit.
179 type rubberBand struct{ stiffness, limit float64 }179 type rubberBand struct{ stiffness, limit float64 }
180 180
181-// workspaceBand holds a workspace swipe past the first or last workspace.181+// workspaceBand holds a swipe past the steps it may reach, in steps: a
182+// twentieth of a workspace at most. Column swipes scale it to pixels.
182 var workspaceBand = rubberBand{stiffness: 0.5, limit: 0.05}183 var workspaceBand = rubberBand{stiffness: 0.5, limit: 0.05}
183 184
185+// scaled is the band measured in units k times smaller.
186+func (r rubberBand) scaled(k float64) rubberBand {
187+ return rubberBand{stiffness: r.stiffness, limit: r.limit * k}
188+}
189+
184 func (r rubberBand) band(x float64) float64 {190 func (r rubberBand) band(x float64) float64 {
185 return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit191 return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit
186 }192 }
M internal/core/motion_test.go +236 -10
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "math"
5+ "slices"
56 "testing"
67 "time"
78 )
@@ -122,21 +123,246 @@ func TestSnap(t *testing.T) {
122123 for i := range 4 {
123124 w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
124125 }
126+ if got := w.snapPoints(); !slices.Equal(got, []float64{0, 400, 800}) {
127+ t.Fatalf("snap points %v", got)
128+ }
125129 for _, tc := range []struct {
126- target float64
127- forward bool
128130 view int
131+ forward bool
129132 focus int
130133 }{
131- {target: 130, forward: true, view: 0, focus: 0},
132- {target: 330, forward: true, view: 400, focus: 2},
133- {target: 330, forward: false, view: 400, focus: 1},
134- {target: 5000, forward: true, view: 800, focus: 3},
135- {target: -900, forward: false, view: 0, focus: 0},
134+ {view: 0, forward: true, focus: 0},
135+ {view: 400, forward: true, focus: 2},
136+ {view: 400, forward: false, focus: 1},
137+ {view: 800, forward: true, focus: 3},
138+ } {
139+ if focus := w.snapFocus(tc.view, tc.forward); focus != tc.focus {
140+ t.Errorf("snapFocus(%d, %t) = %d; want %d", tc.view, tc.forward, focus, tc.focus)
141+ }
142+ }
143+}
144+
145+// Column edges closer than snapSpacing merge: a step always shows. The
146+// last column's end stays, dropping a point close before it.
147+func TestSnapPointsMergeClose(t *testing.T) {
148+ for _, tc := range []struct {
149+ widths []int
150+ want []float64
151+ }{
152+ // Views aligning a column: 0, 600, 640 (merged into 600), 1000.
153+ {[]int{600, 400, 640, 360}, []float64{0, 600, 1000}},
154+ // 0, 360, 600, 960 and the end 1000: 960 goes, 1000 stays.
155+ {[]int{600, 360, 400, 640}, []float64{0, 360, 600, 1000}},
156+ // Columns narrower than the view: one point.
157+ {[]int{300, 300}, []float64{0}},
158+ } {
159+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}
160+ for _, px := range tc.widths {
161+ w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162+ }
163+ if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164+ t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
165+ }
166+ }
167+}
168+
169+// swipe pushes n updates of delta, 8 ms apart, from time 0.
170+func swipe(s *snapSwipe, n int, delta float64) time.Duration {
171+ at := time.Duration(0)
172+ for range n {
173+ at += 8 * time.Millisecond
174+ s.push(delta, at)
175+ }
176+ return at
177+}
178+
179+// slowSwipe pushes dist over two seconds and rests before the lift.
180+func slowSwipe(s *snapSwipe, dist float64) time.Duration {
181+ at := time.Duration(0)
182+ for range 40 {
183+ at += 50 * time.Millisecond
184+ s.push(dist/40, at)
185+ }
186+ return at + 200*time.Millisecond
187+}
188+
189+func workspaceSnap(start float64) snapSwipe {
190+ return newSnapSwipe(start, math.Round(start), 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
191+}
192+
193+// One swipe reaches the next workspace at most, however fast or far: a
194+// slow one settles on the closest, back unless past halfway; a cancelled
195+// one returns.
196+func TestSnapSwipeOneStep(t *testing.T) {
197+ for _, tc := range []struct {
198+ name string
199+ push func(*snapSwipe) time.Duration
200+ cancelled bool
201+ want float64
202+ }{
203+ {"quick flick down", func(s *snapSwipe) time.Duration { return swipe(s, 6, 30) }, false, 3},
204+ {"quick flick up", func(s *snapSwipe) time.Duration { return swipe(s, 6, -30) }, false, 1},
205+ {"hard long flick", func(s *snapSwipe) time.Duration { return swipe(s, 30, 60) }, false, 3},
206+ {"short quick flick", func(s *snapSwipe) time.Duration { return swipe(s, 3, 20) }, false, 3},
207+ {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, 100) }, false, 2},
208+ {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, 200) }, false, 3},
209+ {"slow far up", func(s *snapSwipe) time.Duration { return slowSwipe(s, -900) }, false, 1},
210+ {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 10, 30) }, true, 2},
211+ // Most of the way down, then a hard flick back: one step up.
212+ {"forward then hard back", func(s *snapSwipe) time.Duration {
213+ at := slowSwipe(s, 250)
214+ for range 4 {
215+ at += 8 * time.Millisecond
216+ s.push(-30, at)
217+ }
218+ return at
219+ }, false, 1},
220+ } {
221+ s := workspaceSnap(2)
222+ at := tc.push(&s)
223+ if p := s.pos(); p < 1-workspaceBand.limit || p > 3+workspaceBand.limit {
224+ t.Errorf("%s: view %v went past the neighbors", tc.name, p)
225+ }
226+ if got, _ := s.end(tc.cancelled, at); got != tc.want {
227+ t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
228+ }
229+ }
230+}
231+
232+// A swipe caught during a landing steps from the landing: one more.
233+func TestSnapSwipeDuringLanding(t *testing.T) {
234+ s := newSnapSwipe(2.6, 3, 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
235+ at := swipe(&s, 6, 30)
236+ if p := s.pos(); p > 4+workspaceBand.limit {
237+ t.Fatalf("view %v went past 4", p)
238+ }
239+ if got, _ := s.end(false, at); got != 4 {
240+ t.Fatalf("landed on %v, want 4", got)
241+ }
242+}
243+
244+// From a view between points (a merged column edge) a slow swipe never
245+// settles behind where it started; a quick one reaches the points around.
246+func TestSnapSwipeRestBetweenPoints(t *testing.T) {
247+ for _, tc := range []struct {
248+ name string
249+ push func(*snapSwipe) time.Duration
250+ want float64
251+ }{
252+ {"slow", func(s *snapSwipe) time.Duration { return slowSwipe(s, 5) }, 640},
253+ {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 4, -40) }, 600},
254+ {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 4, 40) }, 1000},
255+ } {
256+ s := newSnapSwipe(640, 640, 1, []float64{0, 600, 1000}, workspaceBand.scaled(1000))
257+ at := tc.push(&s)
258+ if got, _ := s.end(false, at); got != tc.want {
259+ t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
260+ }
261+ }
262+}
263+
264+// A swipe caught while sliding to a far workspace lands on that one or
265+// its neighbors, never back where the slide shows.
266+func TestSnapSwipeCaughtFarFromRest(t *testing.T) {
267+ for _, delta := range []float64{-60, 60} {
268+ s := newSnapSwipe(0.5, 4, 1/workspaceSwipeMovement, indexPoints(6), workspaceBand)
269+ at := swipe(&s, 20, delta)
270+ if got, _ := s.end(false, at); got < 3 || got > 5 {
271+ t.Errorf("delta %v: landed on %v, want 3 to 5", delta, got)
272+ }
273+ }
274+}
275+
276+// The view follows the fingers in their direction, without jumps, from a
277+// start on a point, between points, or caught past the edge (a spring back
278+// from the rubber band); near a point it sticks, a full step shows whole.
279+func TestSnapSwipeDetents(t *testing.T) {
280+ for _, start := range []float64{2, 2.4, -0.03, 4.03} {
281+ s := workspaceSnap(start)
282+ if p := s.pos(); math.Abs(p-start) > 1e-9 {
283+ t.Fatalf("start %v shows %v", start, p)
284+ }
285+ for _, dir := range []float64{1, -1} {
286+ s := workspaceSnap(start)
287+ prev := s.pos()
288+ for i := range 400 {
289+ s.push(dir, time.Duration(i)*time.Millisecond)
290+ p := s.pos()
291+ if (p-prev)*dir < 0 || math.Abs(p-prev) > 0.01 {
292+ t.Fatalf("start %v dir %v: view %v then %v at %d", start, dir, prev, p, i)
293+ }
294+ prev = p
295+ }
296+ }
297+ }
298+ s := workspaceSnap(2)
299+ s.push(300, 0)
300+ if p := s.pos(); math.Abs(p-3) > 1e-9 {
301+ t.Fatalf("a full step shows %v", p)
302+ }
303+ s = workspaceSnap(2)
304+ s.push(30, 0)
305+ if p := s.pos(); p >= 2.1 || p <= 2 {
306+ t.Fatalf("near a point the view moved to %v for 0.1", p)
307+ }
308+}
309+
310+// One workspace: nowhere to go, the view resists and stays.
311+func TestSnapSwipeSinglePoint(t *testing.T) {
312+ s := newSnapSwipe(0, 0, 1/workspaceSwipeMovement, indexPoints(1), workspaceBand)
313+ at := swipe(&s, 10, 40)
314+ if p := s.pos(); p <= 0 || p > workspaceBand.limit {
315+ t.Fatalf("view %v", p)
316+ }
317+ target, velocity := s.end(false, at)
318+ if target != 0 || math.IsNaN(velocity) {
319+ t.Fatalf("landed on %v at %v", target, velocity)
320+ }
321+}
322+
323+func TestStepSwipe(t *testing.T) {
324+ for _, tc := range []struct {
325+ name string
326+ push func(*snapSwipe) time.Duration
327+ cancelled bool
328+ want int
329+ }{
330+ {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 3, -40) }, false, -1},
331+ {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, false, 1},
332+ {"hard", func(s *snapSwipe) time.Duration { return swipe(s, 30, 80) }, false, 1},
333+ {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, -100) }, false, 0},
334+ {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, -200) }, false, -1},
335+ {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, true, 0},
136336 } {
137- view, focus := w.snap(tc.target, tc.forward)
138- if view != tc.view || focus != tc.focus {
139- t.Errorf("snap(%v, %t) = %d, %d; want %d, %d", tc.target, tc.forward, view, focus, tc.view, tc.focus)
337+ s := newStepSwipe()
338+ at := tc.push(&s)
339+ target, v := s.end(tc.cancelled, at)
340+ if got := int(math.Round(target)); got != tc.want {
341+ t.Errorf("%s: step %d, want %d", tc.name, got, tc.want)
342+ }
343+ if math.IsNaN(v) {
344+ t.Errorf("%s: velocity NaN", tc.name)
345+ }
346+ }
347+}
348+
349+// With no column fully shown (a column wider than the space between the
350+// gaps), the focus goes to the column at the left edge.
351+func TestSnapFocusWideColumn(t *testing.T) {
352+ w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, Gaps: 10}
353+ for i := range 3 {
354+ w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
355+ }
356+ // Full width with gaps: 780 wide, between 10 and 790. The view
357+ // columnX(1)+1 shifts it 1 px left, so it is not fully shown.
358+ w.Columns[1].FullWidth = true
359+ if !w.slidable() {
360+ t.Fatal("the workspace does not scroll")
361+ }
362+ view := w.columnX(1) - w.Usable.X - w.gap() + 1
363+ for _, forward := range []bool{true, false} {
364+ if focus := w.snapFocus(view, forward); focus != 1 {
365+ t.Fatalf("forward=%t: focus %d, want the wide column 1", forward, focus)
140366 }
141367 }
142368 }
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "math"4 "math"
5+ "slices"
5 "testing"6 "testing"
6 "time"7 "time"
7 )8 )
@@ -122,21 +123,246 @@ func TestSnap(t *testing.T) {
122 for i := range 4 {123 for i := range 4 {
123 w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})124 w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
124 }125 }
126+ if got := w.snapPoints(); !slices.Equal(got, []float64{0, 400, 800}) {
127+ t.Fatalf("snap points %v", got)
128+ }
125 for _, tc := range []struct {129 for _, tc := range []struct {
126- target float64
127- forward bool
128 view int130 view int
131+ forward bool
129 focus int132 focus int
130 }{133 }{
131- {target: 130, forward: true, view: 0, focus: 0},134+ {view: 0, forward: true, focus: 0},
132- {target: 330, forward: true, view: 400, focus: 2},135+ {view: 400, forward: true, focus: 2},
133- {target: 330, forward: false, view: 400, focus: 1},136+ {view: 400, forward: false, focus: 1},
134- {target: 5000, forward: true, view: 800, focus: 3},137+ {view: 800, forward: true, focus: 3},
135- {target: -900, forward: false, view: 0, focus: 0},138+ } {
139+ if focus := w.snapFocus(tc.view, tc.forward); focus != tc.focus {
140+ t.Errorf("snapFocus(%d, %t) = %d; want %d", tc.view, tc.forward, focus, tc.focus)
141+ }
142+ }
143+}
144+
145+// Column edges closer than snapSpacing merge: a step always shows. The
146+// last column's end stays, dropping a point close before it.
147+func TestSnapPointsMergeClose(t *testing.T) {
148+ for _, tc := range []struct {
149+ widths []int
150+ want []float64
151+ }{
152+ // Views aligning a column: 0, 600, 640 (merged into 600), 1000.
153+ {[]int{600, 400, 640, 360}, []float64{0, 600, 1000}},
154+ // 0, 360, 600, 960 and the end 1000: 960 goes, 1000 stays.
155+ {[]int{600, 360, 400, 640}, []float64{0, 360, 600, 1000}},
156+ // Columns narrower than the view: one point.
157+ {[]int{300, 300}, []float64{0}},
158+ } {
159+ w := &Workspace{Output: Rect{W: 1000, H: 600}, Usable: Rect{W: 1000, H: 600}}
160+ for _, px := range tc.widths {
161+ w.Columns = append(w.Columns, Column{Width: Width{Pixels: px}, Windows: []WindowID{WindowID(len(w.Columns) + 1)}})
162+ }
163+ if got := w.snapPoints(); !slices.Equal(got, tc.want) {
164+ t.Errorf("widths %v: snap points %v, want %v", tc.widths, got, tc.want)
165+ }
166+ }
167+}
168+
169+// swipe pushes n updates of delta, 8 ms apart, from time 0.
170+func swipe(s *snapSwipe, n int, delta float64) time.Duration {
171+ at := time.Duration(0)
172+ for range n {
173+ at += 8 * time.Millisecond
174+ s.push(delta, at)
175+ }
176+ return at
177+}
178+
179+// slowSwipe pushes dist over two seconds and rests before the lift.
180+func slowSwipe(s *snapSwipe, dist float64) time.Duration {
181+ at := time.Duration(0)
182+ for range 40 {
183+ at += 50 * time.Millisecond
184+ s.push(dist/40, at)
185+ }
186+ return at + 200*time.Millisecond
187+}
188+
189+func workspaceSnap(start float64) snapSwipe {
190+ return newSnapSwipe(start, math.Round(start), 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
191+}
192+
193+// One swipe reaches the next workspace at most, however fast or far: a
194+// slow one settles on the closest, back unless past halfway; a cancelled
195+// one returns.
196+func TestSnapSwipeOneStep(t *testing.T) {
197+ for _, tc := range []struct {
198+ name string
199+ push func(*snapSwipe) time.Duration
200+ cancelled bool
201+ want float64
202+ }{
203+ {"quick flick down", func(s *snapSwipe) time.Duration { return swipe(s, 6, 30) }, false, 3},
204+ {"quick flick up", func(s *snapSwipe) time.Duration { return swipe(s, 6, -30) }, false, 1},
205+ {"hard long flick", func(s *snapSwipe) time.Duration { return swipe(s, 30, 60) }, false, 3},
206+ {"short quick flick", func(s *snapSwipe) time.Duration { return swipe(s, 3, 20) }, false, 3},
207+ {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, 100) }, false, 2},
208+ {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, 200) }, false, 3},
209+ {"slow far up", func(s *snapSwipe) time.Duration { return slowSwipe(s, -900) }, false, 1},
210+ {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 10, 30) }, true, 2},
211+ // Most of the way down, then a hard flick back: one step up.
212+ {"forward then hard back", func(s *snapSwipe) time.Duration {
213+ at := slowSwipe(s, 250)
214+ for range 4 {
215+ at += 8 * time.Millisecond
216+ s.push(-30, at)
217+ }
218+ return at
219+ }, false, 1},
220+ } {
221+ s := workspaceSnap(2)
222+ at := tc.push(&s)
223+ if p := s.pos(); p < 1-workspaceBand.limit || p > 3+workspaceBand.limit {
224+ t.Errorf("%s: view %v went past the neighbors", tc.name, p)
225+ }
226+ if got, _ := s.end(tc.cancelled, at); got != tc.want {
227+ t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
228+ }
229+ }
230+}
231+
232+// A swipe caught during a landing steps from the landing: one more.
233+func TestSnapSwipeDuringLanding(t *testing.T) {
234+ s := newSnapSwipe(2.6, 3, 1/workspaceSwipeMovement, indexPoints(5), workspaceBand)
235+ at := swipe(&s, 6, 30)
236+ if p := s.pos(); p > 4+workspaceBand.limit {
237+ t.Fatalf("view %v went past 4", p)
238+ }
239+ if got, _ := s.end(false, at); got != 4 {
240+ t.Fatalf("landed on %v, want 4", got)
241+ }
242+}
243+
244+// From a view between points (a merged column edge) a slow swipe never
245+// settles behind where it started; a quick one reaches the points around.
246+func TestSnapSwipeRestBetweenPoints(t *testing.T) {
247+ for _, tc := range []struct {
248+ name string
249+ push func(*snapSwipe) time.Duration
250+ want float64
251+ }{
252+ {"slow", func(s *snapSwipe) time.Duration { return slowSwipe(s, 5) }, 640},
253+ {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 4, -40) }, 600},
254+ {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 4, 40) }, 1000},
255+ } {
256+ s := newSnapSwipe(640, 640, 1, []float64{0, 600, 1000}, workspaceBand.scaled(1000))
257+ at := tc.push(&s)
258+ if got, _ := s.end(false, at); got != tc.want {
259+ t.Errorf("%s: landed on %v, want %v", tc.name, got, tc.want)
260+ }
261+ }
262+}
263+
264+// A swipe caught while sliding to a far workspace lands on that one or
265+// its neighbors, never back where the slide shows.
266+func TestSnapSwipeCaughtFarFromRest(t *testing.T) {
267+ for _, delta := range []float64{-60, 60} {
268+ s := newSnapSwipe(0.5, 4, 1/workspaceSwipeMovement, indexPoints(6), workspaceBand)
269+ at := swipe(&s, 20, delta)
270+ if got, _ := s.end(false, at); got < 3 || got > 5 {
271+ t.Errorf("delta %v: landed on %v, want 3 to 5", delta, got)
272+ }
273+ }
274+}
275+
276+// The view follows the fingers in their direction, without jumps, from a
277+// start on a point, between points, or caught past the edge (a spring back
278+// from the rubber band); near a point it sticks, a full step shows whole.
279+func TestSnapSwipeDetents(t *testing.T) {
280+ for _, start := range []float64{2, 2.4, -0.03, 4.03} {
281+ s := workspaceSnap(start)
282+ if p := s.pos(); math.Abs(p-start) > 1e-9 {
283+ t.Fatalf("start %v shows %v", start, p)
284+ }
285+ for _, dir := range []float64{1, -1} {
286+ s := workspaceSnap(start)
287+ prev := s.pos()
288+ for i := range 400 {
289+ s.push(dir, time.Duration(i)*time.Millisecond)
290+ p := s.pos()
291+ if (p-prev)*dir < 0 || math.Abs(p-prev) > 0.01 {
292+ t.Fatalf("start %v dir %v: view %v then %v at %d", start, dir, prev, p, i)
293+ }
294+ prev = p
295+ }
296+ }
297+ }
298+ s := workspaceSnap(2)
299+ s.push(300, 0)
300+ if p := s.pos(); math.Abs(p-3) > 1e-9 {
301+ t.Fatalf("a full step shows %v", p)
302+ }
303+ s = workspaceSnap(2)
304+ s.push(30, 0)
305+ if p := s.pos(); p >= 2.1 || p <= 2 {
306+ t.Fatalf("near a point the view moved to %v for 0.1", p)
307+ }
308+}
309+
310+// One workspace: nowhere to go, the view resists and stays.
311+func TestSnapSwipeSinglePoint(t *testing.T) {
312+ s := newSnapSwipe(0, 0, 1/workspaceSwipeMovement, indexPoints(1), workspaceBand)
313+ at := swipe(&s, 10, 40)
314+ if p := s.pos(); p <= 0 || p > workspaceBand.limit {
315+ t.Fatalf("view %v", p)
316+ }
317+ target, velocity := s.end(false, at)
318+ if target != 0 || math.IsNaN(velocity) {
319+ t.Fatalf("landed on %v at %v", target, velocity)
320+ }
321+}
322+
323+func TestStepSwipe(t *testing.T) {
324+ for _, tc := range []struct {
325+ name string
326+ push func(*snapSwipe) time.Duration
327+ cancelled bool
328+ want int
329+ }{
330+ {"quick back", func(s *snapSwipe) time.Duration { return swipe(s, 3, -40) }, false, -1},
331+ {"quick on", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, false, 1},
332+ {"hard", func(s *snapSwipe) time.Duration { return swipe(s, 30, 80) }, false, 1},
333+ {"slow short", func(s *snapSwipe) time.Duration { return slowSwipe(s, -100) }, false, 0},
334+ {"slow past half", func(s *snapSwipe) time.Duration { return slowSwipe(s, -200) }, false, -1},
335+ {"cancelled", func(s *snapSwipe) time.Duration { return swipe(s, 3, 40) }, true, 0},
136 } {336 } {
137- view, focus := w.snap(tc.target, tc.forward)337+ s := newStepSwipe()
138- if view != tc.view || focus != tc.focus {338+ at := tc.push(&s)
139- t.Errorf("snap(%v, %t) = %d, %d; want %d, %d", tc.target, tc.forward, view, focus, tc.view, tc.focus)339+ target, v := s.end(tc.cancelled, at)
340+ if got := int(math.Round(target)); got != tc.want {
341+ t.Errorf("%s: step %d, want %d", tc.name, got, tc.want)
342+ }
343+ if math.IsNaN(v) {
344+ t.Errorf("%s: velocity NaN", tc.name)
345+ }
346+ }
347+}
348+
349+// With no column fully shown (a column wider than the space between the
350+// gaps), the focus goes to the column at the left edge.
351+func TestSnapFocusWideColumn(t *testing.T) {
352+ w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, Gaps: 10}
353+ for i := range 3 {
354+ w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}})
355+ }
356+ // Full width with gaps: 780 wide, between 10 and 790. The view
357+ // columnX(1)+1 shifts it 1 px left, so it is not fully shown.
358+ w.Columns[1].FullWidth = true
359+ if !w.slidable() {
360+ t.Fatal("the workspace does not scroll")
361+ }
362+ view := w.columnX(1) - w.Usable.X - w.gap() + 1
363+ for _, forward := range []bool{true, false} {
364+ if focus := w.snapFocus(view, forward); focus != 1 {
365+ t.Fatalf("forward=%t: focus %d, want the wide column 1", forward, focus)
140 }366 }
141 }367 }
142 }368 }
M internal/core/overview.go +64 -14
@@ -24,6 +24,11 @@ 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
28+ // 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 per
30+ // overviewScrollRepeat.
31+ scrolled, sideways bool
2732 }
2833
2934 // rowSnapshot is a row as the overview first showed it: browsing moves its
@@ -71,7 +76,7 @@ func (m *Monitor) ToggleOverview() {
7176 m.stopSwitch()
7277 m.each(func(w *Workspace) { w.stopSlide() })
7378 m.ov.open, m.ov.from, m.ov.back = true, w, m.back
74- m.ov.scrollX, m.ov.scrollY = 0, 0
79+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
7580 m.overviewOpens++
7681 m.selectRow()
7782 }
@@ -892,21 +897,45 @@ func (m *Monitor) overviewFocus(a Action) bool {
892897 return true
893898 }
894899
895-// overviewScrollStep is the two-finger scroll distance, in libinput's
896-// pointer units, that moves the selection one column or workspace.
897-const overviewScrollStep = 60
900+const (
901+ // overviewScrollStep is the scroll distance, in libinput's pointer
902+ // units, that moves the selection one column or workspace.
903+ overviewScrollStep = 60
904+ // overviewScrollRepeat is the two-finger distance of each further
905+ // column step in one scroll: longer than the first, so a short scroll
906+ // reaches the next column exactly and a long one goes on.
907+ overviewScrollRepeat = 150
908+)
898909
899-// overviewScroll moves the overview selection with a scroll frame: two
900-// fingers step once per overviewScrollStep on the axis they move most
901-// along, a wheel once per notch (high-resolution wheels add up their
902-// fractions of a notch). Vertical steps select cards first, then rows;
903-// touchpad.natural-scroll flips both axes as libinput reports.
910+// overviewScroll moves the overview selection with a scroll frame: a row
911+// step when the vertical scroll reaches overviewScrollStep and leads, else
912+// a column step. Two fingers step to the next column after
913+// overviewScrollStep, then once per overviewScrollRepeat, and stay on
914+// columns until they lift; up or down they step once per scroll, so a card
915+// in a stack is easy to pick.
916+// Continuous scrolling steps once per overviewScrollStep and a wheel once
917+// per notch (high-resolution wheels add up their fractions of a notch).
918+// Vertical steps select cards first, then rows; touchpad.natural-scroll
919+// flips both axes as libinput reports. The end of a scroll resets it
920+// (scrollStop).
904921 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
905922 if a.Vertical.Stop || a.Horizontal.Stop {
906- // Fingers lifted: the next scroll starts from zero.
907- m.ov.scrollX, m.ov.scrollY = 0, 0
908923 return false
909924 }
925+ finger := a.Source == ports.AxisFinger
926+ if finger && m.ov.scrolled {
927+ return false
928+ }
929+ // A sideways finger scroll stays on columns; other sources do not, and
930+ // start from zero rather than from its leftover.
931+ sideways := finger && m.ov.sideways
932+ if !finger && m.ov.sideways {
933+ m.ov.scrollX, m.ov.scrollY = 0, 0
934+ }
935+ stepX := float64(overviewScrollStep)
936+ if sideways {
937+ stepX = overviewScrollRepeat
938+ }
910939 add := func(acc *float64, ax ports.ScrollAxis) {
911940 switch {
912941 case !ax.Set:
@@ -916,7 +945,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
916945 *acc += ax.Value
917946 }
918947 }
919- add(&m.ov.scrollY, a.Vertical)
948+ if !sideways {
949+ add(&m.ov.scrollY, a.Vertical)
950+ }
920951 add(&m.ov.scrollX, a.Horizontal)
921952 for {
922953 switch {
@@ -924,10 +955,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
924955 m.OverviewMove(0, sign(m.ov.scrollY))
925956 m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
926957 m.ov.scrollX = 0
927- case math.Abs(m.ov.scrollX) >= overviewScrollStep:
958+ if finger {
959+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
960+ return true
961+ }
962+ case math.Abs(m.ov.scrollX) >= stepX:
928963 m.OverviewMove(sign(m.ov.scrollX), 0)
929- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep
964+ m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX
930965 m.ov.scrollY = 0
966+ if finger {
967+ m.ov.sideways, stepX = true, overviewScrollRepeat
968+ }
931969 default:
932970 return changed
933971 }
@@ -935,6 +973,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
935973 }
936974 }
937975
976+// scrollStop ends a scroll on every monitor: the fingers lifted, maybe
977+// after the pointer took the focus to another output, so the next scroll
978+// starts from zero wherever it goes.
979+func (c *Core) scrollStop(a ports.PointerAxis) {
980+ if !a.Vertical.Stop && !a.Horizontal.Stop {
981+ return
982+ }
983+ for _, sc := range c.screens {
984+ sc.mon.ov.scrollX, sc.mon.ov.scrollY, sc.mon.ov.scrolled, sc.mon.ov.sideways = 0, 0, false, false
985+ }
986+}
987+
938988 func sign(v float64) int {
939989 if v < 0 {
940990 return -1
@@ -24,6 +24,11 @@ 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: it
28+ // 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 per
30+ // overviewScrollRepeat.
31+ scrolled, sideways bool
27 }32 }
28 33
29 // rowSnapshot is a row as the overview first showed it: browsing moves its34 // rowSnapshot is a row as the overview first showed it: browsing moves its
@@ -71,7 +76,7 @@ func (m *Monitor) ToggleOverview() {
71 m.stopSwitch()76 m.stopSwitch()
72 m.each(func(w *Workspace) { w.stopSlide() })77 m.each(func(w *Workspace) { w.stopSlide() })
73 m.ov.open, m.ov.from, m.ov.back = true, w, m.back78 m.ov.open, m.ov.from, m.ov.back = true, w, m.back
74- m.ov.scrollX, m.ov.scrollY = 0, 079+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled, m.ov.sideways = 0, 0, false, false
75 m.overviewOpens++80 m.overviewOpens++
76 m.selectRow()81 m.selectRow()
77 }82 }
@@ -892,21 +897,45 @@ func (m *Monitor) overviewFocus(a Action) bool {
892 return true897 return true
893 }898 }
894 899
895-// overviewScrollStep is the two-finger scroll distance, in libinput's900+const (
896-// pointer units, that moves the selection one column or workspace.901+ // overviewScrollStep is the scroll distance, in libinput's pointer
897-const overviewScrollStep = 60902+ // units, that moves the selection one column or workspace.
903+ overviewScrollStep = 60
904+ // overviewScrollRepeat is the two-finger distance of each further
905+ // column step in one scroll: longer than the first, so a short scroll
906+ // reaches the next column exactly and a long one goes on.
907+ overviewScrollRepeat = 150
908+)
898 909
899-// overviewScroll moves the overview selection with a scroll frame: two910+// overviewScroll moves the overview selection with a scroll frame: a row
900-// fingers step once per overviewScrollStep on the axis they move most911+// step when the vertical scroll reaches overviewScrollStep and leads, else
901-// along, a wheel once per notch (high-resolution wheels add up their912+// a column step. Two fingers step to the next column after
902-// fractions of a notch). Vertical steps select cards first, then rows;913+// overviewScrollStep, then once per overviewScrollRepeat, and stay on
903-// touchpad.natural-scroll flips both axes as libinput reports.914+// columns until they lift; up or down they step once per scroll, so a card
915+// in a stack is easy to pick.
916+// Continuous scrolling steps once per overviewScrollStep and a wheel once
917+// per notch (high-resolution wheels add up their fractions of a notch).
918+// Vertical steps select cards first, then rows; touchpad.natural-scroll
919+// flips both axes as libinput reports. The end of a scroll resets it
920+// (scrollStop).
904 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {921 func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
905 if a.Vertical.Stop || a.Horizontal.Stop {922 if a.Vertical.Stop || a.Horizontal.Stop {
906- // Fingers lifted: the next scroll starts from zero.
907- m.ov.scrollX, m.ov.scrollY = 0, 0
908 return false923 return false
909 }924 }
925+ finger := a.Source == ports.AxisFinger
926+ if finger && m.ov.scrolled {
927+ return false
928+ }
929+ // A sideways finger scroll stays on columns; other sources do not, and
930+ // start from zero rather than from its leftover.
931+ sideways := finger && m.ov.sideways
932+ if !finger && m.ov.sideways {
933+ m.ov.scrollX, m.ov.scrollY = 0, 0
934+ }
935+ stepX := float64(overviewScrollStep)
936+ if sideways {
937+ stepX = overviewScrollRepeat
938+ }
910 add := func(acc *float64, ax ports.ScrollAxis) {939 add := func(acc *float64, ax ports.ScrollAxis) {
911 switch {940 switch {
912 case !ax.Set:941 case !ax.Set:
@@ -916,7 +945,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
916 *acc += ax.Value945 *acc += ax.Value
917 }946 }
918 }947 }
919- add(&m.ov.scrollY, a.Vertical)948+ if !sideways {
949+ add(&m.ov.scrollY, a.Vertical)
950+ }
920 add(&m.ov.scrollX, a.Horizontal)951 add(&m.ov.scrollX, a.Horizontal)
921 for {952 for {
922 switch {953 switch {
@@ -924,10 +955,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
924 m.OverviewMove(0, sign(m.ov.scrollY))955 m.OverviewMove(0, sign(m.ov.scrollY))
925 m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep956 m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep
926 m.ov.scrollX = 0957 m.ov.scrollX = 0
927- case math.Abs(m.ov.scrollX) >= overviewScrollStep:958+ if finger {
959+ m.ov.scrollX, m.ov.scrollY, m.ov.scrolled = 0, 0, true
960+ return true
961+ }
962+ case math.Abs(m.ov.scrollX) >= stepX:
928 m.OverviewMove(sign(m.ov.scrollX), 0)963 m.OverviewMove(sign(m.ov.scrollX), 0)
929- m.ov.scrollX -= float64(sign(m.ov.scrollX)) * overviewScrollStep964+ m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX
930 m.ov.scrollY = 0965 m.ov.scrollY = 0
966+ if finger {
967+ m.ov.sideways, stepX = true, overviewScrollRepeat
968+ }
931 default:969 default:
932 return changed970 return changed
933 }971 }
@@ -935,6 +973,18 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) {
935 }973 }
936 }974 }
937 975
976+// scrollStop ends a scroll on every monitor: the fingers lifted, maybe
977+// after the pointer took the focus to another output, so the next scroll
978+// starts from zero wherever it goes.
979+func (c *Core) scrollStop(a ports.PointerAxis) {
980+ if !a.Vertical.Stop && !a.Horizontal.Stop {
981+ return
982+ }
983+ for _, sc := range c.screens {
984+ sc.mon.ov.scrollX, sc.mon.ov.scrollY, sc.mon.ov.scrolled, sc.mon.ov.sideways = 0, 0, false, false
985+ }
986+}
987+
938 func sign(v float64) int {988 func sign(v float64) int {
939 if v < 0 {989 if v < 0 {
940 return -1990 return -1
M internal/core/overview_input_test.go +6 -2
@@ -226,18 +226,22 @@ func TestOverviewTwoFingerScroll(t *testing.T) {
226226 finger := func(dx, dy float64) ports.PointerAxis {
227227 return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}
228228 }
229- // 50 left, lifted, then 50 and 50 left: one step (to 2). Without the
230- // reset it would be two (to 1), and the l below would land on 2.
229+ // 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), and
231+ // the l below would land on 2.
231232 input <- finger(-50, 5)
232233 input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
233234 input <- finger(-50, 5)
234235 input <- finger(-50, 0)
236+ input <- finger(-100, 0)
235237 sceneMatch(t, scenes, focused(2))
238+ input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
236239 input <- ports.KeyEvent{Keysym: "l", Pressed: true}
237240 sceneMatch(t, scenes, focused(3))
238241 input <- ports.KeyEvent{Keysym: "l"}
239242 input <- finger(-60, 0)
240243 sceneMatch(t, scenes, focused(2))
244+ input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
241245 // A wheel notch down has nowhere to go (no workspace below with
242246 // windows); a finger scroll left moves on.
243247 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
@@ -226,18 +226,22 @@ func TestOverviewTwoFingerScroll(t *testing.T) {
226 finger := func(dx, dy float64) ports.PointerAxis {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}}227 return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}
228 }228 }
229- // 50 left, lifted, then 50 and 50 left: one step (to 2). Without the229+ // 50 left, lifted, then 50, 50 and 100 left: one step (to 2), the
230- // reset it would be two (to 1), and the l below would land on 2.230+ // next ones are longer. Without the reset it would be two (to 1), and
231+ // the l below would land on 2.
231 input <- finger(-50, 5)232 input <- finger(-50, 5)
232 input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}233 input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
233 input <- finger(-50, 5)234 input <- finger(-50, 5)
234 input <- finger(-50, 0)235 input <- finger(-50, 0)
236+ input <- finger(-100, 0)
235 sceneMatch(t, scenes, focused(2))237 sceneMatch(t, scenes, focused(2))
238+ input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
236 input <- ports.KeyEvent{Keysym: "l", Pressed: true}239 input <- ports.KeyEvent{Keysym: "l", Pressed: true}
237 sceneMatch(t, scenes, focused(3))240 sceneMatch(t, scenes, focused(3))
238 input <- ports.KeyEvent{Keysym: "l"}241 input <- ports.KeyEvent{Keysym: "l"}
239 input <- finger(-60, 0)242 input <- finger(-60, 0)
240 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}}
241 // A wheel notch down has nowhere to go (no workspace below with245 // A wheel notch down has nowhere to go (no workspace below with
242 // windows); a finger scroll left moves on.246 // windows); a finger scroll left moves on.
243 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}247 input <- ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, Value: 15, V120: 120}}
M internal/core/overview_test.go +80 -0
@@ -653,3 +653,83 @@ func TestOverviewScrollSteps(t *testing.T) {
653653 t.Fatalf("two notches from 3 landed on %d, want 1 (started on %d)", f, start)
654654 }
655655 }
656+
657+// A two-finger scroll sideways steps to the next column after
658+// overviewScrollStep, then once per overviewScrollRepeat, and stays on
659+// columns; up or down it steps once until the fingers lift. Reopening the
660+// overview, a wheel notch and continuous scrolling are not held by it.
661+func TestOverviewFingerScroll(t *testing.T) {
662+ finger := func(dx, dy float64) ports.PointerAxis {
663+ return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}
664+ }
665+ m := overviewMonitor()
666+ focused := func() WindowID { f, _ := m.Focused(); return f }
667+ m.Current().FocusID(3)
668+ m.ToggleOverview()
669+ // 70: one column; 100 more is under the longer repeat step.
670+ if !m.overviewScroll(finger(-70, 0)) || focused() != 2 {
671+ t.Fatalf("first step on %d", focused())
672+ }
673+ if m.overviewScroll(finger(-100, 0)) {
674+ t.Fatal("stepped again before overviewScrollRepeat")
675+ }
676+ // The fingers drift down: still on columns, no row step.
677+ if m.overviewScroll(finger(0, 200)) {
678+ t.Fatal("a sideways scroll stepped down")
679+ }
680+ if !m.overviewScroll(finger(-50, 0)) || focused() != 1 {
681+ t.Fatalf("long scroll on %d, want 1", focused())
682+ }
683+ // The fingers still rest: a wheel notch steps one column as always.
684+ if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Horizontal: ports.ScrollAxis{Set: true, V120: 120}}) || focused() != 2 {
685+ t.Fatalf("wheel after a sideways scroll on %d, want 2", focused())
686+ }
687+ // Once lifted, the next scroll starts with the short step again, and
688+ // one large frame takes the short step then a repeat.
689+ c := &Core{screens: []*screen{{mon: m}}}
690+ c.scrollStop(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}})
691+ if !m.overviewScroll(finger(220, 0)) || focused() != 3 {
692+ t.Fatalf("large frame on %d, want 3", focused())
693+ }
694+ // Reopening forgets a sideways scroll left without its stop.
695+ m.CancelOverview()
696+ m.Current().FocusID(3)
697+ m.ToggleOverview()
698+ if !m.overviewScroll(finger(-70, 0)) || focused() != 2 {
699+ t.Fatalf("reopened sideways scroll on %d, want 2", focused())
700+ }
701+
702+ // Vertical: one step per scroll, through the stack's cards.
703+ sm := stackMonitor()
704+ sm.ToggleOverview()
705+ before, _ := sm.Focused()
706+ if !sm.overviewScroll(finger(0, 70)) {
707+ t.Fatal("first vertical step")
708+ }
709+ after, _ := sm.Focused()
710+ if after == before {
711+ t.Fatal("vertical step selected nothing new")
712+ }
713+ if sm.overviewScroll(finger(0, 400)) {
714+ t.Fatal("the same vertical scroll stepped twice")
715+ }
716+ if !sm.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) {
717+ t.Fatal("wheel held by the finger scroll")
718+ }
719+ // A sideways finger scroll does not hold a vertical wheel notch.
720+ sm.CancelOverview()
721+ sm.ToggleOverview()
722+ sm.overviewScroll(finger(70, 0))
723+ if !sm.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) {
724+ t.Fatal("vertical wheel held by a sideways finger scroll")
725+ }
726+ sm.CancelOverview()
727+ sm.ToggleOverview()
728+ if !sm.overviewScroll(finger(0, 70)) {
729+ t.Fatal("reopened overview still held the old scroll")
730+ }
731+ cont := ports.PointerAxis{Source: ports.AxisContinuous, Vertical: ports.ScrollAxis{Set: true, Value: -70}}
732+ if !sm.overviewScroll(cont) {
733+ t.Fatal("continuous scroll held by the finger scroll")
734+ }
735+}
@@ -653,3 +653,83 @@ func TestOverviewScrollSteps(t *testing.T) {
653 t.Fatalf("two notches from 3 landed on %d, want 1 (started on %d)", f, start)653 t.Fatalf("two notches from 3 landed on %d, want 1 (started on %d)", f, start)
654 }654 }
655 }655 }
656+
657+// A two-finger scroll sideways steps to the next column after
658+// overviewScrollStep, then once per overviewScrollRepeat, and stays on
659+// columns; up or down it steps once until the fingers lift. Reopening the
660+// overview, a wheel notch and continuous scrolling are not held by it.
661+func TestOverviewFingerScroll(t *testing.T) {
662+ finger := func(dx, dy float64) ports.PointerAxis {
663+ return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}, Vertical: ports.ScrollAxis{Set: true, Value: dy}}
664+ }
665+ m := overviewMonitor()
666+ focused := func() WindowID { f, _ := m.Focused(); return f }
667+ m.Current().FocusID(3)
668+ m.ToggleOverview()
669+ // 70: one column; 100 more is under the longer repeat step.
670+ if !m.overviewScroll(finger(-70, 0)) || focused() != 2 {
671+ t.Fatalf("first step on %d", focused())
672+ }
673+ if m.overviewScroll(finger(-100, 0)) {
674+ t.Fatal("stepped again before overviewScrollRepeat")
675+ }
676+ // The fingers drift down: still on columns, no row step.
677+ if m.overviewScroll(finger(0, 200)) {
678+ t.Fatal("a sideways scroll stepped down")
679+ }
680+ if !m.overviewScroll(finger(-50, 0)) || focused() != 1 {
681+ t.Fatalf("long scroll on %d, want 1", focused())
682+ }
683+ // The fingers still rest: a wheel notch steps one column as always.
684+ if !m.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Horizontal: ports.ScrollAxis{Set: true, V120: 120}}) || focused() != 2 {
685+ t.Fatalf("wheel after a sideways scroll on %d, want 2", focused())
686+ }
687+ // Once lifted, the next scroll starts with the short step again, and
688+ // one large frame takes the short step then a repeat.
689+ c := &Core{screens: []*screen{{mon: m}}}
690+ c.scrollStop(ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}})
691+ if !m.overviewScroll(finger(220, 0)) || focused() != 3 {
692+ t.Fatalf("large frame on %d, want 3", focused())
693+ }
694+ // Reopening forgets a sideways scroll left without its stop.
695+ m.CancelOverview()
696+ m.Current().FocusID(3)
697+ m.ToggleOverview()
698+ if !m.overviewScroll(finger(-70, 0)) || focused() != 2 {
699+ t.Fatalf("reopened sideways scroll on %d, want 2", focused())
700+ }
701+
702+ // Vertical: one step per scroll, through the stack's cards.
703+ sm := stackMonitor()
704+ sm.ToggleOverview()
705+ before, _ := sm.Focused()
706+ if !sm.overviewScroll(finger(0, 70)) {
707+ t.Fatal("first vertical step")
708+ }
709+ after, _ := sm.Focused()
710+ if after == before {
711+ t.Fatal("vertical step selected nothing new")
712+ }
713+ if sm.overviewScroll(finger(0, 400)) {
714+ t.Fatal("the same vertical scroll stepped twice")
715+ }
716+ if !sm.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: -120}}) {
717+ t.Fatal("wheel held by the finger scroll")
718+ }
719+ // A sideways finger scroll does not hold a vertical wheel notch.
720+ sm.CancelOverview()
721+ sm.ToggleOverview()
722+ sm.overviewScroll(finger(70, 0))
723+ if !sm.overviewScroll(ports.PointerAxis{Source: ports.AxisWheel, Vertical: ports.ScrollAxis{Set: true, V120: 120}}) {
724+ t.Fatal("vertical wheel held by a sideways finger scroll")
725+ }
726+ sm.CancelOverview()
727+ sm.ToggleOverview()
728+ if !sm.overviewScroll(finger(0, 70)) {
729+ t.Fatal("reopened overview still held the old scroll")
730+ }
731+ cont := ports.PointerAxis{Source: ports.AxisContinuous, Vertical: ports.ScrollAxis{Set: true, Value: -70}}
732+ if !sm.overviewScroll(cont) {
733+ t.Fatal("continuous scroll held by the finger scroll")
734+ }
735+}
M internal/core/slide.go +37 -15
@@ -2,6 +2,7 @@ package core
22
33 import (
44 "math"
5+ "slices"
56 "time"
67 )
78
@@ -37,33 +38,54 @@ func (w *Workspace) swipeScale() float64 {
3738 return float64(w.Usable.W) / viewSwipeMovement
3839 }
3940
40-// snap returns the view closest to target that aligns a column edge with
41-// the usable area, never past the first or last column, and the column to
42-// focus there: the current one while it stays fully shown, else the
43-// furthest fully shown column toward the swipe (forward is rightward).
44-func (w *Workspace) snap(target float64, forward bool) (view, focus int) {
41+// snapPoints are the sorted views that align a column edge with the usable
42+// area, never past the first or last column. Points closer than a
43+// snapSpacing of the usable width merge into the first: a swipe step
44+// always moves the view visibly.
45+func (w *Workspace) snapPoints() []float64 {
4546 g := w.gap()
4647 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
4748 last := len(w.Columns) - 1
4849 lo := w.columnX(0) - minX
4950 hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo)
50- view, best := lo, math.Inf(1)
51- try := func(v int) {
52- v = min(max(v, lo), hi)
53- if d := math.Abs(float64(v) - target); d < best {
54- view, best = v, d
51+ views := make([]int, 0, 2*len(w.Columns))
52+ for i := range w.Columns {
53+ views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi))
54+ }
55+ slices.Sort(views)
56+ minStep := float64(w.Usable.W) * snapSpacing
57+ points := []float64{float64(lo)}
58+ for _, v := range views {
59+ if float64(v)-points[len(points)-1] >= minStep {
60+ points = append(points, float64(v))
5561 }
5662 }
57- for i := range w.Columns {
58- try(w.columnX(i) - minX)
59- try(w.columnX(i) + w.columnWidth(i) - maxX)
63+ // The last column's end stays a point; a point close before it goes.
64+ if n := len(points); float64(hi) != points[n-1] {
65+ if n > 1 && float64(hi)-points[n-1] < minStep {
66+ points = points[:n-1]
67+ }
68+ points = append(points, float64(hi))
6069 }
70+ return points
71+}
72+
73+// snapSpacing is the smallest distance between column snap points, in
74+// usable widths.
75+const snapSpacing = 0.1
76+
77+// snapFocus is the column to focus at view: the current one while it stays
78+// fully shown at the current view, else the furthest fully shown column
79+// toward the swipe (forward is rightward).
80+func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
81+ g := w.gap()
82+ minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
6183 full := func(i int) bool {
6284 x := w.columnX(i) - view
6385 return x >= minX && x+w.columnWidth(i) <= maxX
6486 }
6587 if full(w.Focus) && view == w.ViewX {
66- return view, w.Focus
88+ return w.Focus
6789 }
6890 focus = -1
6991 for i := range w.Columns {
@@ -80,7 +102,7 @@ func (w *Workspace) snap(target float64, forward bool) (view, focus int) {
80102 }
81103 }
82104 }
83- return view, focus
105+ return focus
84106 }
85107
86108 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }
@@ -2,6 +2,7 @@ package core
2 2
3 import (3 import (
4 "math"4 "math"
5+ "slices"
5 "time"6 "time"
6 )7 )
7 8
@@ -37,33 +38,54 @@ func (w *Workspace) swipeScale() float64 {
37 return float64(w.Usable.W) / viewSwipeMovement38 return float64(w.Usable.W) / viewSwipeMovement
38 }39 }
39 40
40-// snap returns the view closest to target that aligns a column edge with41+// snapPoints are the sorted views that align a column edge with the usable
41-// the usable area, never past the first or last column, and the column to42+// area, never past the first or last column. Points closer than a
42-// focus there: the current one while it stays fully shown, else the43+// snapSpacing of the usable width merge into the first: a swipe step
43-// furthest fully shown column toward the swipe (forward is rightward).44+// always moves the view visibly.
44-func (w *Workspace) snap(target float64, forward bool) (view, focus int) {45+func (w *Workspace) snapPoints() []float64 {
45 g := w.gap()46 g := w.gap()
46 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g47 minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
47 last := len(w.Columns) - 148 last := len(w.Columns) - 1
48 lo := w.columnX(0) - minX49 lo := w.columnX(0) - minX
49 hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo)50 hi := max(w.columnX(last)+w.columnWidth(last)-maxX, lo)
50- view, best := lo, math.Inf(1)51+ views := make([]int, 0, 2*len(w.Columns))
51- try := func(v int) {52+ for i := range w.Columns {
52- v = min(max(v, lo), hi)53+ views = append(views, min(max(w.columnX(i)-minX, lo), hi), min(max(w.columnX(i)+w.columnWidth(i)-maxX, lo), hi))
53- if d := math.Abs(float64(v) - target); d < best {54+ }
54- view, best = v, d55+ slices.Sort(views)
56+ minStep := float64(w.Usable.W) * snapSpacing
57+ points := []float64{float64(lo)}
58+ for _, v := range views {
59+ if float64(v)-points[len(points)-1] >= minStep {
60+ points = append(points, float64(v))
55 }61 }
56 }62 }
57- for i := range w.Columns {63+ // The last column's end stays a point; a point close before it goes.
58- try(w.columnX(i) - minX)64+ if n := len(points); float64(hi) != points[n-1] {
59- try(w.columnX(i) + w.columnWidth(i) - maxX)65+ if n > 1 && float64(hi)-points[n-1] < minStep {
66+ points = points[:n-1]
67+ }
68+ points = append(points, float64(hi))
60 }69 }
70+ return points
71+}
72+
73+// snapSpacing is the smallest distance between column snap points, in
74+// usable widths.
75+const snapSpacing = 0.1
76+
77+// snapFocus is the column to focus at view: the current one while it stays
78+// fully shown at the current view, else the furthest fully shown column
79+// toward the swipe (forward is rightward).
80+func (w *Workspace) snapFocus(view int, forward bool) (focus int) {
81+ g := w.gap()
82+ minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g
61 full := func(i int) bool {83 full := func(i int) bool {
62 x := w.columnX(i) - view84 x := w.columnX(i) - view
63 return x >= minX && x+w.columnWidth(i) <= maxX85 return x >= minX && x+w.columnWidth(i) <= maxX
64 }86 }
65 if full(w.Focus) && view == w.ViewX {87 if full(w.Focus) && view == w.ViewX {
66- return view, w.Focus88+ return w.Focus
67 }89 }
68 focus = -190 focus = -1
69 for i := range w.Columns {91 for i := range w.Columns {
@@ -80,7 +102,7 @@ func (w *Workspace) snap(target float64, forward bool) (view, focus int) {
80 }102 }
81 }103 }
82 }104 }
83- return view, focus105+ return focus
84 }106 }
85 107
86 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }108 func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil }
A internal/core/snapswipe.go +187 -0
@@ -0,0 +1,187 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "time"
7+)
8+
9+// A snap swipe moves a view through sorted snap points (libadwaita's swipe
10+// tracker): one gesture reaches the neighbors of the point it starts from
11+// at most, so a quick flick never skips a point. Past them the view resists
12+// (rubber band); between points it sticks near each point and speeds up in
13+// between (detents). When the fingers lift, a quick swipe goes on to the
14+// next point in its direction; a slow one settles on the closest point.
15+
16+const (
17+ // snapVelocity is the touchpad speed, in units per second, above which
18+ // a swipe goes on to the next point (libadwaita's touchpad threshold).
19+ snapVelocity = 600.0
20+ // detent is how much the view sticks near a snap point: it follows the
21+ // fingers at 1-detent there and 1+detent halfway between points.
22+ detent = 0.3
23+ // snapEpsilon is how close to a point counts as on it.
24+ snapEpsilon = 1e-6
25+)
26+
27+type snapSwipe struct {
28+ tracker swipeTracker
29+ // scale turns touchpad units into view units.
30+ scale float64
31+ // start is the view shown when the swipe began; points are sorted.
32+ start float64
33+ points []float64
34+ // home indexes the point the view rests on or lands on when the swipe
35+ // began: the swipe steps from it, so a swipe during a landing goes one
36+ // step past the landing, not from wherever the slide shows.
37+ home int
38+ // landLo and landHi are home's neighbors: where the swipe may land.
39+ landLo, landHi float64
40+ // lo and hi bound the fingers' view before resistance: the neighbors,
41+ // widened to start when a slide was caught past them.
42+ lo, hi float64
43+ // stops are the detent ends: the points within [lo, hi], and start.
44+ stops []float64
45+ band rubberBand
46+}
47+
48+// newSnapSwipe follows a swipe from the view start toward points, the view
49+// resting on or heading to rest. A rest between points becomes a point:
50+// a slow swipe never settles behind the view it started from.
51+func newSnapSwipe(start, rest, scale float64, points []float64, band rubberBand) snapSwipe {
52+ s := snapSwipe{scale: scale, start: start, points: points, band: band}
53+ s.points = withPoint(s.points, rest)
54+ s.home = s.closest(rest)
55+ s.landLo = s.points[max(s.home-1, 0)]
56+ s.landHi = s.points[min(s.home+1, len(s.points)-1)]
57+ s.lo, s.hi = min(s.landLo, start), max(s.landHi, start)
58+ for _, v := range s.points {
59+ if v >= s.lo && v <= s.hi {
60+ s.stops = append(s.stops, v)
61+ }
62+ }
63+ s.stops = withPoint(s.stops, start)
64+ return s
65+}
66+
67+// withPoint returns sorted points with v added unless one is within
68+// snapEpsilon of it.
69+func withPoint(points []float64, v float64) []float64 {
70+ i, _ := slices.BinarySearch(points, v)
71+ if i < len(points) && points[i]-v <= snapEpsilon || i > 0 && v-points[i-1] <= snapEpsilon {
72+ return points
73+ }
74+ return slices.Insert(slices.Clone(points), i, v)
75+}
76+
77+// indexPoints are the points 0 to n-1: one per workspace.
78+func indexPoints(n int) []float64 {
79+ points := make([]float64, n)
80+ for i := range points {
81+ points[i] = float64(i)
82+ }
83+ return points
84+}
85+
86+func (s *snapSwipe) closest(pos float64) int {
87+ best := 0
88+ for i, v := range s.points {
89+ if math.Abs(v-pos) < math.Abs(s.points[best]-pos) {
90+ best = i
91+ }
92+ }
93+ return best
94+}
95+
96+func (s *snapSwipe) push(delta float64, at time.Duration) { s.tracker.push(delta, at) }
97+
98+// raw is where the fingers put the view, before resistance and detents.
99+func (s *snapSwipe) raw() float64 { return s.start + s.tracker.pos*s.scale }
100+
101+// pos is the view shown: rubber-banded past the bounds, with detents
102+// between them.
103+func (s *snapSwipe) pos() float64 {
104+ x := s.raw()
105+ if x < s.lo || x > s.hi {
106+ return s.band.clamp(s.lo, s.hi, x)
107+ }
108+ a, b, ok := s.around(x)
109+ if !ok {
110+ return x
111+ }
112+ t := (x - a) / (b - a)
113+ return a + (b-a)*(t-detent*math.Sin(2*math.Pi*t)/(2*math.Pi))
114+}
115+
116+// slope is the derivative of pos at the fingers' view.
117+func (s *snapSwipe) slope() float64 {
118+ x := s.raw()
119+ if x < s.lo || x > s.hi {
120+ return s.band.clampDerivative(s.lo, s.hi, x)
121+ }
122+ a, b, ok := s.around(x)
123+ if !ok {
124+ return 1
125+ }
126+ return 1 - detent*math.Cos(2*math.Pi*(x-a)/(b-a))
127+}
128+
129+// around returns the stops around x, which lies within [lo, hi].
130+func (s *snapSwipe) around(x float64) (a, b float64, ok bool) {
131+ for i := 1; i < len(s.stops); i++ {
132+ if x <= s.stops[i] {
133+ a, b = s.stops[i-1], s.stops[i]
134+ return a, b, b-a > snapEpsilon
135+ }
136+ }
137+ return 0, 0, false
138+}
139+
140+// end is the point the swipe settles on when the fingers lift (home when
141+// cancelled), and the view's speed for the spring there, in view units
142+// per second.
143+func (s *snapSwipe) end(cancelled bool, at time.Duration) (target, velocity float64) {
144+ // Idle time before the lift slows the swipe down.
145+ s.tracker.push(0, at)
146+ v := s.tracker.velocity()
147+ velocity = v * s.scale * s.slope()
148+ switch {
149+ case cancelled:
150+ return s.points[s.home], velocity
151+ case math.Abs(v) < snapVelocity:
152+ return min(max(s.points[s.closest(s.raw())], s.landLo), s.landHi), velocity
153+ }
154+ // A quick swipe lands on the first landing point at or past where its
155+ // speed would carry it, in its direction.
156+ proj := min(max(s.start+s.tracker.projectedEnd()*s.scale, s.landLo), s.landHi)
157+ land := [3]float64{s.landLo, s.points[s.home], s.landHi}
158+ if v > 0 {
159+ for _, p := range land {
160+ if p >= proj-snapEpsilon {
161+ return p, velocity
162+ }
163+ }
164+ }
165+ // proj lies within [landLo, landHi]: one of them always matches.
166+ for _, p := range slices.Backward(land[:]) {
167+ if p <= proj+snapEpsilon {
168+ return p, velocity
169+ }
170+ }
171+ return s.landLo, velocity
172+}
173+
174+// discreteSwipeMovement is the touchpad distance of one step of a swipe
175+// that runs an action instead of sliding the view.
176+const discreteSwipeMovement = 300.0
177+
178+// newStepSwipe is a swipe that settles on a step of -1, 0 or 1.
179+func newStepSwipe() snapSwipe {
180+ return newSnapSwipe(0, 0, 1/discreteSwipeMovement, []float64{-1, 0, 1}, workspaceBand)
181+}
182+
183+// step is where a step swipe settles when the fingers lift.
184+func (s *snapSwipe) step(cancelled bool, at time.Duration) int {
185+ target, _ := s.end(cancelled, at)
186+ return int(math.Round(target))
187+}
@@ -0,0 +1,187 @@
1+package core
2+
3+import (
4+ "math"
5+ "slices"
6+ "time"
7+)
8+
9+// A snap swipe moves a view through sorted snap points (libadwaita's swipe
10+// tracker): one gesture reaches the neighbors of the point it starts from
11+// at most, so a quick flick never skips a point. Past them the view resists
12+// (rubber band); between points it sticks near each point and speeds up in
13+// between (detents). When the fingers lift, a quick swipe goes on to the
14+// next point in its direction; a slow one settles on the closest point.
15+
16+const (
17+ // snapVelocity is the touchpad speed, in units per second, above which
18+ // a swipe goes on to the next point (libadwaita's touchpad threshold).
19+ snapVelocity = 600.0
20+ // detent is how much the view sticks near a snap point: it follows the
21+ // fingers at 1-detent there and 1+detent halfway between points.
22+ detent = 0.3
23+ // snapEpsilon is how close to a point counts as on it.
24+ snapEpsilon = 1e-6
25+)
26+
27+type snapSwipe struct {
28+ tracker swipeTracker
29+ // scale turns touchpad units into view units.
30+ scale float64
31+ // start is the view shown when the swipe began; points are sorted.
32+ start float64
33+ points []float64
34+ // home indexes the point the view rests on or lands on when the swipe
35+ // began: the swipe steps from it, so a swipe during a landing goes one
36+ // step past the landing, not from wherever the slide shows.
37+ home int
38+ // landLo and landHi are home's neighbors: where the swipe may land.
39+ landLo, landHi float64
40+ // lo and hi bound the fingers' view before resistance: the neighbors,
41+ // widened to start when a slide was caught past them.
42+ lo, hi float64
43+ // stops are the detent ends: the points within [lo, hi], and start.
44+ stops []float64
45+ band rubberBand
46+}
47+
48+// newSnapSwipe follows a swipe from the view start toward points, the view
49+// resting on or heading to rest. A rest between points becomes a point:
50+// a slow swipe never settles behind the view it started from.
51+func newSnapSwipe(start, rest, scale float64, points []float64, band rubberBand) snapSwipe {
52+ s := snapSwipe{scale: scale, start: start, points: points, band: band}
53+ s.points = withPoint(s.points, rest)
54+ s.home = s.closest(rest)
55+ s.landLo = s.points[max(s.home-1, 0)]
56+ s.landHi = s.points[min(s.home+1, len(s.points)-1)]
57+ s.lo, s.hi = min(s.landLo, start), max(s.landHi, start)
58+ for _, v := range s.points {
59+ if v >= s.lo && v <= s.hi {
60+ s.stops = append(s.stops, v)
61+ }
62+ }
63+ s.stops = withPoint(s.stops, start)
64+ return s
65+}
66+
67+// withPoint returns sorted points with v added unless one is within
68+// snapEpsilon of it.
69+func withPoint(points []float64, v float64) []float64 {
70+ i, _ := slices.BinarySearch(points, v)
71+ if i < len(points) && points[i]-v <= snapEpsilon || i > 0 && v-points[i-1] <= snapEpsilon {
72+ return points
73+ }
74+ return slices.Insert(slices.Clone(points), i, v)
75+}
76+
77+// indexPoints are the points 0 to n-1: one per workspace.
78+func indexPoints(n int) []float64 {
79+ points := make([]float64, n)
80+ for i := range points {
81+ points[i] = float64(i)
82+ }
83+ return points
84+}
85+
86+func (s *snapSwipe) closest(pos float64) int {
87+ best := 0
88+ for i, v := range s.points {
89+ if math.Abs(v-pos) < math.Abs(s.points[best]-pos) {
90+ best = i
91+ }
92+ }
93+ return best
94+}
95+
96+func (s *snapSwipe) push(delta float64, at time.Duration) { s.tracker.push(delta, at) }
97+
98+// raw is where the fingers put the view, before resistance and detents.
99+func (s *snapSwipe) raw() float64 { return s.start + s.tracker.pos*s.scale }
100+
101+// pos is the view shown: rubber-banded past the bounds, with detents
102+// between them.
103+func (s *snapSwipe) pos() float64 {
104+ x := s.raw()
105+ if x < s.lo || x > s.hi {
106+ return s.band.clamp(s.lo, s.hi, x)
107+ }
108+ a, b, ok := s.around(x)
109+ if !ok {
110+ return x
111+ }
112+ t := (x - a) / (b - a)
113+ return a + (b-a)*(t-detent*math.Sin(2*math.Pi*t)/(2*math.Pi))
114+}
115+
116+// slope is the derivative of pos at the fingers' view.
117+func (s *snapSwipe) slope() float64 {
118+ x := s.raw()
119+ if x < s.lo || x > s.hi {
120+ return s.band.clampDerivative(s.lo, s.hi, x)
121+ }
122+ a, b, ok := s.around(x)
123+ if !ok {
124+ return 1
125+ }
126+ return 1 - detent*math.Cos(2*math.Pi*(x-a)/(b-a))
127+}
128+
129+// around returns the stops around x, which lies within [lo, hi].
130+func (s *snapSwipe) around(x float64) (a, b float64, ok bool) {
131+ for i := 1; i < len(s.stops); i++ {
132+ if x <= s.stops[i] {
133+ a, b = s.stops[i-1], s.stops[i]
134+ return a, b, b-a > snapEpsilon
135+ }
136+ }
137+ return 0, 0, false
138+}
139+
140+// end is the point the swipe settles on when the fingers lift (home when
141+// cancelled), and the view's speed for the spring there, in view units
142+// per second.
143+func (s *snapSwipe) end(cancelled bool, at time.Duration) (target, velocity float64) {
144+ // Idle time before the lift slows the swipe down.
145+ s.tracker.push(0, at)
146+ v := s.tracker.velocity()
147+ velocity = v * s.scale * s.slope()
148+ switch {
149+ case cancelled:
150+ return s.points[s.home], velocity
151+ case math.Abs(v) < snapVelocity:
152+ return min(max(s.points[s.closest(s.raw())], s.landLo), s.landHi), velocity
153+ }
154+ // A quick swipe lands on the first landing point at or past where its
155+ // speed would carry it, in its direction.
156+ proj := min(max(s.start+s.tracker.projectedEnd()*s.scale, s.landLo), s.landHi)
157+ land := [3]float64{s.landLo, s.points[s.home], s.landHi}
158+ if v > 0 {
159+ for _, p := range land {
160+ if p >= proj-snapEpsilon {
161+ return p, velocity
162+ }
163+ }
164+ }
165+ // proj lies within [landLo, landHi]: one of them always matches.
166+ for _, p := range slices.Backward(land[:]) {
167+ if p <= proj+snapEpsilon {
168+ return p, velocity
169+ }
170+ }
171+ return s.landLo, velocity
172+}
173+
174+// discreteSwipeMovement is the touchpad distance of one step of a swipe
175+// that runs an action instead of sliding the view.
176+const discreteSwipeMovement = 300.0
177+
178+// newStepSwipe is a swipe that settles on a step of -1, 0 or 1.
179+func newStepSwipe() snapSwipe {
180+ return newSnapSwipe(0, 0, 1/discreteSwipeMovement, []float64{-1, 0, 1}, workspaceBand)
181+}
182+
183+// step is where a step swipe settles when the fingers lift.
184+func (s *snapSwipe) step(cancelled bool, at time.Duration) int {
185+ target, _ := s.end(cancelled, at)
186+ return int(math.Round(target))
187+}
M internal/core/swipe_test.go +149 -8
@@ -166,13 +166,20 @@ func TestSwipeFollowsFingers(t *testing.T) {
166166 r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}
167167 s := r.move(t, -10, 0)
168168 after, _ := rectOf(s, 2)
169- // 10 units of 1200 per 800 px view: 7 px, not snapped to a column.
170- if d := after.X - before.X; d != 7 {
169+ // 10 units of 1200 per 800 px view are 7 px; near the resting view
170+ // the detent holds the view back a little.
171+ if d := after.X - before.X; d <= 0 || d >= 7 {
171172 t.Fatalf("column moved %d px with the fingers", d)
172173 }
173- s = r.move(t, -30, 0)
174- if got, _ := rectOf(s, 2); got.X-before.X != 27 {
175- t.Fatalf("column moved %d px after 40 units", got.X-before.X)
174+ // Halfway to the next column edge (400 px: 600 units) the view has
175+ // caught up with the fingers; a full step shows whole.
176+ s = r.move(t, -290, 0)
177+ if got, _ := rectOf(s, 2); got.X-before.X != 200 {
178+ t.Fatalf("column moved %d px halfway", got.X-before.X)
179+ }
180+ s = r.move(t, -300, 0)
181+ if got, _ := rectOf(s, 2); got.X-before.X != 400 {
182+ t.Fatalf("column moved %d px after a full step", got.X-before.X)
176183 }
177184 }
178185
@@ -478,13 +485,13 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
478485 }
479486 // The swipe on DP-1 still follows the fingers.
480487 var got ports.Rect
481- for _, sc := range receiveMove(t, r, -30) {
488+ for _, sc := range receiveMove(t, r, -570) {
482489 if sc.Output == wide.Name {
483490 got, _ = rectOf(sc, 2)
484491 }
485492 }
486- // 60 units at 2/3 px each: 40 px.
487- if got.X-before.X != 40 {
493+ // 600 units at 2/3 px each: a full step of 400 px.
494+ if got.X-before.X != 400 {
488495 t.Fatalf("column 2 moved %d px", got.X-before.X)
489496 }
490497 }
@@ -684,3 +691,137 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
684691 }
685692 }
686693 }
694+
695+// A hard flick down from the first of four workspaces lands on the second:
696+// one swipe never skips a workspace.
697+func TestSwipeHardFlickMovesOneWorkspace(t *testing.T) {
698+ r := startSwipe(t, nil)
699+ for id := ports.WindowID(1); id <= 4; id++ {
700+ r.mapWindow(t, id)
701+ r.key(t, "Next", ports.ModAlt)
702+ }
703+ for range 4 {
704+ r.key(t, "Prior", ports.ModAlt)
705+ }
706+ r.flick(t, 20, 0, 60)
707+ s := r.settle(t)
708+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
709+ t.Fatalf("hard flick did not land on workspace 2: %v %t", got, ok)
710+ }
711+}
712+
713+// A hard flick across many columns moves the view one column edge.
714+func TestSwipeHardFlickMovesOneColumn(t *testing.T) {
715+ r := startSwipe(t, nil)
716+ for id := ports.WindowID(1); id <= 5; id++ {
717+ r.mapWindow(t, id)
718+ }
719+ // The view shows 4 and 5; a hard flick left shows 3 and 4, not 1.
720+ r.flick(t, 20, -60, 0)
721+ s := r.settle(t)
722+ if got, ok := rectOf(s, 3); !ok || got.X != 0 {
723+ t.Fatalf("column 3 at %v %t, want the left edge", got, ok)
724+ }
725+}
726+
727+// A two-finger scroll that steps in the overview and ends with the pointer
728+// on another output does not hold the next scroll back.
729+func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
730+ r := startSwipe(t, nil)
731+ threeColumns(t, r)
732+ r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
733+ r.key(t, "o", ports.ModAlt)
734+ selected := func(s ports.Scene) ports.WindowID {
735+ for _, w := range s.Windows {
736+ if w.Focused && w.Preview > 0 {
737+ return w.ID
738+ }
739+ }
740+ return 0
741+ }
742+ on := func(id ports.WindowID) func(ports.Scene) bool {
743+ return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
744+ }
745+ finger := func(dx float64) ports.PointerAxis {
746+ return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}
747+ }
748+ r.input <- ports.PointerMotion{X: 100, Y: 300}
749+ r.input <- finger(-70)
750+ sceneMatch(t, r.scenes, on(2))
751+ // The pointer crosses to DP-2 and the fingers lift there.
752+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
753+ r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
754+ r.input <- ports.PointerMotion{X: 100, Y: 300}
755+ r.input <- finger(-70)
756+ sceneMatch(t, r.scenes, on(1))
757+}
758+
759+// The output shrinks mid-swipe: the snap points no longer hold, so the
760+// swipe lets go. The view shows what the resize alone shows, and the rest
761+// of the swipe moves nothing.
762+func TestSwipeOutputResizedMidSwipe(t *testing.T) {
763+ smaller := ports.OutputInfo{Name: wide.Name, Width: 600, Height: 600, RefreshMilli: 60000}
764+ four := func(r *swipeRig) {
765+ for id := ports.WindowID(1); id <= 4; id++ {
766+ r.mapWindow(t, id)
767+ }
768+ }
769+ plain := startSwipe(t, nil)
770+ four(plain)
771+ plain.output <- ports.OutputAdded{Info: smaller}
772+ want := scene(t, plain.scenes)
773+
774+ r := startSwipe(t, nil)
775+ four(r)
776+ r.begin()
777+ for range 3 {
778+ r.move(t, -40, 0)
779+ }
780+ r.output <- ports.OutputAdded{Info: smaller}
781+ scene(t, r.scenes)
782+ // The first update after the resize lets go: the slide stops.
783+ s := r.move(t, -40, 0)
784+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
785+ w, _ := rectOf(want, id)
786+ if got, _ := rectOf(s, id); got != w {
787+ t.Fatalf("window %d at %+v after the next update, want %+v", id, got, w)
788+ }
789+ }
790+ for range 2 {
791+ r.at += 8 * time.Millisecond
792+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
793+ }
794+ s = r.end(t, false)
795+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
796+ w, _ := rectOf(want, id)
797+ if got, _ := rectOf(s, id); got != w {
798+ t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
799+ }
800+ }
801+}
802+
803+// A window maps mid-swipe: the column snap points moved, so the swipe lets
804+// go and the view shows what the map alone shows.
805+func TestSwipeWindowMappedMidSwipe(t *testing.T) {
806+ plain := startSwipe(t, nil)
807+ threeColumns(t, plain)
808+ want := plain.mapWindow(t, 4)[0]
809+
810+ r := startSwipe(t, nil)
811+ threeColumns(t, r)
812+ r.begin()
813+ for range 3 {
814+ r.move(t, -40, 0)
815+ }
816+ r.mapWindow(t, 4)
817+ r.move(t, -40, 0)
818+ r.at += 8 * time.Millisecond
819+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
820+ s := r.end(t, false)
821+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
822+ w, _ := rectOf(want, id)
823+ if got, _ := rectOf(s, id); got != w {
824+ t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
825+ }
826+ }
827+}
@@ -166,13 +166,20 @@ func TestSwipeFollowsFingers(t *testing.T) {
166 r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}166 r.input <- ports.SwipeUpdate{DX: -10, Time: r.at}
167 s := r.move(t, -10, 0)167 s := r.move(t, -10, 0)
168 after, _ := rectOf(s, 2)168 after, _ := rectOf(s, 2)
169- // 10 units of 1200 per 800 px view: 7 px, not snapped to a column.169+ // 10 units of 1200 per 800 px view are 7 px; near the resting view
170- if d := after.X - before.X; d != 7 {170+ // the detent holds the view back a little.
171+ if d := after.X - before.X; d <= 0 || d >= 7 {
171 t.Fatalf("column moved %d px with the fingers", d)172 t.Fatalf("column moved %d px with the fingers", d)
172 }173 }
173- s = r.move(t, -30, 0)174+ // Halfway to the next column edge (400 px: 600 units) the view has
174- if got, _ := rectOf(s, 2); got.X-before.X != 27 {175+ // caught up with the fingers; a full step shows whole.
175- t.Fatalf("column moved %d px after 40 units", got.X-before.X)176+ s = r.move(t, -290, 0)
177+ if got, _ := rectOf(s, 2); got.X-before.X != 200 {
178+ t.Fatalf("column moved %d px halfway", got.X-before.X)
179+ }
180+ s = r.move(t, -300, 0)
181+ if got, _ := rectOf(s, 2); got.X-before.X != 400 {
182+ t.Fatalf("column moved %d px after a full step", got.X-before.X)
176 }183 }
177 }184 }
178 185
@@ -478,13 +485,13 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) {
478 }485 }
479 // The swipe on DP-1 still follows the fingers.486 // The swipe on DP-1 still follows the fingers.
480 var got ports.Rect487 var got ports.Rect
481- for _, sc := range receiveMove(t, r, -30) {488+ for _, sc := range receiveMove(t, r, -570) {
482 if sc.Output == wide.Name {489 if sc.Output == wide.Name {
483 got, _ = rectOf(sc, 2)490 got, _ = rectOf(sc, 2)
484 }491 }
485 }492 }
486- // 60 units at 2/3 px each: 40 px.493+ // 600 units at 2/3 px each: a full step of 400 px.
487- if got.X-before.X != 40 {494+ if got.X-before.X != 400 {
488 t.Fatalf("column 2 moved %d px", got.X-before.X)495 t.Fatalf("column 2 moved %d px", got.X-before.X)
489 }496 }
490 }497 }
@@ -684,3 +691,137 @@ func TestOverviewOpenedMidSwipeDropsIt(t *testing.T) {
684 }691 }
685 }692 }
686 }693 }
694+
695+// A hard flick down from the first of four workspaces lands on the second:
696+// one swipe never skips a workspace.
697+func TestSwipeHardFlickMovesOneWorkspace(t *testing.T) {
698+ r := startSwipe(t, nil)
699+ for id := ports.WindowID(1); id <= 4; id++ {
700+ r.mapWindow(t, id)
701+ r.key(t, "Next", ports.ModAlt)
702+ }
703+ for range 4 {
704+ r.key(t, "Prior", ports.ModAlt)
705+ }
706+ r.flick(t, 20, 0, 60)
707+ s := r.settle(t)
708+ if got, ok := rectOf(s, 2); !ok || got.Y != 0 {
709+ t.Fatalf("hard flick did not land on workspace 2: %v %t", got, ok)
710+ }
711+}
712+
713+// A hard flick across many columns moves the view one column edge.
714+func TestSwipeHardFlickMovesOneColumn(t *testing.T) {
715+ r := startSwipe(t, nil)
716+ for id := ports.WindowID(1); id <= 5; id++ {
717+ r.mapWindow(t, id)
718+ }
719+ // The view shows 4 and 5; a hard flick left shows 3 and 4, not 1.
720+ r.flick(t, 20, -60, 0)
721+ s := r.settle(t)
722+ if got, ok := rectOf(s, 3); !ok || got.X != 0 {
723+ t.Fatalf("column 3 at %v %t, want the left edge", got, ok)
724+ }
725+}
726+
727+// A two-finger scroll that steps in the overview and ends with the pointer
728+// on another output does not hold the next scroll back.
729+func TestOverviewScrollStopOnOtherOutput(t *testing.T) {
730+ r := startSwipe(t, nil)
731+ threeColumns(t, r)
732+ r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000})
733+ r.key(t, "o", ports.ModAlt)
734+ selected := func(s ports.Scene) ports.WindowID {
735+ for _, w := range s.Windows {
736+ if w.Focused && w.Preview > 0 {
737+ return w.ID
738+ }
739+ }
740+ return 0
741+ }
742+ on := func(id ports.WindowID) func(ports.Scene) bool {
743+ return func(s ports.Scene) bool { return s.Output == wide.Name && selected(s) == id }
744+ }
745+ finger := func(dx float64) ports.PointerAxis {
746+ return ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Value: dx}}
747+ }
748+ r.input <- ports.PointerMotion{X: 100, Y: 300}
749+ r.input <- finger(-70)
750+ sceneMatch(t, r.scenes, on(2))
751+ // The pointer crosses to DP-2 and the fingers lift there.
752+ r.input <- ports.PointerMotion{X: 1000, Y: 300}
753+ r.input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}}
754+ r.input <- ports.PointerMotion{X: 100, Y: 300}
755+ r.input <- finger(-70)
756+ sceneMatch(t, r.scenes, on(1))
757+}
758+
759+// The output shrinks mid-swipe: the snap points no longer hold, so the
760+// swipe lets go. The view shows what the resize alone shows, and the rest
761+// of the swipe moves nothing.
762+func TestSwipeOutputResizedMidSwipe(t *testing.T) {
763+ smaller := ports.OutputInfo{Name: wide.Name, Width: 600, Height: 600, RefreshMilli: 60000}
764+ four := func(r *swipeRig) {
765+ for id := ports.WindowID(1); id <= 4; id++ {
766+ r.mapWindow(t, id)
767+ }
768+ }
769+ plain := startSwipe(t, nil)
770+ four(plain)
771+ plain.output <- ports.OutputAdded{Info: smaller}
772+ want := scene(t, plain.scenes)
773+
774+ r := startSwipe(t, nil)
775+ four(r)
776+ r.begin()
777+ for range 3 {
778+ r.move(t, -40, 0)
779+ }
780+ r.output <- ports.OutputAdded{Info: smaller}
781+ scene(t, r.scenes)
782+ // The first update after the resize lets go: the slide stops.
783+ s := r.move(t, -40, 0)
784+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
785+ w, _ := rectOf(want, id)
786+ if got, _ := rectOf(s, id); got != w {
787+ t.Fatalf("window %d at %+v after the next update, want %+v", id, got, w)
788+ }
789+ }
790+ for range 2 {
791+ r.at += 8 * time.Millisecond
792+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
793+ }
794+ s = r.end(t, false)
795+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
796+ w, _ := rectOf(want, id)
797+ if got, _ := rectOf(s, id); got != w {
798+ t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
799+ }
800+ }
801+}
802+
803+// A window maps mid-swipe: the column snap points moved, so the swipe lets
804+// go and the view shows what the map alone shows.
805+func TestSwipeWindowMappedMidSwipe(t *testing.T) {
806+ plain := startSwipe(t, nil)
807+ threeColumns(t, plain)
808+ want := plain.mapWindow(t, 4)[0]
809+
810+ r := startSwipe(t, nil)
811+ threeColumns(t, r)
812+ r.begin()
813+ for range 3 {
814+ r.move(t, -40, 0)
815+ }
816+ r.mapWindow(t, 4)
817+ r.move(t, -40, 0)
818+ r.at += 8 * time.Millisecond
819+ r.input <- ports.SwipeUpdate{DX: -40, Time: r.at}
820+ s := r.end(t, false)
821+ for _, id := range []ports.WindowID{1, 2, 3, 4} {
822+ w, _ := rectOf(want, id)
823+ if got, _ := rectOf(s, id); got != w {
824+ t.Fatalf("window %d at %+v, want %+v as without the swipe", id, got, w)
825+ }
826+ }
827+}

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