detached e2957a8

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 UnverifiedB5690EEEBB952194 The 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`).
2828 | `layout.overflow` | `scroll` | Past the max: `scroll` right, or `fixed` spiral split |
2929 | `layout.<output>.max-columns`, `.overflow` | layout values | Per-screen layout: connector (`DP-2`) or monitor key |
3030 | `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)) |
3132 | `focus.follow-move` | `off` | Follow a column moved to another workspace |
3233 | `workspace.<name>.*` | none | Named workspaces are outside the numbered list; each needs a `workspace <name>` bind to show it and toggle back |
3334 | `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`).
4344 | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` |
4445 | `bind.<keys>` | see below | Action for a key combo; `none` removes a default |
4546
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+
4660 ## HDR
4761
4862 `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 ## HDR60 ## 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
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)
2122
2223 `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:
2324
@@ -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
4242 # cycle-column-width steps through these, then back to the equal share.
4343 layout.presets = 1/3, 1/2, 2/3, 1
4444
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+
4550 # on: moving a column or window to another workspace shows that workspace.
4651 focus.follow-move = off
4752
@@ -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, 143 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 = off51 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 {
489489 }
490490 }
491491 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
492498 case "focus.follow-move":
493499 b, err := onOff(v)
494500 if err != nil {
@@ -489,6 +489,12 @@ func set(c *ports.Config, key, v string) error {
489 }489 }
490 }490 }
491 c.Layout.Presets = list491 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) {
5959 }
6060 }
6161
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+
6276 func TestParse(t *testing.T) {
6377 c, w := parseString(t, `
6478 # 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 # comment78 # 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
125125 log.Warn().Str("line", line).Msg("invalid move")
126126 continue
127127 }
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+ }
128143 if len(fields) > 0 && (fields[0] == "click" || fields[0] == "down" || fields[0] == "up") {
129144 name := "left"
130145 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 continue126 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) {
6161 if err != nil {
6262 t.Skip(err)
6363 }
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"} {
6666 script <- line
6767 }
6868 close(script)
@@ -70,7 +70,7 @@ func TestPointerScript(t *testing.T) {
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) != 5 {
73+ if len(input) != 6 {
7474 t.Fatalf("events: %d", len(input))
7575 }
7676 m := (<-input).(ports.PointerMotion)
@@ -83,4 +83,7 @@ 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)
88+ }
8689 }
@@ -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 <- line66 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 (
3030 evScrollWheel = 404
3131 evScrollFinger = 405
3232 evScrollCont = 406
33+ evSwipeBegin = 800
34+ evSwipeUpdate = 801
35+ evSwipeEnd = 802
3336 )
3437
3538 var (
@@ -66,6 +69,15 @@ var (
6669 pointerHasAxis func(pev uintptr, axis uint32) int32
6770 scrollValue func(pev uintptr, axis uint32) float64
6871 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
6981 iface [2]uintptr
7082 active ports.Seat
7183 )
@@ -118,6 +130,15 @@ func load() error {
118130 reg(&pointerHasAxis, "event_pointer_has_axis")
119131 reg(&scrollValue, "event_pointer_get_scroll_value")
120132 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")
121142 iface[0] = purego.NewCallback(func(path *byte, _, _ uintptr) uintptr {
122143 fd, err := active.OpenDevice(goString(path))
123144 if err != nil {
@@ -154,7 +175,10 @@ type Options struct {
154175 // Constraints holds the pointer lock or confinement of the focused
155176 // window, in global logical coordinates.
156177 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
158182 // MoveCursor, when set, places the hardware cursor as soon as motion is
159183 // read, before core sees the event: the output under the pointer and
160184 // the physical position on it.
@@ -194,6 +218,8 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
194218 }
195219 p := newPointer(opts.Layout)
196220 p.moved(opts.MoveCursor)
221+ in := &inputState{touchpad: opts.Touchpad, scrollers: map[uintptr]bool{}, swipes: map[uintptr]swipe{}}
222+ defer in.release()
197223 fd := getFD(li)
198224 fwd := newForwarder(opts.Log)
199225 fwdCtx, stopFwd := context.WithCancel(ctx)
@@ -226,6 +252,12 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
226252 case c := <-opts.Constraints:
227253 p.constrain(c)
228254 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")
229261 default:
230262 }
231263 fds := []unix.PollFd{{Fd: fd, Events: unix.POLLIN}}
@@ -240,7 +272,7 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
240272 opts.Log.Warn().Msg("libinput_dispatch failed")
241273 }
242274 for ev := getEvent(li); ev != 0; ev = getEvent(li) {
243- out, err := translate(ev, opts, p)
275+ out, err := translate(ev, opts, p, in)
244276 eventDestroy(ev)
245277 if err != nil {
246278 return err
@@ -253,14 +285,104 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
253285 return nil
254286 }
255287
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) {
257348 now := uint32(time.Now().UnixMilli())
258349 log := opts.Log
259350 switch eventType(ev) {
260351 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+ }
262358 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+ }
264386 case evKeyboardKey:
265387 k := keyboardEvent(ev)
266388 code, pressed := keyboardKey(k), keyboardState(k) == 1
@@ -30,6 +30,9 @@ const (
30 evScrollWheel = 40430 evScrollWheel = 404
31 evScrollFinger = 40531 evScrollFinger = 405
32 evScrollCont = 40632 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) int3269 pointerHasAxis func(pev uintptr, axis uint32) int32
67 scrollValue func(pev uintptr, axis uint32) float6470 scrollValue func(pev uintptr, axis uint32) float64
68 scrollV120 func(pev uintptr, axis uint32) float6471 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]uintptr81 iface [2]uintptr
70 active ports.Seat82 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 focused175 // 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.PointerConstraint177 Constraints <-chan ports.PointerConstraint
157- Active <-chan bool178+ // 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 is182 // MoveCursor, when set, places the hardware cursor as soon as motion is
159 // read, before core sees the event: the output under the pointer and183 // 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 err278 return err
@@ -253,14 +285,104 @@ func Run(ctx context.Context, opts Options, input chan<- ports.InputEvent) error
253 return nil285 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.Log349 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) == 1388 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) {
2929 }
3030 }
3131
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+
3253 func TestPointerAcrossOutputs(t *testing.T) {
3354 layout := ports.Layout{
3455 {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 (
77 ext "github.com/bnema/purego-libwayland/protocol/extimagecopycapture"
88 "image"
99 "reflect"
10+ "slices"
1011 "strings"
1112 "testing"
1213 "time"
@@ -357,6 +358,30 @@ func deliverCapture(t *testing.T, s *Server, replies chan<- ports.CaptureDone, d
357358 }
358359 }
359360
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+
360385 // captureDetails records protocol payloads, not just the event opcodes.
361386 type captureDetails struct {
362387 wlturbo.BaseProxy
@@ -490,10 +515,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
490515 resized := initial
491516 resized.Info.Width = 5
492517 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}})
497519 if err := c.Roundtrip(); err != nil {
498520 t.Fatal(err)
499521 }
@@ -589,25 +611,12 @@ func TestExtCaptureStoppedOnOutputRemoval(t *testing.T) {
589611 for range 4 { // initial constraint batch
590612 <-p.events
591613 }
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)
611620 }
612621 }
613622
@@ -658,10 +667,7 @@ func TestWlrOutputGoneAndDestroyedBeforeReply(t *testing.T) {
658667 t.Fatal(got)
659668 }
660669 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{})
665671 shm, buf, fd := captureSmallBuffer(t, c)
666672 defer unix.Close(fd)
667673 _ = 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.BaseProxy387 wlturbo.BaseProxy
@@ -490,10 +515,7 @@ func TestExtCaptureLifecycle(t *testing.T) {
490 resized := initial515 resized := initial
491 resized.Info.Width = 5516 resized.Info.Width = 5
492 resized.Width = 5517 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 batch611 for range 4 { // initial constraint batch
590 <-p.events612 <-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: round615+ 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 _ = shm673 _ = shm
M internal/app/app.go +15 -4
@@ -152,6 +152,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
152152 }
153153 watched := make(chan ports.ConfigChanged, 8)
154154 keymaps := make(chan *xkb.Keymap, 1)
155+ touchpads := make(chan ports.TouchpadConfig, 1)
155156 go func() {
156157 defer workers.Done()
157158 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)
167168 workers.Add(1)
168169 go func() {
169170 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"))
171172 }()
172173 script := make(chan string)
173174 curs := newCursors()
@@ -183,7 +184,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
183184 return
184185 }
185186 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)
187188 })
188189 return
189190 }
@@ -290,9 +291,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
290291
291292 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
292293 // 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) {
295297 kb := cur.Keyboard
298+ tp := cur.Touchpad
296299 for {
297300 var ev ports.ConfigChanged
298301 select {
@@ -326,6 +329,14 @@ func relayConfig(ctx context.Context, cur ports.Config, in <-chan ports.ConfigCh
326329 }
327330 }
328331 }
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+ }
329340 if layoutChanged || repeatChanged {
330341 select {
331342 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 return184 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 return189 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 it292 // 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 updates293 // 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.Keyboard297 kb := cur.Keyboard
298+ tp := cur.Touchpad
296 for {299 for {
297 var ev ports.ConfigChanged300 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) {
270270 out := make(chan ports.ConfigChanged, 1)
271271 keymaps := make(chan *xkb.Keymap, 1)
272272 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"))
274275
275276 next := config.Defaults()
276277 next.Background.Color = "#000000"
277278 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 {
279280 t.Fatalf("non-keyboard change rebuilt keymap: %+v", got)
280281 }
281282
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+
282294 next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
283295 in <- ports.ConfigChanged{Config: next}
284296 <-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", 40294 next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
283 in <- ports.ConfigChanged{Config: next}295 in <- ports.ConfigChanged{Config: next}
284 <-out296 <-out
M internal/core/action.go +20 -0
@@ -3,6 +3,8 @@ package core
33 import (
44 "strconv"
55 "strings"
6+
7+ "github.com/bnema/neferwl/internal/ports"
68 )
79
810 type Action string
@@ -42,6 +44,24 @@ const (
4244 ActionConsumeOrExpelRight Action = "consume-or-expel-window-right"
4345 )
4446
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+
4565 // WorkspaceOp is what a numbered workspace action does.
4666 type WorkspaceOp int
4767
@@ -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 string10 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 int66 type WorkspaceOp int
47 67
M internal/core/core.go +11 -0
@@ -907,6 +907,17 @@ func (c *Core) Run(ctx context.Context) error {
907907 }
908908 }
909909 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
910921 }
911922 key, ok := ev.(ports.KeyEvent)
912923 if !ok {
@@ -907,6 +907,17 @@ func (c *Core) Run(ctx context.Context) error {
907 }907 }
908 }908 }
909 continue909 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 {
8888 LayoutRules
8989 Outputs []OutputLayout
9090 }
91- Focus struct {
91+ Touchpad TouchpadConfig
92+ Focus struct {
9293 // FollowMove shows the target workspace after a column or window
9394 // moves to it.
9495 FollowMove bool
@@ -110,6 +111,12 @@ type Config struct {
110111 }
111112 }
112113
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+
113120 // WorkspaceConfig declares a bind-only named workspace, outside the numbered list.
114121 // Zero MaxColumns and an empty Overflow use the layout.* defaults.
115122 type WorkspaceConfig struct {
@@ -88,7 +88,8 @@ type Config struct {
88 LayoutRules88 LayoutRules
89 Outputs []OutputLayout89 Outputs []OutputLayout
90 }90 }
91- Focus struct {91+ Touchpad TouchpadConfig
92+ Focus struct {
92 // FollowMove shows the target workspace after a column or window93 // FollowMove shows the target workspace after a column or window
93 // moves to it.94 // moves to it.
94 FollowMove bool95 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 {
365365
366366 func (PointerAxis) inputEvent() {}
367367
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+
368383 // OutputEvent carries output → core notifications.
369384 type OutputEvent interface{ outputEvent() }
370385
@@ -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

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 26

Tags 10

↑↓ moveenter openesc close