diff options
| author | srdusr <[email protected]> | 2024-04-02 00:58:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-04-02 00:58:00 +0200 |
| commit | 8110bb2773b6c841029a51eca7971f42a36f480c (patch) | |
| tree | b3b364a6c0231fe408229e94dc9e25ce4562ad02 /crates/core | |
| parent | d1f856edc516047575cf46f12fad2b59006363c8 (diff) | |
| download | srdwm-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.toml | 12 | ||||
| -rw-r--r-- | crates/core/src/event.rs | 63 | ||||
| -rw-r--r-- | crates/core/src/geometry.rs | 77 | ||||
| -rw-r--r-- | crates/core/src/layout.rs | 150 | ||||
| -rw-r--r-- | crates/core/src/lib.rs | 17 | ||||
| -rw-r--r-- | crates/core/src/manager.rs | 724 | ||||
| -rw-r--r-- | crates/core/src/monitor.rs | 18 | ||||
| -rw-r--r-- | crates/core/src/placement.rs | 187 | ||||
| -rw-r--r-- | crates/core/src/window.rs | 221 | ||||
| -rw-r--r-- | crates/core/src/workspace.rs | 14 |
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() } + } +} |