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

commit505bf8e52b563531851c7d69e5d968b94b38593e
parentfa7be27448
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 18:00
feat(ipc): min-size lists and forgets learned X11 minimums

`ccectl min-size [list]` prints the stored minimums (X11 px), numbered;
`min-size forget <app_id|id>` drops an open X11 window's entry and the
minimum it is held to now, so it takes a smaller size at once and is
relearned on the next refusal; `min-size forget-entry <n>` drops an
entry for an app that is not running.

The table is read once and held in memory, so editing min-sizes.json
under a running compositor did nothing, and a minimum learned too high
(or one an app's DPI change lowered) could only be cleared by restarting
the session.

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

 CLAUDE.md                     |  9 +++++++-
 src/cce_ctl.rs                |  3 +++
 src/server/min_sizes.rs       |  6 ++++++
 src/server/window_manager.rs  | 49 +++++++++++++++++++++++++++++++++++++++++++
 src/server/xwayland_window.rs | 21 +++++++++++++++++++
 5 files changed, 87 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index af4c9eb8..ddf3e729 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -882,7 +882,14 @@ 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.
+map, and lowered when a window maps smaller than it. The table is read once
+and held in memory, so editing the file under a running compositor does
+nothing (the next learned value rewrites it): `ccectl min-size list` shows
+it, `min-size forget <app_id|id>` drops an open window's entry and the
+minimum it is held to, and `forget-entry <n>` drops one for an app that is
+not running. A minimum the app's DPI decided (Ubisoft Connect: ~1214x689
+DIPs, so 2428x1378 X11 px at Wine's default 192 from `Xft.dpi`) is lowered
+in the prefix's `Control Panel\Desktop` `LogPixels`, not here.
 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/cce_ctl.rs b/src/cce_ctl.rs
index d5e589d2..21a8da65 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -57,6 +57,9 @@ fn usage(name: &str, to_stderr: bool) {
     print("  move-window <square> [<app_id>] # put focused/named window on a desktop square (e.g. C-9)");
     print("  resize-window <w> <h> [<app_id|id>] # set focused/named window's size (logical px),");
     print("                              # top-left fixed, clamped to its min/max hints");
+    print("  min-size [list]            # X11 minimum sizes learned from refusals (X11 px), numbered");
+    print("  min-size forget <app_id|id> # drop an open X11 window's learned minimum (relearned on refusal)");
+    print("  min-size forget-entry <n>  # drop stored entry n from `min-size list`");
     print("  move-window-left|-right|-up|-down    # step the focused window one grid cell;");
     print("                              # tiled swaps with the tiled window already there,");
     print("                              # floating just moves");
diff --git a/src/server/min_sizes.rs b/src/server/min_sizes.rs
index baee100f..e8509664 100644
--- a/src/server/min_sizes.rs
+++ b/src/server/min_sizes.rs
@@ -56,6 +56,12 @@ impl MinSizes {
             .map(|e| (e.width, e.height))
     }
 
+    /// Every stored entry, in file order (`ccectl min-size list`).
+    pub fn entries(&mut self) -> &[MinSize] {
+        self.ensure_loaded();
+        &self.entries
+    }
+
     /// 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.
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a9c4ccee..819a5121 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -6000,6 +6000,55 @@ impl WindowManager {
                 self.dirty_windowing();
                 format!("ok w={} h={}\n", w, h)
             }
+            "min-size" => {
+                // The minimum sizes X11 apps revealed by refusing a smaller
+                // configure (`min_sizes`). `list` numbers the stored entries;
+                // `forget <app_id|id>` drops an open window's entry and the
+                // minimum it is held to now; `forget-entry <n>` drops one by
+                // its `list` number, for an app that is not running. A
+                // forgotten minimum is learned again on the next drag the
+                // app refuses.
+                match parts.get(1).copied().unwrap_or("list") {
+                    "list" => {
+                        let mut out = String::new();
+                        for (i, e) in self.min_sizes.entries().iter().enumerate() {
+                            out.push_str(&format!(
+                                "{} {}x{} app_id={} title={:?} program={:?}\n",
+                                i, e.width, e.height, e.app_id, e.title, e.program
+                            ));
+                        }
+                        if out.is_empty() { "none\n".to_string() } else { out }
+                    }
+                    "forget" if parts.len() >= 3 => {
+                        let target = self.find_window_by_query(&parts[2..].join(" "));
+                        if target.is_null() {
+                            return "error: window not found\n".to_string();
+                        }
+                        let crate::window::WindowImpl::Xwayland(xw) = (*target).impl_type else {
+                            return "error: not an X11 window; only those have a learned minimum\n".to_string();
+                        };
+                        if xw.is_null() {
+                            return "error: window not found\n".to_string();
+                        }
+                        match (*xw).forget_min_size() {
+                            Some((w, h)) => format!("ok forgot {}x{}\n", w, h),
+                            None => "ok none stored\n".to_string(),
+                        }
+                    }
+                    "forget-entry" if parts.len() == 3 => {
+                        let Some(e) = parts[2]
+                            .parse::<usize>()
+                            .ok()
+                            .and_then(|i| self.min_sizes.entries().get(i).cloned())
+                        else {
+                            return format!("error: no entry '{}' (see min-size list)\n", parts[2]);
+                        };
+                        self.min_sizes.set(&e.app_id, &e.program, &e.title, 0, 0);
+                        format!("ok forgot {}x{} app_id={} title={:?}\n", e.width, e.height, e.app_id, e.title)
+                    }
+                    _ => "error: usage: min-size [list] | forget <app_id|id> | forget-entry <n>\n".to_string(),
+                }
+            }
             "center-window" | "bring-window" => {
                 // Pan the desktop so the target window (given app_id/id, or the
                 // focused window if omitted) is centered in the output, then focus
diff --git a/src/server/xwayland_window.rs b/src/server/xwayland_window.rs
index de512171..126cb1a4 100644
--- a/src/server/xwayland_window.rs
+++ b/src/server/xwayland_window.rs
@@ -582,6 +582,27 @@ impl XwaylandWindow {
         (*(*self.window).server).wm.min_sizes.set(&app_id, &program, &title, w, h);
     }
 
+    /// Drop this window's stored minimum and the one it is held to now, and
+    /// start learning afresh (`ccectl min-size forget`). The learned value
+    /// is the only minimum an X11 window carries here, so the hint's min
+    /// goes to 0. Returns what was stored, X11 pixels.
+    pub unsafe fn forget_min_size(&mut self) -> Option<(u32, u32)> {
+        let stored = self.min_size_key().and_then(|(app_id, program, title)| {
+            let min_sizes = &mut (*(*self.window).server).wm.min_sizes;
+            let stored = min_sizes.get(&app_id, &program, &title);
+            min_sizes.set(&app_id, &program, &title, 0, 0);
+            stored
+        });
+        self.refusals = [None, None];
+        let hint = crate::window::DimensionsHint {
+            min_width: 0,
+            min_height: 0,
+            ..(*self.window).wm_scheduled.dimensions_hint
+        };
+        (*self.window).set_dimensions_hint(hint);
+        stored
+    }
+
     /// 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) {