diff options
| -rw-r--r-- | crates/core/src/manager/dragresize.rs | 8 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 33 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 19 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 41 | ||||
| -rw-r--r-- | crates/wayland/src/state/geometry.rs | 6 | ||||
| -rw-r--r-- | docs/DEFAULTS.md | 16 | ||||
| -rw-r--r-- | docs/TODO.md | 62 |
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 |