srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/manager.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/core/src/manager.rs')
-rw-r--r--crates/core/src/manager.rs706
1 files changed, 696 insertions, 10 deletions
diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs
index 3856a25..8696508 100644
--- a/crates/core/src/manager.rs
+++ b/crates/core/src/manager.rs
@@ -3,6 +3,7 @@ use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
use crate::monitor::{Monitor, MonitorId};
use crate::placement::{PlacementConfig, SmartPlacement, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH};
use crate::rules::WindowRule;
+use crate::theme::ThemeConfig;
use crate::window::{ResizeEdge, TitlebarHit, Window, WindowId};
use crate::workspace::{Workspace, WorkspaceId};
use std::collections::HashMap;
@@ -41,14 +42,36 @@ pub struct WindowManager {
monitors: Vec<Monitor>,
workspaces: Vec<Workspace>,
current_workspace: WorkspaceId,
+ /// Whichever workspace was current immediately before the current one
+ /// became current - see `switch_workspace`'s doc comment.
+ previous_workspace: WorkspaceId,
+ /// Read from `workspace.auto_back_and_forth`. When set, switching to
+ /// the workspace that's already active switches to `previous_workspace`
+ /// instead - sway's `workspace_auto_back_and_forth` behavior, a quick
+ /// "jump back to whatever I was just on" toggle on a single keybinding.
+ pub auto_back_and_forth: bool,
next_workspace_id: WorkspaceId,
next_window_id: WindowId,
layouts: HashMap<String, Box<dyn Layout>>,
pub tiling: TilingConfig,
pub placement: PlacementConfig,
+ /// Whether geometry changes made via `toggle_maximize`/`toggle_fullscreen`
+ /// should be animated. Read from `general.animations`; a backend's open
+ /// animation is gated on this too, since core has no notion of "open".
+ pub animations_enabled: bool,
+ /// Tween duration in milliseconds, read from `general.animation_duration`.
+ pub animation_duration_ms: u32,
+ /// Default decoration colours and border width, read from `theme.colors.*`/
+ /// `theme.decorations.*`. See `ThemeConfig`'s own doc comment.
+ pub theme: ThemeConfig,
drag: Option<DragState>,
resize: Option<ResizeState>,
rules: Vec<WindowRule>,
+ /// Windows a client-close was requested for, drained once per tick by
+ /// `main.rs`'s event loop and forwarded to `Platform::close`. Needed
+ /// because `WindowManager` is platform-agnostic and has no way to send
+ /// a client its close request directly - see `close_window`.
+ close_requests: Vec<WindowId>,
}
impl Default for WindowManager {
@@ -71,14 +94,20 @@ impl WindowManager {
monitors: Vec::new(),
workspaces: vec![Workspace::new(0, "1", "dynamic")],
current_workspace: 0,
+ previous_workspace: 0,
+ auto_back_and_forth: false,
next_workspace_id: 1,
next_window_id: 1,
layouts,
tiling: TilingConfig::default(),
placement: PlacementConfig::default(),
+ animations_enabled: true,
+ animation_duration_ms: 200,
+ theme: ThemeConfig::default(),
drag: None,
resize: None,
rules: Vec::new(),
+ close_requests: Vec::new(),
}
}
@@ -147,6 +176,28 @@ impl WindowManager {
}
}
}
+ // A maximized/fullscreen window's geometry was set to a snapshot of
+ // its monitor's usable/full rect at the moment it was toggled on --
+ // it is not live-bound to that rect afterward. Without this, a bar
+ // or dock changing its exclusive zone while a window is maximized
+ // (the live case: a dock dropping its reservation to 0 so a
+ // maximized window can cover its area) grows or shrinks `Monitor::
+ // geometry`/`full_geometry` here, but the already-maximized window
+ // keeps its stale pre-change size until manually un-maximized and
+ // re-maximized - reported as "maximize does not extend past the
+ // dock" even though the dock's own zone change took effect
+ // immediately in every other respect (new windows placed correctly,
+ // `Monitor::geometry` itself correct if queried fresh).
+ for window in self.windows.values_mut() {
+ if !window.maximized && !window.fullscreen {
+ continue;
+ }
+ let Some(monitor) = live.iter().find(|m| m.id == window.monitor) else { continue };
+ let target = if window.fullscreen { monitor.full_geometry } else { monitor.geometry };
+ if window.geometry != target {
+ window.geometry = target;
+ }
+ }
}
pub fn monitors(&self) -> &[Monitor] {
@@ -175,7 +226,16 @@ impl WindowManager {
/// it's left for the next `arrange_workspace` call to place.
pub fn add_window(&mut self, mut window: Window) -> WindowId {
let id = window.id;
+ // Applied before rule matching below, which still wins when a rule
+ // sets its own `border_color`/`border_width` - this only replaces
+ // whatever a backend's `Window::new` happened to hardcode.
+ window.border_color = self.theme.default_border_color;
+ window.border_width = self.theme.default_border_width;
let actions = self.rules.iter().find(|r| r.matcher.matches(&window)).map(|r| r.actions.clone());
+ // See `Window::rules_applied`'s doc comment: a native Wayland window
+ // still has empty title/app_id at this point, so a real (if
+ // inconclusive) match attempt needs to wait for `reapply_rules_if_pending`.
+ window.rules_applied = actions.is_some() || !(window.title.is_empty() && window.app_id.is_empty());
let workspace = actions.as_ref().and_then(|a| a.workspace).unwrap_or(self.current_workspace);
window.workspace = workspace;
@@ -222,6 +282,63 @@ impl WindowManager {
id
}
+ /// Retries rule matching for a window `add_window` couldn't conclusively
+ /// match yet (see `Window::rules_applied`'s doc comment) - a backend
+ /// calls this once a native Wayland window's real `title`/`app_id`
+ /// become known, typically on its first real commit. A no-op once
+ /// `rules_applied` is already `true`, so this is safe to call on every
+ /// subsequent metadata change without rules re-applying repeatedly.
+ ///
+ /// Returns whether a rule actually matched and was applied - distinct
+ /// from simply "ran" (this is a no-op past the first call regardless).
+ /// A backend uses this to decide whether a follow-up geometry/decoration
+ /// sync is warranted: `sync_geometry` re-stacks the window to the top
+ /// via smithay's `Space::map_element` as a side effect of updating its
+ /// tracked position (`map_element` always does this, `activate` or
+ /// not - there is no "move without restacking" in this smithay
+ /// version), so calling it on *every* title/app_id change - which
+ /// happens constantly for perfectly ordinary reasons (a browser tab
+ /// finishing a page load) long after the window's own creation - would
+ /// silently yank an unfocused, unrelated window back to the front any
+ /// time its title happened to update. Reported live as exactly that:
+ /// an older window jumping in front of a newer, focused one with no
+ /// user action to explain it.
+ pub fn reapply_rules_if_pending(&mut self, id: WindowId) -> bool {
+ let Some(window) = self.windows.get(&id) else { return false };
+ if window.rules_applied || (window.title.is_empty() && window.app_id.is_empty()) {
+ return false;
+ }
+ let actions = self.rules.iter().find(|r| r.matcher.matches(window)).map(|r| r.actions.clone());
+ let Some(window) = self.windows.get_mut(&id) else { return false };
+ window.rules_applied = true;
+ let Some(actions) = actions else { return false };
+ if let Some(floating) = actions.floating {
+ window.floating = floating;
+ }
+ if let Some(decorated) = actions.decorated {
+ window.decorated = decorated;
+ }
+ if let Some(color) = actions.border_color {
+ window.border_color = color;
+ }
+ if let Some(width) = actions.border_width {
+ window.border_width = width;
+ }
+ if let Some(pinned) = actions.pinned {
+ window.always_on_top = pinned;
+ }
+ if let Some(geometry) = actions.geometry {
+ window.geometry = geometry;
+ }
+ if let Some(workspace) = actions.workspace {
+ self.move_window_to_workspace(id, workspace);
+ }
+ if actions.maximized.unwrap_or(false) {
+ self.toggle_maximize(id);
+ }
+ true
+ }
+
pub fn remove_window(&mut self, id: WindowId) -> Option<Window> {
self.order.retain(|&w| w != id);
if self.focused == Some(id) {
@@ -259,6 +376,22 @@ impl WindowManager {
self.restack_pinned();
}
+ /// Sends a window to the back of the stack - the middle-click-titlebar
+ /// convention most X11 WMs (twm, fvwm, IceWM) have always had and this
+ /// one never did. Doesn't touch focus: lowering the window you're
+ /// currently looking at out from under the pointer without also moving
+ /// keyboard focus elsewhere would leave input going to a window that's
+ /// no longer visible under the cursor, which is more surprising than
+ /// useful. `restack_pinned` still runs afterward so a pinned window
+ /// can't accidentally end up buried by this either.
+ pub fn lower_window(&mut self, id: WindowId) {
+ if let Some(pos) = self.order.iter().position(|&w| w == id) {
+ let id = self.order.remove(pos);
+ self.order.insert(0, id);
+ }
+ self.restack_pinned();
+ }
+
/// Toggles "always on top" for a window (Hyprland's `pin`), used for
/// picture-in-picture and small HUD overlays that must stay visible
/// while you work in something else.
@@ -430,6 +563,14 @@ impl WindowManager {
pub fn close_window(&mut self, id: WindowId) {
log::info!("close_window({id})");
+ self.close_requests.push(id);
+ }
+
+ /// Drains windows queued by `close_window` since the last call. Core
+ /// has no way to reach a client itself - the caller (`main.rs`) is
+ /// expected to forward each id to `Platform::close`.
+ pub fn take_close_requests(&mut self) -> Vec<WindowId> {
+ std::mem::take(&mut self.close_requests)
}
pub fn minimize_window(&mut self, id: WindowId) {
@@ -447,9 +588,68 @@ impl WindowManager {
}
}
+ /// Moves a window into the scratchpad pool, hiding it immediately --
+ /// sway's `move scratchpad`. The single most-used "quick terminal"
+ /// pattern in tiling window managers, and srdwm had no equivalent at
+ /// all before this.
+ ///
+ /// Also floats the window: tiling something that's meant to pop in and
+ /// out on demand doesn't make sense, and would otherwise fight
+ /// `arrange_workspace` every time it's shown. Reuses `minimized` for
+ /// the actual show/hide gating rather than introducing a second
+ /// visibility flag - `scratchpad` here is purely a marker of *pool
+ /// membership*, kept separate so `scratchpad_show` knows which hidden
+ /// windows are its own to bring back, as opposed to an ordinarily
+ /// minimized one.
+ pub fn scratchpad_add(&mut self, id: WindowId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.scratchpad = true;
+ w.floating = true;
+ }
+ self.minimize_window(id);
+ }
+
+ /// Removes a window from the scratchpad pool without changing its
+ /// current visibility - for a rule or script that wants to opt a
+ /// window back into ordinary window management.
+ pub fn scratchpad_remove(&mut self, id: WindowId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.scratchpad = false;
+ }
+ }
+
+ /// Toggles the scratchpad - sway's `scratchpad show`, meant for one
+ /// keybinding a user presses repeatedly. If the focused window is
+ /// itself a currently-shown scratchpad window, hides it; otherwise
+ /// shows (and focuses) the most recently added hidden scratchpad
+ /// window, if any, moving it onto whichever workspace is current so it
+ /// follows the user rather than staying pinned to wherever it was
+ /// added from - sway's own behavior. "Most recently added" is `id`
+ /// order, since ids are allocated monotonically and no separate
+ /// timestamp is tracked; only ever one window is shown/hidden per
+ /// call, deliberately not sway's full multi-window cycling, which
+ /// needs its own remembered order and is a rarer need than a single
+ /// scratchpad window covers.
+ pub fn scratchpad_show(&mut self) {
+ if let Some(id) = self.focused {
+ if self.windows.get(&id).is_some_and(|w| w.scratchpad && !w.minimized) {
+ self.minimize_window(id);
+ return;
+ }
+ }
+ let Some(id) = self.windows.values().filter(|w| w.scratchpad && w.minimized).map(|w| w.id).max() else { return };
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.workspace = self.current_workspace;
+ }
+ self.restore_window(id);
+ self.focus_window(id);
+ }
+
pub fn toggle_maximize(&mut self, id: WindowId) {
let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry);
+ let animations_enabled = self.animations_enabled;
let Some(w) = self.windows.get_mut(&id) else { return };
+ let from = w.geometry;
if w.maximized {
if let Some(restore) = w.restore_geometry.take() {
w.geometry = restore;
@@ -460,6 +660,9 @@ impl WindowManager {
w.geometry = geom;
w.maximized = true;
}
+ if animations_enabled && w.geometry != from {
+ w.anim_from = Some(from);
+ }
}
/// Fullscreen: the window covers its whole monitor with no decoration.
@@ -469,15 +672,38 @@ impl WindowManager {
/// they are mutually exclusive - toggling one off restores whatever the
/// window's geometry was before *either* was applied, and entering
/// fullscreen from a maximised window doesn't lose the original size.
+ ///
+ /// `decorated` is saved and restored the same way, via
+ /// `restore_decorated` - exiting used to hardcode `w.decorated = true`
+ /// unconditionally, which is only correct for a window that was
+ /// decorated to begin with. Any window a rule sets `decorated = false`
+ /// for (client-side-decorated apps like Firefox, matched via
+ /// `srd.rule({ class = "firefox" }, { decorated = false })`) that ever
+ /// goes fullscreen - an HTML5 video, a PDF presentation, plain F11 --
+ /// came back from it permanently `decorated = true`, with no further
+ /// event to ever set it back. Since border/titlebar redraw fresh from
+ /// live `Window.decorated` every frame but the *hit-testing* band this
+ /// wrongly turned on doesn't correspond to anything the client is
+ /// actually drawing there, every click in what srdwm now (incorrectly)
+ /// treats as the titlebar band got swallowed as a drag/button hit
+ /// instead of ever reaching the client - reported live as a click on
+ /// Firefox's back button minimizing the window instead.
pub fn toggle_fullscreen(&mut self, id: WindowId) {
- let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry);
+ // Unlike `toggle_maximize`, fullscreen uses the monitor's true
+ // full rect, not the exclusive-zone-shrunk usable area - a
+ // fullscreen window should cover (or go under) a bar/dock like
+ // everywhere else, not stop short of it. See `Monitor::
+ // full_geometry`'s doc comment.
+ let monitor_geom = self.windows.get(&id).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.full_geometry);
+ let animations_enabled = self.animations_enabled;
let Some(w) = self.windows.get_mut(&id) else { return };
+ let from = w.geometry;
if w.fullscreen {
if let Some(restore) = w.restore_geometry.take() {
w.geometry = restore;
}
w.fullscreen = false;
- w.decorated = true;
+ w.decorated = w.restore_decorated.take().unwrap_or(true);
} else if let Some(geom) = monitor_geom {
// Only remember the pre-fullscreen geometry if we aren't already
// maximised, otherwise the monitor rect would overwrite the real
@@ -488,8 +714,12 @@ impl WindowManager {
w.maximized = false;
w.geometry = geom;
w.fullscreen = true;
+ w.restore_decorated = Some(w.decorated);
w.decorated = false;
}
+ if animations_enabled && w.geometry != from {
+ w.anim_from = Some(from);
+ }
}
pub fn is_fullscreen(&self, id: WindowId) -> bool {
@@ -529,7 +759,7 @@ impl WindowManager {
if w.minimized {
continue;
}
- if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y) {
+ if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width) {
return Some((w.id, hit));
}
}
@@ -578,7 +808,13 @@ impl WindowManager {
new_geom.x += dx;
new_geom.y += dy;
- let monitor_bounds = self.windows.get(&drag.window).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.geometry);
+ // `full_geometry`, not `geometry`: a floating window being dragged
+ // must be able to cross into (or land under/over) the strip a
+ // bar/dock reserves - only *placement* of a brand-new window and
+ // maximize avoid it. Clamping a drag to the shrunk usable area
+ // made it physically impossible to ever drag a window past a
+ // dock, at any speed or angle.
+ let monitor_bounds = self.windows.get(&drag.window).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.full_geometry);
if let Some(bounds) = monitor_bounds {
new_geom.x = new_geom.x.clamp(bounds.x - new_geom.width as i32 + 40, bounds.right() - 40);
new_geom.y = new_geom.y.clamp(bounds.y, bounds.bottom() - 40);
@@ -630,6 +866,15 @@ impl WindowManager {
self.resize.is_some()
}
+ /// The edge currently being dragged, if a resize is in progress - so a
+ /// backend can keep showing the matching resize cursor for the whole
+ /// drag, not just while the pointer happens to still be hovering that
+ /// exact edge (which it usually isn't, once the drag is actually
+ /// underway).
+ pub fn resize_edge(&self) -> Option<ResizeEdge> {
+ self.resize.as_ref().map(|r| r.edge)
+ }
+
// ---- Workspaces -----------------------------------------------------
pub fn add_workspace(&mut self, name: impl Into<String>, layout: impl Into<String>) -> WorkspaceId {
@@ -639,6 +884,17 @@ impl WindowManager {
id
}
+ /// Sets a workspace's display name - used to apply `workspace.names`
+ /// at startup (`crates/srdwm/src/main.rs`'s `apply_workspace_count`),
+ /// since `WindowManager::new`/`add_workspace` otherwise leave every
+ /// workspace named after its own 1-based index regardless of what a
+ /// config asked for. A no-op if `id` doesn't exist.
+ pub fn rename_workspace(&mut self, id: WorkspaceId, name: impl Into<String>) {
+ if let Some(w) = self.workspaces.iter_mut().find(|w| w.id == id) {
+ w.name = name.into();
+ }
+ }
+
pub fn remove_workspace(&mut self, id: WorkspaceId) {
if self.workspaces.len() <= 1 {
return;
@@ -653,9 +909,19 @@ impl WindowManager {
}
}
+ /// Switches to `id`, unless `auto_back_and_forth` is set and `id` is
+ /// already the current workspace - in which case this jumps to
+ /// `previous_workspace` instead, sway's `workspace_auto_back_and_forth`
+ /// behavior. `previous_workspace` itself always tracks "whatever was
+ /// current right before this call changed it", updated on every real
+ /// switch regardless of the setting, so turning the setting on later
+ /// (or a client-driven switch, e.g. `ext_workspace_v1`'s `activate`)
+ /// doesn't need its own separate bookkeeping.
pub fn switch_workspace(&mut self, id: WorkspaceId) {
- if self.workspaces.iter().any(|w| w.id == id) {
- self.current_workspace = id;
+ let target = if self.auto_back_and_forth && id == self.current_workspace { self.previous_workspace } else { id };
+ if self.workspaces.iter().any(|w| w.id == target) && target != self.current_workspace {
+ self.previous_workspace = self.current_workspace;
+ self.current_workspace = target;
}
}
@@ -683,6 +949,19 @@ impl WindowManager {
self.windows.values().filter(|w| w.workspace == self.current_workspace && !w.minimized)
}
+ /// Same windows as [`Self::visible_windows`], but in real front-to-back
+ /// stacking order (topmost first) instead of arbitrary `HashMap`
+ /// iteration order. Needed anywhere a backend composites more than one
+ /// window's elements (content, decoration, border) together and their
+ /// relative order across *different* windows actually matters - unlike
+ /// `visible_windows`, which is fine for anything per-window in
+ /// isolation (border color, geometry) where order never came up.
+ /// `self.order` reversed is the same "topmost first" convention
+ /// `hit_test`/`window_at` already use.
+ pub fn visible_windows_front_to_back(&self) -> impl Iterator<Item = &Window> {
+ self.order.iter().rev().filter_map(|id| self.windows.get(id)).filter(|w| w.workspace == self.current_workspace && !w.minimized)
+ }
+
// ---- Layout -----------------------------------------------------------
pub fn set_layout(&mut self, workspace: WorkspaceId, layout_name: impl Into<String>) {
@@ -854,7 +1133,7 @@ mod tests {
w.geometry = Rect::new(500, 500, 400, 300);
wm.add_window(w);
wm.start_drag(a, 510, 510);
- wm.update_drag(20, 510); // drag far left, within snap threshold of edge 0
+ wm.update_drag(15, 510); // drag far left, landing within snap_threshold (8px) of edge 0
wm.end_drag();
let g = wm.window(a).unwrap().geometry;
assert_eq!(g, Rect::new(0, 0, 960, 1080));
@@ -891,6 +1170,42 @@ mod tests {
}
#[test]
+ fn maximize_records_anim_from_when_animations_enabled() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(50, 50, 300, 200);
+ wm.add_window(w);
+ let placed = wm.window(a).unwrap().geometry;
+ wm.toggle_maximize(a);
+ assert_eq!(wm.window(a).unwrap().anim_from, Some(placed));
+ }
+
+ #[test]
+ fn maximize_does_not_record_anim_from_when_animations_disabled() {
+ let mut wm = wm_with_monitor();
+ wm.animations_enabled = false;
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(50, 50, 300, 200);
+ wm.add_window(w);
+ wm.toggle_maximize(a);
+ assert_eq!(wm.window(a).unwrap().anim_from, None);
+ }
+
+ #[test]
+ fn fullscreen_records_anim_from_covering_the_full_monitor() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(50, 50, 300, 200);
+ wm.add_window(w);
+ let placed = wm.window(a).unwrap().geometry;
+ wm.toggle_fullscreen(a);
+ assert_eq!(wm.window(a).unwrap().anim_from, Some(placed));
+ }
+
+ #[test]
fn directional_focus_picks_nearest_window_in_that_direction() {
let mut wm = wm_with_monitor();
wm.set_layout(wm.current_workspace(), "tiling");
@@ -950,7 +1265,7 @@ mod tests {
let mut wm = wm_with_monitor();
wm.set_layout(wm.current_workspace(), "tiling");
wm.add_rule(WindowRule {
- matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None },
+ matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), ..Default::default() },
actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
});
let id = wm.alloc_window_id();
@@ -962,7 +1277,7 @@ mod tests {
fn non_matching_rule_leaves_window_untouched() {
let mut wm = wm_with_monitor();
wm.add_rule(WindowRule {
- matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), class: None },
+ matcher: crate::rules::WindowMatch { title_contains: Some("calculator".into()), ..Default::default() },
actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
});
let id = wm.alloc_window_id();
@@ -975,7 +1290,7 @@ mod tests {
let mut wm = wm_with_monitor();
let target = wm.add_workspace("scratch", "dynamic");
wm.add_rule(WindowRule {
- matcher: crate::rules::WindowMatch { title_contains: None, class: Some("scratchpad".into()) },
+ matcher: crate::rules::WindowMatch { class: Some("scratchpad".into()), ..Default::default() },
actions: crate::rules::WindowRuleActions { workspace: Some(target), ..Default::default() },
});
let id = wm.alloc_window_id();
@@ -997,6 +1312,150 @@ mod tests {
assert!(wm.workspace(ws2).is_none());
}
+ #[test]
+ fn rename_workspace_changes_the_display_name() {
+ let mut wm = wm_with_monitor();
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.rename_workspace(ws2, "code");
+ assert_eq!(wm.workspace(ws2).unwrap().name, "code");
+ }
+
+ #[test]
+ fn auto_back_and_forth_jumps_to_the_previous_workspace_when_reselecting_the_active_one() {
+ let mut wm = wm_with_monitor();
+ wm.auto_back_and_forth = true;
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ assert_eq!(wm.current_workspace(), ws2);
+ // Re-selecting the already-active workspace jumps back to 0, the
+ // one that was active right before.
+ wm.switch_workspace(ws2);
+ assert_eq!(wm.current_workspace(), 0);
+ }
+
+ #[test]
+ fn without_auto_back_and_forth_reselecting_the_active_workspace_is_a_plain_no_op() {
+ let mut wm = wm_with_monitor();
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ wm.switch_workspace(ws2);
+ assert_eq!(wm.current_workspace(), ws2);
+ }
+
+ #[test]
+ fn switching_to_a_nonexistent_workspace_does_not_move_or_touch_previous() {
+ let mut wm = wm_with_monitor();
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ wm.switch_workspace(9999);
+ assert_eq!(wm.current_workspace(), ws2);
+ // The failed switch must not have overwritten `previous_workspace`
+ // either - auto_back_and_forth would otherwise jump to a
+ // workspace id that was never really visited.
+ wm.auto_back_and_forth = true;
+ wm.switch_workspace(ws2);
+ assert_eq!(wm.current_workspace(), 0);
+ }
+
+ #[test]
+ fn rename_workspace_is_a_no_op_for_an_id_that_does_not_exist() {
+ let mut wm = wm_with_monitor();
+ wm.rename_workspace(9999, "ghost");
+ assert!(wm.workspaces().iter().all(|w| w.name != "ghost"));
+ }
+
+ // ---- Scratchpad --------------------------------------------------------
+
+ #[test]
+ fn scratchpad_add_hides_the_window_and_marks_pool_membership() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ let w = wm.window(a).unwrap();
+ assert!(w.scratchpad);
+ assert!(w.minimized);
+ assert!(w.floating);
+ assert!(!wm.visible_windows().any(|w| w.id == a));
+ }
+
+ #[test]
+ fn scratchpad_show_brings_back_the_hidden_window_and_focuses_it() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ wm.scratchpad_show();
+ let w = wm.window(a).unwrap();
+ assert!(!w.minimized);
+ assert_eq!(wm.focused_id(), Some(a));
+ assert!(wm.visible_windows().any(|w| w.id == a));
+ }
+
+ #[test]
+ fn scratchpad_show_hides_again_when_the_shown_scratchpad_window_is_focused() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ wm.scratchpad_show(); // shows + focuses
+ wm.scratchpad_show(); // toggles back off
+ assert!(wm.window(a).unwrap().minimized);
+ assert!(!wm.visible_windows().any(|w| w.id == a));
+ }
+
+ #[test]
+ fn scratchpad_show_moves_the_window_onto_the_current_workspace() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ wm.scratchpad_show();
+ assert_eq!(wm.window(a).unwrap().workspace, ws2);
+ assert!(wm.visible_windows().any(|w| w.id == a));
+ }
+
+ #[test]
+ fn scratchpad_show_with_no_scratchpad_windows_is_a_no_op() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "normal"));
+ wm.scratchpad_show();
+ assert_eq!(wm.focused_id(), Some(a));
+ assert!(!wm.window(a).unwrap().minimized);
+ }
+
+ #[test]
+ fn scratchpad_show_picks_the_most_recently_added_hidden_window() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "old"));
+ wm.scratchpad_add(a);
+ let b = wm.alloc_window_id();
+ wm.add_window(Window::new(b, "new"));
+ wm.scratchpad_add(b);
+ wm.scratchpad_show();
+ assert_eq!(wm.focused_id(), Some(b));
+ assert!(wm.window(a).unwrap().minimized);
+ }
+
+ #[test]
+ fn scratchpad_remove_leaves_current_visibility_untouched_but_drops_pool_membership() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "term"));
+ wm.scratchpad_add(a);
+ wm.scratchpad_remove(a);
+ assert!(!wm.window(a).unwrap().scratchpad);
+ assert!(wm.window(a).unwrap().minimized);
+ // No longer scratchpad-managed, so a later `scratchpad_show` must
+ // not touch it.
+ wm.scratchpad_show();
+ assert!(wm.window(a).unwrap().minimized);
+ }
+
// ---- Monitor hotplug -------------------------------------------------
fn two_monitors() -> Vec<Monitor> {
@@ -1140,6 +1599,203 @@ mod tests {
}
#[test]
+ fn fullscreen_round_trip_restores_a_client_side_decorated_window_to_undecorated() {
+ // Regression test: exiting fullscreen used to hardcode
+ // `decorated = true` unconditionally, which is only correct for a
+ // window that was decorated to begin with. A window a rule sets
+ // `decorated = false` for (client-side-decorated apps like
+ // Firefox) that goes fullscreen and back used to come back
+ // permanently `decorated = true` - with nothing to ever set it
+ // back, since the client only negotiates its decoration mode once.
+ // Since border/titlebar hit-testing is keyed off `Window.decorated`
+ // directly, this made srdwm swallow every click near the top of
+ // the window as a fake titlebar hit instead of forwarding it to
+ // the client.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "firefox");
+ w.geometry = Rect::new(100, 100, 400, 300);
+ w.decorated = false;
+ wm.add_window(w);
+
+ wm.toggle_fullscreen(id);
+ assert!(!wm.window(id).unwrap().decorated, "fullscreen itself must still drop the titlebar");
+
+ wm.toggle_fullscreen(id);
+ assert!(!wm.window(id).unwrap().decorated, "must restore the pre-fullscreen decorated=false, not default to true");
+ }
+
+ /// A monitor whose usable `geometry` is shrunk by a bottom dock's
+ /// exclusive zone, distinct from its true `full_geometry` - the shape
+ /// every real backend reports once a bar/dock has claimed space (see
+ /// `Monitor::full_geometry`'s doc comment).
+ fn monitor_with_dock() -> Monitor {
+ let mut m = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1020));
+ m.full_geometry = Rect::new(0, 0, 1920, 1080);
+ m.primary = true;
+ m
+ }
+
+ #[test]
+ fn fullscreen_covers_the_full_monitor_ignoring_a_dock_reservation() {
+ // Regression test: fullscreen used to target `Monitor::geometry`
+ // (the usable, exclusive-zone-shrunk area), the same field maximize
+ // correctly uses - so a fullscreened window stopped short of a
+ // dock's reserved strip instead of covering (or going under) it
+ // like fullscreen does everywhere else. `full_geometry` is what
+ // fixes that; `geometry` must stay untouched so maximize keeps
+ // respecting the dock.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+
+ wm.toggle_fullscreen(id);
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080), "fullscreen must reach the true monitor edge, past the dock");
+ }
+
+ #[test]
+ fn maximize_still_respects_the_dock_reservation() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+
+ wm.toggle_maximize(id);
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1020), "maximize must still stop at the dock, unlike fullscreen");
+ }
+
+ #[test]
+ fn maximized_window_grows_when_the_dock_drops_its_reservation_live() {
+ // Regression test: a dock that hides/reduces its exclusive zone
+ // while a window is already maximized (an auto-hide dock reacting
+ // to monocle/maximize, exactly the scenario an AGS peer session hit
+ // live) used to leave that window stuck at its stale, dock-shrunk
+ // size - `set_monitors` updated `Monitor::geometry` correctly but
+ // never touched already-maximized/fullscreen windows' `geometry`,
+ // so nothing re-grew until the window was manually un-maximized and
+ // re-maximized.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ wm.toggle_maximize(id);
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1020));
+
+ // The dock drops its exclusive zone to 0.
+ let mut freed = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1080));
+ freed.full_geometry = Rect::new(0, 0, 1920, 1080);
+ freed.primary = true;
+ wm.set_monitors(vec![freed]);
+
+ assert_eq!(
+ wm.window(id).unwrap().geometry,
+ Rect::new(0, 0, 1920, 1080),
+ "an already-maximized window must live-track a monitor geometry change, not just windows placed afterward"
+ );
+ }
+
+ #[test]
+ fn fullscreen_window_also_live_tracks_a_monitor_geometry_change() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "a"));
+ wm.toggle_fullscreen(id);
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 1920, 1080));
+
+ let mut resized = Monitor::new(0, "primary", Rect::new(0, 0, 2560, 1420));
+ resized.full_geometry = Rect::new(0, 0, 2560, 1440);
+ resized.primary = true;
+ wm.set_monitors(vec![resized]);
+
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(0, 0, 2560, 1440), "fullscreen must live-track the true full rect, not the usable one");
+ }
+
+ #[test]
+ fn a_non_maximized_window_is_left_alone_by_a_monitor_geometry_change() {
+ // set_monitors' new re-sync pass is gated on maximized/fullscreen --
+ // must not clobber an ordinary floating/tiled window's geometry just
+ // because the monitor rect changed underneath it.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "a");
+ w.geometry = Rect::new(100, 100, 400, 300);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 400, 300);
+
+ let mut freed = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1080));
+ freed.full_geometry = Rect::new(0, 0, 1920, 1080);
+ freed.primary = true;
+ wm.set_monitors(vec![freed]);
+
+ assert_eq!(wm.window(id).unwrap().geometry, Rect::new(100, 100, 400, 300));
+ }
+
+ #[test]
+ fn dragging_a_window_can_cross_into_the_dock_reserved_strip() {
+ // Regression test: `update_drag`'s clamp used to also use
+ // `Monitor::geometry` (the shrunk usable area), which made it
+ // physically impossible to ever drag a floating window into the
+ // strip a dock reserves - not just discouraged, genuinely
+ // unreachable at any drag speed or angle. `full_geometry` is what
+ // makes that space reachable again; the dock still renders on top
+ // as an overlay, same as it does everywhere else.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![monitor_with_dock()]);
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "a");
+ w.geometry = Rect::new(500, 500, 200, 200);
+ wm.add_window(w);
+
+ wm.start_drag(id, 600, 600);
+ // Drag far down - past the old usable-area bottom (1020) and
+ // toward the true monitor bottom (1080).
+ wm.update_drag(600, 5000);
+ let g = wm.window(id).unwrap().geometry;
+ // Old behavior (clamped to `geometry`, bottom 1020) would stop at
+ // y=980; clamped to `full_geometry` (bottom 1080), it reaches 1040.
+ assert_eq!(g.y, 1040, "must clamp against the true monitor bottom, not the dock-shrunk usable area");
+ }
+
+ #[test]
+ fn class_rule_applies_once_app_id_is_known_after_creation() {
+ // Regression test: `add_window` matches rules against whatever
+ // `app_id`/`title` the window already has - for a native Wayland
+ // client those are still empty at that moment (the real values
+ // only arrive on a later commit, well after `new_toplevel`), so
+ // every class-based rule - including `srd.rule({ class =
+ // "firefox" }, { decorated = false })`, meant to stop srdwm
+ // drawing a second titlebar over Firefox's own - silently never
+ // matched. `reapply_rules_if_pending` is the retry a backend calls
+ // once the real app_id is known.
+ let mut wm = wm_with_monitor();
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("firefox".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { decorated: Some(false), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ // Empty app_id, exactly as a fresh native Wayland toplevel has it.
+ wm.add_window(Window::new(id, ""));
+ assert!(wm.window(id).unwrap().decorated, "no app_id yet, so no match - must not have flipped early");
+
+ let w = wm.window_mut(id).unwrap();
+ w.app_id = "firefox".into();
+ wm.reapply_rules_if_pending(id);
+ assert!(!wm.window(id).unwrap().decorated, "app_id now known - the rule must apply on retry");
+
+ // A later, unrelated title change (e.g. a browser tab switching)
+ // must not re-match and re-apply - rule actions apply once.
+ let w = wm.window_mut(id).unwrap();
+ w.decorated = true;
+ w.title = "a new tab title".into();
+ wm.reapply_rules_if_pending(id);
+ assert!(wm.window(id).unwrap().decorated, "rules_applied is already true - must not re-run the match");
+ }
+
+ #[test]
fn fullscreen_from_maximized_still_restores_the_pre_maximize_size() {
// Both share `restore_geometry`; entering fullscreen from a
// maximised window must not overwrite it with the monitor rect, or
@@ -1305,4 +1961,34 @@ mod tests {
wm.raise_window(b);
assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(b));
}
+
+ #[test]
+ fn lower_window_sends_it_to_the_back_of_the_stack() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let b = wm.alloc_window_id();
+ wm.add_window(Window::new(b, "b"));
+ let c = wm.alloc_window_id();
+ wm.add_window(Window::new(c, "c"));
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(c), "precondition: c is on top after being added last");
+
+ wm.lower_window(c);
+ let order: Vec<_> = wm.stacking_order().map(|w| w.id).collect();
+ assert_eq!(order, vec![c, a, b], "c must be at the very back, a/b unchanged relative to each other");
+ }
+
+ #[test]
+ fn lower_window_never_buries_a_pinned_window() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let pinned = wm.alloc_window_id();
+ wm.add_window(Window::new(pinned, "pinned"));
+ wm.toggle_always_on_top(pinned);
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned));
+
+ wm.lower_window(a);
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned), "a pinned window must stay on top even after an unrelated lower_window call");
+ }
}