Skip to content

Add experimental libinput FFI input plugin (Phase 1) - #362

Open
iberianpig wants to merge 9 commits into
mainfrom
ffi
Open

Add experimental libinput FFI input plugin (Phase 1)#362
iberianpig wants to merge 9 commits into
mainfrom
ffi

Conversation

@iberianpig

Copy link
Copy Markdown
Owner

Summary

Adds libinput_ffi_input, an experimental input plugin that talks to libinput.so directly via Fiddle (FFI) instead of spawning the libinput debug-events subprocess.

  • No libinput-tools dependency: device discovery uses the udev backend / FFI device enumeration instead of libinput list-devices (~200ms per call)
  • Hotplug support: devices on the seat are discovered by libinput itself
  • No text parsing: pre-parsed GestureRecord objects flow straight into the pipeline
  • Opt-in: disabled by default; enable via config (see docs/libinput_ffi_input.md)

What's included

Area Content
FFI bindings lib/fusuma/libinput/ — context (path/udev), interface callbacks, gesture/touch/pointer event extraction, device detection
Input plugin lib/fusuma/plugin/inputs/libinput_ffi_input.rb — udev backend, per-device tap/dwt config, device: filtering (regex, like keep_device), enabled? opt-in mechanism for inputs
New records TouchRecord (touchscreens) and PointerRecord (motion/button/scroll, opt-in) for plugins that consume non-gesture events
Tests unit specs (FFI stubbed, run anywhere) + uinput-based integration specs driving real libinput end-to-end (swipe/pinch/hold, touchscreen TOUCH, full plugin pipe)
CI / Docker integration job running the uinput specs on the runner VM; Dockerfile.integration for local reproduction
Docs docs/libinput_ffi_input.md — enabling, all config parameters, 4 ways to verify, limitations

Boot safety: the FFI bindings are required lazily, so fusuma still boots and tests still pass on systems without libinput.so (verified in a libinput-less container; the build matrix exercises this path).

Phased rollout

This PR is Phase 1: experimental, opt-in, zero impact on default behavior (no new gem dependencies; fiddle stays undeclared while experimental).

Later phases (separate PRs):

  • Phase 2: port external plugins that parse libinput's raw text output (e.g. fusuma-plugin-touchscreen) to consume TouchRecord / PointerRecord
  • Phase 3: make the FFI input the default and deprecate the CLI input; declare the fiddle dependency for Ruby 3.5+

Test plan

  • bundle exec rake (332 specs + rbs validate + steep) green on Ruby 3.3
  • Integration specs green on uinput (host + Docker + CI job)
  • Full suite green without libinput.so installed (CLI fallback / skip paths)
  • Verified gestures end-to-end through the plugin pipe over the udev backend

🤖 Generated with Claude Code

iberianpig and others added 9 commits June 12, 2026 10:13
Wrap libinput.so/libudev.so directly instead of spawning the libinput
CLI: context (path/udev backends), libinput_interface callbacks,
gesture event extraction to GestureRecord, and FFI-based device
detection.

- scale/angle_delta are only queried for PINCH events per the
  libinput API contract
- dlopen happens at require of libinput.rb; callers load it lazily
- declare fiddle in Gemfile (removed from default gems in Ruby 3.5)
  and add minimal Fiddle RBS (no upstream signatures exist)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Create a virtual touchpad via /dev/uinput and verify swipe/pinch/hold
gestures end-to-end through real libinput.

Lessons encoded in the helper:
- UI_ABS_SETUP encodes the struct size (28 = 0x1c) in the ioctl number;
  a wrong size makes the kernel return E2BIG
- event node is resolved via sysfs by device name; kernel reuses event
  numbers so guessing from /dev/input ordering is unreliable
- all slots of a multi-finger update must share one SYN_REPORT frame,
  otherwise libinput's touch-jump detection discards the motion
- BTN_TOOL_FINGER/DOUBLETAP/TRIPLETAP must track the finger count;
  without them pressure-less touchpads stay in TOUCH_HOVERING and emit
  nothing
- dispatch while injecting (pump_events); libinput schedules tap/hold
  timers from event timestamps

Specs skip gracefully without libinput.so or /dev/uinput access.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Input plugin that reads gestures via the FFI bindings instead of the
libinput debug-events subprocess. Uses the udev backend so devices on
the seat (including hotplugged ones) are discovered without the CLI,
applies tap/dwt config per device on DEVICE_ADDED, and streams
GestureRecords through a pipe as length-prefixed Marshal.

- disabled by default; Input#enabled? lets config turn inputs on/off
  and Runner#initialize_plugins skips disabled ones
- FFI bindings are required lazily in #start_event_loop: this file is
  auto-required at boot and an eager require would dlopen libinput.so,
  breaking fusuma on systems without libinput
- #shutdown stops the event thread and releases the context

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Device.fetch_devices uses Libinput::DeviceDetector instead of shelling
out to `libinput list-devices` (~200ms) when the FFI input is enabled,
removing the libinput-tools dependency from device detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add an integration job that loads the uinput module, relaxes device
permissions via a udev rule, and runs the integration specs directly
on the runner VM. Docker cannot self-contain this: uinput devices live
in the host kernel and libinput classifies devices via the udev
daemon's database.

Dockerfile.integration reproduces the setup locally by borrowing the
host kernel and udev db (see header comment; --device-cgroup-rule is
required because the device cgroup rejects open(2) on input nodes
with EPERM even as root).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rbs-trace derives signatures from observed calls, which can be
narrower than reality: Base.inherited is a hook (-> void, not the
return of Manager.add) and LibinputCommand#which returns nil when the
command is not found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code review follow-up for the libinput_ffi_input series:

- LibinputGestureParser only matched libinput debug-events text lines,
  so pre-parsed GestureRecords from libinput_ffi_input never reached
  the gesture buffer; pass them through so the event is re-tagged
- declare :source in Parser#config_param_types: every parser supports
  source switching, but overriding it via config.yml raised
  NotImplementedError because the key was never declared
- close the writer when the FFI event thread dies so the reader sees
  EOF and shuts fusuma down, matching the CLI input's behavior when
  its subprocess exits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the remaining feature gaps with the CLI input:

- device: config (CLI --device equivalent): on DEVICE_ADDED, gesture
  devices whose name does not match are muted via
  libinput_device_config_send_events_set_mode. Matching uses the same
  regex semantics as libinput_device_filter's keep_device. Non-gesture
  devices stay enabled so disable-while-typing keeps working.
- TouchRecord (TOUCH_DOWN/UP/MOTION/CANCEL/FRAME with mm positions)
  and PointerRecord (motion/button/scroll) replace the CLI's raw text
  lines for plugins that consume non-gesture events. touch-events
  defaults to on (only touchscreens produce them); pointer-events is
  opt-in because of its frequency.
- uinput helper grows a VirtualTouchscreen (INPUT_PROP_DIRECT) and the
  integration specs cover TOUCH records end-to-end through libinput.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
How to enable the FFI input, all config parameters, four ways to verify
it works (specs on uinput, Docker, CI, on a real machine), the record
types it emits, and current limitations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant