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 Unverified
B5690EEEBB952194The key is unknown
11 files · +825 -101
M docs/config.md +6 -4
| @@ -66,10 +66,12 @@ bind.Cmd+p = workspace presentation | |||
| 66 | 66 | ||
| 67 | 67 | ## 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. | |
| 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. | |
| 73 | 75 | ||
| 74 | 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 | 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 | 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 | ||
| @@ -66,10 +66,12 @@ bind.Cmd+p = workspace presentation | |||||
| 66 | 66 | ||||
| 67 | ## Touchpad | 67 | ## 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 ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 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. | |
| 16 | 18 | ||
| 17 | 19 | const ( | |
| 18 | 20 | // swipeDecide is the touchpad distance before the swipe picks its axis | |
| @@ -24,10 +26,6 @@ const ( | |||
| 24 | 26 | // workspaceSwipeMovement is the touchpad distance that slides one | |
| 25 | 27 | // workspace. | |
| 26 | 28 | 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 | 31 | type swipeMode uint8 | |
| @@ -51,18 +49,20 @@ type swipeGesture struct { | |||
| 51 | 49 | mode swipeMode | |
| 52 | 50 | horizontal bool | |
| 53 | 51 | 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 | |
| 57 | 58 | // 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. | |
| 66 | 66 | list []*Workspace | |
| 67 | 67 | // opens is the monitor's overviewOpens when the swipe picked its | |
| 68 | 68 | // mode: a slide the overview opened over is dropped, even once the | |
| @@ -76,6 +76,14 @@ func (g *swipeGesture) listChanged(m *Monitor) bool { | |||
| 76 | 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 | 87 | // swipeSign turns finger movement into view movement: natural scroll moves | |
| 80 | 88 | // the content with the fingers. | |
| 81 | 89 | func (c *Core) swipeSign() float64 { | |
| @@ -119,19 +127,19 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 119 | 127 | if g.mode == swipeOverview { | |
| 120 | 128 | // Like niri, the overview gesture ignores natural scroll: the | |
| 121 | 129 | // fingers going up open it. | |
| 122 | - | g.tracker.push(d, u.Time) | |
| 130 | + | g.snap.push(d, u.Time) | |
| 123 | 131 | return false | |
| 124 | 132 | } | |
| 125 | - | g.tracker.push(d*c.swipeSign(), u.Time) | |
| 133 | + | g.snap.push(d*c.swipeSign(), u.Time) | |
| 126 | 134 | m := g.screen.mon | |
| 127 | 135 | switch g.mode { | |
| 128 | 136 | case swipeColumns: | |
| 129 | - | if m.overviewOpens != g.opens || m.Current() != g.ws || !g.ws.slidable() { | |
| 137 | + | if g.columnsChanged(m) { | |
| 130 | 138 | g.ws.stopSlide() | |
| 131 | 139 | g.mode = swipeDropped | |
| 132 | 140 | 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 | 143 | return true | |
| 136 | 144 | case swipeWorkspaces: | |
| 137 | 145 | if g.listChanged(m) { | |
| @@ -139,8 +147,7 @@ func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 139 | 147 | g.mode = swipeDropped | |
| 140 | 148 | return true | |
| 141 | 149 | } | |
| 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) | |
| 144 | 151 | return true | |
| 145 | 152 | } | |
| 146 | 153 | return false | |
| @@ -155,17 +162,17 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 155 | 162 | w := m.Current() | |
| 156 | 163 | switch { | |
| 157 | 164 | case g.fingers == 4 && !g.horizontal: | |
| 158 | - | g.mode = swipeOverview | |
| 165 | + | g.mode, g.snap = swipeOverview, newStepSwipe() | |
| 159 | 166 | case g.fingers == 4: | |
| 160 | 167 | // Four fingers sideways do nothing. | |
| 161 | 168 | g.mode = swipeDropped | |
| 162 | 169 | case m.ov.open: | |
| 163 | 170 | // The overview does not slide: the swipe moves its selection. | |
| 164 | - | g.mode = swipeDiscrete | |
| 171 | + | g.mode, g.snap = swipeDiscrete, newStepSwipe() | |
| 165 | 172 | case g.horizontal && w.slidable(): | |
| 166 | - | g.mode, g.ws = swipeColumns, w | |
| 173 | + | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() | |
| 167 | 174 | 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))) | |
| 169 | 176 | case !g.horizontal && m.shown == nil: | |
| 170 | 177 | // A landing slide measured in an older list lands at once first. | |
| 171 | 178 | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |
| @@ -174,9 +181,9 @@ func (c *Core) decide(g *swipeGesture) { | |||
| 174 | 181 | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 175 | 182 | g.list = slices.Clone(m.Workspaces) | |
| 176 | 183 | 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) | |
| 178 | 185 | default: | |
| 179 | - | g.mode = swipeDiscrete | |
| 186 | + | 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 | 195 | if g == nil || !c.hasScreen(g.screen) { | |
| 189 | 196 | return false | |
| 190 | 197 | } | |
| 191 | - | // Idle time before the lift slows the swipe down. | |
| 192 | - | g.tracker.push(0, e.Time) | |
| 193 | 198 | now := c.now() | |
| 194 | 199 | m := g.screen.mon | |
| 195 | 200 | switch g.mode { | |
| 196 | 201 | case swipeColumns: | |
| 197 | 202 | w := g.ws | |
| 198 | - | if m.overviewOpens != g.opens || m.Current() != w || !w.slidable() { | |
| 203 | + | if g.columnsChanged(m) { | |
| 199 | 204 | w.stopSlide() | |
| 200 | 205 | return false | |
| 201 | 206 | } | |
| 202 | - | scale := w.swipeScale() | |
| 203 | 207 | shown := float64(w.ViewX) + w.shift | |
| 208 | + | target, velocity := g.snap.end(e.Cancelled, e.Time) | |
| 204 | 209 | 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 | |
| 207 | 213 | // A column wider than the view aligns as focus moves align it. | |
| 208 | 214 | w.scroll() | |
| 209 | 215 | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 210 | 216 | c.keyboard.takeBack() | |
| 211 | 217 | } | |
| 212 | 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 | 220 | case swipeWorkspaces: | |
| 215 | 221 | if g.listChanged(m) { | |
| 216 | 222 | m.stopSwitch() | |
| 217 | 223 | return false | |
| 218 | 224 | } | |
| 219 | - | last := float64(len(m.Workspaces) - 1) | |
| 220 | 225 | 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 | 227 | idx := m.Active | |
| 223 | 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 | 231 | // Focus may drop the empty workspace left behind and renumber the | |
| 227 | 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 | 242 | m.switchOff, m.switchList = off, g.list | |
| 238 | 243 | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |
| 239 | 244 | 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 { | |
| 242 | 247 | return false | |
| 243 | 248 | } | |
| 244 | 249 | before := m.Current() | |
| @@ -247,17 +252,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 247 | 252 | c.keyboard.takeBack() | |
| 248 | 253 | return m.Current() != before | |
| 249 | 254 | 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 { | |
| 252 | 257 | return false | |
| 253 | 258 | } | |
| 254 | 259 | a := ActionFocusWorkspaceDown | |
| 255 | 260 | switch { | |
| 256 | - | case g.horizontal && p < 0: | |
| 261 | + | case g.horizontal && step < 0: | |
| 257 | 262 | a = ActionFocusColumnLeft | |
| 258 | 263 | case g.horizontal: | |
| 259 | 264 | a = ActionFocusColumnRight | |
| 260 | - | case p < 0: | |
| 265 | + | case step < 0: | |
| 261 | 266 | a = ActionFocusWorkspaceUp | |
| 262 | 267 | } | |
| 263 | 268 | // 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 view | 12 | + | // 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 there | 13 | + | // most (snapswipe.go): the next column edge or the next workspace. When |
| 14 | - | // (motion.go). Where the view cannot slide (fixed overflow, the stash, a | 14 | + | // 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 axis | 20 | // swipeDecide is the touchpad distance before the swipe picks its axis | ||
| @@ -24,10 +26,6 @@ const ( | |||||
| 24 | // workspaceSwipeMovement is the touchpad distance that slides one | 26 | // workspaceSwipeMovement is the touchpad distance that slides one | ||
| 25 | // workspace. | 27 | // workspace. | ||
| 26 | workspaceSwipeMovement = 300.0 | 28 | 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 uint8 | 31 | type swipeMode uint8 | ||
| @@ -51,18 +49,20 @@ type swipeGesture struct { | |||||
| 51 | mode swipeMode | 49 | mode swipeMode | ||
| 52 | horizontal bool | 50 | horizontal bool | ||
| 53 | cx, cy float64 | 51 | cx, cy float64 | ||
| 54 | - | // tracker adds up the movement along the chosen axis, natural scroll | 52 | + | // 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 swipeTracker | 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 | |||
| 57 | // ws is the workspace whose columns scroll, or the active one when a | 58 | // 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 | + | // workspace slide began. |
| 59 | - | // took over: its x in pixels (columns) or the monitor's fractional | 60 | + | ws *Workspace |
| 60 | - | // workspace index (workspaces). Both are absolute, so a focus change | 61 | + | // 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 *Workspace | 63 | + | points []float64 |
| 63 | - | start float64 | 64 | + | // list is the numbered workspaces when a workspace slide began: the |
| 64 | - | // list is the numbered workspaces when a workspace slide began: start | 65 | + | // slide indexes it, so it ends if the list changes. |
| 65 | - | // indexes it, so the slide ends if the list changes. | |||
| 66 | list []*Workspace | 66 | list []*Workspace | ||
| 67 | // opens is the monitor's overviewOpens when the swipe picked its | 67 | // opens is the monitor's overviewOpens when the swipe picked its | ||
| 68 | // mode: a slide the overview opened over is dropped, even once the | 68 | // 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 moves | 87 | // 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: the | 128 | // 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 false | 131 | 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.mon | 134 | 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 = swipeDropped | 139 | g.mode = swipeDropped | ||
| 132 | return true | 140 | 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 true | 143 | 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 = swipeDropped | 147 | g.mode = swipeDropped | ||
| 140 | return true | 148 | 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 true | 151 | return true | ||
| 145 | } | 152 | } | ||
| 146 | return false | 153 | 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 = swipeOverview | 165 | + | 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 = swipeDropped | 168 | 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 = swipeDiscrete | 171 | + | g.mode, g.snap = swipeDiscrete, newStepSwipe() |
| 165 | case g.horizontal && w.slidable(): | 172 | case g.horizontal && w.slidable(): | ||
| 166 | - | g.mode, g.ws = swipeColumns, w | 173 | + | g.mode, g.ws, g.points = swipeColumns, w, w.snapPoints() |
| 167 | w.motion = nil | 174 | 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))) |
| 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, nil | 183 | 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) |
| 178 | default: | 185 | default: | ||
| 179 | - | g.mode = swipeDiscrete | 186 | + | 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 false | 196 | 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.mon | 199 | m := g.screen.mon | ||
| 195 | switch g.mode { | 200 | switch g.mode { | ||
| 196 | case swipeColumns: | 201 | case swipeColumns: | ||
| 197 | w := g.ws | 202 | 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 false | 205 | return false | ||
| 201 | } | 206 | } | ||
| 202 | - | scale := w.swipeScale() | |||
| 203 | shown := float64(w.ViewX) + w.shift | 207 | 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()*scale | 210 | + | 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 false | 223 | return false | ||
| 218 | } | 224 | } | ||
| 219 | - | last := float64(len(m.Workspaces) - 1) | |||
| 220 | cur := float64(m.Active) + m.switchOff | 225 | 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.Active | 227 | 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 the | 231 | // 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.list | 242 | 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 false | 247 | 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() != before | 253 | 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 false | 257 | return false | ||
| 253 | } | 258 | } | ||
| 254 | a := ActionFocusWorkspaceDown | 259 | a := ActionFocusWorkspaceDown | ||
| 255 | switch { | 260 | switch { | ||
| 256 | - | case g.horizontal && p < 0: | 261 | + | case g.horizontal && step < 0: |
| 257 | a = ActionFocusColumnLeft | 262 | a = ActionFocusColumnLeft | ||
| 258 | case g.horizontal: | 263 | case g.horizontal: | ||
| 259 | a = ActionFocusColumnRight | 264 | a = ActionFocusColumnRight | ||
| 260 | - | case p < 0: | 265 | + | case step < 0: |
| 261 | a = ActionFocusWorkspaceUp | 266 | a = ActionFocusWorkspaceUp | ||
| 262 | } | 267 | } | ||
| 263 | // Focus actions act on the focused screen: the swipe's, unless the | 268 | // 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 { | |||
| 136 | 136 | } | |
| 137 | 137 | return nil | |
| 138 | 138 | case ports.PointerAxis: | |
| 139 | + | c.scrollStop(v) | |
| 139 | 140 | // In the overview, scrolling moves the selection. | |
| 140 | 141 | if c.cur().mon.ov.open && !c.overviewKeyboardTaken() { | |
| 141 | 142 | 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 nil | 137 | 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) { | |||
| 178 | 178 | // it moves, never more than limit. | |
| 179 | 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 | 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 | 190 | func (r rubberBand) band(x float64) float64 { | |
| 185 | 191 | return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit | |
| 186 | 192 | } | |
| @@ -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.limit | 191 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| 7 | 8 | ) | |
| @@ -122,21 +123,246 @@ func TestSnap(t *testing.T) { | |||
| 122 | 123 | for i := range 4 { | |
| 123 | 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 | 129 | for _, tc := range []struct { | |
| 126 | - | target float64 | |
| 127 | - | forward bool | |
| 128 | 130 | view int | |
| 131 | + | forward bool | |
| 129 | 132 | focus int | |
| 130 | 133 | }{ | |
| 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}, | |
| 136 | 336 | } { | |
| 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) | |
| 140 | 366 | } | |
| 141 | 367 | } | |
| 142 | 368 | } | |
| @@ -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 int | 130 | view int | ||
| 131 | + | forward bool | |||
| 129 | focus int | 132 | 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 { | |||
| 24 | 24 | selected WindowID | |
| 25 | 25 | selectedAt int | |
| 26 | 26 | 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 | 34 | // rowSnapshot is a row as the overview first showed it: browsing moves its | |
| @@ -71,7 +76,7 @@ func (m *Monitor) ToggleOverview() { | |||
| 71 | 76 | m.stopSwitch() | |
| 72 | 77 | m.each(func(w *Workspace) { w.stopSlide() }) | |
| 73 | 78 | 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 | |
| 75 | 80 | m.overviewOpens++ | |
| 76 | 81 | m.selectRow() | |
| 77 | 82 | } | |
| @@ -892,21 +897,45 @@ func (m *Monitor) overviewFocus(a Action) bool { | |||
| 892 | 897 | return true | |
| 893 | 898 | } | |
| 894 | 899 | ||
| 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 | + | ) | |
| 898 | 909 | ||
| 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). | |
| 904 | 921 | func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |
| 905 | 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 | 923 | 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 | 939 | add := func(acc *float64, ax ports.ScrollAxis) { | |
| 911 | 940 | switch { | |
| 912 | 941 | case !ax.Set: | |
| @@ -916,7 +945,9 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 916 | 945 | *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 | 951 | add(&m.ov.scrollX, a.Horizontal) | |
| 921 | 952 | for { | |
| 922 | 953 | switch { | |
| @@ -924,10 +955,17 @@ func (m *Monitor) overviewScroll(a ports.PointerAxis) (changed bool) { | |||
| 924 | 955 | m.OverviewMove(0, sign(m.ov.scrollY)) | |
| 925 | 956 | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | |
| 926 | 957 | 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 | 963 | 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 | |
| 930 | 965 | m.ov.scrollY = 0 | |
| 966 | + | if finger { | |
| 967 | + | m.ov.sideways, stepX = true, overviewScrollRepeat | |
| 968 | + | } | |
| 931 | 969 | default: | |
| 932 | 970 | 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 | 988 | func sign(v float64) int { | |
| 939 | 989 | if v < 0 { | |
| 940 | 990 | return -1 | |
| @@ -24,6 +24,11 @@ type overviewState struct { | |||||
| 24 | selected WindowID | 24 | selected WindowID | ||
| 25 | selectedAt int | 25 | selectedAt int | ||
| 26 | scrollX, scrollY float64 | 26 | 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 its | 34 | // 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.back | 78 | 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 |
| 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 true | 897 | return true | ||
| 893 | } | 898 | } | ||
| 894 | 899 | ||||
| 895 | - | // overviewScrollStep is the two-finger scroll distance, in libinput's | 900 | + | 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 = 60 | 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 | + | ) | |||
| 898 | 909 | ||||
| 899 | - | // overviewScroll moves the overview selection with a scroll frame: two | 910 | + | // overviewScroll moves the overview selection with a scroll frame: a row |
| 900 | - | // fingers step once per overviewScrollStep on the axis they move most | 911 | + | // step when the vertical scroll reaches overviewScrollStep and leads, else |
| 901 | - | // along, a wheel once per notch (high-resolution wheels add up their | 912 | + | // 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 false | 923 | 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.Value | 945 | *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)) * overviewScrollStep | 956 | m.ov.scrollY -= float64(sign(m.ov.scrollY)) * overviewScrollStep | ||
| 926 | m.ov.scrollX = 0 | 957 | 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)) * overviewScrollStep | 964 | + | m.ov.scrollX -= float64(sign(m.ov.scrollX)) * stepX |
| 930 | m.ov.scrollY = 0 | 965 | m.ov.scrollY = 0 | ||
| 966 | + | if finger { | |||
| 967 | + | m.ov.sideways, stepX = true, overviewScrollRepeat | |||
| 968 | + | } | |||
| 931 | default: | 969 | default: | ||
| 932 | return changed | 970 | 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 -1 | 990 | return -1 | ||
M internal/core/overview_input_test.go +6 -2
| @@ -226,18 +226,22 @@ func TestOverviewTwoFingerScroll(t *testing.T) { | |||
| 226 | 226 | finger := func(dx, dy float64) ports.PointerAxis { | |
| 227 | 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 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. | |
| 231 | 232 | input <- finger(-50, 5) | |
| 232 | 233 | input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}} | |
| 233 | 234 | input <- finger(-50, 5) | |
| 234 | 235 | input <- finger(-50, 0) | |
| 236 | + | input <- finger(-100, 0) | |
| 235 | 237 | sceneMatch(t, scenes, focused(2)) | |
| 238 | + | input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}} | |
| 236 | 239 | input <- ports.KeyEvent{Keysym: "l", Pressed: true} | |
| 237 | 240 | sceneMatch(t, scenes, focused(3)) | |
| 238 | 241 | input <- ports.KeyEvent{Keysym: "l"} | |
| 239 | 242 | input <- finger(-60, 0) | |
| 240 | 243 | sceneMatch(t, scenes, focused(2)) | |
| 244 | + | input <- ports.PointerAxis{Source: ports.AxisFinger, Horizontal: ports.ScrollAxis{Set: true, Stop: true}} | |
| 241 | 245 | // A wheel notch down has nowhere to go (no workspace below with | |
| 242 | 246 | // windows); a finger scroll left moves on. | |
| 243 | 247 | 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 the | 229 | + | // 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 with | 245 | // 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) { | |||
| 653 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | + | "slices" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -37,33 +38,54 @@ func (w *Workspace) swipeScale() float64 { | |||
| 37 | 38 | return float64(w.Usable.W) / viewSwipeMovement | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 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 { | |
| 45 | 46 | g := w.gap() | |
| 46 | 47 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 47 | 48 | last := len(w.Columns) - 1 | |
| 48 | 49 | lo := w.columnX(0) - minX | |
| 49 | 50 | 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)) | |
| 55 | 61 | } | |
| 56 | 62 | } | |
| 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)) | |
| 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 | 83 | full := func(i int) bool { | |
| 62 | 84 | x := w.columnX(i) - view | |
| 63 | 85 | return x >= minX && x+w.columnWidth(i) <= maxX | |
| 64 | 86 | } | |
| 65 | 87 | if full(w.Focus) && view == w.ViewX { | |
| 66 | - | return view, w.Focus | |
| 88 | + | return w.Focus | |
| 67 | 89 | } | |
| 68 | 90 | focus = -1 | |
| 69 | 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, focus | |
| 105 | + | return focus | |
| 84 | 106 | } | |
| 85 | 107 | ||
| 86 | 108 | 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) / viewSwipeMovement | 38 | return float64(w.Usable.W) / viewSwipeMovement | ||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 40 | - | // snap returns the view closest to target that aligns a column edge with | 41 | + | // 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 to | 42 | + | // area, never past the first or last column. Points closer than a |
| 42 | - | // focus there: the current one while it stays fully shown, else the | 43 | + | // 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-g | 47 | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | ||
| 47 | last := len(w.Columns) - 1 | 48 | last := len(w.Columns) - 1 | ||
| 48 | lo := w.columnX(0) - minX | 49 | 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, d | 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)) | |||
| 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) - view | 84 | x := w.columnX(i) - view | ||
| 63 | return x >= minX && x+w.columnWidth(i) <= maxX | 85 | 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.Focus | 88 | + | return w.Focus |
| 67 | } | 89 | } | ||
| 68 | focus = -1 | 90 | 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, focus | 105 | + | 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) { | |||
| 166 | 166 | r.input <- ports.SwipeUpdate{DX: -10, Time: r.at} | |
| 167 | 167 | s := r.move(t, -10, 0) | |
| 168 | 168 | 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 { | |
| 171 | 172 | t.Fatalf("column moved %d px with the fingers", d) | |
| 172 | 173 | } | |
| 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) | |
| 176 | 183 | } | |
| 177 | 184 | } | |
| 178 | 185 | ||
| @@ -478,13 +485,13 @@ func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | |||
| 478 | 485 | } | |
| 479 | 486 | // The swipe on DP-1 still follows the fingers. | |
| 480 | 487 | var got ports.Rect | |
| 481 | - | for _, sc := range receiveMove(t, r, -30) { | |
| 488 | + | for _, sc := range receiveMove(t, r, -570) { | |
| 482 | 489 | if sc.Output == wide.Name { | |
| 483 | 490 | got, _ = rectOf(sc, 2) | |
| 484 | 491 | } | |
| 485 | 492 | } | |
| 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 { | |
| 488 | 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 | + | } | |
| @@ -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.Rect | 487 | 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 | + | } | |||