Files
ARCADIA-Studio/crates/oa-desktop/src/diagram_selection.rs
T

417 lines
14 KiB
Rust

//! 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<Target>,
primary: Option<Target>,
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<Target>,
primary: Option<Target>,
}
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<Snapshot> {
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()));
}
}