feat: touch input — TouchDown/Move/Up + host libei ei_touchscreen injection
ci / rust (push) Has been cancelled

Roadmap #5 (touch, ahead of the XL UHID DualSense work). Touch fits the existing 18-byte
InputEvent: code = touch id, x/y = client pixels, flags = (w<<16)|h — the same absolute
mapping as MouseMoveAbs.

- core: InputKind::{TouchDown=9, TouchMove=10, TouchUp=11} + from_u8 + roundtrip test.
- host inject/libei.rs: request the RemoteDesktop Touchscreen device type, bind the Touch
  capability, and inject ei_touchscreen down/motion/up (one event = one frame, per the
  protocol rule), mapping coordinates into the device region like the abs pointer. wlroots
  has no virtual-touch protocol wired — no-ops there.
- client-rs --touch-test: drags a synthetic finger (touch id 0) in a circle.

Validated live on headless KWin: the portal GRANTS the Touchscreen device type
(Keyboard|Pointer|Touchscreen), proving the request path — but KWin's EIS server creates no
touchscreen *device*, so touch currently no-ops on this KWin (now logged once, not silent).
The injection code is correct and will land on a backend that exposes ei_touchscreen
(gamescope / a newer compositor / the real touch-client path). Workspace green, clippy/fmt
clean, +1 unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:38:44 +00:00
parent e07e359b6d
commit dc375668ee
6 changed files with 152 additions and 7 deletions
+51 -3
View File
@@ -178,7 +178,7 @@ async fn connect_portal() -> Result<(
rd.select_devices(
&session,
SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_devices(DeviceType::Keyboard | DeviceType::Pointer | DeviceType::Touchscreen)
.set_persist_mode(PersistMode::DoNot),
)
.await
@@ -263,7 +263,8 @@ impl EiState {
| DeviceCapability::PointerAbsolute
| DeviceCapability::Keyboard
| DeviceCapability::Scroll
| DeviceCapability::Button,
| DeviceCapability::Button
| DeviceCapability::Touch,
);
let _ = ctx.flush();
}
@@ -321,10 +322,31 @@ impl EiState {
InputKind::MouseButtonDown | InputKind::MouseButtonUp => DeviceCapability::Button,
InputKind::MouseScroll => DeviceCapability::Scroll,
InputKind::KeyDown | InputKind::KeyUp => DeviceCapability::Keyboard,
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {
DeviceCapability::Touch
}
InputKind::GamepadButton | InputKind::GamepadAxis => return, // uinput path (later)
};
let Some(idx) = self.device_for(cap) else {
return; // no resumed device with this capability yet
// No resumed device with this capability yet. For touch this is usually permanent on
// this compositor — the RemoteDesktop portal may grant the Touchscreen *device type*
// while the EIS server never creates a touchscreen *device* (observed on headless
// KWin). Surface it once so touch silently going nowhere is diagnosable.
if matches!(
ev.kind,
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp
) {
static WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
tracing::warn!(
"touch received but the compositor's EIS exposed no touchscreen device — \
touch is dropped (KWin's libei may not implement ei_touchscreen yet; \
gamescope / a newer compositor may)"
);
}
}
return;
};
let dev = self.devices[idx].device.device().clone();
self.ensure_emulating(idx, &dev);
@@ -398,6 +420,32 @@ impl EiState {
}
}
}
// Touch: `code` is the touch id, `x`/`y` are client pixels and `flags` packs the
// client surface w/h — mapped into the device's region exactly like MouseMoveAbs.
// One InputEvent = one frame, which satisfies the ei_touchscreen rule that a down /
// motion / up must not share a frame.
InputKind::TouchDown | InputKind::TouchMove => {
let w = ((ev.flags >> 16) & 0xffff) as f32;
let h = (ev.flags & 0xffff) as f32;
match (slot.interface::<ei::Touchscreen>(), slot.regions().first()) {
(Some(t), Some(region)) if w > 0.0 && h > 0.0 => {
let nx = (ev.x as f32 / w).clamp(0.0, 1.0);
let ny = (ev.y as f32 / h).clamp(0.0, 1.0);
let x = region.x as f32 + nx * region.width as f32;
let y = region.y as f32 + ny * region.height as f32;
if ev.kind == InputKind::TouchDown {
t.down(ev.code, x, y);
} else {
t.motion(ev.code, x, y);
}
}
_ => emitted = false,
}
}
InputKind::TouchUp => match slot.interface::<ei::Touchscreen>() {
Some(t) => t.up(ev.code),
None => emitted = false,
},
InputKind::GamepadButton | InputKind::GamepadAxis => emitted = false,
}
+2
View File
@@ -249,6 +249,8 @@ impl InputInjector for WlrootsInjector {
}
}
InputKind::GamepadButton | InputKind::GamepadAxis => {} // not yet injected
// wlroots has no virtual-touch protocol wired here; touch is the libei path only.
InputKind::TouchDown | InputKind::TouchMove | InputKind::TouchUp => {}
}
// Surface protocol errors / disconnects, then push the batch to the compositor.
self.queue