Publish output topology through a desktop reconciliation boundary
This commit is contained in:
1 parent
647eaf4ad9
commit
f5de30d037
16 files changed
+1351
-298
No files matched your search
@@ -6,7 +6,7 @@ mod workspace;
|
||||
mod workspace_policy;
|
||||
|
||||
pub use focus::{FocusCycle, FocusHistory};
|
||||
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
|
||||
pub use output::{DesktopPlan, OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
|
||||
pub use workspace::{
|
||||
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout, WorkspaceSet,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ impl OutputGeometry {
|
||||
pub enum OutputError {
|
||||
AlreadyConnected(OutputId),
|
||||
UnknownOutput(OutputId),
|
||||
InvalidGeometry(OutputId),
|
||||
WorkspaceAlreadyAssigned { workspace: WorkspaceId, output: OutputId },
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ impl fmt::Display for OutputError {
|
||||
match self {
|
||||
Self::AlreadyConnected(output) => write!(formatter, "output {output:?} is connected"),
|
||||
Self::UnknownOutput(output) => write!(formatter, "unknown output {output:?}"),
|
||||
Self::InvalidGeometry(output) => write!(formatter, "invalid geometry for output {output:?}"),
|
||||
Self::WorkspaceAlreadyAssigned { workspace, output } => {
|
||||
write!(formatter, "workspace {workspace:?} is assigned to {output:?}")
|
||||
}
|
||||
@@ -55,7 +57,7 @@ pub enum WorkspaceSwitch {
|
||||
FocusedExisting(OutputId),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct OutputState {
|
||||
geometry: OutputGeometry,
|
||||
active: WorkspaceId,
|
||||
@@ -72,20 +74,20 @@ impl OutputState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct EvacuatedWorkspace {
|
||||
workspace: WorkspaceId,
|
||||
target: Option<OutputId>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct EvacuationRecord {
|
||||
active: WorkspaceId,
|
||||
workspaces: Vec<EvacuatedWorkspace>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OutputWorkspaceMap {
|
||||
outputs: BTreeMap<OutputId, OutputState>,
|
||||
assignments: HashMap<WorkspaceId, OutputId>,
|
||||
@@ -96,7 +98,110 @@ pub struct OutputWorkspaceMap {
|
||||
orphaned_focus: Option<WorkspaceId>,
|
||||
}
|
||||
|
||||
/// A staged ownership decision. No windows, renderers or protocol resources are copied.
|
||||
#[derive(Debug)]
|
||||
pub struct DesktopPlan {
|
||||
pub outputs: OutputWorkspaceMap,
|
||||
pub created_workspaces: Vec<WorkspaceId>,
|
||||
pub affected_outputs: BTreeSet<OutputId>,
|
||||
pub focus_changed: bool,
|
||||
}
|
||||
|
||||
impl OutputWorkspaceMap {
|
||||
/// Plan against the final usable inventory, never a per-device intermediate.
|
||||
/// An empty inventory is a valid headless desktop after physical loss.
|
||||
pub fn plan_desktop(
|
||||
&self,
|
||||
usable: &[(OutputId, OutputGeometry)],
|
||||
workspaces: &[WorkspaceId],
|
||||
mut next_workspace: WorkspaceId,
|
||||
) -> Result<DesktopPlan, OutputError> {
|
||||
let mut desired = BTreeMap::new();
|
||||
for &(id, geometry) in usable {
|
||||
if geometry.width <= 0
|
||||
|| geometry.height <= 0
|
||||
|| geometry.x.checked_add(geometry.width).is_none()
|
||||
|| geometry.y.checked_add(geometry.height).is_none()
|
||||
{
|
||||
return Err(OutputError::InvalidGeometry(id));
|
||||
}
|
||||
if desired.insert(id, geometry).is_some() {
|
||||
return Err(OutputError::AlreadyConnected(id));
|
||||
}
|
||||
}
|
||||
let returning = desired
|
||||
.keys()
|
||||
.filter(|id| !self.outputs.contains_key(id))
|
||||
.filter_map(|id| self.evacuations.get(id).map(|record| (*id, record.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
let mut outputs = self.clone();
|
||||
let mut created_workspaces = Vec::new();
|
||||
// New destinations exist before evacuation. Temporary removals must
|
||||
// never become migration destinations, even when several GPUs disappear.
|
||||
for (&id, &geometry) in &desired {
|
||||
if outputs.outputs.contains_key(&id) {
|
||||
outputs.update_geometry(id, geometry);
|
||||
continue;
|
||||
}
|
||||
let fallback = workspaces
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|workspace| {
|
||||
outputs.output_for_workspace(*workspace).is_none()
|
||||
&& outputs.home_output(*workspace).is_none_or(|home| home == id)
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_else(|| {
|
||||
let workspace = next_workspace;
|
||||
next_workspace.0 = next_workspace.0.checked_add(1).expect("workspace IDs exhausted");
|
||||
created_workspaces.push(workspace);
|
||||
workspace
|
||||
});
|
||||
outputs.connect(id, geometry, fallback)?;
|
||||
}
|
||||
let mut removed = self
|
||||
.connected_outputs()
|
||||
.filter(|id| !desired.contains_key(id))
|
||||
.map(|id| (id, outputs.outputs.remove(&id).unwrap()))
|
||||
.collect::<Vec<_>>();
|
||||
// Preserve the original focused workspace when several outputs vanish.
|
||||
removed.sort_by_key(|(id, _)| (Some(*id) == self.focused, *id));
|
||||
for (id, state) in removed {
|
||||
outputs.evacuate(id, state);
|
||||
}
|
||||
// A returning output may initially be unable to reclaim the only
|
||||
// workspace of a donor that is itself retiring. Retry the same policy
|
||||
// against the final inventory, where that donor no longer needs it.
|
||||
for (id, record) in returning {
|
||||
let reclaimed = outputs.reclaim(id, &record);
|
||||
let state = outputs.outputs.get_mut(&id).unwrap();
|
||||
state.workspaces.extend(reclaimed.iter().copied());
|
||||
if reclaimed.contains(&record.active) {
|
||||
state.activate(record.active);
|
||||
}
|
||||
}
|
||||
debug_assert!(outputs.validate());
|
||||
let mut affected_outputs = BTreeSet::new();
|
||||
for id in self.connected_outputs().chain(outputs.connected_outputs()) {
|
||||
if self.outputs.get(&id) != outputs.outputs.get(&id) {
|
||||
affected_outputs.insert(id);
|
||||
}
|
||||
}
|
||||
let focus_changed = self.focused != outputs.focused
|
||||
|| self.focused.and_then(|id| self.active_workspace(id))
|
||||
!= outputs.focused.and_then(|id| outputs.active_workspace(id));
|
||||
if focus_changed {
|
||||
affected_outputs.extend(self.focused);
|
||||
affected_outputs.extend(outputs.focused);
|
||||
}
|
||||
Ok(DesktopPlan {
|
||||
outputs,
|
||||
created_workspaces,
|
||||
affected_outputs,
|
||||
focus_changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn assigned_workspaces(&self, output: OutputId) -> impl DoubleEndedIterator<Item = WorkspaceId> + '_ {
|
||||
self.outputs
|
||||
.get(&output)
|
||||
@@ -170,6 +275,56 @@ impl OutputWorkspaceMap {
|
||||
}
|
||||
}
|
||||
|
||||
fn reclaim(&mut self, output: OutputId, record: &EvacuationRecord) -> Vec<WorkspaceId> {
|
||||
let mut reclaimed = Vec::new();
|
||||
for evacuated in &record.workspaces {
|
||||
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 {
|
||||
if target == Some(output) {
|
||||
reclaimed.push(evacuated.workspace);
|
||||
continue;
|
||||
}
|
||||
// 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 target
|
||||
.and_then(|target| self.outputs.get(&target))
|
||||
.is_some_and(|state| state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(target) = target
|
||||
&& let Some(target_state) = self.outputs.get_mut(&target)
|
||||
{
|
||||
target_state.workspaces.remove(&evacuated.workspace);
|
||||
if target_state.previous == Some(evacuated.workspace) {
|
||||
target_state.previous = None;
|
||||
}
|
||||
if target_state.active == evacuated.workspace
|
||||
&& let Some(active) = target_state
|
||||
.workspaces
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|workspace| workspace.0)
|
||||
{
|
||||
target_state.activate(active);
|
||||
}
|
||||
}
|
||||
self.assignments.insert(evacuated.workspace, output);
|
||||
reclaimed.push(evacuated.workspace);
|
||||
}
|
||||
}
|
||||
reclaimed
|
||||
}
|
||||
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
output: OutputId,
|
||||
@@ -181,52 +336,10 @@ impl OutputWorkspaceMap {
|
||||
}
|
||||
|
||||
let record = self.evacuations.remove(&output);
|
||||
let mut reclaimed = Vec::new();
|
||||
if let Some(record) = &record {
|
||||
for evacuated in &record.workspaces {
|
||||
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 target
|
||||
.and_then(|target| self.outputs.get(&target))
|
||||
.is_some_and(|state| {
|
||||
state.workspaces.len() == 1 && state.workspaces.contains(&evacuated.workspace)
|
||||
})
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(target) = target
|
||||
&& let Some(target_state) = self.outputs.get_mut(&target)
|
||||
{
|
||||
target_state.workspaces.remove(&evacuated.workspace);
|
||||
if target_state.previous == Some(evacuated.workspace) {
|
||||
target_state.previous = None;
|
||||
}
|
||||
if target_state.active == evacuated.workspace
|
||||
&& let Some(active) = target_state
|
||||
.workspaces
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|workspace| workspace.0)
|
||||
{
|
||||
target_state.activate(active);
|
||||
}
|
||||
}
|
||||
self.assignments.insert(evacuated.workspace, output);
|
||||
reclaimed.push(evacuated.workspace);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut reclaimed = record
|
||||
.as_ref()
|
||||
.map(|record| self.reclaim(output, record))
|
||||
.unwrap_or_default();
|
||||
|
||||
let active = record
|
||||
.as_ref()
|
||||
@@ -286,6 +399,12 @@ impl OutputWorkspaceMap {
|
||||
|
||||
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.evacuate(output, removed);
|
||||
debug_assert!(self.validate());
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn evacuate(&mut self, output: OutputId, removed: OutputState) -> Option<OutputId> {
|
||||
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<_>>();
|
||||
@@ -332,8 +451,7 @@ impl OutputWorkspaceMap {
|
||||
},
|
||||
);
|
||||
|
||||
debug_assert!(self.validate());
|
||||
Ok(target)
|
||||
target
|
||||
}
|
||||
|
||||
pub fn focus_output(&mut self, output: OutputId) -> Result<(), OutputError> {
|
||||
@@ -445,6 +563,9 @@ impl OutputWorkspaceMap {
|
||||
}
|
||||
if donor.active == workspace {
|
||||
donor.active = *donor.workspaces.first().expect("donor retains a workspace");
|
||||
if donor.previous == Some(donor.active) {
|
||||
donor.previous = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assignments.insert(workspace, output);
|
||||
@@ -516,6 +637,199 @@ impl OutputWorkspaceMap {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn plan(map: &OutputWorkspaceMap, ids: &[u64]) -> DesktopPlan {
|
||||
map.plan_desktop(
|
||||
&ids.iter()
|
||||
.map(|id| (OutputId(*id), geometry(*id as i32 * 1920)))
|
||||
.collect::<Vec<_>>(),
|
||||
&(1..=32).map(WorkspaceId).collect::<Vec<_>>(),
|
||||
WorkspaceId(33),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_add_remove_and_noop_leave_the_source_unchanged() {
|
||||
let original = OutputWorkspaceMap::default();
|
||||
let first = plan(&original, &[1]);
|
||||
assert_eq!(original, OutputWorkspaceMap::default());
|
||||
let second = plan(&first.outputs, &[1, 2]);
|
||||
assert_eq!(first.outputs.connected_outputs().count(), 1);
|
||||
assert_eq!(second.outputs.connected_outputs().count(), 2);
|
||||
let unchanged = plan(&second.outputs, &[2, 1]);
|
||||
assert_eq!(unchanged.outputs, second.outputs);
|
||||
assert!(unchanged.affected_outputs.is_empty());
|
||||
assert!(!unchanged.focus_changed);
|
||||
let removed = plan(&second.outputs, &[1]);
|
||||
assert_eq!(removed.outputs.focused_output(), Some(OutputId(1)));
|
||||
assert_eq!(removed.outputs.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
||||
assert_eq!(removed.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_removals_migrate_directly_to_final_outputs_and_preserve_focus() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
let after = plan(&before, &[4]);
|
||||
assert_eq!(after.outputs.focused_output(), Some(OutputId(4)));
|
||||
assert_eq!(after.outputs.active_workspace(OutputId(4)), Some(WorkspaceId(2)));
|
||||
for workspace in 1..=3 {
|
||||
assert_eq!(
|
||||
after.outputs.output_for_workspace(WorkspaceId(workspace)),
|
||||
Some(OutputId(4))
|
||||
);
|
||||
}
|
||||
for evacuation in after.outputs.evacuations.values() {
|
||||
assert!(
|
||||
evacuation
|
||||
.workspaces
|
||||
.iter()
|
||||
.all(|workspace| workspace.target == Some(OutputId(4)))
|
||||
);
|
||||
}
|
||||
assert_eq!(before.connected_outputs().count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_reconnect_respects_explicit_reassignment() {
|
||||
let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
let mut evacuated = plan(&before, &[1]).outputs;
|
||||
let restored = plan(&evacuated, &[1, 2]);
|
||||
assert_eq!(restored.outputs.output_for_workspace(WorkspaceId(2)), Some(OutputId(2)));
|
||||
evacuated
|
||||
.reassign_workspace(OutputId(1), WorkspaceId(2), WorkspaceId(9))
|
||||
.unwrap();
|
||||
let reassigned = plan(&evacuated, &[1, 2]);
|
||||
assert_eq!(
|
||||
reassigned.outputs.output_for_workspace(WorkspaceId(2)),
|
||||
Some(OutputId(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_home_reclaims_from_a_donor_retired_in_the_same_plan() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2, 3]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
before.disconnect(OutputId(1)).unwrap();
|
||||
before.switch_workspace(OutputId(2), WorkspaceId(1)).unwrap();
|
||||
assert!(before.forget_workspace(WorkspaceId(2)));
|
||||
before.focus_output(OutputId(3)).unwrap();
|
||||
let after = plan(&before, &[1, 3]).outputs;
|
||||
assert_eq!(after.output_for_workspace(WorkspaceId(1)), Some(OutputId(1)));
|
||||
assert_eq!(after.active_workspace(OutputId(1)), Some(WorkspaceId(1)));
|
||||
assert_eq!(after.focused_output(), Some(OutputId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inventory_cannot_mutate_the_published_map() {
|
||||
let before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
let saved = before.clone();
|
||||
assert!(
|
||||
before
|
||||
.plan_desktop(
|
||||
&[(OutputId(1), geometry(0)), (OutputId(1), geometry(1))],
|
||||
&[],
|
||||
WorkspaceId(3)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
before
|
||||
.plan_desktop(&[(OutputId(3), OutputGeometry::new(0, 0, 0, 800))], &[], WorkspaceId(3))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(before, saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_loss_can_publish_headless_and_recover_all_workspaces() {
|
||||
let mut before = plan(&OutputWorkspaceMap::default(), &[1, 2]).outputs;
|
||||
before.focus_output(OutputId(2)).unwrap();
|
||||
let headless = plan(&before, &[]).outputs;
|
||||
assert_eq!(headless.focused_output(), None);
|
||||
assert!(headless.assignments.is_empty());
|
||||
let recovered = plan(&headless, &[3]).outputs;
|
||||
assert_eq!(recovered.active_workspace(OutputId(3)), Some(WorkspaceId(2)));
|
||||
for id in [1, 2] {
|
||||
assert_eq!(recovered.output_for_workspace(WorkspaceId(id)), Some(OutputId(3)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_topology_sequence_is_coherent_and_idempotent() {
|
||||
let mut map = OutputWorkspaceMap::default();
|
||||
let mut seed = 0x5eed_u64;
|
||||
for step in 0..512 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let ids = (1..=4).filter(|id| seed & (1 << id) != 0).collect::<Vec<_>>();
|
||||
let next = plan(&map, &ids);
|
||||
assert!(next.outputs.validate(), "step {step}");
|
||||
assert_eq!(plan(&next.outputs, &ids).outputs, next.outputs);
|
||||
map = next.outputs;
|
||||
if let Some(&id) = ids.first() {
|
||||
map.focus_output(OutputId(id)).unwrap();
|
||||
let workspace = WorkspaceId(16 + step % 8);
|
||||
map.switch_workspace(OutputId(id), workspace).unwrap();
|
||||
if step % 3 == 0 {
|
||||
map.reassign_workspace(OutputId(id), workspace, WorkspaceId(31))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(map.validate());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_desktop_sequence_preserves_every_window() {
|
||||
use crate::WorkspaceSet;
|
||||
use ferese_layout::{Axis, WindowId};
|
||||
let mut workspaces = WorkspaceSet::default();
|
||||
let mut outputs = OutputWorkspaceMap::default();
|
||||
let mut seed = 42u64;
|
||||
let mut windows = std::collections::HashSet::new();
|
||||
for step in 1..=256 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
let inventory = (1..=3)
|
||||
.filter(|id| seed & (1 << id) != 0)
|
||||
.map(|id| (OutputId(id), geometry(id as i32 * 800)))
|
||||
.collect::<Vec<_>>();
|
||||
let ids = workspaces.iter().map(|workspace| workspace.id).collect::<Vec<_>>();
|
||||
let plan = outputs
|
||||
.plan_desktop(&inventory, &ids, workspaces.next_workspace_id())
|
||||
.unwrap();
|
||||
for id in plan.created_workspaces {
|
||||
assert_eq!(workspaces.create_workspace(), id);
|
||||
}
|
||||
outputs = plan.outputs;
|
||||
outputs.reconcile_workspaces(&mut workspaces, &Default::default());
|
||||
let window = WindowId(step);
|
||||
workspaces.insert_window(window, Axis::Horizontal, 0.5).unwrap();
|
||||
windows.insert(window);
|
||||
if let Some((output, _)) = inventory.last() {
|
||||
outputs.focus_output(*output).unwrap();
|
||||
let target = outputs.active_workspace(*output).unwrap();
|
||||
workspaces
|
||||
.move_window_to_workspace(window, target, Axis::Horizontal, 0.5)
|
||||
.unwrap();
|
||||
workspaces.activate(target).unwrap();
|
||||
}
|
||||
assert!(outputs.validate());
|
||||
workspaces.validate().unwrap();
|
||||
let actual = workspaces
|
||||
.iter()
|
||||
.flat_map(|workspace| workspace.layout.window_ids().chain(workspace.floating.iter().copied()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(actual.len(), windows.len(), "duplicate or lost window at {step}");
|
||||
assert_eq!(actual.into_iter().collect::<std::collections::HashSet<_>>(), windows);
|
||||
for workspace in workspaces.iter().filter(|workspace| !workspace.is_empty()) {
|
||||
assert_eq!(
|
||||
outputs.output_for_workspace(workspace.id).is_some(),
|
||||
!inventory.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_cannot_reclaim_the_donor_monitors_only_workspace() {
|
||||
let mut outputs = OutputWorkspaceMap::default();
|
||||
|
||||
@@ -672,6 +672,11 @@ impl WorkspaceSet {
|
||||
.ok_or(WorkspaceError::UnknownWorkspace(id))
|
||||
}
|
||||
|
||||
/// Reserve workspace identities in a pure topology plan without creating them.
|
||||
pub fn next_workspace_id(&self) -> WorkspaceId {
|
||||
WorkspaceId(self.next_id)
|
||||
}
|
||||
|
||||
/// Creates an identity; display positions are assigned by the output owner.
|
||||
pub fn create_workspace(&mut self) -> WorkspaceId {
|
||||
let id = WorkspaceId(self.next_id);
|
||||
|
||||
Reference in new issue
Block a user