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

commit95572c01f56c7be1c3787ace99a3bd451ddc9677
parentb5d45a50e6
authorLucas Galante <[email protected]>
date2026-09-12 13:08
fix(xwayland): grant a client's geometry on the logical grid

At scale 2 an odd X11 coordinate has no logical integer — 181 reads back as
91 and goes out again as 182 — and the window's geometry is logical. So
granting a request verbatim and storing the rounded logical meant the next
configure handed X a different number than the one just sent, and the client
saw a move it had not asked for.

Counted in an Xwayland shadow at --scale 2, driving a transient dialog with
xdotool and logging every ConfigureNotify the client received:

  asked x=181 w=401: 8 configures, x went 182 -> 181 -> 182
  asked x=301 w=501: 9 configures, width went 501 -> 502
  asked x=421 w=601: 7 configures, x went 422 -> 421 -> 422

Both axes and both sizes did it. `snap_x11` is `to_x11` of `from_x11`, the
nearest value the logical grid can express, and the two places that hand a
client-requested geometry back to X now send that instead of the raw
request. Same run after:

  asked x=181 w=401: 5 configures, all (182, ...)
  asked x=301 w=501: 5 configures, all (302, ...)
  asked x=421 w=601: 5 configures, all (422, ...)

The window no longer visits a position it is about to be moved off; every
geometry the client is told is one the compositor can reproduce.

This is a one-pixel snap that converges immediately, not an accumulating
drift — the correcting configure was always a single step. What it removes
is the spurious second configure per request, which is the kind of
unrequested geometry change a toolkit's size negotiation re-enters on.

At scale 1 snap_x11 is the identity, so nothing outside xwayland_hidpi
changes. The new unit test covers the odd/even split, idempotence (snapping
a snapped value is a no-op, which is what makes repeated configures
converge), and the scale-1 identity. 56 lib tests pass; the parented-move
runaway from b5d45a5 still settles on the first request, and a plain X11
toplevel, the X11 parent and a Wayland client still map with no panics.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/server/xwayland_window.rs | 64 +++++++++++++++++++++++++++++++++++++++----
 1 file changed, 58 insertions(+), 6 deletions(-)

diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 8aabcac..b158e94 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -259,6 +259,24 @@ pub fn from_x11(x11: i32, scale: f32) -> i32 {
     (x11 as f32 / scale).round() as i32
 }
 
+/// The nearest X11 value the LOGICAL grid can express — `to_x11` of
+/// `from_x11`.
+///
+/// At scale 2 an odd X11 coordinate has no logical integer: 181 reads back as
+/// 91 and goes out again as 182. The window's geometry is logical, so
+/// granting a client's request verbatim and storing the rounded logical means
+/// the next configure hands X a value a pixel off the one it asked for —
+/// which the client reads as an unrequested move and answers with another
+/// request, a pixel further along each time. Granting the snapped value makes
+/// the geometry sent and the geometry the compositor's own model reproduces
+/// the same number, so the round trip is stable however often it repeats.
+///
+/// At scale 1 this is the identity, so nothing outside `xwayland_hidpi`
+/// changes.
+pub fn snap_x11(x11: i32, scale: f32) -> i32 {
+    to_x11(from_x11(x11, scale), scale)
+}
+
 impl XwaylandWindow {
     pub unsafe fn create(
         xsurface: *mut ffi::wlr_xwayland_surface,
@@ -592,11 +610,15 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
     );
 
     if has_parent {
+        // Granted on the logical grid rather than verbatim — see `snap_x11`.
+        // The logical values below are what the window's geometry becomes, so
+        // handing X anything else is handing it a number this compositor
+        // cannot reproduce.
         (*xwindow).send_configure(X11Geom {
-            x: (*event).x,
-            y: (*event).y,
-            width: (*event).width,
-            height: (*event).height,
+            x: snap_x11((*event).x as i32, s) as i16,
+            y: snap_x11((*event).y as i32, s) as i16,
+            width: snap_x11((*event).width as i32, s) as u16,
+            height: snap_x11((*event).height as i32, s) as u16,
         });
         let log_x = from_x11((*event).x as i32, s);
         let log_y = from_x11((*event).y as i32, s);
@@ -654,10 +676,13 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
             }
             (to_x11(w as i32, s) as u16, to_x11(h as i32, s) as u16)
         } else {
-            ((*event).width, (*event).height)
+            (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
         }
     } else {
-        ((*event).width, (*event).height)
+        // Snapped for the same reason as the parented branch above: the size
+        // stored below is `from_x11` of what goes out here, and the next
+        // configure sends `to_x11` of that back.
+        (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
     };
 
     let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
@@ -856,6 +881,33 @@ mod tests {
         assert_eq!(to_x11(logical, resolve_x11_scale(Some(2.0), &last)), 3712);
     }
 
+    #[test]
+    fn snap_x11_is_what_the_logical_grid_can_express() {
+        // An odd X11 coordinate at scale 2 has no logical integer, so it
+        // moves by one; the point is that it then STAYS there. Granting the
+        // raw value instead is what let Houdini's dialog gain a pixel per
+        // request: 181 -> 91 -> 182 -> 91 -> 182 ...
+        assert_eq!(from_x11(181, 2.0), 91);
+        assert_eq!(to_x11(91, 2.0), 182);
+        assert_eq!(snap_x11(181, 2.0), 182);
+
+        // Idempotent: snapping a snapped value is a no-op, which is what
+        // makes repeated configures converge instead of drifting.
+        for x in [-91, -90, -1, 0, 1, 180, 181, 757, 1300, 3712] {
+            let once = snap_x11(x, 2.0);
+            assert_eq!(snap_x11(once, 2.0), once, "not idempotent at x={x}");
+        }
+
+        // Even values — and every value at scale 1 — are untouched, so
+        // nothing outside xwayland_hidpi changes.
+        for x in [-90, 0, 180, 720, 1360, 3712] {
+            assert_eq!(snap_x11(x, 2.0), x, "even value moved at x={x}");
+        }
+        for x in [-91, -1, 0, 1, 181, 757, 1301] {
+            assert_eq!(snap_x11(x, 1.0), x, "scale 1 moved at x={x}");
+        }
+    }
+
     #[test]
     fn configure_is_sent_until_it_has_actually_been_sent_once() {
         let g = |x, y, w, h| X11Geom { x, y, width: w, height: h };