Extend output profiles with monitor reconciliation and mirroring

This commit is contained in:
Samuel Onoja committed 2026-10-02 12:08:19 +01:00
1 parent ac184576f6
commit d14cd3cbd2
23 files changed
+3614 -474

No files matched your search

+150 -5
View File
@@ -89,9 +89,11 @@ struct EvacuationRecord {
pub struct OutputWorkspaceMap {
outputs: BTreeMap<OutputId, OutputState>,
assignments: HashMap<WorkspaceId, OutputId>,
homes: HashMap<WorkspaceId, OutputId>,
revisions: HashMap<WorkspaceId, u64>,
evacuations: HashMap<OutputId, EvacuationRecord>,
focused: Option<OutputId>,
orphaned_focus: Option<WorkspaceId>,
}
impl OutputWorkspaceMap {
@@ -118,6 +120,10 @@ impl OutputWorkspaceMap {
self.assignments.get(&workspace).copied()
}
pub fn home_output(&self, workspace: WorkspaceId) -> Option<OutputId> {
self.homes.get(&workspace).copied()
}
pub fn forget_workspace(&mut self, workspace: WorkspaceId) -> bool {
if self.outputs.values().any(|output| output.active == workspace) {
return false;
@@ -131,6 +137,10 @@ impl OutputWorkspaceMap {
}
}
self.revisions.remove(&workspace);
self.homes.remove(&workspace);
if self.orphaned_focus == Some(workspace) {
self.orphaned_focus = None;
}
self.evacuations.retain(|_, record| {
record.workspaces.retain(|entry| entry.workspace != workspace);
if record.active == workspace {
@@ -150,6 +160,10 @@ impl OutputWorkspaceMap {
self.outputs.keys().copied()
}
pub fn geometry(&self, output: OutputId) -> Option<OutputGeometry> {
self.outputs.get(&output).map(|state| state.geometry)
}
pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) {
if let Some(state) = self.outputs.get_mut(&output) {
state.geometry = geometry;
@@ -170,15 +184,19 @@ impl OutputWorkspaceMap {
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 target = self
.assignments
.get(&evacuated.workspace)
.copied()
.or_else(|| evacuated.target.filter(|target| self.outputs.contains_key(target)));
let assignment_matches = self.homes.get(&evacuated.workspace) == Some(&output);
let revision_matches = self.revision(evacuated.workspace) == evacuated.revision;
if assignment_matches && revision_matches {
// Cleanup may have removed every other workspace while this
// monitor was disconnected. Never strand the donor output;
// the reconnecting monitor can use its fresh fallback.
if evacuated
.target
if target
.and_then(|target| self.outputs.get(&target))
.is_some_and(|state| {
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
@@ -187,7 +205,7 @@ impl OutputWorkspaceMap {
continue;
}
if let Some(target) = evacuated.target
if let Some(target) = target
&& let Some(target_state) = self.outputs.get_mut(&target)
{
target_state.workspaces.remove(&evacuated.workspace);
@@ -214,7 +232,7 @@ impl OutputWorkspaceMap {
.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 {
let mut active = match active {
Some(active) => active,
None => {
if let Some(owner) = self.assignments.get(&fallback_workspace).copied() {
@@ -224,11 +242,33 @@ impl OutputWorkspaceMap {
});
}
self.assignments.insert(fallback_workspace, output);
self.homes.insert(fallback_workspace, output);
reclaimed.push(fallback_workspace);
fallback_workspace
}
};
if self.outputs.is_empty() {
// A replacement may have to reuse the final CRTC, or every cable
// may be unplugged. Attach unowned evacuated workspaces without
// changing their homes, so every window remains reachable.
let orphaned = self
.homes
.keys()
.filter(|workspace| !self.assignments.contains_key(workspace))
.copied()
.collect::<Vec<_>>();
for workspace in orphaned {
self.assignments.insert(workspace, output);
reclaimed.push(workspace);
}
if let Some(focused) = self.orphaned_focus.take()
&& reclaimed.contains(&focused)
{
active = focused;
}
}
self.outputs.insert(
output,
OutputState {
@@ -266,6 +306,9 @@ impl OutputWorkspaceMap {
if removed_had_focus {
self.focused = target;
if target.is_none() {
self.orphaned_focus = Some(removed.active);
}
if let Some(target) = target {
self.outputs
.get_mut(&target)
@@ -327,6 +370,7 @@ impl OutputWorkspaceMap {
state.activate(workspace);
self.focused = Some(output);
if reassigned {
self.homes.insert(workspace, output);
self.bump_revision(workspace);
}
@@ -357,6 +401,7 @@ impl OutputWorkspaceMap {
}
self.assignments.insert(workspace, output);
self.homes.insert(workspace, output);
self.outputs
.get_mut(&output)
.expect("output was checked above")
@@ -368,6 +413,48 @@ impl OutputWorkspaceMap {
Ok(output)
}
/// Explicit reassignment changes the home, unlike merely focusing an evacuated workspace.
/// The caller supplies a fresh workspace if moving the donor's final workspace.
pub fn reassign_workspace(
&mut self,
output: OutputId,
workspace: WorkspaceId,
donor_fallback: WorkspaceId,
) -> Result<(), OutputError> {
if !self.outputs.contains_key(&output) {
return Err(OutputError::UnknownOutput(output));
}
if let Some(owner) = self.assignments.get(&workspace).copied()
&& owner != output
{
let donor = self.outputs.get_mut(&owner).expect("assigned owner is connected");
if donor.workspaces.len() == 1 {
if let Some(assigned) = self.assignments.get(&donor_fallback) {
return Err(OutputError::WorkspaceAlreadyAssigned {
workspace: donor_fallback,
output: *assigned,
});
}
donor.workspaces.insert(donor_fallback);
self.assignments.insert(donor_fallback, owner);
self.homes.insert(donor_fallback, owner);
}
donor.workspaces.remove(&workspace);
if donor.previous == Some(workspace) {
donor.previous = None;
}
if donor.active == workspace {
donor.active = *donor.workspaces.first().expect("donor retains a workspace");
}
}
self.assignments.insert(workspace, output);
self.homes.insert(workspace, output);
self.outputs.get_mut(&output).unwrap().workspaces.insert(workspace);
self.bump_revision(workspace);
debug_assert!(self.validate());
Ok(())
}
fn migration_target(&self, removed: OutputId, removed_geometry: OutputGeometry) -> Option<OutputId> {
if let Some(focused) = self.focused
&& focused != removed
@@ -705,4 +792,62 @@ mod tests {
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
}
#[test]
fn two_hop_evacuation_restores_original_home() {
let mut outputs = OutputWorkspaceMap::default();
for id in 1..=3 {
outputs
.connect(OutputId(id), geometry(id as i32 * 1920), WorkspaceId(id))
.unwrap();
}
outputs.focus_output(OutputId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.disconnect(OutputId(2)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(0), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert!(outputs.active_workspace(OutputId(3)).is_some());
}
#[test]
fn explicit_reassignment_after_evacuation_prevents_home_steal_back() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(9))
.unwrap();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(3)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(2)));
}
#[test]
fn reassigning_last_workspace_supplies_donor_fallback() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.connect(OutputId(2), geometry(1920), WorkspaceId(2)).unwrap();
outputs
.reassign_workspace(OutputId(2), WorkspaceId(1), WorkspaceId(3))
.unwrap();
assert_eq!(outputs.active_workspace(OutputId(1)), Some(WorkspaceId(3)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(2)));
}
#[test]
fn final_output_replacement_keeps_all_workspaces_reachable_without_changing_homes() {
let mut outputs = OutputWorkspaceMap::default();
outputs.connect(OutputId(1), geometry(0), WorkspaceId(1)).unwrap();
outputs.assign_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs.switch_workspace(OutputId(1), WorkspaceId(2)).unwrap();
outputs.disconnect(OutputId(1)).unwrap();
outputs.connect(OutputId(3), geometry(0), WorkspaceId(3)).unwrap();
assert_eq!(outputs.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(3)));
assert_eq!(outputs.home_output(WorkspaceId(1)), Some(OutputId(1)));
outputs.connect(OutputId(1), geometry(1920), WorkspaceId(4)).unwrap();
assert_eq!(outputs.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
assert_eq!(outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
}
}