srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/manager/mod.rs19
-rw-r--r--crates/core/src/manager/tests.rs66
-rw-r--r--crates/core/src/manager/windows.rs19
-rw-r--r--crates/platform/src/ipc/dispatch.rs12
-rw-r--r--crates/platform/src/ipc/types.rs1
-rw-r--r--crates/srdwm/src/main.rs2
-rw-r--r--crates/wayland/src/input/focus.rs12
-rw-r--r--docs/DEFAULTS.md11
-rw-r--r--docs/TODO.md16
9 files changed, 157 insertions, 1 deletions
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index d449486..9265885 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -241,6 +241,24 @@ pub struct WindowManager {
/// see the Wayland backend's shadow render call site - so this only
/// ever turns it off entirely, not on for those.
pub shadows_enabled: bool,
+ /// Whether closing your own focused window is allowed to fall back to
+ /// a window on a *different* workspace and switch you there to follow
+ /// it, when nothing else is left on your current one. Read from
+ /// `general.close_focus_follows_workspace`. `false` (the default)
+ /// matches every mainstream desktop (Windows/GNOME/macOS never change
+ /// your active workspace just because a window closed) - `remove_
+ /// window`'s own fallback then only ever considers a same-workspace
+ /// window, leaving focus at `None` if there isn't one, rather than
+ /// picking whatever window was next in the *global* most-recently-
+ /// focused order regardless of which workspace it happens to be on.
+ /// Reported live as windows closing and "teleporting" the user to a
+ /// previous workspace - exactly this: the global fallback picking a
+ /// background window elsewhere, then `focus_window`'s own (separate,
+ /// correct, and unrelated to this setting) "switch workspace to match
+ /// the newly focused window" side effect following it there. `true`
+ /// restores that original always-follow behaviour for anyone who
+ /// wants it.
+ pub close_focus_follows_workspace: bool,
/// Width, in pixels, of the resize grab band along a window's edges,
/// read from `general.resize_margin`. See [`crate::window::RESIZE_MARGIN`]'s
/// doc comment for the default and why it's what it is.
@@ -548,6 +566,7 @@ impl WindowManager {
animations_enabled: true,
animation_duration_ms: 200,
shadows_enabled: true,
+ close_focus_follows_workspace: false,
resize_margin: RESIZE_MARGIN,
rounded_corners_enabled: None,
gpu_enabled: false,
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index f703f25..6a3ba3f 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -11,6 +11,72 @@
}
#[test]
+ fn closing_the_focused_window_prefers_a_same_workspace_fallback_over_a_more_recent_global_one() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a")); // workspace 1
+
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ let c = wm.alloc_window_id();
+ wm.add_window(Window::new(c, "c")); // workspace 2, now globally most-recent
+
+ wm.switch_workspace(1);
+ let b = wm.alloc_window_id();
+ wm.add_window(Window::new(b, "b")); // workspace 1, now focused and globally most-recent
+
+ assert_eq!(wm.current_workspace(), 1);
+ wm.remove_window(b);
+ // The naive "global most recent" fallback would have landed on `c`
+ // (workspace 2) here - `a`, still on the workspace the user is
+ // actually looking at, is what a real desktop would land on.
+ assert_eq!(wm.focused_id(), Some(a));
+ assert_eq!(wm.current_workspace(), 1, "must not have been dragged onto workspace 2 by the fallback");
+ }
+
+ #[test]
+ fn closing_the_last_window_on_a_workspace_leaves_focus_none_by_default() {
+ let mut wm = wm_with_monitor();
+ assert!(!wm.close_focus_follows_workspace, "default must be off, matching every mainstream desktop");
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a")); // workspace 1
+
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ let c = wm.alloc_window_id();
+ wm.add_window(Window::new(c, "c")); // workspace 2
+
+ wm.switch_workspace(1);
+ wm.focus_window(a); // re-focus `a`; current_workspace stays 1 (already there)
+ assert_eq!(wm.current_workspace(), 1);
+
+ wm.remove_window(a);
+ // No window left on workspace 1 at all - with the setting off,
+ // this must not silently jump the user over to `c` on workspace 2.
+ assert_eq!(wm.focused_id(), None);
+ assert_eq!(wm.current_workspace(), 1);
+ }
+
+ #[test]
+ fn closing_the_last_window_on_a_workspace_can_still_follow_when_opted_in() {
+ let mut wm = wm_with_monitor();
+ wm.close_focus_follows_workspace = true;
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a")); // workspace 1
+
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ let c = wm.alloc_window_id();
+ wm.add_window(Window::new(c, "c")); // workspace 2
+
+ wm.switch_workspace(1);
+ wm.focus_window(a);
+ wm.remove_window(a);
+ // Opted in: the old always-follow-the-global-fallback behaviour.
+ assert_eq!(wm.focused_id(), Some(c));
+ }
+
+ #[test]
fn new_window_on_dynamic_workspace_uses_smart_placement() {
let mut wm = wm_with_monitor();
let id = wm.alloc_window_id();
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
index f29d19a..3271dee 100644
--- a/crates/core/src/manager/windows.rs
+++ b/crates/core/src/manager/windows.rs
@@ -271,7 +271,24 @@ impl WindowManager {
pub fn remove_window(&mut self, id: WindowId) -> Option<Window> {
self.order.retain(|&w| w != id);
if self.focused == Some(id) {
- self.focused = self.order.last().copied();
+ // Prefer the most-recently-focused window still on *this*
+ // workspace - `self.order` tracks every window globally, not
+ // per-workspace, so its own `.last()` (the previous behaviour)
+ // could just as easily be a background window sitting on a
+ // workspace the user isn't even looking at. `focus_window`
+ // switches the active workspace to match whatever it's given
+ // (a real, separate, and correct feature for a deliberate
+ // `srd dispatch focus` from elsewhere) - so handing it a
+ // cross-workspace fallback here silently dragged the user's
+ // whole view along with it the moment they closed a window,
+ // reported live as "teleports me to previous workspace".
+ // `close_focus_follows_workspace` (default `false`, matching
+ // every mainstream desktop - none of them change your active
+ // workspace just because a window closed) restores that
+ // original always-follow behaviour for anyone who wants it.
+ let current = self.current_workspace;
+ let same_workspace = self.order.iter().rev().copied().find(|&w| self.windows.get(&w).is_some_and(|win| win.workspace == current));
+ self.focused = same_workspace.or_else(|| self.close_focus_follows_workspace.then(|| self.order.last().copied()).flatten());
}
let window = self.windows.remove(&id);
// Remembers wherever this app's window actually ended up, not just
diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs
index c5e70be..5668d14 100644
--- a/crates/platform/src/ipc/dispatch.rs
+++ b/crates/platform/src/ipc/dispatch.rs
@@ -22,6 +22,7 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi
let wm = wm.borrow();
let settings = SettingsResponse {
shadows: wm.shadows_enabled,
+ close_focus_follows_workspace: wm.close_focus_follows_workspace,
rounded_corners: wm.rounded_corners_enabled,
animations: wm.animations_enabled,
night_light: wm.color_filter == srdwm_core::ColorFilter::NightLight,
@@ -636,6 +637,17 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<Windo
wm.borrow_mut().shadows_enabled = v;
(ok(), true)
}
+ // `srd set close_focus_follows_workspace <bool>` - live equivalent
+ // of `general.close_focus_follows_workspace`. See `WindowManager::
+ // close_focus_follows_workspace`'s own doc comment for what this
+ // actually gates: whether closing your focused window is allowed
+ // to fall back to (and switch your active workspace to follow) a
+ // window elsewhere, when nothing else is left on your current one.
+ "close_focus_follows_workspace" => {
+ let Some(v) = value.and_then(|v| v.as_bool()) else { return (err("close_focus_follows_workspace needs a boolean value"), false) };
+ wm.borrow_mut().close_focus_follows_workspace = v;
+ (ok(), true)
+ }
// A bool, not a radius: the actual corner radius is a fixed
// constant (`crates/wayland/src/decoration.rs::CORNER_RADIUS`),
// not a per-session config value anywhere in the compositor yet --
diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs
index bf1526e..095dc3d 100644
--- a/crates/platform/src/ipc/types.rs
+++ b/crates/platform/src/ipc/types.rs
@@ -158,6 +158,7 @@ pub(crate) struct PinnedInputsResponse {
#[derive(Serialize)]
pub(crate) struct SettingsResponse {
pub(crate) shadows: bool,
+ pub(crate) close_focus_follows_workspace: bool,
pub(crate) rounded_corners: Option<bool>,
pub(crate) animations: bool,
pub(crate) night_light: bool,
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index 6b8724a..edb87d6 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -166,6 +166,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
let animations = engine.get_bool("general.animations", true);
let duration = engine.get_f64("general.animation_duration", 200.0).max(0.0) as u32;
let shadows = engine.get_bool("general.shadows", true);
+ let close_focus_follows_workspace = engine.get_bool("general.close_focus_follows_workspace", false);
let resize_margin = engine.get_f64("general.resize_margin", srdwm_core::RESIZE_MARGIN as f64).max(1.0) as i32;
// Genuinely absent, not `false`, when the user's config never sets it
// - see `WindowManager::rounded_corners_enabled`'s doc comment for why
@@ -310,6 +311,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
wm.animations_enabled = animations;
wm.animation_duration_ms = duration;
wm.shadows_enabled = shadows;
+ wm.close_focus_follows_workspace = close_focus_follows_workspace;
wm.resize_margin = resize_margin;
wm.rounded_corners_enabled = rounded_corners;
wm.gpu_enabled = gpu;
diff --git a/crates/wayland/src/input/focus.rs b/crates/wayland/src/input/focus.rs
index ade0759..3fcff58 100644
--- a/crates/wayland/src/input/focus.rs
+++ b/crates/wayland/src/input/focus.rs
@@ -53,6 +53,18 @@ pub(crate) fn close_dwindow(w: &DWindow) {
/// Wayland/X11 keyboard focus - without this, a window can be raised and
/// tiled correctly yet never receive a single keystroke.
pub(crate) fn focus_window(state: &mut CompState, id: WindowId) {
+ // Real desktop convention (Windows/GNOME/macOS all do this): a
+ // selected desktop icon stays highlighted only until something else
+ // takes focus. Reported live as "highlighted desktop icons don't
+ // become not highlighted anymore" when clicking a window - `select_
+ // desktop_icon(None)` (clearing selection) was only ever called from
+ // `start_desktop_marquee` (a new marquee-select on bare desktop), never
+ // from the one place every focus path already funnels through
+ // regardless of how it got triggered (a click, Alt-Tab, a dock's IPC
+ // focus dispatch, scratchpad show, ...) - see this function's own
+ // doc comment on why that funnel already exists for raising. A no-op,
+ // cheap, when nothing was selected to begin with.
+ state.select_desktop_icon(None);
state.wm.borrow_mut().focus_window(id);
// Raises the window in smithay's own `Space` too, not just core's
// `order` - `Space` keeps a completely independent stacking order of
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index 3986654..3d1a863 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -27,7 +27,18 @@ srd.set("general.file_manager", "") -- Default: "" - empty me
srd.set("general.desktop_icon_single_click", false) -- Default: false - double-click opens an icon
srd.set("general.terminal", "") -- Default: "" - empty tries a common terminal on $PATH
srd.set("general.phone_mode", false) -- Default: false - see "Phone mode" below
+srd.set("general.close_focus_follows_workspace", false) - Default: false - see below
```
+
+`close_focus_follows_workspace` decides what happens when your currently
+focused window closes and no other window is left on the workspace you're
+looking at. `false` (the default) leaves you with nothing focused on your
+own workspace - it never switches you elsewhere, matching Windows/GNOME/
+macOS, none of which change your active workspace just because a window
+closed. `true` restores the alternate behaviour: falling back to whichever
+window was focused most recently anywhere, switching your active workspace
+to follow it there (matching Hyprland's own `focuswindow`-driven
+convention). Live-settable: `srd set close_focus_follows_workspace <bool>`.
`general.smart_placement`/`general.border_width` are not listed: neither
is implemented - new-window placement always uses smart placement
unconditionally (no toggle exists), and the real, working border-width
diff --git a/docs/TODO.md b/docs/TODO.md
index 71e3e59..c533d39 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,21 @@
# TODO / planned features - master checklist
+## Four live reports from a real second monitor: one diagnosis, two real fixes, one config toggle, one AGS-side finding (2026-08-28)
+
+A second monitor was physically connected, surfacing several reports at once.
+
+**"Windows show a bit in the other monitor", diagnosed, not yet independently re-verified.** `srd clients` on the live session showed several real windows sitting at `x: 1920` - exactly the seam between the two 1920-wide outputs. With shadows still active on the (not-yet-restarted) live binary, each one's 24px shadow strip has nowhere to land but the neighbouring monitor. This is a real, separate gap from anything fixed earlier today: the shadow-tint fix only ever considered a window's *neighbouring tile*, never a *neighbouring monitor* - `shadow_rect` expands blindly by `SHADOW_SIZE` on every side with no monitor-boundary awareness at all, so any floating window near a multi-monitor seam would still bleed onto the adjacent screen even with today's other shadow fixes applied. Not fixed as its own thing, since `general.shadows` was already turned off for this user's own live config today (per their own "tinting no" - see the shadow-regression entry below) - moot for them specifically, but a real, still-open limitation worth flagging for anyone who re-enables shadows on a multi-monitor setup.
+
+**Desktop icons stayed highlighted after clicking a window - fixed.** `select_desktop_icon(None)` (clearing the selection) was only ever called from `start_desktop_marquee` (starting a fresh rubber-band select on bare desktop) - never from anywhere a real window becoming focused would reach. Every focus path in this compositor (a click, Alt-Tab, a dock's IPC focus dispatch, scratchpad show, the Snap-Layouts flyout) already funnels through one shared `focus_window` in `crates/wayland/src/input/focus.rs` for raising - added the same deselect call there, so it's now correct regardless of *how* a window got focused, matching Windows/GNOME/macOS convention (a selected icon stays highlighted only until something else takes focus).
+
+**Closing a window "teleported" the user to a different workspace - root-caused and fixed, with a new config toggle.** `WindowManager::remove_window`'s own fallback, when the closed window was the focused one, picked `self.order.last()` - but `self.order` tracks every window *globally*, not per-workspace, so that fallback could just as easily land on a background window sitting on a completely different workspace. `focus_window` already switches the active workspace to match whatever it's given (a real, separate, correct feature for a deliberate `srd dispatch focus` from elsewhere, fixed earlier this project's history) - handing it a cross-workspace fallback here silently dragged the user's entire view along with it the instant they closed a window. Fixed by preferring the most-recently-focused window still on the *current* workspace first; a new `general.close_focus_follows_workspace` (default `false`, live-settable via `srd set`) decides what happens only when there's truly nothing left on the current workspace to fall back to - `false` leaves focus at nothing (matching every mainstream desktop, none of which change your active workspace just because a window closed), `true` restores the original always-follow-the-global-fallback behaviour for anyone who wants Hyprland's own convention instead. Three new tests covering same-workspace preference, the off default, and the opt-in follow behaviour.
+
+**AGS/waybar/aegis not auto-loading a bar on the newly connected monitor - investigated, srdwm's own side confirmed correct.** `srd monitors` immediately showed the new output (`HDMI-A-1`), positioned and enabled correctly - `reprobe_outputs`' hotplug path really did create a real `wl_output` global and push a `CoreEvent::MonitorAdded`, and `srd subscribe` already emits a `monitors` event specifically for this (added in an earlier session at the AGS side's own request, precisely so a panel wouldn't need to poll). Everything this compositor is responsible for advertising is being advertised correctly. If a panel still doesn't create a bar on the new output, the gap is very likely that panel's own monitor-added reactivity (many bar toolkits enumerate outputs once at their own startup and never re-scan), not anything srdwm failed to tell it - raised with the peer sessions that own those tools rather than guessed at or fixed here, since this compositor has no way to reach into another process's own window-creation logic.
+
+**Also owned directly, not fixed:** two accidental live-session side effects from testing Firefox/Nemo's own decoration earlier in this same stretch - both are single-instance apps that activate against whatever instance is already running regardless of a `WAYLAND_DISPLAY` override on the new invocation, opening real new windows on the user's actual desktop instead of the intended nested test instance. Told to the user directly as soon as noticed; neither window was closed without being asked.
+
+Full workspace build/test/clippy clean (247 core tests, +3 for the close-focus-workspace fix; 152 wayland, unchanged - the desktop-icon fix is one line inside a function too tightly coupled to `CompState`/smithay to unit-test in isolation, same class of gap this project's own testing convention already accepts elsewhere).
+
## Zathura double-titlebar: same class of bug as Firefox/Nemo, heuristic broadened to catch it (2026-08-28)
Reported live: "also noticed double title bars in zathura. i hope there aren't more programs experiencing this" - while checking the user's own live session for an unrelated reason, a screenshot of their real, already-open Zathura window showed two stacked title rows with two *visibly different* button styles (plain X/minus/square icons on top, filled traffic-light-style dots directly underneath) - the same tell the Firefox/Nemo double-decoration bug always had: srdwm's own server-side titlebar, with zathura's own girara-drawn header underneath it, unsuppressed.