git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitc12c94bd52624dc810780b36359492e3dfb8af42
parent7c09314c68
authorLucas Galante <[email protected]>
date2026-09-08 13:05
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