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