Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(ccectl): inject touchpad finger scrolls and pinches for headless testing
`pointer-scroll <dy> [dx] finger` sends an axis event with source FINGER
and no discrete step — what libinput sends for a two-finger scroll — and
`pointer-scroll finger-stop` the zero-delta event that ends one;
`pointer-pinch <scale> [rotation] [steps]` drives a whole two-finger
pinch through the compositor's pinch policy and the pointer-gestures
forward. Every injection is followed by a pointer frame: clients
(Xwayland among them) deliver only on the frame, and wlroots asserts if
a later axis event changes source within one — which is how the first
headless run of these took the compositor down.
Used to establish what an X11 client sees of the trackpad under this
compositor: Xwayland attributes finger scroll to its relative-pointer
device, which Qt classifies as a TouchPad, and pinches arrive as XI 2.4
gestures that Qt 6 turns into native zoom/rotate events.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/server/cursor.rs | 93 ++++++++++++++++++++++++++++++++++++++++++--
src/server/window_manager.rs | 24 ++++++++++--
2 files changed, 111 insertions(+), 6 deletions(-)
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index c2f67be..04c3356 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -976,7 +976,12 @@ impl Cursor {
/// Wheel scroll; positive `dy` scrolls down (content up), matching a real wheel.
/// One notch is 15 delta units / 120 `value120` steps (the libinput convention).
- pub unsafe fn inject_scroll(&mut self, dy: f64, dx: f64) {
+ /// `finger` injects a touchpad two-finger scroll (axis source FINGER, no
+ /// discrete steps) instead of a wheel click, so a headless session can
+ /// exercise the trackpad paths — the compositor's own desk pan and what
+ /// X11/Wayland clients receive. A finger scroll ends with a zero-delta
+ /// event, which `finger_stop` sends.
+ pub unsafe fn inject_scroll(&mut self, dy: f64, dx: f64, finger: bool) {
let time = crate::util::msec_timestamp();
for (delta, orientation) in [
(dy, ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL),
@@ -988,11 +993,15 @@ impl Cursor {
let mut ev = ffi::wlr_pointer_axis_event {
pointer: std::ptr::null_mut(),
time_msec: time,
- source: ffi::wl_pointer_axis_source_WL_POINTER_AXIS_SOURCE_WHEEL,
+ source: if finger {
+ ffi::wl_pointer_axis_source_WL_POINTER_AXIS_SOURCE_FINGER
+ } else {
+ ffi::wl_pointer_axis_source_WL_POINTER_AXIS_SOURCE_WHEEL
+ },
orientation,
relative_direction: ffi::wl_pointer_axis_relative_direction_WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL,
delta,
- delta_discrete: ((delta / 15.0) * 120.0) as i32,
+ delta_discrete: if finger { 0 } else { ((delta / 15.0) * 120.0) as i32 },
};
handle_axis(
&mut self.axis_listener as *mut ffi::wl_listener,
@@ -1000,6 +1009,84 @@ impl Cursor {
);
}
handle_frame(&mut self.frame_listener as *mut ffi::wl_listener, std::ptr::null_mut());
+ // Every libinput axis event is followed by a frame; clients
+ // (Xwayland among them) deliver only on the frame, and wlroots
+ // asserts if a later axis event changes source within one.
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+ }
+
+ /// The zero-delta event that ends a finger scroll (libinput sends one
+ /// when the fingers lift); see `inject_scroll`.
+ pub unsafe fn inject_finger_stop(&mut self) {
+ let time = crate::util::msec_timestamp();
+ for orientation in [
+ ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL,
+ ffi::wl_pointer_axis_WL_POINTER_AXIS_HORIZONTAL_SCROLL,
+ ] {
+ let mut ev = ffi::wlr_pointer_axis_event {
+ pointer: std::ptr::null_mut(),
+ time_msec: time,
+ source: ffi::wl_pointer_axis_source_WL_POINTER_AXIS_SOURCE_FINGER,
+ orientation,
+ relative_direction: ffi::wl_pointer_axis_relative_direction_WL_POINTER_AXIS_RELATIVE_DIRECTION_IDENTICAL,
+ delta: 0.0,
+ delta_discrete: 0,
+ };
+ handle_axis(
+ &mut self.axis_listener as *mut ffi::wl_listener,
+ &mut ev as *mut ffi::wlr_pointer_axis_event as *mut std::ffi::c_void,
+ );
+ }
+ // Every libinput axis event is followed by a frame; clients
+ // (Xwayland among them) deliver only on the frame, and wlroots
+ // asserts if a later axis event changes source within one.
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+ }
+
+ /// Inject a whole two-finger pinch: begin, `steps` updates easing the
+ /// scale from 1 to `scale` (and the rotation to `rotation` degrees),
+ /// end. Exercises the compositor's pinch policy and the
+ /// pointer-gestures forward to clients headlessly.
+ pub unsafe fn inject_pinch(&mut self, scale: f64, rotation: f64, steps: u32) {
+ let time = crate::util::msec_timestamp();
+ let mut begin = ffi::wlr_pointer_pinch_begin_event {
+ pointer: std::ptr::null_mut(),
+ time_msec: time,
+ fingers: 2,
+ };
+ handle_pinch_begin(
+ &mut self.pinch_begin_listener as *mut ffi::wl_listener,
+ &mut begin as *mut ffi::wlr_pointer_pinch_begin_event as *mut std::ffi::c_void,
+ );
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+ let steps = steps.max(1);
+ for i in 1..=steps {
+ let t = i as f64 / steps as f64;
+ let mut update = ffi::wlr_pointer_pinch_update_event {
+ pointer: std::ptr::null_mut(),
+ time_msec: time + i,
+ fingers: 2,
+ dx: 0.0,
+ dy: 0.0,
+ scale: 1.0 + (scale - 1.0) * t,
+ rotation: rotation * t,
+ };
+ handle_pinch_update(
+ &mut self.pinch_update_listener as *mut ffi::wl_listener,
+ &mut update as *mut ffi::wlr_pointer_pinch_update_event as *mut std::ffi::c_void,
+ );
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+ }
+ let mut end = ffi::wlr_pointer_pinch_end_event {
+ pointer: std::ptr::null_mut(),
+ time_msec: time + steps + 1,
+ cancelled: false,
+ };
+ handle_pinch_end(
+ &mut self.pinch_end_listener as *mut ffi::wl_listener,
+ &mut end as *mut ffi::wlr_pointer_pinch_end_event as *mut std::ffi::c_void,
+ );
+ ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
}
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index b2c7de5..3123ada 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5029,16 +5029,34 @@ impl WindowManager {
"ok\n".to_string()
}
"pointer-scroll" => {
- if parts.len() < 2 { return "error: usage: pointer-scroll <dy> [dx]\n".to_string(); }
+ if parts.len() < 2 { return "error: usage: pointer-scroll <dy> [dx] [finger|finger-stop]\n".to_string(); }
+ if parts[1] == "finger-stop" {
+ self.for_each_cursor(|cursor| cursor.inject_finger_stop());
+ return "ok\n".to_string();
+ }
+ let finger = parts.last().map_or(false, |p| *p == "finger");
let dy = parts[1].parse::<f64>();
- let dx = parts.get(2).map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
+ let dx = parts.get(2).filter(|p| **p != "finger").map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
if let (Ok(dy), Ok(dx)) = (dy, dx) {
- self.for_each_cursor(|cursor| cursor.inject_scroll(dy, dx));
+ self.for_each_cursor(|cursor| cursor.inject_scroll(dy, dx, finger));
"ok\n".to_string()
} else {
"error: invalid dy or dx\n".to_string()
}
}
+ "pointer-pinch" => {
+ // pointer-pinch <scale> [rotation-degrees] [steps]
+ if parts.len() < 2 { return "error: usage: pointer-pinch <scale> [rotation] [steps]\n".to_string(); }
+ let scale = parts[1].parse::<f64>();
+ let rotation = parts.get(2).map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
+ let steps = parts.get(3).map(|v| v.parse::<u32>()).unwrap_or(Ok(10));
+ if let (Ok(scale), Ok(rotation), Ok(steps)) = (scale, rotation, steps) {
+ self.for_each_cursor(|cursor| cursor.inject_pinch(scale, rotation, steps));
+ "ok\n".to_string()
+ } else {
+ "error: invalid pinch arguments\n".to_string()
+ }
+ }
"place-next" => {
// place-next <app_id> <x> <y>: one-shot hint — the next map of
// a floating toplevel with this app_id lands near this layout