diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 18845de..583b199 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -1,3 +1,5 @@ +mod output; mod workspace; +pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use workspace::{WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceSet}; diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs new file mode 100644 index 0000000..a42436e --- /dev/null +++ b/crates/ferese-core/src/output.rs @@ -0,0 +1,501 @@ +use std::{ + collections::{HashMap, HashSet}, + error::Error, + fmt, +}; + +use crate::WorkspaceId; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct OutputId(pub u64); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputGeometry { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +impl OutputGeometry { + pub const fn new(x: i32, y: i32, width: i32, height: i32) -> Self { + Self { + x, + y, + width, + height, + } + } + + fn center(self) -> (i64, i64) { + ( + i64::from(self.x) * 2 + i64::from(self.width), + i64::from(self.y) * 2 + i64::from(self.height), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OutputError { + AlreadyConnected(OutputId), + UnknownOutput(OutputId), + WorkspaceAlreadyAssigned { + workspace: WorkspaceId, + output: OutputId, + }, +} + +impl fmt::Display for OutputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"), + Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"), + Self::WorkspaceAlreadyAssigned { workspace, output } => { + write!( + formatter, + "workspace {workspace:?} is assigned to {output:?}" + ) + } + } + } +} + +impl Error for OutputError {} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorkspaceSwitch { + Activated(OutputId), + FocusedExisting(OutputId), +} + +#[derive(Debug)] +struct OutputState { + geometry: OutputGeometry, + active: WorkspaceId, + workspaces: HashSet, +} + +#[derive(Clone, Copy, Debug)] +struct EvacuatedWorkspace { + workspace: WorkspaceId, + target: Option, + revision: u64, +} + +#[derive(Debug)] +struct EvacuationRecord { + active: WorkspaceId, + workspaces: Vec, +} + +#[derive(Debug, Default)] +pub struct OutputWorkspaceMap { + outputs: HashMap, + assignments: HashMap, + revisions: HashMap, + evacuations: HashMap, + focused: Option, +} + +impl OutputWorkspaceMap { + pub fn focused_output(&self) -> Option { + self.focused + } + + pub fn active_workspace(&self, output: OutputId) -> Option { + self.outputs.get(&output).map(|state| state.active) + } + + pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option { + self.assignments.get(&workspace).copied() + } + + pub fn connected_outputs(&self) -> impl Iterator + '_ { + self.outputs.keys().copied() + } + + pub fn connect( + &mut self, + output: OutputId, + geometry: OutputGeometry, + fallback_workspace: WorkspaceId, + ) -> Result { + if self.outputs.contains_key(&output) { + return Err(OutputError::AlreadyConnected(output)); + } + + let record = self.evacuations.remove(&output); + let mut reclaimed = Vec::new(); + if let Some(record) = &record { + for evacuated in &record.workspaces { + let assignment_matches = + self.assignments.get(&evacuated.workspace).copied() == evacuated.target; + let revision_matches = self.revision(evacuated.workspace) == evacuated.revision; + + if assignment_matches && revision_matches { + if let Some(target) = evacuated.target + && let Some(target_state) = self.outputs.get_mut(&target) + { + target_state.workspaces.remove(&evacuated.workspace); + if target_state.active == evacuated.workspace + && let Some(active) = target_state + .workspaces + .iter() + .copied() + .min_by_key(|workspace| workspace.0) + { + target_state.active = active; + } + } + self.assignments.insert(evacuated.workspace, output); + reclaimed.push(evacuated.workspace); + } + } + } + + let active = record + .as_ref() + .and_then(|record| reclaimed.contains(&record.active).then_some(record.active)) + .or_else(|| { + reclaimed + .iter() + .copied() + .min_by_key(|workspace| workspace.0) + }); + let active = match active { + Some(active) => active, + None => { + if let Some(owner) = self.assignments.get(&fallback_workspace).copied() { + return Err(OutputError::WorkspaceAlreadyAssigned { + workspace: fallback_workspace, + output: owner, + }); + } + self.assignments.insert(fallback_workspace, output); + reclaimed.push(fallback_workspace); + fallback_workspace + } + }; + + self.outputs.insert( + output, + OutputState { + geometry, + active, + workspaces: reclaimed.into_iter().collect(), + }, + ); + self.focused.get_or_insert(output); + + debug_assert!(self.validate()); + Ok(active) + } + + pub fn disconnect(&mut self, output: OutputId) -> Result, OutputError> { + let removed = self + .outputs + .remove(&output) + .ok_or(OutputError::UnknownOutput(output))?; + let target = self.migration_target(output, removed.geometry); + let removed_had_focus = self.focused == Some(output); + let mut evacuated = removed.workspaces.into_iter().collect::>(); + evacuated.sort_by_key(|workspace| workspace.0); + + for workspace in &evacuated { + if let Some(target) = target { + self.assignments.insert(*workspace, target); + self.outputs + .get_mut(&target) + .expect("migration target is connected") + .workspaces + .insert(*workspace); + } else { + self.assignments.remove(workspace); + } + } + + if removed_had_focus { + self.focused = target; + if let Some(target) = target { + self.outputs + .get_mut(&target) + .expect("migration target is connected") + .active = removed.active; + } + } + + self.evacuations.insert( + output, + EvacuationRecord { + active: removed.active, + workspaces: evacuated + .into_iter() + .map(|workspace| EvacuatedWorkspace { + workspace, + target, + revision: self.revision(workspace), + }) + .collect(), + }, + ); + + debug_assert!(self.validate()); + Ok(target) + } + + pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> { + if !self.outputs.contains_key(&output) { + return Err(OutputError::UnknownOutput(output)); + } + self.focused = Some(output); + Ok(()) + } + + pub fn switch_workspace( + &mut self, + output: OutputId, + workspace: WorkspaceId, + ) -> Result { + if !self.outputs.contains_key(&output) { + return Err(OutputError::UnknownOutput(output)); + } + + if let Some(owner) = self.assignments.get(&workspace).copied() + && owner != output + { + self.focused = Some(owner); + self.outputs + .get_mut(&owner) + .expect("workspace owner is connected") + .active = workspace; + return Ok(WorkspaceSwitch::FocusedExisting(owner)); + } + + self.assignments.insert(workspace, output); + let state = self + .outputs + .get_mut(&output) + .expect("output was checked above"); + state.workspaces.insert(workspace); + state.active = workspace; + self.focused = Some(output); + self.bump_revision(workspace); + + debug_assert!(self.validate()); + Ok(WorkspaceSwitch::Activated(output)) + } + + fn migration_target( + &self, + removed: OutputId, + removed_geometry: OutputGeometry, + ) -> Option { + if let Some(focused) = self.focused + && focused != removed + && self.outputs.contains_key(&focused) + { + return Some(focused); + } + + let removed_center = removed_geometry.center(); + self.outputs + .iter() + .min_by_key(|(id, state)| { + let center = state.geometry.center(); + let x = center.0 - removed_center.0; + let y = center.1 - removed_center.1; + (x * x + y * y, **id) + }) + .map(|(id, _)| *id) + } + + fn revision(&self, workspace: WorkspaceId) -> u64 { + self.revisions.get(&workspace).copied().unwrap_or(0) + } + + fn bump_revision(&mut self, workspace: WorkspaceId) { + let revision = self.revisions.entry(workspace).or_default(); + *revision = revision.saturating_add(1); + } + + fn validate(&self) -> bool { + if self + .focused + .is_some_and(|focused| !self.outputs.contains_key(&focused)) + { + return false; + } + + for (output, state) in &self.outputs { + if !state.workspaces.contains(&state.active) { + return false; + } + for workspace in &state.workspaces { + if self.assignments.get(workspace) != Some(output) { + return false; + } + } + } + + self.assignments.iter().all(|(workspace, output)| { + self.outputs + .get(output) + .is_some_and(|state| state.workspaces.contains(workspace)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn geometry(x: i32) -> OutputGeometry { + OutputGeometry::new(x, 0, 1_920, 1_080) + } + + #[test] + fn new_outputs_receive_distinct_workspaces() { + let mut outputs = OutputWorkspaceMap::default(); + + assert_eq!( + outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)), + Ok(WorkspaceId(1)) + ); + assert_eq!( + outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(2)), + Ok(WorkspaceId(2)) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(1)), + Some(OutputId(1)) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(2)) + ); + } + + #[test] + fn removal_prefers_a_remaining_focused_output_without_replacing_its_workspace() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + outputs.focus_output(OutputId(1)).unwrap(); + + assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1)))); + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(1)) + ); + } + + #[test] + fn removing_the_focused_output_activates_its_workspace_on_the_nearest_output() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + outputs + .connect(OutputId(3), geometry(3_840), WorkspaceId(3)) + .unwrap(); + outputs.focus_output(OutputId(2)).unwrap(); + + assert_eq!(outputs.disconnect(OutputId(2)), Ok(Some(OutputId(1)))); + assert_eq!(outputs.focused_output(), Some(OutputId(1))); + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2))); + } + + #[test] + fn reconnect_reclaims_automatically_evacuated_workspaces() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + outputs.focus_output(OutputId(1)).unwrap(); + outputs.disconnect(OutputId(2)).unwrap(); + + assert_eq!( + outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)), + Ok(WorkspaceId(2)) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(2)) + ); + assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2))); + } + + #[test] + fn reconnect_restores_the_migration_targets_remaining_workspace() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + outputs.focus_output(OutputId(2)).unwrap(); + outputs.disconnect(OutputId(2)).unwrap(); + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(2))); + + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(3)) + .unwrap(); + + assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); + assert_eq!(outputs.active_workspace(OutputId(2)), Some(WorkspaceId(2))); + } + + #[test] + fn explicit_workspace_selection_prevents_automatic_return() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + outputs.focus_output(OutputId(1)).unwrap(); + outputs.disconnect(OutputId(2)).unwrap(); + outputs + .switch_workspace(OutputId(1), WorkspaceId(2)) + .unwrap(); + + assert_eq!( + outputs.connect(OutputId(2), geometry(1_920), WorkspaceId(3)), + Ok(WorkspaceId(3)) + ); + assert_eq!( + outputs.output_for_workspace(WorkspaceId(2)), + Some(OutputId(1)) + ); + } + + #[test] + fn selecting_a_workspace_owned_by_another_output_focuses_that_output() { + let mut outputs = OutputWorkspaceMap::default(); + outputs + .connect(OutputId(1), geometry(0), WorkspaceId(1)) + .unwrap(); + outputs + .connect(OutputId(2), geometry(1_920), WorkspaceId(2)) + .unwrap(); + + assert_eq!( + outputs.switch_workspace(OutputId(1), WorkspaceId(2)), + Ok(WorkspaceSwitch::FocusedExisting(OutputId(2))) + ); + assert_eq!(outputs.focused_output(), Some(OutputId(2))); + } +}