Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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]