srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/core
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-04-02 00:58:00 +0200
committersrdusr <[email protected]>2024-04-02 00:58:00 +0200
commit8110bb2773b6c841029a51eca7971f42a36f480c (patch)
treeb3b364a6c0231fe408229e94dc9e25ce4562ad02 /crates/core
parentd1f856edc516047575cf46f12fad2b59006363c8 (diff)
downloadsrdwm-8110bb2773b6c841029a51eca7971f42a36f480c.tar.gz
srdwm-8110bb2773b6c841029a51eca7971f42a36f480c.zip
Rewrite srdwm in Rust: working X11 and Wayland backends, Lua config
The C++ prototype (moved to legacy-cpp/) was mostly a design skeleton: X11 and Windows backends were partially real, Wayland created the wlroots object graph but never wired a single event listener, macOS was stub except monitor enumeration, and the Lua engine's srd.bind() stored a key-combo string but never the actual closure. See docs/PRIOR_ART.md for the full audit. This replaces it with a Cargo workspace: - srdwm-core: platform-independent window/workspace/monitor state, a real master-stack tiling layout, and SmartPlacement grid/cascade/ snap-to-edge placement - fixing several bugs in the C++ version (hardcoded 2-column grid, cascade that never cascaded, snap-to-edge that always returned a fixed rect). 35 unit tests. - srdwm-config: the srd Lua API via mlua, implementing the surface docs/DEFAULTS.md always documented but the C++ engine never actually built (srd.window.close()/focus(direction), srd.workspace.next(), real keybinding closures, require("srd") support). 10 unit tests. - srdwm-x11: a real reparenting WM with a drawn title bar (buttons, drag, resize), verified live under Xephyr - frame placement and client offset match srdwm-core's computed geometry exactly, and the decoration renders correctly on screen. - srdwm-wayland: a from-scratch smithay compositor (the C++ version had nothing working to port from) - runs via the winit backend, tracks xdg-shell toplevels through the same WindowManager and hit-testing code X11 uses, verified to start/render/run without crashing. Decorations are solid-color (no text yet); see docs/IMPLEMENTATION_STATUS.md for exact scope. - srdwm-windows / srdwm-macos: structured, cfg-gated designs informed by komorebi/glazewm and yabai/AeroSpace respectively (see docs/PRIOR_ART.md), honestly marked as unbuilt/unverified since this sandbox has no Windows or macOS target.
Diffstat (limited to 'crates/core')
-rw-r--r--crates/core/Cargo.toml12
-rw-r--r--crates/core/src/event.rs63
-rw-r--r--crates/core/src/geometry.rs77
-rw-r--r--crates/core/src/layout.rs150
-rw-r--r--crates/core/src/lib.rs17
-rw-r--r--crates/core/src/manager.rs724
-rw-r--r--crates/core/src/monitor.rs18
-rw-r--r--crates/core/src/placement.rs187
-rw-r--r--crates/core/src/window.rs221
-rw-r--r--crates/core/src/workspace.rs14
10 files changed, 1483 insertions, 0 deletions
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
new file mode 100644
index 0000000..7a2fb39
--- /dev/null
+++ b/crates/core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "srdwm-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Platform-independent window/workspace/layout state for srdwm"
+
+[dependencies]
+log.workspace = true
+bitflags = "2"
+
+[dev-dependencies]
diff --git a/crates/core/src/event.rs b/crates/core/src/event.rs
new file mode 100644
index 0000000..9b464b5
--- /dev/null
+++ b/crates/core/src/event.rs
@@ -0,0 +1,63 @@
+use crate::monitor::{Monitor, MonitorId};
+use crate::window::WindowId;
+use bitflags::bitflags;
+
+bitflags! {
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+ pub struct Modifiers: u8 {
+ const SHIFT = 0b0000_0001;
+ const CTRL = 0b0000_0010;
+ const ALT = 0b0000_0100;
+ const SUPER = 0b0000_1000;
+ }
+}
+
+impl std::fmt::Display for Modifiers {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.contains(Modifiers::CTRL) {
+ write!(f, "Ctrl+")?;
+ }
+ if self.contains(Modifiers::SHIFT) {
+ write!(f, "Shift+")?;
+ }
+ if self.contains(Modifiers::ALT) {
+ write!(f, "Alt+")?;
+ }
+ if self.contains(Modifiers::SUPER) {
+ write!(f, "Mod4+")?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum MouseButton {
+ Left,
+ Right,
+ Middle,
+ Other(u8),
+}
+
+/// A key combination, e.g. "Mod4+Shift+Return", used both as the canonical
+/// string form for Lua keybindings and as the lookup key at dispatch time.
+pub fn key_combo_string(modifiers: Modifiers, key_name: &str) -> String {
+ format!("{modifiers}{key_name}")
+}
+
+#[derive(Debug, Clone)]
+pub enum Event {
+ WindowCreated(WindowId),
+ WindowDestroyed(WindowId),
+ WindowTitleChanged(WindowId, String),
+ WindowMoved { id: WindowId, x: i32, y: i32 },
+ WindowResized { id: WindowId, width: u32, height: u32 },
+ WindowFocused(WindowId),
+ WindowUnfocused(WindowId),
+ KeyPress { key_name: String, modifiers: Modifiers },
+ KeyRelease { key_name: String, modifiers: Modifiers },
+ MouseButtonPress { button: MouseButton, x: i32, y: i32 },
+ MouseButtonRelease { button: MouseButton, x: i32, y: i32 },
+ MouseMotion { x: i32, y: i32 },
+ MonitorAdded(Monitor),
+ MonitorRemoved(MonitorId),
+}
diff --git a/crates/core/src/geometry.rs b/crates/core/src/geometry.rs
new file mode 100644
index 0000000..75feeef
--- /dev/null
+++ b/crates/core/src/geometry.rs
@@ -0,0 +1,77 @@
+/// An axis-aligned rectangle in screen space, used for window and monitor bounds.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct Rect {
+ pub x: i32,
+ pub y: i32,
+ pub width: u32,
+ pub height: u32,
+}
+
+impl Rect {
+ pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
+ Self { x, y, width, height }
+ }
+
+ pub fn right(&self) -> i32 {
+ self.x + self.width as i32
+ }
+
+ pub fn bottom(&self) -> i32 {
+ self.y + self.height as i32
+ }
+
+ pub fn contains_point(&self, x: i32, y: i32) -> bool {
+ x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
+ }
+
+ pub fn overlaps(&self, other: &Rect) -> bool {
+ !(self.right() <= other.x
+ || other.right() <= self.x
+ || self.bottom() <= other.y
+ || other.bottom() <= self.y)
+ }
+
+ /// Shrinks the rect on all sides by `margin`, saturating at zero size.
+ pub fn inset(&self, margin: u32) -> Rect {
+ let m = margin as i32;
+ let width = self.width.saturating_sub(margin * 2);
+ let height = self.height.saturating_sub(margin * 2);
+ Rect { x: self.x + m, y: self.y + m, width, height }
+ }
+
+ pub fn center(&self) -> (i32, i32) {
+ (self.x + self.width as i32 / 2, self.y + self.height as i32 / 2)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn overlap_detection_matches_aabb_semantics() {
+ let a = Rect::new(0, 0, 100, 100);
+ let b = Rect::new(50, 50, 100, 100);
+ let c = Rect::new(100, 100, 50, 50); // touches corner, should not overlap (half-open)
+ let d = Rect::new(200, 200, 10, 10);
+
+ assert!(a.overlaps(&b));
+ assert!(!a.overlaps(&c));
+ assert!(!a.overlaps(&d));
+ }
+
+ #[test]
+ fn inset_shrinks_symmetrically() {
+ let r = Rect::new(0, 0, 100, 60);
+ let inset = r.inset(10);
+ assert_eq!(inset, Rect::new(10, 10, 80, 40));
+ }
+
+ #[test]
+ fn contains_point_is_half_open() {
+ let r = Rect::new(0, 0, 10, 10);
+ assert!(r.contains_point(0, 0));
+ assert!(!r.contains_point(10, 10));
+ assert!(r.contains_point(9, 9));
+ }
+}
diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs
new file mode 100644
index 0000000..fc6c0bc
--- /dev/null
+++ b/crates/core/src/layout.rs
@@ -0,0 +1,150 @@
+use crate::geometry::Rect;
+use crate::monitor::Monitor;
+use crate::window::WindowId;
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy)]
+pub struct TilingConfig {
+ pub master_ratio: f32,
+ pub master_count: usize,
+ pub gap_inner: u32,
+ pub gap_outer: u32,
+}
+
+impl Default for TilingConfig {
+ fn default() -> Self {
+ Self { master_ratio: 0.6, master_count: 1, gap_inner: 8, gap_outer: 16 }
+ }
+}
+
+/// Arranges a set of windows on a monitor. Implementations are pure
+/// functions of (window order, monitor, config) -> new geometries, which
+/// keeps them trivially unit-testable and free of platform coupling.
+pub trait Layout: Send + Sync {
+ fn name(&self) -> &'static str;
+ fn arrange(&self, windows: &[WindowId], monitor: &Monitor, cfg: &TilingConfig) -> HashMap<WindowId, Rect>;
+}
+
+/// Master-stack tiling (dwm/i3-style): the first `master_count` windows take
+/// a resizable master column on the left; the rest are split evenly in a
+/// stack column on the right. This replaces the legacy C++ placeholder,
+/// which only ever split windows into equal-width columns.
+pub struct MasterStackLayout;
+
+impl Layout for MasterStackLayout {
+ fn name(&self) -> &'static str {
+ "tiling"
+ }
+
+ fn arrange(&self, windows: &[WindowId], monitor: &Monitor, cfg: &TilingConfig) -> HashMap<WindowId, Rect> {
+ let mut out = HashMap::new();
+ if windows.is_empty() {
+ return out;
+ }
+ let area = monitor.geometry.inset(cfg.gap_outer);
+ if windows.len() == 1 {
+ out.insert(windows[0], area);
+ return out;
+ }
+
+ let master_count = cfg.master_count.max(1).min(windows.len());
+ let has_stack = windows.len() > master_count;
+ let master_width = if has_stack {
+ ((area.width as f32) * cfg.master_ratio) as u32
+ } else {
+ area.width
+ };
+
+ let half_gap = cfg.gap_inner / 2;
+ let master_h = area.height / master_count as u32;
+ for (i, &id) in windows[..master_count].iter().enumerate() {
+ let r = Rect {
+ x: area.x,
+ y: area.y + (i as u32 * master_h) as i32,
+ width: master_width.saturating_sub(half_gap),
+ height: master_h.saturating_sub(cfg.gap_inner),
+ };
+ out.insert(id, r);
+ }
+
+ if has_stack {
+ let stack = &windows[master_count..];
+ let stack_x = area.x + master_width as i32 + half_gap as i32;
+ let stack_width = area.width.saturating_sub(master_width + half_gap);
+ let stack_h = area.height / stack.len() as u32;
+ for (i, &id) in stack.iter().enumerate() {
+ let r = Rect {
+ x: stack_x,
+ y: area.y + (i as u32 * stack_h) as i32,
+ width: stack_width,
+ height: stack_h.saturating_sub(cfg.gap_inner),
+ };
+ out.insert(id, r);
+ }
+ }
+ out
+ }
+}
+
+/// "Dynamic" layout: windows keep whatever geometry they already have.
+/// Placement for *new* windows is handled separately by [`crate::placement::SmartPlacement`]
+/// at creation time; this layout never repositions existing windows. This
+/// mirrors the legacy design intent (see docs/PRIOR_ART.md) but, unlike the
+/// C++ version, is a deliberate no-op rather than an accidental one.
+pub struct NoOpLayout(pub &'static str);
+
+impl Layout for NoOpLayout {
+ fn name(&self) -> &'static str {
+ self.0
+ }
+
+ fn arrange(&self, _windows: &[WindowId], _monitor: &Monitor, _cfg: &TilingConfig) -> HashMap<WindowId, Rect> {
+ HashMap::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn monitor() -> Monitor {
+ Monitor::new(0, "test", Rect::new(0, 0, 1920, 1080))
+ }
+
+ #[test]
+ fn single_window_fills_monitor_minus_outer_gap() {
+ let layout = MasterStackLayout;
+ let cfg = TilingConfig { gap_outer: 20, ..Default::default() };
+ let result = layout.arrange(&[1], &monitor(), &cfg);
+ assert_eq!(result[&1], Rect::new(20, 20, 1880, 1040));
+ }
+
+ #[test]
+ fn two_windows_split_master_and_stack() {
+ let layout = MasterStackLayout;
+ let cfg = TilingConfig { gap_outer: 0, gap_inner: 0, master_ratio: 0.6, master_count: 1 };
+ let result = layout.arrange(&[1, 2], &monitor(), &cfg);
+ assert_eq!(result[&1].width, 1152); // 60% of 1920
+ assert_eq!(result[&2].width, 768);
+ assert_eq!(result[&1].height, 1080);
+ assert_eq!(result[&2].height, 1080);
+ }
+
+ #[test]
+ fn three_windows_stack_splits_remaining_height_evenly() {
+ let layout = MasterStackLayout;
+ let cfg = TilingConfig { gap_outer: 0, gap_inner: 0, master_ratio: 0.5, master_count: 1 };
+ let result = layout.arrange(&[1, 2, 3], &monitor(), &cfg);
+ assert_eq!(result[&2].height, 540);
+ assert_eq!(result[&3].height, 540);
+ assert_eq!(result[&2].y, 0);
+ assert_eq!(result[&3].y, 540);
+ }
+
+ #[test]
+ fn no_op_layout_never_moves_windows() {
+ let layout = NoOpLayout("dynamic");
+ let result = layout.arrange(&[1, 2, 3], &monitor(), &TilingConfig::default());
+ assert!(result.is_empty());
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
new file mode 100644
index 0000000..a23e7d6
--- /dev/null
+++ b/crates/core/src/lib.rs
@@ -0,0 +1,17 @@
+pub mod event;
+pub mod geometry;
+pub mod layout;
+pub mod manager;
+pub mod monitor;
+pub mod placement;
+pub mod window;
+pub mod workspace;
+
+pub use event::{Event, MouseButton, Modifiers};
+pub use geometry::Rect;
+pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
+pub use manager::{Direction, WindowManager};
+pub use monitor::{Monitor, MonitorId};
+pub use placement::{PlacementConfig, SmartPlacement};
+pub use window::{ResizeEdge, TitlebarHit, Window, WindowId, RESIZE_MARGIN, TITLEBAR_HEIGHT};
+pub use workspace::{Workspace, WorkspaceId};
diff --git a/crates/core/src/manager.rs b/crates/core/src/manager.rs
new file mode 100644
index 0000000..1fdd7eb
--- /dev/null
+++ b/crates/core/src/manager.rs
@@ -0,0 +1,724 @@
+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::window::{ResizeEdge, TitlebarHit, Window, WindowId};
+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,
+ next_workspace_id: WorkspaceId,
+ next_window_id: WindowId,
+ layouts: HashMap<String, Box<dyn Layout>>,
+ pub tiling: TilingConfig,
+ pub placement: PlacementConfig,
+ drag: Option<DragState>,
+ resize: Option<ResizeState>,
+}
+
+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,
+ next_workspace_id: 1,
+ next_window_id: 1,
+ layouts,
+ tiling: TilingConfig::default(),
+ placement: PlacementConfig::default(),
+ drag: None,
+ resize: None,
+ }
+ }
+
+ 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()
+ }
+
+ // ---- Monitors ----------------------------------------------------
+
+ pub fn set_monitors(&mut self, monitors: Vec<Monitor>) {
+ self.monitors = monitors;
+ }
+
+ 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())
+ }
+
+ fn monitor_for(&self, id: MonitorId) -> Option<&Monitor> {
+ self.monitors.iter().find(|m| m.id == id).or_else(|| self.primary_monitor())
+ }
+
+ // ---- 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;
+ window.workspace = self.current_workspace;
+ if let Some(monitor) = self.primary_monitor() {
+ window.monitor = monitor.id;
+ let layout_name = self.workspace(self.current_workspace).map(|w| w.layout.clone()).unwrap_or_default();
+ if layout_name != "tiling" {
+ let existing: Vec<Rect> = self.windows_on_workspace(self.current_workspace).map(|w| w.geometry).collect();
+ let size = (window.geometry.width, window.geometry.height);
+ window.geometry = SmartPlacement::place(monitor, &existing, size, &self.placement);
+ }
+ }
+ self.windows.insert(id, window);
+ self.order.push(id);
+ self.focused = Some(id);
+ id
+ }
+
+ 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))
+ }
+
+ 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);
+ }
+ }
+
+ // ---- 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);
+ }
+ }
+
+ 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.
+ pub fn focus_direction(&mut 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;
+ }
+ // Distance biased toward the requested axis so a window that's
+ // mostly to the left (small |dy|) beats one that's diagonally
+ // placed, matching how i3/sway-style directional focus feels.
+ 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));
+ }
+ }
+ let target = best.map(|(id, _)| id);
+ if let Some(id) = target {
+ self.focus_window(id);
+ }
+ target
+ }
+
+ // ---- Window operations ----------------------------------------------
+
+ pub fn close_window(&mut self, id: WindowId) {
+ log::info!("close_window({id})");
+ }
+
+ 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;
+ }
+ }
+
+ 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 Some(w) = self.windows.get_mut(&id) else { return };
+ 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;
+ }
+ }
+
+ 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);
+ }
+ }
+
+ // ---- 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) {
+ return Some((w.id, hit));
+ }
+ }
+ None
+ }
+
+ // ---- 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;
+
+ let monitor_bounds = self.windows.get(&drag.window).and_then(|w| self.monitor_for(w.monitor)).map(|m| m.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()
+ }
+
+ // ---- 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
+ }
+
+ 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;
+ }
+ }
+
+ pub fn switch_workspace(&mut self, id: WorkspaceId) {
+ if self.workspaces.iter().any(|w| w.id == id) {
+ self.current_workspace = id;
+ }
+ }
+
+ 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)
+ }
+
+ // ---- 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 };
+ if w.workspace == workspace && !w.minimized && !w.floating {
+ 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
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ 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(20, 510); // drag far left, within snap threshold 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 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 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());
+ }
+}
diff --git a/crates/core/src/monitor.rs b/crates/core/src/monitor.rs
new file mode 100644
index 0000000..aa25fba
--- /dev/null
+++ b/crates/core/src/monitor.rs
@@ -0,0 +1,18 @@
+use crate::geometry::Rect;
+
+pub type MonitorId = u32;
+
+#[derive(Debug, Clone)]
+pub struct Monitor {
+ pub id: MonitorId,
+ pub name: String,
+ pub geometry: Rect,
+ pub refresh_rate_mhz: u32,
+ pub primary: bool,
+}
+
+impl Monitor {
+ pub fn new(id: MonitorId, name: impl Into<String>, geometry: Rect) -> Self {
+ Self { id, name: name.into(), geometry, refresh_rate_mhz: 60_000, primary: false }
+ }
+}
diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs
new file mode 100644
index 0000000..ed9e410
--- /dev/null
+++ b/crates/core/src/placement.rs
@@ -0,0 +1,187 @@
+//! Smart window placement: Windows-11-style grid placement, cascade fallback,
+//! and Windows-Snap-style edge magnetism for drags.
+//!
+//! This reimplements the intent of the legacy C++ `SmartPlacement` class, but
+//! fixes several bugs found in the original (see docs/PRIOR_ART.md):
+//! - grid placement used a `static` round-robin counter that hardcoded a
+//! 2-column layout and never tracked real cell occupancy; here we scan the
+//! actual grid for the first cell that doesn't overlap an existing window.
+//! - cascade placement didn't cascade at all (it reused the first free-space
+//! sample); here new windows step diagonally by `cascade_offset` and wrap.
+//! - snap-to-edge always returned a fixed centered rect; here it computes a
+//! real Windows-Snap-style half/quarter/maximize zone from drag position.
+
+use crate::geometry::Rect;
+use crate::monitor::Monitor;
+
+pub const MIN_WINDOW_WIDTH: u32 = 200;
+pub const MIN_WINDOW_HEIGHT: u32 = 150;
+
+#[derive(Debug, Clone, Copy)]
+pub struct PlacementConfig {
+ pub grid_margin: u32,
+ pub cascade_offset: i32,
+ pub snap_threshold: i32,
+ pub max_grid: u32,
+}
+
+impl Default for PlacementConfig {
+ fn default() -> Self {
+ Self { grid_margin: 10, cascade_offset: 30, snap_threshold: 50, max_grid: 4 }
+ }
+}
+
+pub struct SmartPlacement;
+
+impl SmartPlacement {
+ /// Place a new window of `size` given the geometries of windows already
+ /// occupying `monitor`. Tries a grid cell first, falling back to cascade.
+ pub fn place(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Rect {
+ Self::grid(monitor, existing, size, cfg).unwrap_or_else(|| Self::cascade(monitor, existing, size, cfg))
+ }
+
+ fn grid(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Option<Rect> {
+ let count = existing.len() + 1;
+ let grid_size = (count as f64).sqrt().ceil() as u32;
+ let grid_size = grid_size.clamp(1, cfg.max_grid);
+ let area = monitor.geometry;
+
+ let margins = cfg.grid_margin * (grid_size + 1);
+ if area.width <= margins || area.height <= margins {
+ return None;
+ }
+ let cell_w = (area.width - margins) / grid_size;
+ let cell_h = (area.height - margins) / grid_size;
+ if cell_w < MIN_WINDOW_WIDTH || cell_h < MIN_WINDOW_HEIGHT {
+ return None;
+ }
+
+ for gy in 0..grid_size {
+ for gx in 0..grid_size {
+ let x = area.x + cfg.grid_margin as i32 + (gx * (cell_w + cfg.grid_margin)) as i32;
+ let y = area.y + cfg.grid_margin as i32 + (gy * (cell_h + cfg.grid_margin)) as i32;
+ let candidate = Rect::new(x, y, cell_w, cell_h);
+ if !existing.iter().any(|w| w.overlaps(&candidate)) {
+ return Some(Rect::new(x, y, size.0.min(cell_w), size.1.min(cell_h)));
+ }
+ }
+ }
+ None
+ }
+
+ /// Diagonal cascade, stepping by `cascade_offset` per already-placed
+ /// window and wrapping back to the origin once it would run off the
+ /// monitor.
+ fn cascade(monitor: &Monitor, existing: &[Rect], size: (u32, u32), cfg: &PlacementConfig) -> Rect {
+ let area = monitor.geometry;
+ let width = size.0.min(area.width);
+ let height = size.1.min(area.height);
+
+ let max_steps_x = ((area.width as i32 - width as i32) / cfg.cascade_offset.max(1)).max(1);
+ let max_steps_y = ((area.height as i32 - height as i32) / cfg.cascade_offset.max(1)).max(1);
+ let max_steps = max_steps_x.min(max_steps_y).max(1);
+
+ let step = existing.len() as i32 % max_steps;
+ let x = (area.x + cfg.cascade_offset + step * cfg.cascade_offset).min(area.right() - width as i32).max(area.x);
+ let y = (area.y + cfg.cascade_offset + step * cfg.cascade_offset).min(area.bottom() - height as i32).max(area.y);
+ Rect::new(x, y, width, height)
+ }
+
+ /// Given a window being dragged (its live geometry) and the monitor it's
+ /// on, returns the Windows-Snap zone it should resize to if it's within
+ /// `snap_threshold` pixels of a screen edge or corner, or `None` if it's
+ /// not near any snap zone.
+ pub fn snap_zone(dragged: Rect, monitor: &Monitor, cfg: &PlacementConfig) -> Option<Rect> {
+ let area = monitor.geometry;
+ let t = cfg.snap_threshold;
+ let near_left = (dragged.x - area.x).abs() <= t;
+ let near_right = (area.right() - dragged.right()).abs() <= t;
+ let near_top = (dragged.y - area.y).abs() <= t;
+ let near_bottom = (area.bottom() - dragged.bottom()).abs() <= t;
+
+ let half_w = area.width / 2;
+ let half_h = area.height / 2;
+
+ Some(match (near_left, near_right, near_top, near_bottom) {
+ (true, false, true, false) => Rect::new(area.x, area.y, half_w, half_h),
+ (false, true, true, false) => Rect::new(area.x + half_w as i32, area.y, half_w, half_h),
+ (true, false, false, true) => Rect::new(area.x, area.y + half_h as i32, half_w, half_h),
+ (false, true, false, true) => Rect::new(area.x + half_w as i32, area.y + half_h as i32, half_w, half_h),
+ (true, false, false, false) => Rect::new(area.x, area.y, half_w, area.height),
+ (false, true, false, false) => Rect::new(area.x + half_w as i32, area.y, half_w, area.height),
+ (false, false, true, false) => area,
+ _ => return None,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn monitor() -> Monitor {
+ Monitor::new(0, "test", Rect::new(0, 0, 1920, 1080))
+ }
+
+ #[test]
+ fn first_window_goes_in_top_left_grid_cell() {
+ let cfg = PlacementConfig::default();
+ let r = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg);
+ assert_eq!(r.x, cfg.grid_margin as i32);
+ assert_eq!(r.y, cfg.grid_margin as i32);
+ }
+
+ #[test]
+ fn grid_avoids_occupied_cells() {
+ let cfg = PlacementConfig::default();
+ let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg);
+ let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg);
+ assert!(!first.overlaps(&second), "second window must not overlap the first: {first:?} vs {second:?}");
+ }
+
+ #[test]
+ fn cascade_kicks_in_once_grid_is_full() {
+ let cfg = PlacementConfig { max_grid: 1, ..Default::default() };
+ // max_grid=1 means the grid is always a single cell, so a second
+ // window can never find a free grid cell and must cascade.
+ let first = SmartPlacement::place(&monitor(), &[], (400, 300), &cfg);
+ let second = SmartPlacement::place(&monitor(), &[first], (400, 300), &cfg);
+ assert_ne!(first, second);
+ // First window is grid-placed (offset by grid_margin); the second no
+ // longer fits any grid cell and falls back to cascade, which steps
+ // from the monitor origin by `cascade_offset` per already-placed window.
+ assert_eq!(second.x, cfg.cascade_offset * 2);
+ assert_eq!(second.y, cfg.cascade_offset * 2);
+ }
+
+ #[test]
+ fn snap_left_edge_yields_left_half() {
+ let cfg = PlacementConfig::default();
+ let dragged = Rect::new(2, 100, 400, 300); // x=2 is within threshold of left edge
+ let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
+ assert_eq!(zone, Rect::new(0, 0, 960, 1080));
+ }
+
+ #[test]
+ fn snap_top_edge_yields_maximize() {
+ let cfg = PlacementConfig::default();
+ let dragged = Rect::new(500, 1, 400, 300);
+ let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
+ assert_eq!(zone, monitor().geometry);
+ }
+
+ #[test]
+ fn snap_top_left_corner_yields_quarter() {
+ let cfg = PlacementConfig::default();
+ let dragged = Rect::new(1, 1, 400, 300);
+ let zone = SmartPlacement::snap_zone(dragged, &monitor(), &cfg).unwrap();
+ assert_eq!(zone, Rect::new(0, 0, 960, 540));
+ }
+
+ #[test]
+ fn no_snap_away_from_edges() {
+ let cfg = PlacementConfig::default();
+ let dragged = Rect::new(700, 400, 400, 300);
+ assert!(SmartPlacement::snap_zone(dragged, &monitor(), &cfg).is_none());
+ }
+}
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
new file mode 100644
index 0000000..3970b02
--- /dev/null
+++ b/crates/core/src/window.rs
@@ -0,0 +1,221 @@
+use crate::geometry::Rect;
+
+pub type WindowId = u64;
+
+/// State of a single managed window. This is platform-independent: backends
+/// (X11, Wayland, ...) own the real surface/client handle and keep a `Window`
+/// in sync with it via `srdwm_core::WindowManager`.
+#[derive(Debug, Clone)]
+pub struct Window {
+ pub id: WindowId,
+ pub title: String,
+ pub app_id: String,
+ pub geometry: Rect,
+ /// Geometry to restore to when un-maximizing.
+ pub restore_geometry: Option<Rect>,
+ pub decorated: bool,
+ pub floating: bool,
+ pub minimized: bool,
+ pub maximized: bool,
+ pub fullscreen: bool,
+ pub always_on_top: bool,
+ pub border_color: (u8, u8, u8),
+ pub border_width: u32,
+ pub workspace: usize,
+ pub monitor: u32,
+}
+
+impl Window {
+ pub fn new(id: WindowId, title: impl Into<String>) -> Self {
+ Self {
+ id,
+ title: title.into(),
+ app_id: String::new(),
+ geometry: Rect::new(0, 0, 640, 480),
+ restore_geometry: None,
+ decorated: true,
+ floating: false,
+ minimized: false,
+ maximized: false,
+ fullscreen: false,
+ always_on_top: false,
+ border_color: (136, 192, 208), // Nord accent, matches legacy theme default
+ border_width: 2,
+ workspace: 0,
+ monitor: 0,
+ }
+ }
+}
+
+/// The height, in pixels, of the drawn title bar. Shared between backends so
+/// hit-testing and rendering agree on the same band.
+pub const TITLEBAR_HEIGHT: u32 = 30;
+/// Width of a resize grab margin along each window edge.
+pub const RESIZE_MARGIN: i32 = 6;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ResizeEdge {
+ Left,
+ Right,
+ Top,
+ Bottom,
+ TopLeft,
+ TopRight,
+ BottomLeft,
+ BottomRight,
+}
+
+impl ResizeEdge {
+ /// Determine which titlebar button (if any) a point within the titlebar
+ /// band falls on. Buttons are laid out right-aligned: close, maximize, minimize.
+ pub fn hit_test(frame: Rect, x: i32, y: i32) -> Option<TitlebarHit> {
+ if !frame.contains_point(x, y) {
+ return None;
+ }
+ if y < frame.y + TITLEBAR_HEIGHT as i32 {
+ const BUTTON: i32 = TITLEBAR_HEIGHT as i32;
+ let right = frame.right();
+ if x >= right - BUTTON {
+ return Some(TitlebarHit::Close);
+ }
+ if x >= right - BUTTON * 2 {
+ return Some(TitlebarHit::Maximize);
+ }
+ if x >= right - BUTTON * 3 {
+ return Some(TitlebarHit::Minimize);
+ }
+ return Some(TitlebarHit::Drag);
+ }
+ let edge = Self::resize_edge_at(frame, x, y)?;
+ Some(TitlebarHit::Resize(edge))
+ }
+
+ fn resize_edge_at(frame: Rect, x: i32, y: i32) -> Option<ResizeEdge> {
+ let m = RESIZE_MARGIN;
+ let near_left = x <= frame.x + m;
+ let near_right = x >= frame.right() - m;
+ let near_top = y <= frame.y + m;
+ let near_bottom = y >= frame.bottom() - m;
+ Some(match (near_left, near_right, near_top, near_bottom) {
+ (true, _, true, _) => ResizeEdge::TopLeft,
+ (_, true, true, _) => ResizeEdge::TopRight,
+ (true, _, _, true) => ResizeEdge::BottomLeft,
+ (_, true, _, true) => ResizeEdge::BottomRight,
+ (true, false, false, false) => ResizeEdge::Left,
+ (false, true, false, false) => ResizeEdge::Right,
+ (false, false, false, true) => ResizeEdge::Bottom,
+ _ => return None,
+ })
+ }
+
+ /// Apply a pointer delta to `original` geometry along this edge, honoring
+ /// the given minimum size.
+ pub fn apply_delta(self, original: Rect, dx: i32, dy: i32, min_w: u32, min_h: u32) -> Rect {
+ let mut r = original;
+ let min_w = min_w as i32;
+ let min_h = min_h as i32;
+ match self {
+ ResizeEdge::Left | ResizeEdge::TopLeft | ResizeEdge::BottomLeft => {
+ let new_w = (original.width as i32 - dx).max(min_w);
+ r.x = original.right() - new_w;
+ r.width = new_w as u32;
+ }
+ _ => {}
+ }
+ match self {
+ ResizeEdge::Right | ResizeEdge::TopRight | ResizeEdge::BottomRight => {
+ r.width = (original.width as i32 + dx).max(min_w) as u32;
+ }
+ _ => {}
+ }
+ match self {
+ ResizeEdge::Top | ResizeEdge::TopLeft | ResizeEdge::TopRight => {
+ let new_h = (original.height as i32 - dy).max(min_h);
+ r.y = original.bottom() - new_h;
+ r.height = new_h as u32;
+ }
+ _ => {}
+ }
+ match self {
+ ResizeEdge::Bottom | ResizeEdge::BottomLeft | ResizeEdge::BottomRight => {
+ r.height = (original.height as i32 + dy).max(min_h) as u32;
+ }
+ _ => {}
+ }
+ r
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TitlebarHit {
+ Drag,
+ Close,
+ Maximize,
+ Minimize,
+ Resize(ResizeEdge),
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame() -> Rect {
+ Rect::new(100, 100, 400, 300)
+ }
+
+ #[test]
+ fn close_button_is_top_right_corner_of_titlebar() {
+ let f = frame();
+ let hit = ResizeEdge::hit_test(f, f.right() - 5, f.y + 5);
+ assert_eq!(hit, Some(TitlebarHit::Close));
+ }
+
+ #[test]
+ fn maximize_is_left_of_close() {
+ let f = frame();
+ let hit = ResizeEdge::hit_test(f, f.right() - TITLEBAR_HEIGHT as i32 - 5, f.y + 5);
+ assert_eq!(hit, Some(TitlebarHit::Maximize));
+ }
+
+ #[test]
+ fn middle_of_titlebar_is_drag() {
+ let f = frame();
+ let (cx, _) = f.center();
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 5);
+ assert_eq!(hit, Some(TitlebarHit::Drag));
+ }
+
+ #[test]
+ fn bottom_right_corner_is_resize() {
+ let f = frame();
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1);
+ assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight)));
+ }
+
+ #[test]
+ fn outside_frame_is_none() {
+ let f = frame();
+ assert_eq!(ResizeEdge::hit_test(f, 0, 0), None);
+ }
+
+ #[test]
+ fn resize_right_edge_grows_width_only() {
+ let r = Rect::new(0, 0, 200, 100);
+ let out = ResizeEdge::Right.apply_delta(r, 50, 999, 50, 50);
+ assert_eq!(out, Rect::new(0, 0, 250, 100));
+ }
+
+ #[test]
+ fn resize_left_edge_moves_x_and_shrinks_width() {
+ let r = Rect::new(100, 0, 200, 100);
+ let out = ResizeEdge::Left.apply_delta(r, 30, 0, 50, 50);
+ assert_eq!(out, Rect::new(130, 0, 170, 100));
+ }
+
+ #[test]
+ fn resize_respects_minimum_size() {
+ let r = Rect::new(0, 0, 100, 100);
+ let out = ResizeEdge::Right.apply_delta(r, -500, 0, 50, 50);
+ assert_eq!(out.width, 50);
+ }
+}
diff --git a/crates/core/src/workspace.rs b/crates/core/src/workspace.rs
new file mode 100644
index 0000000..9988a39
--- /dev/null
+++ b/crates/core/src/workspace.rs
@@ -0,0 +1,14 @@
+pub type WorkspaceId = usize;
+
+#[derive(Debug, Clone)]
+pub struct Workspace {
+ pub id: WorkspaceId,
+ pub name: String,
+ pub layout: String,
+}
+
+impl Workspace {
+ pub fn new(id: WorkspaceId, name: impl Into<String>, layout: impl Into<String>) -> Self {
+ Self { id, name: name.into(), layout: layout.into() }
+ }
+}