diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/core/src/manager/mod.rs | 19 | ||||
| -rw-r--r-- | crates/core/src/manager/tests.rs | 66 | ||||
| -rw-r--r-- | crates/core/src/manager/windows.rs | 19 | ||||
| -rw-r--r-- | crates/platform/src/ipc/dispatch.rs | 12 | ||||
| -rw-r--r-- | crates/platform/src/ipc/types.rs | 1 | ||||
| -rw-r--r-- | crates/srdwm/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/wayland/src/input/focus.rs | 12 |
7 files changed, 130 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 |