feat: model multi-output workspace ownership

This commit is contained in:
Samuel Onoja committed 2026-09-25 01:39:46 +01:00
1 parent 20b468c6ac
commit 36d69ab6f5
2 files changed
+503

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
mod output;
mod workspace;
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceSet};
+501
View File
@@ -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<WorkspaceId>,
}
#[derive(Clone, Copy, Debug)]
struct EvacuatedWorkspace {
workspace: WorkspaceId,
target: Option<OutputId>,
revision: u64,
}
#[derive(Debug)]
struct EvacuationRecord {
active: WorkspaceId,
workspaces: Vec<EvacuatedWorkspace>,
}
#[derive(Debug, Default)]
pub struct OutputWorkspaceMap {
outputs: HashMap<OutputId, OutputState>,
assignments: HashMap<WorkspaceId, OutputId>,
revisions: HashMap<WorkspaceId, u64>,
evacuations: HashMap<OutputId, EvacuationRecord>,
focused: Option<OutputId>,
}
impl OutputWorkspaceMap {
pub fn focused_output(&self) -> Option<OutputId> {
self.focused
}
pub fn active_workspace(&self, output: OutputId) -> Option<WorkspaceId> {
self.outputs.get(&output).map(|state| state.active)
}
pub fn output_for_workspace(&self, workspace: WorkspaceId) -> Option<OutputId> {
self.assignments.get(&workspace).copied()
}
pub fn connected_outputs(&self) -> impl Iterator<Item = OutputId> + '_ {
self.outputs.keys().copied()
}
pub fn connect(
&mut self,
output: OutputId,
geometry: OutputGeometry,
fallback_workspace: WorkspaceId,
) -> Result<WorkspaceId, OutputError> {
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<Option<OutputId>, 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::<Vec<_>>();
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<WorkspaceSwitch, OutputError> {
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<OutputId> {
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)));
}
}