srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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