Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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;