feat(input): add three-finger touchpad swipes and natural scroll config (#35)
* feat(input): add three-finger touchpad swipes and natural scroll config * feat(input): track swipes per touchpad and script swipes headless * test(wayland): wait for output layouts to apply in capture tests
- commit
- e2957a853ada00ddc7f9198c4b0cc7e0b986ae74
- author
- bnema · · 2026-09-28 12:04 UTC
- committer
- GitHub · · 2026-09-28 12:04 UTC
- parent
- 8e13c90
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
17 files · +394 -42
M docs/config.md +14 -0
| @@ -28,6 +28,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 28 | 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | |
| 29 | 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | |
| 30 | 30 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | |
| 31 | + | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | |
| 31 | 32 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | |
| 32 | 33 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | |
| 33 | 34 | | `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 | | |
| @@ -43,6 +44,19 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 43 | 44 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 44 | 45 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | |
| 45 | 46 | ||
| 47 | + | ## Touchpad | |
| 48 | + | ||
| 49 | + | A three-finger swipe runs one action when the fingers lift: | |
| 50 | + | ||
| 51 | + | | Swipe | `natural-scroll = off` | `natural-scroll = on` | | |
| 52 | + | |---|---|---| | |
| 53 | + | | Up | `focus-workspace-up` | `focus-workspace-down` | | |
| 54 | + | | Down | `focus-workspace-down` | `focus-workspace-up` | | |
| 55 | + | | Left | `focus-column-left` | `focus-column-right` | | |
| 56 | + | | Right | `focus-column-right` | `focus-column-left` | | |
| 57 | + | ||
| 58 | + | Column swipes move the focus with either overflow: `scroll` brings the column into view, `fixed` only moves the focus. | |
| 59 | + | ||
| 46 | 60 | ## HDR | |
| 47 | 61 | ||
| 48 | 62 | `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits. | |
| @@ -28,6 +28,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | 28 | | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split | | ||
| 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | 29 | | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key | | ||
| 30 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | 30 | | `layout.presets` | `1/3, 1/2, 2/3, 1` | Widths for `cycle-column-width` (scroll overflow) | | ||
| 31 | + | | `touchpad.natural-scroll` | `off` | Content follows the fingers, for two-finger scroll and three-finger swipes (see [Touchpad](#touchpad)) | | |||
| 31 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | 32 | | `focus.follow-move` | `off` | Follow a column moved to another workspace | | ||
| 32 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | 33 | | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back | | ||
| 33 | | `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 | | 34 | | `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 | | ||
| @@ -43,6 +44,19 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 43 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 44 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
| 44 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | 45 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | ||
| 45 | 46 | ||||
| 47 | + | ## Touchpad | |||
| 48 | + | ||||
| 49 | + | A three-finger swipe runs one action when the fingers lift: | |||
| 50 | + | ||||
| 51 | + | | Swipe | `natural-scroll = off` | `natural-scroll = on` | | |||
| 52 | + | |---|---|---| | |||
| 53 | + | | Up | `focus-workspace-up` | `focus-workspace-down` | | |||
| 54 | + | | Down | `focus-workspace-down` | `focus-workspace-up` | | |||
| 55 | + | | Left | `focus-column-left` | `focus-column-right` | | |||
| 56 | + | | Right | `focus-column-right` | `focus-column-left` | | |||
| 57 | + | ||||
| 58 | + | Column swipes move the focus with either overflow: `scroll` brings the column into view, `fixed` only moves the focus. | |||
| 59 | + | ||||
| 46 | ## HDR | 60 | ## HDR | ||
| 47 | 61 | ||||
| 48 | `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits. | 62 | `output.<name>.hdr = on` sends HDR10 (BT.2020, PQ) to displays whose EDID and connector support it. The desktop stays SDR content, shown at `sdr-brightness` nits. | ||
M docs/headless.md +1 -0
| @@ -18,6 +18,7 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image | |||
| 18 | 18 | - `sleep 1s` | |
| 19 | 19 | - `move <x> <y>` (output coordinates) | |
| 20 | 20 | - `click [left|right|middle]`, `down <button>`, `up <button>` | |
| 21 | + | - `swipe up|down|left|right` (three-finger touchpad swipe) | |
| 21 | 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 | 24 | ||
| @@ -18,6 +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 | 22 | ||||
| 22 | `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: | ||
| 23 | 24 | ||||
M examples/config +5 -0
| @@ -42,6 +42,11 @@ layout.overflow = scroll | |||
| 42 | 42 | # cycle-column-width steps through these, then back to the equal share. | |
| 43 | 43 | layout.presets = 1/3, 1/2, 2/3, 1 | |
| 44 | 44 | ||
| 45 | + | # Touchpad. A three-finger swipe up or down changes workspace, left or right | |
| 46 | + | # changes column. natural-scroll = on moves the content with the fingers, | |
| 47 | + | # for scroll and swipes. | |
| 48 | + | touchpad.natural-scroll = off | |
| 49 | + | ||
| 45 | 50 | # on: moving a column or window to another workspace shows that workspace. | |
| 46 | 51 | focus.follow-move = off | |
| 47 | 52 | ||
| @@ -42,6 +42,11 @@ layout.overflow = scroll | |||||
| 42 | # cycle-column-width steps through these, then back to the equal share. | 42 | # cycle-column-width steps through these, then back to the equal share. | ||
| 43 | layout.presets = 1/3, 1/2, 2/3, 1 | 43 | layout.presets = 1/3, 1/2, 2/3, 1 | ||
| 44 | 44 | ||||
| 45 | + | # Touchpad. A three-finger swipe up or down changes workspace, left or right | |||
| 46 | + | # changes column. natural-scroll = on moves the content with the fingers, | |||
| 47 | + | # for scroll and swipes. | |||
| 48 | + | touchpad.natural-scroll = off | |||
| 49 | + | ||||
| 45 | # on: moving a column or window to another workspace shows that workspace. | 50 | # on: moving a column or window to another workspace shows that workspace. | ||
| 46 | focus.follow-move = off | 51 | focus.follow-move = off | ||
| 47 | 52 | ||||
M internal/adapters/config/config.go +6 -0
| @@ -489,6 +489,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 489 | 489 | } | |
| 490 | 490 | } | |
| 491 | 491 | c.Layout.Presets = list | |
| 492 | + | case "touchpad.natural-scroll": | |
| 493 | + | b, err := onOff(v) | |
| 494 | + | if err != nil { | |
| 495 | + | return err | |
| 496 | + | } | |
| 497 | + | c.Touchpad.NaturalScroll = b | |
| 492 | 498 | case "focus.follow-move": | |
| 493 | 499 | b, err := onOff(v) | |
| 494 | 500 | if err != nil { | |
| @@ -489,6 +489,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 489 | } | 489 | } | ||
| 490 | } | 490 | } | ||
| 491 | c.Layout.Presets = list | 491 | c.Layout.Presets = list | ||
| 492 | + | case "touchpad.natural-scroll": | |||
| 493 | + | b, err := onOff(v) | |||
| 494 | + | if err != nil { | |||
| 495 | + | return err | |||
| 496 | + | } | |||
| 497 | + | c.Touchpad.NaturalScroll = b | |||
| 492 | case "focus.follow-move": | 498 | case "focus.follow-move": | ||
| 493 | b, err := onOff(v) | 499 | b, err := onOff(v) | ||
| 494 | if err != nil { | 500 | if err != nil { | ||
M internal/adapters/config/config_test.go +14 -0
| @@ -59,6 +59,20 @@ func TestFollowMove(t *testing.T) { | |||
| 59 | 59 | } | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | func TestTouchpadNaturalScroll(t *testing.T) { | |
| 63 | + | if Defaults().Touchpad.NaturalScroll { | |
| 64 | + | t.Fatal("natural scroll on by default") | |
| 65 | + | } | |
| 66 | + | c, w := parseString(t, "touchpad.natural-scroll = on\n") | |
| 67 | + | if !c.Touchpad.NaturalScroll || len(w) != 0 { | |
| 68 | + | t.Fatal(c.Touchpad, w) | |
| 69 | + | } | |
| 70 | + | c, w = parseString(t, "touchpad.natural-scroll = maybe\n") | |
| 71 | + | if c.Touchpad.NaturalScroll || len(w) != 1 { | |
| 72 | + | t.Fatal(c.Touchpad, w) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 62 | 76 | func TestParse(t *testing.T) { | |
| 63 | 77 | c, w := parseString(t, ` | |
| 64 | 78 | # comment | |
| @@ -59,6 +59,20 @@ func TestFollowMove(t *testing.T) { | |||||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | + | func TestTouchpadNaturalScroll(t *testing.T) { | |||
| 63 | + | if Defaults().Touchpad.NaturalScroll { | |||
| 64 | + | t.Fatal("natural scroll on by default") | |||
| 65 | + | } | |||
| 66 | + | c, w := parseString(t, "touchpad.natural-scroll = on\n") | |||
| 67 | + | if !c.Touchpad.NaturalScroll || len(w) != 0 { | |||
| 68 | + | t.Fatal(c.Touchpad, w) | |||
| 69 | + | } | |||
| 70 | + | c, w = parseString(t, "touchpad.natural-scroll = maybe\n") | |||
| 71 | + | if c.Touchpad.NaturalScroll || len(w) != 1 { | |||
| 72 | + | t.Fatal(c.Touchpad, w) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 62 | func TestParse(t *testing.T) { | 76 | func TestParse(t *testing.T) { | ||
| 63 | c, w := parseString(t, ` | 77 | c, w := parseString(t, ` | ||
| 64 | # comment | 78 | # comment | ||
M internal/adapters/headlessinput/input.go +15 -0
| @@ -125,6 +125,21 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||
| 125 | 125 | log.Warn().Str("line", line).Msg("invalid move") | |
| 126 | 126 | continue | |
| 127 | 127 | } | |
| 128 | + | if len(fields) > 0 && fields[0] == "swipe" { | |
| 129 | + | dirs := map[string]ports.SwipeDir{"up": ports.SwipeUp, "down": ports.SwipeDown, "left": ports.SwipeLeft, "right": ports.SwipeRight} | |
| 130 | + | d, ok := ports.SwipeDir(0), len(fields) == 2 | |
| 131 | + | if ok { | |
| 132 | + | d, ok = dirs[fields[1]] | |
| 133 | + | } | |
| 134 | + | if !ok { | |
| 135 | + | log.Warn().Str("line", line).Msg("invalid swipe") | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | if err := sendPointer(ports.Swipe{Dir: d}); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 141 | + | continue | |
| 142 | + | } | |
| 128 | 143 | if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") { | |
| 129 | 144 | name := "left" | |
| 130 | 145 | if len(fields) == 2 { | |
| @@ -125,6 +125,21 @@ func Run(ctx context.Context, km *xkb.Keymap, keymaps <-chan *xkb.Keymap, script | |||||
| 125 | log.Warn().Str("line", line).Msg("invalid move") | 125 | log.Warn().Str("line", line).Msg("invalid move") | ||
| 126 | continue | 126 | continue | ||
| 127 | } | 127 | } | ||
| 128 | + | if len(fields) > 0 && fields[0] == "swipe" { | |||
| 129 | + | dirs := map[string]ports.SwipeDir{"up": ports.SwipeUp, "down": ports.SwipeDown, "left": ports.SwipeLeft, "right": ports.SwipeRight} | |||
| 130 | + | d, ok := ports.SwipeDir(0), len(fields) == 2 | |||
| 131 | + | if ok { | |||
| 132 | + | d, ok = dirs[fields[1]] | |||
| 133 | + | } | |||
| 134 | + | if !ok { | |||
| 135 | + | log.Warn().Str("line", line).Msg("invalid swipe") | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 138 | + | if err := sendPointer(ports.Swipe{Dir: d}); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 128 | if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") { | 143 | if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") { | ||
| 129 | name := "left" | 144 | name := "left" | ||
| 130 | if len(fields) == 2 { | 145 | if len(fields) == 2 { | ||
M internal/adapters/headlessinput/input_test.go +6 -3
| @@ -61,8 +61,8 @@ func TestPointerScript(t *testing.T) { | |||
| 61 | 61 | if err != nil { | |
| 62 | 62 | t.Skip(err) | |
| 63 | 63 | } | |
| 64 | - | script := make(chan string, 4) | |
| 65 | - | for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} { | |
| 64 | + | script := make(chan string, 8) | |
| 65 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left"} { | |
| 66 | 66 | script <- line | |
| 67 | 67 | } | |
| 68 | 68 | close(script) | |
| @@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) { | |||
| 70 | 70 | if err := Run(context.Background(), km, nil, script, input, nil, nil, logging.For(context.Background(), "input")); err != nil { | |
| 71 | 71 | t.Fatal(err) | |
| 72 | 72 | } | |
| 73 | - | if len(input) != 5 { | |
| 73 | + | if len(input) != 6 { | |
| 74 | 74 | t.Fatalf("events: %d", len(input)) | |
| 75 | 75 | } | |
| 76 | 76 | m := (<-input).(ports.PointerMotion) | |
| @@ -83,4 +83,7 @@ func TestPointerScript(t *testing.T) { | |||
| 83 | 83 | t.Fatalf("got %+v want %+v", got, want) | |
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | + | if s := (<-input).(ports.Swipe); s.Dir != ports.SwipeLeft { | |
| 87 | + | t.Fatal(s) | |
| 88 | + | } | |
| 86 | 89 | } | |
| @@ -61,8 +61,8 @@ func TestPointerScript(t *testing.T) { | |||||
| 61 | if err != nil { | 61 | if err != nil { | ||
| 62 | t.Skip(err) | 62 | t.Skip(err) | ||
| 63 | } | 63 | } | ||
| 64 | - | script := make(chan string, 4) | 64 | + | script := make(chan string, 8) |
| 65 | - | for _, line := range []string{"move 12.5 45", "click", "down right", "up right"} { | 65 | + | for _, line := range []string{"move 12.5 45", "click", "down right", "up right", "swipe sideways", "swipe left"} { |
| 66 | script <- line | 66 | script <- line | ||
| 67 | } | 67 | } | ||
| 68 | close(script) | 68 | close(script) | ||
| @@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) { | |||||
| 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) != 5 { | 73 | + | if len(input) != 6 { |
| 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,4 +83,7 @@ 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 { | |||
| 87 | + | t.Fatal(s) | |||
| 88 | + | } | |||
| 86 | } | 89 | } | ||
M internal/adapters/libinput/libinput.go +127 -5
| @@ -30,6 +30,9 @@ const ( | |||
| 30 | 30 | evScrollWheel = 404 | |
| 31 | 31 | evScrollFinger = 405 | |
| 32 | 32 | evScrollCont = 406 | |
| 33 | + | evSwipeBegin = 800 | |
| 34 | + | evSwipeUpdate = 801 | |
| 35 | + | evSwipeEnd = 802 | |
| 33 | 36 | ) | |
| 34 | 37 | ||
| 35 | 38 | var ( | |
| @@ -66,6 +69,15 @@ var ( | |||
| 66 | 69 | pointerHasAxis func(pev uintptr, axis uint32) int32 | |
| 67 | 70 | scrollValue func(pev uintptr, axis uint32) float64 | |
| 68 | 71 | scrollV120 func(pev uintptr, axis uint32) float64 | |
| 72 | + | gestureEvent func(ev uintptr) uintptr | |
| 73 | + | gestureFingers func(gev uintptr) int32 | |
| 74 | + | gestureCanceled func(gev uintptr) int32 | |
| 75 | + | gestureDX func(gev uintptr) float64 | |
| 76 | + | gestureDY func(gev uintptr) float64 | |
| 77 | + | deviceRef func(dev uintptr) uintptr | |
| 78 | + | deviceUnref func(dev uintptr) uintptr | |
| 79 | + | hasNatural func(dev uintptr) int32 | |
| 80 | + | setNatural func(dev uintptr, on int32) int32 | |
| 69 | 81 | iface [2]uintptr | |
| 70 | 82 | active ports.Seat | |
| 71 | 83 | ) | |
| @@ -118,6 +130,15 @@ func load() error { | |||
| 118 | 130 | reg(&pointerHasAxis, "event_pointer_has_axis") | |
| 119 | 131 | reg(&scrollValue, "event_pointer_get_scroll_value") | |
| 120 | 132 | reg(&scrollV120, "event_pointer_get_scroll_value_v120") | |
| 133 | + | reg(&gestureEvent, "event_get_gesture_event") | |
| 134 | + | reg(&gestureFingers, "event_gesture_get_finger_count") | |
| 135 | + | reg(&gestureCanceled, "event_gesture_get_cancelled") | |
| 136 | + | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | |
| 137 | + | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | |
| 138 | + | reg(&deviceRef, "device_ref") | |
| 139 | + | reg(&deviceUnref, "device_unref") | |
| 140 | + | reg(&hasNatural, "device_config_scroll_has_natural_scroll") | |
| 141 | + | reg(&setNatural, "device_config_scroll_set_natural_scroll_enabled") | |
| 121 | 142 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | |
| 122 | 143 | fd, err := active.OpenDevice(goString(path)) | |
| 123 | 144 | if err != nil { | |
| @@ -154,7 +175,10 @@ type Options struct { | |||
| 154 | 175 | // Constraints holds the pointer lock or confinement of the focused | |
| 155 | 176 | // window, in global logical coordinates. | |
| 156 | 177 | Constraints <-chan ports.PointerConstraint | |
| 157 | - | Active <-chan bool | |
| 178 | + | // Touchpad is the initial touchpad config; Touchpads replaces it live. | |
| 179 | + | Touchpad ports.TouchpadConfig | |
| 180 | + | Touchpads <-chan ports.TouchpadConfig | |
| 181 | + | Active <-chan bool | |
| 158 | 182 | // MoveCursor, when set, places the hardware cursor as soon as motion is | |
| 159 | 183 | // read, before core sees the event: the output under the pointer and | |
| 160 | 184 | // the physical position on it. | |
| @@ -194,6 +218,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 194 | 218 | } | |
| 195 | 219 | p := newPointer(opts.Layout) | |
| 196 | 220 | p.moved(opts.MoveCursor) | |
| 221 | + | in := &inputState{touchpad: opts.Touchpad, scrollers: map[uintptr]bool{}, swipes: map[uintptr]swipe{}} | |
| 222 | + | defer in.release() | |
| 197 | 223 | fd := getFD(li) | |
| 198 | 224 | fwd := newForwarder(opts.Log) | |
| 199 | 225 | fwdCtx, stopFwd := context.WithCancel(ctx) | |
| @@ -226,6 +252,12 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 226 | 252 | case c := <-opts.Constraints: | |
| 227 | 253 | p.constrain(c) | |
| 228 | 254 | p.moved(opts.MoveCursor) | |
| 255 | + | case t := <-opts.Touchpads: | |
| 256 | + | in.touchpad = t | |
| 257 | + | for dev := range in.scrollers { | |
| 258 | + | in.applyNatural(dev, opts.Log) | |
| 259 | + | } | |
| 260 | + | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Msg("touchpad reloaded") | |
| 229 | 261 | default: | |
| 230 | 262 | } | |
| 231 | 263 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | |
| @@ -240,7 +272,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 240 | 272 | opts.Log.Warn().Msg("libinput_dispatch failed") | |
| 241 | 273 | } | |
| 242 | 274 | for ev := getEvent(li); ev != 0; ev = getEvent(li) { | |
| 243 | - | out, err := translate(ev, opts, p) | |
| 275 | + | out, err := translate(ev, opts, p, in) | |
| 244 | 276 | eventDestroy(ev) | |
| 245 | 277 | if err != nil { | |
| 246 | 278 | return err | |
| @@ -253,14 +285,104 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||
| 253 | 285 | return nil | |
| 254 | 286 | } | |
| 255 | 287 | ||
| 256 | - | func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | |
| 288 | + | // 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. | |
| 291 | + | type inputState struct { | |
| 292 | + | touchpad ports.TouchpadConfig | |
| 293 | + | scrollers map[uintptr]bool | |
| 294 | + | swipes map[uintptr]swipe | |
| 295 | + | } | |
| 296 | + | ||
| 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 { | |
| 303 | + | log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected") | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (s *inputState) release() { | |
| 308 | + | for dev := range s.scrollers { | |
| 309 | + | deviceUnref(dev) | |
| 310 | + | } | |
| 311 | + | clear(s.scrollers) | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // swipeFingers is the finger count of the swipes neferwl handles. | |
| 315 | + | const swipeFingers = 3 | |
| 316 | + | ||
| 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 | |
| 321 | + | ||
| 322 | + | // swipe adds up one touchpad swipe until the fingers lift. | |
| 323 | + | type swipe struct { | |
| 324 | + | fingers int | |
| 325 | + | dx, dy float64 | |
| 326 | + | } | |
| 327 | + | ||
| 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 | |
| 333 | + | } | |
| 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 | |
| 341 | + | } | |
| 342 | + | return ports.SwipeRight, true | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func abs(v float64) float64 { return max(v, -v) } | |
| 346 | + | ||
| 347 | + | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | |
| 257 | 348 | now := uint32(time.Now().UnixMilli()) | |
| 258 | 349 | log := opts.Log | |
| 259 | 350 | switch eventType(ev) { | |
| 260 | 351 | case evDeviceAdded: | |
| 261 | - | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added") | |
| 352 | + | dev := eventDevice(ev) | |
| 353 | + | 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) | |
| 357 | + | } | |
| 262 | 358 | case evDeviceRemoved: | |
| 263 | - | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed") | |
| 359 | + | dev := eventDevice(ev) | |
| 360 | + | 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) | |
| 364 | + | deviceUnref(dev) | |
| 365 | + | } | |
| 366 | + | case evSwipeBegin: | |
| 367 | + | in.swipes[eventDevice(ev)] = swipe{fingers: int(gestureFingers(gestureEvent(ev)))} | |
| 368 | + | 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 | + | } | |
| 375 | + | 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 | + | } | |
| 264 | 386 | case evKeyboardKey: | |
| 265 | 387 | k := keyboardEvent(ev) | |
| 266 | 388 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | |
| @@ -30,6 +30,9 @@ const ( | |||||
| 30 | evScrollWheel = 404 | 30 | evScrollWheel = 404 | ||
| 31 | evScrollFinger = 405 | 31 | evScrollFinger = 405 | ||
| 32 | evScrollCont = 406 | 32 | evScrollCont = 406 | ||
| 33 | + | evSwipeBegin = 800 | |||
| 34 | + | evSwipeUpdate = 801 | |||
| 35 | + | evSwipeEnd = 802 | |||
| 33 | ) | 36 | ) | ||
| 34 | 37 | ||||
| 35 | var ( | 38 | var ( | ||
| @@ -66,6 +69,15 @@ var ( | |||||
| 66 | pointerHasAxis func(pev uintptr, axis uint32) int32 | 69 | pointerHasAxis func(pev uintptr, axis uint32) int32 | ||
| 67 | scrollValue func(pev uintptr, axis uint32) float64 | 70 | scrollValue func(pev uintptr, axis uint32) float64 | ||
| 68 | scrollV120 func(pev uintptr, axis uint32) float64 | 71 | scrollV120 func(pev uintptr, axis uint32) float64 | ||
| 72 | + | gestureEvent func(ev uintptr) uintptr | |||
| 73 | + | gestureFingers func(gev uintptr) int32 | |||
| 74 | + | gestureCanceled func(gev uintptr) int32 | |||
| 75 | + | gestureDX func(gev uintptr) float64 | |||
| 76 | + | gestureDY func(gev uintptr) float64 | |||
| 77 | + | deviceRef func(dev uintptr) uintptr | |||
| 78 | + | deviceUnref func(dev uintptr) uintptr | |||
| 79 | + | hasNatural func(dev uintptr) int32 | |||
| 80 | + | setNatural func(dev uintptr, on int32) int32 | |||
| 69 | iface [2]uintptr | 81 | iface [2]uintptr | ||
| 70 | active ports.Seat | 82 | active ports.Seat | ||
| 71 | ) | 83 | ) | ||
| @@ -118,6 +130,15 @@ func load() error { | |||||
| 118 | reg(&pointerHasAxis, "event_pointer_has_axis") | 130 | reg(&pointerHasAxis, "event_pointer_has_axis") | ||
| 119 | reg(&scrollValue, "event_pointer_get_scroll_value") | 131 | reg(&scrollValue, "event_pointer_get_scroll_value") | ||
| 120 | reg(&scrollV120, "event_pointer_get_scroll_value_v120") | 132 | reg(&scrollV120, "event_pointer_get_scroll_value_v120") | ||
| 133 | + | reg(&gestureEvent, "event_get_gesture_event") | |||
| 134 | + | reg(&gestureFingers, "event_gesture_get_finger_count") | |||
| 135 | + | reg(&gestureCanceled, "event_gesture_get_cancelled") | |||
| 136 | + | reg(&gestureDX, "event_gesture_get_dx_unaccelerated") | |||
| 137 | + | reg(&gestureDY, "event_gesture_get_dy_unaccelerated") | |||
| 138 | + | reg(&deviceRef, "device_ref") | |||
| 139 | + | reg(&deviceUnref, "device_unref") | |||
| 140 | + | reg(&hasNatural, "device_config_scroll_has_natural_scroll") | |||
| 141 | + | reg(&setNatural, "device_config_scroll_set_natural_scroll_enabled") | |||
| 121 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | 142 | iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr { | ||
| 122 | fd, err := active.OpenDevice(goString(path)) | 143 | fd, err := active.OpenDevice(goString(path)) | ||
| 123 | if err != nil { | 144 | if err != nil { | ||
| @@ -154,7 +175,10 @@ type Options struct { | |||||
| 154 | // Constraints holds the pointer lock or confinement of the focused | 175 | // Constraints holds the pointer lock or confinement of the focused | ||
| 155 | // window, in global logical coordinates. | 176 | // window, in global logical coordinates. | ||
| 156 | Constraints <-chan ports.PointerConstraint | 177 | Constraints <-chan ports.PointerConstraint | ||
| 157 | - | Active <-chan bool | 178 | + | // Touchpad is the initial touchpad config; Touchpads replaces it live. |
| 179 | + | Touchpad ports.TouchpadConfig | |||
| 180 | + | Touchpads <-chan ports.TouchpadConfig | |||
| 181 | + | Active <-chan bool | |||
| 158 | // MoveCursor, when set, places the hardware cursor as soon as motion is | 182 | // MoveCursor, when set, places the hardware cursor as soon as motion is | ||
| 159 | // read, before core sees the event: the output under the pointer and | 183 | // read, before core sees the event: the output under the pointer and | ||
| 160 | // the physical position on it. | 184 | // the physical position on it. | ||
| @@ -194,6 +218,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 194 | } | 218 | } | ||
| 195 | p := newPointer(opts.Layout) | 219 | p := newPointer(opts.Layout) | ||
| 196 | p.moved(opts.MoveCursor) | 220 | p.moved(opts.MoveCursor) | ||
| 221 | + | in := &inputState{touchpad: opts.Touchpad, scrollers: map[uintptr]bool{}, swipes: map[uintptr]swipe{}} | |||
| 222 | + | defer in.release() | |||
| 197 | fd := getFD(li) | 223 | fd := getFD(li) | ||
| 198 | fwd := newForwarder(opts.Log) | 224 | fwd := newForwarder(opts.Log) | ||
| 199 | fwdCtx, stopFwd := context.WithCancel(ctx) | 225 | fwdCtx, stopFwd := context.WithCancel(ctx) | ||
| @@ -226,6 +252,12 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 226 | case c := <-opts.Constraints: | 252 | case c := <-opts.Constraints: | ||
| 227 | p.constrain(c) | 253 | p.constrain(c) | ||
| 228 | p.moved(opts.MoveCursor) | 254 | p.moved(opts.MoveCursor) | ||
| 255 | + | case t := <-opts.Touchpads: | |||
| 256 | + | in.touchpad = t | |||
| 257 | + | for dev := range in.scrollers { | |||
| 258 | + | in.applyNatural(dev, opts.Log) | |||
| 259 | + | } | |||
| 260 | + | opts.Log.Info().Bool("natural_scroll", t.NaturalScroll).Msg("touchpad reloaded") | |||
| 229 | default: | 261 | default: | ||
| 230 | } | 262 | } | ||
| 231 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | 263 | fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}} | ||
| @@ -240,7 +272,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 240 | opts.Log.Warn().Msg("libinput_dispatch failed") | 272 | opts.Log.Warn().Msg("libinput_dispatch failed") | ||
| 241 | } | 273 | } | ||
| 242 | for ev := getEvent(li); ev != 0; ev = getEvent(li) { | 274 | for ev := getEvent(li); ev != 0; ev = getEvent(li) { | ||
| 243 | - | out, err := translate(ev, opts, p) | 275 | + | out, err := translate(ev, opts, p, in) |
| 244 | eventDestroy(ev) | 276 | eventDestroy(ev) | ||
| 245 | if err != nil { | 277 | if err != nil { | ||
| 246 | return err | 278 | return err | ||
| @@ -253,14 +285,104 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error | |||||
| 253 | return nil | 285 | return nil | ||
| 254 | } | 286 | } | ||
| 255 | 287 | ||||
| 256 | - | func translate(ev uintptr, opts Options, p *pointer) (ports.InputEvent, error) { | 288 | + | // 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. | |||
| 291 | + | type inputState struct { | |||
| 292 | + | touchpad ports.TouchpadConfig | |||
| 293 | + | scrollers map[uintptr]bool | |||
| 294 | + | swipes map[uintptr]swipe | |||
| 295 | + | } | |||
| 296 | + | ||||
| 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 { | |||
| 303 | + | log.Warn().Str("device", deviceName(dev)).Msg("natural scroll rejected") | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (s *inputState) release() { | |||
| 308 | + | for dev := range s.scrollers { | |||
| 309 | + | deviceUnref(dev) | |||
| 310 | + | } | |||
| 311 | + | clear(s.scrollers) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // swipeFingers is the finger count of the swipes neferwl handles. | |||
| 315 | + | const swipeFingers = 3 | |||
| 316 | + | ||||
| 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 | |||
| 321 | + | ||||
| 322 | + | // swipe adds up one touchpad swipe until the fingers lift. | |||
| 323 | + | type swipe struct { | |||
| 324 | + | fingers int | |||
| 325 | + | dx, dy float64 | |||
| 326 | + | } | |||
| 327 | + | ||||
| 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 | |||
| 333 | + | } | |||
| 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 | |||
| 341 | + | } | |||
| 342 | + | return ports.SwipeRight, true | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func abs(v float64) float64 { return max(v, -v) } | |||
| 346 | + | ||||
| 347 | + | func translate(ev uintptr, opts Options, p *pointer, in *inputState) (ports.InputEvent, error) { | |||
| 257 | now := uint32(time.Now().UnixMilli()) | 348 | now := uint32(time.Now().UnixMilli()) | ||
| 258 | log := opts.Log | 349 | log := opts.Log | ||
| 259 | switch eventType(ev) { | 350 | switch eventType(ev) { | ||
| 260 | case evDeviceAdded: | 351 | case evDeviceAdded: | ||
| 261 | - | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device added") | 352 | + | dev := eventDevice(ev) |
| 353 | + | 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) | |||
| 357 | + | } | |||
| 262 | case evDeviceRemoved: | 358 | case evDeviceRemoved: | ||
| 263 | - | log.Info().Str("device", deviceName(eventDevice(ev))).Msg("input device removed") | 359 | + | dev := eventDevice(ev) |
| 360 | + | 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) | |||
| 364 | + | deviceUnref(dev) | |||
| 365 | + | } | |||
| 366 | + | case evSwipeBegin: | |||
| 367 | + | in.swipes[eventDevice(ev)] = swipe{fingers: int(gestureFingers(gestureEvent(ev)))} | |||
| 368 | + | 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 | + | } | |||
| 375 | + | 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 | + | } | |||
| 264 | case evKeyboardKey: | 386 | case evKeyboardKey: | ||
| 265 | k := keyboardEvent(ev) | 387 | k := keyboardEvent(ev) | ||
| 266 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | 388 | code, pressed := keyboardKey(k), keyboardState(k) == 1 | ||
M internal/adapters/libinput/libinput_test.go +21 -0
| @@ -29,6 +29,27 @@ func TestHotkey(t *testing.T) { | |||
| 29 | 29 | } | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 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 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 32 | 53 | func TestPointerAcrossOutputs(t *testing.T) { | |
| 33 | 54 | layout := ports.Layout{ | |
| 34 | 55 | {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1}, | |
| @@ -29,6 +29,27 @@ func TestHotkey(t *testing.T) { | |||||
| 29 | } | 29 | } | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 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 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 32 | func TestPointerAcrossOutputs(t *testing.T) { | 53 | func TestPointerAcrossOutputs(t *testing.T) { | ||
| 33 | layout := ports.Layout{ | 54 | layout := ports.Layout{ | ||
| 34 | {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1}, | 55 | {Info: ports.OutputInfo{Name: "A"}, Width: 100, Height: 50, Scale: 1}, | ||
M internal/adapters/wayland/screencopy_test.go +33 -27
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | |
| 8 | 8 | "image" | |
| 9 | 9 | "reflect" | |
| 10 | + | "slices" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| @@ -357,6 +358,30 @@ func deliverCapture(t *testing.T, s *Server, replies chan<- ports.CaptureDone, d | |||
| 357 | 358 | } | |
| 358 | 359 | } | |
| 359 | 360 | ||
| 361 | + | // applyOutputs sends a layout and waits until the display goroutine has | |
| 362 | + | // applied it, so a following Roundtrip sees its events. A no-op Do alone can | |
| 363 | + | // run before the command goroutine has even received the command. | |
| 364 | + | func applyOutputs(t *testing.T, s *Server, commands chan<- ports.ClientCommand, c ports.SetOutputs) { | |
| 365 | + | t.Helper() | |
| 366 | + | commands <- c | |
| 367 | + | deadline := time.Now().Add(2 * time.Second) | |
| 368 | + | for { | |
| 369 | + | applied := false | |
| 370 | + | if !s.display.Do(func() { | |
| 371 | + | applied = len(s.outputs) == len(c.Outputs) && slices.Equal(s.outputPlaces, c.Outputs) | |
| 372 | + | }) { | |
| 373 | + | t.Fatal("display stopped") | |
| 374 | + | } | |
| 375 | + | if applied { | |
| 376 | + | return | |
| 377 | + | } | |
| 378 | + | if time.Now().After(deadline) { | |
| 379 | + | t.Fatal("outputs not applied") | |
| 380 | + | } | |
| 381 | + | time.Sleep(time.Millisecond) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 360 | 385 | // captureDetails records protocol payloads, not just the event opcodes. | |
| 361 | 386 | type captureDetails struct { | |
| 362 | 387 | wlturbo.BaseProxy | |
| @@ -490,10 +515,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||
| 490 | 515 | resized := initial | |
| 491 | 516 | resized.Info.Width = 5 | |
| 492 | 517 | resized.Width = 5 | |
| 493 | - | commands <- ports.SetOutputs{Outputs: ports.Layout{resized}} | |
| 494 | - | if !s.display.Do(func() {}) { | |
| 495 | - | t.Fatal("display stopped") | |
| 496 | - | } | |
| 518 | + | applyOutputs(t, s, commands, ports.SetOutputs{Outputs: ports.Layout{resized}}) | |
| 497 | 519 | if err := c.Roundtrip(); err != nil { | |
| 498 | 520 | t.Fatal(err) | |
| 499 | 521 | } | |
| @@ -589,25 +611,12 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||
| 589 | 611 | for range 4 { // initial constraint batch | |
| 590 | 612 | <-p.events | |
| 591 | 613 | } | |
| 592 | - | commands <- ports.SetOutputs{} | |
| 593 | - | // The command goroutine applies SetOutputs on its own schedule: round | |
| 594 | - | // trip until the stopped event arrives. | |
| 595 | - | deadline := time.Now().Add(5 * time.Second) | |
| 596 | - | for { | |
| 597 | - | if err := c.Roundtrip(); err != nil { | |
| 598 | - | t.Fatal(err) | |
| 599 | - | } | |
| 600 | - | select { | |
| 601 | - | case got := <-p.events: | |
| 602 | - | if !reflect.DeepEqual(got, []uint32{5}) { | |
| 603 | - | t.Fatalf("stopped: %v", got) | |
| 604 | - | } | |
| 605 | - | return | |
| 606 | - | case <-time.After(10 * time.Millisecond): | |
| 607 | - | } | |
| 608 | - | if time.Now().After(deadline) { | |
| 609 | - | t.Fatal("session not stopped") | |
| 610 | - | } | |
| 614 | + | applyOutputs(t, s, commands, ports.SetOutputs{}) | |
| 615 | + | if err := c.Roundtrip(); err != nil { | |
| 616 | + | t.Fatal(err) | |
| 617 | + | } | |
| 618 | + | if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) { | |
| 619 | + | t.Fatalf("stopped: %v", got) | |
| 611 | 620 | } | |
| 612 | 621 | } | |
| 613 | 622 | ||
| @@ -658,10 +667,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||
| 658 | 667 | t.Fatal(got) | |
| 659 | 668 | } | |
| 660 | 669 | if !destroy { | |
| 661 | - | commands <- ports.SetOutputs{} | |
| 662 | - | if !s.display.Do(func() {}) { | |
| 663 | - | t.Fatal("display stopped") | |
| 664 | - | } | |
| 670 | + | applyOutputs(t, s, commands, ports.SetOutputs{}) | |
| 665 | 671 | shm, buf, fd := captureSmallBuffer(t, c) | |
| 666 | 672 | defer unix.Close(fd) | |
| 667 | 673 | _ = shm | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | 7 | ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture" | ||
| 8 | "image" | 8 | "image" | ||
| 9 | "reflect" | 9 | "reflect" | ||
| 10 | + | "slices" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "testing" | 12 | "testing" | ||
| 12 | "time" | 13 | "time" | ||
| @@ -357,6 +358,30 @@ func deliverCapture(t *testing.T, s *Server, replies chan<- ports.CaptureDone, d | |||||
| 357 | } | 358 | } | ||
| 358 | } | 359 | } | ||
| 359 | 360 | ||||
| 361 | + | // applyOutputs sends a layout and waits until the display goroutine has | |||
| 362 | + | // applied it, so a following Roundtrip sees its events. A no-op Do alone can | |||
| 363 | + | // run before the command goroutine has even received the command. | |||
| 364 | + | func applyOutputs(t *testing.T, s *Server, commands chan<- ports.ClientCommand, c ports.SetOutputs) { | |||
| 365 | + | t.Helper() | |||
| 366 | + | commands <- c | |||
| 367 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 368 | + | for { | |||
| 369 | + | applied := false | |||
| 370 | + | if !s.display.Do(func() { | |||
| 371 | + | applied = len(s.outputs) == len(c.Outputs) && slices.Equal(s.outputPlaces, c.Outputs) | |||
| 372 | + | }) { | |||
| 373 | + | t.Fatal("display stopped") | |||
| 374 | + | } | |||
| 375 | + | if applied { | |||
| 376 | + | return | |||
| 377 | + | } | |||
| 378 | + | if time.Now().After(deadline) { | |||
| 379 | + | t.Fatal("outputs not applied") | |||
| 380 | + | } | |||
| 381 | + | time.Sleep(time.Millisecond) | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | ||||
| 360 | // captureDetails records protocol payloads, not just the event opcodes. | 385 | // captureDetails records protocol payloads, not just the event opcodes. | ||
| 361 | type captureDetails struct { | 386 | type captureDetails struct { | ||
| 362 | wlturbo.BaseProxy | 387 | wlturbo.BaseProxy | ||
| @@ -490,10 +515,7 @@ func TestExtCaptureLifecycle(t *testing.T) { | |||||
| 490 | resized := initial | 515 | resized := initial | ||
| 491 | resized.Info.Width = 5 | 516 | resized.Info.Width = 5 | ||
| 492 | resized.Width = 5 | 517 | resized.Width = 5 | ||
| 493 | - | commands <- ports.SetOutputs{Outputs: ports.Layout{resized}} | 518 | + | applyOutputs(t, s, commands, ports.SetOutputs{Outputs: ports.Layout{resized}}) |
| 494 | - | if !s.display.Do(func() {}) { | |||
| 495 | - | t.Fatal("display stopped") | |||
| 496 | - | } | |||
| 497 | if err := c.Roundtrip(); err != nil { | 519 | if err := c.Roundtrip(); err != nil { | ||
| 498 | t.Fatal(err) | 520 | t.Fatal(err) | ||
| 499 | } | 521 | } | ||
| @@ -589,25 +611,12 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) { | |||||
| 589 | for range 4 { // initial constraint batch | 611 | for range 4 { // initial constraint batch | ||
| 590 | <-p.events | 612 | <-p.events | ||
| 591 | } | 613 | } | ||
| 592 | - | commands <- ports.SetOutputs{} | 614 | + | applyOutputs(t, s, commands, ports.SetOutputs{}) |
| 593 | - | // The command goroutine applies SetOutputs on its own schedule: round | 615 | + | if err := c.Roundtrip(); err != nil { |
| 594 | - | // trip until the stopped event arrives. | 616 | + | t.Fatal(err) |
| 595 | - | deadline := time.Now().Add(5 * time.Second) | 617 | + | } |
| 596 | - | for { | 618 | + | if got := <-p.events; !reflect.DeepEqual(got, []uint32{5}) { |
| 597 | - | if err := c.Roundtrip(); err != nil { | 619 | + | t.Fatalf("stopped: %v", got) |
| 598 | - | t.Fatal(err) | |||
| 599 | - | } | |||
| 600 | - | select { | |||
| 601 | - | case got := <-p.events: | |||
| 602 | - | if !reflect.DeepEqual(got, []uint32{5}) { | |||
| 603 | - | t.Fatalf("stopped: %v", got) | |||
| 604 | - | } | |||
| 605 | - | return | |||
| 606 | - | case <-time.After(10 * time.Millisecond): | |||
| 607 | - | } | |||
| 608 | - | if time.Now().After(deadline) { | |||
| 609 | - | t.Fatal("session not stopped") | |||
| 610 | - | } | |||
| 611 | } | 620 | } | ||
| 612 | } | 621 | } | ||
| 613 | 622 | ||||
| @@ -658,10 +667,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) { | |||||
| 658 | t.Fatal(got) | 667 | t.Fatal(got) | ||
| 659 | } | 668 | } | ||
| 660 | if !destroy { | 669 | if !destroy { | ||
| 661 | - | commands <- ports.SetOutputs{} | 670 | + | applyOutputs(t, s, commands, ports.SetOutputs{}) |
| 662 | - | if !s.display.Do(func() {}) { | |||
| 663 | - | t.Fatal("display stopped") | |||
| 664 | - | } | |||
| 665 | shm, buf, fd := captureSmallBuffer(t, c) | 671 | shm, buf, fd := captureSmallBuffer(t, c) | ||
| 666 | defer unix.Close(fd) | 672 | defer unix.Close(fd) | ||
| 667 | _ = shm | 673 | _ = shm | ||
M internal/app/app.go +15 -4
| @@ -152,6 +152,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 152 | 152 | } | |
| 153 | 153 | watched := make(chan ports.ConfigChanged, 8) | |
| 154 | 154 | keymaps := make(chan *xkb.Keymap, 1) | |
| 155 | + | touchpads := make(chan ports.TouchpadConfig, 1) | |
| 155 | 156 | go func() { | |
| 156 | 157 | defer workers.Done() | |
| 157 | 158 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | |
| @@ -167,7 +168,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 167 | 168 | workers.Add(1) | |
| 168 | 169 | go func() { | |
| 169 | 170 | defer workers.Done() | |
| 170 | - | relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config")) | |
| 171 | + | relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, commands, logging.For(ctx, "config")) | |
| 171 | 172 | }() | |
| 172 | 173 | script := make(chan string) | |
| 173 | 174 | curs := newCursors() | |
| @@ -183,7 +184,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 183 | 184 | return | |
| 184 | 185 | } | |
| 185 | 186 | done <- safe("input", func() error { | |
| 186 | - | return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input) | |
| 187 | + | return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input) | |
| 187 | 188 | }) | |
| 188 | 189 | return | |
| 189 | 190 | } | |
| @@ -290,9 +291,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 290 | 291 | ||
| 291 | 292 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | |
| 292 | 293 | // to the input goroutine and sends it to clients; a repeat-only change just updates | |
| 293 | - | // clients. A keymap that fails to build keeps the previous layout. | |
| 294 | - | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |
| 294 | + | // clients. A keymap that fails to build keeps the previous layout. A touchpad | |
| 295 | + | // change goes to the input goroutine; only the newest one waits there. | |
| 296 | + | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |
| 295 | 297 | kb := cur.Keyboard | |
| 298 | + | tp := cur.Touchpad | |
| 296 | 299 | for { | |
| 297 | 300 | var ev ports.ConfigChanged | |
| 298 | 301 | select { | |
| @@ -326,6 +329,14 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||
| 326 | 329 | } | |
| 327 | 330 | } | |
| 328 | 331 | } | |
| 332 | + | if ev.Config.Touchpad != tp { | |
| 333 | + | tp = ev.Config.Touchpad | |
| 334 | + | select { | |
| 335 | + | case <-touchpads: | |
| 336 | + | default: | |
| 337 | + | } | |
| 338 | + | touchpads <- tp // buffered and just drained: this goroutine is the only sender | |
| 339 | + | } | |
| 329 | 340 | if layoutChanged || repeatChanged { | |
| 330 | 341 | select { | |
| 331 | 342 | case commands <- cmd: | |
| @@ -152,6 +152,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 152 | } | 152 | } | ||
| 153 | watched := make(chan ports.ConfigChanged, 8) | 153 | watched := make(chan ports.ConfigChanged, 8) | ||
| 154 | keymaps := make(chan *xkb.Keymap, 1) | 154 | keymaps := make(chan *xkb.Keymap, 1) | ||
| 155 | + | touchpads := make(chan ports.TouchpadConfig, 1) | |||
| 155 | go func() { | 156 | go func() { | ||
| 156 | defer workers.Done() | 157 | defer workers.Done() | ||
| 157 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | 158 | done <- config.Watch(ctx, path, watched, logging.For(ctx, "config")) | ||
| @@ -167,7 +168,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 167 | workers.Add(1) | 168 | workers.Add(1) | ||
| 168 | go func() { | 169 | go func() { | ||
| 169 | defer workers.Done() | 170 | defer workers.Done() | ||
| 170 | - | relayConfig(ctx, opts.Config, watched, filtered, keymaps, commands, logging.For(ctx, "config")) | 171 | + | relayConfig(ctx, opts.Config, watched, filtered, keymaps, touchpads, commands, logging.For(ctx, "config")) |
| 171 | }() | 172 | }() | ||
| 172 | script := make(chan string) | 173 | script := make(chan string) | ||
| 173 | curs := newCursors() | 174 | curs := newCursors() | ||
| @@ -183,7 +184,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 183 | return | 184 | return | ||
| 184 | } | 185 | } | ||
| 185 | done <- safe("input", func() error { | 186 | done <- safe("input", func() error { | ||
| 186 | - | return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input) | 187 | + | return libinput.Run(ctx, libinput.Options{Seat: hw.seat, SeatName: hw.seat.Name(), Keymap: km, Keymaps: keymaps, Layout: layout, Layouts: layouts, Constraints: constraints, Touchpad: opts.Config.Touchpad, Touchpads: touchpads, Active: hw.seat.Subscribe(), MoveCursor: curs.move, Log: logging.For(ctx, "input"), LogMotion: logging.Enabled(ctx, "input-motion")}, input) |
| 187 | }) | 188 | }) | ||
| 188 | return | 189 | return | ||
| 189 | } | 190 | } | ||
| @@ -290,9 +291,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 290 | 291 | ||||
| 291 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | 292 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | ||
| 292 | // to the input goroutine and sends it to clients; a repeat-only change just updates | 293 | // to the input goroutine and sends it to clients; a repeat-only change just updates | ||
| 293 | - | // clients. A keymap that fails to build keeps the previous layout. | 294 | + | // clients. A keymap that fails to build keeps the previous layout. A touchpad |
| 294 | - | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | 295 | + | // change goes to the input goroutine; only the newest one waits there. |
| 296 | + | func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigChanged, out chan<- ports.ConfigChanged, keymaps chan *xkb.Keymap, touchpads chan ports.TouchpadConfig, commands chan<- ports.ClientCommand, log zerowrap.Logger) { | |||
| 295 | kb := cur.Keyboard | 297 | kb := cur.Keyboard | ||
| 298 | + | tp := cur.Touchpad | |||
| 296 | for { | 299 | for { | ||
| 297 | var ev ports.ConfigChanged | 300 | var ev ports.ConfigChanged | ||
| 298 | select { | 301 | select { | ||
| @@ -326,6 +329,14 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh | |||||
| 326 | } | 329 | } | ||
| 327 | } | 330 | } | ||
| 328 | } | 331 | } | ||
| 332 | + | if ev.Config.Touchpad != tp { | |||
| 333 | + | tp = ev.Config.Touchpad | |||
| 334 | + | select { | |||
| 335 | + | case <-touchpads: | |||
| 336 | + | default: | |||
| 337 | + | } | |||
| 338 | + | touchpads <- tp // buffered and just drained: this goroutine is the only sender | |||
| 339 | + | } | |||
| 329 | if layoutChanged || repeatChanged { | 340 | if layoutChanged || repeatChanged { | ||
| 330 | select { | 341 | select { | ||
| 331 | case commands <- cmd: | 342 | case commands <- cmd: | ||
M internal/app/app_test.go +14 -2
| @@ -270,15 +270,27 @@ func TestRelayConfigKeyboard(t *testing.T) { | |||
| 270 | 270 | out := make(chan ports.ConfigChanged, 1) | |
| 271 | 271 | keymaps := make(chan *xkb.Keymap, 1) | |
| 272 | 272 | commands := make(chan ports.ClientCommand, 1) | |
| 273 | - | go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config")) | |
| 273 | + | touchpads := make(chan ports.TouchpadConfig, 1) | |
| 274 | + | go relayConfig(ctx, cur, in, out, keymaps, touchpads, commands, logging.For(ctx, "config")) | |
| 274 | 275 | ||
| 275 | 276 | next := config.Defaults() | |
| 276 | 277 | next.Background.Color = "#000000" | |
| 277 | 278 | in <- ports.ConfigChanged{Config: next} | |
| 278 | - | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 { | |
| 279 | + | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 || len(touchpads) != 0 { | |
| 279 | 280 | t.Fatalf("non-keyboard change rebuilt keymap: %+v", got) | |
| 280 | 281 | } | |
| 281 | 282 | ||
| 283 | + | // Touchpad changes reach input; the newest replaces one not yet taken. | |
| 284 | + | next.Touchpad.NaturalScroll = true | |
| 285 | + | in <- ports.ConfigChanged{Config: next} | |
| 286 | + | <-out | |
| 287 | + | next.Touchpad.NaturalScroll = false | |
| 288 | + | in <- ports.ConfigChanged{Config: next} | |
| 289 | + | <-out | |
| 290 | + | if tp := <-touchpads; tp.NaturalScroll || len(touchpads) != 0 { | |
| 291 | + | t.Fatalf("touchpad: %+v", tp) | |
| 292 | + | } | |
| 293 | + | ||
| 282 | 294 | next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40 | |
| 283 | 295 | in <- ports.ConfigChanged{Config: next} | |
| 284 | 296 | <-out | |
| @@ -270,15 +270,27 @@ func TestRelayConfigKeyboard(t *testing.T) { | |||||
| 270 | out := make(chan ports.ConfigChanged, 1) | 270 | out := make(chan ports.ConfigChanged, 1) | ||
| 271 | keymaps := make(chan *xkb.Keymap, 1) | 271 | keymaps := make(chan *xkb.Keymap, 1) | ||
| 272 | commands := make(chan ports.ClientCommand, 1) | 272 | commands := make(chan ports.ClientCommand, 1) | ||
| 273 | - | go relayConfig(ctx, cur, in, out, keymaps, commands, logging.For(ctx, "config")) | 273 | + | touchpads := make(chan ports.TouchpadConfig, 1) |
| 274 | + | go relayConfig(ctx, cur, in, out, keymaps, touchpads, commands, logging.For(ctx, "config")) | |||
| 274 | 275 | ||||
| 275 | next := config.Defaults() | 276 | next := config.Defaults() | ||
| 276 | next.Background.Color = "#000000" | 277 | next.Background.Color = "#000000" | ||
| 277 | in <- ports.ConfigChanged{Config: next} | 278 | in <- ports.ConfigChanged{Config: next} | ||
| 278 | - | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 { | 279 | + | if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 || len(touchpads) != 0 { |
| 279 | t.Fatalf("non-keyboard change rebuilt keymap: %+v", got) | 280 | t.Fatalf("non-keyboard change rebuilt keymap: %+v", got) | ||
| 280 | } | 281 | } | ||
| 281 | 282 | ||||
| 283 | + | // Touchpad changes reach input; the newest replaces one not yet taken. | |||
| 284 | + | next.Touchpad.NaturalScroll = true | |||
| 285 | + | in <- ports.ConfigChanged{Config: next} | |||
| 286 | + | <-out | |||
| 287 | + | next.Touchpad.NaturalScroll = false | |||
| 288 | + | in <- ports.ConfigChanged{Config: next} | |||
| 289 | + | <-out | |||
| 290 | + | if tp := <-touchpads; tp.NaturalScroll || len(touchpads) != 0 { | |||
| 291 | + | t.Fatalf("touchpad: %+v", tp) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 282 | next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40 | 294 | next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40 | ||
| 283 | in <- ports.ConfigChanged{Config: next} | 295 | in <- ports.ConfigChanged{Config: next} | ||
| 284 | <-out | 296 | <-out | ||
M internal/core/action.go +20 -0
| @@ -3,6 +3,8 @@ package core | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | type Action string | |
| @@ -42,6 +44,24 @@ const ( | |||
| 42 | 44 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | |
| 43 | 45 | ) | |
| 44 | 46 | ||
| 47 | + | // swipeAction maps a three-finger swipe to its action. Natural scroll moves | |
| 48 | + | // the content with the fingers, so it shows what lies the other way. | |
| 49 | + | func swipeAction(d ports.SwipeDir, natural bool) Action { | |
| 50 | + | actions := [...]Action{ | |
| 51 | + | ports.SwipeUp: ActionFocusWorkspaceUp, | |
| 52 | + | ports.SwipeDown: ActionFocusWorkspaceDown, | |
| 53 | + | ports.SwipeLeft: ActionFocusColumnLeft, | |
| 54 | + | ports.SwipeRight: ActionFocusColumnRight, | |
| 55 | + | } | |
| 56 | + | if int(d) >= len(actions) { | |
| 57 | + | return "" | |
| 58 | + | } | |
| 59 | + | if natural { | |
| 60 | + | d ^= 1 // up<->down, left<->right | |
| 61 | + | } | |
| 62 | + | return actions[d] | |
| 63 | + | } | |
| 64 | + | ||
| 45 | 65 | // WorkspaceOp is what a numbered workspace action does. | |
| 46 | 66 | type WorkspaceOp int | |
| 47 | 67 | ||
| @@ -3,6 +3,8 @@ package core | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 8 | type Action string | 10 | type Action string | ||
| @@ -42,6 +44,24 @@ const ( | |||||
| 42 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | 44 | ActionConsumeOrExpelRight Action = "consume-or-expel-window-right" | ||
| 43 | ) | 45 | ) | ||
| 44 | 46 | ||||
| 47 | + | // swipeAction maps a three-finger swipe to its action. Natural scroll moves | |||
| 48 | + | // the content with the fingers, so it shows what lies the other way. | |||
| 49 | + | func swipeAction(d ports.SwipeDir, natural bool) Action { | |||
| 50 | + | actions := [...]Action{ | |||
| 51 | + | ports.SwipeUp: ActionFocusWorkspaceUp, | |||
| 52 | + | ports.SwipeDown: ActionFocusWorkspaceDown, | |||
| 53 | + | ports.SwipeLeft: ActionFocusColumnLeft, | |||
| 54 | + | ports.SwipeRight: ActionFocusColumnRight, | |||
| 55 | + | } | |||
| 56 | + | if int(d) >= len(actions) { | |||
| 57 | + | return "" | |||
| 58 | + | } | |||
| 59 | + | if natural { | |||
| 60 | + | d ^= 1 // up<->down, left<->right | |||
| 61 | + | } | |||
| 62 | + | return actions[d] | |||
| 63 | + | } | |||
| 64 | + | ||||
| 45 | // WorkspaceOp is what a numbered workspace action does. | 65 | // WorkspaceOp is what a numbered workspace action does. | ||
| 46 | type WorkspaceOp int | 66 | type WorkspaceOp int | ||
| 47 | 67 | ||||
M internal/core/core.go +11 -0
| @@ -907,6 +907,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 907 | 907 | } | |
| 908 | 908 | } | |
| 909 | 909 | continue | |
| 910 | + | case ports.Swipe: | |
| 911 | + | before := c.cur().mon.Current() | |
| 912 | + | c.layerFocus = 0 | |
| 913 | + | c.applyAction(swipeAction(v.Dir, c.cfg.Touchpad.NaturalScroll)) | |
| 914 | + | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | |
| 915 | + | return nil | |
| 916 | + | } | |
| 917 | + | if err := c.publish(ctx); err != nil { | |
| 918 | + | return nil | |
| 919 | + | } | |
| 920 | + | continue | |
| 910 | 921 | } | |
| 911 | 922 | key, ok := ev.(ports.KeyEvent) | |
| 912 | 923 | if !ok { | |
| @@ -907,6 +907,17 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 907 | } | 907 | } | ||
| 908 | } | 908 | } | ||
| 909 | continue | 909 | continue | ||
| 910 | + | case ports.Swipe: | |||
| 911 | + | before := c.cur().mon.Current() | |||
| 912 | + | c.layerFocus = 0 | |||
| 913 | + | c.applyAction(swipeAction(v.Dir, c.cfg.Touchpad.NaturalScroll)) | |||
| 914 | + | if c.workspaceVisible(ctx, c.cur().mon.Current() != before) != nil { | |||
| 915 | + | return nil | |||
| 916 | + | } | |||
| 917 | + | if err := c.publish(ctx); err != nil { | |||
| 918 | + | return nil | |||
| 919 | + | } | |||
| 920 | + | continue | |||
| 910 | } | 921 | } | ||
| 911 | key, ok := ev.(ports.KeyEvent) | 922 | key, ok := ev.(ports.KeyEvent) | ||
| 912 | if !ok { | 923 | if !ok { | ||
A internal/core/swipe_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func (r *multiRig) swipe(t *testing.T, d ports.SwipeDir) []ports.Scene { | |
| 10 | + | t.Helper() | |
| 11 | + | for len(r.commands) > 0 { | |
| 12 | + | <-r.commands | |
| 13 | + | } | |
| 14 | + | r.input <- ports.Swipe{Dir: d} | |
| 15 | + | return receive(t, r.scenes) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // lastFocus returns the last window core focused since the swipe. | |
| 19 | + | func (r *multiRig) lastFocus() ports.WindowID { | |
| 20 | + | var id ports.WindowID | |
| 21 | + | for len(r.commands) > 0 { | |
| 22 | + | if v, ok := (<-r.commands).(ports.FocusWindow); ok { | |
| 23 | + | id = v.ID | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | return id | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestSwipeChangesWorkspace(t *testing.T) { | |
| 30 | + | 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 | |
| 34 | + | if natural { | |
| 35 | + | down, up = up, down | |
| 36 | + | } | |
| 37 | + | if got := shown(r.swipe(t, down))["DP-2"]; len(got) != 0 { | |
| 38 | + | t.Fatalf("natural=%t: next workspace shows %v", natural, got) | |
| 39 | + | } | |
| 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) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 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 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func (r *multiRig) swipe(t *testing.T, d ports.SwipeDir) []ports.Scene { | |||
| 10 | + | t.Helper() | |||
| 11 | + | for len(r.commands) > 0 { | |||
| 12 | + | <-r.commands | |||
| 13 | + | } | |||
| 14 | + | r.input <- ports.Swipe{Dir: d} | |||
| 15 | + | return receive(t, r.scenes) | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // lastFocus returns the last window core focused since the swipe. | |||
| 19 | + | func (r *multiRig) lastFocus() ports.WindowID { | |||
| 20 | + | var id ports.WindowID | |||
| 21 | + | for len(r.commands) > 0 { | |||
| 22 | + | if v, ok := (<-r.commands).(ports.FocusWindow); ok { | |||
| 23 | + | id = v.ID | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | return id | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestSwipeChangesWorkspace(t *testing.T) { | |||
| 30 | + | 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 | |||
| 34 | + | if natural { | |||
| 35 | + | down, up = up, down | |||
| 36 | + | } | |||
| 37 | + | if got := shown(r.swipe(t, down))["DP-2"]; len(got) != 0 { | |||
| 38 | + | t.Fatalf("natural=%t: next workspace shows %v", natural, got) | |||
| 39 | + | } | |||
| 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) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 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 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
M internal/ports/config.go +8 -1
| @@ -88,7 +88,8 @@ type Config struct { | |||
| 88 | 88 | LayoutRules | |
| 89 | 89 | Outputs []OutputLayout | |
| 90 | 90 | } | |
| 91 | - | Focus struct { | |
| 91 | + | Touchpad TouchpadConfig | |
| 92 | + | Focus struct { | |
| 92 | 93 | // FollowMove shows the target workspace after a column or window | |
| 93 | 94 | // moves to it. | |
| 94 | 95 | FollowMove bool | |
| @@ -110,6 +111,12 @@ type Config struct { | |||
| 110 | 111 | } | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 114 | + | // TouchpadConfig configures touchpads. NaturalScroll moves the content with | |
| 115 | + | // the fingers, for two-finger scroll and three-finger swipes. | |
| 116 | + | type TouchpadConfig struct { | |
| 117 | + | NaturalScroll bool | |
| 118 | + | } | |
| 119 | + | ||
| 113 | 120 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | |
| 114 | 121 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | |
| 115 | 122 | type WorkspaceConfig struct { | |
| @@ -88,7 +88,8 @@ type Config struct { | |||||
| 88 | LayoutRules | 88 | LayoutRules | ||
| 89 | Outputs []OutputLayout | 89 | Outputs []OutputLayout | ||
| 90 | } | 90 | } | ||
| 91 | - | Focus struct { | 91 | + | Touchpad TouchpadConfig |
| 92 | + | Focus struct { | |||
| 92 | // FollowMove shows the target workspace after a column or window | 93 | // FollowMove shows the target workspace after a column or window | ||
| 93 | // moves to it. | 94 | // moves to it. | ||
| 94 | FollowMove bool | 95 | FollowMove bool | ||
| @@ -110,6 +111,12 @@ type Config struct { | |||||
| 110 | } | 111 | } | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 114 | + | // TouchpadConfig configures touchpads. NaturalScroll moves the content with | |||
| 115 | + | // the fingers, for two-finger scroll and three-finger swipes. | |||
| 116 | + | type TouchpadConfig struct { | |||
| 117 | + | NaturalScroll bool | |||
| 118 | + | } | |||
| 119 | + | ||||
| 113 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | 120 | // WorkspaceConfig declares a bind-only named workspace, outside the numbered list. | ||
| 114 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | 121 | // Zero MaxColumns and an empty Overflow use the layout.* defaults. | ||
| 115 | type WorkspaceConfig struct { | 122 | type WorkspaceConfig struct { | ||
M internal/ports/messages.go +15 -0
| @@ -365,6 +365,21 @@ type PointerAxis struct { | |||
| 365 | 365 | ||
| 366 | 366 | func (PointerAxis) inputEvent() {} | |
| 367 | 367 | ||
| 368 | + | // SwipeDir is the way the fingers moved on the touchpad. | |
| 369 | + | type SwipeDir uint8 | |
| 370 | + | ||
| 371 | + | const ( | |
| 372 | + | SwipeUp SwipeDir = iota | |
| 373 | + | SwipeDown | |
| 374 | + | SwipeLeft | |
| 375 | + | SwipeRight | |
| 376 | + | ) | |
| 377 | + | ||
| 378 | + | // Swipe is a finished three-finger touchpad swipe. | |
| 379 | + | type Swipe struct{ Dir SwipeDir } | |
| 380 | + | ||
| 381 | + | func (Swipe) inputEvent() {} | |
| 382 | + | ||
| 368 | 383 | // OutputEvent carries output → core notifications. | |
| 369 | 384 | type OutputEvent interface{ outputEvent() } | |
| 370 | 385 | ||
| @@ -365,6 +365,21 @@ type PointerAxis struct { | |||||
| 365 | 365 | ||||
| 366 | func (PointerAxis) inputEvent() {} | 366 | func (PointerAxis) inputEvent() {} | ||
| 367 | 367 | ||||
| 368 | + | // SwipeDir is the way the fingers moved on the touchpad. | |||
| 369 | + | type SwipeDir uint8 | |||
| 370 | + | ||||
| 371 | + | const ( | |||
| 372 | + | SwipeUp SwipeDir = iota | |||
| 373 | + | SwipeDown | |||
| 374 | + | SwipeLeft | |||
| 375 | + | SwipeRight | |||
| 376 | + | ) | |||
| 377 | + | ||||
| 378 | + | // Swipe is a finished three-finger touchpad swipe. | |||
| 379 | + | type Swipe struct{ Dir SwipeDir } | |||
| 380 | + | ||||
| 381 | + | func (Swipe) inputEvent() {} | |||
| 382 | + | ||||
| 368 | // OutputEvent carries output → core notifications. | 383 | // OutputEvent carries output → core notifications. | ||
| 369 | type OutputEvent interface{ outputEvent() } | 384 | type OutputEvent interface{ outputEvent() } | ||
| 370 | 385 | ||||