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

commit1ffd0e98cced03763ed0456ed58419e5b6c5af5c
parentda95c6ff2f
authorLucas Galante <[email protected]>
date2026-09-08 12:16
fix(xwayland): keep X11 pointer coordinates stable across commits and drags

Two coordinate holes in the HiDPI X11 path (da95c6f):

Hover flickered in Houdini. The scene's commit handler resets a committed
buffer's dest size to natural — for an X11 surface the physical size,
twice the logical box — and only the per-frame pass restored the 1/scale
dest. Every pointer event in that gap hit-tested through the unscaled
buffer and reached the client at half its coordinates. Houdini repaints
on each hover change, so each repaint opened the gap, the next motion
event landed elsewhere, the widget un-hovered, and so on. The window's
own commit handler (registered after the scene's, so it runs after the
reset) now re-applies the scale at once, and override-redirect surfaces
get a commit listener doing the same.

Held-button drags reached X11 clients at half speed: the implicit grab's
motion was the layout delta from the press origin, in logical pixels,
where an X11 surface expects buffer pixels. The grab now freezes the
surface-units-per-layout-pixel ratio at press (buffer width over dest
width, so the overview zoom is covered too) and scales the delta by it.

Verified headless at output scale 2 with a GTK4 X11 client logging what it
receives while repainting every 8 ms: on the previous binary 60 one-pixel
motions produced seven jumps of up to 56 units and a 50-pixel drag arrived
as 25; on this one, no step larger than 2 and the drag arrives as 50.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/cursor.rs                     | 23 ++++++++++++++++++++---
 src/server/window.rs                     | 11 +++++++++++
 src/server/xwayland_override_redirect.rs | 14 ++++++++++++++
 3 files changed, 45 insertions(+), 3 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index b6eaf1e..c2f67be 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -51,6 +51,12 @@ pub struct Cursor {
     /// implicit grab's frame of reference, so held-button motion stays
     /// surface-relative wherever the pointer goes (passthrough's grab branch).
     pub grab_origin: (f64, f64),
+    /// Surface units per layout pixel for the grabbed surface, frozen at
+    /// press: 1 for a buffer shown at its natural size, the output scale
+    /// for an X11 surface under xwayland_hidpi (physical-pixel buffer drawn
+    /// at 1/scale), 1/zoom in the overview. Without it a held-button drag
+    /// reached an X11 client at half speed.
+    pub grab_scale: f64,
 
     pub touch_down_listener: ffi::wl_listener,
     pub touch_motion_listener: ffi::wl_listener,
@@ -132,6 +138,7 @@ impl Default for Cursor {
             pressed: HashMap::new(),
             notified_pressed: HashSet::new(),
             grab_origin: (0.0, 0.0),
+            grab_scale: 1.0,
 
             touch_down_listener: unsafe { std::mem::zeroed() },
             touch_motion_listener: unsafe { std::mem::zeroed() },
@@ -747,8 +754,8 @@ impl Cursor {
                 ffi::wlr_seat_pointer_notify_motion(
                     (*self.seat).wlr_seat,
                     time_msec,
-                    lx - self.grab_origin.0,
-                    ly - self.grab_origin.1,
+                    (lx - self.grab_origin.0) * self.grab_scale,
+                    (ly - self.grab_origin.1) * self.grab_scale,
                 );
                 return;
             }
@@ -1653,7 +1660,17 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 let glx = cursor.x();
                 let gly = cursor.y();
                 if let Some(result) = (*seat.server).scene.at(glx, gly) {
-                    cursor.grab_origin = (glx - result.sx, gly - result.sy);
+                    // A surface node's scene buffer begins with its node.
+                    let mut ratio = 1.0;
+                    if !result.surface.is_null() && !result.node.is_null() {
+                        let dest_w = ffi::river_scene_buffer_get_dest_width(result.node as *mut ffi::wlr_scene_buffer);
+                        let surf_w = ffi::river_wlr_surface_get_width(result.surface);
+                        if dest_w > 0 && surf_w > 0 {
+                            ratio = surf_w as f64 / dest_w as f64;
+                        }
+                    }
+                    cursor.grab_scale = ratio;
+                    cursor.grab_origin = (glx - result.sx / ratio, gly - result.sy / ratio);
                 }
                 // A grab that starts on the grid freezes its node mapping
                 // here instead of using grab_origin: passthrough maps motion
diff --git a/src/server/window.rs b/src/server/window.rs
index f0fea0c..1318f6f 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -5270,6 +5270,17 @@ unsafe extern "C" fn handle_window_commit(listener: *mut ffi::wl_listener, _data
         }
     }
     (*window).render_finish();
+    // The scene's own commit handler (registered before this one, so it has
+    // already run) resets the committed buffer's dest size to natural. For
+    // an X11 surface under xwayland_hidpi that is the physical size — twice
+    // the logical box — and until the per-frame pass restores it every
+    // pointer event hit-tests through the unscaled buffer and reaches the
+    // client at HALF its coordinates. Houdini repaints on every hover
+    // change, so hover flickered: each repaint opened the gap, the next
+    // motion event landed elsewhere, the widget un-hovered, repeat.
+    if (*window).x11_buffer_scale() != 1.0 {
+        (*window).scale_only_render_finish();
+    }
     if was_status {
         (*(*window).server).wm.dirty_windowing();
     }
diff --git a/src/server/xwayland_override_redirect.rs b/src/server/xwayland_override_redirect.rs
index 0889e48..b81f47c 100644
--- a/src/server/xwayland_override_redirect.rs
+++ b/src/server/xwayland_override_redirect.rs
@@ -21,6 +21,10 @@ pub struct XwaylandOverrideRedirect {
     pub unmap: ffi::wl_listener,
 
     pub set_geometry: ffi::wl_listener,
+    /// Re-applies the 1/scale dest size after every commit (the scene's
+    /// commit handler resets it) — see `Window`'s commit handler for why a
+    /// per-frame pass alone leaves a hit-testing gap.
+    pub commit: ffi::wl_listener,
 }
 
 unsafe fn connect_listener(
@@ -68,6 +72,7 @@ impl XwaylandOverrideRedirect {
             map: std::mem::zeroed(),
             unmap: std::mem::zeroed(),
             set_geometry: std::mem::zeroed(),
+            commit: std::mem::zeroed(),
         });
 
         let raw = Box::into_raw(override_redirect);
@@ -246,6 +251,8 @@ unsafe fn handle_map_impl(or: *mut XwaylandOverrideRedirect) {
     (*or).apply_x11_scale();
 
     connect_listener(&mut (*(*or).xsurface).events.set_geometry, &mut (*or).set_geometry, handle_set_geometry);
+    // After the scene's subsurface tree, so this runs after its reset.
+    connect_listener(ffi::river_wlr_surface_get_commit_signal(surface), &mut (*or).commit, handle_commit);
 
     (*or).focus_if_desired();
 }
@@ -254,6 +261,7 @@ unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut s
     let or = crate::container_of!(listener, XwaylandOverrideRedirect, unmap);
 
     wl_listener_remove_safe(&mut (*or).set_geometry);
+    wl_listener_remove_safe(&mut (*or).commit);
 
     let surface = (*(*or).xsurface).surface;
     if !surface.is_null() {
@@ -285,6 +293,11 @@ unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut s
     (*(*or).server).wm.dirty_windowing();
 }
 
+unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
+    let or = crate::container_of!(listener, XwaylandOverrideRedirect, commit);
+    (*or).apply_x11_scale();
+}
+
 unsafe extern "C" fn handle_set_geometry(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let or = crate::container_of!(listener, XwaylandOverrideRedirect, set_geometry);
     (*or).place();
@@ -301,6 +314,7 @@ unsafe extern "C" fn handle_set_override_redirect(listener: *mut ffi::wl_listene
         if ffi::river_wlr_surface_is_mapped(surface) {
             // handle unmap inline
             wl_listener_remove_safe(&mut (*or).set_geometry);
+    wl_listener_remove_safe(&mut (*or).commit);
             ffi::river_wlr_surface_set_data(surface, std::ptr::null_mut());
             if !(*or).surface_tree.is_null() {
                 ffi::wlr_scene_node_destroy((*or).surface_tree as *mut ffi::wlr_scene_node);