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

commit881c2b1eec0646e39d30a00f85bc3b3474ee0612
parentf082902012
authorLucas Galante <[email protected]>
date2026-07-13 11:52
feat(ipc): ccectl synthetic input — full wlrctl replacement with held buttons

New pointer commands (pointer-location, pointer-move-to/by, pointer-press,
pointer-release, pointer-click, pointer-scroll) and one-direction key
events (key-down/key-up). Injection runs through the REAL cursor handlers
(Cursor::inject_* builds the event a device would deliver and calls the
handler via its listener), so ops, grabs, overview gating, and focus treat
synthetic input exactly like hardware; motions/buttons/axes finish with a
frame like a real device batch (sctk clients queue events until the frame).
pointer-move-to now takes layout pixels (wlr_cursor_warp) — the old
warp_absolute call expected 0..1-normalized coordinates and never worked.
Press and release are separate on purpose: held drags are
press -> moves -> release, which wlrctl could never drive.

Verified in a nested compositor (isolated XDG_CONFIG_HOME/STATE_HOME,
-c true): window move-by-drag lands exactly, clicks/scrolls drive clients
(WAYLAND_DEBUG wire log confirms button/motion/frame sequences), and a held
slider drag works end to end.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01N4ajhvVZtyEEEus9bodsj3

 scratch/print_xwayland_offsets   | Bin 0 -> 15984 bytes
 scratch/print_xwayland_offsets.c |  21 +++++++++
 src/cce_ctl.rs                   |  16 +++----
 src/server/cursor.rs             |  93 ++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs     |  99 +++++++++++++++++++++++++++++++++++++--
 5 files changed, 216 insertions(+), 13 deletions(-)

diff --git a/scratch/print_xwayland_offsets b/scratch/print_xwayland_offsets
new file mode 100755
index 0000000..956ef25
Binary files /dev/null and b/scratch/print_xwayland_offsets differ
diff --git a/scratch/print_xwayland_offsets.c b/scratch/print_xwayland_offsets.c
new file mode 100644
index 0000000..6eee12e
--- /dev/null
+++ b/scratch/print_xwayland_offsets.c
@@ -0,0 +1,21 @@
+#define WLR_USE_UNSTABLE
+#include <wlr/xwayland.h>
+#include <stdio.h>
+#include <stddef.h>
+
+#define PRINT_OFFSET(struct_name, member) \
+    printf("offset of " #member ": %zu\n", offsetof(struct_name, member))
+
+int main() {
+    printf("C sizeof(struct wlr_xwayland): %zu\n", sizeof(struct wlr_xwayland));
+    PRINT_OFFSET(struct wlr_xwayland, server);
+    PRINT_OFFSET(struct wlr_xwayland, own_server);
+    PRINT_OFFSET(struct wlr_xwayland, xwm);
+    PRINT_OFFSET(struct wlr_xwayland, shell_v1);
+    PRINT_OFFSET(struct wlr_xwayland, display_name);
+    PRINT_OFFSET(struct wlr_xwayland, wl_display);
+    PRINT_OFFSET(struct wlr_xwayland, compositor);
+    PRINT_OFFSET(struct wlr_xwayland, seat);
+    PRINT_OFFSET(struct wlr_xwayland, events);
+    return 0;
+}
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index f77d54f..d258233 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -69,15 +69,15 @@ fn usage(name: &str, to_stderr: bool) {
     print("  mode <cascade|grid|fullscreen|floating|popup|maximized|overlay> <app_id> [title]");
     print("  viewport-layout <1-4> <cascade|grid|fullscreen|floating|popup|maximized|overlay>");
     print("  pointer-location");
-    print("  pointer-move-to <x> <y>");
+    print("  pointer-move-to <x> <y>            (layout pixels)");
     print("  pointer-move-by <dx> <dy>");
-    print("  pointer-scroll <dx> <dy>");
-    print("  pointer-click <button>");
-    print("  pointer-press <button>");
-    print("  pointer-release <button>");
-    print("  keypress <key>");
-    print("  key-press <key>");
-    print("  key-release <key>");
+    print("  pointer-scroll <dy> [dx]           (positive dy scrolls down; 15 = one notch)");
+    print("  pointer-click [button]             (left|right|middle|back|forward or evdev code)");
+    print("  pointer-press [button]             (held until pointer-release — drives drags)");
+    print("  pointer-release [button]");
+    print("  keypress <keycode>                 (evdev code; press+release to the focused client)");
+    print("  key-down <keycode>");
+    print("  key-up <keycode>");
 }
  
 pub fn run_cce_ctl(args: Vec<String>) {
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index e573cc4..e1d8317 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -525,6 +525,99 @@ impl Cursor {
             b"default\0".as_ptr() as *const _,
         );
     }
+
+    // ── Synthetic pointer injection (`ccectl pointer-*`) ─────────────────────────
+    // Each call builds the event a real device would deliver and runs it through the
+    // REAL handler (via its listener — the same entry wlroots invokes), so compositor
+    // policy — window ops, overview gating, grabs, focus, pointer constraints — treats
+    // injected input exactly like hardware. Every handler null-checks `event.pointer`,
+    // so a device-less event is safe. Buttons and axes finish with a frame, like a
+    // real device batch. Press and release are separate entry points on purpose: a
+    // held drag is press → any number of moves → release.
+
+    /// Warp to layout coordinates and run the motion tail (hover, drag icons, and
+    /// op-update-or-passthrough, mirroring `handle_motion`). `wlr_cursor_warp` takes
+    /// layout pixels — `wlr_cursor_warp_absolute` is 0..1-normalized, which is the
+    /// bug the old `pointer-move-to` had.
+    pub unsafe fn inject_motion_to(&mut self, x: f64, y: f64) {
+        ffi::wlr_cursor_warp(self.wlr_cursor, std::ptr::null_mut(), x, y);
+        self.update_hovered();
+        self.update_drag_icons();
+        let seat = &mut *self.seat;
+        if seat.op.is_some() {
+            let lx = (*self.wlr_cursor).x as i32;
+            let ly = (*self.wlr_cursor).y as i32;
+            seat.op_update(lx, ly);
+            return;
+        }
+        self.passthrough(crate::util::msec_timestamp());
+        // Real devices terminate every motion batch with a frame; sctk-based clients
+        // queue pointer events until they see one.
+        handle_frame(&mut self.frame_listener as *mut ffi::wl_listener, std::ptr::null_mut());
+    }
+
+    pub unsafe fn inject_motion_by(&mut self, dx: f64, dy: f64) {
+        let mut ev = ffi::wlr_pointer_motion_event {
+            pointer: std::ptr::null_mut(),
+            time_msec: crate::util::msec_timestamp(),
+            delta_x: dx,
+            delta_y: dy,
+            unaccel_dx: dx,
+            unaccel_dy: dy,
+        };
+        handle_motion(
+            &mut self.motion_listener as *mut ffi::wl_listener,
+            &mut ev as *mut ffi::wlr_pointer_motion_event as *mut std::ffi::c_void,
+        );
+        handle_frame(&mut self.frame_listener as *mut ffi::wl_listener, std::ptr::null_mut());
+    }
+
+    pub unsafe fn inject_button(&mut self, button: u32, pressed: bool) {
+        let state = if pressed {
+            ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_PRESSED
+        } else {
+            ffi::wl_pointer_button_state_WL_POINTER_BUTTON_STATE_RELEASED
+        };
+        let mut ev = ffi::wlr_pointer_button_event {
+            pointer: std::ptr::null_mut(),
+            time_msec: crate::util::msec_timestamp(),
+            button,
+            state,
+        };
+        handle_button(
+            &mut self.button_listener as *mut ffi::wl_listener,
+            &mut ev as *mut ffi::wlr_pointer_button_event as *mut std::ffi::c_void,
+        );
+        handle_frame(&mut self.frame_listener as *mut ffi::wl_listener, std::ptr::null_mut());
+    }
+
+    /// 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) {
+        let time = crate::util::msec_timestamp();
+        for (delta, orientation) in [
+            (dy, ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL),
+            (dx, ffi::wl_pointer_axis_WL_POINTER_AXIS_HORIZONTAL_SCROLL),
+        ] {
+            if delta == 0.0 {
+                continue;
+            }
+            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,
+                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,
+            };
+            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,
+            );
+        }
+        handle_frame(&mut self.frame_listener as *mut ffi::wl_listener, std::ptr::null_mut());
+    }
 }
 
 unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 8643a2b..b23fd17 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -3384,23 +3384,86 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
                     format!("error: unknown input command: {}\n", key)
                 }
             }
+            // ── Synthetic pointer input (`ccectl pointer-*`): full wlrctl replacement
+            // plus held buttons. Injection runs through the real cursor handlers
+            // (`Cursor::inject_*`), so grabs/ops/focus behave exactly as with hardware.
             "pointer-move-to" => {
                 if parts.len() < 3 { return "error: usage: pointer-move-to <x> <y>\n".to_string(); }
                 if let (Ok(x), Ok(y)) = (parts[1].parse::<f64>(), parts[2].parse::<f64>()) {
+                    self.for_each_cursor(|cursor| cursor.inject_motion_to(x, y));
+                    "ok\n".to_string()
+                } else {
+                    "error: invalid x or y\n".to_string()
+                }
+            }
+            "pointer-move-by" => {
+                if parts.len() < 3 { return "error: usage: pointer-move-by <dx> <dy>\n".to_string(); }
+                if let (Ok(dx), Ok(dy)) = (parts[1].parse::<f64>(), parts[2].parse::<f64>()) {
+                    self.for_each_cursor(|cursor| cursor.inject_motion_by(dx, dy));
+                    "ok\n".to_string()
+                } else {
+                    "error: invalid dx or dy\n".to_string()
+                }
+            }
+            "pointer-press" | "pointer-release" | "pointer-click" => {
+                let button = match Self::parse_pointer_button(parts.get(1).copied()) {
+                    Some(b) => b,
+                    None => return "error: unknown button (left|right|middle|back|forward or an evdev code)\n".to_string(),
+                };
+                match action {
+                    "pointer-press" => self.for_each_cursor(|cursor| cursor.inject_button(button, true)),
+                    "pointer-release" => self.for_each_cursor(|cursor| cursor.inject_button(button, false)),
+                    _ => self.for_each_cursor(|cursor| {
+                        cursor.inject_button(button, true);
+                        cursor.inject_button(button, false);
+                    }),
+                }
+                "ok\n".to_string()
+            }
+            "pointer-scroll" => {
+                if parts.len() < 2 { return "error: usage: pointer-scroll <dy> [dx]\n".to_string(); }
+                let dy = parts[1].parse::<f64>();
+                let dx = parts.get(2).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));
+                    "ok\n".to_string()
+                } else {
+                    "error: invalid dy or dx\n".to_string()
+                }
+            }
+            "pointer-location" => {
+                let mut reply = "error: no seat\n".to_string();
+                let seats_list = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
+                let curr_seat = (*seats_list).next;
+                if curr_seat != seats_list {
+                    let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
+                    let cursor = &(*seat).cursor;
+                    reply = format!("x={} y={}\n", cursor.x(), cursor.y());
+                }
+                reply
+            }
+            // One-direction key events (held modifiers/keys); `keycode` is the evdev
+            // code. Like `keypress`, this notifies the focused client directly — it does
+            // not run compositor keybindings or update xkb modifier state.
+            "key-down" | "key-up" => {
+                if parts.len() < 2 { return "error: usage: key-down|key-up <keycode>\n".to_string(); }
+                if let Ok(keycode) = parts[1].parse::<u32>() {
+                    let state = if action == "key-down" {
+                        ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED
+                    } else {
+                        ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_RELEASED
+                    };
                     let seats_list = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
                     let mut curr_seat = (*seats_list).next;
                     while curr_seat != seats_list {
                         let next_seat = (*curr_seat).next;
                         let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
-                        let cursor = &mut (*seat).cursor;
-                        ffi::wlr_cursor_warp_absolute(cursor.wlr_cursor, std::ptr::null_mut(), x, y);
-                        cursor.update_hovered();
-                        cursor.passthrough(crate::util::msec_timestamp());
+                        ffi::wlr_seat_keyboard_notify_key((*seat).wlr_seat, crate::util::msec_timestamp(), keycode, state);
                         curr_seat = next_seat;
                     }
                     "ok\n".to_string()
                 } else {
-                    "error: invalid x or y\n".to_string()
+                    "error: invalid keycode\n".to_string()
                 }
             }
             "keypress" | "key-press" => {
@@ -3425,6 +3488,32 @@ fn get_closest_tag(x: f64, y: f64) -> i32 {
         }
     }
 
+    /// Run `f` on every seat's cursor (the synthetic-input commands act on all seats,
+    /// like the pre-existing pointer-move-to loop did).
+    unsafe fn for_each_cursor(&mut self, mut f: impl FnMut(&mut crate::cursor::Cursor)) {
+        let seats_list = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
+        let mut curr_seat = (*seats_list).next;
+        while curr_seat != seats_list {
+            let next_seat = (*curr_seat).next;
+            let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
+            f(&mut (*seat).cursor);
+            curr_seat = next_seat;
+        }
+    }
+
+    /// Button-name/evdev-code parsing for the pointer commands; a missing argument
+    /// means the left button, like wlrctl.
+    fn parse_pointer_button(arg: Option<&str>) -> Option<u32> {
+        match arg {
+            None | Some("left") => Some(0x110),
+            Some("right") => Some(0x111),
+            Some("middle") => Some(0x112),
+            Some("back") | Some("side") => Some(0x113),
+            Some("forward") | Some("extra") => Some(0x114),
+            Some(other) => other.parse::<u32>().ok(),
+        }
+    }
+
     pub unsafe fn apply_input_rules(&mut self) {
         if self.server.is_null() {
             return;