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 }