feat: model multi-output workspace ownership
This commit is contained in:
1 parent
20b468c6ac
commit
36d69ab6f5
2 files changed
+503
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
mod output;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
|
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
|
||||||
pub use workspace::{WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceSet};
|
pub use workspace::{WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceSet};
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user