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

src/server/xwayland_window.rs (50.5K)

   1 // SPDX-FileCopyrightText: © 2020 The River Developers
   2 // SPDX-License-Identifier: GPL-3.0-only
   3 
   4 use crate::ffi;
   5 use crate::server::{Server, WlListener, wl_signal_add};
   6 use crate::window::{Window, WindowImpl, WindowState};
   7 use crate::xwayland_override_redirect::XwaylandOverrideRedirect;
   8 
   9 #[repr(C)]
  10 pub struct XwaylandWindow {
  11     pub window: *mut Window,
  12     pub xsurface: *mut ffi::wlr_xwayland_surface,
  13     pub surface_tree: *mut ffi::wlr_scene_tree,
  14 
  15     pub destroy: ffi::wl_listener,
  16     pub request_configure: ffi::wl_listener,
  17     pub set_override_redirect: ffi::wl_listener,
  18     pub associate: ffi::wl_listener,
  19     pub dissociate: ffi::wl_listener,
  20     pub set_size_hints: ffi::wl_listener,
  21     pub set_title: ffi::wl_listener,
  22     pub set_class: ffi::wl_listener,
  23     pub set_parent: ffi::wl_listener,
  24     pub set_decorations: ffi::wl_listener,
  25     pub request_maximize: ffi::wl_listener,
  26     pub request_fullscreen: ffi::wl_listener,
  27     pub request_minimize: ffi::wl_listener,
  28 
  29     pub map: ffi::wl_listener,
  30     pub unmap: ffi::wl_listener,
  31 
  32     /// The last geometry this compositor handed to X through
  33     /// `send_configure`, physical pixels; `None` until the first one. See
  34     /// `needs_configure` for why this is kept apart from the wlroots mirror.
  35     pub sent_geom: Option<X11Geom>,
  36 }
  37 
  38 /// A window geometry in X11 root coordinates — physical pixels under
  39 /// `xwayland_hidpi` (see `x11_scale`), the same units as the
  40 /// `wlr_xwayland_surface` fields it is compared against.
  41 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
  42 pub struct X11Geom {
  43     pub x: i16,
  44     pub y: i16,
  45     pub width: u16,
  46     pub height: u16,
  47 }
  48 
  49 /// Whether `wanted` has to be sent to X, given what wlroots reports the
  50 /// window's geometry to be (`reported`) and the last geometry this
  51 /// compositor sent (`sent`).
  52 ///
  53 /// Comparing against the wlroots mirror alone was the bug: the saved-state
  54 /// restore (`Window::try_restore`) pre-writes the mirror's width/height to
  55 /// the saved size so the first frame renders at it, which makes the mirror
  56 /// a statement of what the compositor wants X to have, not what X has. A
  57 /// window saved fullscreen restored at exactly the fullscreen size then
  58 /// looked already-configured, the configure was skipped, and the real X
  59 /// window stayed at its natural size. So a geometry is also considered
  60 /// unsent until this compositor has actually sent it once; after that the
  61 /// mirror is what catches X changing the geometry on its own (a
  62 /// ConfigureNotify updates it), which the sent record cannot see.
  63 pub fn needs_configure(wanted: X11Geom, reported: X11Geom, sent: Option<X11Geom>) -> bool {
  64     wanted != reported || sent != Some(wanted)
  65 }
  66 
  67 unsafe fn connect_listener(
  68     signal: *mut ffi::wl_signal,
  69     listener: *mut ffi::wl_listener,
  70     callback: unsafe extern "C" fn(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void),
  71 ) {
  72     let wl_lis = listener as *mut WlListener;
  73     (*wl_lis).notify = Some(callback);
  74     wl_signal_add(signal, listener);
  75 }
  76 
  77 unsafe fn wl_listener_remove_safe(listener: *mut ffi::wl_listener) {
  78     let prev = (*listener).link.prev;
  79     let next = (*listener).link.next;
  80     if !prev.is_null() && !next.is_null() && prev != listener as *mut ffi::wl_list && next != listener as *mut ffi::wl_list {
  81         ffi::wl_list_remove(&mut (*listener).link);
  82         (*listener).link.prev = std::ptr::null_mut();
  83         (*listener).link.next = std::ptr::null_mut();
  84     }
  85 }
  86 
  87 /// Wine draws its own frame in a margin around the window; the compositor
  88 /// hides it by oversizing and offsetting the X window. Logical pixels.
  89 pub const WINE_MARGIN: i32 = 16;
  90 
  91 /// The factor between X11 root coordinates and the logical layout.
  92 ///
  93 /// With `xwayland_hidpi` on (the default) the xdg-output global is hidden
  94 /// from Xwayland (`server.rs`), so it sizes its screen from the wl_output
  95 /// MODE — the physical pixel grid — and X11 is a physical-pixel world: a
  96 /// HiDPI-aware X11 app (Houdini, any Qt 6 app reading Xft.dpi) renders at
  97 /// full resolution and its surfaces are drawn at 1/scale
  98 /// (`Window::x11_buffer_scale`), sharp instead of upscaled from logical size.
  99 /// Every position and size crossing into or out of X11 converts through
 100 /// `to_x11` / `from_x11`; the window's own geometry stays logical.
 101 ///
 102 /// Off, X11 is the logical layout (Xwayland reads xdg-output) and the
 103 /// factor is 1. Multi-output with differing scales is not a case X11 can
 104 /// express — the first output's scale stands for the screen. A single
 105 /// window can opt out through `xwayland_hidpi_except` — see `x11_scale_for`,
 106 /// which every per-window caller goes through; this screen-wide value is
 107 /// only for what has no window, like the Xft.dpi pushed at Xwayland-ready.
 108 ///
 109 /// The factor must survive the output going away. A lid-close suspend
 110 /// destroys the DRM output and re-creates it on resume, and X11 windows
 111 /// live on through it: any geometry read back from X while no output
 112 /// exists (`Window::render_finish`, `XwaylandWindow::configure`) is still
 113 /// in physical pixels, and dividing it by 1 instead of the panel's scale
 114 /// records a window twice its logical size — which the first configure
 115 /// after resume then multiplies by the real scale again, handing X a
 116 /// window four times too big. So the last scale an output reported is
 117 /// remembered and stands in while there is none.
 118 pub unsafe fn x11_scale(server: *mut crate::server::Server) -> f32 {
 119     if server.is_null() || !(*server).wm.xwayland_hidpi {
 120         return 1.0;
 121     }
 122     let mut current = None;
 123     let link = (*server).om.outputs.next;
 124     if link != &mut (*server).om.outputs as *mut ffi::wl_list {
 125         let output = crate::container_of!(link, crate::output::Output, link);
 126         let scale = (*output).current.scale;
 127         if scale > 0.0 {
 128             current = Some(scale);
 129         }
 130     }
 131     resolve_x11_scale(current, &LAST_X11_SCALE)
 132 }
 133 
 134 /// The scale of the last output `x11_scale` saw, as `f32` bits; 0 until an
 135 /// output has reported one.
 136 static LAST_X11_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
 137 
 138 /// `x11_scale` without the FFI: the live output's scale when there is one
 139 /// (remembering it in `last`), else the remembered one, else 1.
 140 pub fn resolve_x11_scale(current: Option<f32>, last: &std::sync::atomic::AtomicU32) -> f32 {
 141     use std::sync::atomic::Ordering;
 142     if let Some(scale) = current {
 143         last.store(scale.to_bits(), Ordering::Relaxed);
 144         return scale;
 145     }
 146     let remembered = f32::from_bits(last.load(Ordering::Relaxed));
 147     if remembered > 0.0 { remembered } else { 1.0 }
 148 }
 149 
 150 /// X11 clients answer an output coming or going — Xwayland re-creates its
 151 /// screen and RandR tells them — by re-asserting a geometry of their own:
 152 /// Houdini (Qt) asks for the whole panel after a resume from suspend, and a
 153 /// floating window's unsolicited size request is otherwise honoured
 154 /// verbatim (`handle_request_configure`). For a moment after any output
 155 /// change those requests are answered with the window's own geometry
 156 /// instead, the way a tiled window's always are, so the size the user set
 157 /// survives the screen change.
 158 static OUTPUT_CHANGE_GRACE_UNTIL: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
 159 
 160 /// How long after an output is created or destroyed to hold floating X11
 161 /// windows at their own size. Xwayland's RandR update and the client's
 162 /// reaction land within the same second in practice; this leaves room for
 163 /// a slow client.
 164 const OUTPUT_CHANGE_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
 165 
 166 /// Called when an output is created or destroyed: (re)starts the grace
 167 /// window during which floating X11 windows keep their size.
 168 pub fn note_output_change() {
 169     if let Ok(mut deadline) = OUTPUT_CHANGE_GRACE_UNTIL.lock() {
 170         *deadline = Some(std::time::Instant::now() + OUTPUT_CHANGE_GRACE);
 171     }
 172 }
 173 
 174 /// True while inside the grace window begun by `note_output_change`.
 175 pub fn in_output_change_grace() -> bool {
 176     OUTPUT_CHANGE_GRACE_UNTIL
 177         .lock()
 178         .ok()
 179         .and_then(|deadline| *deadline)
 180         .is_some_and(|deadline| std::time::Instant::now() < deadline)
 181 }
 182 
 183 /// `x11_scale` for one X11 surface: 1 when the window is named in
 184 /// `window_manager { xwayland_hidpi_except }`, else the screen's factor.
 185 ///
 186 /// The screen Xwayland shows is one thing for every client, so an exempt
 187 /// window still SEES a physical-pixel root; what changes is what the
 188 /// compositor does with it. Its configures go out in logical pixels and its
 189 /// buffer is drawn at 1 (`Window::x11_buffer_scale`), the pre-`xwayland_hidpi`
 190 /// arrangement — so a borderless-fullscreen game that asks for the whole
 191 /// root is answered with the logical size and renders that many pixels,
 192 /// not scale² as many. Matched by WM_CLASS class, WM_CLASS instance or
 193 /// title (`hidpi_exempt`), re-evaluated on every use so a title that
 194 /// arrives after the first configure still takes effect.
 195 pub unsafe fn x11_scale_for(
 196     server: *mut crate::server::Server,
 197     xsurface: *const ffi::wlr_xwayland_surface,
 198 ) -> f32 {
 199     if server.is_null() || !(*server).wm.xwayland_hidpi {
 200         return 1.0;
 201     }
 202     if !xsurface.is_null() && !(*server).wm.xwayland_hidpi_except.is_empty() {
 203         let text = |p: *const libc::c_char| -> String {
 204             if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
 205         };
 206         let class = text((*xsurface).class);
 207         let instance = text((*xsurface).instance);
 208         let title = text((*xsurface).title);
 209         if hidpi_exempt(&(*server).wm.xwayland_hidpi_except, &class, &instance, &title) {
 210             return 1.0;
 211         }
 212     }
 213     x11_scale(server)
 214 }
 215 
 216 /// The factor an X11 client's CURSOR is drawn at: the screen's X11 factor
 217 /// for any X11 surface, 1 for anything that is not X11.
 218 ///
 219 /// What a cursor request needs. Deliberately NOT `x11_scale_for`: the
 220 /// `xwayland_hidpi_except` exemption is about a game's window pixels — it
 221 /// sizes itself to the root ignoring DPI, so its buffer is drawn at 1 —
 222 /// but its cursor comes from the toolkit or Wine underneath, which follow
 223 /// the DPI this compositor publishes (Xft.dpi / Xcursor.size at 96×scale
 224 /// and 24×scale). Wine at LogPixels 192 hands Trackmania a 64px arrow;
 225 /// drawn in the logical world with the window it was twice the desktop's
 226 /// cursor. Every X11 cursor is a physical-pixel bitmap, exempt window or
 227 /// not.
 228 pub unsafe fn x11_scale_for_surface(
 229     server: *mut crate::server::Server,
 230     surface: *mut ffi::wlr_surface,
 231 ) -> f32 {
 232     if surface.is_null() {
 233         return 1.0;
 234     }
 235     let root = ffi::wlr_surface_get_root_surface(surface);
 236     if root.is_null() {
 237         return 1.0;
 238     }
 239     let xsurface = ffi::wlr_xwayland_surface_try_from_wlr_surface(root);
 240     if xsurface.is_null() {
 241         return 1.0;
 242     }
 243     x11_scale(server)
 244 }
 245 
 246 /// Whether any of `patterns` names this window: each is tried against the
 247 /// WM_CLASS class, the WM_CLASS instance and the title with the
 248 /// `app_id_matches` rules (case-insensitive, `*` wildcards). Empty fields
 249 /// never match.
 250 /// Whether `window` is an X11 window named in `xwayland_hidpi_except` — a
 251 /// full-screen X11 game, by the key's definition. Such a window sizes and
 252 /// places itself to the screen, and the compositor stays out of its way:
 253 /// no saved-state restore (`Window::try_restore`); its own requests are
 254 /// granted, clamped to the output's logical box since it sees a
 255 /// physical-pixel root (`handle_request_configure`); a compositor
 256 /// fullscreen overrides its size and survives Wine's withdrawal of the
 257 /// state (`handle_request_fullscreen`). Its cursor is NOT exempt — see
 258 /// `x11_scale_for_surface`.
 259 pub unsafe fn window_is_hidpi_exempt(window: *const crate::window::Window) -> bool {
 260     if window.is_null() {
 261         return false;
 262     }
 263     let crate::window::WindowImpl::Xwayland(xwindow) = (*window).impl_type else {
 264         return false;
 265     };
 266     if xwindow.is_null() || (*xwindow).xsurface.is_null() {
 267         return false;
 268     }
 269     let server = (*window).server;
 270     if server.is_null() || !(*server).wm.xwayland_hidpi || (*server).wm.xwayland_hidpi_except.is_empty() {
 271         return false;
 272     }
 273     let xsurface = (*xwindow).xsurface;
 274     let text = |p: *const libc::c_char| -> String {
 275         if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
 276     };
 277     hidpi_exempt(
 278         &(*server).wm.xwayland_hidpi_except,
 279         &text((*xsurface).class),
 280         &text((*xsurface).instance),
 281         &text((*xsurface).title),
 282     )
 283 }
 284 
 285 pub fn hidpi_exempt(patterns: &[String], class: &str, instance: &str, title: &str) -> bool {
 286     use crate::window_manager::app_id_matches;
 287     patterns.iter().any(|p| {
 288         [class, instance, title]
 289             .iter()
 290             .any(|field| !field.is_empty() && app_id_matches(p, field))
 291     })
 292 }
 293 
 294 pub fn to_x11(logical: i32, scale: f32) -> i32 {
 295     (logical as f32 * scale).round() as i32
 296 }
 297 
 298 pub fn from_x11(x11: i32, scale: f32) -> i32 {
 299     (x11 as f32 / scale).round() as i32
 300 }
 301 
 302 /// The nearest X11 value the LOGICAL grid can express — `to_x11` of
 303 /// `from_x11`.
 304 ///
 305 /// At scale 2 an odd X11 coordinate has no logical integer: 181 reads back as
 306 /// 91 and goes out again as 182. The window's geometry is logical, so
 307 /// granting a client's request verbatim and storing the rounded logical means
 308 /// the next configure hands X a value a pixel off the one it asked for —
 309 /// which the client reads as an unrequested move and answers with another
 310 /// request, a pixel further along each time. Granting the snapped value makes
 311 /// the geometry sent and the geometry the compositor's own model reproduces
 312 /// the same number, so the round trip is stable however often it repeats.
 313 ///
 314 /// At scale 1 this is the identity, so nothing outside `xwayland_hidpi`
 315 /// changes.
 316 pub fn snap_x11(x11: i32, scale: f32) -> i32 {
 317     to_x11(from_x11(x11, scale), scale)
 318 }
 319 
 320 impl XwaylandWindow {
 321     pub unsafe fn create(
 322         xsurface: *mut ffi::wlr_xwayland_surface,
 323         server: *mut Server,
 324     ) -> Result<(), &'static str> {
 325         let title_ptr = (*xsurface).title;
 326         let class_ptr = (*xsurface).class;
 327         log::debug!(
 328             "new xwayland window: title='{:?}', class='{:?}'",
 329             if title_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(title_ptr).to_str().unwrap_or("") },
 330             if class_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(class_ptr).to_str().unwrap_or("") }
 331         );
 332 
 333         let window = Window::create(WindowImpl::Xwayland(std::ptr::null_mut()), server)?;
 334 
 335         let xwindow = Box::new(XwaylandWindow {
 336             window,
 337             xsurface,
 338             surface_tree: std::ptr::null_mut(),
 339             destroy: std::mem::zeroed(),
 340             request_configure: std::mem::zeroed(),
 341             set_override_redirect: std::mem::zeroed(),
 342             associate: std::mem::zeroed(),
 343             dissociate: std::mem::zeroed(),
 344             set_size_hints: std::mem::zeroed(),
 345             set_title: std::mem::zeroed(),
 346             set_class: std::mem::zeroed(),
 347             set_parent: std::mem::zeroed(),
 348             set_decorations: std::mem::zeroed(),
 349             request_maximize: std::mem::zeroed(),
 350             request_fullscreen: std::mem::zeroed(),
 351             request_minimize: std::mem::zeroed(),
 352             map: std::mem::zeroed(),
 353             unmap: std::mem::zeroed(),
 354             sent_geom: None,
 355         });
 356 
 357         let raw = Box::into_raw(xwindow);
 358         (*window).set_impl(WindowImpl::Xwayland(raw));
 359 
 360         (*xsurface).data = raw as *mut std::ffi::c_void;
 361 
 362         connect_listener(&mut (*xsurface).events.destroy, &mut (*raw).destroy, handle_destroy);
 363         connect_listener(&mut (*xsurface).events.associate, &mut (*raw).associate, handle_associate);
 364         connect_listener(&mut (*xsurface).events.dissociate, &mut (*raw).dissociate, handle_dissociate);
 365         connect_listener(&mut (*xsurface).events.request_configure, &mut (*raw).request_configure, handle_request_configure);
 366         connect_listener(&mut (*xsurface).events.set_override_redirect, &mut (*raw).set_override_redirect, handle_set_override_redirect);
 367         // connect_listener(&mut (*xsurface).events.set_size_hints, &mut (*raw).set_size_hints, handle_set_size_hints);
 368         connect_listener(&mut (*xsurface).events.set_title, &mut (*raw).set_title, handle_set_title);
 369         connect_listener(&mut (*xsurface).events.set_class, &mut (*raw).set_class, handle_set_class);
 370         connect_listener(&mut (*xsurface).events.set_parent, &mut (*raw).set_parent, handle_set_parent);
 371         connect_listener(&mut (*xsurface).events.set_decorations, &mut (*raw).set_decorations, handle_set_decorations);
 372         connect_listener(&mut (*xsurface).events.request_maximize, &mut (*raw).request_maximize, handle_request_maximize);
 373         connect_listener(&mut (*xsurface).events.request_fullscreen, &mut (*raw).request_fullscreen, handle_request_fullscreen);
 374         connect_listener(&mut (*xsurface).events.request_minimize, &mut (*raw).request_minimize, handle_request_minimize);
 375 
 376         if !(*xsurface).surface.is_null() {
 377             handle_associate_impl(raw);
 378             if ffi::river_wlr_surface_is_mapped((*xsurface).surface) {
 379                 handle_map_impl(raw);
 380             }
 381         }
 382 
 383         Ok(())
 384     }
 385 
 386     pub unsafe fn configure(&mut self) -> bool {
 387         let window = self.window;
 388         let scheduled = &mut (*window).configure_scheduled;
 389         let sent = &mut (*window).configure_sent;
 390         let s = x11_scale_for((*window).server, self.xsurface);
 391 
 392         if scheduled.width == Some(0) {
 393             scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
 394         }
 395         if scheduled.height == Some(0) {
 396             scheduled.height = Some(from_x11((*self.xsurface).height as i32, s) as u32);
 397         }
 398 
 399         let mut phys_width = if let Some(w) = scheduled.width {
 400             to_x11(w as i32, s) as u16
 401         } else {
 402             (*self.xsurface).width
 403         };
 404 
 405         let mut phys_height = if let Some(h) = scheduled.height {
 406             to_x11(h as i32, s) as u16
 407         } else {
 408             (*self.xsurface).height
 409         };
 410 
 411         // X11 root coordinates: see `x11_scale`. Everything sent to X goes
 412         // through `to_x11`, everything read back through `from_x11`; the
 413         // window's own geometry stays logical.
 414         let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
 415         let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
 416 
 417         let has_parent = !(*self.xsurface).parent.is_null();
 418 
 419         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
 420             if scheduled.width.is_some() {
 421                 phys_width += to_x11(WINE_MARGIN * 2, s) as u16;
 422             }
 423             if scheduled.height.is_some() {
 424                 phys_height += to_x11(WINE_MARGIN * 2, s) as u16;
 425             }
 426             phys_x -= to_x11(WINE_MARGIN, s) as i16;
 427             phys_y -= to_x11(WINE_MARGIN, s) as i16;
 428         }
 429 
 430         let wanted = X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height };
 431         if needs_configure(wanted, self.reported_geom(), self.sent_geom) {
 432             self.send_configure(wanted);
 433         }
 434 
 435         if scheduled.activated != sent.activated {
 436             self.set_activated(scheduled.activated);
 437         }
 438         if scheduled.maximized != sent.maximized {
 439             ffi::wlr_xwayland_surface_set_maximized(self.xsurface, scheduled.maximized, scheduled.maximized);
 440         }
 441         if scheduled.inform_fullscreen != sent.inform_fullscreen {
 442             ffi::wlr_xwayland_surface_set_fullscreen(self.xsurface, scheduled.inform_fullscreen);
 443         }
 444 
 445         let mut width = scheduled.width.unwrap_or(from_x11((*self.xsurface).width as i32, s) as u32);
 446         let mut height = scheduled.height.unwrap_or(from_x11((*self.xsurface).height as i32, s) as u32);
 447 
 448         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
 449             if scheduled.width.is_none() {
 450                 width = width.saturating_sub((WINE_MARGIN * 2) as u32);
 451             }
 452             if scheduled.height.is_none() {
 453                 height = height.saturating_sub((WINE_MARGIN * 2) as u32);
 454             }
 455         }
 456 
 457         (*window).configure_sent = (*window).configure_scheduled.clone();
 458         (*window).configure_sent.width = Some(width);
 459         (*window).configure_sent.height = Some(height);
 460         (*window).configure_scheduled.width = None;
 461         (*window).configure_scheduled.height = None;
 462 
 463         false
 464     }
 465 
 466     /// The geometry wlroots currently reports for the X window.
 467     pub unsafe fn reported_geom(&self) -> X11Geom {
 468         X11Geom {
 469             x: (*self.xsurface).x,
 470             y: (*self.xsurface).y,
 471             width: (*self.xsurface).width,
 472             height: (*self.xsurface).height,
 473         }
 474     }
 475 
 476     /// The one path to `wlr_xwayland_surface_configure`: every geometry
 477     /// handed to X is recorded in `sent_geom` so `needs_configure` can tell
 478     /// a geometry X has from one the compositor merely mirrored.
 479     pub unsafe fn send_configure(&mut self, g: X11Geom) {
 480         ffi::wlr_xwayland_surface_configure(self.xsurface, g.x, g.y, g.width, g.height);
 481         self.sent_geom = Some(g);
 482     }
 483 
 484     pub unsafe fn set_activated(&self, activated: bool) {
 485         if activated && (*self.xsurface).minimized {
 486             ffi::wlr_xwayland_surface_set_minimized(self.xsurface, false);
 487         }
 488         ffi::wlr_xwayland_surface_activate(self.xsurface, activated);
 489     }
 490 }
 491 
 492 unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 493     let xwindow = crate::container_of!(listener, XwaylandWindow, destroy);
 494     handle_destroy_impl(xwindow);
 495 }
 496 
 497 unsafe fn handle_destroy_impl(xwindow: *mut XwaylandWindow) {
 498     wl_listener_remove_safe(&mut (*xwindow).destroy);
 499     wl_listener_remove_safe(&mut (*xwindow).associate);
 500     wl_listener_remove_safe(&mut (*xwindow).dissociate);
 501     wl_listener_remove_safe(&mut (*xwindow).request_configure);
 502     wl_listener_remove_safe(&mut (*xwindow).set_override_redirect);
 503     wl_listener_remove_safe(&mut (*xwindow).set_size_hints);
 504     wl_listener_remove_safe(&mut (*xwindow).set_title);
 505     wl_listener_remove_safe(&mut (*xwindow).set_class);
 506     wl_listener_remove_safe(&mut (*xwindow).set_parent);
 507     wl_listener_remove_safe(&mut (*xwindow).set_decorations);
 508     wl_listener_remove_safe(&mut (*xwindow).request_maximize);
 509     wl_listener_remove_safe(&mut (*xwindow).request_fullscreen);
 510     wl_listener_remove_safe(&mut (*xwindow).request_minimize);
 511 
 512     (*(*xwindow).xsurface).data = std::ptr::null_mut();
 513 
 514     let window = (*xwindow).window;
 515     (*window).impl_destroying();
 516 
 517     let _ = Box::from_raw(xwindow);
 518 }
 519 
 520 unsafe extern "C" fn handle_associate(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 521     let xwindow = crate::container_of!(listener, XwaylandWindow, associate);
 522     handle_associate_impl(xwindow);
 523 }
 524 
 525 unsafe fn handle_associate_impl(xwindow: *mut XwaylandWindow) {
 526     let surface = (*(*xwindow).xsurface).surface;
 527     if !surface.is_null() {
 528         connect_listener(
 529             ffi::river_wlr_surface_get_map_signal(surface),
 530             &mut (*xwindow).map,
 531             handle_map,
 532         );
 533         connect_listener(
 534             ffi::river_wlr_surface_get_unmap_signal(surface),
 535             &mut (*xwindow).unmap,
 536             handle_unmap,
 537         );
 538     }
 539 }
 540 
 541 unsafe extern "C" fn handle_dissociate(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 542     let xwindow = crate::container_of!(listener, XwaylandWindow, dissociate);
 543     handle_dissociate_impl(xwindow);
 544 }
 545 
 546 unsafe fn handle_dissociate_impl(xwindow: *mut XwaylandWindow) {
 547     wl_listener_remove_safe(&mut (*xwindow).map);
 548     wl_listener_remove_safe(&mut (*xwindow).unmap);
 549 }
 550 
 551 unsafe extern "C" fn handle_map(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 552     let xwindow = crate::container_of!(listener, XwaylandWindow, map);
 553     handle_map_impl(xwindow);
 554 }
 555 
 556 unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
 557     let surfaces_tree = (*(*xwindow).window).surfaces.tree;
 558     let surface = (*(*xwindow).xsurface).surface;
 559     let surface_tree = ffi::wlr_scene_subsurface_tree_create(surfaces_tree, surface);
 560     if surface_tree.is_null() {
 561         log::error!("out of memory creating subsurface tree");
 562         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 563         let client = ffi::wl_resource_get_client(surface_resource);
 564         ffi::wl_client_post_no_memory(client);
 565         return;
 566     }
 567     (*xwindow).surface_tree = surface_tree;
 568 
 569     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
 570 
 571     if (*(*xwindow).window).is_wine() && !has_parent && !(*(*xwindow).window).is_fullscreen() {
 572         ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -WINE_MARGIN, -WINE_MARGIN);
 573     }
 574 
 575     ffi::river_wlr_surface_set_data(surface, &mut (*(*xwindow).window).node as *mut crate::wm_node::WmNode as *mut _);
 576 
 577     let capture_tree = &mut (*(*(*xwindow).window).capture_scene).tree as *mut ffi::wlr_scene_tree;
 578     let capture_surface = ffi::wlr_scene_surface_create(capture_tree, surface);
 579     if capture_surface.is_null() {
 580         log::error!("out of memory creating capture surface");
 581         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 582         let client = ffi::wl_resource_get_client(surface_resource);
 583         ffi::wl_client_post_no_memory(client);
 584         return;
 585     }
 586 
 587     if (*(*xwindow).xsurface).fullscreen {
 588         (*(*xwindow).window).wm_scheduled.fullscreen_requested = crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut());
 589     }
 590 
 591     place_transient_where_it_asked(xwindow);
 592     place_shy_where_it_is(xwindow);
 593 
 594     (*(*xwindow).window).state = WindowState::Initialized;
 595     if let Err(e) = (*(*xwindow).window).map() {
 596         log::error!("out of memory mapping window: {}", e);
 597         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 598         let client = ffi::wl_resource_get_client(surface_resource);
 599         ffi::wl_client_post_no_memory(client);
 600     }
 601     (*(*(*xwindow).window).server).wm.dirty_windowing();
 602 }
 603 
 604 /// A transient that asked for a position before mapping maps there.
 605 ///
 606 /// `handle_request_configure` grants a request that arrives before the
 607 /// window is mapped verbatim, but records nothing: the window has no
 608 /// geometry yet and the arrange pass has not placed it. The grant updates
 609 /// the X surface's x/y, and nothing read them back at map, so a dialog that
 610 /// positioned itself before showing -- Qt's `move()` before `show()`, which
 611 /// is how Houdini's HC Panel centres itself on the pane it was opened over
 612 /// -- mapped at the constructor's default origin instead, a hundred pixels
 613 /// in from the desk corner. The client never asks again, since X told it
 614 /// the request was granted, so the dialog sat there for good.
 615 ///
 616 /// Only a window with a parent, and only when the client says the position
 617 /// is its own: ICCCM's `USPosition` / `PPosition` flags in WM_NORMAL_HINTS
 618 /// are what toolkits set for an explicit move before map. A transient
 619 /// without them is at whatever the X server defaulted to, and stays on the
 620 /// compositor's placement. Top-level windows keep theirs too: restore and
 621 /// the placement hints own those, and a transient is the one kind of window
 622 /// `try_restore` refuses to touch.
 623 unsafe fn place_transient_where_it_asked(xwindow: *mut XwaylandWindow) {
 624     let xsurface = (*xwindow).xsurface;
 625     if (*xsurface).parent.is_null() {
 626         return;
 627     }
 628     let Some(asked) = (*xwindow).sent_geom else {
 629         return;
 630     };
 631     let hints = (*xsurface).size_hints;
 632     if hints.is_null() {
 633         return;
 634     }
 635     let position_flags = ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_US_POSITION
 636         | ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_P_POSITION;
 637     if (*hints).flags & position_flags == 0 {
 638         return;
 639     }
 640 
 641     let window = (*xwindow).window;
 642     let s = x11_scale_for((*window).server, xsurface);
 643     let log_x = from_x11(asked.x as i32, s);
 644     let log_y = from_x11(asked.y as i32, s);
 645     let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
 646     (*window).virtual_x = vx;
 647     (*window).virtual_y = vy;
 648     // Placed by the client, like a picker placed by its hint: the camera
 649     // must not pan to it on spawn or first focus.
 650     (*window).hint_placed = true;
 651     log::info!(
 652         "XWayland transient mapped where it asked: title='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
 653         (*window).get_title_string().unwrap_or_default(),
 654         asked.x, asked.y, log_x, log_y, vx, vy,
 655     );
 656 }
 657 
 658 /// A shy helper window (`Window::is_shy`) maps where its app put it: the
 659 /// X window's own geometry, which Wine set from the app's CreateWindow
 660 /// position — for Ubisoft Connect's shadow window, exactly its main
 661 /// window's rect. The compositor's spawn placement would put it at the
 662 /// default origin, in front of everything, as a blank white window.
 663 unsafe fn place_shy_where_it_is(xwindow: *mut XwaylandWindow) {
 664     let window = (*xwindow).window;
 665     if !(*window).is_shy() {
 666         return;
 667     }
 668     let xsurface = (*xwindow).xsurface;
 669     let s = x11_scale_for((*window).server, xsurface);
 670     let log_x = from_x11((*xsurface).x as i32, s);
 671     let log_y = from_x11((*xsurface).y as i32, s);
 672     let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
 673     (*window).virtual_x = vx;
 674     (*window).virtual_y = vy;
 675     (*window).box_geom.x = log_x;
 676     (*window).box_geom.y = log_y;
 677     (*window).rendering_requested.x = log_x;
 678     (*window).rendering_requested.y = log_y;
 679     // Placed by the client: no spawn pan to it, ever.
 680     (*window).hint_placed = true;
 681     log::info!(
 682         "XWayland no-activate helper window mapped where it is: title='{}' class='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
 683         (*window).get_title_string().unwrap_or_default(),
 684         (*window).get_app_id_string().unwrap_or_default(),
 685         (*xsurface).x, (*xsurface).y, log_x, log_y, vx, vy,
 686     );
 687 }
 688 
 689 unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 690     let xwindow = crate::container_of!(listener, XwaylandWindow, unmap);
 691     handle_unmap_impl(xwindow);
 692 }
 693 
 694 unsafe fn handle_unmap_impl(xwindow: *mut XwaylandWindow) {
 695     let surface = (*(*xwindow).xsurface).surface;
 696     if !surface.is_null() {
 697         ffi::river_wlr_surface_set_data(surface, std::ptr::null_mut());
 698     }
 699     (*(*xwindow).window).unmap();
 700     if !(*xwindow).surface_tree.is_null() {
 701         ffi::wlr_scene_node_destroy((*xwindow).surface_tree as *mut ffi::wlr_scene_node);
 702         (*xwindow).surface_tree = std::ptr::null_mut();
 703     }
 704 }
 705 
 706 unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
 707     let xwindow = crate::container_of!(listener, XwaylandWindow, request_configure);
 708     let event = data as *mut ffi::wlr_xwayland_surface_configure_event;
 709 
 710     let surface = (*(*xwindow).xsurface).surface;
 711     if surface.is_null() || !ffi::river_wlr_surface_is_mapped(surface) {
 712         (*xwindow).send_configure(X11Geom {
 713             x: (*event).x,
 714             y: (*event).y,
 715             width: (*event).width,
 716             height: (*event).height,
 717         });
 718         return;
 719     }
 720 
 721     let class_ptr = (*(*xwindow).xsurface).class;
 722     let class = if class_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(class_ptr).to_str().unwrap_or("") };
 723     let title_ptr = (*(*xwindow).xsurface).title;
 724     let title = if title_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(title_ptr).to_str().unwrap_or("") };
 725     let window = (*xwindow).window;
 726     let is_wine = (*window).is_wine();
 727 
 728     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
 729     let s = x11_scale_for((*window).server, (*xwindow).xsurface);
 730     log::info!(
 731         "XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
 732         title,
 733         class,
 734         has_parent,
 735         is_wine,
 736         (*event).x, (*event).y, (*event).width, (*event).height,
 737         (*(*xwindow).xsurface).x, (*(*xwindow).xsurface).y, (*(*xwindow).xsurface).width, (*(*xwindow).xsurface).height,
 738     );
 739 
 740     let is_tiled = unsafe {
 741         (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Utility)
 742     };
 743     let is_fullscreen = unsafe { (*window).is_fullscreen() };
 744 
 745     // A window named in `xwayland_hidpi_except` is a full-screen X11 game
 746     // that sizes AND places itself to the screen (Trackmania's
 747     // "windowedfull" asks for (0, 0) at the desktop size). Refusing the
 748     // position — answering every request with the compositor's placement —
 749     // had Wine re-asking ~170 times a second for as long as the window was
 750     // up. It gets the parented treatment: position and size granted, the
 751     // virtual origin moved with it. Not while the compositor has it
 752     // fullscreen or tiled: then the size is the compositor's (below).
 753     let exempt_self_placed = !has_parent && !is_fullscreen && !is_tiled && window_is_hidpi_exempt(window);
 754     // A shy helper window (`Window::is_shy`) is placed by its app, which
 755     // moves it to track its main window: position and size granted.
 756     let shy_self_placed = !has_parent && !is_fullscreen && !is_tiled && (*window).is_shy();
 757 
 758     if has_parent || exempt_self_placed || shy_self_placed {
 759         // Granted on the logical grid rather than verbatim — see `snap_x11`.
 760         // The logical values below are what the window's geometry becomes, so
 761         // handing X anything else is handing it a number this compositor
 762         // cannot reproduce.
 763         let (mut ex, mut ey, mut ew, mut eh) =
 764             ((*event).x as i32, (*event).y as i32, (*event).width as i32, (*event).height as i32);
 765         if exempt_self_placed {
 766             // The game SEES the physical-pixel root and asks for all of it
 767             // (Trackmania's windowedfull: 3840x2160 at (0, 0)), but its
 768             // pixels are logical here — granted verbatim that is a window
 769             // twice the screen, which the arrange pass then keeps pulling
 770             // back on-desk while the game keeps asking, ~60 requests a
 771             // second. So the request is answered with at most the output's
 772             // logical box, kept on that output: the whole root becomes the
 773             // whole screen, which is what the game meant.
 774             let out = (*window).fullscreen_output();
 775             if !out.is_null() {
 776                 let ob = (*out).sent.box_layout();
 777                 let (ox, oy, ow, oh) = (ob.x, ob.y, ob.width, ob.height);
 778                 ew = from_x11(ew, s).min(ow).max(1);
 779                 eh = from_x11(eh, s).min(oh).max(1);
 780                 ex = from_x11(ex, s).clamp(ox, (ox + ow - ew).max(ox));
 781                 ey = from_x11(ey, s).clamp(oy, (oy + oh - eh).max(oy));
 782                 ex = to_x11(ex, s);
 783                 ey = to_x11(ey, s);
 784                 ew = to_x11(ew, s);
 785                 eh = to_x11(eh, s);
 786                 if (ex, ey, ew, eh) != ((*event).x as i32, (*event).y as i32, (*event).width as i32, (*event).height as i32) {
 787                     log::info!(
 788                         "XWayland configure request: '{}' is hidpi-exempt; ({}, {}, {}x{}) clamped to the output's logical box as ({}, {}, {}x{})",
 789                         title, (*event).x, (*event).y, (*event).width, (*event).height, ex, ey, ew, eh,
 790                     );
 791                 }
 792             }
 793         }
 794         (*xwindow).send_configure(X11Geom {
 795             x: snap_x11(ex, s) as i16,
 796             y: snap_x11(ey, s) as i16,
 797             width: snap_x11(ew, s) as u16,
 798             height: snap_x11(eh, s) as u16,
 799         });
 800         let log_x = from_x11(ex, s);
 801         let log_y = from_x11(ey, s);
 802         let log_width = from_x11(ew, s) as u32;
 803         let log_height = from_x11(eh, s) as u32;
 804         
 805         (*window).box_geom.x = log_x;
 806         (*window).box_geom.y = log_y;
 807         (*window).box_geom.width = log_width as i32;
 808         (*window).box_geom.height = log_height as i32;
 809         (*window).rendering_requested.x = log_x;
 810         (*window).rendering_requested.y = log_y;
 811         (*window).rendering_sent.width = log_width;
 812         (*window).rendering_sent.height = log_height;
 813         // The screen origin above is only half the move: the arrange pass
 814         // places a floating window from its VIRTUAL origin, so leaving that
 815         // stale meant the very next transaction recomputed the window back
 816         // to where it was. An X11 client reads that as its move being
 817         // refused and asks again from the position it was pushed to, which
 818         // is a runaway: Houdini's Edit Theme dialog walked 270px left across
 819         // one tab switch, re-requesting 15 times in a second and never
 820         // converging on a size either.
 821         let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
 822         (*window).virtual_x = vx;
 823         (*window).virtual_y = vy;
 824         if exempt_self_placed || shy_self_placed {
 825             // Placed by the client: the camera must not pan to it on spawn.
 826             (*window).hint_placed = true;
 827         }
 828         (*window).set_dimensions(log_width, log_height);
 829         return;
 830     }
 831 
 832     // A floating window normally gets the size it asks for; not while an
 833     // output is coming or going (see `note_output_change`), and not while
 834     // the compositor has it FULLSCREEN: Wine syncs a window's
 835     // _NET_WM_STATE from its own idea of the window rect, so a game whose
 836     // fixed-size hints were granted here shrank the X window back the
 837     // instant the fullscreen configure went out, then withdrew the
 838     // fullscreen state Wine no longer saw as true — every Fullscreen press
 839     // on Trackmania undid itself within the same frame. Held at the
 840     // fullscreen size, Wine sees a screen-sized rect and keeps the state.
 841     let hold_size = is_tiled || is_fullscreen || in_output_change_grace();
 842     if hold_size && !is_tiled {
 843         log::info!(
 844             "XWayland configure request: holding floating '{}' at its own size during output-change grace",
 845             title,
 846         );
 847     }
 848 
 849     let (phys_width, phys_height) = if hold_size {
 850         let log_w = (*window).configure_sent.width.unwrap_or((*window).box_geom.width as u32);
 851         let log_h = (*window).configure_sent.height.unwrap_or((*window).box_geom.height as u32);
 852         if log_w > 0 && log_h > 0 {
 853             let mut w = log_w;
 854             let mut h = log_h;
 855             if is_wine && !has_parent && !is_fullscreen {
 856                 w += (WINE_MARGIN * 2) as u32;
 857                 h += (WINE_MARGIN * 2) as u32;
 858             }
 859             (to_x11(w as i32, s) as u16, to_x11(h as i32, s) as u16)
 860         } else {
 861             (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
 862         }
 863     } else {
 864         // Snapped for the same reason as the parented branch above: the size
 865         // stored below is `from_x11` of what goes out here, and the next
 866         // configure sends `to_x11` of that back.
 867         (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
 868     };
 869 
 870     let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
 871     let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
 872 
 873     if is_wine && !has_parent && !is_fullscreen {
 874         phys_x -= to_x11(WINE_MARGIN, s) as i16;
 875         phys_y -= to_x11(WINE_MARGIN, s) as i16;
 876     }
 877 
 878     (*xwindow).send_configure(X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height });
 879     let mut log_width = from_x11(phys_width as i32, s) as u32;
 880     let mut log_height = from_x11(phys_height as i32, s) as u32;
 881     if is_wine && !has_parent && !is_fullscreen {
 882         log_width = log_width.saturating_sub((WINE_MARGIN * 2) as u32);
 883         log_height = log_height.saturating_sub((WINE_MARGIN * 2) as u32);
 884     }
 885     (*window).set_dimensions(log_width, log_height);
 886 }
 887 
 888 unsafe extern "C" fn handle_set_override_redirect(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 889     let xwindow = crate::container_of!(listener, XwaylandWindow, set_override_redirect);
 890     let xsurface = (*xwindow).xsurface;
 891     log::info!("xwayland surface set override redirect: val={}", (*xsurface).override_redirect);
 892     assert!((*xsurface).override_redirect);
 893 
 894     let surface = (*xsurface).surface;
 895     if !surface.is_null() {
 896         if ffi::river_wlr_surface_is_mapped(surface) {
 897             handle_unmap_impl(xwindow);
 898         }
 899         handle_dissociate_impl(xwindow);
 900     }
 901     let server = (*(*xwindow).window).server;
 902     handle_destroy_impl(xwindow);
 903 
 904     if let Err(e) = XwaylandOverrideRedirect::create(xsurface, server) {
 905         log::error!("Failed to create XwaylandOverrideRedirect: {}", e);
 906     }
 907 }
 908 
 909 #[allow(dead_code)]
 910 unsafe extern "C" fn handle_set_size_hints(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 911     let xwindow = crate::container_of!(listener, XwaylandWindow, set_size_hints);
 912     let size_hints = (*(*xwindow).xsurface).size_hints;
 913     if !size_hints.is_null() {
 914         let min_width = std::cmp::max(0, (*size_hints).min_width) as u32;
 915         let min_height = std::cmp::max(0, (*size_hints).min_height) as u32;
 916         let max_width = if (*size_hints).max_width <= 0 {
 917             0
 918         } else {
 919             std::cmp::max(min_width, (*size_hints).max_width as u32)
 920         };
 921         let max_height = if (*size_hints).max_height <= 0 {
 922             0
 923         } else {
 924             std::cmp::max(min_height, (*size_hints).max_height as u32)
 925         };
 926         let hint = crate::window::DimensionsHint {
 927             min_width,
 928             max_width,
 929             min_height,
 930             max_height,
 931         };
 932         (*(*xwindow).window).set_dimensions_hint(hint);
 933     }
 934 }
 935 
 936 unsafe extern "C" fn handle_set_title(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 937     let xwindow = crate::container_of!(listener, XwaylandWindow, set_title);
 938     (*(*xwindow).window).notify_title();
 939 }
 940 
 941 unsafe extern "C" fn handle_set_class(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 942     let xwindow = crate::container_of!(listener, XwaylandWindow, set_class);
 943     (*(*xwindow).window).notify_app_id();
 944 }
 945 
 946 unsafe extern "C" fn handle_set_parent(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 947     let xwindow = crate::container_of!(listener, XwaylandWindow, set_parent);
 948     (*(*(*xwindow).window).server).wm.dirty_windowing();
 949 }
 950 
 951 unsafe extern "C" fn handle_set_decorations(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 952     let xwindow = crate::container_of!(listener, XwaylandWindow, set_decorations);
 953     let prefers_csd = ((*(*xwindow).xsurface).decorations
 954         & (ffi::wlr_xwayland_surface_decorations_WLR_XWAYLAND_SURFACE_DECORATIONS_NO_BORDER
 955             | ffi::wlr_xwayland_surface_decorations_WLR_XWAYLAND_SURFACE_DECORATIONS_NO_TITLE) as u32)
 956         != 0;
 957 
 958     let hint = if prefers_csd {
 959         ffi::zcce_window_v1_decoration_hint_ZCCE_WINDOW_V1_DECORATION_HINT_PREFERS_CSD
 960     } else {
 961         ffi::zcce_window_v1_decoration_hint_ZCCE_WINDOW_V1_DECORATION_HINT_PREFERS_SSD
 962     };
 963     (*(*xwindow).window).set_decoration_hint(hint);
 964 }
 965 
 966 unsafe extern "C" fn handle_request_maximize(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 967     let xwindow = crate::container_of!(listener, XwaylandWindow, request_maximize);
 968     let maximized = (*(*xwindow).xsurface).maximized_vert || (*(*xwindow).xsurface).maximized_horz;
 969     let window = (*xwindow).window;
 970     if maximized {
 971         (*window).tiling_mode = crate::tiling::TilingMode::Tiled;
 972         (*window).mode_locked = true;
 973     } else {
 974         (*window).tiling_mode = crate::tiling::TilingMode::Floating;
 975         (*window).mode_locked = true;
 976     }
 977     (*window).wm_scheduled.maximize_requested = if maximized {
 978         crate::window::MaximizeRequest::Maximize
 979     } else {
 980         crate::window::MaximizeRequest::Unmaximize
 981     };
 982     (*(*window).server).wm.dirty_windowing();
 983 }
 984 
 985 unsafe extern "C" fn handle_request_fullscreen(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 986     let xwindow = crate::container_of!(listener, XwaylandWindow, request_fullscreen);
 987     let fullscreen = (*(*xwindow).xsurface).fullscreen;
 988     log::info!(
 989         "XWayland fullscreen request: title='{}' fullscreen={}",
 990         (*(*xwindow).window).get_title_string().unwrap_or_default(),
 991         fullscreen,
 992     );
 993     // An exempt game's pixels are logical, so the compositor's fullscreen
 994     // is a 1920x1200 X window on a 3840x2400 root. Wine syncs
 995     // _NET_WM_STATE from its own idea of the screen and withdraws
 996     // FULLSCREEN the moment it sees a window that does not cover its
 997     // root — every Fullscreen press on Trackmania was undone by this
 998     // request within the frame. The user's fullscreen stands; the key that
 999     // set it clears it.
1000     if !fullscreen
1001         && (*(*xwindow).window).is_fullscreen()
1002         && window_is_hidpi_exempt((*xwindow).window)
1003     {
1004         log::info!("XWayland fullscreen request: ignored — the window is hidpi-exempt and fullscreen by the compositor");
1005         return;
1006     }
1007     (*(*xwindow).window).wm_scheduled.fullscreen_requested = if fullscreen {
1008         crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut())
1009     } else {
1010         crate::window::FullscreenRequest::Exit
1011     };
1012     (*(*(*xwindow).window).server).wm.dirty_windowing();
1013     (*(*(*xwindow).window).server).wm.apply_client_fullscreen((*xwindow).window, fullscreen);
1014 }
1015 
1016 unsafe extern "C" fn handle_request_minimize(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
1017     let xwindow = crate::container_of!(listener, XwaylandWindow, request_minimize);
1018     let event = data as *mut ffi::wlr_xwayland_minimize_event;
1019     ffi::wlr_xwayland_surface_set_minimized((*xwindow).xsurface, (*event).minimize);
1020     (*(*xwindow).window).wm_scheduled.minimize_requested = true;
1021     (*(*(*xwindow).window).server).wm.dirty_windowing();
1022 }
1023 
1024 #[cfg(test)]
1025 mod tests {
1026     use super::*;
1027     use std::sync::atomic::AtomicU32;
1028 
1029     #[test]
1030     fn scale_from_live_output_is_remembered() {
1031         let last = AtomicU32::new(0);
1032         assert_eq!(resolve_x11_scale(Some(2.0), &last), 2.0);
1033         // Output gone (suspend): the remembered scale stands in, not 1.
1034         assert_eq!(resolve_x11_scale(None, &last), 2.0);
1035         // A new output with another scale takes over and is remembered.
1036         assert_eq!(resolve_x11_scale(Some(1.5), &last), 1.5);
1037         assert_eq!(resolve_x11_scale(None, &last), 1.5);
1038     }
1039 
1040     fn pats(list: &[&str]) -> Vec<String> {
1041         list.iter().map(|s| s.to_string()).collect()
1042     }
1043 
1044     #[test]
1045     fn exempt_matches_class_instance_or_title() {
1046         // Proton: every window is class steam_proton, so the game is told
1047         // apart by its instance (the exe) or its title.
1048         let p = pats(&["Trackmania"]);
1049         assert!(hidpi_exempt(&p, "steam_proton", "trackmania.exe", "Trackmania"));
1050         assert!(hidpi_exempt(&p, "steam_proton", "", "Trackmania"));
1051         assert!(hidpi_exempt(&p, "Trackmania", "", ""));
1052         assert!(!hidpi_exempt(&p, "steam_proton", "upc.exe", "Ubisoft Connect"));
1053         // Wildcards and case follow app_id_matches.
1054         let p = pats(&["trackmania*"]);
1055         assert!(hidpi_exempt(&p, "steam_proton", "Trackmania.exe", ""));
1056         assert!(!hidpi_exempt(&p, "steam_proton", "", "My Trackmania"));
1057     }
1058 
1059     #[test]
1060     fn exempt_ignores_empty_fields_and_lists() {
1061         assert!(!hidpi_exempt(&[], "steam_proton", "trackmania.exe", "Trackmania"));
1062         // An empty field must not match a pattern that is itself empty-ish.
1063         assert!(!hidpi_exempt(&pats(&["*"]), "", "", ""));
1064         assert!(hidpi_exempt(&pats(&["*"]), "x", "", ""));
1065     }
1066 
1067     #[test]
1068     fn scale_before_any_output_is_one() {
1069         let last = AtomicU32::new(0);
1070         assert_eq!(resolve_x11_scale(None, &last), 1.0);
1071     }
1072 
1073     #[test]
1074     fn x11_round_trip_holds_at_remembered_scale() {
1075         // The suspend case: physical 3712 read back while no output exists
1076         // must come back as logical 1856, and go out again as 3712.
1077         let last = AtomicU32::new(0);
1078         let _ = resolve_x11_scale(Some(2.0), &last);
1079         let s = resolve_x11_scale(None, &last);
1080         let logical = from_x11(3712, s);
1081         assert_eq!(logical, 1856);
1082         assert_eq!(to_x11(logical, resolve_x11_scale(Some(2.0), &last)), 3712);
1083     }
1084 
1085     #[test]
1086     fn snap_x11_is_what_the_logical_grid_can_express() {
1087         // An odd X11 coordinate at scale 2 has no logical integer, so it
1088         // moves by one; the point is that it then STAYS there. Granting the
1089         // raw value instead is what let Houdini's dialog gain a pixel per
1090         // request: 181 -> 91 -> 182 -> 91 -> 182 ...
1091         assert_eq!(from_x11(181, 2.0), 91);
1092         assert_eq!(to_x11(91, 2.0), 182);
1093         assert_eq!(snap_x11(181, 2.0), 182);
1094 
1095         // Idempotent: snapping a snapped value is a no-op, which is what
1096         // makes repeated configures converge instead of drifting.
1097         for x in [-91, -90, -1, 0, 1, 180, 181, 757, 1300, 3712] {
1098             let once = snap_x11(x, 2.0);
1099             assert_eq!(snap_x11(once, 2.0), once, "not idempotent at x={x}");
1100         }
1101 
1102         // Even values — and every value at scale 1 — are untouched, so
1103         // nothing outside xwayland_hidpi changes.
1104         for x in [-90, 0, 180, 720, 1360, 3712] {
1105             assert_eq!(snap_x11(x, 2.0), x, "even value moved at x={x}");
1106         }
1107         for x in [-91, -1, 0, 1, 181, 757, 1301] {
1108             assert_eq!(snap_x11(x, 1.0), x, "scale 1 moved at x={x}");
1109         }
1110     }
1111 
1112     #[test]
1113     fn configure_is_sent_until_it_has_actually_been_sent_once() {
1114         let g = |x, y, w, h| X11Geom { x, y, width: w, height: h };
1115         let fullscreen = g(0, 0, 1280, 720);
1116         // The restore case: the mirror already says 1280x720 (pre-written by
1117         // try_restore) but nothing was ever sent — X is at its natural size.
1118         assert!(needs_configure(fullscreen, fullscreen, None));
1119         // Something else was sent (the client's own pre-map request).
1120         assert!(needs_configure(fullscreen, fullscreen, Some(g(0, 0, 103, 36))));
1121         // Sent once and X reports it: nothing to do.
1122         assert!(!needs_configure(fullscreen, fullscreen, Some(fullscreen)));
1123         // X moved or resized itself since: the mirror disagrees, resend.
1124         assert!(needs_configure(fullscreen, g(10, 10, 1280, 720), Some(fullscreen)));
1125         assert!(needs_configure(fullscreen, g(0, 0, 640, 360), Some(fullscreen)));
1126         // A different wanted geometry always goes out.
1127         assert!(needs_configure(g(0, 0, 640, 360), fullscreen, Some(fullscreen)));
1128     }
1129 
1130     #[test]
1131     fn output_change_grace_begins_and_is_re_armed() {
1132         note_output_change();
1133         assert!(in_output_change_grace());
1134         // Expire it by hand, then a second change re-arms it.
1135         *OUTPUT_CHANGE_GRACE_UNTIL.lock().unwrap() =
1136             Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
1137         assert!(!in_output_change_grace());
1138         note_output_change();
1139         assert!(in_output_change_grace());
1140     }
1141 }