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

src/server/xwayland_window.rs (71.2K)

   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     /// Whether the client is a Wine/Proton process (`is_wine_process`),
  38     /// read from /proc once and cached; `None` until first asked.
  39     pub wine_process: Option<bool>,
  40 
  41     /// A Wine window has been told it is maximized and has not yet echoed
  42     /// the state back (`absorbs_wine_echo`). Cleared once the echo is over
  43     /// — its FULLSCREEN swap absorbed and written back, or Wine's own
  44     /// `_NET_WM_STATE_MAXIMIZED` add — or by un-maximizing it.
  45     pub wine_max_unconfirmed: bool,
  46     /// When this compositor last configured the X window or its maximized
  47     /// state: Wine's reaction to either arrives within moments of it.
  48     pub last_configure: Option<std::time::Instant>,
  49 
  50     /// The last refusal of a smaller size seen per axis (width, height),
  51     /// as `(sent, requested)` logical — what `learned_min` compares the
  52     /// next one against to tell a floor from rounding.
  53     pub refusals: [Option<(u32, u32)>; 2],
  54 }
  55 
  56 /// A window geometry in X11 root coordinates — physical pixels under
  57 /// `xwayland_hidpi` (see `x11_scale`), the same units as the
  58 /// `wlr_xwayland_surface` fields it is compared against.
  59 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
  60 pub struct X11Geom {
  61     pub x: i16,
  62     pub y: i16,
  63     pub width: u16,
  64     pub height: u16,
  65 }
  66 
  67 /// Whether `wanted` has to be sent to X, given what wlroots reports the
  68 /// window's geometry to be (`reported`) and the last geometry this
  69 /// compositor sent (`sent`).
  70 ///
  71 /// Comparing against the wlroots mirror alone was the bug: the saved-state
  72 /// restore (`Window::try_restore`) pre-writes the mirror's width/height to
  73 /// the saved size so the first frame renders at it, which makes the mirror
  74 /// a statement of what the compositor wants X to have, not what X has. A
  75 /// window saved fullscreen restored at exactly the fullscreen size then
  76 /// looked already-configured, the configure was skipped, and the real X
  77 /// window stayed at its natural size. So a geometry is also considered
  78 /// unsent until this compositor has actually sent it once; after that the
  79 /// mirror is what catches X changing the geometry on its own (a
  80 /// ConfigureNotify updates it), which the sent record cannot see.
  81 pub fn needs_configure(wanted: X11Geom, reported: X11Geom, sent: Option<X11Geom>) -> bool {
  82     wanted != reported || sent != Some(wanted)
  83 }
  84 
  85 unsafe fn connect_listener(
  86     signal: *mut ffi::wl_signal,
  87     listener: *mut ffi::wl_listener,
  88     callback: unsafe extern "C" fn(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void),
  89 ) {
  90     let wl_lis = listener as *mut WlListener;
  91     (*wl_lis).notify = Some(callback);
  92     wl_signal_add(signal, listener);
  93 }
  94 
  95 unsafe fn wl_listener_remove_safe(listener: *mut ffi::wl_listener) {
  96     let prev = (*listener).link.prev;
  97     let next = (*listener).link.next;
  98     if !prev.is_null() && !next.is_null() && prev != listener as *mut ffi::wl_list && next != listener as *mut ffi::wl_list {
  99         ffi::wl_list_remove(&mut (*listener).link);
 100         (*listener).link.prev = std::ptr::null_mut();
 101         (*listener).link.next = std::ptr::null_mut();
 102     }
 103 }
 104 
 105 /// Wine draws its own frame in a margin around the window; the compositor
 106 /// hides it by oversizing and offsetting the X window. Logical pixels.
 107 pub const WINE_MARGIN: i32 = 16;
 108 
 109 /// The factor between X11 root coordinates and the logical layout.
 110 ///
 111 /// With `xwayland_hidpi` on (the default) the xdg-output global is hidden
 112 /// from Xwayland (`server.rs`), so it sizes its screen from the wl_output
 113 /// MODE — the physical pixel grid — and X11 is a physical-pixel world: a
 114 /// HiDPI-aware X11 app (Houdini, any Qt 6 app reading Xft.dpi) renders at
 115 /// full resolution and its surfaces are drawn at 1/scale
 116 /// (`Window::x11_buffer_scale`), sharp instead of upscaled from logical size.
 117 /// Every position and size crossing into or out of X11 converts through
 118 /// `to_x11` / `from_x11`; the window's own geometry stays logical.
 119 ///
 120 /// Off, X11 is the logical layout (Xwayland reads xdg-output) and the
 121 /// factor is 1. Multi-output with differing scales is not a case X11 can
 122 /// express — the first output's scale stands for the screen. A single
 123 /// window can opt out through `xwayland_hidpi_except` — see `x11_scale_for`,
 124 /// which every per-window caller goes through; this screen-wide value is
 125 /// only for what has no window, like the Xft.dpi pushed at Xwayland-ready.
 126 ///
 127 /// The factor must survive the output going away. A lid-close suspend
 128 /// destroys the DRM output and re-creates it on resume, and X11 windows
 129 /// live on through it: any geometry read back from X while no output
 130 /// exists (`Window::render_finish`, `XwaylandWindow::configure`) is still
 131 /// in physical pixels, and dividing it by 1 instead of the panel's scale
 132 /// records a window twice its logical size — which the first configure
 133 /// after resume then multiplies by the real scale again, handing X a
 134 /// window four times too big. So the last scale an output reported is
 135 /// remembered and stands in while there is none.
 136 pub unsafe fn x11_scale(server: *mut crate::server::Server) -> f32 {
 137     if server.is_null() || !(*server).wm.xwayland_hidpi {
 138         return 1.0;
 139     }
 140     let mut current = None;
 141     let link = (*server).om.outputs.next;
 142     if link != &mut (*server).om.outputs as *mut ffi::wl_list {
 143         let output = crate::container_of!(link, crate::output::Output, link);
 144         let scale = (*output).current.scale;
 145         if scale > 0.0 {
 146             current = Some(scale);
 147         }
 148     }
 149     resolve_x11_scale(current, &LAST_X11_SCALE)
 150 }
 151 
 152 /// The scale of the last output `x11_scale` saw, as `f32` bits; 0 until an
 153 /// output has reported one.
 154 static LAST_X11_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
 155 
 156 /// `x11_scale` without the FFI: the live output's scale when there is one
 157 /// (remembering it in `last`), else the remembered one, else 1.
 158 pub fn resolve_x11_scale(current: Option<f32>, last: &std::sync::atomic::AtomicU32) -> f32 {
 159     use std::sync::atomic::Ordering;
 160     if let Some(scale) = current {
 161         last.store(scale.to_bits(), Ordering::Relaxed);
 162         return scale;
 163     }
 164     let remembered = f32::from_bits(last.load(Ordering::Relaxed));
 165     if remembered > 0.0 { remembered } else { 1.0 }
 166 }
 167 
 168 /// X11 clients answer an output coming or going — Xwayland re-creates its
 169 /// screen and RandR tells them — by re-asserting a geometry of their own:
 170 /// Houdini (Qt) asks for the whole panel after a resume from suspend, and a
 171 /// floating window's unsolicited size request is otherwise honoured
 172 /// verbatim (`handle_request_configure`). For a moment after any output
 173 /// change those requests are answered with the window's own geometry
 174 /// instead, the way a tiled window's always are, so the size the user set
 175 /// survives the screen change.
 176 static OUTPUT_CHANGE_GRACE_UNTIL: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
 177 
 178 /// How long after an output is created or destroyed to hold floating X11
 179 /// windows at their own size. Xwayland's RandR update and the client's
 180 /// reaction land within the same second in practice; this leaves room for
 181 /// a slow client.
 182 const OUTPUT_CHANGE_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
 183 
 184 /// Called when an output is created or destroyed: (re)starts the grace
 185 /// window during which floating X11 windows keep their size.
 186 pub fn note_output_change() {
 187     if let Ok(mut deadline) = OUTPUT_CHANGE_GRACE_UNTIL.lock() {
 188         *deadline = Some(std::time::Instant::now() + OUTPUT_CHANGE_GRACE);
 189     }
 190 }
 191 
 192 /// True while inside the grace window begun by `note_output_change`.
 193 pub fn in_output_change_grace() -> bool {
 194     OUTPUT_CHANGE_GRACE_UNTIL
 195         .lock()
 196         .ok()
 197         .and_then(|deadline| *deadline)
 198         .is_some_and(|deadline| std::time::Instant::now() < deadline)
 199 }
 200 
 201 /// `x11_scale` for one X11 surface: 1 when the window is named in
 202 /// `window_manager { xwayland_hidpi_except }`, else the screen's factor.
 203 ///
 204 /// The screen Xwayland shows is one thing for every client, so an exempt
 205 /// window still SEES a physical-pixel root; what changes is what the
 206 /// compositor does with it. Its configures go out in logical pixels and its
 207 /// buffer is drawn at 1 (`Window::x11_buffer_scale`), the pre-`xwayland_hidpi`
 208 /// arrangement — so a borderless-fullscreen game that asks for the whole
 209 /// root is answered with the logical size and renders that many pixels,
 210 /// not scale² as many. Matched by WM_CLASS class, WM_CLASS instance or
 211 /// title (`hidpi_exempt`), re-evaluated on every use so a title that
 212 /// arrives after the first configure still takes effect.
 213 pub unsafe fn x11_scale_for(
 214     server: *mut crate::server::Server,
 215     xsurface: *const ffi::wlr_xwayland_surface,
 216 ) -> f32 {
 217     if server.is_null() || !(*server).wm.xwayland_hidpi {
 218         return 1.0;
 219     }
 220     if !xsurface.is_null() && !(*server).wm.xwayland_hidpi_except.is_empty() {
 221         // Borrowed (no copy for valid UTF-8): this runs every frame for
 222         // every X11 window, and allocated all three fields each time.
 223         let text = |p: *const libc::c_char| -> std::borrow::Cow<'_, str> {
 224             if p.is_null() { std::borrow::Cow::Borrowed("") } else { std::ffi::CStr::from_ptr(p).to_string_lossy() }
 225         };
 226         let class = text((*xsurface).class);
 227         let instance = text((*xsurface).instance);
 228         let title = text((*xsurface).title);
 229         if hidpi_exempt(&(*server).wm.xwayland_hidpi_except, &class, &instance, &title) {
 230             return 1.0;
 231         }
 232     }
 233     x11_scale(server)
 234 }
 235 
 236 /// The factor an X11 client's CURSOR is drawn at: the screen's X11 factor
 237 /// for any X11 surface, 1 for anything that is not X11.
 238 ///
 239 /// What a cursor request needs. Deliberately NOT `x11_scale_for`: the
 240 /// `xwayland_hidpi_except` exemption is about a game's window pixels — it
 241 /// sizes itself to the root ignoring DPI, so its buffer is drawn at 1 —
 242 /// but its cursor comes from the toolkit or Wine underneath, which follow
 243 /// the DPI this compositor publishes (Xft.dpi / Xcursor.size at 96×scale
 244 /// and 24×scale). Wine at LogPixels 192 hands Trackmania a 64px arrow;
 245 /// drawn in the logical world with the window it was twice the desktop's
 246 /// cursor. Every X11 cursor is a physical-pixel bitmap, exempt window or
 247 /// not.
 248 pub unsafe fn x11_scale_for_surface(
 249     server: *mut crate::server::Server,
 250     surface: *mut ffi::wlr_surface,
 251 ) -> f32 {
 252     if surface.is_null() {
 253         return 1.0;
 254     }
 255     let root = ffi::wlr_surface_get_root_surface(surface);
 256     if root.is_null() {
 257         return 1.0;
 258     }
 259     let xsurface = ffi::wlr_xwayland_surface_try_from_wlr_surface(root);
 260     if xsurface.is_null() {
 261         return 1.0;
 262     }
 263     x11_scale(server)
 264 }
 265 
 266 /// Whether any of `patterns` names this window: each is tried against the
 267 /// WM_CLASS class, the WM_CLASS instance and the title with the
 268 /// `app_id_matches` rules (case-insensitive, `*` wildcards). Empty fields
 269 /// never match.
 270 /// Whether `window` is an X11 window named in `xwayland_hidpi_except` — a
 271 /// full-screen X11 game, by the key's definition. Such a window sizes and
 272 /// places itself to the screen, and the compositor stays out of its way:
 273 /// no saved-state restore (`Window::try_restore`); its own requests are
 274 /// granted, clamped to the output's logical box since it sees a
 275 /// physical-pixel root (`handle_request_configure`); a compositor
 276 /// fullscreen overrides its size and survives Wine's withdrawal of the
 277 /// state (`handle_request_fullscreen`). Its cursor is NOT exempt — see
 278 /// `x11_scale_for_surface`.
 279 pub unsafe fn window_is_hidpi_exempt(window: *const crate::window::Window) -> bool {
 280     if window.is_null() {
 281         return false;
 282     }
 283     let crate::window::WindowImpl::Xwayland(xwindow) = (*window).impl_type else {
 284         return false;
 285     };
 286     if xwindow.is_null() || (*xwindow).xsurface.is_null() {
 287         return false;
 288     }
 289     let server = (*window).server;
 290     if server.is_null() || !(*server).wm.xwayland_hidpi || (*server).wm.xwayland_hidpi_except.is_empty() {
 291         return false;
 292     }
 293     let xsurface = (*xwindow).xsurface;
 294     let text = |p: *const libc::c_char| -> String {
 295         if p.is_null() { String::new() } else { std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() }
 296     };
 297     hidpi_exempt(
 298         &(*server).wm.xwayland_hidpi_except,
 299         &text((*xsurface).class),
 300         &text((*xsurface).instance),
 301         &text((*xsurface).title),
 302     )
 303 }
 304 
 305 /// Whether `exe` (a `/proc/<pid>/exe` target) is Wine's loader, which every
 306 /// Wine and Proton program runs as: `wine-preloader`/`wine64-preloader`, or
 307 /// `wine`/`wine64` itself on a build without a preloader.
 308 pub fn is_wine_exe(exe: &str) -> bool {
 309     let name = exe.trim_end_matches(" (deleted)").rsplit('/').next().unwrap_or("");
 310     matches!(name, "wine-preloader" | "wine64-preloader" | "wine" | "wine64")
 311 }
 312 
 313 /// The minimum an X11 client revealed along one axis by asking for
 314 /// `requested` right after being configured to `sent` — larger than it was
 315 /// given means it would not go that small — and the refusal to remember
 316 /// for the next call. `prev` is the last refusal seen, `(sent, requested)`.
 317 ///
 318 /// One refusal is not enough: an app that rounds its size up (to character
 319 /// cells, to an aspect) answers "bigger" too, and a minimum learned from
 320 /// that is stored for good (`min_sizes`) and blocks every smaller size.
 321 /// A true minimum is a FLOOR — two different sent sizes answered with the
 322 /// same one (Ubisoft Connect asked for 2428 at every step of a drag).
 323 /// `None` when that says nothing new, including a floor no larger than
 324 /// the minimum already known.
 325 pub fn learned_min(prev: Option<(u32, u32)>, sent: u32, requested: u32, known_min: u32) -> (Option<u32>, Option<(u32, u32)>) {
 326     if requested <= sent {
 327         return (None, prev);
 328     }
 329     let floor = matches!(prev, Some((prev_sent, prev_req)) if prev_req == requested && prev_sent != sent);
 330     ((floor && requested > known_min).then_some(requested), Some((sent, requested)))
 331 }
 332 
 333 /// A stored minimum (0 = none) checked against the size a window actually
 334 /// has: a smaller real size is the new minimum.
 335 pub fn lowered_min(stored: u32, actual: u32) -> u32 {
 336     if stored > 0 && actual > 0 && actual < stored { actual } else { stored }
 337 }
 338 
 339 /// Whether `_MOTIF_WM_HINTS` (flags, functions, decorations, …) offer the
 340 /// maximize function. Without the functions flag nothing is restricted;
 341 /// with MWM_FUNC_ALL set the listed functions are the ones REMOVED.
 342 pub fn motif_allows_maximize(hints: &[u32]) -> bool {
 343     const MWM_HINTS_FUNCTIONS: u32 = 1;
 344     const MWM_FUNC_ALL: u32 = 1;
 345     const MWM_FUNC_MAXIMIZE: u32 = 16;
 346     let (Some(&flags), Some(&functions)) = (hints.first(), hints.get(1)) else {
 347         return false;
 348     };
 349     if flags & MWM_HINTS_FUNCTIONS == 0 {
 350         return true;
 351     }
 352     (functions & MWM_FUNC_ALL != 0) != (functions & MWM_FUNC_MAXIMIZE != 0)
 353 }
 354 
 355 pub fn hidpi_exempt(patterns: &[String], class: &str, instance: &str, title: &str) -> bool {
 356     use crate::window_manager::app_id_matches;
 357     patterns.iter().any(|p| {
 358         [class, instance, title]
 359             .iter()
 360             .any(|field| !field.is_empty() && app_id_matches(p, field))
 361     })
 362 }
 363 
 364 pub fn to_x11(logical: i32, scale: f32) -> i32 {
 365     (logical as f32 * scale).round() as i32
 366 }
 367 
 368 pub fn from_x11(x11: i32, scale: f32) -> i32 {
 369     (x11 as f32 / scale).round() as i32
 370 }
 371 
 372 /// The nearest X11 value the LOGICAL grid can express — `to_x11` of
 373 /// `from_x11`.
 374 ///
 375 /// At scale 2 an odd X11 coordinate has no logical integer: 181 reads back as
 376 /// 91 and goes out again as 182. The window's geometry is logical, so
 377 /// granting a client's request verbatim and storing the rounded logical means
 378 /// the next configure hands X a value a pixel off the one it asked for —
 379 /// which the client reads as an unrequested move and answers with another
 380 /// request, a pixel further along each time. Granting the snapped value makes
 381 /// the geometry sent and the geometry the compositor's own model reproduces
 382 /// the same number, so the round trip is stable however often it repeats.
 383 ///
 384 /// At scale 1 this is the identity, so nothing outside `xwayland_hidpi`
 385 /// changes.
 386 pub fn snap_x11(x11: i32, scale: f32) -> i32 {
 387     to_x11(from_x11(x11, scale), scale)
 388 }
 389 
 390 impl XwaylandWindow {
 391     pub unsafe fn create(
 392         xsurface: *mut ffi::wlr_xwayland_surface,
 393         server: *mut Server,
 394     ) -> Result<(), &'static str> {
 395         let title_ptr = (*xsurface).title;
 396         let class_ptr = (*xsurface).class;
 397         log::debug!(
 398             "new xwayland window: title='{:?}', class='{:?}'",
 399             if title_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(title_ptr).to_str().unwrap_or("") },
 400             if class_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(class_ptr).to_str().unwrap_or("") }
 401         );
 402 
 403         let window = Window::create(WindowImpl::Xwayland(std::ptr::null_mut()), server)?;
 404 
 405         let xwindow = Box::new(XwaylandWindow {
 406             window,
 407             xsurface,
 408             surface_tree: std::ptr::null_mut(),
 409             destroy: std::mem::zeroed(),
 410             request_configure: std::mem::zeroed(),
 411             set_override_redirect: std::mem::zeroed(),
 412             associate: std::mem::zeroed(),
 413             dissociate: std::mem::zeroed(),
 414             set_size_hints: std::mem::zeroed(),
 415             set_title: std::mem::zeroed(),
 416             set_class: std::mem::zeroed(),
 417             set_parent: std::mem::zeroed(),
 418             set_decorations: std::mem::zeroed(),
 419             request_maximize: std::mem::zeroed(),
 420             request_fullscreen: std::mem::zeroed(),
 421             request_minimize: std::mem::zeroed(),
 422             map: std::mem::zeroed(),
 423             unmap: std::mem::zeroed(),
 424             sent_geom: None,
 425             wine_process: None,
 426             wine_max_unconfirmed: false,
 427             last_configure: None,
 428             refusals: [None, None],
 429         });
 430 
 431         let raw = Box::into_raw(xwindow);
 432         (*window).set_impl(WindowImpl::Xwayland(raw));
 433 
 434         (*xsurface).data = raw as *mut std::ffi::c_void;
 435 
 436         connect_listener(&mut (*xsurface).events.destroy, &mut (*raw).destroy, handle_destroy);
 437         connect_listener(&mut (*xsurface).events.associate, &mut (*raw).associate, handle_associate);
 438         connect_listener(&mut (*xsurface).events.dissociate, &mut (*raw).dissociate, handle_dissociate);
 439         connect_listener(&mut (*xsurface).events.request_configure, &mut (*raw).request_configure, handle_request_configure);
 440         connect_listener(&mut (*xsurface).events.set_override_redirect, &mut (*raw).set_override_redirect, handle_set_override_redirect);
 441         // connect_listener(&mut (*xsurface).events.set_size_hints, &mut (*raw).set_size_hints, handle_set_size_hints);
 442         connect_listener(&mut (*xsurface).events.set_title, &mut (*raw).set_title, handle_set_title);
 443         connect_listener(&mut (*xsurface).events.set_class, &mut (*raw).set_class, handle_set_class);
 444         connect_listener(&mut (*xsurface).events.set_parent, &mut (*raw).set_parent, handle_set_parent);
 445         connect_listener(&mut (*xsurface).events.set_decorations, &mut (*raw).set_decorations, handle_set_decorations);
 446         connect_listener(&mut (*xsurface).events.request_maximize, &mut (*raw).request_maximize, handle_request_maximize);
 447         connect_listener(&mut (*xsurface).events.request_fullscreen, &mut (*raw).request_fullscreen, handle_request_fullscreen);
 448         connect_listener(&mut (*xsurface).events.request_minimize, &mut (*raw).request_minimize, handle_request_minimize);
 449 
 450         if !(*xsurface).surface.is_null() {
 451             handle_associate_impl(raw);
 452             if ffi::river_wlr_surface_is_mapped((*xsurface).surface) {
 453                 handle_map_impl(raw);
 454             }
 455         }
 456 
 457         Ok(())
 458     }
 459 
 460     pub unsafe fn configure(&mut self) -> bool {
 461         let window = self.window;
 462         let scheduled = &mut (*window).configure_scheduled;
 463         let sent = &mut (*window).configure_sent;
 464         let s = x11_scale_for((*window).server, self.xsurface);
 465 
 466         if scheduled.width == Some(0) {
 467             scheduled.width = Some(from_x11((*self.xsurface).width as i32, s) as u32);
 468         }
 469         if scheduled.height == Some(0) {
 470             scheduled.height = Some(from_x11((*self.xsurface).height as i32, s) as u32);
 471         }
 472 
 473         let mut phys_width = if let Some(w) = scheduled.width {
 474             to_x11(w as i32, s) as u16
 475         } else {
 476             (*self.xsurface).width
 477         };
 478 
 479         let mut phys_height = if let Some(h) = scheduled.height {
 480             to_x11(h as i32, s) as u16
 481         } else {
 482             (*self.xsurface).height
 483         };
 484 
 485         // X11 root coordinates: see `x11_scale`. Everything sent to X goes
 486         // through `to_x11`, everything read back through `from_x11`; the
 487         // window's own geometry stays logical.
 488         let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
 489         let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
 490 
 491         let has_parent = !(*self.xsurface).parent.is_null();
 492 
 493         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
 494             if scheduled.width.is_some() {
 495                 phys_width += to_x11(WINE_MARGIN * 2, s) as u16;
 496             }
 497             if scheduled.height.is_some() {
 498                 phys_height += to_x11(WINE_MARGIN * 2, s) as u16;
 499             }
 500             phys_x -= to_x11(WINE_MARGIN, s) as i16;
 501             phys_y -= to_x11(WINE_MARGIN, s) as i16;
 502         }
 503 
 504         let wanted = X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height };
 505         if needs_configure(wanted, self.reported_geom(), self.sent_geom) {
 506             self.send_configure(wanted);
 507         }
 508 
 509         if scheduled.activated != sent.activated {
 510             self.set_activated(scheduled.activated);
 511         }
 512         if scheduled.maximized != sent.maximized {
 513             ffi::wlr_xwayland_surface_set_maximized(self.xsurface, scheduled.maximized, scheduled.maximized);
 514             self.last_configure = Some(std::time::Instant::now());
 515             // Wine answers this by maximizing the Win32 window itself; see
 516             // `absorbs_wine_echo` for what that sets off and how it ends.
 517             self.wine_max_unconfirmed = scheduled.maximized && self.is_wine_process();
 518         }
 519         if scheduled.inform_fullscreen != sent.inform_fullscreen {
 520             ffi::wlr_xwayland_surface_set_fullscreen(self.xsurface, scheduled.inform_fullscreen);
 521         }
 522 
 523         let mut width = scheduled.width.unwrap_or(from_x11((*self.xsurface).width as i32, s) as u32);
 524         let mut height = scheduled.height.unwrap_or(from_x11((*self.xsurface).height as i32, s) as u32);
 525 
 526         if (*window).is_wine() && !has_parent && !(*window).is_fullscreen() {
 527             if scheduled.width.is_none() {
 528                 width = width.saturating_sub((WINE_MARGIN * 2) as u32);
 529             }
 530             if scheduled.height.is_none() {
 531                 height = height.saturating_sub((WINE_MARGIN * 2) as u32);
 532             }
 533         }
 534 
 535         (*window).configure_sent = (*window).configure_scheduled.clone();
 536         (*window).configure_sent.width = Some(width);
 537         (*window).configure_sent.height = Some(height);
 538         (*window).configure_scheduled.width = None;
 539         (*window).configure_scheduled.height = None;
 540 
 541         false
 542     }
 543 
 544     /// Whether the client is a Wine/Proton process: its executable is
 545     /// Wine's loader (`is_wine_exe`), or its argv[0] is a Windows path
 546     /// (`window_manager::is_windows_path`) — the loader is what both
 547     /// system Wine and Proton run as, and argv[0] alone misses a program
 548     /// started by a relative name (`wine popup.exe` keeps `popup.exe`).
 549     /// Not cached while the pid is still unknown, so a window read before
 550     /// _NET_WM_PID arrives is asked again.
 551     pub unsafe fn is_wine_process(&mut self) -> bool {
 552         if let Some(wine) = self.wine_process {
 553             return wine;
 554         }
 555         let pid = (*self.xsurface).pid;
 556         if pid <= 0 {
 557             return false;
 558         }
 559         let exe = std::fs::read_link(format!("/proc/{}/exe", pid)).unwrap_or_default();
 560         let wine = is_wine_exe(&exe.to_string_lossy())
 561             || crate::window_manager::proc_args(pid)
 562                 .first()
 563                 .map_or(false, |argv0| crate::window_manager::is_windows_path(argv0));
 564         self.wine_process = Some(wine);
 565         wine
 566     }
 567 
 568     /// This window's key in `min_sizes`: app_id, program (argv[0]) and
 569     /// title. `None` while any is unknown — a window with no readable
 570     /// process cannot be told from another program with its app_id.
 571     unsafe fn min_size_key(&self) -> Option<(String, String, String)> {
 572         let app_id = (*self.window).get_app_id_string()?;
 573         let program = crate::window_manager::proc_args((*self.xsurface).pid).into_iter().next()?;
 574         let title = (*self.window).get_title_string().unwrap_or_default();
 575         Some((app_id, program, title))
 576     }
 577 
 578     /// Persist a learned minimum (logical `width`x`height` at X11 scale `s`).
 579     unsafe fn store_min_size(&self, width: u32, height: u32, s: f32) {
 580         let Some((app_id, program, title)) = self.min_size_key() else { return };
 581         let (w, h) = (to_x11(width as i32, s) as u32, to_x11(height as i32, s) as u32);
 582         (*(*self.window).server).wm.min_sizes.set(&app_id, &program, &title, w, h);
 583     }
 584 
 585     /// Drop this window's stored minimum and the one it is held to now, and
 586     /// start learning afresh (`ccectl min-size forget`). The learned value
 587     /// is the only minimum an X11 window carries here, so the hint's min
 588     /// goes to 0. Returns what was stored, X11 pixels.
 589     pub unsafe fn forget_min_size(&mut self) -> Option<(u32, u32)> {
 590         let stored = self.min_size_key().and_then(|(app_id, program, title)| {
 591             let min_sizes = &mut (*(*self.window).server).wm.min_sizes;
 592             let stored = min_sizes.get(&app_id, &program, &title);
 593             min_sizes.set(&app_id, &program, &title, 0, 0);
 594             stored
 595         });
 596         self.refusals = [None, None];
 597         let hint = crate::window::DimensionsHint {
 598             min_width: 0,
 599             min_height: 0,
 600             ..(*self.window).wm_scheduled.dimensions_hint
 601         };
 602         (*self.window).set_dimensions_hint(hint);
 603         stored
 604     }
 605 
 606     /// Hand the window the minimum a previous run learned, before its first
 607     /// arrange, so the first drag past it already stops there.
 608     unsafe fn apply_stored_min_size(&self) {
 609         let Some((app_id, program, title)) = self.min_size_key() else { return };
 610         let Some((stored_w, stored_h)) = (*(*self.window).server).wm.min_sizes.get(&app_id, &program, &title) else { return };
 611         // Mapping at a size below the stored minimum proves the app takes
 612         // it: the minimum was learned too high, or the app lowered it. Kept
 613         // unchecked, a stale minimum outlives every restart.
 614         let (w, h) = (
 615             lowered_min(stored_w, (*self.xsurface).width as u32),
 616             lowered_min(stored_h, (*self.xsurface).height as u32),
 617         );
 618         if (w, h) != (stored_w, stored_h) {
 619             log::info!("XWayland map: '{}' maps below its stored minimum; lowered to {}x{} (X11 px)", title, w, h);
 620             (*(*self.window).server).wm.min_sizes.set(&app_id, &program, &title, w, h);
 621         }
 622         let s = x11_scale_for((*self.window).server, self.xsurface);
 623         let hint = crate::window::DimensionsHint {
 624             min_width: if w > 0 { from_x11(w as i32, s) as u32 } else { 0 },
 625             min_height: if h > 0 { from_x11(h as i32, s) as u32 } else { 0 },
 626             ..(*self.window).wm_scheduled.dimensions_hint
 627         };
 628         log::info!("XWayland map: '{}' has a stored minimum of {}x{}", title, hint.min_width, hint.min_height);
 629         (*self.window).set_dimensions_hint(hint);
 630     }
 631 
 632     /// Whether a `_NET_WM_STATE` request from this window is Wine's echo
 633     /// of being told it is maximized, to be absorbed rather than acted on.
 634     ///
 635     /// Wine answers `_NET_WM_STATE_MAXIMIZED` by maximizing the Win32
 636     /// window itself, on the next ConfigureNotify. A window with no
 637     /// caption (Ubisoft Connect, any CEF/Electron frameless app) maximizes
 638     /// to the WHOLE monitor, and Wine reports a monitor-sized maximized
 639     /// window as FULLSCREEN in place of MAXIMIZED: moving a tiled Ubisoft
 640     /// Connect turned it fullscreen (2026-09-26). Absorbed, the size stays
 641     /// the tile's (`handle_request_configure` holds a tiled window's size),
 642     /// Wine sees a rect that no longer covers the monitor and settles on
 643     /// MAXIMIZED by itself, which ends the echo (`handle_request_maximize`).
 644     ///
 645     /// Only within a moment of this compositor's own configure: Wine
 646     /// reacts at once, and a Wine window with a caption never echoes at
 647     /// all, so an open-ended wait would swallow a game's real fullscreen
 648     /// request much later.
 649     /// Whether a FULLSCREEN request from this window is really the app's
 650     /// own maximize button. Wine sends no MAXIMIZED for it: a captionless
 651     /// window maximizes to the whole monitor (plus its resize frame, off
 652     /// screen), which Wine reports as FULLSCREEN alone, and no geometry
 653     /// comes with it — a fullscreen window's size is left to the window
 654     /// manager. What tells it from a borderless-fullscreen game is the
 655     /// maximize box: Wine mirrors WS_MAXIMIZEBOX into _MOTIF_WM_HINTS'
 656     /// functions (Ubisoft Connect: 0x3e; a WS_POPUP game: 0x24). The size
 657     /// hints looked like a signal too — Wine pins min == max on a window
 658     /// without WS_THICKFRAME — but it loosens them for a fullscreen window
 659     /// before the request arrives. A window named in
 660     /// `xwayland_hidpi_except` is a fullscreen game by definition.
 661     pub unsafe fn is_wine_maximize(&mut self) -> bool {
 662         let xs = self.xsurface;
 663         if !(*xs).parent.is_null() || window_is_hidpi_exempt(self.window) {
 664             return false;
 665         }
 666         let no_title = (*xs).decorations
 667             & ffi::wlr_xwayland_surface_decorations_WLR_XWAYLAND_SURFACE_DECORATIONS_NO_TITLE as u32
 668             != 0;
 669         no_title && self.is_wine_process() && self.motif_hints().map_or(false, |h| motif_allows_maximize(&h))
 670     }
 671 
 672     /// The window's `_MOTIF_WM_HINTS`, read over the XWM's own connection.
 673     /// wlroots parses only the decorations out of it, and this is asked
 674     /// rarely (a Wine window's fullscreen request), so a synchronous
 675     /// round-trip here costs nothing that matters.
 676     unsafe fn motif_hints(&self) -> Option<Vec<u32>> {
 677         let xwayland = (*(*self.window).server).xwayland;
 678         if xwayland.is_null() {
 679             return None;
 680         }
 681         let conn = ffi::wlr_xwayland_get_xwm_connection(xwayland);
 682         if conn.is_null() {
 683             return None;
 684         }
 685         let name = b"_MOTIF_WM_HINTS";
 686         let cookie = ffi::xcb_intern_atom(conn, 1, name.len() as u16, name.as_ptr() as *const libc::c_char);
 687         let atom_reply = ffi::xcb_intern_atom_reply(conn, cookie, std::ptr::null_mut());
 688         if atom_reply.is_null() {
 689             return None;
 690         }
 691         let atom = (*atom_reply).atom;
 692         libc::free(atom_reply as *mut libc::c_void);
 693         if atom == 0 {
 694             return None;
 695         }
 696         // AnyPropertyType; the hints are five CARD32s.
 697         let cookie = ffi::xcb_get_property(conn, 0, (*self.xsurface).window_id, atom, 0, 0, 5);
 698         let reply = ffi::xcb_get_property_reply(conn, cookie, std::ptr::null_mut());
 699         if reply.is_null() {
 700             return None;
 701         }
 702         let hints = if (*reply).format == 32 {
 703             let len = ffi::xcb_get_property_value_length(reply) as usize / 4;
 704             let data = ffi::xcb_get_property_value(reply) as *const u32;
 705             Some(std::slice::from_raw_parts(data, len).to_vec())
 706         } else {
 707             None
 708         };
 709         libc::free(reply as *mut libc::c_void);
 710         hints
 711     }
 712 
 713     pub unsafe fn absorbs_wine_echo(&self) -> bool {
 714         const ECHO_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
 715         self.wine_max_unconfirmed
 716             && (*self.window).tiling_mode == crate::tiling::TilingMode::Tiled
 717             && self.last_configure.map_or(false, |t| t.elapsed() < ECHO_WINDOW)
 718     }
 719 
 720     /// The geometry wlroots currently reports for the X window.
 721     pub unsafe fn reported_geom(&self) -> X11Geom {
 722         X11Geom {
 723             x: (*self.xsurface).x,
 724             y: (*self.xsurface).y,
 725             width: (*self.xsurface).width,
 726             height: (*self.xsurface).height,
 727         }
 728     }
 729 
 730     /// The one path to `wlr_xwayland_surface_configure`: every geometry
 731     /// handed to X is recorded in `sent_geom` so `needs_configure` can tell
 732     /// a geometry X has from one the compositor merely mirrored.
 733     pub unsafe fn send_configure(&mut self, g: X11Geom) {
 734         ffi::wlr_xwayland_surface_configure(self.xsurface, g.x, g.y, g.width, g.height);
 735         self.sent_geom = Some(g);
 736         self.last_configure = Some(std::time::Instant::now());
 737     }
 738 
 739     pub unsafe fn set_activated(&self, activated: bool) {
 740         if activated && (*self.xsurface).minimized {
 741             ffi::wlr_xwayland_surface_set_minimized(self.xsurface, false);
 742         }
 743         ffi::wlr_xwayland_surface_activate(self.xsurface, activated);
 744     }
 745 }
 746 
 747 unsafe extern "C" fn handle_destroy(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 748     let xwindow = crate::container_of!(listener, XwaylandWindow, destroy);
 749     handle_destroy_impl(xwindow);
 750 }
 751 
 752 unsafe fn handle_destroy_impl(xwindow: *mut XwaylandWindow) {
 753     wl_listener_remove_safe(&mut (*xwindow).destroy);
 754     wl_listener_remove_safe(&mut (*xwindow).associate);
 755     wl_listener_remove_safe(&mut (*xwindow).dissociate);
 756     wl_listener_remove_safe(&mut (*xwindow).request_configure);
 757     wl_listener_remove_safe(&mut (*xwindow).set_override_redirect);
 758     wl_listener_remove_safe(&mut (*xwindow).set_size_hints);
 759     wl_listener_remove_safe(&mut (*xwindow).set_title);
 760     wl_listener_remove_safe(&mut (*xwindow).set_class);
 761     wl_listener_remove_safe(&mut (*xwindow).set_parent);
 762     wl_listener_remove_safe(&mut (*xwindow).set_decorations);
 763     wl_listener_remove_safe(&mut (*xwindow).request_maximize);
 764     wl_listener_remove_safe(&mut (*xwindow).request_fullscreen);
 765     wl_listener_remove_safe(&mut (*xwindow).request_minimize);
 766 
 767     (*(*xwindow).xsurface).data = std::ptr::null_mut();
 768 
 769     let window = (*xwindow).window;
 770     (*window).impl_destroying();
 771 
 772     let _ = Box::from_raw(xwindow);
 773 }
 774 
 775 unsafe extern "C" fn handle_associate(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 776     let xwindow = crate::container_of!(listener, XwaylandWindow, associate);
 777     handle_associate_impl(xwindow);
 778 }
 779 
 780 unsafe fn handle_associate_impl(xwindow: *mut XwaylandWindow) {
 781     let surface = (*(*xwindow).xsurface).surface;
 782     if !surface.is_null() {
 783         connect_listener(
 784             ffi::river_wlr_surface_get_map_signal(surface),
 785             &mut (*xwindow).map,
 786             handle_map,
 787         );
 788         connect_listener(
 789             ffi::river_wlr_surface_get_unmap_signal(surface),
 790             &mut (*xwindow).unmap,
 791             handle_unmap,
 792         );
 793     }
 794 }
 795 
 796 unsafe extern "C" fn handle_dissociate(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 797     let xwindow = crate::container_of!(listener, XwaylandWindow, dissociate);
 798     handle_dissociate_impl(xwindow);
 799 }
 800 
 801 unsafe fn handle_dissociate_impl(xwindow: *mut XwaylandWindow) {
 802     wl_listener_remove_safe(&mut (*xwindow).map);
 803     wl_listener_remove_safe(&mut (*xwindow).unmap);
 804 }
 805 
 806 unsafe extern "C" fn handle_map(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 807     let xwindow = crate::container_of!(listener, XwaylandWindow, map);
 808     handle_map_impl(xwindow);
 809 }
 810 
 811 unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
 812     let surfaces_tree = (*(*xwindow).window).surfaces.tree;
 813     let surface = (*(*xwindow).xsurface).surface;
 814     let surface_tree = ffi::wlr_scene_subsurface_tree_create(surfaces_tree, surface);
 815     if surface_tree.is_null() {
 816         log::error!("out of memory creating subsurface tree");
 817         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 818         let client = ffi::wl_resource_get_client(surface_resource);
 819         ffi::wl_client_post_no_memory(client);
 820         return;
 821     }
 822     (*xwindow).surface_tree = surface_tree;
 823 
 824     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
 825 
 826     if (*(*xwindow).window).is_wine() && !has_parent && !(*(*xwindow).window).is_fullscreen() {
 827         ffi::wlr_scene_node_set_position(surface_tree as *mut ffi::wlr_scene_node, -WINE_MARGIN, -WINE_MARGIN);
 828     }
 829 
 830     ffi::river_wlr_surface_set_data(surface, &mut (*(*xwindow).window).node as *mut crate::wm_node::WmNode as *mut _);
 831 
 832     let capture_tree = &mut (*(*(*xwindow).window).capture_scene).tree as *mut ffi::wlr_scene_tree;
 833     let capture_surface = ffi::wlr_scene_surface_create(capture_tree, surface);
 834     if capture_surface.is_null() {
 835         log::error!("out of memory creating capture surface");
 836         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 837         let client = ffi::wl_resource_get_client(surface_resource);
 838         ffi::wl_client_post_no_memory(client);
 839         return;
 840     }
 841 
 842     if (*(*xwindow).xsurface).fullscreen {
 843         (*(*xwindow).window).wm_scheduled.fullscreen_requested = crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut());
 844     }
 845 
 846     place_transient_where_it_asked(xwindow);
 847     place_shy_where_it_is(xwindow);
 848     (*xwindow).apply_stored_min_size();
 849 
 850     (*(*xwindow).window).state = WindowState::Initialized;
 851     if let Err(e) = (*(*xwindow).window).map() {
 852         log::error!("out of memory mapping window: {}", e);
 853         let surface_resource = ffi::river_wlr_surface_get_resource(surface);
 854         let client = ffi::wl_resource_get_client(surface_resource);
 855         ffi::wl_client_post_no_memory(client);
 856     }
 857     (*(*(*xwindow).window).server).wm.dirty_windowing();
 858 }
 859 
 860 /// A transient that asked for a position before mapping maps there.
 861 ///
 862 /// `handle_request_configure` grants a request that arrives before the
 863 /// window is mapped verbatim, but records nothing: the window has no
 864 /// geometry yet and the arrange pass has not placed it. The grant updates
 865 /// the X surface's x/y, and nothing read them back at map, so a dialog that
 866 /// positioned itself before showing -- Qt's `move()` before `show()`, which
 867 /// is how Houdini's HC Panel centres itself on the pane it was opened over
 868 /// -- mapped at the constructor's default origin instead, a hundred pixels
 869 /// in from the desk corner. The client never asks again, since X told it
 870 /// the request was granted, so the dialog sat there for good.
 871 ///
 872 /// Only a window with a parent, and only when the client says the position
 873 /// is its own: ICCCM's `USPosition` / `PPosition` flags in WM_NORMAL_HINTS
 874 /// are what toolkits set for an explicit move before map. A transient
 875 /// without them is at whatever the X server defaulted to, and stays on the
 876 /// compositor's placement. Top-level windows keep theirs too: restore and
 877 /// the placement hints own those, and a transient is the one kind of window
 878 /// `try_restore` refuses to touch.
 879 unsafe fn place_transient_where_it_asked(xwindow: *mut XwaylandWindow) {
 880     let xsurface = (*xwindow).xsurface;
 881     if (*xsurface).parent.is_null() {
 882         return;
 883     }
 884     let Some(asked) = (*xwindow).sent_geom else {
 885         return;
 886     };
 887     let hints = (*xsurface).size_hints;
 888     if hints.is_null() {
 889         return;
 890     }
 891     let position_flags = ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_US_POSITION
 892         | ffi::xcb_icccm_size_hints_flags_t_XCB_ICCCM_SIZE_HINT_P_POSITION;
 893     if (*hints).flags & position_flags == 0 {
 894         return;
 895     }
 896 
 897     let window = (*xwindow).window;
 898     let s = x11_scale_for((*window).server, xsurface);
 899     let log_x = from_x11(asked.x as i32, s);
 900     let log_y = from_x11(asked.y as i32, s);
 901     let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
 902     (*window).virtual_x = vx;
 903     (*window).virtual_y = vy;
 904     // Placed by the client, like a picker placed by its hint: the camera
 905     // must not pan to it on spawn or first focus.
 906     (*window).hint_placed = true;
 907     log::info!(
 908         "XWayland transient mapped where it asked: title='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
 909         (*window).get_title_string().unwrap_or_default(),
 910         asked.x, asked.y, log_x, log_y, vx, vy,
 911     );
 912 }
 913 
 914 /// A shy helper window (`Window::is_shy`) maps where its app put it: the
 915 /// X window's own geometry, which Wine set from the app's CreateWindow
 916 /// position — for Ubisoft Connect's shadow window, exactly its main
 917 /// window's rect. The compositor's spawn placement would put it at the
 918 /// default origin, in front of everything, as a blank white window.
 919 unsafe fn place_shy_where_it_is(xwindow: *mut XwaylandWindow) {
 920     let window = (*xwindow).window;
 921     if !(*window).is_shy() {
 922         return;
 923     }
 924     let xsurface = (*xwindow).xsurface;
 925     let s = x11_scale_for((*window).server, xsurface);
 926     let log_x = from_x11((*xsurface).x as i32, s);
 927     let log_y = from_x11((*xsurface).y as i32, s);
 928     let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
 929     (*window).virtual_x = vx;
 930     (*window).virtual_y = vy;
 931     (*window).box_geom.x = log_x;
 932     (*window).box_geom.y = log_y;
 933     (*window).rendering_requested.x = log_x;
 934     (*window).rendering_requested.y = log_y;
 935     // Placed by the client: no spawn pan to it, ever.
 936     (*window).hint_placed = true;
 937     log::info!(
 938         "XWayland no-activate helper window mapped where it is: title='{}' class='{}' x11=({}, {}) logical=({}, {}) virtual=({:.1}, {:.1})",
 939         (*window).get_title_string().unwrap_or_default(),
 940         (*window).get_app_id_string().unwrap_or_default(),
 941         (*xsurface).x, (*xsurface).y, log_x, log_y, vx, vy,
 942     );
 943 }
 944 
 945 unsafe extern "C" fn handle_unmap(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
 946     let xwindow = crate::container_of!(listener, XwaylandWindow, unmap);
 947     handle_unmap_impl(xwindow);
 948 }
 949 
 950 unsafe fn handle_unmap_impl(xwindow: *mut XwaylandWindow) {
 951     let surface = (*(*xwindow).xsurface).surface;
 952     if !surface.is_null() {
 953         ffi::river_wlr_surface_set_data(surface, std::ptr::null_mut());
 954     }
 955     (*(*xwindow).window).unmap();
 956     if !(*xwindow).surface_tree.is_null() {
 957         ffi::wlr_scene_node_destroy((*xwindow).surface_tree as *mut ffi::wlr_scene_node);
 958         (*xwindow).surface_tree = std::ptr::null_mut();
 959     }
 960 }
 961 
 962 unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
 963     let xwindow = crate::container_of!(listener, XwaylandWindow, request_configure);
 964     let event = data as *mut ffi::wlr_xwayland_surface_configure_event;
 965 
 966     let surface = (*(*xwindow).xsurface).surface;
 967     if surface.is_null() || !ffi::river_wlr_surface_is_mapped(surface) {
 968         (*xwindow).send_configure(X11Geom {
 969             x: (*event).x,
 970             y: (*event).y,
 971             width: (*event).width,
 972             height: (*event).height,
 973         });
 974         return;
 975     }
 976 
 977     let class_ptr = (*(*xwindow).xsurface).class;
 978     let class = if class_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(class_ptr).to_str().unwrap_or("") };
 979     let title_ptr = (*(*xwindow).xsurface).title;
 980     let title = if title_ptr.is_null() { "" } else { std::ffi::CStr::from_ptr(title_ptr).to_str().unwrap_or("") };
 981     let window = (*xwindow).window;
 982     let is_wine = (*window).is_wine();
 983 
 984     let has_parent = !(*(*xwindow).xsurface).parent.is_null();
 985     let s = x11_scale_for((*window).server, (*xwindow).xsurface);
 986     log::info!(
 987         "XWayland configure request: title='{}' class='{}' has_parent={} is_wine={} event=({}, {}, {}, {}) xsurface=({}, {}, {}, {})",
 988         title,
 989         class,
 990         has_parent,
 991         is_wine,
 992         (*event).x, (*event).y, (*event).width, (*event).height,
 993         (*(*xwindow).xsurface).x, (*(*xwindow).xsurface).y, (*(*xwindow).xsurface).width, (*(*xwindow).xsurface).height,
 994     );
 995 
 996     let is_tiled = unsafe {
 997         (*window).wm_requested.tiled != 0 || !matches!((*window).tiling_mode, crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Popup | crate::tiling::TilingMode::Utility)
 998     };
 999     let is_fullscreen = unsafe { (*window).is_fullscreen() };
1000 
1001     // A window named in `xwayland_hidpi_except` is a full-screen X11 game
1002     // that sizes AND places itself to the screen (Trackmania's
1003     // "windowedfull" asks for (0, 0) at the desktop size). Refusing the
1004     // position — answering every request with the compositor's placement —
1005     // had Wine re-asking ~170 times a second for as long as the window was
1006     // up. It gets the parented treatment: position and size granted, the
1007     // virtual origin moved with it. Not while the compositor has it
1008     // fullscreen or tiled: then the size is the compositor's (below).
1009     let exempt_self_placed = !has_parent && !is_fullscreen && !is_tiled && window_is_hidpi_exempt(window);
1010     // A shy helper window (`Window::is_shy`) is placed by its app, which
1011     // moves it to track its main window: position and size granted.
1012     let shy_self_placed = !has_parent && !is_fullscreen && !is_tiled && (*window).is_shy();
1013 
1014     if has_parent || exempt_self_placed || shy_self_placed {
1015         // Granted on the logical grid rather than verbatim — see `snap_x11`.
1016         // The logical values below are what the window's geometry becomes, so
1017         // handing X anything else is handing it a number this compositor
1018         // cannot reproduce.
1019         let (mut ex, mut ey, mut ew, mut eh) =
1020             ((*event).x as i32, (*event).y as i32, (*event).width as i32, (*event).height as i32);
1021         if exempt_self_placed {
1022             // The game SEES the physical-pixel root and asks for all of it
1023             // (Trackmania's windowedfull: 3840x2160 at (0, 0)), but its
1024             // pixels are logical here — granted verbatim that is a window
1025             // twice the screen, which the arrange pass then keeps pulling
1026             // back on-desk while the game keeps asking, ~60 requests a
1027             // second. So the request is answered with at most the output's
1028             // logical box, kept on that output: the whole root becomes the
1029             // whole screen, which is what the game meant.
1030             let out = (*window).fullscreen_output();
1031             if !out.is_null() {
1032                 let ob = (*out).sent.box_layout();
1033                 let (ox, oy, ow, oh) = (ob.x, ob.y, ob.width, ob.height);
1034                 ew = from_x11(ew, s).min(ow).max(1);
1035                 eh = from_x11(eh, s).min(oh).max(1);
1036                 ex = from_x11(ex, s).clamp(ox, (ox + ow - ew).max(ox));
1037                 ey = from_x11(ey, s).clamp(oy, (oy + oh - eh).max(oy));
1038                 ex = to_x11(ex, s);
1039                 ey = to_x11(ey, s);
1040                 ew = to_x11(ew, s);
1041                 eh = to_x11(eh, s);
1042                 if (ex, ey, ew, eh) != ((*event).x as i32, (*event).y as i32, (*event).width as i32, (*event).height as i32) {
1043                     log::info!(
1044                         "XWayland configure request: '{}' is hidpi-exempt; ({}, {}, {}x{}) clamped to the output's logical box as ({}, {}, {}x{})",
1045                         title, (*event).x, (*event).y, (*event).width, (*event).height, ex, ey, ew, eh,
1046                     );
1047                 }
1048             }
1049         }
1050         (*xwindow).send_configure(X11Geom {
1051             x: snap_x11(ex, s) as i16,
1052             y: snap_x11(ey, s) as i16,
1053             width: snap_x11(ew, s) as u16,
1054             height: snap_x11(eh, s) as u16,
1055         });
1056         let log_x = from_x11(ex, s);
1057         let log_y = from_x11(ey, s);
1058         let log_width = from_x11(ew, s) as u32;
1059         let log_height = from_x11(eh, s) as u32;
1060         
1061         (*window).box_geom.x = log_x;
1062         (*window).box_geom.y = log_y;
1063         (*window).box_geom.width = log_width as i32;
1064         (*window).box_geom.height = log_height as i32;
1065         (*window).rendering_requested.x = log_x;
1066         (*window).rendering_requested.y = log_y;
1067         (*window).rendering_sent.width = log_width;
1068         (*window).rendering_sent.height = log_height;
1069         // The screen origin above is only half the move: the arrange pass
1070         // places a floating window from its VIRTUAL origin, so leaving that
1071         // stale meant the very next transaction recomputed the window back
1072         // to where it was. An X11 client reads that as its move being
1073         // refused and asks again from the position it was pushed to, which
1074         // is a runaway: Houdini's Edit Theme dialog walked 270px left across
1075         // one tab switch, re-requesting 15 times in a second and never
1076         // converging on a size either.
1077         let (vx, vy) = (*window).screen_to_virtual(log_x, log_y);
1078         (*window).virtual_x = vx;
1079         (*window).virtual_y = vy;
1080         if exempt_self_placed || shy_self_placed {
1081             // Placed by the client: the camera must not pan to it on spawn.
1082             (*window).hint_placed = true;
1083         }
1084         (*window).set_dimensions(log_width, log_height);
1085         return;
1086     }
1087 
1088     // A floating window normally gets the size it asks for; not while an
1089     // output is coming or going (see `note_output_change`), and not while
1090     // the compositor has it FULLSCREEN: Wine syncs a window's
1091     // _NET_WM_STATE from its own idea of the window rect, so a game whose
1092     // fixed-size hints were granted here shrank the X window back the
1093     // instant the fullscreen configure went out, then withdrew the
1094     // fullscreen state Wine no longer saw as true — every Fullscreen press
1095     // on Trackmania undid itself within the same frame. Held at the
1096     // fullscreen size, Wine sees a screen-sized rect and keeps the state.
1097     // An app with a minimum size refuses a smaller configure by asking for
1098     // its minimum back, and Wine carries no minimum for a resizable window
1099     // into WM_NORMAL_HINTS (only a fixed-size one's min == max). Granted as
1100     // a request, every step of a shrinking drag was answered with the old
1101     // size — Ubisoft Connect fought the pointer at 1214x804 (2026-09-26).
1102     // So a refusal during an interactive resize is learned as the minimum,
1103     // and the drag clamps there (`DimensionsHint::clamp` in the seat op).
1104     // Stored across restarts (`min_sizes`). And a minimum the app later
1105     // goes below on its own, outside a drag, was set too high — or the app
1106     // lowered it — so it follows the app down.
1107     {
1108         let (req_w, req_h) = (from_x11((*event).width as i32, s) as u32, from_x11((*event).height as i32, s) as u32);
1109         let hint = (*window).wm_scheduled.dimensions_hint;
1110         let (min_w, min_h) = if (*window).wm_requested.resizing {
1111             let axis = |i: usize, sent: Option<u32>, req: u32, known: u32| {
1112                 let sent = sent?;
1113                 let (learned, prev) = learned_min((*xwindow).refusals[i], sent, req, known);
1114                 (*xwindow).refusals[i] = prev;
1115                 learned
1116             };
1117             (
1118                 axis(0, (*window).configure_sent.width, req_w, hint.min_width),
1119                 axis(1, (*window).configure_sent.height, req_h, hint.min_height),
1120             )
1121         } else {
1122             (
1123                 (hint.min_width > 0 && req_w < hint.min_width).then_some(req_w),
1124                 (hint.min_height > 0 && req_h < hint.min_height).then_some(req_h),
1125             )
1126         };
1127         if min_w.is_some() || min_h.is_some() {
1128             let learned = crate::window::DimensionsHint {
1129                 min_width: min_w.unwrap_or(hint.min_width),
1130                 min_height: min_h.unwrap_or(hint.min_height),
1131                 ..hint
1132             };
1133             log::info!(
1134                 "XWayland configure request: '{}' {}; minimum now {}x{}",
1135                 title,
1136                 if (*window).wm_requested.resizing { "refused a smaller size" } else { "went below its minimum" },
1137                 learned.min_width,
1138                 learned.min_height,
1139             );
1140             (*window).set_dimensions_hint(learned);
1141             (*xwindow).store_min_size(learned.min_width, learned.min_height, s);
1142         }
1143     }
1144 
1145     let hold_size = is_tiled || is_fullscreen || in_output_change_grace();
1146     if hold_size && !is_tiled {
1147         log::info!(
1148             "XWayland configure request: holding floating '{}' at its own size during output-change grace",
1149             title,
1150         );
1151     }
1152 
1153     let (phys_width, phys_height) = if hold_size {
1154         let log_w = (*window).configure_sent.width.unwrap_or((*window).box_geom.width as u32);
1155         let log_h = (*window).configure_sent.height.unwrap_or((*window).box_geom.height as u32);
1156         if log_w > 0 && log_h > 0 {
1157             let mut w = log_w;
1158             let mut h = log_h;
1159             if is_wine && !has_parent && !is_fullscreen {
1160                 w += (WINE_MARGIN * 2) as u32;
1161                 h += (WINE_MARGIN * 2) as u32;
1162             }
1163             (to_x11(w as i32, s) as u16, to_x11(h as i32, s) as u16)
1164         } else {
1165             (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
1166         }
1167     } else {
1168         // Snapped for the same reason as the parented branch above: the size
1169         // stored below is `from_x11` of what goes out here, and the next
1170         // configure sends `to_x11` of that back.
1171         (snap_x11((*event).width as i32, s) as u16, snap_x11((*event).height as i32, s) as u16)
1172     };
1173 
1174     let mut phys_x = to_x11((*window).box_geom.x, s) as i16;
1175     let mut phys_y = to_x11((*window).box_geom.y, s) as i16;
1176 
1177     if is_wine && !has_parent && !is_fullscreen {
1178         phys_x -= to_x11(WINE_MARGIN, s) as i16;
1179         phys_y -= to_x11(WINE_MARGIN, s) as i16;
1180     }
1181 
1182     (*xwindow).send_configure(X11Geom { x: phys_x, y: phys_y, width: phys_width, height: phys_height });
1183     let mut log_width = from_x11(phys_width as i32, s) as u32;
1184     let mut log_height = from_x11(phys_height as i32, s) as u32;
1185     if is_wine && !has_parent && !is_fullscreen {
1186         log_width = log_width.saturating_sub((WINE_MARGIN * 2) as u32);
1187         log_height = log_height.saturating_sub((WINE_MARGIN * 2) as u32);
1188     }
1189     (*window).set_dimensions(log_width, log_height);
1190 }
1191 
1192 unsafe extern "C" fn handle_set_override_redirect(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1193     let xwindow = crate::container_of!(listener, XwaylandWindow, set_override_redirect);
1194     let xsurface = (*xwindow).xsurface;
1195     log::info!("xwayland surface set override redirect: val={}", (*xsurface).override_redirect);
1196     assert!((*xsurface).override_redirect);
1197 
1198     let surface = (*xsurface).surface;
1199     if !surface.is_null() {
1200         if ffi::river_wlr_surface_is_mapped(surface) {
1201             handle_unmap_impl(xwindow);
1202         }
1203         handle_dissociate_impl(xwindow);
1204     }
1205     let server = (*(*xwindow).window).server;
1206     handle_destroy_impl(xwindow);
1207 
1208     if let Err(e) = XwaylandOverrideRedirect::create(xsurface, server) {
1209         log::error!("Failed to create XwaylandOverrideRedirect: {}", e);
1210     }
1211 }
1212 
1213 #[allow(dead_code)]
1214 unsafe extern "C" fn handle_set_size_hints(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1215     let xwindow = crate::container_of!(listener, XwaylandWindow, set_size_hints);
1216     let size_hints = (*(*xwindow).xsurface).size_hints;
1217     if !size_hints.is_null() {
1218         let min_width = std::cmp::max(0, (*size_hints).min_width) as u32;
1219         let min_height = std::cmp::max(0, (*size_hints).min_height) as u32;
1220         let max_width = if (*size_hints).max_width <= 0 {
1221             0
1222         } else {
1223             std::cmp::max(min_width, (*size_hints).max_width as u32)
1224         };
1225         let max_height = if (*size_hints).max_height <= 0 {
1226             0
1227         } else {
1228             std::cmp::max(min_height, (*size_hints).max_height as u32)
1229         };
1230         let hint = crate::window::DimensionsHint {
1231             min_width,
1232             max_width,
1233             min_height,
1234             max_height,
1235         };
1236         (*(*xwindow).window).set_dimensions_hint(hint);
1237     }
1238 }
1239 
1240 unsafe extern "C" fn handle_set_title(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1241     let xwindow = crate::container_of!(listener, XwaylandWindow, set_title);
1242     (*(*xwindow).window).notify_title();
1243 }
1244 
1245 unsafe extern "C" fn handle_set_class(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1246     let xwindow = crate::container_of!(listener, XwaylandWindow, set_class);
1247     (*(*xwindow).window).notify_app_id();
1248 }
1249 
1250 unsafe extern "C" fn handle_set_parent(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1251     let xwindow = crate::container_of!(listener, XwaylandWindow, set_parent);
1252     (*(*(*xwindow).window).server).wm.dirty_windowing();
1253 }
1254 
1255 unsafe extern "C" fn handle_set_decorations(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1256     let xwindow = crate::container_of!(listener, XwaylandWindow, set_decorations);
1257     let prefers_csd = ((*(*xwindow).xsurface).decorations
1258         & (ffi::wlr_xwayland_surface_decorations_WLR_XWAYLAND_SURFACE_DECORATIONS_NO_BORDER
1259             | ffi::wlr_xwayland_surface_decorations_WLR_XWAYLAND_SURFACE_DECORATIONS_NO_TITLE) as u32)
1260         != 0;
1261 
1262     let hint = if prefers_csd {
1263         ffi::zcce_window_v1_decoration_hint_ZCCE_WINDOW_V1_DECORATION_HINT_PREFERS_CSD
1264     } else {
1265         ffi::zcce_window_v1_decoration_hint_ZCCE_WINDOW_V1_DECORATION_HINT_PREFERS_SSD
1266     };
1267     (*(*xwindow).window).set_decoration_hint(hint);
1268 }
1269 
1270 unsafe extern "C" fn handle_request_maximize(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1271     let xwindow = crate::container_of!(listener, XwaylandWindow, request_maximize);
1272     let maximized = (*(*xwindow).xsurface).maximized_vert || (*(*xwindow).xsurface).maximized_horz;
1273     let window = (*xwindow).window;
1274     // Wine's echo of a tiled window's maximized state (`absorbs_wine_echo`):
1275     // the MAXIMIZED it drops while it believes itself fullscreen, and the
1276     // MAXIMIZED it adds once it has settled — which ends the echo. Neither
1277     // is the user asking for a mode.
1278     if (*xwindow).absorbs_wine_echo() || (maximized && (*xwindow).wine_max_unconfirmed) {
1279         log::info!(
1280             "XWayland maximize request: absorbed maximized={} — Wine's echo of a tiled window's maximized state",
1281             maximized,
1282         );
1283         if maximized {
1284             (*xwindow).wine_max_unconfirmed = false;
1285         } else if (*(*xwindow).xsurface).fullscreen {
1286             // The echo is complete (Wine sends the FULLSCREEN add first)
1287             // and wlroots has written it into _NET_WM_STATE: FULLSCREEN in
1288             // place of MAXIMIZED. Wine settles its rect on the tile but does
1289             // not correct the property until the window next moves, so the
1290             // compositor's state is written back here. Safe inside the
1291             // signal: wlroots compared the maximized fields before emitting.
1292             let xs = (*xwindow).xsurface;
1293             ffi::wlr_xwayland_surface_set_fullscreen(xs, false);
1294             ffi::wlr_xwayland_surface_set_maximized(xs, true, true);
1295             // Wine maximizes once per report, so the echo is over: its
1296             // MAXIMIZED add, when it comes, changes nothing wlroots would
1297             // signal, and an unmaximize from here on is the user's.
1298             (*xwindow).wine_max_unconfirmed = false;
1299         }
1300         return;
1301     }
1302     if maximized {
1303         (*window).tiling_mode = crate::tiling::TilingMode::Tiled;
1304         (*window).mode_locked = true;
1305     } else {
1306         (*window).tiling_mode = crate::tiling::TilingMode::Floating;
1307         (*window).mode_locked = true;
1308     }
1309     (*window).wm_scheduled.maximize_requested = if maximized {
1310         crate::window::MaximizeRequest::Maximize
1311     } else {
1312         crate::window::MaximizeRequest::Unmaximize
1313     };
1314     (*(*window).server).wm.dirty_windowing();
1315 }
1316 
1317 unsafe extern "C" fn handle_request_fullscreen(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
1318     let xwindow = crate::container_of!(listener, XwaylandWindow, request_fullscreen);
1319     let fullscreen = (*(*xwindow).xsurface).fullscreen;
1320     log::info!(
1321         "XWayland fullscreen request: title='{}' fullscreen={}",
1322         (*(*xwindow).window).get_title_string().unwrap_or_default(),
1323         fullscreen,
1324     );
1325     // An exempt game's pixels are logical, so the compositor's fullscreen
1326     // is a 1920x1200 X window on a 3840x2400 root. Wine syncs
1327     // _NET_WM_STATE from its own idea of the screen and withdraws
1328     // FULLSCREEN the moment it sees a window that does not cover its
1329     // root — every Fullscreen press on Trackmania was undone by this
1330     // request within the frame. The user's fullscreen stands; the key that
1331     // set it clears it.
1332     if !fullscreen
1333         && (*(*xwindow).window).is_fullscreen()
1334         && window_is_hidpi_exempt((*xwindow).window)
1335     {
1336         log::info!("XWayland fullscreen request: ignored — the window is hidpi-exempt and fullscreen by the compositor");
1337         return;
1338     }
1339     if (*xwindow).absorbs_wine_echo() {
1340         log::info!("XWayland fullscreen request: absorbed — Wine's echo of a tiled window's maximized state");
1341         return;
1342     }
1343     if fullscreen && !(*(*xwindow).window).is_fullscreen() && (*xwindow).is_wine_maximize() {
1344         // Its own maximize button, not a request for fullscreen: tiled, the
1345         // way a maximize from any other client is (`handle_request_maximize`).
1346         // FULLSCREEN is taken back out of _NET_WM_STATE; the MAXIMIZED the
1347         // tile reports then sets off Wine's echo, which is absorbed.
1348         log::info!("XWayland fullscreen request: a Wine window's own maximize — tiling it instead");
1349         let window = (*xwindow).window;
1350         ffi::wlr_xwayland_surface_set_fullscreen((*xwindow).xsurface, false);
1351         // The Win32 window is maximized already, so there is no echo to
1352         // wait for: MAXIMIZED is written here and marked sent, which keeps
1353         // `configure` from arming `wine_max_unconfirmed` for it.
1354         ffi::wlr_xwayland_surface_set_maximized((*xwindow).xsurface, true, true);
1355         (*window).configure_sent.maximized = true;
1356         (*xwindow).wine_max_unconfirmed = false;
1357         (*window).tiling_mode = crate::tiling::TilingMode::Tiled;
1358         (*window).mode_locked = true;
1359         (*window).wm_scheduled.maximize_requested = crate::window::MaximizeRequest::Maximize;
1360         (*(*window).server).wm.dirty_windowing();
1361         return;
1362     }
1363     (*(*xwindow).window).wm_scheduled.fullscreen_requested = if fullscreen {
1364         crate::window::FullscreenRequest::Fullscreen(std::ptr::null_mut())
1365     } else {
1366         crate::window::FullscreenRequest::Exit
1367     };
1368     (*(*(*xwindow).window).server).wm.dirty_windowing();
1369     (*(*(*xwindow).window).server).wm.apply_client_fullscreen((*xwindow).window, fullscreen);
1370 }
1371 
1372 unsafe extern "C" fn handle_request_minimize(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
1373     let xwindow = crate::container_of!(listener, XwaylandWindow, request_minimize);
1374     let event = data as *mut ffi::wlr_xwayland_minimize_event;
1375     ffi::wlr_xwayland_surface_set_minimized((*xwindow).xsurface, (*event).minimize);
1376     (*(*xwindow).window).wm_scheduled.minimize_requested = true;
1377     (*(*(*xwindow).window).server).wm.dirty_windowing();
1378 }
1379 
1380 #[cfg(test)]
1381 mod tests {
1382     use super::*;
1383     use std::sync::atomic::AtomicU32;
1384 
1385     #[test]
1386     fn a_refused_shrink_is_a_minimum() {
1387         // Ubisoft Connect: dragged to 1100, then 1050, asks for 1214 each
1388         // time — a floor, learned on the second.
1389         let (l, prev) = learned_min(None, 1100, 1214, 0);
1390         assert_eq!((l, prev), (None, Some((1100, 1214))));
1391         assert_eq!(learned_min(prev, 1050, 1214, 0), (Some(1214), Some((1050, 1214))));
1392         // Already known: nothing new.
1393         assert_eq!(learned_min(prev, 1050, 1214, 1214).0, None);
1394         // Rounding up to a cell: every answer differs, never a floor.
1395         let (l, prev) = learned_min(None, 803, 808, 0);
1396         assert_eq!(l, None);
1397         assert_eq!(learned_min(prev, 797, 800, 0).0, None);
1398         // The same size sent twice is one refusal repeated, not a floor.
1399         assert_eq!(learned_min(Some((1100, 1214)), 1100, 1214, 0).0, None);
1400         // Taking the size given, or a smaller one, refuses nothing and
1401         // keeps the last refusal.
1402         assert_eq!(learned_min(Some((1100, 1214)), 1214, 1214, 0), (None, Some((1100, 1214))));
1403         assert_eq!(learned_min(None, 1300, 1214, 0), (None, None));
1404     }
1405 
1406     #[test]
1407     fn a_window_smaller_than_its_stored_minimum_lowers_it() {
1408         assert_eq!(lowered_min(1400, 1200), 1200);
1409         assert_eq!(lowered_min(1400, 1600), 1400);
1410         assert_eq!(lowered_min(0, 1200), 0);
1411         assert_eq!(lowered_min(1400, 0), 1400);
1412     }
1413 
1414     #[test]
1415     fn motif_maximize_function() {
1416         // Ubisoft Connect: resize|move|minimize|maximize|close.
1417         assert!(motif_allows_maximize(&[0x3, 0x3e, 0, 0, 0]));
1418         // A WS_POPUP game: move|close.
1419         assert!(!motif_allows_maximize(&[0x3, 0x24, 0, 0, 0]));
1420         // No functions flag: nothing restricted.
1421         assert!(motif_allows_maximize(&[0x2, 0, 0, 0, 0]));
1422         // MWM_FUNC_ALL inverts the list.
1423         assert!(!motif_allows_maximize(&[0x1, 0x11, 0, 0, 0]));
1424         assert!(motif_allows_maximize(&[0x1, 0x21, 0, 0, 0]));
1425         assert!(!motif_allows_maximize(&[]));
1426     }
1427 
1428     #[test]
1429     fn wine_loader_is_recognised() {
1430         assert!(is_wine_exe("/usr/lib/wine/x86_64-unix/wine-preloader"));
1431         assert!(is_wine_exe(
1432             "/home/u/.local/share/Steam/steamapps/common/Proton - Experimental/files/lib/wine/i386-unix/wine-preloader"
1433         ));
1434         assert!(is_wine_exe("/usr/bin/wine64"));
1435         assert!(is_wine_exe("/usr/lib/wine/x86_64-unix/wine64-preloader (deleted)"));
1436         assert!(!is_wine_exe("/usr/bin/winecfg-helper"));
1437         assert!(!is_wine_exe("/usr/bin/zenity"));
1438         assert!(!is_wine_exe(""));
1439     }
1440 
1441     #[test]
1442     fn scale_from_live_output_is_remembered() {
1443         let last = AtomicU32::new(0);
1444         assert_eq!(resolve_x11_scale(Some(2.0), &last), 2.0);
1445         // Output gone (suspend): the remembered scale stands in, not 1.
1446         assert_eq!(resolve_x11_scale(None, &last), 2.0);
1447         // A new output with another scale takes over and is remembered.
1448         assert_eq!(resolve_x11_scale(Some(1.5), &last), 1.5);
1449         assert_eq!(resolve_x11_scale(None, &last), 1.5);
1450     }
1451 
1452     fn pats(list: &[&str]) -> Vec<String> {
1453         list.iter().map(|s| s.to_string()).collect()
1454     }
1455 
1456     #[test]
1457     fn exempt_matches_class_instance_or_title() {
1458         // Proton: every window is class steam_proton, so the game is told
1459         // apart by its instance (the exe) or its title.
1460         let p = pats(&["Trackmania"]);
1461         assert!(hidpi_exempt(&p, "steam_proton", "trackmania.exe", "Trackmania"));
1462         assert!(hidpi_exempt(&p, "steam_proton", "", "Trackmania"));
1463         assert!(hidpi_exempt(&p, "Trackmania", "", ""));
1464         assert!(!hidpi_exempt(&p, "steam_proton", "upc.exe", "Ubisoft Connect"));
1465         // Wildcards and case follow app_id_matches.
1466         let p = pats(&["trackmania*"]);
1467         assert!(hidpi_exempt(&p, "steam_proton", "Trackmania.exe", ""));
1468         assert!(!hidpi_exempt(&p, "steam_proton", "", "My Trackmania"));
1469     }
1470 
1471     #[test]
1472     fn exempt_ignores_empty_fields_and_lists() {
1473         assert!(!hidpi_exempt(&[], "steam_proton", "trackmania.exe", "Trackmania"));
1474         // An empty field must not match a pattern that is itself empty-ish.
1475         assert!(!hidpi_exempt(&pats(&["*"]), "", "", ""));
1476         assert!(hidpi_exempt(&pats(&["*"]), "x", "", ""));
1477     }
1478 
1479     #[test]
1480     fn scale_before_any_output_is_one() {
1481         let last = AtomicU32::new(0);
1482         assert_eq!(resolve_x11_scale(None, &last), 1.0);
1483     }
1484 
1485     #[test]
1486     fn x11_round_trip_holds_at_remembered_scale() {
1487         // The suspend case: physical 3712 read back while no output exists
1488         // must come back as logical 1856, and go out again as 3712.
1489         let last = AtomicU32::new(0);
1490         let _ = resolve_x11_scale(Some(2.0), &last);
1491         let s = resolve_x11_scale(None, &last);
1492         let logical = from_x11(3712, s);
1493         assert_eq!(logical, 1856);
1494         assert_eq!(to_x11(logical, resolve_x11_scale(Some(2.0), &last)), 3712);
1495     }
1496 
1497     #[test]
1498     fn snap_x11_is_what_the_logical_grid_can_express() {
1499         // An odd X11 coordinate at scale 2 has no logical integer, so it
1500         // moves by one; the point is that it then STAYS there. Granting the
1501         // raw value instead is what let Houdini's dialog gain a pixel per
1502         // request: 181 -> 91 -> 182 -> 91 -> 182 ...
1503         assert_eq!(from_x11(181, 2.0), 91);
1504         assert_eq!(to_x11(91, 2.0), 182);
1505         assert_eq!(snap_x11(181, 2.0), 182);
1506 
1507         // Idempotent: snapping a snapped value is a no-op, which is what
1508         // makes repeated configures converge instead of drifting.
1509         for x in [-91, -90, -1, 0, 1, 180, 181, 757, 1300, 3712] {
1510             let once = snap_x11(x, 2.0);
1511             assert_eq!(snap_x11(once, 2.0), once, "not idempotent at x={x}");
1512         }
1513 
1514         // Even values — and every value at scale 1 — are untouched, so
1515         // nothing outside xwayland_hidpi changes.
1516         for x in [-90, 0, 180, 720, 1360, 3712] {
1517             assert_eq!(snap_x11(x, 2.0), x, "even value moved at x={x}");
1518         }
1519         for x in [-91, -1, 0, 1, 181, 757, 1301] {
1520             assert_eq!(snap_x11(x, 1.0), x, "scale 1 moved at x={x}");
1521         }
1522     }
1523 
1524     #[test]
1525     fn configure_is_sent_until_it_has_actually_been_sent_once() {
1526         let g = |x, y, w, h| X11Geom { x, y, width: w, height: h };
1527         let fullscreen = g(0, 0, 1280, 720);
1528         // The restore case: the mirror already says 1280x720 (pre-written by
1529         // try_restore) but nothing was ever sent — X is at its natural size.
1530         assert!(needs_configure(fullscreen, fullscreen, None));
1531         // Something else was sent (the client's own pre-map request).
1532         assert!(needs_configure(fullscreen, fullscreen, Some(g(0, 0, 103, 36))));
1533         // Sent once and X reports it: nothing to do.
1534         assert!(!needs_configure(fullscreen, fullscreen, Some(fullscreen)));
1535         // X moved or resized itself since: the mirror disagrees, resend.
1536         assert!(needs_configure(fullscreen, g(10, 10, 1280, 720), Some(fullscreen)));
1537         assert!(needs_configure(fullscreen, g(0, 0, 640, 360), Some(fullscreen)));
1538         // A different wanted geometry always goes out.
1539         assert!(needs_configure(g(0, 0, 640, 360), fullscreen, Some(fullscreen)));
1540     }
1541 
1542     #[test]
1543     fn output_change_grace_begins_and_is_re_armed() {
1544         note_output_change();
1545         assert!(in_output_change_grace());
1546         // Expire it by hand, then a second change re-arms it.
1547         *OUTPUT_CHANGE_GRACE_UNTIL.lock().unwrap() =
1548             Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
1549         assert!(!in_output_change_grace());
1550         note_output_change();
1551         assert!(in_output_change_grace());
1552     }
1553 }