detached ffbd0df

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 UnverifiedB5690EEEBB952194 The 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`).
3333 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
3434 | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) |
3535 | `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` |
3640 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
3741 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
3842 | `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`).
5054
5155 ## Touchpad
5256
53-A three-finger swipe runs one action when the fingers lift:
57+A three-finger swipe follows the fingers:
5458
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.
6165
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.
6367
6468 ## HDR
6569
@@ -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 ## Touchpad55 ## 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 ## HDR68 ## 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
1818 - `sleep 1s`
1919 - `move <x> <y>` (output coordinates)
2020 - `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)
2222
2323 `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:
2424
@@ -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
4545 # cycle-column-width steps through these, then back to the equal share.
4646 layout.presets = 1/3, 1/2, 2/3, 1
4747
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.
5152 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
5259
5360 # on: moving a column or window to another workspace shows that workspace.
5461 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, 146 layout.presets = 1/3, 1/2, 2/3, 1
47 47
48-# Touchpad. A three-finger swipe up or down changes workspace, left or right48+# 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 = off52 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 = off61 focus.follow-move = off
M internal/adapters/config/config.go +26 -0
@@ -99,6 +99,9 @@ func Defaults() ports.Config {
9999 c.Layout.MaxColumns = 2
100100 c.Layout.Presets = []string{"1/3", "1/2", "2/3", "1"}
101101 c.Layout.Overflow = "scroll"
102+ c.Touchpad.Tap = true
103+ c.Touchpad.AccelProfile = ports.AccelAdaptive
104+ c.Touchpad.ScrollFactor = 1
102105 c.Binds = map[string]string{}
103106 for _, b := range defaultBinds {
104107 combos := []string{b.combo}
@@ -525,6 +528,29 @@ func set(c *ports.Config, key, v string) error {
525528 return err
526529 }
527530 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
528554 case "focus.follow-move":
529555 b, err := onOff(v)
530556 if err != nil {
@@ -99,6 +99,9 @@ func Defaults() ports.Config {
99 c.Layout.MaxColumns = 299 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 err528 return err
526 }529 }
527 c.Touchpad.NaturalScroll = b530 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) {
7373 }
7474 }
7575
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+
76113 func TestParse(t *testing.T) {
77114 c, w := parseString(t, `
78115 # 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 # comment115 # 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
2323 var layout ports.Layout
2424 defer func() { km.Close() }()
2525 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()))
2727 select {
2828 case <-ctx.Done():
2929 return ctx.Err()
@@ -107,7 +107,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
107107 }
108108 return 0
109109 }
110- now := uint32(time.Now().UnixMilli())
110+ now := msec(monotonic())
111111 if len(fields) > 0 && fields[0] == "move" {
112112 if len(fields) == 3 {
113113 x, ex := strconv.ParseFloat(fields[1], 64)
@@ -126,8 +126,8 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
126126 continue
127127 }
128128 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
131131 if ok {
132132 d, ok = dirs[fields[1]]
133133 }
@@ -135,8 +135,10 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
135135 log.Warn().Str("line", line).Msg("invalid swipe")
136136 continue
137137 }
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+ }
140142 }
141143 continue
142144 }
@@ -23,7 +23,7 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script
23 var layout ports.Layout23 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 0108 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 continue126 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) == 2130+ 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 continue136 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 err139+ if err := sendPointer(ev); err != nil {
140+ return err
141+ }
140 }142 }
141 continue143 continue
142 }144 }
M internal/adapters/headlessinput/input_test.go +13 -4
@@ -66,11 +66,11 @@ func TestPointerScript(t *testing.T) {
6666 script <- line
6767 }
6868 close(script)
69- input := make(chan ports.InputEvent, 8)
69+ input := make(chan ports.InputEvent, 32)
7070 if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil {
7171 t.Fatal(err)
7272 }
73- if len(input) != 6 {
73+ if len(input) != 5+swipeSteps+2 {
7474 t.Fatalf("events: %d", len(input))
7575 }
7676 m := (<-input).(ports.PointerMotion)
@@ -83,7 +83,16 @@ func TestPointerScript(t *testing.T) {
8383 t.Fatalf("got %+v want %+v", got, want)
8484 }
8585 }
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)
8897 }
8998 }
@@ -66,11 +66,11 @@ func TestPointerScript(t *testing.T) {
66 script <- line66 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 (
5656 keyboardEvent func(ev uintptr) uintptr
5757 keyboardKey func(kev uintptr) uint32
5858 keyboardState func(kev uintptr) int32
59+ keyboardUsec func(kev uintptr) uint64
5960 pointerEvent func(ev uintptr) uintptr
6061 pointerDX func(pev uintptr) float64
6162 pointerDY func(pev uintptr) float64
@@ -74,10 +75,19 @@ var (
7475 gestureCanceled func(gev uintptr) int32
7576 gestureDX func(gev uintptr) float64
7677 gestureDY func(gev uintptr) float64
78+ gestureUsec func(gev uintptr) uint64
7779 deviceRef func(dev uintptr) uintptr
7880 deviceUnref func(dev uintptr) uintptr
7981 hasNatural func(dev uintptr) int32
8082 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
8191 iface [2]uintptr
8292 active ports.Seat
8393 )
@@ -117,6 +127,7 @@ func load() error {
117127 reg(&keyboardEvent, "event_get_keyboard_event")
118128 reg(&keyboardKey, "event_keyboard_get_key")
119129 reg(&keyboardState, "event_keyboard_get_key_state")
130+ reg(&keyboardUsec, "event_keyboard_get_time_usec")
120131 reg(&pointerEvent, "event_get_pointer_event")
121132 reg(&pointerDX, "event_pointer_get_dx")
122133 reg(&pointerDY, "event_pointer_get_dy")
@@ -135,10 +146,19 @@ func load() error {
135146 reg(&gestureCanceled, "event_gesture_get_cancelled")
136147 reg(&gestureDX, "event_gesture_get_dx_unaccelerated")
137148 reg(&gestureDY, "event_gesture_get_dy_unaccelerated")
149+ reg(&gestureUsec, "event_gesture_get_time_usec")
138150 reg(&deviceRef, "device_ref")
139151 reg(&deviceUnref, "device_unref")
140152 reg(&hasNatural, "device_config_scroll_has_natural_scroll")
141153 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")
142162 iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
143163 fd, err := active.OpenDevice(goString(path))
144164 if err != nil {
@@ -218,7 +238,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
218238 }
219239 p := newPointer(opts.Layout)
220240 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{}}
222242 defer in.release()
223243 fd := getFD(li)
224244 fwd := newForwarder(opts.Log)
@@ -254,10 +274,10 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
254274 p.moved(opts.MoveCursor)
255275 case t := <-opts.Touchpads:
256276 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)
259279 }
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")
261281 default:
262282 }
263283 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -286,107 +306,150 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
286306 }
287307
288308 // 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.
291311 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
295315 }
296316
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 {
303335 log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected")
304336 }
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
305370 }
306371
307372 func (s *inputState) release() {
308- for dev := range s.scrollers {
373+ for dev := range s.devices {
309374 deviceUnref(dev)
310375 }
311- clear(s.scrollers)
376+ clear(s.devices)
312377 }
313378
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.
315381 const swipeFingers = 3
316382
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 }
321388
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}
326395 }
327396
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
333400 }
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
341407 }
342- return ports.SwipeRight, true
408+ s.owner = 0
409+ return ports.SwipeEnd{Cancelled: cancelled, Time: at}
343410 }
344411
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 }
346414
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.
347421 func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) {
348- now := uint32(time.Now().UnixMilli())
349422 log := opts.Log
350423 switch eventType(ev) {
351424 case evDeviceAdded:
352425 dev := eventDevice(ev)
353426 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)
357430 }
358431 case evDeviceRemoved:
359432 dev := eventDevice(ev)
360433 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)
364436 deviceUnref(dev)
365437 }
438+ // Core waits for the end of a swipe the device began.
439+ return in.swipes.end(dev, true, 0), nil
366440 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
368443 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
375446 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
386449 case evKeyboardKey:
387450 k := keyboardEvent(ev)
388451 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)))
390453 log.Debug().Uint32("code", code).Str("keysym", ke.Keysym).Bool("pressed", pressed).Uint8("mods", uint8(ke.Mods)).Msg("key")
391454 switch action, vt := hotkey(ke); action {
392455 case hotkeyQuit:
@@ -401,7 +464,8 @@ func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.Inpu
401464 pe := pointerEvent(ev)
402465 m := p.move(pointerDX(pe), pointerDY(pe))
403466 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)
405469 p.moved(opts.MoveCursor)
406470 if opts.LogMotion {
407471 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
410474 case evPointerAbs:
411475 // Absolute devices (tablets, VMs) map to the whole layout.
412476 pe := pointerEvent(ev)
477+ now := msec(pointerUsec(pe))
413478 b := p.bounds()
414479 if p.constraint.Mode == ports.ConstraintLock {
415480 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
421486 pe := pointerEvent(ev)
422487 b, pressed := pointerButton(pe), pointerBtnState(pe) == 1
423488 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
425490 case evScrollWheel, evScrollFinger, evScrollCont:
426491 pe := pointerEvent(ev)
427492 // The three scroll events follow each other as the sources do.
428493 source := ports.AxisSource(eventType(ev) - evScrollWheel)
429- a := ports.PointerAxis{Source: source, TimeMsec: now}
494+ a := ports.PointerAxis{Source: source, TimeMsec: msec(pointerUsec(pe))}
430495 // libinput axis 0 is vertical, 1 horizontal, as in wl_pointer.
431496 for i, s := range []*ports.ScrollAxis{&a.Vertical, &a.Horizontal} {
432497 if pointerHasAxis(pe, uint32(i)) == 0 {
433498 continue
434499 }
435500 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+ }
436505 if source == ports.AxisWheel {
437506 s.V120 = int32(scrollV120(pe, uint32(i)))
438507 } else {
@@ -56,6 +56,7 @@ var (
56 keyboardEvent func(ev uintptr) uintptr56 keyboardEvent func(ev uintptr) uintptr
57 keyboardKey func(kev uintptr) uint3257 keyboardKey func(kev uintptr) uint32
58 keyboardState func(kev uintptr) int3258 keyboardState func(kev uintptr) int32
59+ keyboardUsec func(kev uintptr) uint64
59 pointerEvent func(ev uintptr) uintptr60 pointerEvent func(ev uintptr) uintptr
60 pointerDX func(pev uintptr) float6461 pointerDX func(pev uintptr) float64
61 pointerDY func(pev uintptr) float6462 pointerDY func(pev uintptr) float64
@@ -74,10 +75,19 @@ var (
74 gestureCanceled func(gev uintptr) int3275 gestureCanceled func(gev uintptr) int32
75 gestureDX func(gev uintptr) float6476 gestureDX func(gev uintptr) float64
76 gestureDY func(gev uintptr) float6477 gestureDY func(gev uintptr) float64
78+ gestureUsec func(gev uintptr) uint64
77 deviceRef func(dev uintptr) uintptr79 deviceRef func(dev uintptr) uintptr
78 deviceUnref func(dev uintptr) uintptr80 deviceUnref func(dev uintptr) uintptr
79 hasNatural func(dev uintptr) int3281 hasNatural func(dev uintptr) int32
80 setNatural func(dev uintptr, on int32) int3282 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]uintptr91 iface [2]uintptr
82 active ports.Seat92 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 = t276 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 devices308 // inputState is the device state Run owns: touchpad config, the devices
289-// with a natural scroll setting (referenced until removed) and the swipe309+// 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.TouchpadConfig312+ touchpad ports.TouchpadConfig
293- scrollers map[uintptr]bool313+ devices map[uintptr]bool
294- swipes map[uintptr]swipe314+ 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 = 1320+ 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 = 3381 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, so384+// 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 = 300386+// 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 int391+ return nil
325- dx, dy float64392+ }
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. Short397+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, true403+
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, true405+ 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, true408+ 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.Log422 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)] = true428+ 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) == 1451 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}, nil480 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) == 1487 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}, nil489+ 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 continue498 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
22
33 import (
44 "testing"
5+ "time"
56
67 "github.com/bnema/neferwl/internal/ports"
78 )
@@ -29,24 +30,63 @@ func TestHotkey(t *testing.T) {
2930 }
3031 }
3132
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))
5090 }
5191 }
5292
@@ -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 string35+ const pad, other = 1, 2
35- s swipe36+ s.begin(pad, 3, time.Second)
36- dir ports.SwipeDir37+ if ev := s.begin(other, 3, time.Second); ev != nil {
37- ok bool38+ 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() {
150150 s := k.server
151151 if len(k.pressed) > 0 && k.focus == s.focused {
152152 _, 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))
154157 for key := range k.pressed {
155158 s.serial++
156159 for _, kb := range keyboards {
@@ -150,7 +150,10 @@ func (k *virtualKeyboard) release() {
150 s := k.server150 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)
9292 contents := make(chan ports.SurfaceContent, 64)
9393 cursorChanges := make(chan ports.CursorChange, 1)
9494 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)
9599 captures := make(chan ports.CaptureRequest, 32)
96100 captured := make(chan ports.CaptureDone, 64)
97101 outputFormats := make(chan ports.OutputFormats, 8)
@@ -103,7 +107,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
103107 // Only real sessions save scales: headless runs never touch the config file.
104108 scales = make(chan ports.ScaleChanged, 8)
105109 }
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}
107111 c, err := core.New(opts.Config, ch)
108112 if err != nil {
109113 return err
@@ -229,10 +233,12 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
229233 }()
230234 }
231235 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) }()
232238
233239 go func() {
234240 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}
236242 apply := newOutputApply(newOutputOverrides(opts.Config, hw == nil), logging.For(ctx, "app"))
237243 newRenderer := func(w, h int) (ports.Renderer, error) {
238244 r, err := vulkan.New(w, h)
@@ -358,6 +364,31 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
358364 }
359365 }
360366
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+
361392 func consumeScenes(ctx context.Context, scenes <-chan []ports.Scene, configErrors <-chan error, tap chan<- []ports.Scene, renderScenes chan []ports.Scene) {
362393 log := logging.For(ctx, "core")
363394 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 err113 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
33 import (
44 "strconv"
55 "strings"
6-
7- "github.com/bnema/neferwl/internal/ports"
86 )
97
108 type Action string
@@ -46,24 +44,6 @@ const (
4644 ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
4745 )
4846
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-
6747 // WorkspaceOp is what a numbered workspace action does.
6848 type WorkspaceOp int
6949
@@ -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 string8 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 int48 type WorkspaceOp int
69 49
M internal/core/core.go +94 -7
@@ -43,8 +43,12 @@ type Channels struct {
4343 Scales chan<- ports.ScaleChanged
4444 // Terminal enables automatic terminal opening according to terminal.auto-open.
4545 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.
4748 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
4852 }
4953
5054 // fullscreenGrace is how long after mapping a window's fullscreen request
@@ -114,6 +118,15 @@ type Core struct {
114118 // activity is when core last told wayland about user input
115119 // (ports.UserActivity), sent at most once per ActivityInterval.
116120 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
117130 }
118131
119132 func keyName(s string) string {
@@ -539,9 +552,57 @@ func (c *Core) publish(ctx context.Context) error {
539552 latest(c.ch.Scenes, scenes)
540553 c.publishState()
541554 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+ }
542563 return nil
543564 }
544565
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+
545606 // updateInhibit makes the shortcuts inhibitor of the keyboard focus the
546607 // active one and tells wayland when it changes.
547608 func (c *Core) updateInhibit(ctx context.Context) error {
@@ -703,6 +764,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
703764 }
704765
705766 func (c *Core) Run(ctx context.Context) error {
767+ defer c.stopFrame()
706768 // Startup commands run once per session; a config reload does not
707769 // run them again.
708770 for _, argv := range c.cfg.Startup {
@@ -835,6 +897,21 @@ func (c *Core) Run(ctx context.Context) error {
835897 if err := c.publish(ctx); err != nil {
836898 return nil
837899 }
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
838915 case ev, ok := <-c.ch.Output:
839916 if !ok {
840917 c.ch.Output = nil
@@ -887,6 +964,7 @@ func (c *Core) Run(ctx context.Context) error {
887964 }
888965 switch v := ev.(type) {
889966 case ports.PointerMotion:
967+ c.motionMsec = v.TimeMsec
890968 // A locked pointer stays still; relative motion still flows.
891969 if c.constraint.Mode != ports.ConstraintLock {
892970 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 {
912990 return nil
913991 }
914992 }
993+ c.pointerAt = [2]float64{x, y}
915994 if id != 0 {
916995 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 {
917996 return nil
@@ -978,14 +1057,18 @@ func (c *Core) Run(ctx context.Context) error {
9781057 }
9791058 }
9801059 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 {
9861067 return nil
9871068 }
988- if err := c.publish(ctx); err != nil {
1069+ continue
1070+ case ports.SwipeEnd:
1071+ if c.slid(ctx, c.swipeEnd(v)) != nil {
9891072 return nil
9901073 }
9911074 continue
@@ -1053,6 +1136,7 @@ func (c *Core) Run(ctx context.Context) error {
10531136 continue
10541137 }
10551138 before := c.cur().mon.Current()
1139+ swiped := c.swipedWorkspace()
10561140 c.layerFocus = 0 // a bind acts on the windows
10571141 effect := c.applyAction(action)
10581142 if effect.Quit {
@@ -1069,6 +1153,9 @@ func (c *Core) Run(ctx context.Context) error {
10691153 case c.ch.Spawn <- ports.SpawnRequest{Argv: argv}:
10701154 }
10711155 }
1156+ if c.swipedWorkspace() != swiped {
1157+ c.dropSwipe()
1158+ }
10721159 if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil {
10731160 return nil
10741161 }
@@ -43,8 +43,12 @@ type Channels struct {
43 Scales chan<- ports.ScaleChanged43 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 bool45 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.Clock48 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 request54 // 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 input118 // 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.Time120 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 nil563 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 the606 // 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 not768 // 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 nil898 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 = nil917 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 nil990 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 nil996 return nil
@@ -978,14 +1057,18 @@ func (c *Core) Run(ctx context.Context) error {
978 }1057 }
979 }1058 }
980 continue1059 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 = 01062+ 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 nil1067 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 nil1072 return nil
990 }1073 }
991 continue1074 continue
@@ -1053,6 +1136,7 @@ func (c *Core) Run(ctx context.Context) error {
1053 continue1136 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 windows1140 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 nil1160 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 {
121121 // ID stays with the workspace across reorder and monitor moves.
122122 ID uint64
123123 // 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
129133 Output Rect
130134 Usable Rect
131135 Gaps int
@@ -839,6 +843,8 @@ func (w *Workspace) columnX(i int) int {
839843 return x
840844 }
841845 func (w *Workspace) scroll() {
846+ before := w.ViewX
847+ defer func() { w.retarget(before) }()
842848 if w.Overflow == OverflowFixed {
843849 w.ViewX = 0
844850 return
@@ -866,8 +872,9 @@ func (w *Workspace) columnRects() []Rect {
866872 g := w.gap()
867873 y, h := w.Usable.Y+g, max(w.Usable.H-2*g, 0)
868874 rects := make([]Rect, len(w.Columns))
875+ view := w.ViewX + w.shiftPixels()
869876 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}
871878 }
872879 if w.Overflow == OverflowFixed && len(w.Columns) > 0 && w.Columns[w.Focus].FullWidth {
873880 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 uint64122 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 string124+ Name string
125- Overflow Overflow125+ Overflow Overflow
126- Columns []Column126+ Columns []Column
127- Focus int127+ Focus int
128- ViewX int128+ 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 Rect133 Output Rect
130 Usable Rect134 Usable Rect
131 Gaps int135 Gaps int
@@ -839,6 +843,8 @@ func (w *Workspace) columnX(i int) int {
839 return x843 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 = 0849 w.ViewX = 0
844 return850 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 + g880 rects[w.Focus].X = w.Usable.X + g
M internal/core/monitor.go +12 -1
@@ -25,6 +25,15 @@ type Monitor struct {
2525 named []NamedWorkspace
2626 // followMove shows the target workspace after a move to it.
2727 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
2837 }
2938
3039 // NamedWorkspace configures a named workspace. Zero MaxColumns and an empty
@@ -156,6 +165,7 @@ func (m *Monitor) normalize() {
156165 // Focus switches to the numbered workspace at index i, clamped to the list.
157166 func (m *Monitor) Focus(i int) {
158167 i = min(max(i, 0), len(m.Workspaces)-1)
168+ m.stopSwitch()
159169 m.shown = nil
160170 m.Active = i
161171 m.normalize()
@@ -170,6 +180,7 @@ func (m *Monitor) show(w *Workspace) {
170180 m.Focus(i)
171181 return
172182 }
183+ m.stopSwitch()
173184 m.shown = w
174185 m.normalize()
175186 }
@@ -626,7 +637,7 @@ func (m *Monitor) Layout() []Placement {
626637 result = append(result, Placement{ID: id, Hidden: true})
627638 }
628639 }
629- return result
640+ return m.slideLayout(result)
630641 }
631642
632643 func (m *Monitor) Output() Rect { return m.template.Output }
@@ -25,6 +25,15 @@ type Monitor struct {
25 named []NamedWorkspace25 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 bool27 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 empty39 // 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 = nil169 m.shown = nil
160 m.Active = i170 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 return181 return
172 }182 }
183+ m.stopSwitch()
173 m.shown = w184 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 result640+ 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 @@
11 package core_test
22
33 import (
4+ "context"
5+ "sync"
46 "testing"
7+ "time"
58
9+ "github.com/bnema/neferwl/internal/adapters/config"
10+ "github.com/bnema/neferwl/internal/core"
11+ portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
612 "github.com/bnema/neferwl/internal/ports"
13+ "github.com/stretchr/testify/mock"
714 )
815
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 {
1030 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)
1338 }
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
16138 }
17139
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()
20142 var id ports.WindowID
21143 for len(r.commands) > 0 {
22144 if v, ok := (<-r.commands).(ports.FocusWindow); ok {
@@ -26,44 +148,412 @@ func (r *multiRig) lastFocus() ports.WindowID {
26148 return id
27149 }
28150
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) {
30210 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
34214 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):
36410 }
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
39413 }
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
42419 }
43420 }
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+ }
44458 }
45459
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)
67486 }
68487 }
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+ }
69559 }
@@ -1,22 +1,144 @@
1 package core_test1 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.commands32+ 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.WindowID142 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 id148 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.SwipeUp213+ dx := -40.0
34 if natural {214 if natural {
35- down, up = up, down215+ 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 = overflow464+ // Window 4 on DP-2, so it has a workspace below to switch to.
51- c.Touchpad.NaturalScroll = natural465+ 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.SwipeRight469+ scene(t, r.scenes)
56- if natural {470+ r.drain()
57- left, right = right, left471+ 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 {
121121 }
122122
123123 // 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.
125128 type TouchpadConfig struct {
126129 NaturalScroll bool
130+ Tap bool
131+ AccelSpeed float64
132+ AccelProfile string
133+ ScrollFactor float64
127134 }
128135
136+// Touchpad pointer acceleration profiles.
137+const (
138+ AccelAdaptive = "adaptive"
139+ AccelFlat = "flat"
140+)
141+
129142 // WorkspaceConfig declares a bind-only named workspace, outside the numbered list.
130143 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
131144 type WorkspaceConfig struct {
@@ -121,11 +121,24 @@ type Config struct {
121 }121 }
122 122
123 // TouchpadConfig configures touchpads. NaturalScroll moves the content with123 // 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 bool129 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 {
368368
369369 func (PointerAxis) inputEvent() {}
370370
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 }
373374
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+}
380381
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+}
383388
384-func (Swipe) inputEvent() {}
389+func (SwipeBegin) inputEvent() {}
390+func (SwipeUpdate) inputEvent() {}
391+func (SwipeEnd) inputEvent() {}
385392
386393 // OutputEvent carries output → core notifications.
387394 type OutputEvent interface{ outputEvent() }
@@ -505,6 +512,10 @@ type FlipInfo struct {
505512 Shows map[WindowID]uint64
506513 }
507514
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+
508519 // OutputRemoved carries output → core an unplugged display.
509520 type OutputRemoved struct{ Name string }
510521
@@ -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 uint8372+// 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 = iota376+// (touchpad units, as libinput reports them, not natural-scroll inverted).
376- SwipeDown377+type SwipeUpdate struct {
377- SwipeLeft378+ DX, DY float64
378- SwipeRight379+ 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]uint64512 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close