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

commitec78eeeacf936db6b6b57c0c7f06e00303fd3702
parent0f95582397
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 18:37
feat(xwayland): remember learned minimum sizes across restarts

A minimum learned from an X11 app's refusal lasted one launch, so every
first drag past Ubisoft Connect's 1214x804 snapped back again. It is now
stored in $XDG_STATE_HOME/cce/min-sizes.json (min_sizes.rs), keyed by
app_id, program (argv[0]) and title, in X11 pixels so a scale change
still converts, and applied as the window's dimensions hint at map.

A stored value is permanent, so learning is stricter: one refusal no
longer counts, since an app rounding its size up to cells also answers
"bigger". `learned_min` wants a floor — two different sent sizes
answered with the same one. And a stored minimum follows the app down:
lowered when the window maps smaller than it, or asks for less outside
a drag.

Verified in a shadow with a Win32 test app reporting a 1000x700
minimum: first drag learns it (2 refusals), then 0 refusals after an
app relaunch and after a compositor restart; a seeded too-large entry
is lowered at map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                     |   7 +++
 src/lib.rs                    |   2 +
 src/server/min_sizes.rs       | 131 +++++++++++++++++++++++++++++++++++++++
 src/server/server.rs          |   1 +
 src/server/window_manager.rs  |   2 +
 src/server/xwayland_window.rs | 140 +++++++++++++++++++++++++++++++++++++-----
 6 files changed, 267 insertions(+), 16 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index d5a67bb1..925e7dc4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -697,6 +697,13 @@ command, which `/bin/sh` cannot run, so the restore never relaunches them
 2026-09-26 Ubisoft Connect's plate stood a minute over the empty desk,
 waiting for a window nothing had started. The entry stays queued, so the app
 still lands on its saved spot when the user launches it.
+Beside it, **`min-sizes.json`** (`min_sizes.rs`) keeps the minimum sizes
+X11 apps revealed by refusing a smaller configure mid-drag — Wine sends no
+minimum for a resizable window, so Ubisoft Connect fought every shrink past
+1214x804. Only a floor counts (two different sizes answered with the same
+one, `xwayland_window::learned_min`), since a stored value is permanent;
+it is keyed by app_id, program and title, kept in X11 pixels, applied at
+map, and lowered when a window maps smaller than it.
 A restored **floating** window is recalled into the current view
 (`policy::camera::recalled_origin`, applied at the end of `try_restore`)
 when its remembered position would show less than a quarter of it: the
diff --git a/src/lib.rs b/src/lib.rs
index a6510d73..3a072531 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -91,6 +91,8 @@ pub mod shell_surface;
 pub mod wm_node;
 #[path = "server/xwayland_window.rs"]
 pub mod xwayland_window;
+#[path = "server/min_sizes.rs"]
+pub mod min_sizes;
 #[path = "server/xwayland_override_redirect.rs"]
 pub mod xwayland_override_redirect;
 #[path = "server/text_input.rs"]
diff --git a/src/server/min_sizes.rs b/src/server/min_sizes.rs
new file mode 100644
index 00000000..baee100f
--- /dev/null
+++ b/src/server/min_sizes.rs
@@ -0,0 +1,131 @@
+//! Minimum sizes X11 apps have revealed, kept across restarts.
+//!
+//! Wine carries no minimum into WM_NORMAL_HINTS for a resizable window, so
+//! the compositor learns one from a refusal: during an interactive resize,
+//! an app asking for more than it was just given on an axis would not go
+//! that small (`xwayland_window::learned_min`). Learned once per launch,
+//! every first drag past the minimum snapped back once, so the minimum is
+//! stored in `$XDG_STATE_HOME/cce/min-sizes.json` and handed to the window
+//! again when it maps.
+//!
+//! An entry is keyed by app_id, program (argv[0] — every Proton window is
+//! `steam_proton`, its program is the exe) and title, since one program's
+//! windows have their own minimums. Sizes are X11 pixels, what the app
+//! itself measured, so a change of output scale still converts correctly.
+//! A width or height of 0 is "not learned".
+
+#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
+pub struct MinSize {
+    pub app_id: String,
+    pub program: String,
+    pub title: String,
+    pub width: u32,
+    pub height: u32,
+}
+
+#[derive(Default)]
+pub struct MinSizes {
+    entries: Vec<MinSize>,
+    loaded: bool,
+}
+
+fn file_path() -> Option<std::path::PathBuf> {
+    crate::config::default_state_path().map(|p| std::path::Path::new(&p).with_file_name("min-sizes.json"))
+}
+
+impl MinSizes {
+    fn ensure_loaded(&mut self) {
+        if self.loaded {
+            return;
+        }
+        self.loaded = true;
+        let Some(path) = file_path() else { return };
+        let Ok(json) = std::fs::read_to_string(&path) else { return };
+        match serde_json::from_str(&json) {
+            Ok(entries) => self.entries = entries,
+            Err(e) => log::warn!("min-sizes: ignoring unreadable {}: {}", path.display(), e),
+        }
+    }
+
+    /// The stored minimum for this window, X11 pixels.
+    pub fn get(&mut self, app_id: &str, program: &str, title: &str) -> Option<(u32, u32)> {
+        self.ensure_loaded();
+        self.entries
+            .iter()
+            .find(|e| e.app_id == app_id && e.program == program && e.title == title)
+            .map(|e| (e.width, e.height))
+    }
+
+    /// Store this window's minimum, X11 pixels; written to disk at once
+    /// (it changes only on a refusal, a handful of times per app, ever).
+    /// 0x0 forgets the entry.
+    pub fn set(&mut self, app_id: &str, program: &str, title: &str, width: u32, height: u32) {
+        self.ensure_loaded();
+        if !update(&mut self.entries, app_id, program, title, width, height) {
+            return;
+        }
+        let Some(path) = file_path() else { return };
+        if let Some(parent) = path.parent() {
+            let _ = std::fs::create_dir_all(parent);
+        }
+        match serde_json::to_string_pretty(&self.entries) {
+            Ok(json) => {
+                if let Err(e) = std::fs::write(&path, json) {
+                    log::error!("min-sizes: failed to write {}: {}", path.display(), e);
+                }
+            }
+            Err(e) => log::error!("min-sizes: failed to serialize: {}", e),
+        }
+    }
+}
+
+/// Apply one entry's new size to the table; whether anything changed.
+fn update(entries: &mut Vec<MinSize>, app_id: &str, program: &str, title: &str, width: u32, height: u32) -> bool {
+    let pos = entries.iter().position(|e| e.app_id == app_id && e.program == program && e.title == title);
+    match (pos, width == 0 && height == 0) {
+        (Some(i), true) => {
+            entries.remove(i);
+            true
+        }
+        (Some(i), false) => {
+            let e = &mut entries[i];
+            let changed = (e.width, e.height) != (width, height);
+            e.width = width;
+            e.height = height;
+            changed
+        }
+        (None, true) => false,
+        (None, false) => {
+            entries.push(MinSize {
+                app_id: app_id.to_string(),
+                program: program.to_string(),
+                title: title.to_string(),
+                width,
+                height,
+            });
+            true
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const UPC: &str = r"C:\Program Files (x86)\Ubisoft\Ubisoft Game Launcher\upc.exe";
+
+    #[test]
+    fn entries_are_added_changed_and_forgotten() {
+        let mut t = Vec::new();
+        assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 0));
+        assert!(!update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 0));
+        assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 2428, 1608));
+        // Another window of the same program is its own entry.
+        assert!(update(&mut t, "steam_proton", UPC, "Settings", 800, 600));
+        assert_eq!(t.len(), 2);
+        assert_eq!((t[0].width, t[0].height), (2428, 1608));
+        assert!(update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 0, 0));
+        assert!(!update(&mut t, "steam_proton", UPC, "Ubisoft Connect", 0, 0));
+        assert_eq!(t.len(), 1);
+    }
+}
diff --git a/src/server/server.rs b/src/server/server.rs
index ac2eafda..c5f9205a 100644
--- a/src/server/server.rs
+++ b/src/server/server.rs
@@ -1020,6 +1020,7 @@ impl Default for Server {
             // explicitly rather than left to the zeroed MaybeUninit.
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.status_backdrops, std::cell::RefCell::new(Vec::new()));
             std::ptr::write(&mut (*server.as_mut_ptr()).wm.last_saved_state_json, None);
+            std::ptr::write(&mut (*server.as_mut_ptr()).wm.min_sizes, crate::min_sizes::MinSizes::default());
             std::ptr::write(&mut (*server.as_mut_ptr()).layer_shell.surfaces, crate::slotmap::SlotMap::new());
             std::ptr::write(&mut (*server.as_mut_ptr()).inspector, crate::inspector::Inspector::new());
             std::ptr::write(&mut (*server.as_mut_ptr()).cce_window_management, crate::cce_window_management::CceWindowManagement::new());
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index f0801de4..bd0b4a1e 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -451,6 +451,8 @@ pub struct WindowManager {
     /// before while making an idle session silent on disk. `None` until the
     /// first write, so a fresh start always writes once.
     pub last_saved_state_json: Option<String>,
+    /// X11 apps' learned minimum sizes, persisted in `min-sizes.json`.
+    pub min_sizes: crate::min_sizes::MinSizes,
     /// One-shot placement hints (`place-next <app_id> <x> <y>` over IPC):
     /// the next map of a floating toplevel with this app_id lands near the
     /// given layout position instead of its remembered spot — widget-spawned
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index 3ebb3e26..0eacd480 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -46,6 +46,11 @@ pub struct XwaylandWindow {
     /// When this compositor last configured the X window or its maximized
     /// state: Wine's reaction to either arrives within moments of it.
     pub last_configure: Option<std::time::Instant>,
+
+    /// The last refusal of a smaller size seen per axis (width, height),
+    /// as `(sent, requested)` logical — what `learned_min` compares the
+    /// next one against to tell a floor from rounding.
+    pub refusals: [Option<(u32, u32)>; 2],
 }
 
 /// A window geometry in X11 root coordinates — physical pixels under
@@ -304,11 +309,29 @@ pub fn is_wine_exe(exe: &str) -> bool {
 }
 
 /// The minimum an X11 client revealed along one axis by asking for
-/// `requested` right after being configured to `sent`: larger than it was
-/// given means it would not go that small. `None` when that says nothing
-/// new (not a refusal, or no larger than the minimum already known).
-pub fn learned_min(sent: u32, requested: u32, known_min: u32) -> Option<u32> {
-    (requested > sent && requested > known_min).then_some(requested)
+/// `requested` right after being configured to `sent` — larger than it was
+/// given means it would not go that small — and the refusal to remember
+/// for the next call. `prev` is the last refusal seen, `(sent, requested)`.
+///
+/// One refusal is not enough: an app that rounds its size up (to character
+/// cells, to an aspect) answers "bigger" too, and a minimum learned from
+/// that is stored for good (`min_sizes`) and blocks every smaller size.
+/// A true minimum is a FLOOR — two different sent sizes answered with the
+/// same one (Ubisoft Connect asked for 2428 at every step of a drag).
+/// `None` when that says nothing new, including a floor no larger than
+/// the minimum already known.
+pub fn learned_min(prev: Option<(u32, u32)>, sent: u32, requested: u32, known_min: u32) -> (Option<u32>, Option<(u32, u32)>) {
+    if requested <= sent {
+        return (None, prev);
+    }
+    let floor = matches!(prev, Some((prev_sent, prev_req)) if prev_req == requested && prev_sent != sent);
+    ((floor && requested > known_min).then_some(requested), Some((sent, requested)))
+}
+
+/// A stored minimum (0 = none) checked against the size a window actually
+/// has: a smaller real size is the new minimum.
+pub fn lowered_min(stored: u32, actual: u32) -> u32 {
+    if stored > 0 && actual > 0 && actual < stored { actual } else { stored }
 }
 
 /// Whether `_MOTIF_WM_HINTS` (flags, functions, decorations, …) offer the
@@ -400,6 +423,7 @@ impl XwaylandWindow {
             wine_process: None,
             wine_max_unconfirmed: false,
             last_configure: None,
+            refusals: [None, None],
         });
 
         let raw = Box::into_raw(xwindow);
@@ -539,6 +563,49 @@ impl XwaylandWindow {
         wine
     }
 
+    /// This window's key in `min_sizes`: app_id, program (argv[0]) and
+    /// title. `None` while any is unknown — a window with no readable
+    /// process cannot be told from another program with its app_id.
+    unsafe fn min_size_key(&self) -> Option<(String, String, String)> {
+        let app_id = (*self.window).get_app_id_string()?;
+        let program = crate::window_manager::proc_args((*self.xsurface).pid).into_iter().next()?;
+        let title = (*self.window).get_title_string().unwrap_or_default();
+        Some((app_id, program, title))
+    }
+
+    /// Persist a learned minimum (logical `width`x`height` at X11 scale `s`).
+    unsafe fn store_min_size(&self, width: u32, height: u32, s: f32) {
+        let Some((app_id, program, title)) = self.min_size_key() else { return };
+        let (w, h) = (to_x11(width as i32, s) as u32, to_x11(height as i32, s) as u32);
+        (*(*self.window).server).wm.min_sizes.set(&app_id, &program, &title, w, h);
+    }
+
+    /// Hand the window the minimum a previous run learned, before its first
+    /// arrange, so the first drag past it already stops there.
+    unsafe fn apply_stored_min_size(&self) {
+        let Some((app_id, program, title)) = self.min_size_key() else { return };
+        let Some((stored_w, stored_h)) = (*(*self.window).server).wm.min_sizes.get(&app_id, &program, &title) else { return };
+        // Mapping at a size below the stored minimum proves the app takes
+        // it: the minimum was learned too high, or the app lowered it. Kept
+        // unchecked, a stale minimum outlives every restart.
+        let (w, h) = (
+            lowered_min(stored_w, (*self.xsurface).width as u32),
+            lowered_min(stored_h, (*self.xsurface).height as u32),
+        );
+        if (w, h) != (stored_w, stored_h) {
+            log::info!("XWayland map: '{}' maps below its stored minimum; lowered to {}x{} (X11 px)", title, w, h);
+            (*(*self.window).server).wm.min_sizes.set(&app_id, &program, &title, w, h);
+        }
+        let s = x11_scale_for((*self.window).server, self.xsurface);
+        let hint = crate::window::DimensionsHint {
+            min_width: if w > 0 { from_x11(w as i32, s) as u32 } else { 0 },
+            min_height: if h > 0 { from_x11(h as i32, s) as u32 } else { 0 },
+            ..(*self.window).wm_scheduled.dimensions_hint
+        };
+        log::info!("XWayland map: '{}' has a stored minimum of {}x{}", title, hint.min_width, hint.min_height);
+        (*self.window).set_dimensions_hint(hint);
+    }
+
     /// Whether a `_NET_WM_STATE` request from this window is Wine's echo
     /// of being told it is maximized, to be absorbed rather than acted on.
     ///
@@ -755,6 +822,7 @@ unsafe fn handle_map_impl(xwindow: *mut XwaylandWindow) {
 
     place_transient_where_it_asked(xwindow);
     place_shy_where_it_is(xwindow);
+    (*xwindow).apply_stored_min_size();
 
     (*(*xwindow).window).state = WindowState::Initialized;
     if let Err(e) = (*(*xwindow).window).map() {
@@ -1010,11 +1078,29 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
     // size — Ubisoft Connect fought the pointer at 1214x804 (2026-09-26).
     // So a refusal during an interactive resize is learned as the minimum,
     // and the drag clamps there (`DimensionsHint::clamp` in the seat op).
-    if (*window).wm_requested.resizing {
+    // Stored across restarts (`min_sizes`). And a minimum the app later
+    // goes below on its own, outside a drag, was set too high — or the app
+    // lowered it — so it follows the app down.
+    {
         let (req_w, req_h) = (from_x11((*event).width as i32, s) as u32, from_x11((*event).height as i32, s) as u32);
         let hint = (*window).wm_scheduled.dimensions_hint;
-        let min_w = (*window).configure_sent.width.and_then(|sent| learned_min(sent, req_w, hint.min_width));
-        let min_h = (*window).configure_sent.height.and_then(|sent| learned_min(sent, req_h, hint.min_height));
+        let (min_w, min_h) = if (*window).wm_requested.resizing {
+            let axis = |i: usize, sent: Option<u32>, req: u32, known: u32| {
+                let sent = sent?;
+                let (learned, prev) = learned_min((*xwindow).refusals[i], sent, req, known);
+                (*xwindow).refusals[i] = prev;
+                learned
+            };
+            (
+                axis(0, (*window).configure_sent.width, req_w, hint.min_width),
+                axis(1, (*window).configure_sent.height, req_h, hint.min_height),
+            )
+        } else {
+            (
+                (hint.min_width > 0 && req_w < hint.min_width).then_some(req_w),
+                (hint.min_height > 0 && req_h < hint.min_height).then_some(req_h),
+            )
+        };
         if min_w.is_some() || min_h.is_some() {
             let learned = crate::window::DimensionsHint {
                 min_width: min_w.unwrap_or(hint.min_width),
@@ -1022,10 +1108,14 @@ unsafe extern "C" fn handle_request_configure(listener: *mut ffi::wl_listener, d
                 ..hint
             };
             log::info!(
-                "XWayland configure request: '{}' refused a smaller size; minimum learned as {}x{}",
-                title, learned.min_width, learned.min_height,
+                "XWayland configure request: '{}' {}; minimum now {}x{}",
+                title,
+                if (*window).wm_requested.resizing { "refused a smaller size" } else { "went below its minimum" },
+                learned.min_width,
+                learned.min_height,
             );
             (*window).set_dimensions_hint(learned);
+            (*xwindow).store_min_size(learned.min_width, learned.min_height, s);
         }
     }
 
@@ -1271,13 +1361,31 @@ mod tests {
 
     #[test]
     fn a_refused_shrink_is_a_minimum() {
-        // Ubisoft Connect: dragged to 1100 wide, asks for 1214 back.
-        assert_eq!(learned_min(1100, 1214, 0), Some(1214));
+        // Ubisoft Connect: dragged to 1100, then 1050, asks for 1214 each
+        // time — a floor, learned on the second.
+        let (l, prev) = learned_min(None, 1100, 1214, 0);
+        assert_eq!((l, prev), (None, Some((1100, 1214))));
+        assert_eq!(learned_min(prev, 1050, 1214, 0), (Some(1214), Some((1050, 1214))));
         // Already known: nothing new.
-        assert_eq!(learned_min(1100, 1214, 1214), None);
-        // Taking the size given, or a smaller one, refuses nothing.
-        assert_eq!(learned_min(1214, 1214, 0), None);
-        assert_eq!(learned_min(1300, 1214, 0), None);
+        assert_eq!(learned_min(prev, 1050, 1214, 1214).0, None);
+        // Rounding up to a cell: every answer differs, never a floor.
+        let (l, prev) = learned_min(None, 803, 808, 0);
+        assert_eq!(l, None);
+        assert_eq!(learned_min(prev, 797, 800, 0).0, None);
+        // The same size sent twice is one refusal repeated, not a floor.
+        assert_eq!(learned_min(Some((1100, 1214)), 1100, 1214, 0).0, None);
+        // Taking the size given, or a smaller one, refuses nothing and
+        // keeps the last refusal.
+        assert_eq!(learned_min(Some((1100, 1214)), 1214, 1214, 0), (None, Some((1100, 1214))));
+        assert_eq!(learned_min(None, 1300, 1214, 0), (None, None));
+    }
+
+    #[test]
+    fn a_window_smaller_than_its_stored_minimum_lowers_it() {
+        assert_eq!(lowered_min(1400, 1200), 1200);
+        assert_eq!(lowered_min(1400, 1600), 1400);
+        assert_eq!(lowered_min(0, 1200), 0);
+        assert_eq!(lowered_min(1400, 0), 1400);
     }
 
     #[test]