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

commitb5d45a50e6968a65cc239eea6841dda3b0804100
parente58f32e352
authorLucas Galante <[email protected]>
date2026-09-12 13:01
fix(xwayland): a transient's self-move updates its virtual origin too

Clicking a tab in Houdini's Edit Theme dialog left every page unusable: the
right-hand column of controls was crushed to nothing, so each tab showed a
stack of labels with no sliders beside them and a clipped Examples box. The
dialog was pinned at exactly its minimum width — 720x1360 physical, the
`program specified minimum size` in its own WM_NORMAL_HINTS, while the same
hints asked for 1300x1360.

handle_request_configure's has_parent branch granted the request and wrote
the new screen origin into box_geom.x/y and rendering_requested.x/y, but
left virtual_x/virtual_y alone. A floating window's screen position is not
stored — the arrange pass recomputes it every transaction from the virtual
origin (policy place_normal_window's floating arm), so the move survived
until the next pass and was then undone. Size had no such problem: it lives
in box_geom, which the branch does update.

An X11 client reads that as its move being refused. Houdini's Qt recomputes
from where it was pushed and asks again, and because each of those requests
re-asserts the width it currently believes it has, every grant of a wider
window was undone by the client's own next move request. One tab click in
the live log is 15 rounds inside a second, the dialog walking from x=181 to
x=-90 while its width climbs 757 -> 1300 and lands on none of them.

Verified in two Xwayland-enabled shadows at --scale 2, unmodified HEAD
against this build, driving a GTK3 transient dialog with min-size hints.
A move then a forced arrange: before, x 24 -> 200 -> back to 24 with vx
never leaving 100.0; after, vx tracks to 276.0 and x stays at 200. Driven by
a loop that reacts to a taken-away geometry the way Qt does: before, 20
rounds with the position reverted every time and no convergence; after,
granted on the first request and held.

55 compositor and 169 policy tests pass. A plain X11 toplevel, the X11
parent and a Wayland client still map and behave in the patched shadow; no
panics, and the xcb error classes match the baseline, which logs more of
them (45 vs 16) because its runaway generates the extra traffic.

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

 src/server/window.rs          | 15 +++++++++++++++
 src/server/xwayland_window.rs | 11 +++++++++++
 2 files changed, 26 insertions(+)

diff --git a/src/server/window.rs b/src/server/window.rs
index 45984c2..f65305f 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -1274,6 +1274,21 @@ impl Window {
         )
     }
 
+    /// Layout (screen) position back to a virtual position — the inverse of
+    /// `virtual_to_screen`. A client that repositions itself hands us a
+    /// SCREEN origin, but the arrange pass places a floating window from its
+    /// VIRTUAL one, so a screen origin written on its own survives exactly
+    /// until the next transaction and is then recomputed away.
+    pub unsafe fn screen_to_virtual(&self, sx: i32, sy: i32) -> (f64, f64) {
+        let wm = &(*self.server).wm;
+        let zoom = wm.desk_zoom.max(0.01);
+        let (out_x, out_y, _, _) = self.first_enabled_output_box();
+        (
+            wm.desk_pan_x + (sx as f64 - out_x) / zoom,
+            wm.desk_pan_y + (sy as f64 - out_y) / zoom,
+        )
+    }
+
     /// Best-known window size in VIRTUAL units at map time. `box_geom` is the
     /// render pass's size and is only filled in once a frame has been drawn
     /// (or by `try_restore` from the saved geometry), so a first-ever launch
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 3016a7b..8aabcac 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -611,6 +611,17 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
         (*window).rendering_requested.y = log_y;
         (*window).rendering_sent.width = log_width;
         (*window).rendering_sent.height = log_height;
+        // The screen origin above is only half the move: the arrange pass
+        // places a floating window from its VIRTUAL origin, so leaving that
+        // stale meant the very next transaction recomputed the window back
+        // to where it was. An X11 client reads that as its move being
+        // refused and asks again from the position it was pushed to, which
+        // is a runaway: Houdini's Edit Theme dialog walked 270px left across
+        // one tab switch, re-requesting 15 times in a second and never
+        // converging on a size either.
+        let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
+        (*window).virtual_x = vx;
+        (*window).virtual_y = vy;
         (*window).set_dimensions(log_width, log_height);
         return;
     }