diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index fa6ed9a..619b98e 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -2,6 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt; +mod workspace; + +pub use workspace::{Workspace, WorkspaceError, WorkspaceId, WorkspaceSet}; + const MIN_SPLIT_RATIO: f64 = 0.05; const MAX_SPLIT_RATIO: f64 = 0.95; @@ -121,6 +125,10 @@ impl LayoutTree { self.windows.contains_key(&window) } + pub fn window_ids(&self) -> impl Iterator + '_ { + self.windows.keys().copied() + } + pub fn insert( &mut self, window: WindowId, diff --git a/crates/ferese-layout/src/workspace.rs b/crates/ferese-layout/src/workspace.rs new file mode 100644 index 0000000..cc9ed3b --- /dev/null +++ b/crates/ferese-layout/src/workspace.rs @@ -0,0 +1,363 @@ +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fmt; + +use crate::{Axis, LayoutError, LayoutTree, WindowId}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct WorkspaceId(pub u64); + +#[derive(Debug)] +pub struct Workspace { + pub id: WorkspaceId, + pub name: String, + pub layout: LayoutTree, + pub last_focused: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceError { + InvalidNumericName(u32), + UnknownWorkspace(WorkspaceId), + DuplicateWindow(WindowId), + InvalidState(&'static str), + Layout(LayoutError), +} + +impl fmt::Display for WorkspaceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidNumericName(index) => { + write!(formatter, "invalid workspace number {index}") + } + Self::UnknownWorkspace(workspace) => { + write!(formatter, "unknown workspace {workspace:?}") + } + Self::DuplicateWindow(window) => write!(formatter, "duplicate window {window:?}"), + Self::InvalidState(reason) => write!(formatter, "invalid workspace state: {reason}"), + Self::Layout(error) => error.fmt(formatter), + } + } +} + +impl Error for WorkspaceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Layout(error) => Some(error), + _ => None, + } + } +} + +impl From for WorkspaceError { + fn from(error: LayoutError) -> Self { + Self::Layout(error) + } +} + +#[derive(Debug)] +pub struct WorkspaceSet { + active: WorkspaceId, + workspaces: HashMap, + names: HashMap, + window_workspaces: HashMap, + next_id: u64, +} + +impl Default for WorkspaceSet { + fn default() -> Self { + let active = WorkspaceId(1); + let workspace = Workspace { + id: active, + name: "1".to_owned(), + layout: LayoutTree::default(), + last_focused: None, + }; + let workspaces = HashMap::from([(active, workspace)]); + let names = HashMap::from([("1".to_owned(), active)]); + + Self { + active, + workspaces, + names, + window_workspaces: HashMap::new(), + next_id: 2, + } + } +} + +impl WorkspaceSet { + pub fn active_id(&self) -> WorkspaceId { + self.active + } + + pub fn active(&self) -> &Workspace { + self.workspaces + .get(&self.active) + .expect("active workspace always exists") + } + + pub fn active_mut(&mut self) -> &mut Workspace { + self.workspaces + .get_mut(&self.active) + .expect("active workspace always exists") + } + + pub fn workspace(&self, id: WorkspaceId) -> Option<&Workspace> { + self.workspaces.get(&id) + } + + pub fn workspace_for_window(&self, window: WindowId) -> Option { + self.window_workspaces.get(&window).copied() + } + + pub fn ensure_numeric(&mut self, index: u32) -> Result { + if index == 0 { + return Err(WorkspaceError::InvalidNumericName(index)); + } + + let name = index.to_string(); + + if let Some(id) = self.names.get(&name) { + return Ok(*id); + } + + let id = WorkspaceId(self.next_id); + self.next_id += 1; + self.names.insert(name.clone(), id); + self.workspaces.insert( + id, + Workspace { + id, + name, + layout: LayoutTree::default(), + last_focused: None, + }, + ); + + debug_assert!(self.validate().is_ok()); + Ok(id) + } + + pub fn switch_to_numeric(&mut self, index: u32) -> Result, WorkspaceError> { + self.active = self.ensure_numeric(index)?; + + debug_assert!(self.validate().is_ok()); + Ok(self.active().last_focused) + } + + pub fn insert_window( + &mut self, + window: WindowId, + axis: Axis, + ratio: f64, + ) -> Result<(), WorkspaceError> { + if self.window_workspaces.contains_key(&window) { + return Err(WorkspaceError::DuplicateWindow(window)); + } + + let workspace = self.active_mut(); + workspace + .layout + .insert(window, workspace.last_focused, axis, ratio)?; + workspace.last_focused = Some(window); + self.window_workspaces.insert(window, self.active); + + debug_assert!(self.validate().is_ok()); + Ok(()) + } + + pub fn remove_window(&mut self, window: WindowId) -> Result<(), WorkspaceError> { + let workspace_id = self + .window_workspaces + .remove(&window) + .ok_or(LayoutError::UnknownWindow(window))?; + let workspace = self + .workspaces + .get_mut(&workspace_id) + .ok_or(WorkspaceError::UnknownWorkspace(workspace_id))?; + + workspace.layout.remove(window)?; + + if workspace.last_focused == Some(window) { + workspace.last_focused = first_window(&workspace.layout); + } + + debug_assert!(self.validate().is_ok()); + Ok(()) + } + + pub fn move_window_to_numeric( + &mut self, + window: WindowId, + index: u32, + axis: Axis, + ratio: f64, + ) -> Result { + let source_id = self + .workspace_for_window(window) + .ok_or(LayoutError::UnknownWindow(window))?; + let destination_id = self.ensure_numeric(index)?; + + if source_id == destination_id { + return Ok(destination_id); + } + + let source = self + .workspaces + .get_mut(&source_id) + .ok_or(WorkspaceError::UnknownWorkspace(source_id))?; + source.layout.remove(window)?; + + if source.last_focused == Some(window) { + source.last_focused = first_window(&source.layout); + } + + let destination = self + .workspaces + .get_mut(&destination_id) + .ok_or(WorkspaceError::UnknownWorkspace(destination_id))?; + destination + .layout + .insert(window, destination.last_focused, axis, ratio)?; + destination.last_focused = Some(window); + self.window_workspaces.insert(window, destination_id); + + debug_assert!(self.validate().is_ok()); + Ok(destination_id) + } + + pub fn validate(&self) -> Result<(), WorkspaceError> { + if !self.workspaces.contains_key(&self.active) { + return Err(WorkspaceError::InvalidState("active workspace is missing")); + } + if self.names.len() != self.workspaces.len() { + return Err(WorkspaceError::InvalidState( + "workspace name index is inconsistent", + )); + } + + let mut seen_windows = HashSet::new(); + + for (id, workspace) in &self.workspaces { + if workspace.id != *id || self.names.get(&workspace.name) != Some(id) { + return Err(WorkspaceError::InvalidState( + "workspace index is inconsistent", + )); + } + + workspace.layout.validate()?; + + for window in workspace.layout.window_ids() { + if !seen_windows.insert(window) || self.window_workspaces.get(&window) != Some(id) { + return Err(WorkspaceError::InvalidState( + "window ownership is inconsistent", + )); + } + } + + if workspace + .last_focused + .is_some_and(|window| !workspace.layout.contains(window)) + { + return Err(WorkspaceError::InvalidState( + "workspace focus is inconsistent", + )); + } + } + + if seen_windows.len() != self.window_workspaces.len() { + return Err(WorkspaceError::InvalidState( + "window index retains stale entries", + )); + } + + Ok(()) + } +} + +fn first_window(layout: &LayoutTree) -> Option { + layout.window_ids().min_by_key(|window| window.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_workspaces_are_created_lazily_with_stable_ids() { + let mut workspaces = WorkspaceSet::default(); + let first = workspaces.active_id(); + let ninth = workspaces.ensure_numeric(9).unwrap(); + + assert_eq!(workspaces.ensure_numeric(1), Ok(first)); + assert_eq!(workspaces.ensure_numeric(9), Ok(ninth)); + assert_ne!(first, ninth); + assert!(workspaces.validate().is_ok()); + } + + #[test] + fn switching_restores_workspace_focus() { + let mut workspaces = WorkspaceSet::default(); + workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + + assert_eq!(workspaces.switch_to_numeric(2).unwrap(), None); + workspaces + .insert_window(WindowId(2), Axis::Horizontal, 0.5) + .unwrap(); + + assert_eq!(workspaces.switch_to_numeric(1).unwrap(), Some(WindowId(1))); + assert_eq!(workspaces.switch_to_numeric(2).unwrap(), Some(WindowId(2))); + assert!(workspaces.validate().is_ok()); + } + + #[test] + fn moving_a_window_updates_membership_without_switching() { + let mut workspaces = WorkspaceSet::default(); + workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + workspaces + .insert_window(WindowId(2), Axis::Horizontal, 0.5) + .unwrap(); + let first = workspaces.active_id(); + let second = workspaces + .move_window_to_numeric(WindowId(2), 2, Axis::Horizontal, 0.5) + .unwrap(); + + assert_eq!(workspaces.active_id(), first); + assert_eq!(workspaces.workspace_for_window(WindowId(2)), Some(second)); + assert!( + !workspaces + .workspace(first) + .unwrap() + .layout + .contains(WindowId(2)) + ); + assert!( + workspaces + .workspace(second) + .unwrap() + .layout + .contains(WindowId(2)) + ); + assert!(workspaces.validate().is_ok()); + } + + #[test] + fn removing_focus_chooses_a_deterministic_remaining_window() { + let mut workspaces = WorkspaceSet::default(); + workspaces + .insert_window(WindowId(2), Axis::Horizontal, 0.5) + .unwrap(); + workspaces + .insert_window(WindowId(1), Axis::Horizontal, 0.5) + .unwrap(); + workspaces.remove_window(WindowId(1)).unwrap(); + + assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); + assert!(workspaces.validate().is_ok()); + } +}