srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core/src/manager
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-07-11 14:17:00 +0200
committersrdusr <[email protected]>2024-07-11 14:17:00 +0200
commit86b557a6c0baa4146b30712d8b7633f1b9b46d43 (patch)
tree96ab749c728ac11c2851ea361c6fda63990e588f /crates/core/src/manager
parent68b645b43e19330474ff106ce5c88dab520d7417 (diff)
downloadsrdwm-86b557a6c0baa4146b30712d8b7633f1b9b46d43.tar.gz
srdwm-86b557a6c0baa4146b30712d8b7633f1b9b46d43.zip
Split crates/core/src/manager.rs (2048 lines) into manager/
Pure reorganization, no behavior change - verified by diffing the function-name set before/after (identical 130 functions) plus a full cargo test pass. WindowManager's struct/field definitions, Default, new(), and the three trivial constructors (add_rule/register_layout/ available_layouts) stay in mod.rs; the rest of the single ~950-line impl block is split into one file per the section comments the file already had (monitors, windows, focus, winops, hittest, dragresize, workspaces, layout). Three methods called across section boundaries (monitor_for, windows_on_workspace, cycle_focus) went from private to pub(super) - Rust's privacy model doesn't let sibling submodules see each other's private items, only a defining module's own descendants. The ~1000-line test module moves to manager/tests.rs unsplit: its helpers (wm_with_monitor, two_monitors, monitor_with_dock) are shared across tests for every section, so splitting further would mean duplicating them or adding another shared-support file for little benefit.
Diffstat (limited to 'crates/core/src/manager')
-rw-r--r--crates/core/src/manager/dragresize.rs92
-rw-r--r--crates/core/src/manager/focus.rs144
-rw-r--r--crates/core/src/manager/hittest.rs51
-rw-r--r--crates/core/src/manager/layout.rs67
-rw-r--r--crates/core/src/manager/mod.rs163
-rw-r--r--crates/core/src/manager/monitors.rs96
-rw-r--r--crates/core/src/manager/tests.rs992
-rw-r--r--crates/core/src/manager/windows.rs227
-rw-r--r--crates/core/src/manager/winops.rs200
-rw-r--r--crates/core/src/manager/workspaces.rs96
10 files changed, 2128 insertions, 0 deletions
diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs
new file mode 100644
index 0000000..06a6db9
--- /dev/null
+++ b/crates/core/src/manager/dragresize.rs
@@ -0,0 +1,92 @@
+//! Interactive drag and resize session state.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Drag / resize ------------------------------------------------------
+
+ pub fn start_drag(&mut self, id: WindowId, x: i32, y: i32) {
+ if let Some(w) = self.windows.get(&id) {
+ self.drag = Some(DragState { window: id, start_x: x, start_y: y, orig: w.geometry });
+ self.focus_window(id);
+ }
+ }
+
+ pub fn update_drag(&mut self, x: i32, y: i32) {
+ let Some(drag) = &self.drag else { return };
+ let (dx, dy) = (x - drag.start_x, y - drag.start_y);
+ let mut new_geom = drag.orig;
+ new_geom.x += dx;
+ new_geom.y += dy;
+
+ // `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);
+ }
+
+ if let Some(w) = self.windows.get_mut(&drag.window) {
+ w.geometry = new_geom;
+ }
+ }
+
+ /// Ends a drag, snapping into a Windows-Snap zone if the pointer ended up
+ /// near a monitor edge.
+ pub fn end_drag(&mut self) {
+ if let Some(drag) = self.drag.take() {
+ let snapped = self.windows.get(&drag.window).and_then(|w| {
+ self.monitor_for(w.monitor).and_then(|m| SmartPlacement::snap_zone(w.geometry, m, &self.placement))
+ });
+ if let (Some(zone), Some(w)) = (snapped, self.windows.get_mut(&drag.window)) {
+ w.geometry = zone;
+ }
+ }
+ }
+
+ pub fn is_dragging(&self) -> bool {
+ self.drag.is_some()
+ }
+
+ pub fn start_resize(&mut self, id: WindowId, edge: ResizeEdge, x: i32, y: i32) {
+ if let Some(w) = self.windows.get(&id) {
+ self.resize = Some(ResizeState { window: id, edge, start_x: x, start_y: y, orig: w.geometry });
+ self.focus_window(id);
+ }
+ }
+
+ pub fn update_resize(&mut self, x: i32, y: i32) {
+ let Some(r) = &self.resize else { return };
+ let (dx, dy) = (x - r.start_x, y - r.start_y);
+ let new_geom = r.edge.apply_delta(r.orig, dx, dy, MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
+ if let Some(w) = self.windows.get_mut(&r.window) {
+ w.geometry = new_geom;
+ }
+ }
+
+ pub fn end_resize(&mut self) {
+ self.resize = None;
+ }
+
+ pub fn is_resizing(&self) -> bool {
+ 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)
+ }
+
+}
diff --git a/crates/core/src/manager/focus.rs b/crates/core/src/manager/focus.rs
new file mode 100644
index 0000000..c281d7c
--- /dev/null
+++ b/crates/core/src/manager/focus.rs
@@ -0,0 +1,144 @@
+//! Focus queries and directional/cyclic focus movement.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Focus ----------------------------------------------------------
+
+ pub fn focused_window(&self) -> Option<&Window> {
+ self.focused.and_then(|id| self.windows.get(&id))
+ }
+
+ pub fn focused_id(&self) -> Option<WindowId> {
+ self.focused
+ }
+
+ pub fn focus_window(&mut self, id: WindowId) {
+ if self.windows.contains_key(&id) {
+ self.focused = Some(id);
+ self.raise_window(id);
+ }
+ }
+
+ pub(super) fn cycle_focus(&mut self, forward: bool) {
+ let ids: Vec<WindowId> = self.windows_on_workspace(self.current_workspace).filter(|w| !w.minimized).map(|w| w.id).collect();
+ if ids.is_empty() {
+ self.focused = None;
+ return;
+ }
+ let cur_pos = self.focused.and_then(|f| ids.iter().position(|&i| i == f));
+ let next = match cur_pos {
+ None => 0,
+ Some(p) if forward => (p + 1) % ids.len(),
+ Some(p) => (p + ids.len() - 1) % ids.len(),
+ };
+ self.focus_window(ids[next]);
+ }
+
+ pub fn focus_next(&mut self) {
+ self.cycle_focus(true);
+ }
+
+ pub fn focus_previous(&mut self) {
+ self.cycle_focus(false);
+ }
+
+ /// Vim-style directional focus: picks the nearest window whose center
+ /// lies in `dir` relative to the focused window's center, on the same
+ /// workspace. Returns the newly focused window, if any.
+ /// Nearest window to the focused one in `dir`, by a distance biased
+ /// toward the requested axis so a window that's mostly to the left
+ /// (small |dy|) beats a diagonally-placed one - matching how
+ /// i3/sway-style directional focus feels.
+ ///
+ /// Shared by [`Self::focus_direction`] and [`Self::move_window_direction`]
+ /// so "the window to the left" means the same thing whether you're
+ /// focusing it or swapping with it.
+ pub fn neighbour_in(&self, dir: Direction) -> Option<WindowId> {
+ let (fx, fy, fid) = {
+ let focused = self.focused_window()?;
+ let (fx, fy) = focused.geometry.center();
+ (fx, fy, focused.id)
+ };
+ let workspace = self.current_workspace;
+ let mut best: Option<(WindowId, i64)> = None;
+ for w in self.windows_on_workspace(workspace).filter(|w| w.id != fid && !w.minimized) {
+ let (cx, cy) = w.geometry.center();
+ let (dx, dy) = ((cx - fx) as i64, (cy - fy) as i64);
+ let matches = match dir {
+ Direction::Left => dx < 0,
+ Direction::Right => dx > 0,
+ Direction::Up => dy < 0,
+ Direction::Down => dy > 0,
+ };
+ if !matches {
+ continue;
+ }
+ let (primary, secondary) = match dir {
+ Direction::Left | Direction::Right => (dx, dy),
+ Direction::Up | Direction::Down => (dy, dx),
+ };
+ let dist = primary * primary + secondary * secondary * 4;
+ if best.is_none_or(|(_, d)| dist < d) {
+ best = Some((w.id, dist));
+ }
+ }
+ best.map(|(id, _)| id)
+ }
+
+ pub fn focus_direction(&mut self, dir: Direction) -> Option<WindowId> {
+ let target = self.neighbour_in(dir);
+ if let Some(id) = target {
+ self.focus_window(id);
+ }
+ target
+ }
+
+ /// Moves the focused window in `dir` by swapping places with its
+ /// neighbour there - the `movewindow l/r/u/d` gesture.
+ ///
+ /// Swapping (rather than nudging by a fixed step) is what makes this
+ /// useful in both of srdwm's modes: under tiling it reorders the layout,
+ /// and in dynamic/floating mode two windows trade positions, which is
+ /// predictable either way. With no neighbour in that direction the
+ /// window is pushed to the corresponding edge of its monitor instead, so
+ /// the key still does something sensible.
+ pub fn move_window_direction(&mut self, dir: Direction) -> Option<WindowId> {
+ let focused = self.focused_id()?;
+ match self.neighbour_in(dir) {
+ Some(other) => {
+ let a = self.windows.get(&focused)?.geometry;
+ let b = self.windows.get(&other)?.geometry;
+ if let Some(w) = self.windows.get_mut(&focused) {
+ w.geometry = b;
+ }
+ if let Some(w) = self.windows.get_mut(&other) {
+ w.geometry = a;
+ }
+ // Keep stacking order in step so a tiling layout, which
+ // assigns slots from `order`, actually reflects the swap.
+ let (ia, ib) = (
+ self.order.iter().position(|&id| id == focused)?,
+ self.order.iter().position(|&id| id == other)?,
+ );
+ self.order.swap(ia, ib);
+ Some(other)
+ }
+ None => {
+ let mon = self.windows.get(&focused).and_then(|w| self.monitor_for(w.monitor))?.geometry;
+ let w = self.windows.get_mut(&focused)?;
+ match dir {
+ Direction::Left => w.geometry.x = mon.x,
+ Direction::Right => w.geometry.x = mon.right() - w.geometry.width as i32,
+ Direction::Up => w.geometry.y = mon.y,
+ Direction::Down => w.geometry.y = mon.bottom() - w.geometry.height as i32,
+ }
+ None
+ }
+ }
+ }
+
+}
diff --git a/crates/core/src/manager/hittest.rs b/crates/core/src/manager/hittest.rs
new file mode 100644
index 0000000..918acb5
--- /dev/null
+++ b/crates/core/src/manager/hittest.rs
@@ -0,0 +1,51 @@
+//! Pointer hit-testing against a window's titlebar/border/content.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Hit testing ------------------------------------------------------
+
+ /// Topmost window whose frame contains `(x, y)`, along with what part of
+ /// its titlebar/border was hit (button, drag area, resize edge).
+ pub fn hit_test(&self, x: i32, y: i32) -> Option<(WindowId, TitlebarHit)> {
+ for w in self.order.iter().rev().filter_map(|id| self.windows.get(id)) {
+ if w.minimized {
+ continue;
+ }
+ if let Some(hit) = ResizeEdge::hit_test(w.geometry, x, y, w.decorated, w.border_width, self.resize_margin) {
+ return Some((w.id, hit));
+ }
+ }
+ None
+ }
+
+ /// Topmost non-minimised window containing a point, ignoring
+ /// decorations. Used for modifier+drag, where the grab applies anywhere
+ /// in the window rather than only on the titlebar (`hit_test`).
+ pub fn window_at(&self, x: i32, y: i32) -> Option<WindowId> {
+ self.order
+ .iter()
+ .rev()
+ .filter_map(|id| self.windows.get(id))
+ .find(|w| !w.minimized && w.geometry.contains_point(x, y))
+ .map(|w| w.id)
+ }
+
+ /// The corner of `id` nearest a point, for modifier+right-drag resize:
+ /// grabbing the closest corner is what makes the gesture feel like it
+ /// pulls the edge you aimed at (matching Hyprland's `resizewindow`).
+ pub fn nearest_corner(&self, id: WindowId, x: i32, y: i32) -> ResizeEdge {
+ let Some(w) = self.windows.get(&id) else { return ResizeEdge::BottomRight };
+ let (cx, cy) = w.geometry.center();
+ match (x < cx, y < cy) {
+ (true, true) => ResizeEdge::TopLeft,
+ (false, true) => ResizeEdge::TopRight,
+ (true, false) => ResizeEdge::BottomLeft,
+ (false, false) => ResizeEdge::BottomRight,
+ }
+ }
+
+}
diff --git a/crates/core/src/manager/layout.rs b/crates/core/src/manager/layout.rs
new file mode 100644
index 0000000..002d0aa
--- /dev/null
+++ b/crates/core/src/manager/layout.rs
@@ -0,0 +1,67 @@
+//! Layout selection and running a workspace's active layout to produce final geometries.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Layout -----------------------------------------------------------
+
+ pub fn set_layout(&mut self, workspace: WorkspaceId, layout_name: impl Into<String>) {
+ let layout_name = layout_name.into();
+ if let Some(w) = self.workspaces.iter_mut().find(|w| w.id == workspace) {
+ w.layout = layout_name;
+ }
+ }
+
+ pub fn layout_name(&self, workspace: WorkspaceId) -> Option<&str> {
+ self.workspace(workspace).map(|w| w.layout.as_str())
+ }
+
+ /// Recomputes geometry for all non-floating, non-minimized windows on
+ /// `workspace`, grouped by the monitor each window is assigned to, and
+ /// applies the results in place. Returns the changed `(id, Rect)` pairs
+ /// so a backend can push them to real surfaces.
+ pub fn arrange_workspace(&mut self, workspace: WorkspaceId) -> Vec<(WindowId, Rect)> {
+ let Some(layout_name) = self.workspace(workspace).map(|w| w.layout.clone()) else {
+ return Vec::new();
+ };
+ let Some(layout) = self.layouts.get(&layout_name) else {
+ log::warn!("unknown layout '{layout_name}' for workspace {workspace}");
+ return Vec::new();
+ };
+
+ // Grouped via `self.order` (insertion/stacking order), not
+ // `self.windows.values()`: HashMap iteration order is randomized
+ // per-process, which would make master/stack assignment reshuffle
+ // unpredictably every time this runs (it runs on every window
+ // create/destroy/keybinding).
+ let mut by_monitor: HashMap<MonitorId, Vec<WindowId>> = HashMap::new();
+ for &id in &self.order {
+ let Some(w) = self.windows.get(&id) else { continue };
+ // Fullscreen windows own their whole monitor, so tiling must
+ // leave them alone, exactly as it does floating ones.
+ if w.workspace == workspace && !w.minimized && !w.floating && !w.fullscreen {
+ by_monitor.entry(w.monitor).or_default().push(id);
+ }
+ }
+
+ let mut monitor_ids: Vec<MonitorId> = by_monitor.keys().copied().collect();
+ monitor_ids.sort_unstable();
+
+ let mut changes = Vec::new();
+ for monitor_id in monitor_ids {
+ let ids = &by_monitor[&monitor_id];
+ let Some(monitor) = self.monitor_for(monitor_id).cloned() else { continue };
+ let placements = layout.arrange(ids, &monitor, &self.tiling);
+ for (id, rect) in placements {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.geometry = rect;
+ }
+ changes.push((id, rect));
+ }
+ }
+ changes
+ }
+}
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
new file mode 100644
index 0000000..91df887
--- /dev/null
+++ b/crates/core/src/manager/mod.rs
@@ -0,0 +1,163 @@
+use crate::geometry::Rect;
+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, RESIZE_MARGIN};
+use crate::workspace::{Workspace, WorkspaceId};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+ Left,
+ Right,
+ Up,
+ Down,
+}
+
+struct DragState {
+ window: WindowId,
+ start_x: i32,
+ start_y: i32,
+ orig: Rect,
+}
+
+struct ResizeState {
+ window: WindowId,
+ edge: ResizeEdge,
+ start_x: i32,
+ start_y: i32,
+ orig: Rect,
+}
+
+/// The platform-independent core of srdwm: owns window/workspace/monitor
+/// state and layout policy. Backends (X11, Wayland, ...) drive this via
+/// `add_window`/`remove_window`/input events, and apply the `Rect`s it
+/// computes back onto real surfaces.
+pub struct WindowManager {
+ windows: HashMap<WindowId, Window>,
+ order: Vec<WindowId>,
+ focused: Option<WindowId>,
+ 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,
+ /// Whether windows get a drop shadow. Read from `general.shadows`. A
+ /// maximized or fullscreen window never gets one regardless of this --
+ /// 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,
+ /// 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.
+ pub resize_margin: i32,
+ /// Whether a decorated window's content rounds its bottom two corners
+ /// to match the titlebar's own curve (an undecorated/CSD window rounds
+ /// all four). Read from `general.rounded_corners` - `None` when the
+ /// user's config never touched that key at all (deliberately *not*
+ /// defaulted in `crates/config`, unlike every other `general.*` key),
+ /// so each backend can fall back to its own default rather than one
+ /// baked in here: GLES/winit defaults on, udev/Pixman defaults off
+ /// (an untested-on-real-hardware per-frame CPU cost for content that
+ /// redraws constantly - see `crates/wayland/src/rounded_corners.rs`).
+ /// `Some(_)` only when the user explicitly set it, and wins either way.
+ pub rounded_corners_enabled: Option<bool>,
+ /// 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 {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl WindowManager {
+ pub fn new() -> Self {
+ let mut layouts: HashMap<String, Box<dyn Layout>> = HashMap::new();
+ layouts.insert("tiling".into(), Box::new(MasterStackLayout));
+ layouts.insert("dynamic".into(), Box::new(NoOpLayout("dynamic")));
+ layouts.insert("floating".into(), Box::new(NoOpLayout("floating")));
+
+ Self {
+ windows: HashMap::new(),
+ order: Vec::new(),
+ focused: None,
+ 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,
+ shadows_enabled: true,
+ resize_margin: RESIZE_MARGIN,
+ rounded_corners_enabled: None,
+ theme: ThemeConfig::default(),
+ drag: None,
+ resize: None,
+ rules: Vec::new(),
+ close_requests: Vec::new(),
+ }
+ }
+
+ /// Registers a window rule; on every subsequent `add_window`, the first
+ /// rule whose matcher matches the new window has its actions applied.
+ pub fn add_rule(&mut self, rule: WindowRule) {
+ self.rules.push(rule);
+ }
+
+ pub fn register_layout(&mut self, name: impl Into<String>, layout: Box<dyn Layout>) {
+ self.layouts.insert(name.into(), layout);
+ }
+
+ pub fn available_layouts(&self) -> Vec<&str> {
+ self.layouts.keys().map(String::as_str).collect()
+ }
+
+}
+
+mod dragresize;
+mod focus;
+mod hittest;
+mod layout;
+mod monitors;
+mod windows;
+mod winops;
+mod workspaces;
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/core/src/manager/monitors.rs b/crates/core/src/manager/monitors.rs
new file mode 100644
index 0000000..173e07d
--- /dev/null
+++ b/crates/core/src/manager/monitors.rs
@@ -0,0 +1,96 @@
+//! Monitor list, hotplug rehoming, and lookups.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Monitors ----------------------------------------------------
+
+ /// Replaces the monitor list, rehoming any window left stranded.
+ ///
+ /// Called at startup and again on every hotplug. Unplugging a monitor
+ /// would otherwise leave its windows pointing at a `monitor` id that no
+ /// longer exists: `arrange_workspace` skips those (it looks the monitor
+ /// up to get a rectangle), so they would stop being tiled, and a
+ /// floating window would sit at coordinates that are no longer on any
+ /// screen - unreachable, with no way to drag it back.
+ ///
+ /// Stranded windows are moved to the primary monitor and, if their
+ /// geometry falls outside it, nudged back inside.
+ ///
+ /// This keys off **geometry**, not just the `monitor` field. That field
+ /// records which monitor a window was *assigned* at creation and does
+ /// not track where the window actually is: a floating window dragged --
+ /// or placed by a rule - onto a second monitor keeps `monitor`
+ /// pointing at the first. Trusting the field alone left such a window
+ /// at coordinates that no longer existed once its real monitor was
+ /// unplugged: off-screen and unreachable, with no way to drag it back.
+ /// Found by unplugging a monitor out from under a window in the QEMU VM
+ /// and watching it vanish; the field-only check had passed its unit
+ /// tests because those set `monitor` explicitly.
+ pub fn set_monitors(&mut self, monitors: Vec<Monitor>) {
+ self.monitors = monitors;
+
+ let Some(primary) = self.primary_monitor().cloned() else {
+ // No monitors at all (every output unplugged): leave windows
+ // as-is rather than collapsing them onto nothing, so they are
+ // restored intact when an output comes back.
+ return;
+ };
+ let live = self.monitors.clone();
+ for window in self.windows.values_mut() {
+ let visible_on = live.iter().find(|m| m.geometry.overlaps(&window.geometry));
+ match visible_on {
+ // Still on screen: just make sure its monitor id points at a
+ // monitor that exists, so tiling keeps working.
+ Some(monitor) => {
+ if !live.iter().any(|m| m.id == window.monitor) {
+ window.monitor = monitor.id;
+ }
+ }
+ // Nothing on screen shows this window any more.
+ None => {
+ window.geometry = window.geometry.clamped_into(primary.geometry);
+ window.monitor = primary.id;
+ }
+ }
+ }
+ // 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] {
+ &self.monitors
+ }
+
+ pub fn primary_monitor(&self) -> Option<&Monitor> {
+ self.monitors.iter().find(|m| m.primary).or_else(|| self.monitors.first())
+ }
+
+ pub(super) fn monitor_for(&self, id: MonitorId) -> Option<&Monitor> {
+ self.monitors.iter().find(|m| m.id == id).or_else(|| self.primary_monitor())
+ }
+
+}
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
new file mode 100644
index 0000000..a098245
--- /dev/null
+++ b/crates/core/src/manager/tests.rs
@@ -0,0 +1,992 @@
+ use super::*;
+
+ fn wm_with_monitor() -> WindowManager {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(vec![{
+ let mut m = Monitor::new(0, "primary", Rect::new(0, 0, 1920, 1080));
+ m.primary = true;
+ m
+ }]);
+ wm
+ }
+
+ #[test]
+ fn new_window_on_dynamic_workspace_uses_smart_placement() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "first");
+ w.geometry = Rect::new(0, 0, 400, 300);
+ wm.add_window(w);
+ let placed = wm.window(id).unwrap().geometry;
+ // Grid placement starts at grid_margin, not (0,0).
+ assert_eq!(placed.x, wm.placement.grid_margin as i32);
+ }
+
+ #[test]
+ fn tiling_workspace_arranges_two_windows_side_by_side() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ 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"));
+
+ wm.arrange_workspace(wm.current_workspace());
+ let ra = wm.window(a).unwrap().geometry;
+ let rb = wm.window(b).unwrap().geometry;
+ assert!(!ra.overlaps(&rb));
+ assert_eq!(ra.y, rb.y);
+ assert!(ra.x < rb.x);
+ }
+
+ #[test]
+ fn floating_window_is_skipped_by_tiling_arrange() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ wm.toggle_floating(a);
+ let before = wm.window(a).unwrap().geometry;
+ wm.arrange_workspace(wm.current_workspace());
+ assert_eq!(wm.window(a).unwrap().geometry, before);
+ }
+
+ #[test]
+ fn focus_cycles_forward_and_wraps() {
+ 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"));
+ // `b` was added last, so it's focused.
+ assert_eq!(wm.focused_id(), Some(b));
+ wm.focus_next();
+ assert_eq!(wm.focused_id(), Some(a));
+ wm.focus_next();
+ assert_eq!(wm.focused_id(), Some(b));
+ }
+
+ #[test]
+ fn minimized_window_is_skipped_by_focus_cycling() {
+ 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"));
+ wm.minimize_window(a);
+ wm.focus_window(b);
+ wm.focus_next();
+ assert_eq!(wm.focused_id(), Some(b), "only unminimized window should ever be focused");
+ }
+
+ #[test]
+ fn drag_moves_window_by_pointer_delta() {
+ let mut wm = wm_with_monitor();
+ // "tiling" layout leaves add_window's requested geometry alone;
+ // "dynamic"/"floating" would override it via SmartPlacement, which
+ // these tests aren't exercising.
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(300, 300, 400, 300);
+ wm.add_window(w);
+ wm.start_drag(a, 310, 310);
+ wm.update_drag(360, 340);
+ let g = wm.window(a).unwrap().geometry;
+ assert_eq!((g.x, g.y), (350, 330));
+ wm.end_drag();
+ assert!(!wm.is_dragging());
+ }
+
+ #[test]
+ fn drag_ending_near_edge_snaps_to_half_screen() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(500, 500, 400, 300);
+ wm.add_window(w);
+ wm.start_drag(a, 510, 510);
+ 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));
+ }
+
+ #[test]
+ fn resize_from_bottom_right_grows_size_only() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "a");
+ w.geometry = Rect::new(100, 100, 300, 200);
+ wm.add_window(w);
+ wm.start_resize(a, ResizeEdge::BottomRight, 400, 300);
+ wm.update_resize(450, 340);
+ let g = wm.window(a).unwrap().geometry;
+ assert_eq!(g, Rect::new(100, 100, 350, 240));
+ wm.end_resize();
+ assert!(!wm.is_resizing());
+ }
+
+ #[test]
+ fn toggle_maximize_restores_original_geometry() {
+ 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 original = wm.window(a).unwrap().geometry;
+ wm.toggle_maximize(a);
+ assert_eq!(wm.window(a).unwrap().geometry, Rect::new(0, 0, 1920, 1080));
+ wm.toggle_maximize(a);
+ assert_eq!(wm.window(a).unwrap().geometry, original);
+ }
+
+ #[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");
+ let center = wm.alloc_window_id();
+ let mut wc = Window::new(center, "center");
+ wc.geometry = Rect::new(500, 500, 100, 100);
+ wm.add_window(wc);
+ let left = wm.alloc_window_id();
+ let mut wl = Window::new(left, "left");
+ wl.geometry = Rect::new(0, 500, 100, 100);
+ wm.add_window(wl);
+ let right = wm.alloc_window_id();
+ let mut wr = Window::new(right, "right");
+ wr.geometry = Rect::new(1000, 500, 100, 100);
+ wm.add_window(wr);
+
+ wm.focus_window(center);
+ assert_eq!(wm.focus_direction(Direction::Left), Some(left));
+ assert_eq!(wm.focused_id(), Some(left));
+
+ wm.focus_window(center);
+ assert_eq!(wm.focus_direction(Direction::Right), Some(right));
+ }
+
+ #[test]
+ fn hit_test_prefers_topmost_window() {
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ let mut wa = Window::new(a, "a");
+ wa.geometry = Rect::new(0, 0, 400, 300);
+ wm.add_window(wa);
+ let b = wm.alloc_window_id();
+ let mut wb = Window::new(b, "b");
+ wb.geometry = Rect::new(0, 0, 400, 300); // fully overlapping, added later -> on top
+ wm.add_window(wb);
+
+ let (hit_id, hit) = wm.hit_test(200, 10).unwrap();
+ assert_eq!(hit_id, b);
+ assert_eq!(hit, TitlebarHit::Drag);
+ }
+
+ #[test]
+ fn moving_window_to_another_workspace_removes_it_from_current() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.move_window_to_workspace(a, ws2);
+ assert_eq!(wm.visible_windows().count(), 0);
+ wm.switch_workspace(ws2);
+ assert_eq!(wm.visible_windows().count(), 1);
+ }
+
+ #[test]
+ fn matching_rule_floats_new_window_on_add() {
+ 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()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "Calculator"));
+ assert!(wm.is_floating(id));
+ }
+
+ #[test]
+ 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()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { floating: Some(true), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, "Terminal"));
+ assert!(!wm.is_floating(id));
+ }
+
+ #[test]
+ fn rule_assigns_window_to_target_workspace() {
+ let mut wm = wm_with_monitor();
+ let target = wm.add_workspace("scratch", "dynamic");
+ wm.add_rule(WindowRule {
+ 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();
+ let mut w = Window::new(id, "notes");
+ w.app_id = "scratchpad".into();
+ wm.add_window(w);
+ assert_eq!(wm.window(id).unwrap().workspace, target);
+ }
+
+ #[test]
+ fn removing_a_workspace_reassigns_its_windows() {
+ let mut wm = wm_with_monitor();
+ let ws2 = wm.add_workspace("2", "dynamic");
+ wm.switch_workspace(ws2);
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ wm.remove_workspace(ws2);
+ assert_ne!(wm.window(a).unwrap().workspace, ws2);
+ 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> {
+ let mut a = Monitor::new(0, "primary", Rect::new(0, 0, 1280, 800));
+ a.primary = true;
+ let b = Monitor::new(1, "secondary", Rect::new(1280, 0, 1920, 1080));
+ vec![a, b]
+ }
+
+ #[test]
+ fn unplugging_a_monitor_rehomes_its_windows_to_the_primary() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "on-second-monitor");
+ w.geometry = Rect::new(1500, 200, 600, 400); // inside monitor 1 only
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 1;
+
+ // Monitor 1 goes away.
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let w = wm.window(id).unwrap();
+ assert_eq!(w.monitor, 0, "window should be rehomed to the primary monitor");
+ assert!(
+ Rect::new(0, 0, 1280, 800).overlaps(&w.geometry),
+ "rehomed window should be on-screen, got {:?}",
+ w.geometry
+ );
+ }
+
+ #[test]
+ fn windows_already_on_a_surviving_monitor_are_left_alone() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "on-primary");
+ w.geometry = Rect::new(10, 20, 300, 200);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 0;
+ wm.window_mut(id).unwrap().geometry = Rect::new(10, 20, 300, 200);
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let w = wm.window(id).unwrap();
+ assert_eq!(w.monitor, 0);
+ assert_eq!(w.geometry, Rect::new(10, 20, 300, 200), "untouched window must not move");
+ }
+
+ #[test]
+ fn a_window_still_overlapping_the_primary_keeps_its_geometry() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "straddling");
+ wm.add_window(w.clone());
+ // Straddles the boundary, so it still overlaps the primary.
+ w.geometry = Rect::new(1200, 100, 400, 300);
+ wm.window_mut(id).unwrap().monitor = 1;
+ wm.window_mut(id).unwrap().geometry = w.geometry;
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let got = wm.window(id).unwrap();
+ assert_eq!(got.monitor, 0, "monitor id must still be remapped");
+ assert_eq!(got.geometry, Rect::new(1200, 100, 400, 300), "already-visible geometry should be kept");
+ }
+
+ #[test]
+ fn losing_every_monitor_leaves_windows_intact_for_when_one_returns() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "orphan");
+ w.geometry = Rect::new(1500, 200, 600, 400);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().monitor = 1;
+ wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400);
+
+ wm.set_monitors(Vec::new());
+
+ let got = wm.window(id).unwrap();
+ assert_eq!(got.geometry, Rect::new(1500, 200, 600, 400));
+ assert_eq!(got.monitor, 1);
+ }
+
+ #[test]
+ fn a_window_whose_monitor_field_is_stale_is_still_rescued() {
+ // Regression: `add_window` assigns `monitor` from the *primary*
+ // monitor, so a window placed on the second monitor by a rule (or
+ // dragged there) keeps `monitor == 0`. Rehoming that keyed off the
+ // field alone skipped this window entirely and left it off-screen.
+ // Reproduced live by unplugging a monitor out from under an xterm.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+
+ let id = wm.alloc_window_id();
+ let w = Window::new(id, "placed-by-rule");
+ wm.add_window(w);
+ // Geometry on monitor 1, but `monitor` still says 0 - exactly what
+ // add_window + a geometry rule produce.
+ wm.window_mut(id).unwrap().geometry = Rect::new(1500, 200, 600, 400);
+ assert_eq!(wm.window(id).unwrap().monitor, 0, "precondition: stale field");
+
+ wm.set_monitors(vec![two_monitors().remove(0)]);
+
+ let got = wm.window(id).unwrap();
+ assert!(
+ Rect::new(0, 0, 1280, 800).overlaps(&got.geometry),
+ "window must be pulled back on-screen, got {:?}",
+ got.geometry
+ );
+ }
+
+ // ---- Fullscreen ------------------------------------------------------
+
+ #[test]
+ fn fullscreen_covers_the_monitor_and_restores_the_original_geometry() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "app");
+ w.geometry = Rect::new(100, 100, 400, 300);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().geometry = Rect::new(100, 100, 400, 300);
+
+ wm.toggle_fullscreen(id);
+ let got = wm.window(id).unwrap();
+ assert!(got.fullscreen);
+ assert_eq!(got.geometry, Rect::new(0, 0, 1280, 800), "should cover the whole monitor");
+ assert!(!got.decorated, "fullscreen must drop the titlebar");
+
+ wm.toggle_fullscreen(id);
+ let got = wm.window(id).unwrap();
+ assert!(!got.fullscreen);
+ assert_eq!(got.geometry, Rect::new(100, 100, 400, 300));
+ assert!(got.decorated);
+ }
+
+ #[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 opacity_rule_applies_on_the_deferred_retry_same_as_other_actions() {
+ // Regression test: `opacity` was added to `add_window`'s own rule
+ // application but missed here, in the deferred retry
+ // `reapply_rules_if_pending` - confirmed live: a rule like
+ // `srd.rule({ class = "Alacritty" }, { opacity = 0.4 })` never took
+ // effect for any real native Wayland client, since (per the test
+ // above) that's the *only* path a class-based rule actually
+ // matches through for one of those - `add_window`'s own match
+ // attempt always fails first, against an as-yet-empty `app_id`.
+ let mut wm = wm_with_monitor();
+ wm.add_rule(WindowRule {
+ matcher: crate::rules::WindowMatch { class: Some("alacritty".into()), ..Default::default() },
+ actions: crate::rules::WindowRuleActions { opacity: Some(0.4), ..Default::default() },
+ });
+ let id = wm.alloc_window_id();
+ wm.add_window(Window::new(id, ""));
+ assert_eq!(wm.window(id).unwrap().opacity, 1.0, "no app_id yet, so no match - must not have applied early");
+
+ let w = wm.window_mut(id).unwrap();
+ w.app_id = "Alacritty".into();
+ wm.reapply_rules_if_pending(id);
+ assert_eq!(wm.window(id).unwrap().opacity, 0.4, "app_id now known - the rule must apply on retry");
+ }
+
+ #[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
+ // the window could never get its real size back.
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "app");
+ w.geometry = Rect::new(50, 60, 300, 200);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().geometry = Rect::new(50, 60, 300, 200);
+
+ wm.toggle_maximize(id);
+ wm.toggle_fullscreen(id);
+ assert!(wm.is_fullscreen(id));
+ assert!(!wm.window(id).unwrap().maximized, "the two states are mutually exclusive");
+
+ wm.toggle_fullscreen(id);
+ assert_eq!(
+ wm.window(id).unwrap().geometry,
+ Rect::new(50, 60, 300, 200),
+ "must restore the size from before maximise, not the monitor rect"
+ );
+ }
+
+ #[test]
+ fn tiling_leaves_fullscreen_windows_alone() {
+ let mut wm = WindowManager::new();
+ wm.set_monitors(two_monitors());
+ wm.set_layout(wm.current_workspace(), "tiling");
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "tiled"));
+ let b = wm.alloc_window_id();
+ wm.add_window(Window::new(b, "full"));
+ wm.toggle_fullscreen(b);
+
+ let changes = wm.arrange_workspace(wm.current_workspace());
+ assert!(
+ !changes.iter().any(|(id, _)| *id == b),
+ "a fullscreen window must not be re-tiled"
+ );
+ assert_eq!(wm.window(b).unwrap().geometry, Rect::new(0, 0, 1280, 800));
+ }
+
+ // ---- Directional move ------------------------------------------------
+
+ #[test]
+ fn moving_a_window_swaps_it_with_its_neighbour() {
+ let mut wm = wm_with_monitor();
+ let left = wm.alloc_window_id();
+ let mut a = Window::new(left, "left");
+ a.geometry = Rect::new(0, 0, 400, 400);
+ wm.add_window(a);
+ wm.window_mut(left).unwrap().geometry = Rect::new(0, 0, 400, 400);
+
+ let right = wm.alloc_window_id();
+ let mut b = Window::new(right, "right");
+ b.geometry = Rect::new(600, 0, 400, 400);
+ wm.add_window(b);
+ wm.window_mut(right).unwrap().geometry = Rect::new(600, 0, 400, 400);
+
+ wm.focus_window(left);
+ let swapped = wm.move_window_direction(Direction::Right);
+
+ assert_eq!(swapped, Some(right));
+ assert_eq!(wm.window(left).unwrap().geometry, Rect::new(600, 0, 400, 400));
+ assert_eq!(wm.window(right).unwrap().geometry, Rect::new(0, 0, 400, 400));
+ }
+
+ #[test]
+ fn moving_with_no_neighbour_pushes_to_the_monitor_edge() {
+ let mut wm = wm_with_monitor();
+ let id = wm.alloc_window_id();
+ let mut w = Window::new(id, "only");
+ w.geometry = Rect::new(500, 300, 200, 150);
+ wm.add_window(w);
+ wm.window_mut(id).unwrap().geometry = Rect::new(500, 300, 200, 150);
+ wm.focus_window(id);
+
+ assert_eq!(wm.move_window_direction(Direction::Left), None);
+ assert_eq!(wm.window(id).unwrap().geometry.x, 0, "should hug the left edge");
+
+ wm.move_window_direction(Direction::Down);
+ let g = wm.window(id).unwrap().geometry;
+ let mon = wm.primary_monitor().unwrap().geometry;
+ assert_eq!(g.bottom(), mon.bottom(), "should hug the bottom edge");
+ }
+
+ #[test]
+ fn swapping_also_reorders_the_stack_so_tiling_follows() {
+ // Under tiling the layout assigns slots from `order`, so a swap that
+ // only exchanged geometry would be undone by the next arrange.
+ let mut wm = wm_with_monitor();
+ wm.set_layout(wm.current_workspace(), "tiling");
+ 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"));
+ wm.arrange_workspace(wm.current_workspace());
+
+ // Snapshot *after* focusing: `focus_window` raises, which reorders
+ // on its own and would otherwise mask what the move did.
+ wm.focus_window(a);
+ let order_before: Vec<_> = wm.stacking_order().map(|w| w.id).collect();
+ wm.move_window_direction(Direction::Right);
+ let order_after: Vec<_> = wm.stacking_order().map(|w| w.id).collect();
+
+ assert_ne!(order_before, order_after, "stacking order must reflect the swap");
+ assert_eq!(
+ order_after,
+ order_before.iter().rev().copied().collect::<Vec<_>>(),
+ "the two windows should have traded places in the stack"
+ );
+ }
+
+ // ---- Always on top ---------------------------------------------------
+
+ #[test]
+ fn pinned_windows_stay_above_newly_raised_ones() {
+ let mut wm = wm_with_monitor();
+ let pinned = wm.alloc_window_id();
+ wm.add_window(Window::new(pinned, "pip"));
+ let other = wm.alloc_window_id();
+ wm.add_window(Window::new(other, "normal"));
+
+ wm.toggle_always_on_top(pinned);
+ assert!(wm.is_always_on_top(pinned));
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned));
+
+ // Raising a normal window must not bury the pinned one.
+ wm.raise_window(other);
+ assert_eq!(
+ wm.stacking_order().last().map(|w| w.id),
+ Some(pinned),
+ "pinned window must remain topmost after another is raised"
+ );
+ }
+
+ #[test]
+ fn a_new_window_does_not_cover_a_pinned_one() {
+ let mut wm = wm_with_monitor();
+ let pinned = wm.alloc_window_id();
+ wm.add_window(Window::new(pinned, "pip"));
+ wm.toggle_always_on_top(pinned);
+
+ let fresh = wm.alloc_window_id();
+ wm.add_window(Window::new(fresh, "just opened"));
+
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(pinned));
+ }
+
+ #[test]
+ fn unpinning_lets_a_window_fall_back_into_the_normal_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"));
+
+ wm.toggle_always_on_top(a);
+ assert_eq!(wm.stacking_order().last().map(|w| w.id), Some(a));
+ wm.toggle_always_on_top(a);
+ 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");
+ }
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
new file mode 100644
index 0000000..6171c15
--- /dev/null
+++ b/crates/core/src/manager/windows.rs
@@ -0,0 +1,227 @@
+//! Window registration/removal, lookup, and stacking-order operations (raise, lower, pin).
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Windows -------------------------------------------------------
+
+ pub fn alloc_window_id(&mut self) -> WindowId {
+ let id = self.next_window_id;
+ self.next_window_id += 1;
+ id
+ }
+
+ /// Registers a window that a backend has already created. If the current
+ /// workspace's layout doesn't auto-tile ("dynamic"/"floating"), the
+ /// window's initial geometry is chosen via [`SmartPlacement`]; otherwise
+ /// 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;
+ if let Some(a) = &actions {
+ if let Some(floating) = a.floating {
+ window.floating = floating;
+ }
+ if let Some(decorated) = a.decorated {
+ window.decorated = decorated;
+ }
+ if let Some(color) = a.border_color {
+ window.border_color = color;
+ }
+ if let Some(width) = a.border_width {
+ window.border_width = width;
+ }
+ if let Some(pinned) = a.pinned {
+ window.always_on_top = pinned;
+ }
+ if let Some(opacity) = a.opacity {
+ window.opacity = opacity.clamp(0.0, 1.0);
+ }
+ }
+
+ if let Some(monitor) = self.primary_monitor() {
+ window.monitor = monitor.id;
+ let layout_name = self.workspace(workspace).map(|w| w.layout.clone()).unwrap_or_default();
+ if layout_name != "tiling" {
+ let existing: Vec<Rect> = self.windows_on_workspace(workspace).map(|w| w.geometry).collect();
+ let size = (window.geometry.width, window.geometry.height);
+ window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement);
+ }
+ }
+ if let Some(geometry) = actions.as_ref().and_then(|a| a.geometry) {
+ window.geometry = geometry;
+ }
+ let maximize = actions.as_ref().and_then(|a| a.maximized).unwrap_or(false);
+
+ self.windows.insert(id, window);
+ self.order.push(id);
+ self.focused = Some(id);
+ // A new window goes on top, but must not cover a pinned one.
+ self.restack_pinned();
+ if maximize {
+ self.toggle_maximize(id);
+ }
+ 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(opacity) = actions.opacity {
+ window.opacity = opacity.clamp(0.0, 1.0);
+ }
+ 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) {
+ self.focused = self.order.last().copied();
+ }
+ self.windows.remove(&id)
+ }
+
+ pub fn window(&self, id: WindowId) -> Option<&Window> {
+ self.windows.get(&id)
+ }
+
+ pub fn window_mut(&mut self, id: WindowId) -> Option<&mut Window> {
+ self.windows.get_mut(&id)
+ }
+
+ pub fn windows(&self) -> impl Iterator<Item = &Window> {
+ self.windows.values()
+ }
+
+ /// Windows in stacking order, topmost (most recently raised) last.
+ pub fn stacking_order(&self) -> impl Iterator<Item = &Window> {
+ self.order.iter().filter_map(|id| self.windows.get(id))
+ }
+
+ pub(super) fn windows_on_workspace(&self, workspace: WorkspaceId) -> impl Iterator<Item = &Window> {
+ self.windows.values().filter(move |w| w.workspace == workspace)
+ }
+
+ pub fn raise_window(&mut self, id: WindowId) {
+ if let Some(pos) = self.order.iter().position(|&w| w == id) {
+ let id = self.order.remove(pos);
+ self.order.push(id);
+ }
+ 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.
+ pub fn toggle_always_on_top(&mut self, id: WindowId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.always_on_top = !w.always_on_top;
+ }
+ self.restack_pinned();
+ }
+
+ pub fn is_always_on_top(&self, id: WindowId) -> bool {
+ self.windows.get(&id).map(|w| w.always_on_top).unwrap_or(false)
+ }
+
+ /// Moves every always-on-top window to the top of the stack, keeping
+ /// their relative order.
+ ///
+ /// `order` is the stacking order (last = topmost), so pinning is not a
+ /// property the renderer checks - it is maintained here, which means
+ /// every existing consumer of `stacking_order` gets it for free and
+ /// cannot forget to honour it.
+ fn restack_pinned(&mut self) {
+ if !self.windows.values().any(|w| w.always_on_top) {
+ return;
+ }
+ let (pinned, rest): (Vec<_>, Vec<_>) = self
+ .order
+ .iter()
+ .partition(|id| self.windows.get(id).is_some_and(|w| w.always_on_top));
+ self.order = rest.into_iter().chain(pinned).collect();
+ }
+
+}
diff --git a/crates/core/src/manager/winops.rs b/crates/core/src/manager/winops.rs
new file mode 100644
index 0000000..e656298
--- /dev/null
+++ b/crates/core/src/manager/winops.rs
@@ -0,0 +1,200 @@
+//! Per-window lifecycle: close, minimize, restore, scratchpad, maximize, fullscreen, floating, and plain move/resize.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Window operations ----------------------------------------------
+
+ 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) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.minimized = true;
+ }
+ if self.focused == Some(id) {
+ self.cycle_focus(true);
+ }
+ }
+
+ pub fn restore_window(&mut self, id: WindowId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.minimized = false;
+ }
+ }
+
+ /// 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;
+ }
+ w.maximized = false;
+ } else if let Some(geom) = monitor_geom {
+ w.restore_geometry = Some(w.geometry);
+ 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.
+ ///
+ /// Distinct from [`Self::toggle_maximize`], which keeps the titlebar (and
+ /// is what a maximise button does). Both share `restore_geometry`, so
+ /// 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) {
+ // 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 = 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
+ // restore point and the window could never get its size back.
+ if !w.maximized {
+ w.restore_geometry = Some(w.geometry);
+ }
+ 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 {
+ self.windows.get(&id).map(|w| w.fullscreen).unwrap_or(false)
+ }
+
+ pub fn toggle_floating(&mut self, id: WindowId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.floating = !w.floating;
+ }
+ }
+
+ pub fn is_floating(&self, id: WindowId) -> bool {
+ self.windows.get(&id).map(|w| w.floating).unwrap_or(false)
+ }
+
+ pub fn move_window(&mut self, id: WindowId, x: i32, y: i32) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.geometry.x = x;
+ w.geometry.y = y;
+ }
+ }
+
+ pub fn resize_window(&mut self, id: WindowId, width: u32, height: u32) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.geometry.width = width.max(MIN_WINDOW_WIDTH);
+ w.geometry.height = height.max(MIN_WINDOW_HEIGHT);
+ }
+ }
+
+}
diff --git a/crates/core/src/manager/workspaces.rs b/crates/core/src/manager/workspaces.rs
new file mode 100644
index 0000000..485ced0
--- /dev/null
+++ b/crates/core/src/manager/workspaces.rs
@@ -0,0 +1,96 @@
+//! Workspace management: create/rename/remove/switch, and per-workspace window queries.
+//! Split out of the original single `manager.rs` - see `super` (`mod.rs`) for
+//! `WindowManager`'s field definitions; everything here is plain `impl WindowManager`
+//! methods, unchanged from before the split.
+
+use super::*;
+
+impl WindowManager {
+ // ---- Workspaces -----------------------------------------------------
+
+ pub fn add_workspace(&mut self, name: impl Into<String>, layout: impl Into<String>) -> WorkspaceId {
+ let id = self.next_workspace_id;
+ self.next_workspace_id += 1;
+ self.workspaces.push(Workspace::new(id, name, layout));
+ 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;
+ }
+ let fallback = self.workspaces.iter().map(|w| w.id).find(|&w| w != id).unwrap_or(0);
+ for w in self.windows.values_mut().filter(|w| w.workspace == id) {
+ w.workspace = fallback;
+ }
+ self.workspaces.retain(|w| w.id != id);
+ if self.current_workspace == id {
+ self.current_workspace = fallback;
+ }
+ }
+
+ /// 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) {
+ 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;
+ }
+ }
+
+ pub fn current_workspace(&self) -> WorkspaceId {
+ self.current_workspace
+ }
+
+ pub fn workspace(&self, id: WorkspaceId) -> Option<&Workspace> {
+ self.workspaces.iter().find(|w| w.id == id)
+ }
+
+ pub fn workspaces(&self) -> &[Workspace] {
+ &self.workspaces
+ }
+
+ pub fn move_window_to_workspace(&mut self, id: WindowId, workspace: WorkspaceId) {
+ if let Some(w) = self.windows.get_mut(&id) {
+ w.workspace = workspace;
+ }
+ }
+
+ /// Windows that should currently be shown to the user: those on the
+ /// active workspace of whichever monitor they're assigned to, and not minimized.
+ pub fn visible_windows(&self) -> impl Iterator<Item = &Window> {
+ 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)
+ }
+
+}