//! Transient exact-target selection; admission and model operations belong to adapters. use crate::diagram_usage::{Surface, Target}; use oa_core::Id; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct Scope { /// Document-instance identity, not the persisted project UUID. pub document: Id, pub surface: Surface, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct Context { pub scope: Scope, pub revision: u64, pub epoch: u64, } /// Insertion-ordered unique targets. Membership does not grant edit capability. #[derive(Debug)] pub(crate) struct Selection { scope: Scope, targets: Vec, primary: Option, generation: u64, } /// Immutable operation input, including the selection lifetime that captured it. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Snapshot { context: Context, generation: u64, targets: Vec, primary: Option, } impl Selection { pub fn new(scope: Scope) -> Self { Self { scope, targets: Vec::new(), primary: None, generation: 0, } } pub fn scope(&self) -> Scope { self.scope } pub fn targets(&self) -> &[Target] { &self.targets } pub fn primary(&self) -> Option<&Target> { self.primary.as_ref() } pub fn contains(&self, target: &Target) -> bool { self.targets.contains(target) } /// Replace with one valid target. Rejected admission preserves the old set. pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool { if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) { return false; } self.targets = vec![target.clone()]; self.primary = Some(target); self.changed(); true } /// Add a new member as primary. Adding an existing exact target is a no-op. pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool { if !admitted(&target, valid) || self.contains(&target) { return false; } self.targets.push(target.clone()); self.primary = Some(target); self.changed(); true } /// Removal is allowed even if the target is no longer valid in the model. pub fn remove(&mut self, target: &Target) -> bool { let Some(index) = self.targets.iter().position(|member| member == target) else { return false; }; self.targets.remove(index); if self.primary.as_ref() == Some(target) { self.primary = self.targets.last().cloned(); } self.changed(); true } pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool { if self.contains(&target) { self.remove(&target) } else { self.add(target, valid) } } /// Inspect another selected member without changing membership or its order. pub fn set_primary(&mut self, target: &Target) -> bool { if !self.contains(target) || self.primary.as_ref() == Some(target) { return false; } self.primary = Some(target.clone()); self.changed(); true } pub fn clear(&mut self) -> bool { if self.targets.is_empty() { return false; } self.targets.clear(); self.primary = None; self.changed(); true } /// Change owner or prune exact targets against the current rendered surface. /// The predicate checks identity/visibility, not eligibility for one action. /// Revision-only changes may retain survivors; no semantic retargeting occurs. pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool { if self.scope != scope { self.scope = scope; self.targets.clear(); self.primary = None; self.changed(); return true; } let previous_len = self.targets.len(); self.targets.retain(valid); if self.targets.len() == previous_len { return false; } if !self .primary .as_ref() .is_some_and(|target| self.contains(target)) { self.primary = self.targets.last().cloned(); } self.changed(); true } /// Capture input, including an empty selection before a first pointer press. /// Does not grant permission to mutate; adapters still guard drafts, modals, /// capability and geometry. A mismatched owner cannot capture a snapshot. pub fn snapshot(&self, context: Context) -> Option { if context.scope != self.scope { return None; } Some(Snapshot { context, generation: self.generation, targets: self.targets.clone(), primary: self.primary.clone(), }) } /// Check before any operation side effect, using the caller's live context. /// Generation rejects remove/re-add and primary-change/restore ABA cycles. pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool { snapshot.context == current && current.scope == self.scope && snapshot.generation == self.generation && snapshot.targets == self.targets && self.primary == snapshot.primary } fn changed(&mut self) { self.generation = self .generation .checked_add(1) .expect("selection generation exhausted"); } } impl Snapshot { pub fn context(&self) -> Context { self.context } pub fn targets(&self) -> &[Target] { &self.targets } pub fn primary(&self) -> Option<&Target> { self.primary.as_ref() } } fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool { !matches!(target, Target::Whole) && valid(target) } #[cfg(test)] mod tests { use super::*; use oa_core::OccurrenceEndpoint; fn id(value: u128) -> Id { Id::from_u128(value) } fn scope() -> Scope { Scope { document: id(1), surface: Surface::Saved(id(2)), } } fn context() -> Context { Context { scope: scope(), revision: 7, epoch: 11, } } fn node(value: u128) -> Target { Target::Placement(id(value)) } #[test] fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() { let mut selection = Selection::new(scope()); let port_a = Target::Port { port: id(30), placement: id(3), }; let port_b = Target::Port { port: id(30), placement: id(4), }; assert!(selection.add(node(3), |_| true)); assert!(selection.add(port_a.clone(), |_| true)); let captured = selection.snapshot(context()).unwrap(); assert!(!selection.add(node(3), |_| true)); assert!(!selection.add(port_a.clone(), |_| true)); assert!(selection.matches(&captured, context())); assert!(selection.add(port_b.clone(), |_| true)); assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]); assert_eq!(selection.primary(), Some(&port_b)); assert_eq!( captured.targets(), &[ node(3), Target::Port { port: id(30), placement: id(3) } ] ); } #[test] fn primary_removal_uses_latest_survivor_without_reordering() { let mut selection = Selection::new(scope()); for value in [3, 4, 5] { selection.add(node(value), |_| true); } assert!(selection.set_primary(&node(3))); assert_eq!(selection.targets(), &[node(3), node(4), node(5)]); assert!(!selection.set_primary(&node(99))); assert!(selection.remove(&node(4))); assert_eq!(selection.primary(), Some(&node(3))); assert!(selection.remove(&node(3))); assert_eq!(selection.primary(), Some(&node(5))); // A now-invalid member remains removable; validity applies to additions. assert!(selection.toggle(node(5), |_| false)); assert!(selection.targets().is_empty()); assert_eq!(selection.primary(), None); let empty = selection.snapshot(context()).unwrap(); assert!(empty.targets().is_empty()); assert_eq!(empty.primary(), None); assert!(selection.matches(&empty, context())); selection.add(node(5), |_| true); assert!(!selection.matches(&empty, context())); } #[test] fn whole_and_rejected_targets_cannot_replace_an_existing_selection() { let mut selection = Selection::new(scope()); selection.add(node(3), |_| true); let captured = selection.snapshot(context()).unwrap(); assert!(!selection.replace(Target::Whole, |_| true)); assert!(!selection.add(Target::Whole, |_| true)); assert!(!selection.toggle(Target::Whole, |_| true)); assert!(!selection.replace(node(4), |_| false)); assert!(!selection.toggle(node(4), |_| false)); assert!(selection.matches(&captured, context())); assert!(selection.replace(node(4), |_| true)); assert_eq!(selection.targets(), &[node(4)]); assert_eq!(selection.primary(), Some(&node(4))); assert!(!selection.replace(node(4), |_| true)); assert!(selection.clear()); assert!(!selection.clear()); assert_eq!(selection.primary(), None); } #[test] fn reused_process_occurrences_reconcile_by_full_path_never_definition() { let owner = Scope { surface: Surface::Process(id(2)), ..scope() }; let mut selection = Selection::new(owner); let occurrence = |path| { Target::ProcessOccurrence(OccurrenceEndpoint { references: vec![id(path), id(50)], occurrence: id(60), }) }; let first = occurrence(10); let second = occurrence(20); selection.add(first.clone(), |_| true); selection.add(second.clone(), |_| true); assert_eq!(selection.targets(), &[first.clone(), second.clone()]); assert!(selection.reconcile(owner, |target| target == &first)); assert_eq!(selection.targets(), std::slice::from_ref(&first)); assert_eq!(selection.primary(), Some(&first)); // A new call path with the same definition is not a replacement identity. let replacement = occurrence(70); assert!(selection.reconcile(owner, |target| target == &replacement)); assert!(selection.targets().is_empty()); assert_eq!(selection.primary(), None); } #[test] fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() { let mut selection = Selection::new(scope()); selection.add(node(3), |_| true); selection.add(node(4), |_| true); selection.set_primary(&node(3)); let captured = selection.snapshot(context()).unwrap(); let next = Context { revision: 8, ..context() }; assert!(!selection.reconcile(scope(), |_| true)); assert_eq!(selection.targets(), &[node(3), node(4)]); assert_eq!(selection.primary(), Some(&node(3))); assert!(!selection.matches(&captured, next)); let accepted = selection.snapshot(next).unwrap(); assert!(selection.matches(&accepted, next)); assert!(selection.reconcile(scope(), |target| target != &node(4))); assert_eq!(selection.primary(), Some(&node(3))); assert!(!selection.matches(&accepted, next)); } #[test] fn changing_document_or_surface_clears_even_identical_target_ids() { for changed in [ Scope { document: id(99), ..scope() }, Scope { surface: Surface::Saved(id(99)), ..scope() }, Scope { surface: Surface::Scenario(id(2)), ..scope() }, ] { let mut selection = Selection::new(scope()); selection.add(node(3), |_| true); let captured = selection.snapshot(context()).unwrap(); assert!(selection.reconcile(changed, |_| true)); assert_eq!(selection.scope(), changed); assert!(selection.targets().is_empty()); assert_eq!(selection.primary(), None); assert!(!selection.matches(&captured, context())); assert!(selection.snapshot(context()).is_none()); selection.reconcile(scope(), |_| true); selection.add(node(3), |_| true); assert!(!selection.matches(&captured, context())); } } #[test] fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() { let mut selection = Selection::new(scope()); selection.add(node(3), |_| true); selection.add(node(4), |_| true); let captured = selection.snapshot(context()).unwrap(); assert_eq!(captured.context(), context()); assert_eq!(captured.primary(), Some(&node(4))); assert!(!selection.matches( &captured, Context { epoch: 12, ..context() } )); selection.set_primary(&node(3)); selection.set_primary(&node(4)); assert!(!selection.matches(&captured, context())); let restored_primary = selection.snapshot(context()).unwrap(); selection.remove(&node(4)); selection.add(node(4), |_| true); assert_eq!(selection.targets(), captured.targets()); assert_eq!(selection.primary(), captured.primary()); assert!(!selection.matches(&restored_primary, context())); assert!(selection.matches(&selection.snapshot(context()).unwrap(), context())); } }