Add exact Process selection and atomic column-slot group reorder

Preserve the full-desktop-1 captured source. Qualified previews inspect their exact subject read-only; group movement preserves source order and other columns. Native acceptance and strict-lint payload-size corrections follow separately.
This commit is contained in:
2026-09-23 05:39:10 -07:00
parent bed7f825fd
commit 1711bff650
25 changed files with 3993 additions and 145 deletions
@@ -0,0 +1,264 @@
//! Exact Process presentation selection and one atomic order transaction.
use super::*;
use crate::behavior::topology::group_reorder::{Plan, Prepared};
use crate::diagram_selection::Selection;
use crate::diagram_usage::{Surface, Target};
use multiselect::{Choice, Event};
fn subject(target: &Target) -> Option<Id> {
match target {
Target::ProcessOccurrence(endpoint) => Some(endpoint.occurrence),
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => Some(*id),
_ => None,
}
}
impl App {
pub(crate) fn process_preview_primary(&self) -> Option<&Target> {
let context = self.topology_context()?;
let selection = self.diagram_selection.as_ref()?;
if selection.scope() != multiselect::process_context(context).scope {
return None;
}
selection
.primary()
.filter(|target| subject(target) == Some(self.selected))
.filter(|target| match target {
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
Target::ProcessPreviewLink { .. } => true,
_ => false,
})
}
pub(super) fn reconcile_process_multiselect(&mut self) {
let Some(context) = self.topology_context() else {
return;
};
let current = multiselect::process_context(context);
let visible = self
.behavior
.topology
.selection_targets(self.project(), context.owner);
let old = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == current.scope)
.and_then(Selection::primary)
.cloned();
let address = self.behavior.topology.selection_address();
let focused = self
.diagram_focus(Surface::Process(context.owner))
.map(|usage| usage.target.clone());
let legacy = focused
.clone()
.or_else(|| {
address
.as_ref()
.filter(|a| {
a.owner == context.owner
&& (a.endpoint.occurrence == self.selected
|| a.endpoint.references.first() == Some(&self.selected))
})
.map(|a| Target::ProcessOccurrence(a.endpoint.clone()))
})
.or_else(|| {
visible
.iter()
.find(|target| match target {
Target::ProcessOccurrence(endpoint) => {
endpoint.references.is_empty() && endpoint.occurrence == self.selected
}
Target::ProcessLink(id) => *id == self.selected,
_ => false,
})
.cloned()
})
.filter(|target| visible.contains(target));
let held = old.as_ref().is_some_and(|target| {
subject(target) == Some(self.selected)
&& focused.as_ref().is_none_or(|focused| focused == target)
&& match target {
Target::ProcessOccurrence(endpoint) if !endpoint.references.is_empty() => {
address
.as_ref()
.is_some_and(|a| a.owner == context.owner && a.endpoint == *endpoint)
}
_ => true,
}
});
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
let selection = self
.diagram_selection
.get_or_insert_with(|| Selection::new(current.scope));
selection.reconcile(current.scope, |target| visible.contains(target));
if held || lost {
if selection.primary() != old.as_ref() {
self.inspect_process_multiselect_primary();
}
} else if selection.primary() != legacy.as_ref() {
if let Some(target) = legacy {
selection.replace(target, |t| visible.contains(t));
} else {
selection.clear();
}
}
if self.process_preview_primary().is_some() {
self.draft = None;
}
}
pub(super) fn inspect_process_multiselect_primary(&mut self) {
let Some(context) = self.topology_context() else {
return;
};
let primary = self
.diagram_selection
.as_ref()
.and_then(Selection::primary)
.cloned();
self.behavior.topology.clear_context();
self.selected = primary.as_ref().and_then(subject).unwrap_or(context.owner);
self.selected_node = None;
self.selected_bend = None;
self.selected_attachment = None;
self.diagram_navigation_focus = None;
if let Some(Target::ProcessOccurrence(endpoint)) = primary
&& !endpoint.references.is_empty()
&& let Ok(resolved) = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
self.behavior.topology.select_context(resolved.address);
}
self.refresh_draft();
if self.process_preview_primary().is_some() {
self.draft = None;
}
}
pub(super) fn process_multiselect_event(&mut self, event: Event) {
let Some(context) = self.topology_context() else {
return;
};
if let Event::Refuse(_, reason) = &event
&& diagram_removal::enabled(self)
{
self.status = reason.clone();
return;
}
if !diagram_removal::enabled(self) || !self.behavior.topology.selecting() {
self.status =
"Finish or cancel the active edit before changing the process selection.".into();
return;
}
let visible = self
.behavior
.topology
.selection_targets(self.project(), context.owner);
match event {
Event::Choose(_, target, choice) => {
if !visible.contains(&target) {
return;
}
self.reconcile_process_multiselect();
let selection = self.diagram_selection.as_mut().unwrap();
let valid = |t: &Target| visible.contains(t);
match choice {
Choice::Replace => {
selection.replace(target, valid);
}
Choice::Add => {
selection.add(target, valid);
}
Choice::Remove => {
selection.remove(&target);
}
Choice::Toggle => {
selection.toggle(target, valid);
}
Choice::Inspect => {
selection.set_primary(&target);
}
}
self.inspect_process_multiselect_primary();
}
Event::Collapse(_, target) => {
if !visible.contains(&target) {
return;
}
self.diagram_selection
.as_mut()
.unwrap()
.replace(target, |t| visible.contains(t));
self.inspect_process_multiselect_primary();
}
Event::Clear(_) => {
self.diagram_selection.as_mut().unwrap().clear();
self.inspect_process_multiselect_primary();
}
Event::Refuse(_, reason) => self.status = reason,
Event::Hide(_) => {
self.status =
"Process cards are model contributions, not placements. Hide is unavailable."
.into()
}
Event::Move(..) => {
self.status =
"Process positions come from their paths. Reorder within one column instead."
.into()
}
Event::ProcessReorder(snapshot, destination) => {
if !snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
{
return;
}
match Prepared::new(self.project(), context.owner, snapshot.targets())
.and_then(|prepared| prepared.plan(destination))
{
Ok(Plan::Noop) => {}
Ok(Plan::Change(command)) => {
if self.execute(command) {
self.inspect_process_multiselect_primary();
self.status = format!(
"Reordered {} contributions · Undo restores their order",
snapshot.targets().len()
);
}
}
Err(error) => self.status = error,
}
}
}
}
/// A no-op must not retire a pending reveal or allocate a history entry.
pub(super) fn process_order_noop(&self, message: &Message) -> bool {
let Some(context) = self.topology_context() else {
return false;
};
match message {
Message::DiagramSelection(Event::ProcessReorder(snapshot, destination)) => {
Prepared::new(self.project(), context.owner, snapshot.targets())
.and_then(|prepared| prepared.plan(*destination))
.is_ok_and(|p| matches!(p, Plan::Noop))
}
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Reorder {
context: captured,
occurrence,
before,
}) if *captured == context => crate::behavior::topology::reorder(
self.project(),
context.owner,
*occurrence,
*before,
)
.is_ok_and(|p| matches!(p, Plan::Noop)),
_ => false,
}
}
}
#[cfg(test)]
mod tests;