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)) } pub fn assign_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() { return Ok(owner); } self.assignments.insert(workspace, output); self.outputs .get_mut(&output) .expect("output was checked above") .workspaces .insert(workspace); self.bump_revision(workspace); debug_assert!(self.validate()); Ok(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))); } #[test] fn assigning_an_unused_workspace_does_not_change_the_active_workspace() { let mut outputs = OutputWorkspaceMap::default(); outputs .connect(OutputId(1), geometry(0), WorkspaceId(1)) .unwrap(); assert_eq!( outputs.assign_workspace(OutputId(1), WorkspaceId(2)), Ok(OutputId(1)) ); assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1))); assert_eq!( outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)) ); } }