feat(input): smooth touchpad swipes, tap to click and touchpad speed (#54)
* feat(input): follow three-finger swipes with inertia and spring snapping - Stream swipe begin/update/end with libinput device timestamps - Track swipe speed, project the landing point and settle with a spring - Slide columns and workspaces live, stepped on output page flips - Send device timestamps on pointer, scroll and key events - Add touchpad.tap (on by default): tap to click, two-finger right click * fix(core): keep swipes consistent with workspace changes and pointer focus - Measure a workspace landing slide in the list its swipe began on - Drop a workspace swipe when the list or active workspace changes - Re-hit-test a still pointer as windows slide under it - Settle a running swipe back when another touchpad begins one * fix(libinput): stream one touchpad swipe at a time A second touchpad's swipe is ignored until the first ends, so its end can no longer finish the other swipe in core. * feat(libinput): configure touchpad pointer speed and scroll factor - touchpad.accel-speed (-1 to 1) and touchpad.accel-profile (adaptive, flat) set the touchpad pointer acceleration through libinput - touchpad.scroll-factor multiplies two-finger scroll - Mice keep their libinput defaults; all three reload live * fix(libinput): set pointer speed on touchpads without tapping Touchpads are found by their gesture capability, so accel-speed and accel-profile reach them even when they cannot tap. * fix(core): drop swipes on workspace binds and keep them on their output - A bind that changes the workspace on screen lets go of the swipe - A discrete swipe does nothing if the focus moved to another output - A still pointer gets a motion only when its window position changes * fix(core): drop a swipe only when its own output changes workspace A workspace bind on another output no longer cancels a swipe in progress.
- commit
- ffbd0df1587d124cbaca3d12d6b3ff262f8e5f84
- author
- bnema · · 2026-09-29 01:50 UTC
- committer
- GitHub · · 2026-09-29 01:50 UTC
- parent
- 3fbf9c7
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
23 files · +1903 -194
M docs/config.md +12 -8
| @@ -33,6 +33,10 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 33 | 33 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 34 | 34 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | |
| 35 | 35 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | |
| 36 | + | | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle | | |
| 37 | + | | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) | | |
| 38 | + | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | |
| 39 | + | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | |
| 36 | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 37 | 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | |
| 38 | 42 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | |
| @@ -50,16 +54,16 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 50 | 54 | ||
| 51 | 55 | ## Touchpad | |
| 52 | 56 | ||
| 53 | - | A three-finger swipe runs one action when the fingers lift: | |
| 57 | + | A three-finger swipe follows the fingers: | |
| 54 | 58 | ||
| 55 | - | | Swipe | `natural-scroll = off` | `natural-scroll = on` | | |
| 56 | - | |---|---|---| | |
| 57 | - | | Up | `focus-workspace-up` | `focus-workspace-down` | | |
| 58 | - | | Down | `focus-workspace-down` | `focus-workspace-up` | | |
| 59 | - | | Left | `focus-column-left` | `focus-column-right` | | |
| 60 | - | | Right | `focus-column-right` | `focus-column-left` | | |
| 59 | + | - 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. | |
| 60 | + | - 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. | |
| 61 | + | ||
| 62 | + | 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. | |
| 63 | + | ||
| 64 | + | Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. | |
| 61 | 65 | ||
| 62 | - | Column swipes move the focus with either overflow: `scroll` brings the column into view, `fixed` only moves the focus. | |
| 66 | + | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. | |
| 63 | 67 | ||
| 64 | 68 | ## HDR | |
| 65 | 69 | ||
| @@ -33,6 +33,10 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 33 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | 33 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | ||
| 34 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | 34 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | ||
| 35 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | 35 | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | ||
| 36 | + | | `touchpad.tap` | `on` | Tap to click: one finger left, two right, three middle | | |||
| 37 | + | | `touchpad.accel-speed` | `0` | Touchpad pointer speed, from `-1` (slowest) to `1` (fastest) | | |||
| 38 | + | | `touchpad.accel-profile` | `adaptive` | `adaptive`: faster finger moves go further; `flat`: constant speed | | |||
| 39 | + | | `touchpad.scroll-factor` | `1` | Multiplies two-finger scroll distance, above `0` up to `10` | | |||
| 36 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 40 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 37 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | 41 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | ||
| 38 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | 42 | | `workspace.<name>.monitor` | first output | Home monitor: connector (`DP-2`) or monitor key; guests on another output while home is absent and returns when it reconnects | | ||
| @@ -50,16 +54,16 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 50 | 54 | ||||
| 51 | ## Touchpad | 55 | ## Touchpad | ||
| 52 | 56 | ||||
| 53 | - | A three-finger swipe runs one action when the fingers lift: | 57 | + | A three-finger swipe follows the fingers: |
| 54 | 58 | ||||
| 55 | - | | Swipe | `natural-scroll = off` | `natural-scroll = on` | | 59 | + | - 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. |
| 56 | - | |---|---|---| | 60 | + | - 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. |
| 57 | - | | Up | `focus-workspace-up` | `focus-workspace-down` | | 61 | + | |
| 58 | - | | Down | `focus-workspace-down` | `focus-workspace-up` | | 62 | + | 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. |
| 59 | - | | Left | `focus-column-left` | `focus-column-right` | | 63 | + | |
| 60 | - | | Right | `focus-column-right` | `focus-column-left` | | 64 | + | Where the view cannot scroll (`fixed` overflow, the stash, a floating or fullscreen window, a named workspace), a quick swipe runs `focus-column-left/right` or `focus-workspace-up/down` when the fingers lift. |
| 61 | 65 | ||||
| 62 | - | Column swipes move the focus with either overflow: `scroll` brings the column into view, `fixed` only moves the focus. | 66 | + | Two-finger scroll goes to the window under the pointer with the touchpad's timestamps, so apps with kinetic scrolling keep their inertia. |
| 63 | 67 | ||||
| 64 | ## HDR | 68 | ## HDR | ||
| 65 | 69 | ||||
M docs/headless.md +1 -1
| @@ -18,7 +18,7 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image | |||
| 18 | 18 | - `sleep 1s` | |
| 19 | 19 | - `move <x> <y>` (output coordinates) | |
| 20 | 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | |
| 21 | - | - `swipe up|down|left|right` (three-finger touchpad swipe) | |
| 21 | + | - `swipe up|down|left|right` (a quick three-finger touchpad flick) | |
| 22 | 22 | ||
| 23 | 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | |
| 24 | 24 | ||
| @@ -18,7 +18,7 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image | |||||
| 18 | - `sleep 1s` | 18 | - `sleep 1s` | ||
| 19 | - `move <x> <y>` (output coordinates) | 19 | - `move <x> <y>` (output coordinates) | ||
| 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | ||
| 21 | - | - `swipe up|down|left|right` (three-finger touchpad swipe) | 21 | + | - `swipe up|down|left|right` (a quick three-finger touchpad flick) |
| 22 | 22 | ||||
| 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | 23 | `examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text: | ||
| 24 | 24 | ||||
M examples/config +10 -3
| @@ -45,10 +45,17 @@ layout.overflow = scroll | |||
| 45 | 45 | # cycle-column-width steps through these, then back to the equal share. | |
| 46 | 46 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 47 | 47 | ||
| 48 | - | # Touchpad. A three-finger swipe up or down changes workspace, left or right | |
| 49 | - | # changes column. natural-scroll = on moves the content with the fingers, | |
| 50 | - | # for scroll and swipes. | |
| 48 | + | # Touchpad. A three-finger swipe up or down slides between workspaces, left | |
| 49 | + | # or right scrolls the columns. natural-scroll = on moves the content with | |
| 50 | + | # the fingers, for scroll and swipes. tap = on clicks on a tap: one finger | |
| 51 | + | # left, two right, three middle. | |
| 51 | 52 | touchpad.natural-scroll = off | |
| 53 | + | touchpad.tap = on | |
| 54 | + | # Pointer speed from -1 to 1; adaptive speeds up quick moves, flat does not. | |
| 55 | + | touchpad.accel-speed = 0 | |
| 56 | + | touchpad.accel-profile = adaptive | |
| 57 | + | # Two-finger scroll distance multiplier. | |
| 58 | + | touchpad.scroll-factor = 1 | |
| 52 | 59 | ||
| 53 | 60 | # on: moving a column or window to another workspace shows that workspace. | |
| 54 | 61 | focus.follow-move = off | |
| @@ -45,10 +45,17 @@ layout.overflow = scroll | |||||
| 45 | # cycle-column-width steps through these, then back to the equal share. | 45 | # cycle-column-width steps through these, then back to the equal share. | ||
| 46 | layout.presets = 1/3, 1/2, 2/3, 1 | 46 | layout.presets = 1/3, 1/2, 2/3, 1 | ||
| 47 | 47 | ||||
| 48 | - | # Touchpad. A three-finger swipe up or down changes workspace, left or right | 48 | + | # Touchpad. A three-finger swipe up or down slides between workspaces, left |
| 49 | - | # changes column. natural-scroll = on moves the content with the fingers, | 49 | + | # or right scrolls the columns. natural-scroll = on moves the content with |
| 50 | - | # for scroll and swipes. | 50 | + | # the fingers, for scroll and swipes. tap = on clicks on a tap: one finger |
| 51 | + | # left, two right, three middle. | |||
| 51 | touchpad.natural-scroll = off | 52 | touchpad.natural-scroll = off | ||
| 53 | + | touchpad.tap = on | |||
| 54 | + | # Pointer speed from -1 to 1; adaptive speeds up quick moves, flat does not. | |||
| 55 | + | touchpad.accel-speed = 0 | |||
| 56 | + | touchpad.accel-profile = adaptive | |||
| 57 | + | # Two-finger scroll distance multiplier. | |||
| 58 | + | touchpad.scroll-factor = 1 | |||
| 52 | 59 | ||||
| 53 | # on: moving a column or window to another workspace shows that workspace. | 60 | # on: moving a column or window to another workspace shows that workspace. | ||
| 54 | focus.follow-move = off | 61 | focus.follow-move = off | ||
M internal/adapters/config/config.go +26 -0
| @@ -99,6 +99,9 @@ func Defaults() ports.Config { | |||
| 99 | 99 | c.Layout.MaxColumns = 2 | |
| 100 | 100 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | |
| 101 | 101 | c.Layout.Overflow = "scroll" | |
| 102 | + | c.Touchpad.Tap = true | |
| 103 | + | c.Touchpad.AccelProfile = ports.AccelAdaptive | |
| 104 | + | c.Touchpad.ScrollFactor = 1 | |
| 102 | 105 | c.Binds = map[string]string{} | |
| 103 | 106 | for _, b := range defaultBinds { | |
| 104 | 107 | combos := []string{b.combo} | |
| @@ -525,6 +528,29 @@ func set(c *ports.Config, key, v string) error { | |||
| 525 | 528 | return err | |
| 526 | 529 | } | |
| 527 | 530 | c.Touchpad.NaturalScroll = b | |
| 531 | + | case "touchpad.tap": | |
| 532 | + | b, err := onOff(v) | |
| 533 | + | if err != nil { | |
| 534 | + | return err | |
| 535 | + | } | |
| 536 | + | c.Touchpad.Tap = b | |
| 537 | + | case "touchpad.accel-speed": | |
| 538 | + | s, err := strconv.ParseFloat(v, 64) | |
| 539 | + | if err != nil || !(s >= -1 && s <= 1) { | |
| 540 | + | return fmt.Errorf("must be between -1 and 1") | |
| 541 | + | } | |
| 542 | + | c.Touchpad.AccelSpeed = s | |
| 543 | + | case "touchpad.accel-profile": | |
| 544 | + | if v != ports.AccelAdaptive && v != ports.AccelFlat { | |
| 545 | + | return fmt.Errorf("must be adaptive or flat") | |
| 546 | + | } | |
| 547 | + | c.Touchpad.AccelProfile = v | |
| 548 | + | case "touchpad.scroll-factor": | |
| 549 | + | f, err := strconv.ParseFloat(v, 64) | |
| 550 | + | if err != nil || !(f > 0 && f <= 10) { | |
| 551 | + | return fmt.Errorf("must be above 0 and at most 10") | |
| 552 | + | } | |
| 553 | + | c.Touchpad.ScrollFactor = f | |
| 528 | 554 | case "focus.follow-move": | |
| 529 | 555 | b, err := onOff(v) | |
| 530 | 556 | if err != nil { | |
| @@ -99,6 +99,9 @@ func Defaults() ports.Config { | |||||
| 99 | c.Layout.MaxColumns = 2 | 99 | c.Layout.MaxColumns = 2 | ||
| 100 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | 100 | c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"} | ||
| 101 | c.Layout.Overflow = "scroll" | 101 | c.Layout.Overflow = "scroll" | ||
| 102 | + | c.Touchpad.Tap = true | |||
| 103 | + | c.Touchpad.AccelProfile = ports.AccelAdaptive | |||
| 104 | + | c.Touchpad.ScrollFactor = 1 | |||
| 102 | c.Binds = map[string]string{} | 105 | c.Binds = map[string]string{} | ||
| 103 | for _, b := range defaultBinds { | 106 | for _, b := range defaultBinds { | ||
| 104 | combos := []string{b.combo} | 107 | combos := []string{b.combo} | ||
| @@ -525,6 +528,29 @@ func set(c *ports.Config, key, v string) error { | |||||
| 525 | return err | 528 | return err | ||
| 526 | } | 529 | } | ||
| 527 | c.Touchpad.NaturalScroll = b | 530 | c.Touchpad.NaturalScroll = b | ||
| 531 | + | case "touchpad.tap": | |||
| 532 | + | b, err := onOff(v) | |||
| 533 | + | if err != nil { | |||
| 534 | + | return err | |||
| 535 | + | } | |||
| 536 | + | c.Touchpad.Tap = b | |||
| 537 | + | case "touchpad.accel-speed": | |||
| 538 | + | s, err := strconv.ParseFloat(v, 64) | |||
| 539 | + | if err != nil || !(s >= -1 && s <= 1) { | |||
| 540 | + | return fmt.Errorf("must be between -1 and 1") | |||
| 541 | + | } | |||
| 542 | + | c.Touchpad.AccelSpeed = s | |||
| 543 | + | case "touchpad.accel-profile": | |||
| 544 | + | if v != ports.AccelAdaptive && v != ports.AccelFlat { | |||
| 545 | + | return fmt.Errorf("must be adaptive or flat") | |||
| 546 | + | } | |||
| 547 | + | c.Touchpad.AccelProfile = v | |||
| 548 | + | case "touchpad.scroll-factor": | |||
| 549 | + | f, err := strconv.ParseFloat(v, 64) | |||
| 550 | + | if err != nil || !(f > 0 && f <= 10) { | |||
| 551 | + | return fmt.Errorf("must be above 0 and at most 10") | |||
| 552 | + | } | |||
| 553 | + | c.Touchpad.ScrollFactor = f | |||
| 528 | case "focus.follow-move": | 554 | case "focus.follow-move": | ||
| 529 | b, err := onOff(v) | 555 | b, err := onOff(v) | ||
| 530 | if err != nil { | 556 | if err != nil { | ||
M internal/adapters/config/config_test.go +37 -0
| @@ -73,6 +73,43 @@ func TestTouchpadNaturalScroll(t *testing.T) { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func TestTouchpadTap(t *testing.T) { | |
| 77 | + | if !Defaults().Touchpad.Tap { | |
| 78 | + | t.Fatal("tap to click off by default") | |
| 79 | + | } | |
| 80 | + | c, w := parseString(t, "touchpad.tap = off\n") | |
| 81 | + | if c.Touchpad.Tap || len(w) != 0 { | |
| 82 | + | t.Fatal(c.Touchpad, w) | |
| 83 | + | } | |
| 84 | + | c, w = parseString(t, "touchpad.tap = sometimes\n") | |
| 85 | + | if !c.Touchpad.Tap || len(w) != 1 { | |
| 86 | + | t.Fatal(c.Touchpad, w) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestTouchpadSpeed(t *testing.T) { | |
| 91 | + | d := Defaults().Touchpad | |
| 92 | + | if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 { | |
| 93 | + | t.Fatal(d) | |
| 94 | + | } | |
| 95 | + | c, w := parseString(t, "touchpad.accel-speed = -0.5\ntouchpad.accel-profile = flat\ntouchpad.scroll-factor = 0.5\n") | |
| 96 | + | if c.Touchpad.AccelSpeed != -0.5 || c.Touchpad.AccelProfile != ports.AccelFlat || c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 { | |
| 97 | + | t.Fatal(c.Touchpad, w) | |
| 98 | + | } | |
| 99 | + | for _, bad := range []string{ | |
| 100 | + | "touchpad.accel-speed = 2", | |
| 101 | + | "touchpad.accel-speed = NaN", | |
| 102 | + | "touchpad.accel-profile = fast", | |
| 103 | + | "touchpad.scroll-factor = 0", | |
| 104 | + | "touchpad.scroll-factor = 11", | |
| 105 | + | } { | |
| 106 | + | c, w := parseString(t, bad+"\n") | |
| 107 | + | if c.Touchpad != d || len(w) != 1 { | |
| 108 | + | t.Errorf("%s: %+v %v", bad, c.Touchpad, w) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 76 | 113 | func TestParse(t *testing.T) { | |
| 77 | 114 | c, w := parseString(t, ` | |
| 78 | 115 | # comment | |
| @@ -73,6 +73,43 @@ func TestTouchpadNaturalScroll(t *testing.T) { | |||||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func TestTouchpadTap(t *testing.T) { | |||
| 77 | + | if !Defaults().Touchpad.Tap { | |||
| 78 | + | t.Fatal("tap to click off by default") | |||
| 79 | + | } | |||
| 80 | + | c, w := parseString(t, "touchpad.tap = off\n") | |||
| 81 | + | if c.Touchpad.Tap || len(w) != 0 { | |||
| 82 | + | t.Fatal(c.Touchpad, w) | |||
| 83 | + | } | |||
| 84 | + | c, w = parseString(t, "touchpad.tap = sometimes\n") | |||
| 85 | + | if !c.Touchpad.Tap || len(w) != 1 { | |||
| 86 | + | t.Fatal(c.Touchpad, w) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestTouchpadSpeed(t *testing.T) { | |||
| 91 | + | d := Defaults().Touchpad | |||
| 92 | + | if d.AccelSpeed != 0 || d.AccelProfile != ports.AccelAdaptive || d.ScrollFactor != 1 { | |||
| 93 | + | t.Fatal(d) | |||
| 94 | + | } | |||
| 95 | + | c, w := parseString(t, "touchpad.accel-speed = -0.5\ntouchpad.accel-profile = flat\ntouchpad.scroll-factor = 0.5\n") | |||
| 96 | + | if c.Touchpad.AccelSpeed != -0.5 || c.Touchpad.AccelProfile != ports.AccelFlat || c.Touchpad.ScrollFactor != 0.5 || len(w) != 0 { | |||
| 97 | + | t.Fatal(c.Touchpad, w) | |||
| 98 | + | } | |||
| 99 | + | for _, bad := range []string{ | |||
| 100 | + | "touchpad.accel-speed = 2", | |||
| 101 | + | "touchpad.accel-speed = NaN", | |||
| 102 | + | "touchpad.accel-profile = fast", | |||
| 103 | + | "touchpad.scroll-factor = 0", | |||
| 104 | + | "touchpad.scroll-factor = 11", | |||
| 105 | + | } { | |||
| 106 | + | c, w := parseString(t, bad+"\n") | |||
| 107 | + | if c.Touchpad != d || len(w) != 1 { | |||
| 108 | + | t.Errorf("%s: %+v %v", bad, c.Touchpad, w) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 76 | func TestParse(t *testing.T) { | 113 | func TestParse(t *testing.T) { | ||
| 77 | c, w := parseString(t, ` | 114 | c, w := parseString(t, ` | ||
| 78 | # comment | 115 | # comment | ||
M internal/adapters/headlessinput/input.go +8 -6
| @@ -23,7 +23,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 23 | 23 | var layout ports.Layout | |
| 24 | 24 | defer func() { km.Close() }() | |
| 25 | 25 | emit := func(code uint32, down bool) error { | |
| 26 | - | ev := km.Key(code, down, uint32(time.Now().UnixMilli())) | |
| 26 | + | ev := km.Key(code, down, msec(monotonic())) | |
| 27 | 27 | select { | |
| 28 | 28 | case <-ctx.Done(): | |
| 29 | 29 | return ctx.Err() | |
| @@ -107,7 +107,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 107 | 107 | } | |
| 108 | 108 | return 0 | |
| 109 | 109 | } | |
| 110 | - | now := uint32(time.Now().UnixMilli()) | |
| 110 | + | now := msec(monotonic()) | |
| 111 | 111 | if len(fields) > 0 && fields[0] == "move" { | |
| 112 | 112 | if len(fields) == 3 { | |
| 113 | 113 | x, ex := strconv.ParseFloat(fields[1], 64) | |
| @@ -126,8 +126,8 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 126 | 126 | continue | |
| 127 | 127 | } | |
| 128 | 128 | if len(fields) > 0 && fields[0] == "swipe" { | |
| 129 | - | dirs := map[string]ports.SwipeDir{"up": ports.SwipeUp, "down": ports.SwipeDown, "left": ports.SwipeLeft, "right": ports.SwipeRight} | |
| 130 | - | d, ok := ports.SwipeDir(0), len(fields) == 2 | |
| 129 | + | dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}} | |
| 130 | + | d, ok := [2]float64{}, len(fields) == 2 | |
| 131 | 131 | if ok { | |
| 132 | 132 | d, ok = dirs[fields[1]] | |
| 133 | 133 | } | |
| @@ -135,8 +135,10 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 135 | 135 | log.Warn().Str("line", line).Msg("invalid swipe") | |
| 136 | 136 | continue | |
| 137 | 137 | } | |
| 138 | - | if err := sendPointer(ports.Swipe{Dir: d}); err != nil { | |
| 139 | - | return err | |
| 138 | + | for _, ev := range swipeEvents(d[0], d[1], monotonic()) { | |
| 139 | + | if err := sendPointer(ev); err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 140 | 142 | } | |
| 141 | 143 | continue | |
| 142 | 144 | } | |
| @@ -23,7 +23,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 23 | var layout ports.Layout | 23 | var layout ports.Layout | ||
| 24 | defer func() { km.Close() }() | 24 | defer func() { km.Close() }() | ||
| 25 | emit := func(code uint32, down bool) error { | 25 | emit := func(code uint32, down bool) error { | ||
| 26 | - | ev := km.Key(code, down, uint32(time.Now().UnixMilli())) | 26 | + | ev := km.Key(code, down, msec(monotonic())) |
| 27 | select { | 27 | select { | ||
| 28 | case <-ctx.Done(): | 28 | case <-ctx.Done(): | ||
| 29 | return ctx.Err() | 29 | return ctx.Err() | ||
| @@ -107,7 +107,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 107 | } | 107 | } | ||
| 108 | return 0 | 108 | return 0 | ||
| 109 | } | 109 | } | ||
| 110 | - | now := uint32(time.Now().UnixMilli()) | 110 | + | now := msec(monotonic()) |
| 111 | if len(fields) > 0 && fields[0] == "move" { | 111 | if len(fields) > 0 && fields[0] == "move" { | ||
| 112 | if len(fields) == 3 { | 112 | if len(fields) == 3 { | ||
| 113 | x, ex := strconv.ParseFloat(fields[1], 64) | 113 | x, ex := strconv.ParseFloat(fields[1], 64) | ||
| @@ -126,8 +126,8 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 126 | continue | 126 | continue | ||
| 127 | } | 127 | } | ||
| 128 | if len(fields) > 0 && fields[0] == "swipe" { | 128 | if len(fields) > 0 && fields[0] == "swipe" { | ||
| 129 | - | dirs := map[string]ports.SwipeDir{"up": ports.SwipeUp, "down": ports.SwipeDown, "left": ports.SwipeLeft, "right": ports.SwipeRight} | 129 | + | dirs := map[string][2]float64{"up": {0, -1}, "down": {0, 1}, "left": {-1, 0}, "right": {1, 0}} |
| 130 | - | d, ok := ports.SwipeDir(0), len(fields) == 2 | 130 | + | d, ok := [2]float64{}, len(fields) == 2 |
| 131 | if ok { | 131 | if ok { | ||
| 132 | d, ok = dirs[fields[1]] | 132 | d, ok = dirs[fields[1]] | ||
| 133 | } | 133 | } | ||
| @@ -135,8 +135,10 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 135 | log.Warn().Str("line", line).Msg("invalid swipe") | 135 | log.Warn().Str("line", line).Msg("invalid swipe") | ||
| 136 | continue | 136 | continue | ||
| 137 | } | 137 | } | ||
| 138 | - | if err := sendPointer(ports.Swipe{Dir: d}); err != nil { | 138 | + | for _, ev := range swipeEvents(d[0], d[1], monotonic()) { |
| 139 | - | return err | 139 | + | if err := sendPointer(ev); err != nil { |
| 140 | + | return err | |||
| 141 | + | } | |||
| 140 | } | 142 | } | ||
| 141 | continue | 143 | continue | ||
| 142 | } | 144 | } | ||
M internal/adapters/headlessinput/input_test.go +13 -4
| @@ -66,11 +66,11 @@ func TestPointerScript(t *testing.T) { | |||
| 66 | 66 | script <- line | |
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| 69 | - | input := make(chan ports.InputEvent, 8) | |
| 69 | + | input := make(chan ports.InputEvent, 32) | |
| 70 | 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | |
| 71 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | - | if len(input) != 6 { | |
| 73 | + | if len(input) != 5+swipeSteps+2 { | |
| 74 | 74 | t.Fatalf("events: %d", len(input)) | |
| 75 | 75 | } | |
| 76 | 76 | m := (<-input).(ports.PointerMotion) | |
| @@ -83,7 +83,16 @@ func TestPointerScript(t *testing.T) { | |||
| 83 | 83 | t.Fatalf("got %+v want %+v", got, want) | |
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | - | if s := (<-input).(ports.Swipe); s.Dir != ports.SwipeLeft { | |
| 87 | - | t.Fatal(s) | |
| 86 | + | begin := (<-input).(ports.SwipeBegin) | |
| 87 | + | last := begin.Time | |
| 88 | + | for range swipeSteps { | |
| 89 | + | u := (<-input).(ports.SwipeUpdate) | |
| 90 | + | if u.DX >= 0 || u.DY != 0 || u.Time <= last { | |
| 91 | + | t.Fatalf("update %+v after %v", u, last) | |
| 92 | + | } | |
| 93 | + | last = u.Time | |
| 94 | + | } | |
| 95 | + | if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last { | |
| 96 | + | t.Fatal(e) | |
| 88 | 97 | } | |
| 89 | 98 | } | |
| @@ -66,11 +66,11 @@ func TestPointerScript(t *testing.T) { | |||||
| 66 | script <- line | 66 | script <- line | ||
| 67 | } | 67 | } | ||
| 68 | close(script) | 68 | close(script) | ||
| 69 | - | input := make(chan ports.InputEvent, 8) | 69 | + | input := make(chan ports.InputEvent, 32) |
| 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | ||
| 71 | t.Fatal(err) | 71 | t.Fatal(err) | ||
| 72 | } | 72 | } | ||
| 73 | - | if len(input) != 6 { | 73 | + | if len(input) != 5+swipeSteps+2 { |
| 74 | t.Fatalf("events: %d", len(input)) | 74 | t.Fatalf("events: %d", len(input)) | ||
| 75 | } | 75 | } | ||
| 76 | m := (<-input).(ports.PointerMotion) | 76 | m := (<-input).(ports.PointerMotion) | ||
| @@ -83,7 +83,16 @@ func TestPointerScript(t *testing.T) { | |||||
| 83 | t.Fatalf("got %+v want %+v", got, want) | 83 | t.Fatalf("got %+v want %+v", got, want) | ||
| 84 | } | 84 | } | ||
| 85 | } | 85 | } | ||
| 86 | - | if s := (<-input).(ports.Swipe); s.Dir != ports.SwipeLeft { | 86 | + | begin := (<-input).(ports.SwipeBegin) |
| 87 | - | t.Fatal(s) | 87 | + | last := begin.Time |
| 88 | + | for range swipeSteps { | |||
| 89 | + | u := (<-input).(ports.SwipeUpdate) | |||
| 90 | + | if u.DX >= 0 || u.DY != 0 || u.Time <= last { | |||
| 91 | + | t.Fatalf("update %+v after %v", u, last) | |||
| 92 | + | } | |||
| 93 | + | last = u.Time | |||
| 94 | + | } | |||
| 95 | + | if e := (<-input).(ports.SwipeEnd); e.Cancelled || e.Time != last { | |||
| 96 | + | t.Fatal(e) | |||
| 88 | } | 97 | } | ||
| 89 | } | 98 | } | ||
A internal/adapters/headlessinput/swipe.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package headlessinput | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "golang.org/x/sys/unix" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A scripted swipe is a quick flick, as libinput reports one: swipeSteps | |
| 11 | + | // updates of swipeStep touchpad units, one every swipeEvery. | |
| 12 | + | const ( | |
| 13 | + | swipeSteps = 10 | |
| 14 | + | swipeStep = 40.0 | |
| 15 | + | swipeEvery = 8 * time.Millisecond | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // swipeEvents is a three-finger swipe toward (dx, dy), a unit direction, | |
| 19 | + | // starting at the device time start. | |
| 20 | + | func swipeEvents(dx, dy float64, start time.Duration) []ports.InputEvent { | |
| 21 | + | evs := []ports.InputEvent{ports.SwipeBegin{Time: start}} | |
| 22 | + | at := start | |
| 23 | + | for range swipeSteps { | |
| 24 | + | at += swipeEvery | |
| 25 | + | evs = append(evs, ports.SwipeUpdate{DX: dx * swipeStep, DY: dy * swipeStep, Time: at}) | |
| 26 | + | } | |
| 27 | + | return append(evs, ports.SwipeEnd{Time: at}) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // monotonic is CLOCK_MONOTONIC, the clock of libinput's timestamps. | |
| 31 | + | func monotonic() time.Duration { | |
| 32 | + | var ts unix.Timespec | |
| 33 | + | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 34 | + | return time.Duration(ts.Nano()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func msec(d time.Duration) uint32 { return uint32(d / time.Millisecond) } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package headlessinput | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | "golang.org/x/sys/unix" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // A scripted swipe is a quick flick, as libinput reports one: swipeSteps | |||
| 11 | + | // updates of swipeStep touchpad units, one every swipeEvery. | |||
| 12 | + | const ( | |||
| 13 | + | swipeSteps = 10 | |||
| 14 | + | swipeStep = 40.0 | |||
| 15 | + | swipeEvery = 8 * time.Millisecond | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // swipeEvents is a three-finger swipe toward (dx, dy), a unit direction, | |||
| 19 | + | // starting at the device time start. | |||
| 20 | + | func swipeEvents(dx, dy float64, start time.Duration) []ports.InputEvent { | |||
| 21 | + | evs := []ports.InputEvent{ports.SwipeBegin{Time: start}} | |||
| 22 | + | at := start | |||
| 23 | + | for range swipeSteps { | |||
| 24 | + | at += swipeEvery | |||
| 25 | + | evs = append(evs, ports.SwipeUpdate{DX: dx * swipeStep, DY: dy * swipeStep, Time: at}) | |||
| 26 | + | } | |||
| 27 | + | return append(evs, ports.SwipeEnd{Time: at}) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // monotonic is CLOCK_MONOTONIC, the clock of libinput's timestamps. | |||
| 31 | + | func monotonic() time.Duration { | |||
| 32 | + | var ts unix.Timespec | |||
| 33 | + | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |||
| 34 | + | return time.Duration(ts.Nano()) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func msec(d time.Duration) uint32 { return uint32(d / time.Millisecond) } | |||
M internal/adapters/libinput/libinput.go +137 -68
| @@ -56,6 +56,7 @@ var ( | |||
| 56 | 56 | keyboardEvent func(ev uintptr) uintptr | |
| 57 | 57 | keyboardKey func(kev uintptr) uint32 | |
| 58 | 58 | keyboardState func(kev uintptr) int32 | |
| 59 | + | keyboardUsec func(kev uintptr) uint64 | |
| 59 | 60 | pointerEvent func(ev uintptr) uintptr | |
| 60 | 61 | pointerDX func(pev uintptr) float64 | |
| 61 | 62 | pointerDY func(pev uintptr) float64 | |
| @@ -74,10 +75,19 @@ var ( | |||
| 74 | 75 | gestureCanceled func(gev uintptr) int32 | |
| 75 | 76 | gestureDX func(gev uintptr) float64 | |
| 76 | 77 | gestureDY func(gev uintptr) float64 | |
| 78 | + | gestureUsec func(gev uintptr) uint64 | |
| 77 | 79 | deviceRef func(dev uintptr) uintptr | |
| 78 | 80 | deviceUnref func(dev uintptr) uintptr | |
| 79 | 81 | hasNatural func(dev uintptr) int32 | |
| 80 | 82 | setNatural func(dev uintptr, on int32) int32 | |
| 83 | + | tapFingers func(dev uintptr) int32 | |
| 84 | + | setTap func(dev uintptr, on int32) int32 | |
| 85 | + | setTapMap func(dev uintptr, m int32) int32 | |
| 86 | + | hasCapability func(dev uintptr, capability int32) int32 | |
| 87 | + | accelAvailable func(dev uintptr) int32 | |
| 88 | + | setAccelSpeed func(dev uintptr, speed float64) int32 | |
| 89 | + | accelProfiles func(dev uintptr) uint32 | |
| 90 | + | setAccelProfile func(dev uintptr, profile uint32) int32 | |
| 81 | 91 | iface [2]uintptr | |
| 82 | 92 | active ports.Seat | |
| 83 | 93 | ) | |
| @@ -117,6 +127,7 @@ func load() error { | |||
| 117 | 127 | reg(&keyboardEvent, "event_get_keyboard_event") | |
| 118 | 128 | reg(&keyboardKey, "event_keyboard_get_key") | |
| 119 | 129 | reg(&keyboardState, "event_keyboard_get_key_state") | |
| 130 | + | reg(&keyboardUsec, "event_keyboard_get_time_usec") | |
| 120 | 131 | reg(&pointerEvent, "event_get_pointer_event") | |
| 121 | 132 | reg(&pointerDX, "event_pointer_get_dx") | |
| 122 | 133 | reg(&pointerDY, "event_pointer_get_dy") | |
| @@ -135,10 +146,19 @@ func load() error { | |||
| 135 | 146 | reg(&gestureCanceled, "event_gesture_get_cancelled") | |
| 136 | 147 | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | |
| 137 | 148 | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | |
| 149 | + | reg(&gestureUsec, "event_gesture_get_time_usec") | |
| 138 | 150 | reg(&deviceRef, "device_ref") | |
| 139 | 151 | reg(&deviceUnref, "device_unref") | |
| 140 | 152 | reg(&hasNatural, "device_config_scroll_has_natural_scroll") | |
| 141 | 153 | reg(&setNatural, "device_config_scroll_set_natural_scroll_enabled") | |
| 154 | + | reg(&tapFingers, "device_config_tap_get_finger_count") | |
| 155 | + | reg(&setTap, "device_config_tap_set_enabled") | |
| 156 | + | reg(&setTapMap, "device_config_tap_set_button_map") | |
| 157 | + | reg(&hasCapability, "device_has_capability") | |
| 158 | + | reg(&accelAvailable, "device_config_accel_is_available") | |
| 159 | + | reg(&setAccelSpeed, "device_config_accel_set_speed") | |
| 160 | + | reg(&accelProfiles, "device_config_accel_get_profiles") | |
| 161 | + | reg(&setAccelProfile, "device_config_accel_set_profile") | |
| 142 | 162 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | |
| 143 | 163 | fd, err := active.OpenDevice(goString(path)) | |
| 144 | 164 | if err != nil { | |
| @@ -218,7 +238,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 218 | 238 | } | |
| 219 | 239 | p := newPointer(opts.Layout) | |
| 220 | 240 | p.moved(opts.MoveCursor) | |
| 221 | - | in := &inputState{touchpad: opts.Touchpad, scrollers: map[uintptr]bool{}, swipes: map[uintptr]swipe{}} | |
| 241 | + | in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}} | |
| 222 | 242 | defer in.release() | |
| 223 | 243 | fd := getFD(li) | |
| 224 | 244 | fwd := newForwarder(opts.Log) | |
| @@ -254,10 +274,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 254 | 274 | p.moved(opts.MoveCursor) | |
| 255 | 275 | case t := <-opts.Touchpads: | |
| 256 | 276 | in.touchpad = t | |
| 257 | - | for dev := range in.scrollers { | |
| 258 | - | in.applyNatural(dev, opts.Log) | |
| 277 | + | for dev := range in.devices { | |
| 278 | + | in.configure(dev, opts.Log) | |
| 259 | 279 | } | |
| 260 | - | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Msg("touchpad reloaded") | |
| 280 | + | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Bool("tap", t.Tap).Float64("accel_speed", t.AccelSpeed).Str("accel_profile", t.AccelProfile).Float64("scroll_factor", t.ScrollFactor).Msg("touchpad reloaded") | |
| 261 | 281 | default: | |
| 262 | 282 | } | |
| 263 | 283 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | |
| @@ -286,107 +306,150 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 286 | 306 | } | |
| 287 | 307 | ||
| 288 | 308 | // inputState is the device state Run owns: touchpad config, the devices | |
| 289 | - | // with a natural scroll setting (referenced until removed) and the swipe | |
| 290 | - | // in progress on each touchpad. | |
| 309 | + | // it configures (referenced until removed) and the swipe in progress on | |
| 310 | + | // each touchpad. | |
| 291 | 311 | type inputState struct { | |
| 292 | - | touchpad ports.TouchpadConfig | |
| 293 | - | scrollers map[uintptr]bool | |
| 294 | - | swipes map[uintptr]swipe | |
| 312 | + | touchpad ports.TouchpadConfig | |
| 313 | + | devices map[uintptr]bool | |
| 314 | + | swipes swipes | |
| 295 | 315 | } | |
| 296 | 316 | ||
| 297 | - | func (s *inputState) applyNatural(dev uintptr, log zerowrap.Logger) { | |
| 298 | - | on := int32(0) | |
| 299 | - | if s.touchpad.NaturalScroll { | |
| 300 | - | on = 1 | |
| 301 | - | } | |
| 302 | - | if setNatural(dev, on) != 0 { | |
| 317 | + | // configurable reports whether the touchpad config applies to dev: it has | |
| 318 | + | // natural scroll or tapping, or is a touchpad. | |
| 319 | + | func configurable(dev uintptr) bool { | |
| 320 | + | return hasNatural(dev) != 0 || tapFingers(dev) > 0 || touchpad(dev) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | // capGesture is LIBINPUT_DEVICE_CAP_GESTURE: libinput gives it to | |
| 324 | + | // touchpads, with or without tapping. | |
| 325 | + | const capGesture = 5 | |
| 326 | + | ||
| 327 | + | func touchpad(dev uintptr) bool { return hasCapability(dev, capGesture) != 0 } | |
| 328 | + | ||
| 329 | + | // tapButtonMap is LIBINPUT_CONFIG_TAP_MAP_LRM: one finger taps left, two | |
| 330 | + | // right, three middle. | |
| 331 | + | const tapButtonMap = 0 | |
| 332 | + | ||
| 333 | + | func (s *inputState) configure(dev uintptr, log zerowrap.Logger) { | |
| 334 | + | if hasNatural(dev) != 0 && setNatural(dev, flag(s.touchpad.NaturalScroll)) != 0 { | |
| 303 | 335 | log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected") | |
| 304 | 336 | } | |
| 337 | + | if tapFingers(dev) > 0 { | |
| 338 | + | if setTap(dev, flag(s.touchpad.Tap)) != 0 { | |
| 339 | + | log.Warn().Str("device", deviceName(dev)).Msg("tap to click rejected") | |
| 340 | + | } | |
| 341 | + | if setTapMap(dev, tapButtonMap) != 0 { | |
| 342 | + | log.Warn().Str("device", deviceName(dev)).Msg("tap button map rejected") | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | // Pointer speed is a touchpad setting: mice keep libinput's own. | |
| 346 | + | if touchpad(dev) && accelAvailable(dev) != 0 { | |
| 347 | + | if setAccelSpeed(dev, s.touchpad.AccelSpeed) != 0 { | |
| 348 | + | log.Warn().Str("device", deviceName(dev)).Msg("accel speed rejected") | |
| 349 | + | } | |
| 350 | + | if p := accelProfile(s.touchpad.AccelProfile); accelProfiles(dev)&p != 0 && setAccelProfile(dev, p) != 0 { | |
| 351 | + | log.Warn().Str("device", deviceName(dev)).Msg("accel profile rejected") | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // accelProfile is the libinput accel profile bit: LIBINPUT_CONFIG_ACCEL_ | |
| 357 | + | // PROFILE_FLAT (1) or _ADAPTIVE (2). | |
| 358 | + | func accelProfile(name string) uint32 { | |
| 359 | + | if name == ports.AccelFlat { | |
| 360 | + | return 1 | |
| 361 | + | } | |
| 362 | + | return 2 | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func flag(on bool) int32 { | |
| 366 | + | if on { | |
| 367 | + | return 1 | |
| 368 | + | } | |
| 369 | + | return 0 | |
| 305 | 370 | } | |
| 306 | 371 | ||
| 307 | 372 | func (s *inputState) release() { | |
| 308 | - | for dev := range s.scrollers { | |
| 373 | + | for dev := range s.devices { | |
| 309 | 374 | deviceUnref(dev) | |
| 310 | 375 | } | |
| 311 | - | clear(s.scrollers) | |
| 376 | + | clear(s.devices) | |
| 312 | 377 | } | |
| 313 | 378 | ||
| 314 | - | // swipeFingers is the finger count of the swipes neferwl handles. | |
| 379 | + | // swipeFingers is the finger count of the swipes neferwl handles; other | |
| 380 | + | // counts are left alone. | |
| 315 | 381 | const swipeFingers = 3 | |
| 316 | 382 | ||
| 317 | - | // swipeMin is the unaccelerated distance a swipe must travel to count. | |
| 318 | - | // libinput normalizes touchpad deltas to 1000 dpi: 300 is about 7.5 mm, so | |
| 319 | - | // a short or hesitant swipe switches nothing. | |
| 320 | - | const swipeMin = 300 | |
| 383 | + | // swipes streams one three-finger swipe at a time to core, which follows | |
| 384 | + | // the fingers. owner is the touchpad of the swipe in progress (0: none); | |
| 385 | + | // another touchpad's swipe is ignored until it ends, so core never sees | |
| 386 | + | // two streams interleaved. | |
| 387 | + | type swipes struct{ owner uintptr } | |
| 321 | 388 | ||
| 322 | - | // swipe adds up one touchpad swipe until the fingers lift. | |
| 323 | - | type swipe struct { | |
| 324 | - | fingers int | |
| 325 | - | dx, dy float64 | |
| 389 | + | func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent { | |
| 390 | + | if fingers != swipeFingers || s.owner != 0 { | |
| 391 | + | return nil | |
| 392 | + | } | |
| 393 | + | s.owner = dev | |
| 394 | + | return ports.SwipeBegin{Time: at} | |
| 326 | 395 | } | |
| 327 | 396 | ||
| 328 | - | // dir is the direction of a finished swipe: the longer axis wins. Short | |
| 329 | - | // swipes and other finger counts return false. | |
| 330 | - | func (s swipe) dir() (ports.SwipeDir, bool) { | |
| 331 | - | if s.fingers != swipeFingers || max(abs(s.dx), abs(s.dy)) < swipeMin { | |
| 332 | - | return 0, false | |
| 397 | + | func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent { | |
| 398 | + | if dev == 0 || s.owner != dev { | |
| 399 | + | return nil | |
| 333 | 400 | } | |
| 334 | - | switch { | |
| 335 | - | case abs(s.dy) >= abs(s.dx) && s.dy < 0: | |
| 336 | - | return ports.SwipeUp, true | |
| 337 | - | case abs(s.dy) >= abs(s.dx): | |
| 338 | - | return ports.SwipeDown, true | |
| 339 | - | case s.dx < 0: | |
| 340 | - | return ports.SwipeLeft, true | |
| 401 | + | return ports.SwipeUpdate{DX: dx, DY: dy, Time: at} | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func (s *swipes) end(dev uintptr, cancelled bool, at time.Duration) ports.InputEvent { | |
| 405 | + | if dev == 0 || s.owner != dev { | |
| 406 | + | return nil | |
| 341 | 407 | } | |
| 342 | - | return ports.SwipeRight, true | |
| 408 | + | s.owner = 0 | |
| 409 | + | return ports.SwipeEnd{Cancelled: cancelled, Time: at} | |
| 343 | 410 | } | |
| 344 | 411 | ||
| 345 | - | func abs(v float64) float64 { return max(v, -v) } | |
| 412 | + | // usec is a libinput timestamp as a duration. | |
| 413 | + | func usec(v uint64) time.Duration { return time.Duration(v) * time.Microsecond } | |
| 346 | 414 | ||
| 415 | + | // msec is a libinput timestamp in wayland's millisecond clock. | |
| 416 | + | func msec(v uint64) uint32 { return uint32(v / 1000) } | |
| 417 | + | ||
| 418 | + | // translate converts one libinput event. Timestamps are the device's | |
| 419 | + | // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on | |
| 420 | + | // when events happened, not when they were read. | |
| 347 | 421 | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | |
| 348 | - | now := uint32(time.Now().UnixMilli()) | |
| 349 | 422 | log := opts.Log | |
| 350 | 423 | switch eventType(ev) { | |
| 351 | 424 | case evDeviceAdded: | |
| 352 | 425 | dev := eventDevice(ev) | |
| 353 | 426 | log.Info().Str("device", deviceName(dev)).Msg("input device added") | |
| 354 | - | if hasNatural(dev) != 0 && !in.scrollers[dev] { | |
| 355 | - | in.scrollers[deviceRef(dev)] = true | |
| 356 | - | in.applyNatural(dev, log) | |
| 427 | + | if configurable(dev) && !in.devices[dev] { | |
| 428 | + | in.devices[deviceRef(dev)] = true | |
| 429 | + | in.configure(dev, log) | |
| 357 | 430 | } | |
| 358 | 431 | case evDeviceRemoved: | |
| 359 | 432 | dev := eventDevice(ev) | |
| 360 | 433 | log.Info().Str("device", deviceName(dev)).Msg("input device removed") | |
| 361 | - | delete(in.swipes, dev) | |
| 362 | - | if in.scrollers[dev] { | |
| 363 | - | delete(in.scrollers, dev) | |
| 434 | + | if in.devices[dev] { | |
| 435 | + | delete(in.devices, dev) | |
| 364 | 436 | deviceUnref(dev) | |
| 365 | 437 | } | |
| 438 | + | // Core waits for the end of a swipe the device began. | |
| 439 | + | return in.swipes.end(dev, true, 0), nil | |
| 366 | 440 | case evSwipeBegin: | |
| 367 | - | in.swipes[eventDevice(ev)] = swipe{fingers: int(gestureFingers(gestureEvent(ev)))} | |
| 441 | + | ge := gestureEvent(ev) | |
| 442 | + | return in.swipes.begin(eventDevice(ev), int(gestureFingers(ge)), usec(gestureUsec(ge))), nil | |
| 368 | 443 | case evSwipeUpdate: | |
| 369 | - | dev, ge := eventDevice(ev), gestureEvent(ev) | |
| 370 | - | if s, ok := in.swipes[dev]; ok { | |
| 371 | - | s.dx += gestureDX(ge) | |
| 372 | - | s.dy += gestureDY(ge) | |
| 373 | - | in.swipes[dev] = s | |
| 374 | - | } | |
| 444 | + | ge := gestureEvent(ev) | |
| 445 | + | return in.swipes.update(eventDevice(ev), gestureDX(ge), gestureDY(ge), usec(gestureUsec(ge))), nil | |
| 375 | 446 | case evSwipeEnd: | |
| 376 | - | dev := eventDevice(ev) | |
| 377 | - | s, ok := in.swipes[dev] | |
| 378 | - | delete(in.swipes, dev) | |
| 379 | - | if !ok || gestureCanceled(gestureEvent(ev)) != 0 { | |
| 380 | - | return nil, nil | |
| 381 | - | } | |
| 382 | - | if d, ok := s.dir(); ok { | |
| 383 | - | log.Debug().Uint8("dir", uint8(d)).Float64("dx", s.dx).Float64("dy", s.dy).Msg("swipe") | |
| 384 | - | return ports.Swipe{Dir: d}, nil | |
| 385 | - | } | |
| 447 | + | ge := gestureEvent(ev) | |
| 448 | + | return in.swipes.end(eventDevice(ev), gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil | |
| 386 | 449 | case evKeyboardKey: | |
| 387 | 450 | k := keyboardEvent(ev) | |
| 388 | 451 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | |
| 389 | - | ke := opts.Keymap.Key(code, pressed, now) | |
| 452 | + | ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k))) | |
| 390 | 453 | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | |
| 391 | 454 | switch action, vt := hotkey(ke); action { | |
| 392 | 455 | case hotkeyQuit: | |
| @@ -401,7 +464,8 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||
| 401 | 464 | pe := pointerEvent(ev) | |
| 402 | 465 | m := p.move(pointerDX(pe), pointerDY(pe)) | |
| 403 | 466 | m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe) | |
| 404 | - | m.TimeMsec, m.TimeUsec = now, pointerUsec(pe) | |
| 467 | + | m.TimeUsec = pointerUsec(pe) | |
| 468 | + | m.TimeMsec = msec(m.TimeUsec) | |
| 405 | 469 | p.moved(opts.MoveCursor) | |
| 406 | 470 | if opts.LogMotion { | |
| 407 | 471 | log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer") | |
| @@ -410,6 +474,7 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||
| 410 | 474 | case evPointerAbs: | |
| 411 | 475 | // Absolute devices (tablets, VMs) map to the whole layout. | |
| 412 | 476 | pe := pointerEvent(ev) | |
| 477 | + | now := msec(pointerUsec(pe)) | |
| 413 | 478 | b := p.bounds() | |
| 414 | 479 | if p.constraint.Mode == ports.ConstraintLock { | |
| 415 | 480 | return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil | |
| @@ -421,18 +486,22 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||
| 421 | 486 | pe := pointerEvent(ev) | |
| 422 | 487 | b, pressed := pointerButton(pe), pointerBtnState(pe) == 1 | |
| 423 | 488 | log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button") | |
| 424 | - | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil | |
| 489 | + | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: msec(pointerUsec(pe))}, nil | |
| 425 | 490 | case evScrollWheel, evScrollFinger, evScrollCont: | |
| 426 | 491 | pe := pointerEvent(ev) | |
| 427 | 492 | // The three scroll events follow each other as the sources do. | |
| 428 | 493 | source := ports.AxisSource(eventType(ev) - evScrollWheel) | |
| 429 | - | a := ports.PointerAxis{Source: source, TimeMsec: now} | |
| 494 | + | a := ports.PointerAxis{Source: source, TimeMsec: msec(pointerUsec(pe))} | |
| 430 | 495 | // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer. | |
| 431 | 496 | for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} { | |
| 432 | 497 | if pointerHasAxis(pe, uint32(i)) == 0 { | |
| 433 | 498 | continue | |
| 434 | 499 | } | |
| 435 | 500 | s.Set, s.Value = true, scrollValue(pe, uint32(i)) | |
| 501 | + | if source == ports.AxisFinger { | |
| 502 | + | // Two-finger scroll is the touchpad's only scroll source. | |
| 503 | + | s.Value *= in.touchpad.ScrollFactor | |
| 504 | + | } | |
| 436 | 505 | if source == ports.AxisWheel { | |
| 437 | 506 | s.V120 = int32(scrollV120(pe, uint32(i))) | |
| 438 | 507 | } else { | |
| @@ -56,6 +56,7 @@ var ( | |||||
| 56 | keyboardEvent func(ev uintptr) uintptr | 56 | keyboardEvent func(ev uintptr) uintptr | ||
| 57 | keyboardKey func(kev uintptr) uint32 | 57 | keyboardKey func(kev uintptr) uint32 | ||
| 58 | keyboardState func(kev uintptr) int32 | 58 | keyboardState func(kev uintptr) int32 | ||
| 59 | + | keyboardUsec func(kev uintptr) uint64 | |||
| 59 | pointerEvent func(ev uintptr) uintptr | 60 | pointerEvent func(ev uintptr) uintptr | ||
| 60 | pointerDX func(pev uintptr) float64 | 61 | pointerDX func(pev uintptr) float64 | ||
| 61 | pointerDY func(pev uintptr) float64 | 62 | pointerDY func(pev uintptr) float64 | ||
| @@ -74,10 +75,19 @@ var ( | |||||
| 74 | gestureCanceled func(gev uintptr) int32 | 75 | gestureCanceled func(gev uintptr) int32 | ||
| 75 | gestureDX func(gev uintptr) float64 | 76 | gestureDX func(gev uintptr) float64 | ||
| 76 | gestureDY func(gev uintptr) float64 | 77 | gestureDY func(gev uintptr) float64 | ||
| 78 | + | gestureUsec func(gev uintptr) uint64 | |||
| 77 | deviceRef func(dev uintptr) uintptr | 79 | deviceRef func(dev uintptr) uintptr | ||
| 78 | deviceUnref func(dev uintptr) uintptr | 80 | deviceUnref func(dev uintptr) uintptr | ||
| 79 | hasNatural func(dev uintptr) int32 | 81 | hasNatural func(dev uintptr) int32 | ||
| 80 | setNatural func(dev uintptr, on int32) int32 | 82 | setNatural func(dev uintptr, on int32) int32 | ||
| 83 | + | tapFingers func(dev uintptr) int32 | |||
| 84 | + | setTap func(dev uintptr, on int32) int32 | |||
| 85 | + | setTapMap func(dev uintptr, m int32) int32 | |||
| 86 | + | hasCapability func(dev uintptr, capability int32) int32 | |||
| 87 | + | accelAvailable func(dev uintptr) int32 | |||
| 88 | + | setAccelSpeed func(dev uintptr, speed float64) int32 | |||
| 89 | + | accelProfiles func(dev uintptr) uint32 | |||
| 90 | + | setAccelProfile func(dev uintptr, profile uint32) int32 | |||
| 81 | iface [2]uintptr | 91 | iface [2]uintptr | ||
| 82 | active ports.Seat | 92 | active ports.Seat | ||
| 83 | ) | 93 | ) | ||
| @@ -117,6 +127,7 @@ func load() error { | |||||
| 117 | reg(&keyboardEvent, "event_get_keyboard_event") | 127 | reg(&keyboardEvent, "event_get_keyboard_event") | ||
| 118 | reg(&keyboardKey, "event_keyboard_get_key") | 128 | reg(&keyboardKey, "event_keyboard_get_key") | ||
| 119 | reg(&keyboardState, "event_keyboard_get_key_state") | 129 | reg(&keyboardState, "event_keyboard_get_key_state") | ||
| 130 | + | reg(&keyboardUsec, "event_keyboard_get_time_usec") | |||
| 120 | reg(&pointerEvent, "event_get_pointer_event") | 131 | reg(&pointerEvent, "event_get_pointer_event") | ||
| 121 | reg(&pointerDX, "event_pointer_get_dx") | 132 | reg(&pointerDX, "event_pointer_get_dx") | ||
| 122 | reg(&pointerDY, "event_pointer_get_dy") | 133 | reg(&pointerDY, "event_pointer_get_dy") | ||
| @@ -135,10 +146,19 @@ func load() error { | |||||
| 135 | reg(&gestureCanceled, "event_gesture_get_cancelled") | 146 | reg(&gestureCanceled, "event_gesture_get_cancelled") | ||
| 136 | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | 147 | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | ||
| 137 | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | 148 | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | ||
| 149 | + | reg(&gestureUsec, "event_gesture_get_time_usec") | |||
| 138 | reg(&deviceRef, "device_ref") | 150 | reg(&deviceRef, "device_ref") | ||
| 139 | reg(&deviceUnref, "device_unref") | 151 | reg(&deviceUnref, "device_unref") | ||
| 140 | reg(&hasNatural, "device_config_scroll_has_natural_scroll") | 152 | reg(&hasNatural, "device_config_scroll_has_natural_scroll") | ||
| 141 | reg(&setNatural, "device_config_scroll_set_natural_scroll_enabled") | 153 | reg(&setNatural, "device_config_scroll_set_natural_scroll_enabled") | ||
| 154 | + | reg(&tapFingers, "device_config_tap_get_finger_count") | |||
| 155 | + | reg(&setTap, "device_config_tap_set_enabled") | |||
| 156 | + | reg(&setTapMap, "device_config_tap_set_button_map") | |||
| 157 | + | reg(&hasCapability, "device_has_capability") | |||
| 158 | + | reg(&accelAvailable, "device_config_accel_is_available") | |||
| 159 | + | reg(&setAccelSpeed, "device_config_accel_set_speed") | |||
| 160 | + | reg(&accelProfiles, "device_config_accel_get_profiles") | |||
| 161 | + | reg(&setAccelProfile, "device_config_accel_set_profile") | |||
| 142 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | 162 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | ||
| 143 | fd, err := active.OpenDevice(goString(path)) | 163 | fd, err := active.OpenDevice(goString(path)) | ||
| 144 | if err != nil { | 164 | if err != nil { | ||
| @@ -218,7 +238,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 218 | } | 238 | } | ||
| 219 | p := newPointer(opts.Layout) | 239 | p := newPointer(opts.Layout) | ||
| 220 | p.moved(opts.MoveCursor) | 240 | p.moved(opts.MoveCursor) | ||
| 221 | - | in := &inputState{touchpad: opts.Touchpad, scrollers: map[uintptr]bool{}, swipes: map[uintptr]swipe{}} | 241 | + | in := &inputState{touchpad: opts.Touchpad, devices: map[uintptr]bool{}} |
| 222 | defer in.release() | 242 | defer in.release() | ||
| 223 | fd := getFD(li) | 243 | fd := getFD(li) | ||
| 224 | fwd := newForwarder(opts.Log) | 244 | fwd := newForwarder(opts.Log) | ||
| @@ -254,10 +274,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 254 | p.moved(opts.MoveCursor) | 274 | p.moved(opts.MoveCursor) | ||
| 255 | case t := <-opts.Touchpads: | 275 | case t := <-opts.Touchpads: | ||
| 256 | in.touchpad = t | 276 | in.touchpad = t | ||
| 257 | - | for dev := range in.scrollers { | 277 | + | for dev := range in.devices { |
| 258 | - | in.applyNatural(dev, opts.Log) | 278 | + | in.configure(dev, opts.Log) |
| 259 | } | 279 | } | ||
| 260 | - | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Msg("touchpad reloaded") | 280 | + | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Bool("tap", t.Tap).Float64("accel_speed", t.AccelSpeed).Str("accel_profile", t.AccelProfile).Float64("scroll_factor", t.ScrollFactor).Msg("touchpad reloaded") |
| 261 | default: | 281 | default: | ||
| 262 | } | 282 | } | ||
| 263 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | 283 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | ||
| @@ -286,107 +306,150 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 286 | } | 306 | } | ||
| 287 | 307 | ||||
| 288 | // inputState is the device state Run owns: touchpad config, the devices | 308 | // inputState is the device state Run owns: touchpad config, the devices | ||
| 289 | - | // with a natural scroll setting (referenced until removed) and the swipe | 309 | + | // it configures (referenced until removed) and the swipe in progress on |
| 290 | - | // in progress on each touchpad. | 310 | + | // each touchpad. |
| 291 | type inputState struct { | 311 | type inputState struct { | ||
| 292 | - | touchpad ports.TouchpadConfig | 312 | + | touchpad ports.TouchpadConfig |
| 293 | - | scrollers map[uintptr]bool | 313 | + | devices map[uintptr]bool |
| 294 | - | swipes map[uintptr]swipe | 314 | + | swipes swipes |
| 295 | } | 315 | } | ||
| 296 | 316 | ||||
| 297 | - | func (s *inputState) applyNatural(dev uintptr, log zerowrap.Logger) { | 317 | + | // configurable reports whether the touchpad config applies to dev: it has |
| 298 | - | on := int32(0) | 318 | + | // natural scroll or tapping, or is a touchpad. |
| 299 | - | if s.touchpad.NaturalScroll { | 319 | + | func configurable(dev uintptr) bool { |
| 300 | - | on = 1 | 320 | + | return hasNatural(dev) != 0 || tapFingers(dev) > 0 || touchpad(dev) |
| 301 | - | } | 321 | + | } |
| 302 | - | if setNatural(dev, on) != 0 { | 322 | + | |
| 323 | + | // capGesture is LIBINPUT_DEVICE_CAP_GESTURE: libinput gives it to | |||
| 324 | + | // touchpads, with or without tapping. | |||
| 325 | + | const capGesture = 5 | |||
| 326 | + | ||||
| 327 | + | func touchpad(dev uintptr) bool { return hasCapability(dev, capGesture) != 0 } | |||
| 328 | + | ||||
| 329 | + | // tapButtonMap is LIBINPUT_CONFIG_TAP_MAP_LRM: one finger taps left, two | |||
| 330 | + | // right, three middle. | |||
| 331 | + | const tapButtonMap = 0 | |||
| 332 | + | ||||
| 333 | + | func (s *inputState) configure(dev uintptr, log zerowrap.Logger) { | |||
| 334 | + | if hasNatural(dev) != 0 && setNatural(dev, flag(s.touchpad.NaturalScroll)) != 0 { | |||
| 303 | log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected") | 335 | log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected") | ||
| 304 | } | 336 | } | ||
| 337 | + | if tapFingers(dev) > 0 { | |||
| 338 | + | if setTap(dev, flag(s.touchpad.Tap)) != 0 { | |||
| 339 | + | log.Warn().Str("device", deviceName(dev)).Msg("tap to click rejected") | |||
| 340 | + | } | |||
| 341 | + | if setTapMap(dev, tapButtonMap) != 0 { | |||
| 342 | + | log.Warn().Str("device", deviceName(dev)).Msg("tap button map rejected") | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | // Pointer speed is a touchpad setting: mice keep libinput's own. | |||
| 346 | + | if touchpad(dev) && accelAvailable(dev) != 0 { | |||
| 347 | + | if setAccelSpeed(dev, s.touchpad.AccelSpeed) != 0 { | |||
| 348 | + | log.Warn().Str("device", deviceName(dev)).Msg("accel speed rejected") | |||
| 349 | + | } | |||
| 350 | + | if p := accelProfile(s.touchpad.AccelProfile); accelProfiles(dev)&p != 0 && setAccelProfile(dev, p) != 0 { | |||
| 351 | + | log.Warn().Str("device", deviceName(dev)).Msg("accel profile rejected") | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // accelProfile is the libinput accel profile bit: LIBINPUT_CONFIG_ACCEL_ | |||
| 357 | + | // PROFILE_FLAT (1) or _ADAPTIVE (2). | |||
| 358 | + | func accelProfile(name string) uint32 { | |||
| 359 | + | if name == ports.AccelFlat { | |||
| 360 | + | return 1 | |||
| 361 | + | } | |||
| 362 | + | return 2 | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func flag(on bool) int32 { | |||
| 366 | + | if on { | |||
| 367 | + | return 1 | |||
| 368 | + | } | |||
| 369 | + | return 0 | |||
| 305 | } | 370 | } | ||
| 306 | 371 | ||||
| 307 | func (s *inputState) release() { | 372 | func (s *inputState) release() { | ||
| 308 | - | for dev := range s.scrollers { | 373 | + | for dev := range s.devices { |
| 309 | deviceUnref(dev) | 374 | deviceUnref(dev) | ||
| 310 | } | 375 | } | ||
| 311 | - | clear(s.scrollers) | 376 | + | clear(s.devices) |
| 312 | } | 377 | } | ||
| 313 | 378 | ||||
| 314 | - | // swipeFingers is the finger count of the swipes neferwl handles. | 379 | + | // swipeFingers is the finger count of the swipes neferwl handles; other |
| 380 | + | // counts are left alone. | |||
| 315 | const swipeFingers = 3 | 381 | const swipeFingers = 3 | ||
| 316 | 382 | ||||
| 317 | - | // swipeMin is the unaccelerated distance a swipe must travel to count. | 383 | + | // swipes streams one three-finger swipe at a time to core, which follows |
| 318 | - | // libinput normalizes touchpad deltas to 1000 dpi: 300 is about 7.5 mm, so | 384 | + | // the fingers. owner is the touchpad of the swipe in progress (0: none); |
| 319 | - | // a short or hesitant swipe switches nothing. | 385 | + | // another touchpad's swipe is ignored until it ends, so core never sees |
| 320 | - | const swipeMin = 300 | 386 | + | // two streams interleaved. |
| 387 | + | type swipes struct{ owner uintptr } | |||
| 321 | 388 | ||||
| 322 | - | // swipe adds up one touchpad swipe until the fingers lift. | 389 | + | func (s *swipes) begin(dev uintptr, fingers int, at time.Duration) ports.InputEvent { |
| 323 | - | type swipe struct { | 390 | + | if fingers != swipeFingers || s.owner != 0 { |
| 324 | - | fingers int | 391 | + | return nil |
| 325 | - | dx, dy float64 | 392 | + | } |
| 393 | + | s.owner = dev | |||
| 394 | + | return ports.SwipeBegin{Time: at} | |||
| 326 | } | 395 | } | ||
| 327 | 396 | ||||
| 328 | - | // dir is the direction of a finished swipe: the longer axis wins. Short | 397 | + | func (s *swipes) update(dev uintptr, dx, dy float64, at time.Duration) ports.InputEvent { |
| 329 | - | // swipes and other finger counts return false. | 398 | + | if dev == 0 || s.owner != dev { |
| 330 | - | func (s swipe) dir() (ports.SwipeDir, bool) { | 399 | + | return nil |
| 331 | - | if s.fingers != swipeFingers || max(abs(s.dx), abs(s.dy)) < swipeMin { | |||
| 332 | - | return 0, false | |||
| 333 | } | 400 | } | ||
| 334 | - | switch { | 401 | + | return ports.SwipeUpdate{DX: dx, DY: dy, Time: at} |
| 335 | - | case abs(s.dy) >= abs(s.dx) && s.dy < 0: | 402 | + | } |
| 336 | - | return ports.SwipeUp, true | 403 | + | |
| 337 | - | case abs(s.dy) >= abs(s.dx): | 404 | + | func (s *swipes) end(dev uintptr, cancelled bool, at time.Duration) ports.InputEvent { |
| 338 | - | return ports.SwipeDown, true | 405 | + | if dev == 0 || s.owner != dev { |
| 339 | - | case s.dx < 0: | 406 | + | return nil |
| 340 | - | return ports.SwipeLeft, true | |||
| 341 | } | 407 | } | ||
| 342 | - | return ports.SwipeRight, true | 408 | + | s.owner = 0 |
| 409 | + | return ports.SwipeEnd{Cancelled: cancelled, Time: at} | |||
| 343 | } | 410 | } | ||
| 344 | 411 | ||||
| 345 | - | func abs(v float64) float64 { return max(v, -v) } | 412 | + | // usec is a libinput timestamp as a duration. |
| 413 | + | func usec(v uint64) time.Duration { return time.Duration(v) * time.Microsecond } | |||
| 346 | 414 | ||||
| 415 | + | // msec is a libinput timestamp in wayland's millisecond clock. | |||
| 416 | + | func msec(v uint64) uint32 { return uint32(v / 1000) } | |||
| 417 | + | ||||
| 418 | + | // translate converts one libinput event. Timestamps are the device's | |||
| 419 | + | // (CLOCK_MONOTONIC), so clients measure speeds (kinetic scrolling) on | |||
| 420 | + | // when events happened, not when they were read. | |||
| 347 | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | 421 | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | ||
| 348 | - | now := uint32(time.Now().UnixMilli()) | |||
| 349 | log := opts.Log | 422 | log := opts.Log | ||
| 350 | switch eventType(ev) { | 423 | switch eventType(ev) { | ||
| 351 | case evDeviceAdded: | 424 | case evDeviceAdded: | ||
| 352 | dev := eventDevice(ev) | 425 | dev := eventDevice(ev) | ||
| 353 | log.Info().Str("device", deviceName(dev)).Msg("input device added") | 426 | log.Info().Str("device", deviceName(dev)).Msg("input device added") | ||
| 354 | - | if hasNatural(dev) != 0 && !in.scrollers[dev] { | 427 | + | if configurable(dev) && !in.devices[dev] { |
| 355 | - | in.scrollers[deviceRef(dev)] = true | 428 | + | in.devices[deviceRef(dev)] = true |
| 356 | - | in.applyNatural(dev, log) | 429 | + | in.configure(dev, log) |
| 357 | } | 430 | } | ||
| 358 | case evDeviceRemoved: | 431 | case evDeviceRemoved: | ||
| 359 | dev := eventDevice(ev) | 432 | dev := eventDevice(ev) | ||
| 360 | log.Info().Str("device", deviceName(dev)).Msg("input device removed") | 433 | log.Info().Str("device", deviceName(dev)).Msg("input device removed") | ||
| 361 | - | delete(in.swipes, dev) | 434 | + | if in.devices[dev] { |
| 362 | - | if in.scrollers[dev] { | 435 | + | delete(in.devices, dev) |
| 363 | - | delete(in.scrollers, dev) | |||
| 364 | deviceUnref(dev) | 436 | deviceUnref(dev) | ||
| 365 | } | 437 | } | ||
| 438 | + | // Core waits for the end of a swipe the device began. | |||
| 439 | + | return in.swipes.end(dev, true, 0), nil | |||
| 366 | case evSwipeBegin: | 440 | case evSwipeBegin: | ||
| 367 | - | in.swipes[eventDevice(ev)] = swipe{fingers: int(gestureFingers(gestureEvent(ev)))} | 441 | + | ge := gestureEvent(ev) |
| 442 | + | return in.swipes.begin(eventDevice(ev), int(gestureFingers(ge)), usec(gestureUsec(ge))), nil | |||
| 368 | case evSwipeUpdate: | 443 | case evSwipeUpdate: | ||
| 369 | - | dev, ge := eventDevice(ev), gestureEvent(ev) | 444 | + | ge := gestureEvent(ev) |
| 370 | - | if s, ok := in.swipes[dev]; ok { | 445 | + | return in.swipes.update(eventDevice(ev), gestureDX(ge), gestureDY(ge), usec(gestureUsec(ge))), nil |
| 371 | - | s.dx += gestureDX(ge) | |||
| 372 | - | s.dy += gestureDY(ge) | |||
| 373 | - | in.swipes[dev] = s | |||
| 374 | - | } | |||
| 375 | case evSwipeEnd: | 446 | case evSwipeEnd: | ||
| 376 | - | dev := eventDevice(ev) | 447 | + | ge := gestureEvent(ev) |
| 377 | - | s, ok := in.swipes[dev] | 448 | + | return in.swipes.end(eventDevice(ev), gestureCanceled(ge) != 0, usec(gestureUsec(ge))), nil |
| 378 | - | delete(in.swipes, dev) | |||
| 379 | - | if !ok || gestureCanceled(gestureEvent(ev)) != 0 { | |||
| 380 | - | return nil, nil | |||
| 381 | - | } | |||
| 382 | - | if d, ok := s.dir(); ok { | |||
| 383 | - | log.Debug().Uint8("dir", uint8(d)).Float64("dx", s.dx).Float64("dy", s.dy).Msg("swipe") | |||
| 384 | - | return ports.Swipe{Dir: d}, nil | |||
| 385 | - | } | |||
| 386 | case evKeyboardKey: | 449 | case evKeyboardKey: | ||
| 387 | k := keyboardEvent(ev) | 450 | k := keyboardEvent(ev) | ||
| 388 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | 451 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | ||
| 389 | - | ke := opts.Keymap.Key(code, pressed, now) | 452 | + | ke := opts.Keymap.Key(code, pressed, msec(keyboardUsec(k))) |
| 390 | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | 453 | log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key") | ||
| 391 | switch action, vt := hotkey(ke); action { | 454 | switch action, vt := hotkey(ke); action { | ||
| 392 | case hotkeyQuit: | 455 | case hotkeyQuit: | ||
| @@ -401,7 +464,8 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||||
| 401 | pe := pointerEvent(ev) | 464 | pe := pointerEvent(ev) | ||
| 402 | m := p.move(pointerDX(pe), pointerDY(pe)) | 465 | m := p.move(pointerDX(pe), pointerDY(pe)) | ||
| 403 | m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe) | 466 | m.UnaccelDX, m.UnaccelDY = pointerRawDX(pe), pointerRawDY(pe) | ||
| 404 | - | m.TimeMsec, m.TimeUsec = now, pointerUsec(pe) | 467 | + | m.TimeUsec = pointerUsec(pe) |
| 468 | + | m.TimeMsec = msec(m.TimeUsec) | |||
| 405 | p.moved(opts.MoveCursor) | 469 | p.moved(opts.MoveCursor) | ||
| 406 | if opts.LogMotion { | 470 | if opts.LogMotion { | ||
| 407 | log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer") | 471 | log.Debug().Float64("x", m.X).Float64("y", m.Y).Msg("pointer") | ||
| @@ -410,6 +474,7 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||||
| 410 | case evPointerAbs: | 474 | case evPointerAbs: | ||
| 411 | // Absolute devices (tablets, VMs) map to the whole layout. | 475 | // Absolute devices (tablets, VMs) map to the whole layout. | ||
| 412 | pe := pointerEvent(ev) | 476 | pe := pointerEvent(ev) | ||
| 477 | + | now := msec(pointerUsec(pe)) | |||
| 413 | b := p.bounds() | 478 | b := p.bounds() | ||
| 414 | if p.constraint.Mode == ports.ConstraintLock { | 479 | if p.constraint.Mode == ports.ConstraintLock { | ||
| 415 | return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil | 480 | return ports.PointerMotion{X: p.x, Y: p.y, TimeMsec: now}, nil | ||
| @@ -421,18 +486,22 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu | |||||
| 421 | pe := pointerEvent(ev) | 486 | pe := pointerEvent(ev) | ||
| 422 | b, pressed := pointerButton(pe), pointerBtnState(pe) == 1 | 487 | b, pressed := pointerButton(pe), pointerBtnState(pe) == 1 | ||
| 423 | log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button") | 488 | log.Debug().Uint32("button", b).Bool("pressed", pressed).Msg("button") | ||
| 424 | - | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: now}, nil | 489 | + | return ports.PointerButton{Button: b, Pressed: pressed, TimeMsec: msec(pointerUsec(pe))}, nil |
| 425 | case evScrollWheel, evScrollFinger, evScrollCont: | 490 | case evScrollWheel, evScrollFinger, evScrollCont: | ||
| 426 | pe := pointerEvent(ev) | 491 | pe := pointerEvent(ev) | ||
| 427 | // The three scroll events follow each other as the sources do. | 492 | // The three scroll events follow each other as the sources do. | ||
| 428 | source := ports.AxisSource(eventType(ev) - evScrollWheel) | 493 | source := ports.AxisSource(eventType(ev) - evScrollWheel) | ||
| 429 | - | a := ports.PointerAxis{Source: source, TimeMsec: now} | 494 | + | a := ports.PointerAxis{Source: source, TimeMsec: msec(pointerUsec(pe))} |
| 430 | // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer. | 495 | // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer. | ||
| 431 | for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} { | 496 | for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} { | ||
| 432 | if pointerHasAxis(pe, uint32(i)) == 0 { | 497 | if pointerHasAxis(pe, uint32(i)) == 0 { | ||
| 433 | continue | 498 | continue | ||
| 434 | } | 499 | } | ||
| 435 | s.Set, s.Value = true, scrollValue(pe, uint32(i)) | 500 | s.Set, s.Value = true, scrollValue(pe, uint32(i)) | ||
| 501 | + | if source == ports.AxisFinger { | |||
| 502 | + | // Two-finger scroll is the touchpad's only scroll source. | |||
| 503 | + | s.Value *= in.touchpad.ScrollFactor | |||
| 504 | + | } | |||
| 436 | if source == ports.AxisWheel { | 505 | if source == ports.AxisWheel { | ||
| 437 | s.V120 = int32(scrollV120(pe, uint32(i))) | 506 | s.V120 = int32(scrollV120(pe, uint32(i))) | ||
| 438 | } else { | 507 | } else { | ||
M internal/adapters/libinput/libinput_test.go +58 -18
| @@ -2,6 +2,7 @@ package libinput | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 8 | ) | |
| @@ -29,24 +30,63 @@ func TestHotkey(t *testing.T) { | |||
| 29 | 30 | } | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | - | func TestSwipeDir(t *testing.T) { | |
| 33 | - | for _, tc := range []struct { | |
| 34 | - | name string | |
| 35 | - | s swipe | |
| 36 | - | dir ports.SwipeDir | |
| 37 | - | ok bool | |
| 38 | - | }{ | |
| 39 | - | {"up", swipe{fingers: 3, dx: 40, dy: -400}, ports.SwipeUp, true}, | |
| 40 | - | {"down", swipe{fingers: 3, dy: 400}, ports.SwipeDown, true}, | |
| 41 | - | {"left", swipe{fingers: 3, dx: -400, dy: 100}, ports.SwipeLeft, true}, | |
| 42 | - | {"right", swipe{fingers: 3, dx: 400}, ports.SwipeRight, true}, | |
| 43 | - | {"too short", swipe{fingers: 3, dx: 100, dy: 100}, 0, false}, | |
| 44 | - | {"four fingers", swipe{fingers: 4, dy: 400}, 0, false}, | |
| 45 | - | } { | |
| 46 | - | d, ok := tc.s.dir() | |
| 47 | - | if d != tc.dir || ok != tc.ok { | |
| 48 | - | t.Errorf("%s: got %v %t", tc.name, d, ok) | |
| 49 | - | } | |
| 33 | + | func TestSwipesIgnoreSecondTouchpad(t *testing.T) { | |
| 34 | + | var s swipes | |
| 35 | + | const pad, other = 1, 2 | |
| 36 | + | s.begin(pad, 3, time.Second) | |
| 37 | + | if ev := s.begin(other, 3, time.Second); ev != nil { | |
| 38 | + | t.Fatalf("second touchpad began: %v", ev) | |
| 39 | + | } | |
| 40 | + | if ev := s.update(other, 1, 1, time.Second); ev != nil { | |
| 41 | + | t.Fatalf("second touchpad moved: %v", ev) | |
| 42 | + | } | |
| 43 | + | if ev := s.end(other, false, time.Second); ev != nil { | |
| 44 | + | t.Fatalf("second touchpad ended the swipe: %v", ev) | |
| 45 | + | } | |
| 46 | + | if ev := s.update(pad, 1, 1, 2*time.Second); ev == nil { | |
| 47 | + | t.Fatal("first touchpad lost its swipe") | |
| 48 | + | } | |
| 49 | + | s.end(pad, false, 3*time.Second) | |
| 50 | + | if ev := s.begin(other, 3, 4*time.Second); ev == nil { | |
| 51 | + | t.Fatal("second touchpad cannot swipe after the first ended") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestSwipesStreamThreeFingers(t *testing.T) { | |
| 56 | + | var s swipes | |
| 57 | + | const pad, other = 1, 2 | |
| 58 | + | if ev := s.begin(pad, 4, time.Second); ev != nil { | |
| 59 | + | t.Fatalf("four fingers: %v", ev) | |
| 60 | + | } | |
| 61 | + | if ev := s.update(pad, 1, 1, time.Second); ev != nil { | |
| 62 | + | t.Fatalf("update without a swipe: %v", ev) | |
| 63 | + | } | |
| 64 | + | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Time: time.Second}) { | |
| 65 | + | t.Fatalf("begin: %v", ev) | |
| 66 | + | } | |
| 67 | + | if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) { | |
| 68 | + | t.Fatalf("update: %v", ev) | |
| 69 | + | } | |
| 70 | + | if ev := s.end(other, false, 0); ev != nil { | |
| 71 | + | t.Fatalf("end on another device: %v", ev) | |
| 72 | + | } | |
| 73 | + | if ev := s.end(pad, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) { | |
| 74 | + | t.Fatalf("end: %v", ev) | |
| 75 | + | } | |
| 76 | + | if ev := s.end(pad, false, 0); ev != nil { | |
| 77 | + | t.Fatalf("second end: %v", ev) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestAccelProfile(t *testing.T) { | |
| 82 | + | if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 { | |
| 83 | + | t.Fatal(accelProfile(ports.AccelFlat), accelProfile(ports.AccelAdaptive)) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestTimestamps(t *testing.T) { | |
| 88 | + | if usec(1500) != 1500*time.Microsecond || msec(1_234_567) != 1234 { | |
| 89 | + | t.Fatal(usec(1500), msec(1_234_567)) | |
| 50 | 90 | } | |
| 51 | 91 | } | |
| 52 | 92 | ||
| @@ -2,6 +2,7 @@ package libinput | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | + | "time" | |||
| 5 | 6 | ||||
| 6 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| 7 | ) | 8 | ) | ||
| @@ -29,24 +30,63 @@ func TestHotkey(t *testing.T) { | |||||
| 29 | } | 30 | } | ||
| 30 | } | 31 | } | ||
| 31 | 32 | ||||
| 32 | - | func TestSwipeDir(t *testing.T) { | 33 | + | func TestSwipesIgnoreSecondTouchpad(t *testing.T) { |
| 33 | - | for _, tc := range []struct { | 34 | + | var s swipes |
| 34 | - | name string | 35 | + | const pad, other = 1, 2 |
| 35 | - | s swipe | 36 | + | s.begin(pad, 3, time.Second) |
| 36 | - | dir ports.SwipeDir | 37 | + | if ev := s.begin(other, 3, time.Second); ev != nil { |
| 37 | - | ok bool | 38 | + | t.Fatalf("second touchpad began: %v", ev) |
| 38 | - | }{ | 39 | + | } |
| 39 | - | {"up", swipe{fingers: 3, dx: 40, dy: -400}, ports.SwipeUp, true}, | 40 | + | if ev := s.update(other, 1, 1, time.Second); ev != nil { |
| 40 | - | {"down", swipe{fingers: 3, dy: 400}, ports.SwipeDown, true}, | 41 | + | t.Fatalf("second touchpad moved: %v", ev) |
| 41 | - | {"left", swipe{fingers: 3, dx: -400, dy: 100}, ports.SwipeLeft, true}, | 42 | + | } |
| 42 | - | {"right", swipe{fingers: 3, dx: 400}, ports.SwipeRight, true}, | 43 | + | if ev := s.end(other, false, time.Second); ev != nil { |
| 43 | - | {"too short", swipe{fingers: 3, dx: 100, dy: 100}, 0, false}, | 44 | + | t.Fatalf("second touchpad ended the swipe: %v", ev) |
| 44 | - | {"four fingers", swipe{fingers: 4, dy: 400}, 0, false}, | 45 | + | } |
| 45 | - | } { | 46 | + | if ev := s.update(pad, 1, 1, 2*time.Second); ev == nil { |
| 46 | - | d, ok := tc.s.dir() | 47 | + | t.Fatal("first touchpad lost its swipe") |
| 47 | - | if d != tc.dir || ok != tc.ok { | 48 | + | } |
| 48 | - | t.Errorf("%s: got %v %t", tc.name, d, ok) | 49 | + | s.end(pad, false, 3*time.Second) |
| 49 | - | } | 50 | + | if ev := s.begin(other, 3, 4*time.Second); ev == nil { |
| 51 | + | t.Fatal("second touchpad cannot swipe after the first ended") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestSwipesStreamThreeFingers(t *testing.T) { | |||
| 56 | + | var s swipes | |||
| 57 | + | const pad, other = 1, 2 | |||
| 58 | + | if ev := s.begin(pad, 4, time.Second); ev != nil { | |||
| 59 | + | t.Fatalf("four fingers: %v", ev) | |||
| 60 | + | } | |||
| 61 | + | if ev := s.update(pad, 1, 1, time.Second); ev != nil { | |||
| 62 | + | t.Fatalf("update without a swipe: %v", ev) | |||
| 63 | + | } | |||
| 64 | + | if ev := s.begin(pad, 3, time.Second); ev != (ports.SwipeBegin{Time: time.Second}) { | |||
| 65 | + | t.Fatalf("begin: %v", ev) | |||
| 66 | + | } | |||
| 67 | + | if ev := s.update(pad, -3, 2, 2*time.Second); ev != (ports.SwipeUpdate{DX: -3, DY: 2, Time: 2 * time.Second}) { | |||
| 68 | + | t.Fatalf("update: %v", ev) | |||
| 69 | + | } | |||
| 70 | + | if ev := s.end(other, false, 0); ev != nil { | |||
| 71 | + | t.Fatalf("end on another device: %v", ev) | |||
| 72 | + | } | |||
| 73 | + | if ev := s.end(pad, true, 3*time.Second); ev != (ports.SwipeEnd{Cancelled: true, Time: 3 * time.Second}) { | |||
| 74 | + | t.Fatalf("end: %v", ev) | |||
| 75 | + | } | |||
| 76 | + | if ev := s.end(pad, false, 0); ev != nil { | |||
| 77 | + | t.Fatalf("second end: %v", ev) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestAccelProfile(t *testing.T) { | |||
| 82 | + | if accelProfile(ports.AccelFlat) != 1 || accelProfile(ports.AccelAdaptive) != 2 { | |||
| 83 | + | t.Fatal(accelProfile(ports.AccelFlat), accelProfile(ports.AccelAdaptive)) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestTimestamps(t *testing.T) { | |||
| 88 | + | if usec(1500) != 1500*time.Microsecond || msec(1_234_567) != 1234 { | |||
| 89 | + | t.Fatal(usec(1500), msec(1_234_567)) | |||
| 50 | } | 90 | } | ||
| 51 | } | 91 | } | ||
| 52 | 92 | ||||
M internal/adapters/wayland/virtualkeyboard.go +4 -1
| @@ -150,7 +150,10 @@ func (k *virtualKeyboard) release() { | |||
| 150 | 150 | s := k.server | |
| 151 | 151 | if len(k.pressed) > 0 && k.focus == s.focused { | |
| 152 | 152 | _, keyboards := s.focusTarget(s.focused) | |
| 153 | - | now := uint32(time.Now().UnixMilli()) | |
| 153 | + | var ts unix.Timespec | |
| 154 | + | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |
| 155 | + | // Input events carry CLOCK_MONOTONIC milliseconds (libinput's clock). | |
| 156 | + | now := uint32(ts.Nano() / int64(time.Millisecond)) | |
| 154 | 157 | for key := range k.pressed { | |
| 155 | 158 | s.serial++ | |
| 156 | 159 | for _, kb := range keyboards { | |
| @@ -150,7 +150,10 @@ func (k *virtualKeyboard) release() { | |||||
| 150 | s := k.server | 150 | s := k.server | ||
| 151 | if len(k.pressed) > 0 && k.focus == s.focused { | 151 | if len(k.pressed) > 0 && k.focus == s.focused { | ||
| 152 | _, keyboards := s.focusTarget(s.focused) | 152 | _, keyboards := s.focusTarget(s.focused) | ||
| 153 | - | now := uint32(time.Now().UnixMilli()) | 153 | + | var ts unix.Timespec |
| 154 | + | _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) | |||
| 155 | + | // Input events carry CLOCK_MONOTONIC milliseconds (libinput's clock). | |||
| 156 | + | now := uint32(ts.Nano() / int64(time.Millisecond)) | |||
| 154 | for key := range k.pressed { | 157 | for key := range k.pressed { | ||
| 155 | s.serial++ | 158 | s.serial++ | ||
| 156 | for _, kb := range keyboards { | 159 | for _, kb := range keyboards { | ||
M internal/app/app.go +33 -2
| @@ -92,6 +92,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 92 | 92 | contents := make(chan ports.SurfaceContent, 64) | |
| 93 | 93 | cursorChanges := make(chan ports.CursorChange, 1) | |
| 94 | 94 | presented := make(chan ports.OutputPresented, 64) | |
| 95 | + | // Outputs report on flips; relayPresented passes them to wayland and | |
| 96 | + | // tells core about each flip (frames) to move running slides. | |
| 97 | + | flips := make(chan ports.OutputPresented, 64) | |
| 98 | + | frames := make(chan ports.OutputFrame, 8) | |
| 95 | 99 | captures := make(chan ports.CaptureRequest, 32) | |
| 96 | 100 | captured := make(chan ports.CaptureDone, 64) | |
| 97 | 101 | outputFormats := make(chan ports.OutputFormats, 8) | |
| @@ -103,7 +107,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 103 | 107 | // Only real sessions save scales: headless runs never touch the config file. | |
| 104 | 108 | scales = make(chan ports.ScaleChanged, 8) | |
| 105 | 109 | } | |
| 106 | - | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}} | |
| 110 | + | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames} | |
| 107 | 111 | c, err := core.New(opts.Config, ch) | |
| 108 | 112 | if err != nil { | |
| 109 | 113 | return err | |
| @@ -229,10 +233,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 229 | 233 | }() | |
| 230 | 234 | } | |
| 231 | 235 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | |
| 236 | + | workers.Add(1) | |
| 237 | + | go func() { defer workers.Done(); relayPresented(ctx, flips, presented, frames) }() | |
| 232 | 238 | ||
| 233 | 239 | go func() { | |
| 234 | 240 | defer workers.Done() | |
| 235 | - | outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} | |
| 241 | + | outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} | |
| 236 | 242 | apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | |
| 237 | 243 | newRenderer := func(w, h int) (ports.Renderer, error) { | |
| 238 | 244 | r, err := vulkan.New(w, h) | |
| @@ -358,6 +364,31 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 358 | 364 | } | |
| 359 | 365 | } | |
| 360 | 366 | ||
| 367 | + | // relayPresented passes output reports to wayland in order. Each flip is | |
| 368 | + | // also a frame for core; a frame core has not read yet is enough, so | |
| 369 | + | // frames never wait. | |
| 370 | + | func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) { | |
| 371 | + | for { | |
| 372 | + | var p ports.OutputPresented | |
| 373 | + | select { | |
| 374 | + | case <-ctx.Done(): | |
| 375 | + | return | |
| 376 | + | case p = <-in: | |
| 377 | + | } | |
| 378 | + | if p.Flip != nil { | |
| 379 | + | select { | |
| 380 | + | case frames <- ports.OutputFrame{Output: p.Output}: | |
| 381 | + | default: | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | select { | |
| 385 | + | case out <- p: | |
| 386 | + | case <-ctx.Done(): | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 361 | 392 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | |
| 362 | 393 | log := logging.For(ctx, "core") | |
| 363 | 394 | configLog := logging.For(ctx, "config") | |
| @@ -92,6 +92,10 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 92 | contents := make(chan ports.SurfaceContent, 64) | 92 | contents := make(chan ports.SurfaceContent, 64) | ||
| 93 | cursorChanges := make(chan ports.CursorChange, 1) | 93 | cursorChanges := make(chan ports.CursorChange, 1) | ||
| 94 | presented := make(chan ports.OutputPresented, 64) | 94 | presented := make(chan ports.OutputPresented, 64) | ||
| 95 | + | // Outputs report on flips; relayPresented passes them to wayland and | |||
| 96 | + | // tells core about each flip (frames) to move running slides. | |||
| 97 | + | flips := make(chan ports.OutputPresented, 64) | |||
| 98 | + | frames := make(chan ports.OutputFrame, 8) | |||
| 95 | captures := make(chan ports.CaptureRequest, 32) | 99 | captures := make(chan ports.CaptureRequest, 32) | ||
| 96 | captured := make(chan ports.CaptureDone, 64) | 100 | captured := make(chan ports.CaptureDone, 64) | ||
| 97 | outputFormats := make(chan ports.OutputFormats, 8) | 101 | outputFormats := make(chan ports.OutputFormats, 8) | ||
| @@ -103,7 +107,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 103 | // Only real sessions save scales: headless runs never touch the config file. | 107 | // Only real sessions save scales: headless runs never touch the config file. | ||
| 104 | scales = make(chan ports.ScaleChanged, 8) | 108 | scales = make(chan ports.ScaleChanged, 8) | ||
| 105 | } | 109 | } | ||
| 106 | - | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}} | 110 | + | ch := core.Channels{Scales: scales, Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, Constraints: constraints, State: states, Workspaces: workspaces, ConfigErrors: configErrors, Terminal: !opts.NoTerminal, Clock: clock.System{}, Frames: frames} |
| 107 | c, err := core.New(opts.Config, ch) | 111 | c, err := core.New(opts.Config, ch) | ||
| 108 | if err != nil { | 112 | if err != nil { | ||
| 109 | return err | 113 | return err | ||
| @@ -229,10 +233,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 229 | }() | 233 | }() | ||
| 230 | } | 234 | } | ||
| 231 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | 235 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | ||
| 236 | + | workers.Add(1) | |||
| 237 | + | go func() { defer workers.Done(); relayPresented(ctx, flips, presented, frames) }() | |||
| 232 | 238 | ||||
| 233 | go func() { | 239 | go func() { | ||
| 234 | defer workers.Done() | 240 | defer workers.Done() | ||
| 235 | - | outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: presented, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} | 241 | + | outputIO := outputChannels{events: output, scenes: renderScenes, contents: contents, cursorChanges: cursorChanges, presented: flips, captures: captures, captured: captured, formats: outputFormats, heads: outputHeads, reloads: filtered, configured: configChanges, requests: applyOutput, replies: appliedOutput} |
| 236 | apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | 242 | apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app")) | ||
| 237 | newRenderer := func(w, h int) (ports.Renderer, error) { | 243 | newRenderer := func(w, h int) (ports.Renderer, error) { | ||
| 238 | r, err := vulkan.New(w, h) | 244 | r, err := vulkan.New(w, h) | ||
| @@ -358,6 +364,31 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||||
| 358 | } | 364 | } | ||
| 359 | } | 365 | } | ||
| 360 | 366 | ||||
| 367 | + | // relayPresented passes output reports to wayland in order. Each flip is | |||
| 368 | + | // also a frame for core; a frame core has not read yet is enough, so | |||
| 369 | + | // frames never wait. | |||
| 370 | + | func relayPresented(ctx context.Context, in <-chan ports.OutputPresented, out chan<- ports.OutputPresented, frames chan<- ports.OutputFrame) { | |||
| 371 | + | for { | |||
| 372 | + | var p ports.OutputPresented | |||
| 373 | + | select { | |||
| 374 | + | case <-ctx.Done(): | |||
| 375 | + | return | |||
| 376 | + | case p = <-in: | |||
| 377 | + | } | |||
| 378 | + | if p.Flip != nil { | |||
| 379 | + | select { | |||
| 380 | + | case frames <- ports.OutputFrame{Output: p.Output}: | |||
| 381 | + | default: | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | select { | |||
| 385 | + | case out <- p: | |||
| 386 | + | case <-ctx.Done(): | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 361 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | 392 | func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) { | ||
| 362 | log := logging.For(ctx, "core") | 393 | log := logging.For(ctx, "core") | ||
| 363 | configLog := logging.For(ctx, "config") | 394 | configLog := logging.For(ctx, "config") | ||
M internal/core/action.go +0 -20
| @@ -3,8 +3,6 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 6 | ) | |
| 9 | 7 | ||
| 10 | 8 | type Action string | |
| @@ -46,24 +44,6 @@ const ( | |||
| 46 | 44 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | |
| 47 | 45 | ) | |
| 48 | 46 | ||
| 49 | - | // swipeAction maps a three-finger swipe to its action. Natural scroll moves | |
| 50 | - | // the content with the fingers, so it shows what lies the other way. | |
| 51 | - | func swipeAction(d ports.SwipeDir, natural bool) Action { | |
| 52 | - | actions := [...]Action{ | |
| 53 | - | ports.SwipeUp: ActionFocusWorkspaceUp, | |
| 54 | - | ports.SwipeDown: ActionFocusWorkspaceDown, | |
| 55 | - | ports.SwipeLeft: ActionFocusColumnLeft, | |
| 56 | - | ports.SwipeRight: ActionFocusColumnRight, | |
| 57 | - | } | |
| 58 | - | if int(d) >= len(actions) { | |
| 59 | - | return "" | |
| 60 | - | } | |
| 61 | - | if natural { | |
| 62 | - | d ^= 1 // up<->down, left<->right | |
| 63 | - | } | |
| 64 | - | return actions[d] | |
| 65 | - | } | |
| 66 | - | ||
| 67 | 47 | // WorkspaceOp is what a numbered workspace action does. | |
| 68 | 48 | type WorkspaceOp int | |
| 69 | 49 | ||
| @@ -3,8 +3,6 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | - | ||||
| 7 | - | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | ) | 6 | ) | ||
| 9 | 7 | ||||
| 10 | type Action string | 8 | type Action string | ||
| @@ -46,24 +44,6 @@ const ( | |||||
| 46 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | 44 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | ||
| 47 | ) | 45 | ) | ||
| 48 | 46 | ||||
| 49 | - | // swipeAction maps a three-finger swipe to its action. Natural scroll moves | |||
| 50 | - | // the content with the fingers, so it shows what lies the other way. | |||
| 51 | - | func swipeAction(d ports.SwipeDir, natural bool) Action { | |||
| 52 | - | actions := [...]Action{ | |||
| 53 | - | ports.SwipeUp: ActionFocusWorkspaceUp, | |||
| 54 | - | ports.SwipeDown: ActionFocusWorkspaceDown, | |||
| 55 | - | ports.SwipeLeft: ActionFocusColumnLeft, | |||
| 56 | - | ports.SwipeRight: ActionFocusColumnRight, | |||
| 57 | - | } | |||
| 58 | - | if int(d) >= len(actions) { | |||
| 59 | - | return "" | |||
| 60 | - | } | |||
| 61 | - | if natural { | |||
| 62 | - | d ^= 1 // up<->down, left<->right | |||
| 63 | - | } | |||
| 64 | - | return actions[d] | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | // WorkspaceOp is what a numbered workspace action does. | 47 | // WorkspaceOp is what a numbered workspace action does. | ||
| 68 | type WorkspaceOp int | 48 | type WorkspaceOp int | ||
| 69 | 49 | ||||
M internal/core/core.go +94 -7
| @@ -43,8 +43,12 @@ type Channels struct { | |||
| 43 | 43 | Scales chan<- ports.ScaleChanged | |
| 44 | 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | |
| 45 | 45 | Terminal bool | |
| 46 | - | // Clock tells the time for fullscreenGrace; nil is the system clock. | |
| 46 | + | // Clock tells the time for fullscreenGrace and slides; nil is the | |
| 47 | + | // system clock. | |
| 47 | 48 | Clock ports.Clock | |
| 49 | + | // Frames, when set, reports outputs' page flips: a running slide moves | |
| 50 | + | // one step per flip. Without flips it moves on a timer. | |
| 51 | + | Frames <-chan ports.OutputFrame | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | 54 | // fullscreenGrace is how long after mapping a window's fullscreen request | |
| @@ -114,6 +118,15 @@ type Core struct { | |||
| 114 | 118 | // activity is when core last told wayland about user input | |
| 115 | 119 | // (ports.UserActivity), sent at most once per ActivityInterval. | |
| 116 | 120 | activity time.Time | |
| 121 | + | // swipe is the touchpad swipe in progress. frameC fires when no page | |
| 122 | + | // flip came in time to move a running slide (frameStop stops it). | |
| 123 | + | swipe *swipeGesture | |
| 124 | + | frameC <-chan time.Time | |
| 125 | + | frameStop func() bool | |
| 126 | + | // motionMsec is the time of the last pointer motion; pointerAt is the | |
| 127 | + | // last position sent in the pointer's window. | |
| 128 | + | motionMsec uint32 | |
| 129 | + | pointerAt [2]float64 | |
| 117 | 130 | } | |
| 118 | 131 | ||
| 119 | 132 | func keyName(s string) string { | |
| @@ -539,9 +552,57 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 539 | 552 | latest(c.ch.Scenes, scenes) | |
| 540 | 553 | c.publishState() | |
| 541 | 554 | c.publishWorkspaces() | |
| 555 | + | // A running slide moves on the next flip, or on the fallback timer. | |
| 556 | + | if c.animating() { | |
| 557 | + | if c.frameC == nil { | |
| 558 | + | c.armFrame() | |
| 559 | + | } | |
| 560 | + | } else { | |
| 561 | + | c.stopFrame() | |
| 562 | + | } | |
| 542 | 563 | return nil | |
| 543 | 564 | } | |
| 544 | 565 | ||
| 566 | + | // step moves the running slides to now and publishes the frame. | |
| 567 | + | func (c *Core) step(ctx context.Context) error { | |
| 568 | + | c.stopFrame() | |
| 569 | + | c.animate(c.now()) | |
| 570 | + | return c.slid(ctx, false) | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | // slid publishes a moved view; shown reports another workspace came on | |
| 574 | + | // screen. | |
| 575 | + | func (c *Core) slid(ctx context.Context, shown bool) error { | |
| 576 | + | if err := c.workspaceVisible(ctx, shown); err != nil { | |
| 577 | + | return err | |
| 578 | + | } | |
| 579 | + | if err := c.publish(ctx); err != nil { | |
| 580 | + | return err | |
| 581 | + | } | |
| 582 | + | return c.rehit(ctx) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // rehit points the pointer at what now lies under a still cursor, as | |
| 586 | + | // windows slide under it. A held button keeps its grab. | |
| 587 | + | func (c *Core) rehit(ctx context.Context) error { | |
| 588 | + | if c.grab != 0 { | |
| 589 | + | return nil | |
| 590 | + | } | |
| 591 | + | id, x, y := c.hit(c.cursorX, c.cursorY) | |
| 592 | + | if id != c.pointer { | |
| 593 | + | c.pointer, c.pointerAt = id, [2]float64{x, y} | |
| 594 | + | return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}) | |
| 595 | + | } | |
| 596 | + | if id == 0 || c.pointerAt == [2]float64{x, y} { | |
| 597 | + | return nil | |
| 598 | + | } | |
| 599 | + | c.pointerAt = [2]float64{x, y} | |
| 600 | + | // The same window moved under the cursor: it moves in the window. No | |
| 601 | + | // relative motion: the device did not move. The input clock is the | |
| 602 | + | // device's; the last motion's time is the closest core knows. | |
| 603 | + | return c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: c.motionMsec}) | |
| 604 | + | } | |
| 605 | + | ||
| 545 | 606 | // updateInhibit makes the shortcuts inhibitor of the keyboard focus the | |
| 546 | 607 | // active one and tells wayland when it changes. | |
| 547 | 608 | func (c *Core) updateInhibit(ctx context.Context) error { | |
| @@ -703,6 +764,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 703 | 764 | } | |
| 704 | 765 | ||
| 705 | 766 | func (c *Core) Run(ctx context.Context) error { | |
| 767 | + | defer c.stopFrame() | |
| 706 | 768 | // Startup commands run once per session; a config reload does not | |
| 707 | 769 | // run them again. | |
| 708 | 770 | for _, argv := range c.cfg.Startup { | |
| @@ -835,6 +897,21 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 835 | 897 | if err := c.publish(ctx); err != nil { | |
| 836 | 898 | return nil | |
| 837 | 899 | } | |
| 900 | + | case f, ok := <-c.ch.Frames: | |
| 901 | + | if !ok { | |
| 902 | + | c.ch.Frames = nil | |
| 903 | + | continue | |
| 904 | + | } | |
| 905 | + | if c.sliding(f.Output) && c.step(ctx) != nil { | |
| 906 | + | return nil | |
| 907 | + | } | |
| 908 | + | continue | |
| 909 | + | case <-c.frameC: | |
| 910 | + | c.frameC, c.frameStop = nil, nil | |
| 911 | + | if c.step(ctx) != nil { | |
| 912 | + | return nil | |
| 913 | + | } | |
| 914 | + | continue | |
| 838 | 915 | case ev, ok := <-c.ch.Output: | |
| 839 | 916 | if !ok { | |
| 840 | 917 | c.ch.Output = nil | |
| @@ -887,6 +964,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 887 | 964 | } | |
| 888 | 965 | switch v := ev.(type) { | |
| 889 | 966 | case ports.PointerMotion: | |
| 967 | + | c.motionMsec = v.TimeMsec | |
| 890 | 968 | // A locked pointer stays still; relative motion still flows. | |
| 891 | 969 | if c.constraint.Mode != ports.ConstraintLock { | |
| 892 | 970 | c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)) | |
| @@ -912,6 +990,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 912 | 990 | return nil | |
| 913 | 991 | } | |
| 914 | 992 | } | |
| 993 | + | c.pointerAt = [2]float64{x, y} | |
| 915 | 994 | if id != 0 { | |
| 916 | 995 | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil { | |
| 917 | 996 | return nil | |
| @@ -978,14 +1057,18 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 978 | 1057 | } | |
| 979 | 1058 | } | |
| 980 | 1059 | continue | |
| 981 | - | case ports.Swipe: | |
| 982 | - | before := c.cur().mon.Current() | |
| 983 | - | c.layerFocus = 0 | |
| 984 | - | c.applyAction(swipeAction(v.Dir, c.cfg.Touchpad.NaturalScroll)) | |
| 985 | - | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | |
| 1060 | + | case ports.SwipeBegin: | |
| 1061 | + | if c.swipeBegin(v.Time) && c.slid(ctx, false) != nil { | |
| 1062 | + | return nil | |
| 1063 | + | } | |
| 1064 | + | continue | |
| 1065 | + | case ports.SwipeUpdate: | |
| 1066 | + | if c.swipeUpdate(v) && c.slid(ctx, false) != nil { | |
| 986 | 1067 | return nil | |
| 987 | 1068 | } | |
| 988 | - | if err := c.publish(ctx); err != nil { | |
| 1069 | + | continue | |
| 1070 | + | case ports.SwipeEnd: | |
| 1071 | + | if c.slid(ctx, c.swipeEnd(v)) != nil { | |
| 989 | 1072 | return nil | |
| 990 | 1073 | } | |
| 991 | 1074 | continue | |
| @@ -1053,6 +1136,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1053 | 1136 | continue | |
| 1054 | 1137 | } | |
| 1055 | 1138 | before := c.cur().mon.Current() | |
| 1139 | + | swiped := c.swipedWorkspace() | |
| 1056 | 1140 | c.layerFocus = 0 // a bind acts on the windows | |
| 1057 | 1141 | effect := c.applyAction(action) | |
| 1058 | 1142 | if effect.Quit { | |
| @@ -1069,6 +1153,9 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 1069 | 1153 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | |
| 1070 | 1154 | } | |
| 1071 | 1155 | } | |
| 1156 | + | if c.swipedWorkspace() != swiped { | |
| 1157 | + | c.dropSwipe() | |
| 1158 | + | } | |
| 1072 | 1159 | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | |
| 1073 | 1160 | return nil | |
| 1074 | 1161 | } | |
| @@ -43,8 +43,12 @@ type Channels struct { | |||||
| 43 | Scales chan<- ports.ScaleChanged | 43 | Scales chan<- ports.ScaleChanged | ||
| 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | 44 | // Terminal enables automatic terminal opening according to terminal.auto-open. | ||
| 45 | Terminal bool | 45 | Terminal bool | ||
| 46 | - | // Clock tells the time for fullscreenGrace; nil is the system clock. | 46 | + | // Clock tells the time for fullscreenGrace and slides; nil is the |
| 47 | + | // system clock. | |||
| 47 | Clock ports.Clock | 48 | Clock ports.Clock | ||
| 49 | + | // Frames, when set, reports outputs' page flips: a running slide moves | |||
| 50 | + | // one step per flip. Without flips it moves on a timer. | |||
| 51 | + | Frames <-chan ports.OutputFrame | |||
| 48 | } | 52 | } | ||
| 49 | 53 | ||||
| 50 | // fullscreenGrace is how long after mapping a window's fullscreen request | 54 | // fullscreenGrace is how long after mapping a window's fullscreen request | ||
| @@ -114,6 +118,15 @@ type Core struct { | |||||
| 114 | // activity is when core last told wayland about user input | 118 | // activity is when core last told wayland about user input | ||
| 115 | // (ports.UserActivity), sent at most once per ActivityInterval. | 119 | // (ports.UserActivity), sent at most once per ActivityInterval. | ||
| 116 | activity time.Time | 120 | activity time.Time | ||
| 121 | + | // swipe is the touchpad swipe in progress. frameC fires when no page | |||
| 122 | + | // flip came in time to move a running slide (frameStop stops it). | |||
| 123 | + | swipe *swipeGesture | |||
| 124 | + | frameC <-chan time.Time | |||
| 125 | + | frameStop func() bool | |||
| 126 | + | // motionMsec is the time of the last pointer motion; pointerAt is the | |||
| 127 | + | // last position sent in the pointer's window. | |||
| 128 | + | motionMsec uint32 | |||
| 129 | + | pointerAt [2]float64 | |||
| 117 | } | 130 | } | ||
| 118 | 131 | ||||
| 119 | func keyName(s string) string { | 132 | func keyName(s string) string { | ||
| @@ -539,9 +552,57 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 539 | latest(c.ch.Scenes, scenes) | 552 | latest(c.ch.Scenes, scenes) | ||
| 540 | c.publishState() | 553 | c.publishState() | ||
| 541 | c.publishWorkspaces() | 554 | c.publishWorkspaces() | ||
| 555 | + | // A running slide moves on the next flip, or on the fallback timer. | |||
| 556 | + | if c.animating() { | |||
| 557 | + | if c.frameC == nil { | |||
| 558 | + | c.armFrame() | |||
| 559 | + | } | |||
| 560 | + | } else { | |||
| 561 | + | c.stopFrame() | |||
| 562 | + | } | |||
| 542 | return nil | 563 | return nil | ||
| 543 | } | 564 | } | ||
| 544 | 565 | ||||
| 566 | + | // step moves the running slides to now and publishes the frame. | |||
| 567 | + | func (c *Core) step(ctx context.Context) error { | |||
| 568 | + | c.stopFrame() | |||
| 569 | + | c.animate(c.now()) | |||
| 570 | + | return c.slid(ctx, false) | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | // slid publishes a moved view; shown reports another workspace came on | |||
| 574 | + | // screen. | |||
| 575 | + | func (c *Core) slid(ctx context.Context, shown bool) error { | |||
| 576 | + | if err := c.workspaceVisible(ctx, shown); err != nil { | |||
| 577 | + | return err | |||
| 578 | + | } | |||
| 579 | + | if err := c.publish(ctx); err != nil { | |||
| 580 | + | return err | |||
| 581 | + | } | |||
| 582 | + | return c.rehit(ctx) | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // rehit points the pointer at what now lies under a still cursor, as | |||
| 586 | + | // windows slide under it. A held button keeps its grab. | |||
| 587 | + | func (c *Core) rehit(ctx context.Context) error { | |||
| 588 | + | if c.grab != 0 { | |||
| 589 | + | return nil | |||
| 590 | + | } | |||
| 591 | + | id, x, y := c.hit(c.cursorX, c.cursorY) | |||
| 592 | + | if id != c.pointer { | |||
| 593 | + | c.pointer, c.pointerAt = id, [2]float64{x, y} | |||
| 594 | + | return c.command(ctx, ports.PointerFocus{ID: id, X: x, Y: y}) | |||
| 595 | + | } | |||
| 596 | + | if id == 0 || c.pointerAt == [2]float64{x, y} { | |||
| 597 | + | return nil | |||
| 598 | + | } | |||
| 599 | + | c.pointerAt = [2]float64{x, y} | |||
| 600 | + | // The same window moved under the cursor: it moves in the window. No | |||
| 601 | + | // relative motion: the device did not move. The input clock is the | |||
| 602 | + | // device's; the last motion's time is the closest core knows. | |||
| 603 | + | return c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, TimeMsec: c.motionMsec}) | |||
| 604 | + | } | |||
| 605 | + | ||||
| 545 | // updateInhibit makes the shortcuts inhibitor of the keyboard focus the | 606 | // updateInhibit makes the shortcuts inhibitor of the keyboard focus the | ||
| 546 | // active one and tells wayland when it changes. | 607 | // active one and tells wayland when it changes. | ||
| 547 | func (c *Core) updateInhibit(ctx context.Context) error { | 608 | func (c *Core) updateInhibit(ctx context.Context) error { | ||
| @@ -703,6 +764,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 703 | } | 764 | } | ||
| 704 | 765 | ||||
| 705 | func (c *Core) Run(ctx context.Context) error { | 766 | func (c *Core) Run(ctx context.Context) error { | ||
| 767 | + | defer c.stopFrame() | |||
| 706 | // Startup commands run once per session; a config reload does not | 768 | // Startup commands run once per session; a config reload does not | ||
| 707 | // run them again. | 769 | // run them again. | ||
| 708 | for _, argv := range c.cfg.Startup { | 770 | for _, argv := range c.cfg.Startup { | ||
| @@ -835,6 +897,21 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 835 | if err := c.publish(ctx); err != nil { | 897 | if err := c.publish(ctx); err != nil { | ||
| 836 | return nil | 898 | return nil | ||
| 837 | } | 899 | } | ||
| 900 | + | case f, ok := <-c.ch.Frames: | |||
| 901 | + | if !ok { | |||
| 902 | + | c.ch.Frames = nil | |||
| 903 | + | continue | |||
| 904 | + | } | |||
| 905 | + | if c.sliding(f.Output) && c.step(ctx) != nil { | |||
| 906 | + | return nil | |||
| 907 | + | } | |||
| 908 | + | continue | |||
| 909 | + | case <-c.frameC: | |||
| 910 | + | c.frameC, c.frameStop = nil, nil | |||
| 911 | + | if c.step(ctx) != nil { | |||
| 912 | + | return nil | |||
| 913 | + | } | |||
| 914 | + | continue | |||
| 838 | case ev, ok := <-c.ch.Output: | 915 | case ev, ok := <-c.ch.Output: | ||
| 839 | if !ok { | 916 | if !ok { | ||
| 840 | c.ch.Output = nil | 917 | c.ch.Output = nil | ||
| @@ -887,6 +964,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 887 | } | 964 | } | ||
| 888 | switch v := ev.(type) { | 965 | switch v := ev.(type) { | ||
| 889 | case ports.PointerMotion: | 966 | case ports.PointerMotion: | ||
| 967 | + | c.motionMsec = v.TimeMsec | |||
| 890 | // A locked pointer stays still; relative motion still flows. | 968 | // A locked pointer stays still; relative motion still flows. | ||
| 891 | if c.constraint.Mode != ports.ConstraintLock { | 969 | if c.constraint.Mode != ports.ConstraintLock { | ||
| 892 | c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)) | 970 | c.cursorX, c.cursorY = c.constraint.Clamp(c.layout().Clamp(c.cursorX, c.cursorY, v.X, v.Y)) | ||
| @@ -912,6 +990,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 912 | return nil | 990 | return nil | ||
| 913 | } | 991 | } | ||
| 914 | } | 992 | } | ||
| 993 | + | c.pointerAt = [2]float64{x, y} | |||
| 915 | if id != 0 { | 994 | if id != 0 { | ||
| 916 | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil { | 995 | if err := c.command(ctx, ports.PointerMotionTo{ID: id, X: x, Y: y, DX: v.DX, DY: v.DY, UnaccelDX: v.UnaccelDX, UnaccelDY: v.UnaccelDY, TimeMsec: v.TimeMsec, TimeUsec: v.TimeUsec}); err != nil { | ||
| 917 | return nil | 996 | return nil | ||
| @@ -978,14 +1057,18 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 978 | } | 1057 | } | ||
| 979 | } | 1058 | } | ||
| 980 | continue | 1059 | continue | ||
| 981 | - | case ports.Swipe: | 1060 | + | case ports.SwipeBegin: |
| 982 | - | before := c.cur().mon.Current() | 1061 | + | if c.swipeBegin(v.Time) && c.slid(ctx, false) != nil { |
| 983 | - | c.layerFocus = 0 | 1062 | + | return nil |
| 984 | - | c.applyAction(swipeAction(v.Dir, c.cfg.Touchpad.NaturalScroll)) | 1063 | + | } |
| 985 | - | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | 1064 | + | continue |
| 1065 | + | case ports.SwipeUpdate: | |||
| 1066 | + | if c.swipeUpdate(v) && c.slid(ctx, false) != nil { | |||
| 986 | return nil | 1067 | return nil | ||
| 987 | } | 1068 | } | ||
| 988 | - | if err := c.publish(ctx); err != nil { | 1069 | + | continue |
| 1070 | + | case ports.SwipeEnd: | |||
| 1071 | + | if c.slid(ctx, c.swipeEnd(v)) != nil { | |||
| 989 | return nil | 1072 | return nil | ||
| 990 | } | 1073 | } | ||
| 991 | continue | 1074 | continue | ||
| @@ -1053,6 +1136,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1053 | continue | 1136 | continue | ||
| 1054 | } | 1137 | } | ||
| 1055 | before := c.cur().mon.Current() | 1138 | before := c.cur().mon.Current() | ||
| 1139 | + | swiped := c.swipedWorkspace() | |||
| 1056 | c.layerFocus = 0 // a bind acts on the windows | 1140 | c.layerFocus = 0 // a bind acts on the windows | ||
| 1057 | effect := c.applyAction(action) | 1141 | effect := c.applyAction(action) | ||
| 1058 | if effect.Quit { | 1142 | if effect.Quit { | ||
| @@ -1069,6 +1153,9 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 1069 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | 1153 | case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}: | ||
| 1070 | } | 1154 | } | ||
| 1071 | } | 1155 | } | ||
| 1156 | + | if c.swipedWorkspace() != swiped { | |||
| 1157 | + | c.dropSwipe() | |||
| 1158 | + | } | |||
| 1072 | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | 1159 | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | ||
| 1073 | return nil | 1160 | return nil | ||
| 1074 | } | 1161 | } | ||
A internal/core/gesture.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "slices" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 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. | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | // swipeDecide is the touchpad distance before the swipe picks its axis | |
| 19 | + | // (GNOME Shell's threshold). | |
| 20 | + | swipeDecide = 16.0 | |
| 21 | + | // viewSwipeMovement is the touchpad distance that scrolls the columns by | |
| 22 | + | // one usable width. | |
| 23 | + | viewSwipeMovement = 1200.0 | |
| 24 | + | // workspaceSwipeMovement is the touchpad distance that slides one | |
| 25 | + | // workspace. | |
| 26 | + | workspaceSwipeMovement = 300.0 | |
| 27 | + | // discreteSwipeMin is the projected distance a swipe needs to run a | |
| 28 | + | // focus action where the view cannot slide. | |
| 29 | + | discreteSwipeMin = 150.0 | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | type swipeMode uint8 | |
| 33 | + | ||
| 34 | + | const ( | |
| 35 | + | swipeUndecided swipeMode = iota | |
| 36 | + | swipeColumns | |
| 37 | + | swipeWorkspaces | |
| 38 | + | swipeDiscrete | |
| 39 | + | // swipeDropped ignores the rest of a swipe whose workspace changed. | |
| 40 | + | swipeDropped | |
| 41 | + | ) | |
| 42 | + | ||
| 43 | + | // swipeGesture is the swipe in progress, on the screen focused when it began. | |
| 44 | + | type swipeGesture struct { | |
| 45 | + | screen *screen | |
| 46 | + | mode swipeMode | |
| 47 | + | horizontal bool | |
| 48 | + | cx, cy float64 | |
| 49 | + | // tracker adds up the movement along the chosen axis, natural scroll | |
| 50 | + | // applied: positive scrolls right or down. | |
| 51 | + | tracker swipeTracker | |
| 52 | + | // ws is the workspace whose columns scroll, or the active one when a | |
| 53 | + | // workspace slide began; start is where the view was when the swipe | |
| 54 | + | // took over: its x in pixels (columns) or the monitor's fractional | |
| 55 | + | // workspace index (workspaces). Both are absolute, so a focus change | |
| 56 | + | // during the swipe does not move the fingers' view. | |
| 57 | + | ws *Workspace | |
| 58 | + | start float64 | |
| 59 | + | // list is the numbered workspaces when a workspace slide began: start | |
| 60 | + | // indexes it, so the slide ends if the list changes. | |
| 61 | + | list []*Workspace | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // listChanged reports whether the numbered workspaces or the active one | |
| 65 | + | // changed since a workspace slide began. | |
| 66 | + | func (g *swipeGesture) listChanged(m *Monitor) bool { | |
| 67 | + | return m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // swipeSign turns finger movement into view movement: natural scroll moves | |
| 71 | + | // the content with the fingers. | |
| 72 | + | func (c *Core) swipeSign() float64 { | |
| 73 | + | if c.cfg.Touchpad.NaturalScroll { | |
| 74 | + | return -1 | |
| 75 | + | } | |
| 76 | + | return 1 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // swipeBegin starts a swipe. Input streams one swipe at a time; one still | |
| 80 | + | // running (its end was lost) ends cancelled first: its slide settles back. | |
| 81 | + | func (c *Core) swipeBegin(at time.Duration) bool { | |
| 82 | + | changed := false | |
| 83 | + | if c.swipe != nil { | |
| 84 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: at}) | |
| 85 | + | changed = true | |
| 86 | + | } | |
| 87 | + | c.swipe = &swipeGesture{screen: c.cur()} | |
| 88 | + | return changed | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // swipeUpdate moves the swipe; it reports whether the scene changed. | |
| 92 | + | func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |
| 93 | + | g := c.swipe | |
| 94 | + | if g == nil || !c.hasScreen(g.screen) { | |
| 95 | + | c.swipe = nil | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | if g.mode == swipeUndecided { | |
| 99 | + | g.cx += u.DX | |
| 100 | + | g.cy += u.DY | |
| 101 | + | if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide { | |
| 102 | + | return false | |
| 103 | + | } | |
| 104 | + | c.decide(g) | |
| 105 | + | } | |
| 106 | + | d := u.DY | |
| 107 | + | if g.horizontal { | |
| 108 | + | d = u.DX | |
| 109 | + | } | |
| 110 | + | g.tracker.push(d*c.swipeSign(), u.Time) | |
| 111 | + | m := g.screen.mon | |
| 112 | + | switch g.mode { | |
| 113 | + | case swipeColumns: | |
| 114 | + | if m.Current() != g.ws || !g.ws.slidable() { | |
| 115 | + | g.ws.stopSlide() | |
| 116 | + | g.mode = swipeDropped | |
| 117 | + | return true | |
| 118 | + | } | |
| 119 | + | g.ws.shift = g.start + g.tracker.pos*g.ws.swipeScale() - float64(g.ws.ViewX) | |
| 120 | + | return true | |
| 121 | + | case swipeWorkspaces: | |
| 122 | + | if g.listChanged(m) { | |
| 123 | + | m.stopSwitch() | |
| 124 | + | g.mode = swipeDropped | |
| 125 | + | return true | |
| 126 | + | } | |
| 127 | + | f := workspaceBand.clamp(0, float64(len(m.Workspaces)-1), g.start+g.tracker.pos/workspaceSwipeMovement) | |
| 128 | + | m.switchOff = f - float64(m.Active) | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // decide picks the swipe's axis and what it moves. A running slide stops | |
| 135 | + | // where it is: the fingers take it from there. | |
| 136 | + | func (c *Core) decide(g *swipeGesture) { | |
| 137 | + | g.horizontal = math.Abs(g.cx) > math.Abs(g.cy) | |
| 138 | + | m := g.screen.mon | |
| 139 | + | w := m.Current() | |
| 140 | + | switch { | |
| 141 | + | case g.horizontal && w.slidable(): | |
| 142 | + | g.mode, g.ws = swipeColumns, w | |
| 143 | + | w.motion = nil | |
| 144 | + | g.start = float64(w.ViewX) + w.shift | |
| 145 | + | case !g.horizontal && m.shown == nil: | |
| 146 | + | // A landing slide measured in an older list lands at once first. | |
| 147 | + | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |
| 148 | + | m.stopSwitch() | |
| 149 | + | } | |
| 150 | + | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |
| 151 | + | g.list = slices.Clone(m.Workspaces) | |
| 152 | + | m.switchMotion, m.switchList = nil, nil | |
| 153 | + | g.start = float64(m.Active) + m.switchOff | |
| 154 | + | default: | |
| 155 | + | g.mode = swipeDiscrete | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // swipeEnd lands the swipe. shown reports that another workspace came on | |
| 160 | + | // screen. | |
| 161 | + | func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |
| 162 | + | g := c.swipe | |
| 163 | + | c.swipe = nil | |
| 164 | + | if g == nil || !c.hasScreen(g.screen) { | |
| 165 | + | return false | |
| 166 | + | } | |
| 167 | + | // Idle time before the lift slows the swipe down. | |
| 168 | + | g.tracker.push(0, e.Time) | |
| 169 | + | now := c.now() | |
| 170 | + | m := g.screen.mon | |
| 171 | + | switch g.mode { | |
| 172 | + | case swipeColumns: | |
| 173 | + | w := g.ws | |
| 174 | + | if m.Current() != w || !w.slidable() { | |
| 175 | + | w.stopSlide() | |
| 176 | + | return false | |
| 177 | + | } | |
| 178 | + | scale := w.swipeScale() | |
| 179 | + | shown := float64(w.ViewX) + w.shift | |
| 180 | + | if !e.Cancelled { | |
| 181 | + | target := g.start + g.tracker.projectedEnd()*scale | |
| 182 | + | w.ViewX, w.Focus = w.snap(target, target >= shown) | |
| 183 | + | // A column wider than the view aligns as focus moves align it. | |
| 184 | + | w.scroll() | |
| 185 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 186 | + | c.layerFocus = 0 | |
| 187 | + | } | |
| 188 | + | w.shift = shown - float64(w.ViewX) | |
| 189 | + | w.motion = newMotion(viewSpring(w.shift, g.tracker.velocity()*scale), now) | |
| 190 | + | case swipeWorkspaces: | |
| 191 | + | if g.listChanged(m) { | |
| 192 | + | m.stopSwitch() | |
| 193 | + | return false | |
| 194 | + | } | |
| 195 | + | last := float64(len(m.Workspaces) - 1) | |
| 196 | + | cur := float64(m.Active) + m.switchOff | |
| 197 | + | velocity := g.tracker.velocity() / workspaceSwipeMovement * workspaceBand.clampDerivative(0, last, g.start+g.tracker.pos/workspaceSwipeMovement) | |
| 198 | + | idx := m.Active | |
| 199 | + | if !e.Cancelled { | |
| 200 | + | idx = int(math.Round(min(max(g.start+g.tracker.projectedEnd()/workspaceSwipeMovement, 0), last))) | |
| 201 | + | } | |
| 202 | + | // Focus may drop the empty workspace left behind and renumber the | |
| 203 | + | // list: the slide keeps measuring in the list it began on. | |
| 204 | + | off := cur - float64(idx) | |
| 205 | + | if idx != m.Active { | |
| 206 | + | m.Focus(idx) | |
| 207 | + | shown = true | |
| 208 | + | } | |
| 209 | + | if !e.Cancelled { | |
| 210 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |
| 211 | + | c.layerFocus = 0 | |
| 212 | + | } | |
| 213 | + | m.switchOff, m.switchList = off, g.list | |
| 214 | + | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |
| 215 | + | case swipeDiscrete: | |
| 216 | + | p := g.tracker.projectedEnd() | |
| 217 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin { | |
| 218 | + | return false | |
| 219 | + | } | |
| 220 | + | a := ActionFocusWorkspaceDown | |
| 221 | + | switch { | |
| 222 | + | case g.horizontal && p < 0: | |
| 223 | + | a = ActionFocusColumnLeft | |
| 224 | + | case g.horizontal: | |
| 225 | + | a = ActionFocusColumnRight | |
| 226 | + | case p < 0: | |
| 227 | + | a = ActionFocusWorkspaceUp | |
| 228 | + | } | |
| 229 | + | // Focus actions act on the focused screen: the swipe's, unless the | |
| 230 | + | // pointer took the focus to another output meanwhile. | |
| 231 | + | if c.cur() != g.screen { | |
| 232 | + | return false | |
| 233 | + | } | |
| 234 | + | before := c.cur().mon.Current() | |
| 235 | + | c.layerFocus = 0 | |
| 236 | + | c.applyAction(a) | |
| 237 | + | return c.cur().mon.Current() != before | |
| 238 | + | } | |
| 239 | + | return shown | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // swipedWorkspace is the workspace on the swipe's screen, or nil without | |
| 243 | + | // a swipe. | |
| 244 | + | func (c *Core) swipedWorkspace() *Workspace { | |
| 245 | + | if c.swipe == nil { | |
| 246 | + | return nil | |
| 247 | + | } | |
| 248 | + | return c.swipe.screen.mon.Current() | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // dropSwipe lets go of the swipe in progress: a bind changed the workspace | |
| 252 | + | // on its screen. Its slide stops where the bind left the view; the rest of | |
| 253 | + | // the swipe is ignored, even if a later bind shows its workspace again. | |
| 254 | + | func (c *Core) dropSwipe() { | |
| 255 | + | g := c.swipe | |
| 256 | + | if g == nil { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | switch g.mode { | |
| 260 | + | case swipeColumns: | |
| 261 | + | g.ws.stopSlide() | |
| 262 | + | case swipeWorkspaces: | |
| 263 | + | g.screen.mon.stopSwitch() | |
| 264 | + | } | |
| 265 | + | g.mode = swipeDropped | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (c *Core) hasScreen(s *screen) bool { | |
| 269 | + | for _, v := range c.screens { | |
| 270 | + | if v == s { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | return false | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // animate moves the running springs to now; settled ones stop. | |
| 278 | + | func (c *Core) animate(now time.Time) { | |
| 279 | + | for _, sc := range c.screens { | |
| 280 | + | m := sc.mon | |
| 281 | + | if m.switchMotion != nil { | |
| 282 | + | v, done := m.switchMotion.at(now) | |
| 283 | + | m.switchOff = v | |
| 284 | + | if done { | |
| 285 | + | m.stopSwitch() | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | for _, w := range m.all() { | |
| 289 | + | if w.motion != nil { | |
| 290 | + | v, done := w.motion.at(now) | |
| 291 | + | w.shift = v | |
| 292 | + | if done { | |
| 293 | + | w.stopSlide() | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // sliding reports whether a spring runs on the named output. | |
| 301 | + | func (c *Core) sliding(output string) bool { | |
| 302 | + | i := c.screenIndex(output) | |
| 303 | + | return i >= 0 && c.screens[i].mon.springing() | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // animating reports whether a spring runs on any output. | |
| 307 | + | func (c *Core) animating() bool { | |
| 308 | + | for _, sc := range c.screens { | |
| 309 | + | if sc.mon.springing() { | |
| 310 | + | return true | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | return false | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func (m *Monitor) springing() bool { | |
| 317 | + | if m.switchMotion != nil { | |
| 318 | + | return true | |
| 319 | + | } | |
| 320 | + | for _, w := range m.all() { | |
| 321 | + | if w.motion != nil { | |
| 322 | + | return true | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | return false | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // frameFallback is how long core waits for a page flip before it moves a | |
| 329 | + | // running slide on its own (an output that stopped flipping). | |
| 330 | + | func (c *Core) frameFallback() time.Duration { | |
| 331 | + | refresh := 60000 | |
| 332 | + | for _, sc := range c.screens { | |
| 333 | + | if r := sc.info.RefreshMilli; r > 0 && sc.mon.springing() { | |
| 334 | + | refresh = min(refresh, r) | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | return 2 * time.Second * 1000 / time.Duration(refresh) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // armFrame starts the fallback timer of a running slide. | |
| 341 | + | func (c *Core) armFrame() { | |
| 342 | + | c.stopFrame() | |
| 343 | + | d := c.frameFallback() | |
| 344 | + | if c.ch.Clock != nil { | |
| 345 | + | t := c.ch.Clock.NewTimer(d) | |
| 346 | + | c.frameC, c.frameStop = t.C(), t.Stop | |
| 347 | + | return | |
| 348 | + | } | |
| 349 | + | t := time.NewTimer(d) | |
| 350 | + | c.frameC, c.frameStop = t.C, t.Stop | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func (c *Core) stopFrame() { | |
| 354 | + | if c.frameStop != nil { | |
| 355 | + | c.frameStop() | |
| 356 | + | } | |
| 357 | + | c.frameC, c.frameStop = nil, nil | |
| 358 | + | } | |
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "slices" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 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. | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | // swipeDecide is the touchpad distance before the swipe picks its axis | |||
| 19 | + | // (GNOME Shell's threshold). | |||
| 20 | + | swipeDecide = 16.0 | |||
| 21 | + | // viewSwipeMovement is the touchpad distance that scrolls the columns by | |||
| 22 | + | // one usable width. | |||
| 23 | + | viewSwipeMovement = 1200.0 | |||
| 24 | + | // workspaceSwipeMovement is the touchpad distance that slides one | |||
| 25 | + | // workspace. | |||
| 26 | + | workspaceSwipeMovement = 300.0 | |||
| 27 | + | // discreteSwipeMin is the projected distance a swipe needs to run a | |||
| 28 | + | // focus action where the view cannot slide. | |||
| 29 | + | discreteSwipeMin = 150.0 | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | type swipeMode uint8 | |||
| 33 | + | ||||
| 34 | + | const ( | |||
| 35 | + | swipeUndecided swipeMode = iota | |||
| 36 | + | swipeColumns | |||
| 37 | + | swipeWorkspaces | |||
| 38 | + | swipeDiscrete | |||
| 39 | + | // swipeDropped ignores the rest of a swipe whose workspace changed. | |||
| 40 | + | swipeDropped | |||
| 41 | + | ) | |||
| 42 | + | ||||
| 43 | + | // swipeGesture is the swipe in progress, on the screen focused when it began. | |||
| 44 | + | type swipeGesture struct { | |||
| 45 | + | screen *screen | |||
| 46 | + | mode swipeMode | |||
| 47 | + | horizontal bool | |||
| 48 | + | cx, cy float64 | |||
| 49 | + | // tracker adds up the movement along the chosen axis, natural scroll | |||
| 50 | + | // applied: positive scrolls right or down. | |||
| 51 | + | tracker swipeTracker | |||
| 52 | + | // ws is the workspace whose columns scroll, or the active one when a | |||
| 53 | + | // workspace slide began; start is where the view was when the swipe | |||
| 54 | + | // took over: its x in pixels (columns) or the monitor's fractional | |||
| 55 | + | // workspace index (workspaces). Both are absolute, so a focus change | |||
| 56 | + | // during the swipe does not move the fingers' view. | |||
| 57 | + | ws *Workspace | |||
| 58 | + | start float64 | |||
| 59 | + | // list is the numbered workspaces when a workspace slide began: start | |||
| 60 | + | // indexes it, so the slide ends if the list changes. | |||
| 61 | + | list []*Workspace | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // listChanged reports whether the numbered workspaces or the active one | |||
| 65 | + | // changed since a workspace slide began. | |||
| 66 | + | func (g *swipeGesture) listChanged(m *Monitor) bool { | |||
| 67 | + | return m.shown != nil || m.Workspaces[m.Active] != g.ws || !slices.Equal(m.Workspaces, g.list) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // swipeSign turns finger movement into view movement: natural scroll moves | |||
| 71 | + | // the content with the fingers. | |||
| 72 | + | func (c *Core) swipeSign() float64 { | |||
| 73 | + | if c.cfg.Touchpad.NaturalScroll { | |||
| 74 | + | return -1 | |||
| 75 | + | } | |||
| 76 | + | return 1 | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // swipeBegin starts a swipe. Input streams one swipe at a time; one still | |||
| 80 | + | // running (its end was lost) ends cancelled first: its slide settles back. | |||
| 81 | + | func (c *Core) swipeBegin(at time.Duration) bool { | |||
| 82 | + | changed := false | |||
| 83 | + | if c.swipe != nil { | |||
| 84 | + | c.swipeEnd(ports.SwipeEnd{Cancelled: true, Time: at}) | |||
| 85 | + | changed = true | |||
| 86 | + | } | |||
| 87 | + | c.swipe = &swipeGesture{screen: c.cur()} | |||
| 88 | + | return changed | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // swipeUpdate moves the swipe; it reports whether the scene changed. | |||
| 92 | + | func (c *Core) swipeUpdate(u ports.SwipeUpdate) bool { | |||
| 93 | + | g := c.swipe | |||
| 94 | + | if g == nil || !c.hasScreen(g.screen) { | |||
| 95 | + | c.swipe = nil | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | if g.mode == swipeUndecided { | |||
| 99 | + | g.cx += u.DX | |||
| 100 | + | g.cy += u.DY | |||
| 101 | + | if g.cx*g.cx+g.cy*g.cy < swipeDecide*swipeDecide { | |||
| 102 | + | return false | |||
| 103 | + | } | |||
| 104 | + | c.decide(g) | |||
| 105 | + | } | |||
| 106 | + | d := u.DY | |||
| 107 | + | if g.horizontal { | |||
| 108 | + | d = u.DX | |||
| 109 | + | } | |||
| 110 | + | g.tracker.push(d*c.swipeSign(), u.Time) | |||
| 111 | + | m := g.screen.mon | |||
| 112 | + | switch g.mode { | |||
| 113 | + | case swipeColumns: | |||
| 114 | + | if m.Current() != g.ws || !g.ws.slidable() { | |||
| 115 | + | g.ws.stopSlide() | |||
| 116 | + | g.mode = swipeDropped | |||
| 117 | + | return true | |||
| 118 | + | } | |||
| 119 | + | g.ws.shift = g.start + g.tracker.pos*g.ws.swipeScale() - float64(g.ws.ViewX) | |||
| 120 | + | return true | |||
| 121 | + | case swipeWorkspaces: | |||
| 122 | + | if g.listChanged(m) { | |||
| 123 | + | m.stopSwitch() | |||
| 124 | + | g.mode = swipeDropped | |||
| 125 | + | return true | |||
| 126 | + | } | |||
| 127 | + | f := workspaceBand.clamp(0, float64(len(m.Workspaces)-1), g.start+g.tracker.pos/workspaceSwipeMovement) | |||
| 128 | + | m.switchOff = f - float64(m.Active) | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // decide picks the swipe's axis and what it moves. A running slide stops | |||
| 135 | + | // where it is: the fingers take it from there. | |||
| 136 | + | func (c *Core) decide(g *swipeGesture) { | |||
| 137 | + | g.horizontal = math.Abs(g.cx) > math.Abs(g.cy) | |||
| 138 | + | m := g.screen.mon | |||
| 139 | + | w := m.Current() | |||
| 140 | + | switch { | |||
| 141 | + | case g.horizontal && w.slidable(): | |||
| 142 | + | g.mode, g.ws = swipeColumns, w | |||
| 143 | + | w.motion = nil | |||
| 144 | + | g.start = float64(w.ViewX) + w.shift | |||
| 145 | + | case !g.horizontal && m.shown == nil: | |||
| 146 | + | // A landing slide measured in an older list lands at once first. | |||
| 147 | + | if m.switchList != nil && !slices.Equal(m.switchList, m.Workspaces) { | |||
| 148 | + | m.stopSwitch() | |||
| 149 | + | } | |||
| 150 | + | g.mode, g.ws = swipeWorkspaces, m.Workspaces[m.Active] | |||
| 151 | + | g.list = slices.Clone(m.Workspaces) | |||
| 152 | + | m.switchMotion, m.switchList = nil, nil | |||
| 153 | + | g.start = float64(m.Active) + m.switchOff | |||
| 154 | + | default: | |||
| 155 | + | g.mode = swipeDiscrete | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // swipeEnd lands the swipe. shown reports that another workspace came on | |||
| 160 | + | // screen. | |||
| 161 | + | func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) { | |||
| 162 | + | g := c.swipe | |||
| 163 | + | c.swipe = nil | |||
| 164 | + | if g == nil || !c.hasScreen(g.screen) { | |||
| 165 | + | return false | |||
| 166 | + | } | |||
| 167 | + | // Idle time before the lift slows the swipe down. | |||
| 168 | + | g.tracker.push(0, e.Time) | |||
| 169 | + | now := c.now() | |||
| 170 | + | m := g.screen.mon | |||
| 171 | + | switch g.mode { | |||
| 172 | + | case swipeColumns: | |||
| 173 | + | w := g.ws | |||
| 174 | + | if m.Current() != w || !w.slidable() { | |||
| 175 | + | w.stopSlide() | |||
| 176 | + | return false | |||
| 177 | + | } | |||
| 178 | + | scale := w.swipeScale() | |||
| 179 | + | shown := float64(w.ViewX) + w.shift | |||
| 180 | + | if !e.Cancelled { | |||
| 181 | + | target := g.start + g.tracker.projectedEnd()*scale | |||
| 182 | + | w.ViewX, w.Focus = w.snap(target, target >= shown) | |||
| 183 | + | // A column wider than the view aligns as focus moves align it. | |||
| 184 | + | w.scroll() | |||
| 185 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |||
| 186 | + | c.layerFocus = 0 | |||
| 187 | + | } | |||
| 188 | + | w.shift = shown - float64(w.ViewX) | |||
| 189 | + | w.motion = newMotion(viewSpring(w.shift, g.tracker.velocity()*scale), now) | |||
| 190 | + | case swipeWorkspaces: | |||
| 191 | + | if g.listChanged(m) { | |||
| 192 | + | m.stopSwitch() | |||
| 193 | + | return false | |||
| 194 | + | } | |||
| 195 | + | last := float64(len(m.Workspaces) - 1) | |||
| 196 | + | cur := float64(m.Active) + m.switchOff | |||
| 197 | + | velocity := g.tracker.velocity() / workspaceSwipeMovement * workspaceBand.clampDerivative(0, last, g.start+g.tracker.pos/workspaceSwipeMovement) | |||
| 198 | + | idx := m.Active | |||
| 199 | + | if !e.Cancelled { | |||
| 200 | + | idx = int(math.Round(min(max(g.start+g.tracker.projectedEnd()/workspaceSwipeMovement, 0), last))) | |||
| 201 | + | } | |||
| 202 | + | // Focus may drop the empty workspace left behind and renumber the | |||
| 203 | + | // list: the slide keeps measuring in the list it began on. | |||
| 204 | + | off := cur - float64(idx) | |||
| 205 | + | if idx != m.Active { | |||
| 206 | + | m.Focus(idx) | |||
| 207 | + | shown = true | |||
| 208 | + | } | |||
| 209 | + | if !e.Cancelled { | |||
| 210 | + | c.focusScreen = c.screenIndex(g.screen.name()) | |||
| 211 | + | c.layerFocus = 0 | |||
| 212 | + | } | |||
| 213 | + | m.switchOff, m.switchList = off, g.list | |||
| 214 | + | m.switchMotion = newMotion(workspaceSpring(off, velocity), now) | |||
| 215 | + | case swipeDiscrete: | |||
| 216 | + | p := g.tracker.projectedEnd() | |||
| 217 | + | if e.Cancelled || math.Abs(p) < discreteSwipeMin { | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | a := ActionFocusWorkspaceDown | |||
| 221 | + | switch { | |||
| 222 | + | case g.horizontal && p < 0: | |||
| 223 | + | a = ActionFocusColumnLeft | |||
| 224 | + | case g.horizontal: | |||
| 225 | + | a = ActionFocusColumnRight | |||
| 226 | + | case p < 0: | |||
| 227 | + | a = ActionFocusWorkspaceUp | |||
| 228 | + | } | |||
| 229 | + | // Focus actions act on the focused screen: the swipe's, unless the | |||
| 230 | + | // pointer took the focus to another output meanwhile. | |||
| 231 | + | if c.cur() != g.screen { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | before := c.cur().mon.Current() | |||
| 235 | + | c.layerFocus = 0 | |||
| 236 | + | c.applyAction(a) | |||
| 237 | + | return c.cur().mon.Current() != before | |||
| 238 | + | } | |||
| 239 | + | return shown | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // swipedWorkspace is the workspace on the swipe's screen, or nil without | |||
| 243 | + | // a swipe. | |||
| 244 | + | func (c *Core) swipedWorkspace() *Workspace { | |||
| 245 | + | if c.swipe == nil { | |||
| 246 | + | return nil | |||
| 247 | + | } | |||
| 248 | + | return c.swipe.screen.mon.Current() | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // dropSwipe lets go of the swipe in progress: a bind changed the workspace | |||
| 252 | + | // on its screen. Its slide stops where the bind left the view; the rest of | |||
| 253 | + | // the swipe is ignored, even if a later bind shows its workspace again. | |||
| 254 | + | func (c *Core) dropSwipe() { | |||
| 255 | + | g := c.swipe | |||
| 256 | + | if g == nil { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | switch g.mode { | |||
| 260 | + | case swipeColumns: | |||
| 261 | + | g.ws.stopSlide() | |||
| 262 | + | case swipeWorkspaces: | |||
| 263 | + | g.screen.mon.stopSwitch() | |||
| 264 | + | } | |||
| 265 | + | g.mode = swipeDropped | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func (c *Core) hasScreen(s *screen) bool { | |||
| 269 | + | for _, v := range c.screens { | |||
| 270 | + | if v == s { | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | return false | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | // animate moves the running springs to now; settled ones stop. | |||
| 278 | + | func (c *Core) animate(now time.Time) { | |||
| 279 | + | for _, sc := range c.screens { | |||
| 280 | + | m := sc.mon | |||
| 281 | + | if m.switchMotion != nil { | |||
| 282 | + | v, done := m.switchMotion.at(now) | |||
| 283 | + | m.switchOff = v | |||
| 284 | + | if done { | |||
| 285 | + | m.stopSwitch() | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | for _, w := range m.all() { | |||
| 289 | + | if w.motion != nil { | |||
| 290 | + | v, done := w.motion.at(now) | |||
| 291 | + | w.shift = v | |||
| 292 | + | if done { | |||
| 293 | + | w.stopSlide() | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | // sliding reports whether a spring runs on the named output. | |||
| 301 | + | func (c *Core) sliding(output string) bool { | |||
| 302 | + | i := c.screenIndex(output) | |||
| 303 | + | return i >= 0 && c.screens[i].mon.springing() | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | // animating reports whether a spring runs on any output. | |||
| 307 | + | func (c *Core) animating() bool { | |||
| 308 | + | for _, sc := range c.screens { | |||
| 309 | + | if sc.mon.springing() { | |||
| 310 | + | return true | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | return false | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | func (m *Monitor) springing() bool { | |||
| 317 | + | if m.switchMotion != nil { | |||
| 318 | + | return true | |||
| 319 | + | } | |||
| 320 | + | for _, w := range m.all() { | |||
| 321 | + | if w.motion != nil { | |||
| 322 | + | return true | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | return false | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // frameFallback is how long core waits for a page flip before it moves a | |||
| 329 | + | // running slide on its own (an output that stopped flipping). | |||
| 330 | + | func (c *Core) frameFallback() time.Duration { | |||
| 331 | + | refresh := 60000 | |||
| 332 | + | for _, sc := range c.screens { | |||
| 333 | + | if r := sc.info.RefreshMilli; r > 0 && sc.mon.springing() { | |||
| 334 | + | refresh = min(refresh, r) | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | return 2 * time.Second * 1000 / time.Duration(refresh) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // armFrame starts the fallback timer of a running slide. | |||
| 341 | + | func (c *Core) armFrame() { | |||
| 342 | + | c.stopFrame() | |||
| 343 | + | d := c.frameFallback() | |||
| 344 | + | if c.ch.Clock != nil { | |||
| 345 | + | t := c.ch.Clock.NewTimer(d) | |||
| 346 | + | c.frameC, c.frameStop = t.C(), t.Stop | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | t := time.NewTimer(d) | |||
| 350 | + | c.frameC, c.frameStop = t.C, t.Stop | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func (c *Core) stopFrame() { | |||
| 354 | + | if c.frameStop != nil { | |||
| 355 | + | c.frameStop() | |||
| 356 | + | } | |||
| 357 | + | c.frameC, c.frameStop = nil, nil | |||
| 358 | + | } | |||
M internal/core/layout.go +13 -6
| @@ -121,11 +121,15 @@ type Workspace struct { | |||
| 121 | 121 | // ID stays with the workspace across reorder and monitor moves. | |
| 122 | 122 | ID uint64 | |
| 123 | 123 | // Name is set for workspaces declared in config; empty for dynamic ones. | |
| 124 | - | Name string | |
| 125 | - | Overflow Overflow | |
| 126 | - | Columns []Column | |
| 127 | - | Focus int | |
| 128 | - | ViewX int | |
| 124 | + | Name string | |
| 125 | + | Overflow Overflow | |
| 126 | + | Columns []Column | |
| 127 | + | Focus int | |
| 128 | + | ViewX int | |
| 129 | + | // shift slides the columns on screen past ViewX, in logical pixels, | |
| 130 | + | // while a swipe follows the fingers or its spring (motion) lands. | |
| 131 | + | shift float64 | |
| 132 | + | motion *motion | |
| 129 | 133 | Output Rect | |
| 130 | 134 | Usable Rect | |
| 131 | 135 | Gaps int | |
| @@ -839,6 +843,8 @@ func (w *Workspace) columnX(i int) int { | |||
| 839 | 843 | return x | |
| 840 | 844 | } | |
| 841 | 845 | func (w *Workspace) scroll() { | |
| 846 | + | before := w.ViewX | |
| 847 | + | defer func() { w.retarget(before) }() | |
| 842 | 848 | if w.Overflow == OverflowFixed { | |
| 843 | 849 | w.ViewX = 0 | |
| 844 | 850 | return | |
| @@ -866,8 +872,9 @@ func (w *Workspace) columnRects() []Rect { | |||
| 866 | 872 | g := w.gap() | |
| 867 | 873 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | |
| 868 | 874 | rects := make([]Rect, len(w.Columns)) | |
| 875 | + | view := w.ViewX + w.shiftPixels() | |
| 869 | 876 | for i := range w.Columns { | |
| 870 | - | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | |
| 877 | + | rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h} | |
| 871 | 878 | } | |
| 872 | 879 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | |
| 873 | 880 | rects[w.Focus].X = w.Usable.X + g | |
| @@ -121,11 +121,15 @@ type Workspace struct { | |||||
| 121 | // ID stays with the workspace across reorder and monitor moves. | 121 | // ID stays with the workspace across reorder and monitor moves. | ||
| 122 | ID uint64 | 122 | ID uint64 | ||
| 123 | // Name is set for workspaces declared in config; empty for dynamic ones. | 123 | // Name is set for workspaces declared in config; empty for dynamic ones. | ||
| 124 | - | Name string | 124 | + | Name string |
| 125 | - | Overflow Overflow | 125 | + | Overflow Overflow |
| 126 | - | Columns []Column | 126 | + | Columns []Column |
| 127 | - | Focus int | 127 | + | Focus int |
| 128 | - | ViewX int | 128 | + | ViewX int |
| 129 | + | // shift slides the columns on screen past ViewX, in logical pixels, | |||
| 130 | + | // while a swipe follows the fingers or its spring (motion) lands. | |||
| 131 | + | shift float64 | |||
| 132 | + | motion *motion | |||
| 129 | Output Rect | 133 | Output Rect | ||
| 130 | Usable Rect | 134 | Usable Rect | ||
| 131 | Gaps int | 135 | Gaps int | ||
| @@ -839,6 +843,8 @@ func (w *Workspace) columnX(i int) int { | |||||
| 839 | return x | 843 | return x | ||
| 840 | } | 844 | } | ||
| 841 | func (w *Workspace) scroll() { | 845 | func (w *Workspace) scroll() { | ||
| 846 | + | before := w.ViewX | |||
| 847 | + | defer func() { w.retarget(before) }() | |||
| 842 | if w.Overflow == OverflowFixed { | 848 | if w.Overflow == OverflowFixed { | ||
| 843 | w.ViewX = 0 | 849 | w.ViewX = 0 | ||
| 844 | return | 850 | return | ||
| @@ -866,8 +872,9 @@ func (w *Workspace) columnRects() []Rect { | |||||
| 866 | g := w.gap() | 872 | g := w.gap() | ||
| 867 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | 873 | y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0) | ||
| 868 | rects := make([]Rect, len(w.Columns)) | 874 | rects := make([]Rect, len(w.Columns)) | ||
| 875 | + | view := w.ViewX + w.shiftPixels() | |||
| 869 | for i := range w.Columns { | 876 | for i := range w.Columns { | ||
| 870 | - | rects[i] = Rect{X: w.columnX(i) - w.ViewX, Y: y, W: w.columnWidth(i), H: h} | 877 | + | rects[i] = Rect{X: w.columnX(i) - view, Y: y, W: w.columnWidth(i), H: h} |
| 871 | } | 878 | } | ||
| 872 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | 879 | if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth { | ||
| 873 | rects[w.Focus].X = w.Usable.X + g | 880 | rects[w.Focus].X = w.Usable.X + g | ||
M internal/core/monitor.go +12 -1
| @@ -25,6 +25,15 @@ type Monitor struct { | |||
| 25 | 25 | named []NamedWorkspace | |
| 26 | 26 | // followMove shows the target workspace after a move to it. | |
| 27 | 27 | followMove bool | |
| 28 | + | // switchOff slides the view between numbered workspaces: the view is | |
| 29 | + | // at Active+switchOff while a swipe follows the fingers or its spring | |
| 30 | + | // (switchMotion) lands. | |
| 31 | + | switchOff float64 | |
| 32 | + | switchMotion *motion | |
| 33 | + | // switchList is the numbered list a landing slide measures from (the | |
| 34 | + | // one its swipe began on); switchOff is then from the current | |
| 35 | + | // workspace's place in it. | |
| 36 | + | switchList []*Workspace | |
| 28 | 37 | } | |
| 29 | 38 | ||
| 30 | 39 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | |
| @@ -156,6 +165,7 @@ func (m *Monitor) normalize() { | |||
| 156 | 165 | // Focus switches to the numbered workspace at index i, clamped to the list. | |
| 157 | 166 | func (m *Monitor) Focus(i int) { | |
| 158 | 167 | i = min(max(i, 0), len(m.Workspaces)-1) | |
| 168 | + | m.stopSwitch() | |
| 159 | 169 | m.shown = nil | |
| 160 | 170 | m.Active = i | |
| 161 | 171 | m.normalize() | |
| @@ -170,6 +180,7 @@ func (m *Monitor) show(w *Workspace) { | |||
| 170 | 180 | m.Focus(i) | |
| 171 | 181 | return | |
| 172 | 182 | } | |
| 183 | + | m.stopSwitch() | |
| 173 | 184 | m.shown = w | |
| 174 | 185 | m.normalize() | |
| 175 | 186 | } | |
| @@ -626,7 +637,7 @@ func (m *Monitor) Layout() []Placement { | |||
| 626 | 637 | result = append(result, Placement{ID: id, Hidden: true}) | |
| 627 | 638 | } | |
| 628 | 639 | } | |
| 629 | - | return result | |
| 640 | + | return m.slideLayout(result) | |
| 630 | 641 | } | |
| 631 | 642 | ||
| 632 | 643 | func (m *Monitor) Output() Rect { return m.template.Output } | |
| @@ -25,6 +25,15 @@ type Monitor struct { | |||||
| 25 | named []NamedWorkspace | 25 | named []NamedWorkspace | ||
| 26 | // followMove shows the target workspace after a move to it. | 26 | // followMove shows the target workspace after a move to it. | ||
| 27 | followMove bool | 27 | followMove bool | ||
| 28 | + | // switchOff slides the view between numbered workspaces: the view is | |||
| 29 | + | // at Active+switchOff while a swipe follows the fingers or its spring | |||
| 30 | + | // (switchMotion) lands. | |||
| 31 | + | switchOff float64 | |||
| 32 | + | switchMotion *motion | |||
| 33 | + | // switchList is the numbered list a landing slide measures from (the | |||
| 34 | + | // one its swipe began on); switchOff is then from the current | |||
| 35 | + | // workspace's place in it. | |||
| 36 | + | switchList []*Workspace | |||
| 28 | } | 37 | } | ||
| 29 | 38 | ||||
| 30 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | 39 | // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty | ||
| @@ -156,6 +165,7 @@ func (m *Monitor) normalize() { | |||||
| 156 | // Focus switches to the numbered workspace at index i, clamped to the list. | 165 | // Focus switches to the numbered workspace at index i, clamped to the list. | ||
| 157 | func (m *Monitor) Focus(i int) { | 166 | func (m *Monitor) Focus(i int) { | ||
| 158 | i = min(max(i, 0), len(m.Workspaces)-1) | 167 | i = min(max(i, 0), len(m.Workspaces)-1) | ||
| 168 | + | m.stopSwitch() | |||
| 159 | m.shown = nil | 169 | m.shown = nil | ||
| 160 | m.Active = i | 170 | m.Active = i | ||
| 161 | m.normalize() | 171 | m.normalize() | ||
| @@ -170,6 +180,7 @@ func (m *Monitor) show(w *Workspace) { | |||||
| 170 | m.Focus(i) | 180 | m.Focus(i) | ||
| 171 | return | 181 | return | ||
| 172 | } | 182 | } | ||
| 183 | + | m.stopSwitch() | |||
| 173 | m.shown = w | 184 | m.shown = w | ||
| 174 | m.normalize() | 185 | m.normalize() | ||
| 175 | } | 186 | } | ||
| @@ -626,7 +637,7 @@ func (m *Monitor) Layout() []Placement { | |||||
| 626 | result = append(result, Placement{ID: id, Hidden: true}) | 637 | result = append(result, Placement{ID: id, Hidden: true}) | ||
| 627 | } | 638 | } | ||
| 628 | } | 639 | } | ||
| 629 | - | return result | 640 | + | return m.slideLayout(result) |
| 630 | } | 641 | } | ||
| 631 | 642 | ||||
| 632 | func (m *Monitor) Output() Rect { return m.template.Output } | 643 | func (m *Monitor) Output() Rect { return m.template.Output } | ||
A internal/core/motion.go +209 -0
| @@ -0,0 +1,209 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Touchpad motion follows GNOME Shell and niri: a swipe tracker measures the | |
| 9 | + | // fingers' speed, the gesture lands where that speed would decelerate to a | |
| 10 | + | // halt, and a spring carries the view there from where the fingers left it. | |
| 11 | + | ||
| 12 | + | // trackerHistory is how far back the swipe speed is measured. | |
| 13 | + | const trackerHistory = 150 * time.Millisecond | |
| 14 | + | ||
| 15 | + | // touchpadDeceleration is the per-millisecond speed ratio of a swipe left | |
| 16 | + | // to decelerate after the fingers lift. | |
| 17 | + | const touchpadDeceleration = 0.997 | |
| 18 | + | ||
| 19 | + | type trackerEvent struct { | |
| 20 | + | delta float64 | |
| 21 | + | at time.Duration | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // swipeTracker adds up one swipe axis. Times are event timestamps (the | |
| 25 | + | // device clock), so the speed does not depend on when events are read. | |
| 26 | + | type swipeTracker struct { | |
| 27 | + | history []trackerEvent | |
| 28 | + | pos float64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // push adds a movement. An event older than the last one is ignored. | |
| 32 | + | func (t *swipeTracker) push(delta float64, at time.Duration) { | |
| 33 | + | if n := len(t.history); n > 0 && at < t.history[n-1].at { | |
| 34 | + | return | |
| 35 | + | } | |
| 36 | + | t.history = append(t.history, trackerEvent{delta: delta, at: at}) | |
| 37 | + | t.pos += delta | |
| 38 | + | i := 0 | |
| 39 | + | for i < len(t.history) && at > t.history[i].at+trackerHistory { | |
| 40 | + | i++ | |
| 41 | + | } | |
| 42 | + | t.history = t.history[i:] | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // velocity is the speed over the recent history, in units per second. | |
| 46 | + | func (t *swipeTracker) velocity() float64 { | |
| 47 | + | if len(t.history) < 2 { | |
| 48 | + | return 0 | |
| 49 | + | } | |
| 50 | + | span := (t.history[len(t.history)-1].at - t.history[0].at).Seconds() | |
| 51 | + | if span <= 0 { | |
| 52 | + | return 0 | |
| 53 | + | } | |
| 54 | + | sum := 0.0 | |
| 55 | + | for _, e := range t.history { | |
| 56 | + | sum += e.delta | |
| 57 | + | } | |
| 58 | + | return sum / span | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // projectedEnd is where the swipe stops once its speed decelerates. | |
| 62 | + | func (t *swipeTracker) projectedEnd() float64 { | |
| 63 | + | return t.pos - t.velocity()/(1000*math.Log(touchpadDeceleration)) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // spring is a damped spring from From to To (libadwaita's model, as in | |
| 67 | + | // niri). Velocity is the initial speed in units per second. | |
| 68 | + | type spring struct { | |
| 69 | + | From, To, Velocity float64 | |
| 70 | + | DampingRatio float64 | |
| 71 | + | Stiffness float64 | |
| 72 | + | Epsilon float64 | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // viewSpring moves a scrolled view; workspaceSpring switches workspaces. | |
| 76 | + | // Both are critically damped: fast, with no overshoot. | |
| 77 | + | func viewSpring(from, velocity float64) spring { | |
| 78 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func workspaceSpring(from, velocity float64) spring { | |
| 82 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001} | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // f32Epsilon compares damping terms: float64's epsilon is too small for | |
| 86 | + | // them (libadwaita, niri). | |
| 87 | + | const f32Epsilon = 1.1920929e-07 | |
| 88 | + | ||
| 89 | + | func (s spring) coefficients() (beta, omega0 float64) { | |
| 90 | + | const mass = 1.0 | |
| 91 | + | k := max(s.Stiffness, 0) | |
| 92 | + | damping := max(s.DampingRatio, 0) * 2 * math.Sqrt(mass*k) | |
| 93 | + | return damping / (2 * mass), math.Sqrt(k / mass) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // valueAt is the spring position t after it started. | |
| 97 | + | func (s spring) valueAt(t time.Duration) float64 { | |
| 98 | + | sec := t.Seconds() | |
| 99 | + | beta, omega0 := s.coefficients() | |
| 100 | + | x0 := s.From - s.To | |
| 101 | + | envelope := math.Exp(-beta * sec) | |
| 102 | + | switch { | |
| 103 | + | case math.Abs(beta-omega0) <= f32Epsilon: | |
| 104 | + | // Critically damped. | |
| 105 | + | return s.To + envelope*(x0+(beta*x0+s.Velocity)*sec) | |
| 106 | + | case beta < omega0: | |
| 107 | + | w := math.Sqrt(omega0*omega0 - beta*beta) | |
| 108 | + | return s.To + envelope*(x0*math.Cos(w*sec)+((beta*x0+s.Velocity)/w)*math.Sin(w*sec)) | |
| 109 | + | default: | |
| 110 | + | w := math.Sqrt(beta*beta - omega0*omega0) | |
| 111 | + | return s.To + envelope*(x0*math.Cosh(w*sec)+((beta*x0+s.Velocity)/w)*math.Sinh(w*sec)) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // duration is how long the spring takes to settle within Epsilon. | |
| 116 | + | func (s spring) duration() time.Duration { | |
| 117 | + | const step = 0.001 | |
| 118 | + | beta, omega0 := s.coefficients() | |
| 119 | + | if beta <= 0 { | |
| 120 | + | return time.Duration(math.MaxInt64) | |
| 121 | + | } | |
| 122 | + | if math.Abs(s.To-s.From) <= math.SmallestNonzeroFloat64 && s.Velocity == 0 { | |
| 123 | + | return 0 | |
| 124 | + | } | |
| 125 | + | x0 := -math.Log(max(s.Epsilon, math.SmallestNonzeroFloat64)) / beta | |
| 126 | + | if math.Abs(beta-omega0) <= f32Epsilon || beta < omega0 { | |
| 127 | + | return seconds(x0) | |
| 128 | + | } | |
| 129 | + | // Overdamped: the envelope decays faster than the motion; find where | |
| 130 | + | // the motion itself settles (Newton's method). | |
| 131 | + | at := func(sec float64) float64 { return s.valueAt(seconds(sec)) } | |
| 132 | + | y0 := at(x0) | |
| 133 | + | m := (at(x0+step) - y0) / step | |
| 134 | + | x1 := (s.To - y0 + m*x0) / m | |
| 135 | + | y1 := at(x1) | |
| 136 | + | for i := 0; math.Abs(s.To-y1) > s.Epsilon; i++ { | |
| 137 | + | if i > 1000 { | |
| 138 | + | return 0 | |
| 139 | + | } | |
| 140 | + | x0, y0 = x1, y1 | |
| 141 | + | m = (at(x0+step) - y0) / step | |
| 142 | + | x1 = (s.To - y0 + m*x0) / m | |
| 143 | + | y1 = at(x1) | |
| 144 | + | if math.IsNaN(y1) || math.IsInf(y1, 0) { | |
| 145 | + | return seconds(x0) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | return seconds(x1) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } | |
| 152 | + | ||
| 153 | + | // motion is a spring started at a given time; a zero start begins at the | |
| 154 | + | // first frame. | |
| 155 | + | type motion struct { | |
| 156 | + | spring spring | |
| 157 | + | start time.Time | |
| 158 | + | end time.Duration | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func newMotion(s spring, now time.Time) *motion { | |
| 162 | + | return &motion{spring: s, start: now, end: s.duration()} | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // at is the position at now and whether the motion has settled. | |
| 166 | + | func (m *motion) at(now time.Time) (float64, bool) { | |
| 167 | + | if m.start.IsZero() { | |
| 168 | + | m.start = now | |
| 169 | + | } | |
| 170 | + | t := now.Sub(m.start) | |
| 171 | + | if t >= m.end { | |
| 172 | + | return m.spring.To, true | |
| 173 | + | } | |
| 174 | + | return m.spring.valueAt(max(t, 0)), false | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // rubberBand resists movement past the edges: the further out, the less | |
| 178 | + | // it moves, never more than limit. | |
| 179 | + | type rubberBand struct{ stiffness, limit float64 } | |
| 180 | + | ||
| 181 | + | // workspaceBand holds a workspace swipe past the first or last workspace. | |
| 182 | + | var workspaceBand = rubberBand{stiffness: 0.5, limit: 0.05} | |
| 183 | + | ||
| 184 | + | func (r rubberBand) band(x float64) float64 { | |
| 185 | + | return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (r rubberBand) derivative(x float64) float64 { | |
| 189 | + | d := r.stiffness*x + r.limit | |
| 190 | + | return r.stiffness * r.limit * r.limit / (d * d) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // clamp keeps x in [lo, hi], letting it stretch past the edges. | |
| 194 | + | func (r rubberBand) clamp(lo, hi, x float64) float64 { | |
| 195 | + | c := min(max(x, lo), hi) | |
| 196 | + | if x < c { | |
| 197 | + | return c - r.band(c-x) | |
| 198 | + | } | |
| 199 | + | return c + r.band(x-c) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // clampDerivative is the slope of clamp at x: 1 inside the edges. | |
| 203 | + | func (r rubberBand) clampDerivative(lo, hi, x float64) float64 { | |
| 204 | + | if lo <= x && x <= hi { | |
| 205 | + | return 1 | |
| 206 | + | } | |
| 207 | + | c := min(max(x, lo), hi) | |
| 208 | + | return r.derivative(math.Abs(x - c)) | |
| 209 | + | } | |
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Touchpad motion follows GNOME Shell and niri: a swipe tracker measures the | |||
| 9 | + | // fingers' speed, the gesture lands where that speed would decelerate to a | |||
| 10 | + | // halt, and a spring carries the view there from where the fingers left it. | |||
| 11 | + | ||||
| 12 | + | // trackerHistory is how far back the swipe speed is measured. | |||
| 13 | + | const trackerHistory = 150 * time.Millisecond | |||
| 14 | + | ||||
| 15 | + | // touchpadDeceleration is the per-millisecond speed ratio of a swipe left | |||
| 16 | + | // to decelerate after the fingers lift. | |||
| 17 | + | const touchpadDeceleration = 0.997 | |||
| 18 | + | ||||
| 19 | + | type trackerEvent struct { | |||
| 20 | + | delta float64 | |||
| 21 | + | at time.Duration | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // swipeTracker adds up one swipe axis. Times are event timestamps (the | |||
| 25 | + | // device clock), so the speed does not depend on when events are read. | |||
| 26 | + | type swipeTracker struct { | |||
| 27 | + | history []trackerEvent | |||
| 28 | + | pos float64 | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // push adds a movement. An event older than the last one is ignored. | |||
| 32 | + | func (t *swipeTracker) push(delta float64, at time.Duration) { | |||
| 33 | + | if n := len(t.history); n > 0 && at < t.history[n-1].at { | |||
| 34 | + | return | |||
| 35 | + | } | |||
| 36 | + | t.history = append(t.history, trackerEvent{delta: delta, at: at}) | |||
| 37 | + | t.pos += delta | |||
| 38 | + | i := 0 | |||
| 39 | + | for i < len(t.history) && at > t.history[i].at+trackerHistory { | |||
| 40 | + | i++ | |||
| 41 | + | } | |||
| 42 | + | t.history = t.history[i:] | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // velocity is the speed over the recent history, in units per second. | |||
| 46 | + | func (t *swipeTracker) velocity() float64 { | |||
| 47 | + | if len(t.history) < 2 { | |||
| 48 | + | return 0 | |||
| 49 | + | } | |||
| 50 | + | span := (t.history[len(t.history)-1].at - t.history[0].at).Seconds() | |||
| 51 | + | if span <= 0 { | |||
| 52 | + | return 0 | |||
| 53 | + | } | |||
| 54 | + | sum := 0.0 | |||
| 55 | + | for _, e := range t.history { | |||
| 56 | + | sum += e.delta | |||
| 57 | + | } | |||
| 58 | + | return sum / span | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // projectedEnd is where the swipe stops once its speed decelerates. | |||
| 62 | + | func (t *swipeTracker) projectedEnd() float64 { | |||
| 63 | + | return t.pos - t.velocity()/(1000*math.Log(touchpadDeceleration)) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // spring is a damped spring from From to To (libadwaita's model, as in | |||
| 67 | + | // niri). Velocity is the initial speed in units per second. | |||
| 68 | + | type spring struct { | |||
| 69 | + | From, To, Velocity float64 | |||
| 70 | + | DampingRatio float64 | |||
| 71 | + | Stiffness float64 | |||
| 72 | + | Epsilon float64 | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // viewSpring moves a scrolled view; workspaceSpring switches workspaces. | |||
| 76 | + | // Both are critically damped: fast, with no overshoot. | |||
| 77 | + | func viewSpring(from, velocity float64) spring { | |||
| 78 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 800, Epsilon: 0.0001} | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func workspaceSpring(from, velocity float64) spring { | |||
| 82 | + | return spring{From: from, Velocity: velocity, DampingRatio: 1, Stiffness: 1000, Epsilon: 0.0001} | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // f32Epsilon compares damping terms: float64's epsilon is too small for | |||
| 86 | + | // them (libadwaita, niri). | |||
| 87 | + | const f32Epsilon = 1.1920929e-07 | |||
| 88 | + | ||||
| 89 | + | func (s spring) coefficients() (beta, omega0 float64) { | |||
| 90 | + | const mass = 1.0 | |||
| 91 | + | k := max(s.Stiffness, 0) | |||
| 92 | + | damping := max(s.DampingRatio, 0) * 2 * math.Sqrt(mass*k) | |||
| 93 | + | return damping / (2 * mass), math.Sqrt(k / mass) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // valueAt is the spring position t after it started. | |||
| 97 | + | func (s spring) valueAt(t time.Duration) float64 { | |||
| 98 | + | sec := t.Seconds() | |||
| 99 | + | beta, omega0 := s.coefficients() | |||
| 100 | + | x0 := s.From - s.To | |||
| 101 | + | envelope := math.Exp(-beta * sec) | |||
| 102 | + | switch { | |||
| 103 | + | case math.Abs(beta-omega0) <= f32Epsilon: | |||
| 104 | + | // Critically damped. | |||
| 105 | + | return s.To + envelope*(x0+(beta*x0+s.Velocity)*sec) | |||
| 106 | + | case beta < omega0: | |||
| 107 | + | w := math.Sqrt(omega0*omega0 - beta*beta) | |||
| 108 | + | return s.To + envelope*(x0*math.Cos(w*sec)+((beta*x0+s.Velocity)/w)*math.Sin(w*sec)) | |||
| 109 | + | default: | |||
| 110 | + | w := math.Sqrt(beta*beta - omega0*omega0) | |||
| 111 | + | return s.To + envelope*(x0*math.Cosh(w*sec)+((beta*x0+s.Velocity)/w)*math.Sinh(w*sec)) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // duration is how long the spring takes to settle within Epsilon. | |||
| 116 | + | func (s spring) duration() time.Duration { | |||
| 117 | + | const step = 0.001 | |||
| 118 | + | beta, omega0 := s.coefficients() | |||
| 119 | + | if beta <= 0 { | |||
| 120 | + | return time.Duration(math.MaxInt64) | |||
| 121 | + | } | |||
| 122 | + | if math.Abs(s.To-s.From) <= math.SmallestNonzeroFloat64 && s.Velocity == 0 { | |||
| 123 | + | return 0 | |||
| 124 | + | } | |||
| 125 | + | x0 := -math.Log(max(s.Epsilon, math.SmallestNonzeroFloat64)) / beta | |||
| 126 | + | if math.Abs(beta-omega0) <= f32Epsilon || beta < omega0 { | |||
| 127 | + | return seconds(x0) | |||
| 128 | + | } | |||
| 129 | + | // Overdamped: the envelope decays faster than the motion; find where | |||
| 130 | + | // the motion itself settles (Newton's method). | |||
| 131 | + | at := func(sec float64) float64 { return s.valueAt(seconds(sec)) } | |||
| 132 | + | y0 := at(x0) | |||
| 133 | + | m := (at(x0+step) - y0) / step | |||
| 134 | + | x1 := (s.To - y0 + m*x0) / m | |||
| 135 | + | y1 := at(x1) | |||
| 136 | + | for i := 0; math.Abs(s.To-y1) > s.Epsilon; i++ { | |||
| 137 | + | if i > 1000 { | |||
| 138 | + | return 0 | |||
| 139 | + | } | |||
| 140 | + | x0, y0 = x1, y1 | |||
| 141 | + | m = (at(x0+step) - y0) / step | |||
| 142 | + | x1 = (s.To - y0 + m*x0) / m | |||
| 143 | + | y1 = at(x1) | |||
| 144 | + | if math.IsNaN(y1) || math.IsInf(y1, 0) { | |||
| 145 | + | return seconds(x0) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | return seconds(x1) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } | |||
| 152 | + | ||||
| 153 | + | // motion is a spring started at a given time; a zero start begins at the | |||
| 154 | + | // first frame. | |||
| 155 | + | type motion struct { | |||
| 156 | + | spring spring | |||
| 157 | + | start time.Time | |||
| 158 | + | end time.Duration | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func newMotion(s spring, now time.Time) *motion { | |||
| 162 | + | return &motion{spring: s, start: now, end: s.duration()} | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // at is the position at now and whether the motion has settled. | |||
| 166 | + | func (m *motion) at(now time.Time) (float64, bool) { | |||
| 167 | + | if m.start.IsZero() { | |||
| 168 | + | m.start = now | |||
| 169 | + | } | |||
| 170 | + | t := now.Sub(m.start) | |||
| 171 | + | if t >= m.end { | |||
| 172 | + | return m.spring.To, true | |||
| 173 | + | } | |||
| 174 | + | return m.spring.valueAt(max(t, 0)), false | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // rubberBand resists movement past the edges: the further out, the less | |||
| 178 | + | // it moves, never more than limit. | |||
| 179 | + | type rubberBand struct{ stiffness, limit float64 } | |||
| 180 | + | ||||
| 181 | + | // workspaceBand holds a workspace swipe past the first or last workspace. | |||
| 182 | + | var workspaceBand = rubberBand{stiffness: 0.5, limit: 0.05} | |||
| 183 | + | ||||
| 184 | + | func (r rubberBand) band(x float64) float64 { | |||
| 185 | + | return (1 - 1/(x*r.stiffness/r.limit+1)) * r.limit | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (r rubberBand) derivative(x float64) float64 { | |||
| 189 | + | d := r.stiffness*x + r.limit | |||
| 190 | + | return r.stiffness * r.limit * r.limit / (d * d) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // clamp keeps x in [lo, hi], letting it stretch past the edges. | |||
| 194 | + | func (r rubberBand) clamp(lo, hi, x float64) float64 { | |||
| 195 | + | c := min(max(x, lo), hi) | |||
| 196 | + | if x < c { | |||
| 197 | + | return c - r.band(c-x) | |||
| 198 | + | } | |||
| 199 | + | return c + r.band(x-c) | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // clampDerivative is the slope of clamp at x: 1 inside the edges. | |||
| 203 | + | func (r rubberBand) clampDerivative(lo, hi, x float64) float64 { | |||
| 204 | + | if lo <= x && x <= hi { | |||
| 205 | + | return 1 | |||
| 206 | + | } | |||
| 207 | + | c := min(max(x, lo), hi) | |||
| 208 | + | return r.derivative(math.Abs(x - c)) | |||
| 209 | + | } | |||
A internal/core/motion_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSwipeTrackerVelocity(t *testing.T) { | |
| 10 | + | var tr swipeTracker | |
| 11 | + | for i := range 11 { | |
| 12 | + | tr.push(10, time.Duration(i)*10*time.Millisecond) | |
| 13 | + | } | |
| 14 | + | // 150 ms of history: 16 pushes would fit, 11 span 100 ms and 110 units. | |
| 15 | + | if got := tr.velocity(); math.Abs(got-1100) > 1e-9 { | |
| 16 | + | t.Fatalf("velocity %v, want 1100", got) | |
| 17 | + | } | |
| 18 | + | if tr.pos != 110 { | |
| 19 | + | t.Fatalf("pos %v", tr.pos) | |
| 20 | + | } | |
| 21 | + | // A flick lands further than where the fingers left it. | |
| 22 | + | if end := tr.projectedEnd(); end <= tr.pos { | |
| 23 | + | t.Fatalf("projected %v not past %v", end, tr.pos) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestSwipeTrackerForgetsOldMovement(t *testing.T) { | |
| 28 | + | var tr swipeTracker | |
| 29 | + | tr.push(500, 0) | |
| 30 | + | tr.push(1, 10*time.Millisecond) | |
| 31 | + | // The fingers rested before lifting: the old flick no longer counts. | |
| 32 | + | tr.push(0, 400*time.Millisecond) | |
| 33 | + | if v := tr.velocity(); v != 0 { | |
| 34 | + | t.Fatalf("velocity %v after a pause", v) | |
| 35 | + | } | |
| 36 | + | if tr.projectedEnd() != tr.pos { | |
| 37 | + | t.Fatal("a still swipe lands where it is") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestSwipeTrackerIgnoresEarlierEvents(t *testing.T) { | |
| 42 | + | var tr swipeTracker | |
| 43 | + | tr.push(5, 20*time.Millisecond) | |
| 44 | + | tr.push(5, 10*time.Millisecond) | |
| 45 | + | if tr.pos != 5 || len(tr.history) != 1 { | |
| 46 | + | t.Fatalf("pos %v history %d", tr.pos, len(tr.history)) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSpringSettles(t *testing.T) { | |
| 51 | + | s := viewSpring(-300, 0) | |
| 52 | + | if v := s.valueAt(0); math.Abs(v+300) > 1e-9 { | |
| 53 | + | t.Fatalf("start %v", v) | |
| 54 | + | } | |
| 55 | + | d := s.duration() | |
| 56 | + | if d <= 0 || d > 2*time.Second { | |
| 57 | + | t.Fatalf("duration %v", d) | |
| 58 | + | } | |
| 59 | + | if v := s.valueAt(d); math.Abs(v) > 0.5 { | |
| 60 | + | t.Fatalf("end %v", v) | |
| 61 | + | } | |
| 62 | + | // Critically damped: it never passes its target. | |
| 63 | + | for at := time.Duration(0); at < d; at += time.Millisecond { | |
| 64 | + | if v := s.valueAt(at); v > 1e-9 { | |
| 65 | + | t.Fatalf("overshoot %v at %v", v, at) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestSpringKeepsVelocity(t *testing.T) { | |
| 71 | + | // A spring launched toward its target moves faster at first. | |
| 72 | + | still, flung := viewSpring(-300, 0), viewSpring(-300, 3000) | |
| 73 | + | at := 10 * time.Millisecond | |
| 74 | + | if still.valueAt(at) >= flung.valueAt(at) { | |
| 75 | + | t.Fatal("initial velocity ignored") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestSpringOverdamped(t *testing.T) { | |
| 80 | + | s := spring{From: 0, To: 1, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001} | |
| 81 | + | d := s.duration() | |
| 82 | + | if v := s.valueAt(d); math.IsNaN(v) || math.Abs(v-1) > 0.01 { | |
| 83 | + | t.Fatalf("value %v at %v", v, d) | |
| 84 | + | } | |
| 85 | + | if (spring{From: 0, To: 0, DampingRatio: 1.15, Stiffness: 850, Epsilon: 0.0001}).duration() != 0 { | |
| 86 | + | t.Fatal("a spring at rest takes time") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestMotionEndsOnTarget(t *testing.T) { | |
| 91 | + | start := time.Unix(10, 0) | |
| 92 | + | m := newMotion(workspaceSpring(0.4, 0), start) | |
| 93 | + | if v, done := m.at(start); done || v != 0.4 { | |
| 94 | + | t.Fatalf("start %v %t", v, done) | |
| 95 | + | } | |
| 96 | + | if v, done := m.at(start.Add(10 * time.Second)); !done || v != 0 { | |
| 97 | + | t.Fatalf("end %v %t", v, done) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestRubberBand(t *testing.T) { | |
| 102 | + | r := workspaceBand | |
| 103 | + | if r.clamp(0, 2, 1.5) != 1.5 { | |
| 104 | + | t.Fatal("inside moves freely") | |
| 105 | + | } | |
| 106 | + | past := r.clamp(0, 2, 3) | |
| 107 | + | if past <= 2 || past >= 2+r.limit { | |
| 108 | + | t.Fatalf("past the end %v", past) | |
| 109 | + | } | |
| 110 | + | if before := r.clamp(0, 2, -10); before >= 0 || before <= -r.limit { | |
| 111 | + | t.Fatalf("before the start %v", before) | |
| 112 | + | } | |
| 113 | + | if r.clampDerivative(0, 2, 1) != 1 || r.clampDerivative(0, 2, 3) >= 1 { | |
| 114 | + | t.Fatal("derivative") | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestSnap(t *testing.T) { | |
| 119 | + | // Four columns of 400 on an 800 wide view, no gaps: views align on | |
| 120 | + | // column edges between 0 and 800. | |
| 121 | + | w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, MaxColumns: 2} | |
| 122 | + | for i := range 4 { | |
| 123 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}}) | |
| 124 | + | } | |
| 125 | + | for _, tc := range []struct { | |
| 126 | + | target float64 | |
| 127 | + | forward bool | |
| 128 | + | view int | |
| 129 | + | focus int | |
| 130 | + | }{ | |
| 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}, | |
| 136 | + | } { | |
| 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) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestSwipeTrackerVelocity(t *testing.T) { | |||
| 10 | + | var tr swipeTracker | |||
| 11 | + | for i := range 11 { | |||
| 12 | + | tr.push(10, time.Duration(i)*10*time.Millisecond) | |||
| 13 | + | } | |||
| 14 | + | // 150 ms of history: 16 pushes would fit, 11 span 100 ms and 110 units. | |||
| 15 | + | if got := tr.velocity(); math.Abs(got-1100) > 1e-9 { | |||
| 16 | + | t.Fatalf("velocity %v, want 1100", got) | |||
| 17 | + | } | |||
| 18 | + | if tr.pos != 110 { | |||
| 19 | + | t.Fatalf("pos %v", tr.pos) | |||
| 20 | + | } | |||
| 21 | + | // A flick lands further than where the fingers left it. | |||
| 22 | + | if end := tr.projectedEnd(); end <= tr.pos { | |||
| 23 | + | t.Fatalf("projected %v not past %v", end, tr.pos) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestSwipeTrackerForgetsOldMovement(t *testing.T) { | |||
| 28 | + | var tr swipeTracker | |||
| 29 | + | tr.push(500, 0) | |||
| 30 | + | tr.push(1, 10*time.Millisecond) | |||
| 31 | + | // The fingers rested before lifting: the old flick no longer counts. | |||
| 32 | + | tr.push(0, 400*time.Millisecond) | |||
| 33 | + | if v := tr.velocity(); v != 0 { | |||
| 34 | + | t.Fatalf("velocity %v after a pause", v) | |||
| 35 | + | } | |||
| 36 | + | if tr.projectedEnd() != tr.pos { | |||
| 37 | + | t.Fatal("a still swipe lands where it is") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestSwipeTrackerIgnoresEarlierEvents(t *testing.T) { | |||
| 42 | + | var tr swipeTracker | |||
| 43 | + | tr.push(5, 20*time.Millisecond) | |||
| 44 | + | tr.push(5, 10*time.Millisecond) | |||
| 45 | + | if tr.pos != 5 || len(tr.history) != 1 { | |||
| 46 | + | t.Fatalf("pos %v history %d", tr.pos, len(tr.history)) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestSpringSettles(t *testing.T) { | |||
| 51 | + | s := viewSpring(-300, 0) | |||
| 52 | + | if v := s.valueAt(0); math.Abs(v+300) > 1e-9 { | |||
| 53 | + | t.Fatalf("start %v", v) | |||
| 54 | + | } | |||
| 55 | + | d := s.duration() | |||
| 56 | + | if d <= 0 || d > 2*time.Second { | |||
| 57 | + | t.Fatalf("duration %v", d) | |||
| 58 | + | } | |||
| 59 | + | if v := s.valueAt(d); math.Abs(v) > 0.5 { | |||
| 60 | + | t.Fatalf("end %v", v) | |||
| 61 | + | } | |||
| 62 | + | // Critically damped: it never passes its target. | |||
| 63 | + | for at := time.Duration(0); at < d; at += time.Millisecond { | |||
| 64 | + | if v := s.valueAt(at); v > 1e-9 { | |||
| 65 | + | t.Fatalf("overshoot %v at %v", v, at) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestSpringKeepsVelocity(t *testing.T) { | |||
| 71 | + | // A spring launched toward its target moves faster at first. | |||
| 72 | + | still, flung := viewSpring(-300, 0), viewSpring(-300, 3000) | |||
| 73 | + | at := 10 * time.Millisecond | |||
| 74 | + | if still.valueAt(at) >= flung.valueAt(at) { | |||
| 75 | + | t.Fatal("initial velocity ignored") | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestSpringOverdamped(t *testing.T) { | |||
| 80 | + | s := spring{From: 0, To: 1, DampingRatio: 6, Stiffness: 1200, Epsilon: 0.0001} | |||
| 81 | + | d := s.duration() | |||
| 82 | + | if v := s.valueAt(d); math.IsNaN(v) || math.Abs(v-1) > 0.01 { | |||
| 83 | + | t.Fatalf("value %v at %v", v, d) | |||
| 84 | + | } | |||
| 85 | + | if (spring{From: 0, To: 0, DampingRatio: 1.15, Stiffness: 850, Epsilon: 0.0001}).duration() != 0 { | |||
| 86 | + | t.Fatal("a spring at rest takes time") | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestMotionEndsOnTarget(t *testing.T) { | |||
| 91 | + | start := time.Unix(10, 0) | |||
| 92 | + | m := newMotion(workspaceSpring(0.4, 0), start) | |||
| 93 | + | if v, done := m.at(start); done || v != 0.4 { | |||
| 94 | + | t.Fatalf("start %v %t", v, done) | |||
| 95 | + | } | |||
| 96 | + | if v, done := m.at(start.Add(10 * time.Second)); !done || v != 0 { | |||
| 97 | + | t.Fatalf("end %v %t", v, done) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestRubberBand(t *testing.T) { | |||
| 102 | + | r := workspaceBand | |||
| 103 | + | if r.clamp(0, 2, 1.5) != 1.5 { | |||
| 104 | + | t.Fatal("inside moves freely") | |||
| 105 | + | } | |||
| 106 | + | past := r.clamp(0, 2, 3) | |||
| 107 | + | if past <= 2 || past >= 2+r.limit { | |||
| 108 | + | t.Fatalf("past the end %v", past) | |||
| 109 | + | } | |||
| 110 | + | if before := r.clamp(0, 2, -10); before >= 0 || before <= -r.limit { | |||
| 111 | + | t.Fatalf("before the start %v", before) | |||
| 112 | + | } | |||
| 113 | + | if r.clampDerivative(0, 2, 1) != 1 || r.clampDerivative(0, 2, 3) >= 1 { | |||
| 114 | + | t.Fatal("derivative") | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestSnap(t *testing.T) { | |||
| 119 | + | // Four columns of 400 on an 800 wide view, no gaps: views align on | |||
| 120 | + | // column edges between 0 and 800. | |||
| 121 | + | w := &Workspace{Output: Rect{W: 800, H: 600}, Usable: Rect{W: 800, H: 600}, MaxColumns: 2} | |||
| 122 | + | for i := range 4 { | |||
| 123 | + | w.Columns = append(w.Columns, Column{Windows: []WindowID{WindowID(i + 1)}}) | |||
| 124 | + | } | |||
| 125 | + | for _, tc := range []struct { | |||
| 126 | + | target float64 | |||
| 127 | + | forward bool | |||
| 128 | + | view int | |||
| 129 | + | focus int | |||
| 130 | + | }{ | |||
| 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}, | |||
| 136 | + | } { | |||
| 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) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | } | |||
A internal/core/slide.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Slides move what is on screen smoothly: a workspace's columns (shift) and | |
| 9 | + | // the monitor between workspaces (switchOff). Both are pure presentation | |
| 10 | + | // over the settled state (ViewX, Focus, Active): the layout ends where the | |
| 11 | + | // state says as soon as a slide stops. | |
| 12 | + | ||
| 13 | + | // slidable reports whether a swipe can scroll the columns: only scroll | |
| 14 | + | // overflow, with the focus on the columns and no fullscreen window. | |
| 15 | + | func (w *Workspace) slidable() bool { | |
| 16 | + | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil } | |
| 20 | + | ||
| 21 | + | // retarget keeps the view where it is on screen when ViewX moved from | |
| 22 | + | // before during a landing slide: the slide heads for the new view. | |
| 23 | + | // Without a landing slide the view moves at once, as it always has. | |
| 24 | + | func (w *Workspace) retarget(before int) { | |
| 25 | + | if w.ViewX == before || w.motion == nil { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | w.shift += float64(before - w.ViewX) | |
| 29 | + | w.motion = newMotion(viewSpring(w.shift, 0), time.Time{}) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | |
| 33 | + | ||
| 34 | + | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | |
| 35 | + | // scrolls one usable width. | |
| 36 | + | func (w *Workspace) swipeScale() float64 { | |
| 37 | + | return float64(w.Usable.W) / viewSwipeMovement | |
| 38 | + | } | |
| 39 | + | ||
| 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) { | |
| 45 | + | g := w.gap() | |
| 46 | + | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |
| 47 | + | last := len(w.Columns) - 1 | |
| 48 | + | lo := w.columnX(0) - minX | |
| 49 | + | 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 | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | for i := range w.Columns { | |
| 58 | + | try(w.columnX(i) - minX) | |
| 59 | + | try(w.columnX(i) + w.columnWidth(i) - maxX) | |
| 60 | + | } | |
| 61 | + | full := func(i int) bool { | |
| 62 | + | x := w.columnX(i) - view | |
| 63 | + | return x >= minX && x+w.columnWidth(i) <= maxX | |
| 64 | + | } | |
| 65 | + | if full(w.Focus) && view == w.ViewX { | |
| 66 | + | return view, w.Focus | |
| 67 | + | } | |
| 68 | + | focus = -1 | |
| 69 | + | for i := range w.Columns { | |
| 70 | + | if full(i) && (focus < 0 || forward) { | |
| 71 | + | focus = i | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | if focus < 0 { | |
| 75 | + | // Columns wider than the view: the one at the left edge. | |
| 76 | + | focus = 0 | |
| 77 | + | for i := range w.Columns { | |
| 78 | + | if w.columnX(i)-view <= minX { | |
| 79 | + | focus = i | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | return view, focus | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil } | |
| 87 | + | ||
| 88 | + | // slideLayout adds the workspaces a workspace slide shows next to the | |
| 89 | + | // current one, moved by their distance, to the current layout (moved too). | |
| 90 | + | // A landing slide places them as the list was when the swipe began | |
| 91 | + | // (switchList): an empty workspace dropped since shows as background. | |
| 92 | + | func (m *Monitor) slideLayout(cur []Placement) []Placement { | |
| 93 | + | if m.switchOff == 0 || m.shown != nil { | |
| 94 | + | return cur | |
| 95 | + | } | |
| 96 | + | list := m.Workspaces | |
| 97 | + | if m.switchList != nil { | |
| 98 | + | list = m.switchList | |
| 99 | + | } | |
| 100 | + | base := indexOf(list, m.Current()) | |
| 101 | + | if base < 0 { | |
| 102 | + | return cur | |
| 103 | + | } | |
| 104 | + | h := float64(m.template.Output.H) | |
| 105 | + | pos := float64(base) + m.switchOff | |
| 106 | + | offset := func(p []Placement, k int) []Placement { | |
| 107 | + | dy := int(math.Round((float64(k) - pos) * h)) | |
| 108 | + | for i := range p { | |
| 109 | + | if !p[i].Hidden { | |
| 110 | + | p[i].Rect.Y += dy | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | return p | |
| 114 | + | } | |
| 115 | + | cur = offset(cur, base) | |
| 116 | + | for _, k := range []int{int(math.Floor(pos)), int(math.Ceil(pos))} { | |
| 117 | + | if k == base || k < 0 || k >= len(list) || !m.has(list[k]) { | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | next := list[k].Layout() | |
| 121 | + | for i := range next { | |
| 122 | + | next[i].Focused = false | |
| 123 | + | } | |
| 124 | + | // Windows of the neighbor are already listed hidden: replace them. | |
| 125 | + | shown := map[WindowID]Placement{} | |
| 126 | + | for _, p := range offset(next, k) { | |
| 127 | + | shown[p.ID] = p | |
| 128 | + | } | |
| 129 | + | for i, p := range cur { | |
| 130 | + | if v, ok := shown[p.ID]; ok { | |
| 131 | + | cur[i] = v | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | return cur | |
| 136 | + | } | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Slides move what is on screen smoothly: a workspace's columns (shift) and | |||
| 9 | + | // the monitor between workspaces (switchOff). Both are pure presentation | |||
| 10 | + | // over the settled state (ViewX, Focus, Active): the layout ends where the | |||
| 11 | + | // state says as soon as a slide stops. | |||
| 12 | + | ||||
| 13 | + | // slidable reports whether a swipe can scroll the columns: only scroll | |||
| 14 | + | // overflow, with the focus on the columns and no fullscreen window. | |||
| 15 | + | func (w *Workspace) slidable() bool { | |||
| 16 | + | return w.Overflow != OverflowFixed && len(w.Columns) > 0 && w.fullscreen == 0 && !w.onFloat() | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (w *Workspace) stopSlide() { w.shift, w.motion = 0, nil } | |||
| 20 | + | ||||
| 21 | + | // retarget keeps the view where it is on screen when ViewX moved from | |||
| 22 | + | // before during a landing slide: the slide heads for the new view. | |||
| 23 | + | // Without a landing slide the view moves at once, as it always has. | |||
| 24 | + | func (w *Workspace) retarget(before int) { | |||
| 25 | + | if w.ViewX == before || w.motion == nil { | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | w.shift += float64(before - w.ViewX) | |||
| 29 | + | w.motion = newMotion(viewSpring(w.shift, 0), time.Time{}) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (w *Workspace) shiftPixels() int { return int(math.Round(w.shift)) } | |||
| 33 | + | ||||
| 34 | + | // swipeScale turns touchpad distance into pixels: viewSwipeMovement | |||
| 35 | + | // scrolls one usable width. | |||
| 36 | + | func (w *Workspace) swipeScale() float64 { | |||
| 37 | + | return float64(w.Usable.W) / viewSwipeMovement | |||
| 38 | + | } | |||
| 39 | + | ||||
| 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) { | |||
| 45 | + | g := w.gap() | |||
| 46 | + | minX, maxX := w.Usable.X+g, w.Usable.X+w.Usable.W-g | |||
| 47 | + | last := len(w.Columns) - 1 | |||
| 48 | + | lo := w.columnX(0) - minX | |||
| 49 | + | 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 | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | for i := range w.Columns { | |||
| 58 | + | try(w.columnX(i) - minX) | |||
| 59 | + | try(w.columnX(i) + w.columnWidth(i) - maxX) | |||
| 60 | + | } | |||
| 61 | + | full := func(i int) bool { | |||
| 62 | + | x := w.columnX(i) - view | |||
| 63 | + | return x >= minX && x+w.columnWidth(i) <= maxX | |||
| 64 | + | } | |||
| 65 | + | if full(w.Focus) && view == w.ViewX { | |||
| 66 | + | return view, w.Focus | |||
| 67 | + | } | |||
| 68 | + | focus = -1 | |||
| 69 | + | for i := range w.Columns { | |||
| 70 | + | if full(i) && (focus < 0 || forward) { | |||
| 71 | + | focus = i | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | if focus < 0 { | |||
| 75 | + | // Columns wider than the view: the one at the left edge. | |||
| 76 | + | focus = 0 | |||
| 77 | + | for i := range w.Columns { | |||
| 78 | + | if w.columnX(i)-view <= minX { | |||
| 79 | + | focus = i | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | return view, focus | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (m *Monitor) stopSwitch() { m.switchOff, m.switchMotion, m.switchList = 0, nil, nil } | |||
| 87 | + | ||||
| 88 | + | // slideLayout adds the workspaces a workspace slide shows next to the | |||
| 89 | + | // current one, moved by their distance, to the current layout (moved too). | |||
| 90 | + | // A landing slide places them as the list was when the swipe began | |||
| 91 | + | // (switchList): an empty workspace dropped since shows as background. | |||
| 92 | + | func (m *Monitor) slideLayout(cur []Placement) []Placement { | |||
| 93 | + | if m.switchOff == 0 || m.shown != nil { | |||
| 94 | + | return cur | |||
| 95 | + | } | |||
| 96 | + | list := m.Workspaces | |||
| 97 | + | if m.switchList != nil { | |||
| 98 | + | list = m.switchList | |||
| 99 | + | } | |||
| 100 | + | base := indexOf(list, m.Current()) | |||
| 101 | + | if base < 0 { | |||
| 102 | + | return cur | |||
| 103 | + | } | |||
| 104 | + | h := float64(m.template.Output.H) | |||
| 105 | + | pos := float64(base) + m.switchOff | |||
| 106 | + | offset := func(p []Placement, k int) []Placement { | |||
| 107 | + | dy := int(math.Round((float64(k) - pos) * h)) | |||
| 108 | + | for i := range p { | |||
| 109 | + | if !p[i].Hidden { | |||
| 110 | + | p[i].Rect.Y += dy | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | return p | |||
| 114 | + | } | |||
| 115 | + | cur = offset(cur, base) | |||
| 116 | + | for _, k := range []int{int(math.Floor(pos)), int(math.Ceil(pos))} { | |||
| 117 | + | if k == base || k < 0 || k >= len(list) || !m.has(list[k]) { | |||
| 118 | + | continue | |||
| 119 | + | } | |||
| 120 | + | next := list[k].Layout() | |||
| 121 | + | for i := range next { | |||
| 122 | + | next[i].Focused = false | |||
| 123 | + | } | |||
| 124 | + | // Windows of the neighbor are already listed hidden: replace them. | |||
| 125 | + | shown := map[WindowID]Placement{} | |||
| 126 | + | for _, p := range offset(next, k) { | |||
| 127 | + | shown[p.ID] = p | |||
| 128 | + | } | |||
| 129 | + | for i, p := range cur { | |||
| 130 | + | if v, ok := shown[p.ID]; ok { | |||
| 131 | + | cur[i] = v | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | return cur | |||
| 136 | + | } | |||
M internal/core/swipe_test.go +527 -37
| @@ -1,22 +1,144 @@ | |||
| 1 | 1 | package core_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 4 | 6 | "testing" | |
| 7 | + | "time" | |
| 5 | 8 | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 10 | + | "github.com/bnema/neferwl/internal/core" | |
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 6 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 7 | 14 | ) | |
| 8 | 15 | ||
| 9 | - | func (r *multiRig) swipe(t *testing.T, d ports.SwipeDir) []ports.Scene { | |
| 16 | + | // swipeRig runs core on one 800x600 output with no gaps or border, a | |
| 17 | + | // clock the test moves, and page flips the test sends. | |
| 18 | + | type swipeRig struct { | |
| 19 | + | *multiRig | |
| 20 | + | frames chan ports.OutputFrame | |
| 21 | + | mu sync.Mutex | |
| 22 | + | now time.Time | |
| 23 | + | // at is the device time of the next swipe event. | |
| 24 | + | at time.Duration | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000} | |
| 28 | + | ||
| 29 | + | func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | |
| 10 | 30 | t.Helper() | |
| 11 | - | for len(r.commands) > 0 { | |
| 12 | - | <-r.commands | |
| 31 | + | cfg := config.Defaults() | |
| 32 | + | cfg.Keyboard.CmdKey = "alt" | |
| 33 | + | cfg.Border.Width = 0 | |
| 34 | + | cfg.Layout.Gaps = 0 | |
| 35 | + | cfg.Terminal.AutoOpen = "never" | |
| 36 | + | if edit != nil { | |
| 37 | + | edit(&cfg) | |
| 13 | 38 | } | |
| 14 | - | r.input <- ports.Swipe{Dir: d} | |
| 15 | - | return receive(t, r.scenes) | |
| 39 | + | r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)} | |
| 40 | + | r.multiRig = &multiRig{ | |
| 41 | + | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64), | |
| 42 | + | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | |
| 43 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg, | |
| 44 | + | } | |
| 45 | + | clock := portsmocks.NewMockClock(t) | |
| 46 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |
| 47 | + | r.mu.Lock() | |
| 48 | + | defer r.mu.Unlock() | |
| 49 | + | return r.now | |
| 50 | + | }).Maybe() | |
| 51 | + | // The fallback timer never fires: frames come from the test. | |
| 52 | + | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer { | |
| 53 | + | timer := portsmocks.NewMockTimer(t) | |
| 54 | + | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |
| 55 | + | timer.EXPECT().Stop().Return(true).Maybe() | |
| 56 | + | return timer | |
| 57 | + | }).Maybe() | |
| 58 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames}) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 63 | + | done := make(chan error, 1) | |
| 64 | + | go func() { done <- c.Run(ctx) }() | |
| 65 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 66 | + | r.plug(t, wide) | |
| 67 | + | return r | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (r *swipeRig) advance(d time.Duration) { | |
| 71 | + | r.mu.Lock() | |
| 72 | + | r.now = r.now.Add(d) | |
| 73 | + | r.mu.Unlock() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // begin starts a swipe; nothing moves until it picks an axis. | |
| 77 | + | func (r *swipeRig) begin() { | |
| 78 | + | r.at += time.Second | |
| 79 | + | r.input <- ports.SwipeBegin{Time: r.at} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // move sends one swipe update and returns the scene it produced. | |
| 83 | + | func (r *swipeRig) move(t *testing.T, dx, dy float64) ports.Scene { | |
| 84 | + | t.Helper() | |
| 85 | + | r.at += 8 * time.Millisecond | |
| 86 | + | r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at} | |
| 87 | + | return scene(t, r.scenes) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (r *swipeRig) end(t *testing.T, cancelled bool) ports.Scene { | |
| 91 | + | t.Helper() | |
| 92 | + | r.input <- ports.SwipeEnd{Cancelled: cancelled, Time: r.at} | |
| 93 | + | return scene(t, r.scenes) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // frame moves the clock by d, sends a flip and returns the next scene. | |
| 97 | + | func (r *swipeRig) frame(t *testing.T, d time.Duration) ports.Scene { | |
| 98 | + | t.Helper() | |
| 99 | + | r.advance(d) | |
| 100 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |
| 101 | + | return scene(t, r.scenes) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // settle moves the clock past any spring and returns the settled scene. | |
| 105 | + | func (r *swipeRig) settle(t *testing.T) ports.Scene { | |
| 106 | + | t.Helper() | |
| 107 | + | return r.frame(t, 5*time.Second) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // flick sends a quick swipe of n updates of (dx, dy) and lifts the | |
| 111 | + | // fingers; it returns the scene right after the lift. | |
| 112 | + | func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene { | |
| 113 | + | t.Helper() | |
| 114 | + | r.begin() | |
| 115 | + | for range n { | |
| 116 | + | r.move(t, dx, dy) | |
| 117 | + | } | |
| 118 | + | return r.end(t, false) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // workspaces puts window 1 on the first workspace and 2 on the second, | |
| 122 | + | // showing the first. | |
| 123 | + | func (r *swipeRig) workspaces(t *testing.T) { | |
| 124 | + | t.Helper() | |
| 125 | + | r.mapWindow(t, 1) | |
| 126 | + | r.key(t, "Next", ports.ModAlt) | |
| 127 | + | r.mapWindow(t, 2) | |
| 128 | + | r.key(t, "Prior", ports.ModAlt) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | |
| 132 | + | for _, w := range s.Windows { | |
| 133 | + | if w.ID == id && !w.Hidden { | |
| 134 | + | return w.Rect, true | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return ports.Rect{}, false | |
| 16 | 138 | } | |
| 17 | 139 | ||
| 18 | - | // lastFocus returns the last window core focused since the swipe. | |
| 19 | - | func (r *multiRig) lastFocus() ports.WindowID { | |
| 140 | + | func (r *swipeRig) focused(t *testing.T) ports.WindowID { | |
| 141 | + | t.Helper() | |
| 20 | 142 | var id ports.WindowID | |
| 21 | 143 | for len(r.commands) > 0 { | |
| 22 | 144 | if v, ok := (<-r.commands).(ports.FocusWindow); ok { | |
| @@ -26,44 +148,412 @@ func (r *multiRig) lastFocus() ports.WindowID { | |||
| 26 | 148 | return id | |
| 27 | 149 | } | |
| 28 | 150 | ||
| 29 | - | func TestSwipeChangesWorkspace(t *testing.T) { | |
| 151 | + | // threeColumns maps three windows of half the width; the view shows 2 | |
| 152 | + | // and 3, with 3 focused. It returns the last scene. | |
| 153 | + | func threeColumns(t *testing.T, r *swipeRig) ports.Scene { | |
| 154 | + | t.Helper() | |
| 155 | + | var s []ports.Scene | |
| 156 | + | for id := ports.WindowID(1); id <= 3; id++ { | |
| 157 | + | s = r.mapWindow(t, id) | |
| 158 | + | } | |
| 159 | + | return s[0] | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestSwipeFollowsFingers(t *testing.T) { | |
| 163 | + | r := startSwipe(t, nil) | |
| 164 | + | before, _ := rectOf(threeColumns(t, r), 2) | |
| 165 | + | r.begin() | |
| 166 | + | // Under the 16 unit threshold the swipe has not picked its axis: | |
| 167 | + | // that movement is not applied (as in GNOME Shell and niri). | |
| 168 | + | r.input <- ports.SwipeUpdate{DX: -10, Time: r.at} | |
| 169 | + | s := r.move(t, -10, 0) | |
| 170 | + | after, _ := rectOf(s, 2) | |
| 171 | + | // 10 units of 1200 per 800 px view: 7 px, not snapped to a column. | |
| 172 | + | if d := after.X - before.X; d != 7 { | |
| 173 | + | t.Fatalf("column moved %d px with the fingers", d) | |
| 174 | + | } | |
| 175 | + | s = r.move(t, -30, 0) | |
| 176 | + | if got, _ := rectOf(s, 2); got.X-before.X != 27 { | |
| 177 | + | t.Fatalf("column moved %d px after 40 units", got.X-before.X) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestSwipeFlickLandsOnColumnWithSpring(t *testing.T) { | |
| 182 | + | r := startSwipe(t, nil) | |
| 183 | + | threeColumns(t, r) | |
| 184 | + | // Focus is on 3 (right); the view shows 2 and 3. Flick right to left | |
| 185 | + | // fingers (content moves left in classic scroll): view goes left. | |
| 186 | + | r.focused(t) | |
| 187 | + | s := r.flick(t, 10, -40, 0) | |
| 188 | + | mid, ok := rectOf(s, 1) | |
| 189 | + | if !ok { | |
| 190 | + | t.Fatal("column 1 not shown while the view slides") | |
| 191 | + | } | |
| 192 | + | if mid.X == 0 { | |
| 193 | + | t.Fatal("view jumped: no slide") | |
| 194 | + | } | |
| 195 | + | s = r.frame(t, 16*time.Millisecond) | |
| 196 | + | next, _ := rectOf(s, 1) | |
| 197 | + | if next.X <= mid.X || next.X > 0 { | |
| 198 | + | t.Fatalf("spring does not approach the target: %d then %d", mid.X, next.X) | |
| 199 | + | } | |
| 200 | + | s = r.settle(t) | |
| 201 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |
| 202 | + | t.Fatalf("landed at %d, want column 1 at the left edge", got.X) | |
| 203 | + | } | |
| 204 | + | if id := r.focused(t); id != 1 && id != 2 { | |
| 205 | + | t.Fatalf("focus %d after swiping left", id) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func TestSwipeNaturalScrollInverts(t *testing.T) { | |
| 30 | 210 | for _, natural := range []bool{false, true} { | |
| 31 | - | r := startMulti(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }, right) | |
| 32 | - | r.mapWindow(t, 1) | |
| 33 | - | down, up := ports.SwipeDown, ports.SwipeUp | |
| 211 | + | r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }) | |
| 212 | + | threeColumns(t, r) | |
| 213 | + | dx := -40.0 | |
| 34 | 214 | if natural { | |
| 35 | - | down, up = up, down | |
| 215 | + | dx = 40 | |
| 216 | + | } | |
| 217 | + | r.flick(t, 10, dx, 0) | |
| 218 | + | s := r.settle(t) | |
| 219 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |
| 220 | + | t.Fatalf("natural=%t: column 1 at %d", natural, got.X) | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestSwipeWorkspaceSlides(t *testing.T) { | |
| 226 | + | r := startSwipe(t, nil) | |
| 227 | + | r.workspaces(t) | |
| 228 | + | r.begin() | |
| 229 | + | s := r.move(t, 0, 30) | |
| 230 | + | one, ok1 := rectOf(s, 1) | |
| 231 | + | two, ok2 := rectOf(s, 2) | |
| 232 | + | if !ok1 || !ok2 { | |
| 233 | + | t.Fatalf("both workspaces show while sliding: %v %v", ok1, ok2) | |
| 234 | + | } | |
| 235 | + | if one.Y >= 0 || two.Y <= 0 || two.Y-one.Y != 600 { | |
| 236 | + | t.Fatalf("slide positions %d %d", one.Y, two.Y) | |
| 237 | + | } | |
| 238 | + | for range 8 { | |
| 239 | + | r.move(t, 0, 30) | |
| 240 | + | } | |
| 241 | + | s = r.end(t, false) | |
| 242 | + | if _, ok := rectOf(s, 2); !ok { | |
| 243 | + | t.Fatal("target workspace hidden while landing") | |
| 244 | + | } | |
| 245 | + | s = r.settle(t) | |
| 246 | + | if _, ok := rectOf(s, 1); ok { | |
| 247 | + | t.Fatal("old workspace still shown") | |
| 248 | + | } | |
| 249 | + | if got, _ := rectOf(s, 2); got.Y != 0 { | |
| 250 | + | t.Fatalf("workspace 2 at %d", got.Y) | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestSwipeWorkspaceCancelReturns(t *testing.T) { | |
| 255 | + | r := startSwipe(t, nil) | |
| 256 | + | r.workspaces(t) | |
| 257 | + | r.begin() | |
| 258 | + | for range 10 { | |
| 259 | + | r.move(t, 0, 40) | |
| 260 | + | } | |
| 261 | + | r.end(t, true) | |
| 262 | + | s := r.settle(t) | |
| 263 | + | if got, ok := rectOf(s, 1); !ok || got.Y != 0 { | |
| 264 | + | t.Fatalf("cancelled swipe left workspace 1 at %v %t", got, ok) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func TestSwipeFixedOverflowRunsFocusAction(t *testing.T) { | |
| 269 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |
| 270 | + | r.mapWindow(t, 1) | |
| 271 | + | r.mapWindow(t, 2) | |
| 272 | + | r.focused(t) | |
| 273 | + | // The view cannot slide: nothing moves until the fingers lift. | |
| 274 | + | r.begin() | |
| 275 | + | for range 10 { | |
| 276 | + | r.at += 8 * time.Millisecond | |
| 277 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |
| 278 | + | } | |
| 279 | + | r.end(t, false) | |
| 280 | + | if id := r.focused(t); id != 1 { | |
| 281 | + | t.Fatalf("fixed overflow swipe left focuses %d", id) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func TestSwipeShortSwipeDoesNothingInFixedOverflow(t *testing.T) { | |
| 286 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |
| 287 | + | r.mapWindow(t, 1) | |
| 288 | + | r.mapWindow(t, 2) | |
| 289 | + | r.focused(t) | |
| 290 | + | r.begin() | |
| 291 | + | r.input <- ports.SwipeUpdate{DX: -20, Time: r.at + time.Second} | |
| 292 | + | r.input <- ports.SwipeEnd{Time: r.at + 2*time.Second} | |
| 293 | + | r.key(t, "Return", 0) | |
| 294 | + | if id := r.focused(t); id != 0 { | |
| 295 | + | t.Fatalf("slow short swipe moved focus to %d", id) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func TestKeyDuringSlideRetargets(t *testing.T) { | |
| 300 | + | r := startSwipe(t, nil) | |
| 301 | + | threeColumns(t, r) | |
| 302 | + | s := r.flick(t, 10, -40, 0) | |
| 303 | + | landing, _ := rectOf(s, 2) | |
| 304 | + | // Back to column 3: the view heads there from where it is, no jump. | |
| 305 | + | r.key(t, "Right", ports.ModAlt) | |
| 306 | + | s = r.key(t, "Right", ports.ModAlt)[0] | |
| 307 | + | if got, _ := rectOf(s, 2); got.X != landing.X { | |
| 308 | + | t.Fatalf("view jumped from %d to %d", landing.X, got.X) | |
| 309 | + | } | |
| 310 | + | // The retargeted spring starts on the next frame. | |
| 311 | + | r.frame(t, 16*time.Millisecond) | |
| 312 | + | s = r.settle(t) | |
| 313 | + | if got, _ := rectOf(s, 3); got.X != 400 { | |
| 314 | + | t.Fatalf("column 3 landed at %d", got.X) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) { | |
| 319 | + | r := startSwipe(t, nil) | |
| 320 | + | r.workspaces(t) | |
| 321 | + | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | |
| 322 | + | r.key(t, "Next", ports.ModAlt) | |
| 323 | + | r.key(t, "Next", ports.ModAlt) | |
| 324 | + | r.begin() | |
| 325 | + | var s ports.Scene | |
| 326 | + | for range 6 { | |
| 327 | + | s = r.move(t, 0, -40) | |
| 328 | + | } | |
| 329 | + | before, ok := rectOf(s, 2) | |
| 330 | + | if !ok { | |
| 331 | + | t.Fatal("workspace 2 not sliding in") | |
| 332 | + | } | |
| 333 | + | // Landing drops no numbered workspace here (the trailing one stays), | |
| 334 | + | // but the view must not jump either way. | |
| 335 | + | s = r.end(t, false) | |
| 336 | + | after, _ := rectOf(s, 2) | |
| 337 | + | if after.Y != before.Y { | |
| 338 | + | t.Fatalf("landing jumped from %d to %d", before.Y, after.Y) | |
| 339 | + | } | |
| 340 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |
| 341 | + | t.Fatalf("workspace 2 landed at %d", got.Y) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func TestSwipeLandingKeepsViewWhenEmptyWorkspaceDrops(t *testing.T) { | |
| 346 | + | r := startSwipe(t, nil) | |
| 347 | + | // 1 on the first workspace, 2 on the third; the second is empty and | |
| 348 | + | // active, so leaving it drops it and renumbers the third. | |
| 349 | + | r.mapWindow(t, 1) | |
| 350 | + | r.key(t, "Next", ports.ModAlt) | |
| 351 | + | r.key(t, "Next", ports.ModAlt) | |
| 352 | + | r.mapWindow(t, 2) | |
| 353 | + | r.key(t, "Prior", ports.ModAlt) | |
| 354 | + | r.begin() | |
| 355 | + | var s ports.Scene | |
| 356 | + | for range 4 { | |
| 357 | + | s = r.move(t, 0, 20) | |
| 358 | + | } | |
| 359 | + | before, ok := rectOf(s, 2) | |
| 360 | + | if !ok { | |
| 361 | + | t.Fatal("workspace 3 not sliding in") | |
| 362 | + | } | |
| 363 | + | s = r.end(t, false) | |
| 364 | + | after, ok := rectOf(s, 2) | |
| 365 | + | if !ok || after.Y != before.Y { | |
| 366 | + | t.Fatalf("landing moved workspace 3 from %d to %d (shown %t)", before.Y, after.Y, ok) | |
| 367 | + | } | |
| 368 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |
| 369 | + | t.Fatalf("landed at %d", got.Y) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func TestSwipeDropsWhenWorkspacesChange(t *testing.T) { | |
| 374 | + | r := startSwipe(t, nil) | |
| 375 | + | r.workspaces(t) | |
| 376 | + | r.begin() | |
| 377 | + | r.move(t, 0, 30) | |
| 378 | + | // A key switches workspace mid-swipe: the swipe lets go at once. | |
| 379 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |
| 380 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |
| 381 | + | t.Fatalf("workspace 2 at %v %t after the key", got, ok) | |
| 382 | + | } | |
| 383 | + | if _, ok := rectOf(s, 1); ok { | |
| 384 | + | t.Fatal("workspace 1 still sliding") | |
| 385 | + | } | |
| 386 | + | // The rest of the swipe moves nothing: its end leaves the view as is. | |
| 387 | + | r.at += 8 * time.Millisecond | |
| 388 | + | r.input <- ports.SwipeUpdate{DY: 30, Time: r.at} | |
| 389 | + | if got, ok := rectOf(r.end(t, false), 2); !ok || got.Y != 0 { | |
| 390 | + | t.Fatalf("workspace 2 at %v %t after the swipe", got, ok) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func TestSlideMovesPointerFocus(t *testing.T) { | |
| 395 | + | r := startSwipe(t, nil) | |
| 396 | + | threeColumns(t, r) | |
| 397 | + | // The pointer rests over column 2 (x 100: the view shows 2 and 3). | |
| 398 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} | |
| 399 | + | r.drain() | |
| 400 | + | r.flick(t, 10, -40, 0) | |
| 401 | + | r.settle(t) | |
| 402 | + | var focus ports.PointerFocus | |
| 403 | + | var motion ports.PointerMotionTo | |
| 404 | + | // Core sends the pointer update right after the scene: wait for it. | |
| 405 | + | for { | |
| 406 | + | var v ports.ClientCommand | |
| 407 | + | select { | |
| 408 | + | case v = <-r.commands: | |
| 409 | + | case <-time.After(50 * time.Millisecond): | |
| 36 | 410 | } | |
| 37 | - | if got := shown(r.swipe(t, down))["DP-2"]; len(got) != 0 { | |
| 38 | - | t.Fatalf("natural=%t: next workspace shows %v", natural, got) | |
| 411 | + | if v == nil { | |
| 412 | + | break | |
| 39 | 413 | } | |
| 40 | - | if got := shown(r.swipe(t, up))["DP-2"]; len(got) != 1 || got[0] != 1 { | |
| 41 | - | t.Fatalf("natural=%t: back shows %v", natural, got) | |
| 414 | + | switch v := v.(type) { | |
| 415 | + | case ports.PointerFocus: | |
| 416 | + | focus = v | |
| 417 | + | case ports.PointerMotionTo: | |
| 418 | + | motion = v | |
| 42 | 419 | } | |
| 43 | 420 | } | |
| 421 | + | // Column 1 slid under the still pointer; it follows the window. | |
| 422 | + | if focus.ID != 1 { | |
| 423 | + | t.Fatalf("pointer focus %+v", focus) | |
| 424 | + | } | |
| 425 | + | if motion.ID != 1 || motion.X != 100 || motion.DX != 0 { | |
| 426 | + | t.Fatalf("last motion %+v", motion) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestSwipeBeginWithoutEndSettlesTheFirst(t *testing.T) { | |
| 431 | + | r := startSwipe(t, nil) | |
| 432 | + | threeColumns(t, r) | |
| 433 | + | r.begin() | |
| 434 | + | s := r.move(t, -40, 0) | |
| 435 | + | moved, _ := rectOf(s, 2) | |
| 436 | + | // A begin without the previous end: the first swipe springs back. | |
| 437 | + | r.begin() | |
| 438 | + | scene(t, r.scenes) | |
| 439 | + | s = r.settle(t) | |
| 440 | + | if got, _ := rectOf(s, 2); got.X == moved.X || got.X != 0 { | |
| 441 | + | t.Fatalf("first swipe left column 2 at %d", got.X) | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) { | |
| 446 | + | r := startSwipe(t, nil) | |
| 447 | + | before, _ := rectOf(threeColumns(t, r), 2) | |
| 448 | + | r.begin() | |
| 449 | + | r.move(t, -30, 0) | |
| 450 | + | r.key(t, "Next", ports.ModAlt) | |
| 451 | + | r.key(t, "Prior", ports.ModAlt) | |
| 452 | + | // The same workspace is back, but the swipe was let go. | |
| 453 | + | r.at += 8 * time.Millisecond | |
| 454 | + | r.input <- ports.SwipeUpdate{DX: -30, Time: r.at} | |
| 455 | + | if got, _ := rectOf(r.end(t, false), 2); got != before { | |
| 456 | + | t.Fatalf("column 2 at %v, want %v", got, before) | |
| 457 | + | } | |
| 44 | 458 | } | |
| 45 | 459 | ||
| 46 | - | func TestSwipeChangesColumn(t *testing.T) { | |
| 47 | - | for _, overflow := range []string{"scroll", "fixed"} { | |
| 48 | - | for _, natural := range []bool{false, true} { | |
| 49 | - | r := startMulti(t, func(c *ports.Config) { | |
| 50 | - | c.Layout.Overflow = overflow | |
| 51 | - | c.Touchpad.NaturalScroll = natural | |
| 52 | - | }, right) | |
| 53 | - | r.mapWindow(t, 1) | |
| 54 | - | r.mapWindow(t, 2) | |
| 55 | - | left, right := ports.SwipeLeft, ports.SwipeRight | |
| 56 | - | if natural { | |
| 57 | - | left, right = right, left | |
| 58 | - | } | |
| 59 | - | r.swipe(t, left) | |
| 60 | - | if got := r.lastFocus(); got != 1 { | |
| 61 | - | t.Fatalf("%s natural=%t: left focuses %d", overflow, natural, got) | |
| 62 | - | } | |
| 63 | - | r.swipe(t, right) | |
| 64 | - | if got := r.lastFocus(); got != 2 { | |
| 65 | - | t.Fatalf("%s natural=%t: right focuses %d", overflow, natural, got) | |
| 66 | - | } | |
| 460 | + | func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { | |
| 461 | + | r := startSwipe(t, nil) | |
| 462 | + | before, _ := rectOf(threeColumns(t, r), 2) | |
| 463 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |
| 464 | + | // Window 4 on DP-2, so it has a workspace below to switch to. | |
| 465 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |
| 466 | + | scene(t, r.scenes) | |
| 467 | + | r.mapWindow(t, 4) | |
| 468 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} | |
| 469 | + | scene(t, r.scenes) | |
| 470 | + | r.drain() | |
| 471 | + | r.begin() | |
| 472 | + | r.move(t, -30, 0) | |
| 473 | + | // The pointer goes to DP-2 and a bind switches its workspace. | |
| 474 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |
| 475 | + | scene(t, r.scenes) | |
| 476 | + | for _, sc := range r.key(t, "Next", ports.ModAlt) { | |
| 477 | + | if _, ok := rectOf(sc, 4); ok { | |
| 478 | + | t.Fatal("DP-2 did not switch workspace") | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | // The swipe on DP-1 still follows the fingers. | |
| 482 | + | var got ports.Rect | |
| 483 | + | for _, sc := range receiveMove(t, r, -30) { | |
| 484 | + | if sc.Output == wide.Name { | |
| 485 | + | got, _ = rectOf(sc, 2) | |
| 67 | 486 | } | |
| 68 | 487 | } | |
| 488 | + | // 60 units at 2/3 px each: 40 px. | |
| 489 | + | if got.X-before.X != 40 { | |
| 490 | + | t.Fatalf("column 2 moved %d px", got.X-before.X) | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | // receiveMove sends one swipe update and returns every output's scene. | |
| 495 | + | func receiveMove(t *testing.T, r *swipeRig, dx float64) []ports.Scene { | |
| 496 | + | t.Helper() | |
| 497 | + | r.at += 8 * time.Millisecond | |
| 498 | + | r.input <- ports.SwipeUpdate{DX: dx, Time: r.at} | |
| 499 | + | return receive(t, r.scenes) | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) { | |
| 503 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |
| 504 | + | r.mapWindow(t, 1) | |
| 505 | + | r.mapWindow(t, 2) | |
| 506 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |
| 507 | + | // Windows 3 and 4 on DP-2, 4 focused there. | |
| 508 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |
| 509 | + | scene(t, r.scenes) | |
| 510 | + | r.mapWindow(t, 3) | |
| 511 | + | r.mapWindow(t, 4) | |
| 512 | + | // The swipe starts on DP-1 ... | |
| 513 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} | |
| 514 | + | scene(t, r.scenes) | |
| 515 | + | r.begin() | |
| 516 | + | for range 10 { | |
| 517 | + | r.at += 8 * time.Millisecond | |
| 518 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |
| 519 | + | } | |
| 520 | + | // ... and the pointer takes the focus to DP-2 before the fingers lift. | |
| 521 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |
| 522 | + | scene(t, r.scenes) | |
| 523 | + | r.drain() | |
| 524 | + | r.input <- ports.SwipeEnd{Time: r.at} | |
| 525 | + | for { | |
| 526 | + | var v ports.ClientCommand | |
| 527 | + | select { | |
| 528 | + | case v = <-r.commands: | |
| 529 | + | case <-time.After(50 * time.Millisecond): | |
| 530 | + | } | |
| 531 | + | if v == nil { | |
| 532 | + | break | |
| 533 | + | } | |
| 534 | + | if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 { | |
| 535 | + | t.Fatal("swipe from DP-1 moved the focus on DP-2") | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // drain waits for core to handle what was sent, then empties commands. | |
| 541 | + | func (r *swipeRig) drain() { | |
| 542 | + | for { | |
| 543 | + | select { | |
| 544 | + | case <-r.commands: | |
| 545 | + | case <-time.After(50 * time.Millisecond): | |
| 546 | + | return | |
| 547 | + | } | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |
| 552 | + | r := startSwipe(t, nil) | |
| 553 | + | threeColumns(t, r) | |
| 554 | + | r.key(t, "Left", ports.ModAlt) | |
| 555 | + | s := r.key(t, "Left", ports.ModAlt)[0] | |
| 556 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |
| 557 | + | t.Fatalf("column 1 at %d", got.X) | |
| 558 | + | } | |
| 69 | 559 | } | |
| @@ -1,22 +1,144 @@ | |||||
| 1 | package core_test | 1 | package core_test | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 4 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 5 | 8 | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 10 | + | "github.com/bnema/neferwl/internal/core" | |||
| 11 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |||
| 6 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 7 | ) | 14 | ) | ||
| 8 | 15 | ||||
| 9 | - | func (r *multiRig) swipe(t *testing.T, d ports.SwipeDir) []ports.Scene { | 16 | + | // swipeRig runs core on one 800x600 output with no gaps or border, a |
| 17 | + | // clock the test moves, and page flips the test sends. | |||
| 18 | + | type swipeRig struct { | |||
| 19 | + | *multiRig | |||
| 20 | + | frames chan ports.OutputFrame | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | now time.Time | |||
| 23 | + | // at is the device time of the next swipe event. | |||
| 24 | + | at time.Duration | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | var wide = ports.OutputInfo{Name: "DP-1", Width: 800, Height: 600, RefreshMilli: 60000} | |||
| 28 | + | ||||
| 29 | + | func startSwipe(t *testing.T, edit func(*ports.Config)) *swipeRig { | |||
| 10 | t.Helper() | 30 | t.Helper() | ||
| 11 | - | for len(r.commands) > 0 { | 31 | + | cfg := config.Defaults() |
| 12 | - | <-r.commands | 32 | + | cfg.Keyboard.CmdKey = "alt" |
| 33 | + | cfg.Border.Width = 0 | |||
| 34 | + | cfg.Layout.Gaps = 0 | |||
| 35 | + | cfg.Terminal.AutoOpen = "never" | |||
| 36 | + | if edit != nil { | |||
| 37 | + | edit(&cfg) | |||
| 13 | } | 38 | } | ||
| 14 | - | r.input <- ports.Swipe{Dir: d} | 39 | + | r := &swipeRig{frames: make(chan ports.OutputFrame), now: time.Unix(100, 0)} |
| 15 | - | return receive(t, r.scenes) | 40 | + | r.multiRig = &multiRig{ |
| 41 | + | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 64), | |||
| 42 | + | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | |||
| 43 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg, | |||
| 44 | + | } | |||
| 45 | + | clock := portsmocks.NewMockClock(t) | |||
| 46 | + | clock.EXPECT().Now().RunAndReturn(func() time.Time { | |||
| 47 | + | r.mu.Lock() | |||
| 48 | + | defer r.mu.Unlock() | |||
| 49 | + | return r.now | |||
| 50 | + | }).Maybe() | |||
| 51 | + | // The fallback timer never fires: frames come from the test. | |||
| 52 | + | clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer { | |||
| 53 | + | timer := portsmocks.NewMockTimer(t) | |||
| 54 | + | timer.EXPECT().C().Return(make(chan time.Time)).Maybe() | |||
| 55 | + | timer.EXPECT().Stop().Return(true).Maybe() | |||
| 56 | + | return timer | |||
| 57 | + | }).Maybe() | |||
| 58 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Clock: clock, Frames: r.frames}) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 63 | + | done := make(chan error, 1) | |||
| 64 | + | go func() { done <- c.Run(ctx) }() | |||
| 65 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 66 | + | r.plug(t, wide) | |||
| 67 | + | return r | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (r *swipeRig) advance(d time.Duration) { | |||
| 71 | + | r.mu.Lock() | |||
| 72 | + | r.now = r.now.Add(d) | |||
| 73 | + | r.mu.Unlock() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // begin starts a swipe; nothing moves until it picks an axis. | |||
| 77 | + | func (r *swipeRig) begin() { | |||
| 78 | + | r.at += time.Second | |||
| 79 | + | r.input <- ports.SwipeBegin{Time: r.at} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // move sends one swipe update and returns the scene it produced. | |||
| 83 | + | func (r *swipeRig) move(t *testing.T, dx, dy float64) ports.Scene { | |||
| 84 | + | t.Helper() | |||
| 85 | + | r.at += 8 * time.Millisecond | |||
| 86 | + | r.input <- ports.SwipeUpdate{DX: dx, DY: dy, Time: r.at} | |||
| 87 | + | return scene(t, r.scenes) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (r *swipeRig) end(t *testing.T, cancelled bool) ports.Scene { | |||
| 91 | + | t.Helper() | |||
| 92 | + | r.input <- ports.SwipeEnd{Cancelled: cancelled, Time: r.at} | |||
| 93 | + | return scene(t, r.scenes) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // frame moves the clock by d, sends a flip and returns the next scene. | |||
| 97 | + | func (r *swipeRig) frame(t *testing.T, d time.Duration) ports.Scene { | |||
| 98 | + | t.Helper() | |||
| 99 | + | r.advance(d) | |||
| 100 | + | r.frames <- ports.OutputFrame{Output: wide.Name} | |||
| 101 | + | return scene(t, r.scenes) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // settle moves the clock past any spring and returns the settled scene. | |||
| 105 | + | func (r *swipeRig) settle(t *testing.T) ports.Scene { | |||
| 106 | + | t.Helper() | |||
| 107 | + | return r.frame(t, 5*time.Second) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // flick sends a quick swipe of n updates of (dx, dy) and lifts the | |||
| 111 | + | // fingers; it returns the scene right after the lift. | |||
| 112 | + | func (r *swipeRig) flick(t *testing.T, n int, dx, dy float64) ports.Scene { | |||
| 113 | + | t.Helper() | |||
| 114 | + | r.begin() | |||
| 115 | + | for range n { | |||
| 116 | + | r.move(t, dx, dy) | |||
| 117 | + | } | |||
| 118 | + | return r.end(t, false) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // workspaces puts window 1 on the first workspace and 2 on the second, | |||
| 122 | + | // showing the first. | |||
| 123 | + | func (r *swipeRig) workspaces(t *testing.T) { | |||
| 124 | + | t.Helper() | |||
| 125 | + | r.mapWindow(t, 1) | |||
| 126 | + | r.key(t, "Next", ports.ModAlt) | |||
| 127 | + | r.mapWindow(t, 2) | |||
| 128 | + | r.key(t, "Prior", ports.ModAlt) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func rectOf(s ports.Scene, id ports.WindowID) (ports.Rect, bool) { | |||
| 132 | + | for _, w := range s.Windows { | |||
| 133 | + | if w.ID == id && !w.Hidden { | |||
| 134 | + | return w.Rect, true | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | return ports.Rect{}, false | |||
| 16 | } | 138 | } | ||
| 17 | 139 | ||||
| 18 | - | // lastFocus returns the last window core focused since the swipe. | 140 | + | func (r *swipeRig) focused(t *testing.T) ports.WindowID { |
| 19 | - | func (r *multiRig) lastFocus() ports.WindowID { | 141 | + | t.Helper() |
| 20 | var id ports.WindowID | 142 | var id ports.WindowID | ||
| 21 | for len(r.commands) > 0 { | 143 | for len(r.commands) > 0 { | ||
| 22 | if v, ok := (<-r.commands).(ports.FocusWindow); ok { | 144 | if v, ok := (<-r.commands).(ports.FocusWindow); ok { | ||
| @@ -26,44 +148,412 @@ func (r *multiRig) lastFocus() ports.WindowID { | |||||
| 26 | return id | 148 | return id | ||
| 27 | } | 149 | } | ||
| 28 | 150 | ||||
| 29 | - | func TestSwipeChangesWorkspace(t *testing.T) { | 151 | + | // threeColumns maps three windows of half the width; the view shows 2 |
| 152 | + | // and 3, with 3 focused. It returns the last scene. | |||
| 153 | + | func threeColumns(t *testing.T, r *swipeRig) ports.Scene { | |||
| 154 | + | t.Helper() | |||
| 155 | + | var s []ports.Scene | |||
| 156 | + | for id := ports.WindowID(1); id <= 3; id++ { | |||
| 157 | + | s = r.mapWindow(t, id) | |||
| 158 | + | } | |||
| 159 | + | return s[0] | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestSwipeFollowsFingers(t *testing.T) { | |||
| 163 | + | r := startSwipe(t, nil) | |||
| 164 | + | before, _ := rectOf(threeColumns(t, r), 2) | |||
| 165 | + | r.begin() | |||
| 166 | + | // Under the 16 unit threshold the swipe has not picked its axis: | |||
| 167 | + | // that movement is not applied (as in GNOME Shell and niri). | |||
| 168 | + | r.input <- ports.SwipeUpdate{DX: -10, Time: r.at} | |||
| 169 | + | s := r.move(t, -10, 0) | |||
| 170 | + | after, _ := rectOf(s, 2) | |||
| 171 | + | // 10 units of 1200 per 800 px view: 7 px, not snapped to a column. | |||
| 172 | + | if d := after.X - before.X; d != 7 { | |||
| 173 | + | t.Fatalf("column moved %d px with the fingers", d) | |||
| 174 | + | } | |||
| 175 | + | s = r.move(t, -30, 0) | |||
| 176 | + | if got, _ := rectOf(s, 2); got.X-before.X != 27 { | |||
| 177 | + | t.Fatalf("column moved %d px after 40 units", got.X-before.X) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestSwipeFlickLandsOnColumnWithSpring(t *testing.T) { | |||
| 182 | + | r := startSwipe(t, nil) | |||
| 183 | + | threeColumns(t, r) | |||
| 184 | + | // Focus is on 3 (right); the view shows 2 and 3. Flick right to left | |||
| 185 | + | // fingers (content moves left in classic scroll): view goes left. | |||
| 186 | + | r.focused(t) | |||
| 187 | + | s := r.flick(t, 10, -40, 0) | |||
| 188 | + | mid, ok := rectOf(s, 1) | |||
| 189 | + | if !ok { | |||
| 190 | + | t.Fatal("column 1 not shown while the view slides") | |||
| 191 | + | } | |||
| 192 | + | if mid.X == 0 { | |||
| 193 | + | t.Fatal("view jumped: no slide") | |||
| 194 | + | } | |||
| 195 | + | s = r.frame(t, 16*time.Millisecond) | |||
| 196 | + | next, _ := rectOf(s, 1) | |||
| 197 | + | if next.X <= mid.X || next.X > 0 { | |||
| 198 | + | t.Fatalf("spring does not approach the target: %d then %d", mid.X, next.X) | |||
| 199 | + | } | |||
| 200 | + | s = r.settle(t) | |||
| 201 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |||
| 202 | + | t.Fatalf("landed at %d, want column 1 at the left edge", got.X) | |||
| 203 | + | } | |||
| 204 | + | if id := r.focused(t); id != 1 && id != 2 { | |||
| 205 | + | t.Fatalf("focus %d after swiping left", id) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func TestSwipeNaturalScrollInverts(t *testing.T) { | |||
| 30 | for _, natural := range []bool{false, true} { | 210 | for _, natural := range []bool{false, true} { | ||
| 31 | - | r := startMulti(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }, right) | 211 | + | r := startSwipe(t, func(c *ports.Config) { c.Touchpad.NaturalScroll = natural }) |
| 32 | - | r.mapWindow(t, 1) | 212 | + | threeColumns(t, r) |
| 33 | - | down, up := ports.SwipeDown, ports.SwipeUp | 213 | + | dx := -40.0 |
| 34 | if natural { | 214 | if natural { | ||
| 35 | - | down, up = up, down | 215 | + | dx = 40 |
| 216 | + | } | |||
| 217 | + | r.flick(t, 10, dx, 0) | |||
| 218 | + | s := r.settle(t) | |||
| 219 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |||
| 220 | + | t.Fatalf("natural=%t: column 1 at %d", natural, got.X) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestSwipeWorkspaceSlides(t *testing.T) { | |||
| 226 | + | r := startSwipe(t, nil) | |||
| 227 | + | r.workspaces(t) | |||
| 228 | + | r.begin() | |||
| 229 | + | s := r.move(t, 0, 30) | |||
| 230 | + | one, ok1 := rectOf(s, 1) | |||
| 231 | + | two, ok2 := rectOf(s, 2) | |||
| 232 | + | if !ok1 || !ok2 { | |||
| 233 | + | t.Fatalf("both workspaces show while sliding: %v %v", ok1, ok2) | |||
| 234 | + | } | |||
| 235 | + | if one.Y >= 0 || two.Y <= 0 || two.Y-one.Y != 600 { | |||
| 236 | + | t.Fatalf("slide positions %d %d", one.Y, two.Y) | |||
| 237 | + | } | |||
| 238 | + | for range 8 { | |||
| 239 | + | r.move(t, 0, 30) | |||
| 240 | + | } | |||
| 241 | + | s = r.end(t, false) | |||
| 242 | + | if _, ok := rectOf(s, 2); !ok { | |||
| 243 | + | t.Fatal("target workspace hidden while landing") | |||
| 244 | + | } | |||
| 245 | + | s = r.settle(t) | |||
| 246 | + | if _, ok := rectOf(s, 1); ok { | |||
| 247 | + | t.Fatal("old workspace still shown") | |||
| 248 | + | } | |||
| 249 | + | if got, _ := rectOf(s, 2); got.Y != 0 { | |||
| 250 | + | t.Fatalf("workspace 2 at %d", got.Y) | |||
| 251 | + | } | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestSwipeWorkspaceCancelReturns(t *testing.T) { | |||
| 255 | + | r := startSwipe(t, nil) | |||
| 256 | + | r.workspaces(t) | |||
| 257 | + | r.begin() | |||
| 258 | + | for range 10 { | |||
| 259 | + | r.move(t, 0, 40) | |||
| 260 | + | } | |||
| 261 | + | r.end(t, true) | |||
| 262 | + | s := r.settle(t) | |||
| 263 | + | if got, ok := rectOf(s, 1); !ok || got.Y != 0 { | |||
| 264 | + | t.Fatalf("cancelled swipe left workspace 1 at %v %t", got, ok) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func TestSwipeFixedOverflowRunsFocusAction(t *testing.T) { | |||
| 269 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |||
| 270 | + | r.mapWindow(t, 1) | |||
| 271 | + | r.mapWindow(t, 2) | |||
| 272 | + | r.focused(t) | |||
| 273 | + | // The view cannot slide: nothing moves until the fingers lift. | |||
| 274 | + | r.begin() | |||
| 275 | + | for range 10 { | |||
| 276 | + | r.at += 8 * time.Millisecond | |||
| 277 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |||
| 278 | + | } | |||
| 279 | + | r.end(t, false) | |||
| 280 | + | if id := r.focused(t); id != 1 { | |||
| 281 | + | t.Fatalf("fixed overflow swipe left focuses %d", id) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestSwipeShortSwipeDoesNothingInFixedOverflow(t *testing.T) { | |||
| 286 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |||
| 287 | + | r.mapWindow(t, 1) | |||
| 288 | + | r.mapWindow(t, 2) | |||
| 289 | + | r.focused(t) | |||
| 290 | + | r.begin() | |||
| 291 | + | r.input <- ports.SwipeUpdate{DX: -20, Time: r.at + time.Second} | |||
| 292 | + | r.input <- ports.SwipeEnd{Time: r.at + 2*time.Second} | |||
| 293 | + | r.key(t, "Return", 0) | |||
| 294 | + | if id := r.focused(t); id != 0 { | |||
| 295 | + | t.Fatalf("slow short swipe moved focus to %d", id) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestKeyDuringSlideRetargets(t *testing.T) { | |||
| 300 | + | r := startSwipe(t, nil) | |||
| 301 | + | threeColumns(t, r) | |||
| 302 | + | s := r.flick(t, 10, -40, 0) | |||
| 303 | + | landing, _ := rectOf(s, 2) | |||
| 304 | + | // Back to column 3: the view heads there from where it is, no jump. | |||
| 305 | + | r.key(t, "Right", ports.ModAlt) | |||
| 306 | + | s = r.key(t, "Right", ports.ModAlt)[0] | |||
| 307 | + | if got, _ := rectOf(s, 2); got.X != landing.X { | |||
| 308 | + | t.Fatalf("view jumped from %d to %d", landing.X, got.X) | |||
| 309 | + | } | |||
| 310 | + | // The retargeted spring starts on the next frame. | |||
| 311 | + | r.frame(t, 16*time.Millisecond) | |||
| 312 | + | s = r.settle(t) | |||
| 313 | + | if got, _ := rectOf(s, 3); got.X != 400 { | |||
| 314 | + | t.Fatalf("column 3 landed at %d", got.X) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestSwipeFromEmptyWorkspaceLandsWithoutJump(t *testing.T) { | |||
| 319 | + | r := startSwipe(t, nil) | |||
| 320 | + | r.workspaces(t) | |||
| 321 | + | // Workspace 3 is the trailing empty one; start there and swipe up to 2. | |||
| 322 | + | r.key(t, "Next", ports.ModAlt) | |||
| 323 | + | r.key(t, "Next", ports.ModAlt) | |||
| 324 | + | r.begin() | |||
| 325 | + | var s ports.Scene | |||
| 326 | + | for range 6 { | |||
| 327 | + | s = r.move(t, 0, -40) | |||
| 328 | + | } | |||
| 329 | + | before, ok := rectOf(s, 2) | |||
| 330 | + | if !ok { | |||
| 331 | + | t.Fatal("workspace 2 not sliding in") | |||
| 332 | + | } | |||
| 333 | + | // Landing drops no numbered workspace here (the trailing one stays), | |||
| 334 | + | // but the view must not jump either way. | |||
| 335 | + | s = r.end(t, false) | |||
| 336 | + | after, _ := rectOf(s, 2) | |||
| 337 | + | if after.Y != before.Y { | |||
| 338 | + | t.Fatalf("landing jumped from %d to %d", before.Y, after.Y) | |||
| 339 | + | } | |||
| 340 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |||
| 341 | + | t.Fatalf("workspace 2 landed at %d", got.Y) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func TestSwipeLandingKeepsViewWhenEmptyWorkspaceDrops(t *testing.T) { | |||
| 346 | + | r := startSwipe(t, nil) | |||
| 347 | + | // 1 on the first workspace, 2 on the third; the second is empty and | |||
| 348 | + | // active, so leaving it drops it and renumbers the third. | |||
| 349 | + | r.mapWindow(t, 1) | |||
| 350 | + | r.key(t, "Next", ports.ModAlt) | |||
| 351 | + | r.key(t, "Next", ports.ModAlt) | |||
| 352 | + | r.mapWindow(t, 2) | |||
| 353 | + | r.key(t, "Prior", ports.ModAlt) | |||
| 354 | + | r.begin() | |||
| 355 | + | var s ports.Scene | |||
| 356 | + | for range 4 { | |||
| 357 | + | s = r.move(t, 0, 20) | |||
| 358 | + | } | |||
| 359 | + | before, ok := rectOf(s, 2) | |||
| 360 | + | if !ok { | |||
| 361 | + | t.Fatal("workspace 3 not sliding in") | |||
| 362 | + | } | |||
| 363 | + | s = r.end(t, false) | |||
| 364 | + | after, ok := rectOf(s, 2) | |||
| 365 | + | if !ok || after.Y != before.Y { | |||
| 366 | + | t.Fatalf("landing moved workspace 3 from %d to %d (shown %t)", before.Y, after.Y, ok) | |||
| 367 | + | } | |||
| 368 | + | if got, _ := rectOf(r.settle(t), 2); got.Y != 0 { | |||
| 369 | + | t.Fatalf("landed at %d", got.Y) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func TestSwipeDropsWhenWorkspacesChange(t *testing.T) { | |||
| 374 | + | r := startSwipe(t, nil) | |||
| 375 | + | r.workspaces(t) | |||
| 376 | + | r.begin() | |||
| 377 | + | r.move(t, 0, 30) | |||
| 378 | + | // A key switches workspace mid-swipe: the swipe lets go at once. | |||
| 379 | + | s := r.key(t, "Next", ports.ModAlt)[0] | |||
| 380 | + | if got, ok := rectOf(s, 2); !ok || got.Y != 0 { | |||
| 381 | + | t.Fatalf("workspace 2 at %v %t after the key", got, ok) | |||
| 382 | + | } | |||
| 383 | + | if _, ok := rectOf(s, 1); ok { | |||
| 384 | + | t.Fatal("workspace 1 still sliding") | |||
| 385 | + | } | |||
| 386 | + | // The rest of the swipe moves nothing: its end leaves the view as is. | |||
| 387 | + | r.at += 8 * time.Millisecond | |||
| 388 | + | r.input <- ports.SwipeUpdate{DY: 30, Time: r.at} | |||
| 389 | + | if got, ok := rectOf(r.end(t, false), 2); !ok || got.Y != 0 { | |||
| 390 | + | t.Fatalf("workspace 2 at %v %t after the swipe", got, ok) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func TestSlideMovesPointerFocus(t *testing.T) { | |||
| 395 | + | r := startSwipe(t, nil) | |||
| 396 | + | threeColumns(t, r) | |||
| 397 | + | // The pointer rests over column 2 (x 100: the view shows 2 and 3). | |||
| 398 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} | |||
| 399 | + | r.drain() | |||
| 400 | + | r.flick(t, 10, -40, 0) | |||
| 401 | + | r.settle(t) | |||
| 402 | + | var focus ports.PointerFocus | |||
| 403 | + | var motion ports.PointerMotionTo | |||
| 404 | + | // Core sends the pointer update right after the scene: wait for it. | |||
| 405 | + | for { | |||
| 406 | + | var v ports.ClientCommand | |||
| 407 | + | select { | |||
| 408 | + | case v = <-r.commands: | |||
| 409 | + | case <-time.After(50 * time.Millisecond): | |||
| 36 | } | 410 | } | ||
| 37 | - | if got := shown(r.swipe(t, down))["DP-2"]; len(got) != 0 { | 411 | + | if v == nil { |
| 38 | - | t.Fatalf("natural=%t: next workspace shows %v", natural, got) | 412 | + | break |
| 39 | } | 413 | } | ||
| 40 | - | if got := shown(r.swipe(t, up))["DP-2"]; len(got) != 1 || got[0] != 1 { | 414 | + | switch v := v.(type) { |
| 41 | - | t.Fatalf("natural=%t: back shows %v", natural, got) | 415 | + | case ports.PointerFocus: |
| 416 | + | focus = v | |||
| 417 | + | case ports.PointerMotionTo: | |||
| 418 | + | motion = v | |||
| 42 | } | 419 | } | ||
| 43 | } | 420 | } | ||
| 421 | + | // Column 1 slid under the still pointer; it follows the window. | |||
| 422 | + | if focus.ID != 1 { | |||
| 423 | + | t.Fatalf("pointer focus %+v", focus) | |||
| 424 | + | } | |||
| 425 | + | if motion.ID != 1 || motion.X != 100 || motion.DX != 0 { | |||
| 426 | + | t.Fatalf("last motion %+v", motion) | |||
| 427 | + | } | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func TestSwipeBeginWithoutEndSettlesTheFirst(t *testing.T) { | |||
| 431 | + | r := startSwipe(t, nil) | |||
| 432 | + | threeColumns(t, r) | |||
| 433 | + | r.begin() | |||
| 434 | + | s := r.move(t, -40, 0) | |||
| 435 | + | moved, _ := rectOf(s, 2) | |||
| 436 | + | // A begin without the previous end: the first swipe springs back. | |||
| 437 | + | r.begin() | |||
| 438 | + | scene(t, r.scenes) | |||
| 439 | + | s = r.settle(t) | |||
| 440 | + | if got, _ := rectOf(s, 2); got.X == moved.X || got.X != 0 { | |||
| 441 | + | t.Fatalf("first swipe left column 2 at %d", got.X) | |||
| 442 | + | } | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestSwipeIgnoredAfterSwitchingAwayAndBack(t *testing.T) { | |||
| 446 | + | r := startSwipe(t, nil) | |||
| 447 | + | before, _ := rectOf(threeColumns(t, r), 2) | |||
| 448 | + | r.begin() | |||
| 449 | + | r.move(t, -30, 0) | |||
| 450 | + | r.key(t, "Next", ports.ModAlt) | |||
| 451 | + | r.key(t, "Prior", ports.ModAlt) | |||
| 452 | + | // The same workspace is back, but the swipe was let go. | |||
| 453 | + | r.at += 8 * time.Millisecond | |||
| 454 | + | r.input <- ports.SwipeUpdate{DX: -30, Time: r.at} | |||
| 455 | + | if got, _ := rectOf(r.end(t, false), 2); got != before { | |||
| 456 | + | t.Fatalf("column 2 at %v, want %v", got, before) | |||
| 457 | + | } | |||
| 44 | } | 458 | } | ||
| 45 | 459 | ||||
| 46 | - | func TestSwipeChangesColumn(t *testing.T) { | 460 | + | func TestSwipeKeptWhenAnotherOutputSwitchesWorkspace(t *testing.T) { |
| 47 | - | for _, overflow := range []string{"scroll", "fixed"} { | 461 | + | r := startSwipe(t, nil) |
| 48 | - | for _, natural := range []bool{false, true} { | 462 | + | before, _ := rectOf(threeColumns(t, r), 2) |
| 49 | - | r := startMulti(t, func(c *ports.Config) { | 463 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) |
| 50 | - | c.Layout.Overflow = overflow | 464 | + | // Window 4 on DP-2, so it has a workspace below to switch to. |
| 51 | - | c.Touchpad.NaturalScroll = natural | 465 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} |
| 52 | - | }, right) | 466 | + | scene(t, r.scenes) |
| 53 | - | r.mapWindow(t, 1) | 467 | + | r.mapWindow(t, 4) |
| 54 | - | r.mapWindow(t, 2) | 468 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} |
| 55 | - | left, right := ports.SwipeLeft, ports.SwipeRight | 469 | + | scene(t, r.scenes) |
| 56 | - | if natural { | 470 | + | r.drain() |
| 57 | - | left, right = right, left | 471 | + | r.begin() |
| 58 | - | } | 472 | + | r.move(t, -30, 0) |
| 59 | - | r.swipe(t, left) | 473 | + | // The pointer goes to DP-2 and a bind switches its workspace. |
| 60 | - | if got := r.lastFocus(); got != 1 { | 474 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} |
| 61 | - | t.Fatalf("%s natural=%t: left focuses %d", overflow, natural, got) | 475 | + | scene(t, r.scenes) |
| 62 | - | } | 476 | + | for _, sc := range r.key(t, "Next", ports.ModAlt) { |
| 63 | - | r.swipe(t, right) | 477 | + | if _, ok := rectOf(sc, 4); ok { |
| 64 | - | if got := r.lastFocus(); got != 2 { | 478 | + | t.Fatal("DP-2 did not switch workspace") |
| 65 | - | t.Fatalf("%s natural=%t: right focuses %d", overflow, natural, got) | 479 | + | } |
| 66 | - | } | 480 | + | } |
| 481 | + | // The swipe on DP-1 still follows the fingers. | |||
| 482 | + | var got ports.Rect | |||
| 483 | + | for _, sc := range receiveMove(t, r, -30) { | |||
| 484 | + | if sc.Output == wide.Name { | |||
| 485 | + | got, _ = rectOf(sc, 2) | |||
| 67 | } | 486 | } | ||
| 68 | } | 487 | } | ||
| 488 | + | // 60 units at 2/3 px each: 40 px. | |||
| 489 | + | if got.X-before.X != 40 { | |||
| 490 | + | t.Fatalf("column 2 moved %d px", got.X-before.X) | |||
| 491 | + | } | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | // receiveMove sends one swipe update and returns every output's scene. | |||
| 495 | + | func receiveMove(t *testing.T, r *swipeRig, dx float64) []ports.Scene { | |||
| 496 | + | t.Helper() | |||
| 497 | + | r.at += 8 * time.Millisecond | |||
| 498 | + | r.input <- ports.SwipeUpdate{DX: dx, Time: r.at} | |||
| 499 | + | return receive(t, r.scenes) | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func TestDiscreteSwipeSkippedWhenPointerChangesOutput(t *testing.T) { | |||
| 503 | + | r := startSwipe(t, func(c *ports.Config) { c.Layout.Overflow = "fixed" }) | |||
| 504 | + | r.mapWindow(t, 1) | |||
| 505 | + | r.mapWindow(t, 2) | |||
| 506 | + | r.plug(t, ports.OutputInfo{Name: "DP-2", Width: 800, Height: 600, RefreshMilli: 60000}) | |||
| 507 | + | // Windows 3 and 4 on DP-2, 4 focused there. | |||
| 508 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |||
| 509 | + | scene(t, r.scenes) | |||
| 510 | + | r.mapWindow(t, 3) | |||
| 511 | + | r.mapWindow(t, 4) | |||
| 512 | + | // The swipe starts on DP-1 ... | |||
| 513 | + | r.input <- ports.PointerMotion{X: 100, Y: 300} | |||
| 514 | + | scene(t, r.scenes) | |||
| 515 | + | r.begin() | |||
| 516 | + | for range 10 { | |||
| 517 | + | r.at += 8 * time.Millisecond | |||
| 518 | + | r.input <- ports.SwipeUpdate{DX: -40, Time: r.at} | |||
| 519 | + | } | |||
| 520 | + | // ... and the pointer takes the focus to DP-2 before the fingers lift. | |||
| 521 | + | r.input <- ports.PointerMotion{X: 1000, Y: 300} | |||
| 522 | + | scene(t, r.scenes) | |||
| 523 | + | r.drain() | |||
| 524 | + | r.input <- ports.SwipeEnd{Time: r.at} | |||
| 525 | + | for { | |||
| 526 | + | var v ports.ClientCommand | |||
| 527 | + | select { | |||
| 528 | + | case v = <-r.commands: | |||
| 529 | + | case <-time.After(50 * time.Millisecond): | |||
| 530 | + | } | |||
| 531 | + | if v == nil { | |||
| 532 | + | break | |||
| 533 | + | } | |||
| 534 | + | if f, ok := v.(ports.FocusWindow); ok && f.ID == 3 { | |||
| 535 | + | t.Fatal("swipe from DP-1 moved the focus on DP-2") | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // drain waits for core to handle what was sent, then empties commands. | |||
| 541 | + | func (r *swipeRig) drain() { | |||
| 542 | + | for { | |||
| 543 | + | select { | |||
| 544 | + | case <-r.commands: | |||
| 545 | + | case <-time.After(50 * time.Millisecond): | |||
| 546 | + | return | |||
| 547 | + | } | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func TestKeyWithoutSlideMovesAtOnce(t *testing.T) { | |||
| 552 | + | r := startSwipe(t, nil) | |||
| 553 | + | threeColumns(t, r) | |||
| 554 | + | r.key(t, "Left", ports.ModAlt) | |||
| 555 | + | s := r.key(t, "Left", ports.ModAlt)[0] | |||
| 556 | + | if got, _ := rectOf(s, 1); got.X != 0 { | |||
| 557 | + | t.Fatalf("column 1 at %d", got.X) | |||
| 558 | + | } | |||
| 69 | } | 559 | } | ||
M internal/ports/config.go +14 -1
| @@ -121,11 +121,24 @@ type Config struct { | |||
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | 123 | // TouchpadConfig configures touchpads. NaturalScroll moves the content with | |
| 124 | - | // the fingers, for two-finger scroll and three-finger swipes. | |
| 124 | + | // the fingers, for two-finger scroll and three-finger swipes. Tap clicks on | |
| 125 | + | // a tap: one finger left, two right, three middle. AccelSpeed (-1 to 1) and | |
| 126 | + | // AccelProfile (AccelAdaptive or AccelFlat) set the pointer speed; | |
| 127 | + | // ScrollFactor multiplies two-finger scroll. | |
| 125 | 128 | type TouchpadConfig struct { | |
| 126 | 129 | NaturalScroll bool | |
| 130 | + | Tap bool | |
| 131 | + | AccelSpeed float64 | |
| 132 | + | AccelProfile string | |
| 133 | + | ScrollFactor float64 | |
| 127 | 134 | } | |
| 128 | 135 | ||
| 136 | + | // Touchpad pointer acceleration profiles. | |
| 137 | + | const ( | |
| 138 | + | AccelAdaptive = "adaptive" | |
| 139 | + | AccelFlat = "flat" | |
| 140 | + | ) | |
| 141 | + | ||
| 129 | 142 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | |
| 130 | 143 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 131 | 144 | type WorkspaceConfig struct { | |
| @@ -121,11 +121,24 @@ type Config struct { | |||||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | // TouchpadConfig configures touchpads. NaturalScroll moves the content with | 123 | // TouchpadConfig configures touchpads. NaturalScroll moves the content with | ||
| 124 | - | // the fingers, for two-finger scroll and three-finger swipes. | 124 | + | // the fingers, for two-finger scroll and three-finger swipes. Tap clicks on |
| 125 | + | // a tap: one finger left, two right, three middle. AccelSpeed (-1 to 1) and | |||
| 126 | + | // AccelProfile (AccelAdaptive or AccelFlat) set the pointer speed; | |||
| 127 | + | // ScrollFactor multiplies two-finger scroll. | |||
| 125 | type TouchpadConfig struct { | 128 | type TouchpadConfig struct { | ||
| 126 | NaturalScroll bool | 129 | NaturalScroll bool | ||
| 130 | + | Tap bool | |||
| 131 | + | AccelSpeed float64 | |||
| 132 | + | AccelProfile string | |||
| 133 | + | ScrollFactor float64 | |||
| 127 | } | 134 | } | ||
| 128 | 135 | ||||
| 136 | + | // Touchpad pointer acceleration profiles. | |||
| 137 | + | const ( | |||
| 138 | + | AccelAdaptive = "adaptive" | |||
| 139 | + | AccelFlat = "flat" | |||
| 140 | + | ) | |||
| 141 | + | ||||
| 129 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | 142 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | ||
| 130 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | 143 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | ||
| 131 | type WorkspaceConfig struct { | 144 | type WorkspaceConfig struct { | ||
M internal/ports/messages.go +22 -11
| @@ -368,20 +368,27 @@ type PointerAxis struct { | |||
| 368 | 368 | ||
| 369 | 369 | func (PointerAxis) inputEvent() {} | |
| 370 | 370 | ||
| 371 | - | // SwipeDir is the way the fingers moved on the touchpad. | |
| 372 | - | type SwipeDir uint8 | |
| 371 | + | // SwipeBegin starts a three-finger touchpad swipe. Time is the device | |
| 372 | + | // timestamp (CLOCK_MONOTONIC), shared by the updates and the end. | |
| 373 | + | type SwipeBegin struct{ Time time.Duration } | |
| 373 | 374 | ||
| 374 | - | const ( | |
| 375 | - | SwipeUp SwipeDir = iota | |
| 376 | - | SwipeDown | |
| 377 | - | SwipeLeft | |
| 378 | - | SwipeRight | |
| 379 | - | ) | |
| 375 | + | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY | |
| 376 | + | // (touchpad units, as libinput reports them, not natural-scroll inverted). | |
| 377 | + | type SwipeUpdate struct { | |
| 378 | + | DX, DY float64 | |
| 379 | + | Time time.Duration | |
| 380 | + | } | |
| 380 | 381 | ||
| 381 | - | // Swipe is a finished three-finger touchpad swipe. | |
| 382 | - | type Swipe struct{ Dir SwipeDir } | |
| 382 | + | // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled | |
| 383 | + | // the swipe (another finger landed). | |
| 384 | + | type SwipeEnd struct { | |
| 385 | + | Cancelled bool | |
| 386 | + | Time time.Duration | |
| 387 | + | } | |
| 383 | 388 | ||
| 384 | - | func (Swipe) inputEvent() {} | |
| 389 | + | func (SwipeBegin) inputEvent() {} | |
| 390 | + | func (SwipeUpdate) inputEvent() {} | |
| 391 | + | func (SwipeEnd) inputEvent() {} | |
| 385 | 392 | ||
| 386 | 393 | // OutputEvent carries output → core notifications. | |
| 387 | 394 | type OutputEvent interface{ outputEvent() } | |
| @@ -505,6 +512,10 @@ type FlipInfo struct { | |||
| 505 | 512 | Shows map[WindowID]uint64 | |
| 506 | 513 | } | |
| 507 | 514 | ||
| 515 | + | // OutputFrame carries output → core a completed page flip: the next frame | |
| 516 | + | // of a running animation is due. | |
| 517 | + | type OutputFrame struct{ Output string } | |
| 518 | + | ||
| 508 | 519 | // OutputRemoved carries output → core an unplugged display. | |
| 509 | 520 | type OutputRemoved struct{ Name string } | |
| 510 | 521 | ||
| @@ -368,20 +368,27 @@ type PointerAxis struct { | |||||
| 368 | 368 | ||||
| 369 | func (PointerAxis) inputEvent() {} | 369 | func (PointerAxis) inputEvent() {} | ||
| 370 | 370 | ||||
| 371 | - | // SwipeDir is the way the fingers moved on the touchpad. | 371 | + | // SwipeBegin starts a three-finger touchpad swipe. Time is the device |
| 372 | - | type SwipeDir uint8 | 372 | + | // timestamp (CLOCK_MONOTONIC), shared by the updates and the end. |
| 373 | + | type SwipeBegin struct{ Time time.Duration } | |||
| 373 | 374 | ||||
| 374 | - | const ( | 375 | + | // SwipeUpdate moves the fingers by the unaccelerated deltas DX, DY |
| 375 | - | SwipeUp SwipeDir = iota | 376 | + | // (touchpad units, as libinput reports them, not natural-scroll inverted). |
| 376 | - | SwipeDown | 377 | + | type SwipeUpdate struct { |
| 377 | - | SwipeLeft | 378 | + | DX, DY float64 |
| 378 | - | SwipeRight | 379 | + | Time time.Duration |
| 379 | - | ) | 380 | + | } |
| 380 | 381 | ||||
| 381 | - | // Swipe is a finished three-finger touchpad swipe. | 382 | + | // SwipeEnd lifts the fingers. Cancelled is set when libinput cancelled |
| 382 | - | type Swipe struct{ Dir SwipeDir } | 383 | + | // the swipe (another finger landed). |
| 384 | + | type SwipeEnd struct { | |||
| 385 | + | Cancelled bool | |||
| 386 | + | Time time.Duration | |||
| 387 | + | } | |||
| 383 | 388 | ||||
| 384 | - | func (Swipe) inputEvent() {} | 389 | + | func (SwipeBegin) inputEvent() {} |
| 390 | + | func (SwipeUpdate) inputEvent() {} | |||
| 391 | + | func (SwipeEnd) inputEvent() {} | |||
| 385 | 392 | ||||
| 386 | // OutputEvent carries output → core notifications. | 393 | // OutputEvent carries output → core notifications. | ||
| 387 | type OutputEvent interface{ outputEvent() } | 394 | type OutputEvent interface{ outputEvent() } | ||
| @@ -505,6 +512,10 @@ type FlipInfo struct { | |||||
| 505 | Shows map[WindowID]uint64 | 512 | Shows map[WindowID]uint64 | ||
| 506 | } | 513 | } | ||
| 507 | 514 | ||||
| 515 | + | // OutputFrame carries output → core a completed page flip: the next frame | |||
| 516 | + | // of a running animation is due. | |||
| 517 | + | type OutputFrame struct{ Output string } | |||
| 518 | + | ||||
| 508 | // OutputRemoved carries output → core an unplugged display. | 519 | // OutputRemoved carries output → core an unplugged display. | ||
| 509 | type OutputRemoved struct{ Name string } | 520 | type OutputRemoved struct{ Name string } | ||
| 510 | 521 | ||||