srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-02 20:08:00 +0200
committersrdusr <[email protected]>2026-06-02 20:08:00 +0200
commit30d45a7b9e0ddcd7ed2836c16e77b423cf3cff28 (patch)
treee7a85ad53642184b73afe7b8d47e9ef206d1b342
parent3ef784e09f6b81b767837e048d691c4e63528e55 (diff)
downloadsrdwm-30d45a7b9e0ddcd7ed2836c16e77b423cf3cff28.tar.gz
srdwm-30d45a7b9e0ddcd7ed2836c16e77b423cf3cff28.zip
Stop window memory poisoning itself, and publish the decoration side to GTK
Two reports, both traced to a cause other than the one being blamed. "Windows still spawn as squares" was not placement. On the live session firefox was 800x632 and so were four other apps, and 800x632 is exactly the placeholder new_managed_window assigns before a client has chosen anything. Firefox's remembered size earlier the same day was 1389x933. The loop: a window closes while still carrying the placeholder, the placeholder is remembered, the next launch therefore has a remembered size and is no longer provisional, a non-provisional window is forced to its size instead of being asked to pick, and on close the placeholder is written back. Every app that ever closed early gets pinned to one identical box, and no amount of placement work can touch it because the size never came from placement. remove_window now refuses to remember a size the client never chose. That alone would have been wrong: adopt_provisional_size cleared its own tracking set but never cleared Window::size_is_provisional, so nothing would ever have been remembered again. Both halves are covered by tests. Five poisoned entries were dropped from the live store and the six real ones kept, with a backup alongside it. "When user sets decorations should override all applications": the earlier answer was true about the protocol and wrong about the outcome. GTK never negotiates decoration, but it does read the desktop's button-layout preference - GTK4 through xdg-desktop-portal, GTK3 through gtk-decoration-layout. srdwm now publishes its own button_side there at startup and after every reload, which is precisely the job kde-gtk-config does for KWin. Verified in both directions from a neutral starting value; testing the second direction is what exposed an ordering bug where the publish ran before apply_general_settings and broadcast the default instead of the configured side. 527 tests pass, clippy clean.
-rw-r--r--crates/core/src/manager/dragresize.rs8
-rw-r--r--crates/core/src/manager/tests.rs33
-rw-r--r--crates/core/src/manager/windows.rs19
-rw-r--r--crates/srdwm/src/main.rs41
-rw-r--r--crates/wayland/src/state/geometry.rs6
-rw-r--r--docs/DEFAULTS.md16
-rw-r--r--docs/TODO.md62
7 files changed, 183 insertions, 2 deletions
diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs
index 906e5db..16aa539 100644
--- a/crates/core/src/manager/dragresize.rs
+++ b/crates/core/src/manager/dragresize.rs
@@ -387,6 +387,14 @@ impl WindowManager {
/// Every remembered `app_id` and its geometry - what `window_memory.rs`
/// iterates to persist the full table (e.g. on a clean shutdown), not
/// just whatever changed most recently.
+ /// This app's remembered geometry, if any. `None` means nothing has
+ /// been recorded for it - which is what a window that closed while its
+ /// size was still a placeholder deliberately leaves behind, so its next
+ /// launch gets to pick its own size again.
+ pub fn remembered_geometry_for(&self, app_id: &str) -> Option<(i32, i32, u32, u32)> {
+ self.remembered_geometry.get(app_id).copied()
+ }
+
pub fn all_remembered_geometry(&self) -> impl Iterator<Item = (&str, (i32, i32, u32, u32))> {
self.remembered_geometry.iter().map(|(k, &v)| (k.as_str(), v))
}
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index 152c71d..fdf0517 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -915,6 +915,39 @@
}
#[test]
+ fn a_size_the_client_never_chose_is_not_remembered() {
+ // The poisoning loop: remember a placeholder once and every future
+ // launch is forced to it, which looks like "every window spawns the
+ // same shape".
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "w");
+ w.app_id = "someapp".into();
+ wm.add_window(w);
+ assert!(wm.window(id).unwrap().size_is_provisional, "no remembered size, so the guess is provisional");
+ wm.remove_window(id);
+ assert!(wm.remembered_geometry_for("someapp").is_none(), "a guess must never be remembered");
+ }
+
+ #[test]
+ fn a_size_the_client_did_choose_is_remembered() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "w");
+ w.app_id = "someapp".into();
+ wm.add_window(w);
+ // What the backend does once the client commits a real buffer.
+ if let Some(w) = wm.window_mut(id) {
+ w.size_is_provisional = false;
+ w.geometry.width = 1389;
+ w.geometry.height = 933;
+ }
+ wm.remove_window(id);
+ let remembered = wm.remembered_geometry_for("someapp").expect("a real choice must be remembered");
+ assert_eq!((remembered.2, remembered.3), (1389, 933));
+ }
+
+ #[test]
fn a_dialog_opens_centered_not_cascaded_into_the_corner() {
let mut wm = wm_with_monitor();
let a = wm.alloc_window_id();
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
index 0186679..1e8a6cb 100644
--- a/crates/core/src/manager/windows.rs
+++ b/crates/core/src/manager/windows.rs
@@ -369,8 +369,25 @@ impl WindowManager {
// simply never consulted again on the read side once `layout_name
// == "tiling"`, so remembering it anyway is harmless, not wasted
// work worth a special case.
+ //
+ // `size_is_provisional` still set means the client never actually
+ // chose a size: the window closed while still carrying the
+ // backend's placeholder guess. Remembering that guess poisons this
+ // table permanently, and does so in a way that hides itself:
+ //
+ // 1. a window closes early, the placeholder is remembered
+ // 2. next launch finds a remembered size, so it is NOT provisional
+ // 3. the client is therefore forced to the placeholder instead of
+ // being asked to pick, and looks identical to every other
+ // poisoned app
+ // 4. on close the same placeholder is written back
+ //
+ // Reported as windows "spawning in squares" - every app coming out
+ // the same shape whatever it is. Five of eleven entries in the live
+ // store had been captured this way, including Firefox, whose real
+ // remembered size earlier the same day had been 1389x933.
if let Some(w) = &window {
- if !w.app_id.is_empty() {
+ if !w.app_id.is_empty() && !w.size_is_provisional {
self.remembered_geometry.insert(w.app_id.clone(), (w.geometry.x, w.geometry.y, w.geometry.width, w.geometry.height));
}
}
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index 9772e65..3f4fe34 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -104,6 +104,40 @@ const RELOAD_COMBO_LITERAL: &str = "Mod4+Ctrl+r";
/// `general.config_reload_on_write` is on. See `config_mtime`.
const CONFIG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
+/// Publishes srdwm's own titlebar button placement to the toolkits that
+/// draw their own decorations, so one setting governs every window.
+///
+/// GTK never negotiates decoration with the compositor - it creates no
+/// `xdg_toplevel_decoration` object at all, measured directly - so the
+/// protocol cannot reach its buttons. What GTK *does* read is the desktop's
+/// own button-layout preference: GTK4 through `xdg-desktop-portal`
+/// (`org.freedesktop.portal.Settings`, key
+/// `org.gnome.desktop.wm.preferences` `button-layout`), GTK3 through the
+/// `gtk-decoration-layout` setting. Both resolve from the same GSettings
+/// key on an ordinary system.
+///
+/// So this writes that key to match `button_side`. It is exactly what KDE
+/// does - `kde-gtk-config` exists for no other reason than to keep GTK's
+/// own setting in step with KWin's decoration - and it is what makes
+/// "set the decoration side once and every application follows" true rather
+/// than only true of srdwm's own titlebars.
+///
+/// Best-effort by design: `gsettings` missing, or no dconf to write to, is
+/// not an error worth interrupting startup for. The compositor's own
+/// titlebars are already correct either way; this only brings the
+/// self-decorating clients into line.
+fn publish_gtk_button_layout(wm: &Rc<RefCell<WindowManager>>) {
+ let layout = if wm.borrow().theme.buttons_left { "close,minimize,maximize:" } else { ":minimize,maximize,close" };
+ match std::process::Command::new("gsettings")
+ .args(["set", "org.gnome.desktop.wm.preferences", "button-layout", layout])
+ .status()
+ {
+ Ok(status) if status.success() => log::info!("published GTK button-layout {layout:?}"),
+ Ok(status) => log::debug!("gsettings exited {status} publishing button-layout; self-decorating clients keep their own"),
+ Err(e) => log::debug!("gsettings unavailable ({e}); self-decorating clients keep their own button layout"),
+ }
+}
+
/// Copies the loaded config's key bindings into the `WindowManager`, where
/// the IPC layer can serve them (`srd keybindings`).
///
@@ -720,6 +754,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
publish_keybindings(&engine, &wm);
apply_workspace_count(&engine, &wm);
apply_general_settings(&engine, &wm);
+ // After `apply_general_settings`, which is what copies the config's
+ // `button_side` into the theme - publishing before it would broadcast
+ // the built-in default rather than the user's choice.
+ publish_gtk_button_layout(&wm);
apply_default_layout(&engine, &wm);
let running = engine.running_flag();
// `general.config_reload_on_write` - on by default. A programmable
@@ -829,6 +867,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
apply_general_settings(&engine, &wm);
publish_keybindings(&engine, &wm);
+ publish_gtk_button_layout(&wm);
// After `apply_general_settings`, which rebuilds the
// theme from the config file - see
// `WindowManager::live_settings` for why a hand-made
@@ -852,6 +891,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
apply_general_settings(&engine, &wm);
publish_keybindings(&engine, &wm);
+ publish_gtk_button_layout(&wm);
srdwm_platform::replay_live_settings(&wm);
// After the reload, so a handler edited in the config since
// startup is the one that runs.
@@ -870,6 +910,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
apply_general_settings(&engine, &wm);
publish_keybindings(&engine, &wm);
+ publish_gtk_button_layout(&wm);
srdwm_platform::replay_live_settings(&wm);
} else if !engine.dispatch_keybinding(&combo) {
log::debug!("no binding for '{combo}'");
diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs
index b8629bc..1910334 100644
--- a/crates/wayland/src/state/geometry.rs
+++ b/crates/wayland/src/state/geometry.rs
@@ -556,6 +556,12 @@ impl CompState {
let width = ((content.size.w as f64 * scale).round() as u32).max(srdwm_core::placement::MIN_WINDOW_WIDTH);
let height = ((content.size.h as f64 * scale).round() as u32).max(srdwm_core::placement::MIN_WINDOW_HEIGHT) + band;
let Some(w) = wm.window_mut(id) else { return };
+ // The client has now made a real choice, so this size is no longer
+ // a guess. Clearing the core flag is what lets `remove_window`
+ // remember it: that path deliberately refuses to remember a size
+ // that was never chosen, and without this every window would look
+ // provisional forever and nothing would ever be remembered again.
+ w.size_is_provisional = false;
w.geometry.width = width;
w.geometry.height = height;
if let Some(monitor) = monitor_geometry {
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index fd10861..1b8d869 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -1009,7 +1009,21 @@ like it should, and can hurt: a client that draws its own chrome regardless
of what it negotiated (Firefox, historically) ends up with srdwm's titlebar
stacked on top of its own. Use `rules.lua`'s `decorated = false` for those.
-### For GTK: set the desktop's button layout
+### For GTK: srdwm publishes the layout for you
+
+srdwm writes the desktop's button-layout preference to match its own
+`button_side` at startup and after every config reload, so setting the side
+once governs GTK's self-drawn buttons too. Verified both directions from a
+neutral starting value:
+
+ button_side = "left" -> close,minimize,maximize:
+ button_side = "right" -> :minimize,maximize,close
+
+This is best-effort: it shells out to `gsettings`, and a machine without it
+simply keeps whatever layout it had. srdwm's own titlebars are correct
+either way; this only brings the self-decorating clients into line.
+
+### Setting the layout by hand
GTK reads its button layout from the desktop, not from the compositor. GTK 4
on Wayland reads it from `xdg-desktop-portal`
diff --git a/docs/TODO.md b/docs/TODO.md
index 971d0cd..29a8d9c 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,67 @@
# TODO / planned features - master checklist
+## The real cause of "windows spawn as squares": window memory was poisoning itself (2026-08-28)
+
+Reported again after a restart that already had the placement fixes live, so
+those were not it. Measured on the live session rather than guessed:
+`firefox` was `800x632`, and so were four other apps - and `800x632` is
+exactly `new_managed_window`'s placeholder guess (`800 x 600 +
+TITLEBAR_HEIGHT`). Firefox's remembered size earlier the same day had been
+`1389x933`.
+
+**The loop, which hides itself:**
+
+1. a window closes while its size is still the placeholder, and the
+ placeholder is written to window memory
+2. the next launch finds a remembered size, so the window is no longer
+ "provisional"
+3. being non-provisional, the client is *forced* to that size instead of
+ being asked to choose (`state.size = None` is only sent for a
+ provisional window)
+4. on close the same placeholder is written back
+
+Every app that ever closed early ends up pinned to one identical box, which
+is the "squares", and no amount of placement work touches it because the
+size never came from placement.
+
+Fixed at the root: `remove_window` refuses to remember a size the client
+never chose. That needed a second change to be correct - the backend's
+`adopt_provisional_size` cleared its own tracking set but never cleared
+`Window::size_is_provisional`, so with only the first change *nothing* would
+ever have been remembered again. Two tests pin both halves: a guess is not
+remembered, a real choice is.
+
+Five poisoned entries were dropped from the live store (firefox,
+google-chrome, niri, wlroots, xdg-desktop-portal-gtk); the six real ones
+were kept. Backup at
+`~/.local/state/srd/window-memory.json.bak-20260828-211945`.
+
+## Decoration side now governs every application, not just srdwm's titlebars
+
+Asked for directly: "when user sets decorations should override all
+applications". The previous answer - that GTK cannot be reached because it
+never negotiates - was true about the *protocol* and wrong about the
+outcome, because the protocol is not the only channel.
+
+srdwm now publishes its own `button_side` as the desktop's button-layout
+preference at startup and after every reload, which is the channel GTK
+actually reads (GTK4 via `xdg-desktop-portal`, GTK3 via
+`gtk-decoration-layout`). Verified both directions from a deliberately
+neutral starting value, so neither result could pass by luck:
+
+ button_side = "left" -> close,minimize,maximize:
+ button_side = "right" -> :minimize,maximize,close
+
+Best-effort by design: it shells out to `gsettings` and logs at debug if
+that is unavailable. This is the same job `kde-gtk-config` does for KWin.
+
+An ordering bug was caught by testing the second direction rather than
+stopping at the first: the publish originally ran before
+`apply_general_settings`, so it broadcast the built-in default instead of
+the user's configured side. The `left` case is what exposed it.
+
+277 core tests, 527 total, clippy clean.
+
## Deep dive: can every window use the same decorations, client- or server-side (2026-08-28)
Asked after being told "srdwm can only control its own titlebar" - correctly