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,544 @@
//! Deterministic Process native fixture and independent complete-Project oracle.
//! Expected order is literal; no desktop reorder/layout planner is used here.
use oa_core::{source_project::*, *};
use std::{fs, io::Write, path::Path};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn id(alias: &str) -> Id {
Id::new_v5(
&Id::from_u128(0xc1192026_0923_5000_8000_000000002930),
alias.as_bytes(),
)
}
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
p.elements.insert(
id(alias),
Element {
id: id(alias),
alias: alias.into(),
name: name.into(),
description: format!("Preserve engineering record {alias}."),
parent,
body: ElementBody::new(kind, layer).unwrap(),
},
);
}
fn occurrence(
p: &mut Project,
alias: &str,
name: &str,
layer: Layer,
owner: &str,
value: OccurrenceBody,
) {
element(p, alias, name, Kind::Occurrence, layer, Some(id(owner)));
let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get_mut(&id(alias)).unwrap().shared_mut()
else {
panic!("Occurrence body");
};
*body = value;
}
fn set_steps(p: &mut Project, owner: &str, aliases: &[&str]) {
*p.elements.get_mut(&id(owner)).unwrap().steps_mut().unwrap() =
aliases.iter().map(|alias| id(alias)).collect();
}
fn sequence_link(
p: &mut Project,
alias: &str,
name: &str,
source: &str,
target: &str,
) -> Result<()> {
let old = p.relate(RelationKind::SequenceLink, name, id(source), id(target));
reidentify(p, old, id(alias))?;
let relation = p.relations.get_mut(&id(alias)).unwrap();
relation.alias = alias.into();
relation.description = format!("Preserve link {alias} exactly.");
if let RelationBody::SequenceLink { guard, .. } = &mut relation.body {
*guard = "ready".into();
}
Ok(())
}
fn fixture() -> Result<Project> {
let mut p = Project::empty();
p.id = id("project");
p.alias = "process_multiselect_native".into();
p.name = "Process group native".into();
p.description = "Select Card C then Shift-select Card A; drop after Card B. Other columns and reusable engineering records remain exact.".into();
for (layer, prefix, owner, name, owner_kind, work_kind) in [
(
Layer::Oa,
"oa",
"oa_process",
"01 OA group order",
Kind::Process,
Kind::Activity,
),
(
Layer::Sa,
"sa",
"sa_chain",
"SA chain - preserve",
Kind::FunctionalChain,
Kind::Function,
),
] {
element(&mut p, owner, name, owner_kind, layer, None);
let shared = format!("{prefix}_shared_work");
let downstream = format!("{prefix}_downstream_work");
element(
&mut p,
&shared,
"Shared request work",
work_kind,
layer,
None,
);
element(
&mut p,
&downstream,
"Downstream verification",
work_kind,
layer,
None,
);
for (suffix, label) in [
("a", "Card A"),
("u", "Downstream U"),
("b", "Card B"),
("v", "Downstream V"),
("c", "Card C"),
] {
let body = if suffix == "b" {
OccurrenceBody::Control {
kind: ControlKind::Choice,
}
} else {
OccurrenceBody::Action {
action: Some(id(if suffix == "a" || suffix == "c" {
&shared
} else {
&downstream
})),
lifeline: None,
}
};
occurrence(
&mut p,
&format!("{prefix}_{suffix}"),
label,
layer,
owner,
body,
);
}
let order: Vec<_> = ["a", "u", "b", "v", "c"]
.map(|suffix| format!("{prefix}_{suffix}"))
.into_iter()
.collect();
set_steps(
&mut p,
owner,
&order.iter().map(String::as_str).collect::<Vec<_>>(),
);
sequence_link(
&mut p,
&format!("{prefix}_a_u"),
"A to U",
&format!("{prefix}_a"),
&format!("{prefix}_u"),
)?;
sequence_link(
&mut p,
&format!("{prefix}_b_v"),
"B to V",
&format!("{prefix}_b"),
&format!("{prefix}_v"),
)?;
}
element(
&mut p,
"reusable",
"Reusable request detail",
Kind::Process,
Layer::Oa,
None,
);
occurrence(
&mut p,
"reused_leaf",
"Shared detail",
Layer::Oa,
"reusable",
OccurrenceBody::Action {
action: Some(id("oa_shared_work")),
lifeline: None,
},
);
set_steps(&mut p, "reusable", &["reused_leaf"]);
element(
&mut p,
"reuse_trial",
"02 Read-only preview trial",
Kind::Process,
Layer::Oa,
None,
);
for (alias, name) in [
("reference_one", "First use"),
("reference_two", "Second use"),
] {
occurrence(
&mut p,
alias,
name,
Layer::Oa,
"reuse_trial",
OccurrenceBody::SequenceReference {
sequence: Some(id("reusable")),
},
);
}
set_steps(&mut p, "reuse_trial", &["reference_one", "reference_two"]);
p.traces.insert(
id("trace"),
Trace {
id: id("trace"),
alias: "preserved_work_refinement".into(),
name: "Shared request refinement".into(),
description: "Process arrangement must preserve cross-layer correspondence.".into(),
oa: id("oa_shared_work"),
sa: id("sa_shared_work"),
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(id("oa_shared_work")).unwrap(),
sa: p.properties(id("sa_shared_work")).unwrap(),
semantic: None,
}),
},
);
validate_candidate(&p)?;
Ok(p)
}
fn load(path: &Path) -> Result<Project> {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
{
let disk = source_files::load_project(path, ProjectLimits::default())?;
if disk
.diagnostics
.iter()
.any(|d| d.diagnostic.severity == validation::Severity::Error)
{
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
}
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
Ok(compiled.decoded.project)
} else {
Ok(persistence::load(path)?)
}
}
fn equal(expected: &Project, actual: &Project) -> Result<()> {
if expected == actual {
return Ok(());
}
let expected = serde_json::to_value(expected)?;
let actual = serde_json::to_value(actual)?;
let fields: Vec<_> = expected
.as_object()
.unwrap()
.keys()
.filter(|key| expected[*key] != actual[*key])
.cloned()
.collect();
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
}
fn roundtrip(p: &Project) -> Result<SourceBundle> {
validate_candidate(p)?;
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
let bundle = format_bundle(p);
let compiled = compile_bundle(&bundle, ProjectLimits::default())
.map_err(|e| format!("Roundtrip: {e:?}"))?;
equal(p, &compiled.decoded.project)?;
Ok(bundle)
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?
.write_all(bytes)?;
Ok(())
}
fn generate(out: &Path) -> Result<()> {
if out.exists() {
return Err("Output directory already exists; choose a fresh path".into());
}
let p = fixture()?;
let bundle = roundtrip(&p)?;
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
for (path, source) in &bundle.files {
write_new(&out.join("source").join(path), source.as_bytes())?;
}
let ids: std::collections::BTreeMap<_, _> = [
"project",
"oa_process",
"oa_a",
"oa_u",
"oa_b",
"oa_v",
"oa_c",
"oa_a_u",
"oa_b_v",
"oa_shared_work",
"oa_downstream_work",
"sa_chain",
"sa_shared_work",
"sa_downstream_work",
"sa_a",
"sa_u",
"sa_b",
"sa_v",
"sa_c",
"sa_a_u",
"sa_b_v",
"trace",
"reusable",
"reused_leaf",
"reuse_trial",
"reference_one",
"reference_two",
]
.into_iter()
.map(|name| (name, id(name)))
.collect();
write_new(
&out.join("manifest.json"),
&serde_json::to_vec_pretty(&serde_json::json!({
"fixture": "process-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
"original_oa_steps": ["oa_a", "oa_u", "oa_b", "oa_v", "oa_c"],
"reordered_oa_steps": ["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
"gesture": "Select Card C then Shift-select Card A; drag selected card to After Card B",
"fixed_slots": {"1": "oa_u", "3": "oa_v"},
"read_only_trial": "02 Read-only preview trial has two qualified tiles of reused_leaf through distinct references",
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction. Never replace baseline with a later saved snapshot.",
"evaluation": "Literal expected OA steps and complete Project equality; all SA/reuse/trace records exact",
"limits": "No pointer, focus, selection-paint, history or source-byte acceptance claim",
}))?,
)?;
equal(&p, &load(&out.join("baseline.json"))?)?;
equal(&p, &load(&out.join("source").join(&bundle.entry))?)?;
println!("PASS generated JSON and source bundle: {}", out.display());
Ok(())
}
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
let before = load(baseline)?;
let actual = load(saved)?;
validate_candidate(&before)?;
validate_candidate(&actual)?;
// Pin the exact deterministic original, not merely a reusable project UUID.
equal(&fixture()?, &before)
.map_err(|e| format!("Baseline is not the immutable original: {e}"))?;
let mut expected = before;
match mode {
"unchanged" => {}
"reordered" => set_steps(
&mut expected,
"oa_process",
&["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"],
),
_ => return Err("Mode must be reordered or unchanged".into()),
}
equal(&expected, &actual)?;
roundtrip(&actual)?;
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
"comparison": "complete Project equality; literal expected order",
"limits": "No pointer/action/focus/Undo/source-byte acceptance claim",
}))?
);
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
match args.as_slice() {
[command, out] if command == "generate" => generate(Path::new(out)),
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
_ => Err("Usage: process_multiselect_native generate DIRECTORY | check reordered|unchanged BASELINE SAVED".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reordered(before: &Project) -> Project {
let mut session = Session::new(before.clone());
session
.execute(Command::SetSteps(
id("oa_process"),
["oa_b", "oa_u", "oa_a", "oa_v", "oa_c"].map(id).to_vec(),
))
.unwrap();
session.project().clone()
}
fn through_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
validate_candidate(baseline).expect("Baseline must be valid");
validate_candidate(actual).expect("Corruption must remain semantically valid");
let dir = tempfile::tempdir()?;
let before = dir.path().join("before.json");
let saved = dir.path().join("saved.json");
persistence::save(&before, baseline)?;
persistence::save(&saved, actual)?;
check(mode, &before, &saved)
}
fn mismatch(before: &Project, actual: &Project) {
let error = through_json("reordered", before, actual)
.unwrap_err()
.to_string();
assert!(
error.contains("Whole Project mismatch"),
"Unexpected rejection: {error}"
);
}
#[test]
fn generate_is_deterministic_create_only_and_checks_real_source() {
equal(&fixture().unwrap(), &fixture().unwrap()).unwrap();
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fixture");
generate(&out).unwrap();
let baseline = out.join("baseline.json");
let bytes = fs::read(&baseline).unwrap();
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
assert!(generate(&out).is_err());
assert_eq!(fs::read(&baseline).unwrap(), bytes);
let invalid = dir.path().join("invalid.arcadia");
fs::write(&invalid, "project {").unwrap();
assert!(load(&invalid).is_err());
}
#[test]
fn real_set_steps_passes_but_unchanged_and_mutated_baseline_do_not() {
let before = fixture().unwrap();
let actual = reordered(&before);
through_json("reordered", &before, &actual).unwrap();
through_json("unchanged", &before, &before).unwrap();
mismatch(&before, &before);
assert!(through_json("unchanged", &before, &actual).is_err());
let error = through_json("unchanged", &actual, &actual)
.unwrap_err()
.to_string();
assert!(error.contains("immutable original"), "{error}");
}
#[test]
fn global_splice_reversed_block_and_primary_only_results_are_rejected() {
let before = fixture().unwrap();
for order in [
["oa_u", "oa_b", "oa_a", "oa_c", "oa_v"],
["oa_b", "oa_u", "oa_c", "oa_v", "oa_a"],
["oa_a", "oa_u", "oa_b", "oa_c", "oa_v"],
] {
let mut actual = before.clone();
set_steps(&mut actual, "oa_process", &order);
mismatch(&before, &actual);
}
}
#[test]
fn semantic_link_work_sa_reuse_and_trace_changes_are_rejected() {
let before = fixture().unwrap();
let correct = reordered(&before);
let mutations: [fn(&mut Project); 7] = [
|p| {
if let RelationBody::SequenceLink { guard, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*guard = "changed".into();
}
},
|p| {
if let RelationBody::SequenceLink { target, .. } =
&mut p.relations.get_mut(&id("oa_a_u")).unwrap().body
{
*target = id("oa_v");
}
},
|p| {
p.elements
.get_mut(&id("oa_shared_work"))
.unwrap()
.name
.push_str(" drift");
},
|p| {
p.elements
.get_mut(&id("sa_shared_work"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
set_steps(p, "sa_chain", &["sa_b", "sa_u", "sa_a", "sa_v", "sa_c"]);
},
|p| {
p.elements
.get_mut(&id("reused_leaf"))
.unwrap()
.description
.push_str(" drift");
},
|p| {
p.traces.get_mut(&id("trace")).unwrap().baseline = None;
},
];
for mutate in mutations {
let mut bad = correct.clone();
mutate(&mut bad);
mismatch(&before, &bad);
}
}
#[test]
fn changed_source_roundtrip_preserves_both_layers_and_checker_rejects_source_drift() {
let before = fixture().unwrap();
let actual = reordered(&before);
let dir = tempfile::tempdir().unwrap();
let baseline = dir.path().join("baseline.json");
persistence::save(&baseline, &before).unwrap();
for corrupt in [false, true] {
let mut saved = actual.clone();
if corrupt {
saved
.elements
.get_mut(&id("sa_c"))
.unwrap()
.name
.push_str(" drift");
}
let bundle = roundtrip(&saved).unwrap();
let folder = dir.path().join(if corrupt { "corrupt" } else { "saved" });
for (path, text) in &bundle.files {
write_new(&folder.join(path), text.as_bytes()).unwrap();
}
let parsed = load(&folder.join(&bundle.entry)).unwrap();
assert_eq!(parsed.elements[&id("oa_process")].kind(), Kind::Process);
assert_eq!(
parsed.elements[&id("sa_chain")].kind(),
Kind::FunctionalChain
);
let result = check("reordered", &baseline, &folder.join(&bundle.entry));
if corrupt {
assert!(
result
.unwrap_err()
.to_string()
.contains("Whole Project mismatch")
);
} else {
result.unwrap();
}
}
}
}
+66 -2
View File
@@ -268,6 +268,7 @@ mod direct_scenario_tests {
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod process_multiselect;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -818,6 +819,9 @@ impl App {
.map(|n| n.id);
}
self.draft = Draft::from_project(self.project(), self.selected);
if self.process_preview_primary().is_some() {
self.draft = None;
}
if self.exact_strings
&& let Some(d) = &mut self.draft
{
@@ -1294,6 +1298,7 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let preview_read_only = self.process_preview_primary().is_some();
let selection_boundary = self.multiselect_boundary();
// Reject delayed gestures before they can retire newer input ownership.
if let Message::DiagramSelection(event)
@@ -1326,6 +1331,52 @@ impl App {
// or allocation ownership before its edit is refused.
return Task::none();
}
if let Message::TopologyCanvas(event) = &message {
use crate::behavior::topology::CanvasEvent;
if !matches!(
event,
CanvasEvent::Camera(..) | CanvasEvent::Scrolled(..) | CanvasEvent::Viewport(..)
) && self.topology_context() != Some(event.context())
{
return Task::none();
}
if matches!(
event,
CanvasEvent::Select(..)
| CanvasEvent::SelectQualified(..)
| CanvasEvent::Remove(..)
| CanvasEvent::Reorder { .. }
| CanvasEvent::Reconnect { .. }
) && self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
return Task::none();
}
}
if self.process_order_noop(&message) {
return Task::none();
}
if self.process_preview_primary().is_some()
&& matches!(
&message,
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
| Message::RelationKind(_)
| Message::Apply
| Message::Delete
| Message::DiagramRemove(_)
)
{
self.status = "Open the referenced definition to edit its contents.".into();
return Task::none();
}
// Captured Text commands validate their origin before they can
// invalidate that same origin or pass into existing command handlers.
if let Message::TextWorkspace(event) = message {
@@ -1420,6 +1471,9 @@ impl App {
{
self.reconcile_multiselect();
}
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
self.refresh_draft();
}
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
@@ -2333,6 +2387,9 @@ impl App {
self.route_context()
};
}
if request.process.is_some() {
request.process = self.topology_context();
}
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
}
Message::Menu(crate::model_menu::Event::Close) => {
@@ -2362,7 +2419,10 @@ impl App {
"Apply or revert pending edits before using modeling actions.".into();
return Task::none();
}
if matches!(*action, Message::DiagramSelection(_)) {
if matches!(*action, Message::DiagramSelection(_))
|| (menu.request.process.is_some()
&& matches!(*action, Message::TopologyCanvas(_)))
{
return self.update(*action);
}
self.select(menu.request.object);
@@ -3215,7 +3275,11 @@ impl App {
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
{
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
self.status = if self.topology_context().is_some() {
"Group removal needs an aggregate review. Select one contribution to remove it individually."
} else {
"Group model-deletion review is not available yet. Select one element or use Hide selected."
}.into();
return Task::none();
}
if self.selected != self.project().id && self.apply() {
@@ -518,9 +518,16 @@ async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shar
.await;
assert!(messages.iter().any(|message| matches!(
message,
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
Message::DiagramSelection(multiselect::Event::Choose(
_, crate::diagram_usage::Target::ProcessOccurrence(actual), multiselect::Choice::Replace
)) if actual == &endpoint
)));
assert_eq!(app.selected, references[0]);
assert_eq!(app.selected, f.nodes[0]);
assert!(app.draft.is_none(), "Qualified inspection stays read-only");
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
endpoint
);
assert!(
app.behavior
.topology
+1 -8
View File
@@ -319,14 +319,7 @@ impl App {
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
Target::ProcessOccurrence(endpoint) => (endpoint.occurrence, None),
};
match usage.surface {
Surface::Saved(id) => {
@@ -881,7 +881,32 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
.unwrap()
.clone();
choose(&mut app, token, row);
assert_eq!(app.selected, first);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
let first_camera = app.behavior.topology.camera_boundary();
@@ -898,7 +923,33 @@ fn repeated_process_calls_reveal_the_chosen_caller_and_distinct_tile_camera() {
})
.unwrap();
choose(&mut app, token, row);
assert_eq!(app.selected, second);
assert_eq!(app.selected, leaf);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
context,
iced::Size::new(850., 460.),
)),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
assert_eq!(
app.behavior.topology.selection_address().unwrap().endpoint,
OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}))
);
assert!(app.process_preview_primary().is_some());
assert!(app.draft.is_none());
assert_eq!(app.selected_node, None);
assert!(app.behavior.topology.camera_boundary() != first_camera);
assert_eq!(authored(&app), before);
+48 -5
View File
@@ -19,6 +19,10 @@ pub(crate) enum Event {
Clear(Context),
Move(Snapshot, Position),
Hide(Snapshot),
ProcessReorder(
Snapshot,
crate::behavior::topology::group_reorder::Destination,
),
Refuse(Snapshot, String),
}
@@ -33,6 +37,17 @@ pub(crate) fn context(c: crate::route_edit::Context) -> Context {
}
}
pub(crate) fn process_context(c: crate::behavior::topology::Context) -> Context {
Context {
scope: Scope {
document: c.document,
surface: Surface::Process(c.owner),
},
revision: c.revision,
epoch: c.epoch,
}
}
/// The entire typed set must support a placement action. Never filter a mixture.
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
snapshot.primary()?;
@@ -83,12 +98,17 @@ impl App {
self.selected_node,
(self.page == Page::Behavior).then(|| self.state_view_identity()),
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
self.topology_context(),
self.behavior.topology.selection_address(),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
match self.page {
Page::Diagram => self.route_context().map(context),
Page::Behavior => {
if let Some(current) = self.topology_context() {
return Some(process_context(current));
}
let current = self.state_context()?;
crate::behavior::state_edit::validate_context(self.project(), current, current)
.ok()?;
@@ -99,6 +119,13 @@ impl App {
}
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
if let Some(context) = self.topology_context() {
return self
.behavior
.topology
.selection_targets(self.project(), context.owner)
.contains(target);
}
let Some(Context {
scope:
Scope {
@@ -130,11 +157,14 @@ impl App {
};
match event {
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
self.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(s, current))
}
Event::Move(s, _)
| Event::Hide(s)
| Event::ProcessReorder(s, _)
| Event::Refuse(s, _)
| Event::Collapse(s, _) => self
.diagram_selection
.as_ref()
.is_some_and(|selection| selection.matches(s, current)),
}
}
@@ -189,6 +219,10 @@ impl App {
}
pub(crate) fn reconcile_multiselect(&mut self) {
if self.topology_context().is_some() {
self.reconcile_process_multiselect();
return;
}
let Some(current) = self.multiselect_context() else {
self.diagram_selection = None;
return;
@@ -250,6 +284,10 @@ impl App {
}
fn inspect_multiselect_primary(&mut self) {
if self.topology_context().is_some() {
self.inspect_process_multiselect_primary();
return;
}
let diagram = self
.multiselect_context()
.and_then(|c| match c.scope.surface {
@@ -293,6 +331,10 @@ impl App {
if !self.multiselect_envelope(&event) {
return;
}
if self.topology_context().is_some() {
self.process_multiselect_event(event);
return;
}
if !diagram_removal::enabled(self)
|| self.architecture.is_some()
|| self.connection.is_some()
@@ -313,6 +355,7 @@ impl App {
self.document.view_index(),
);
match event {
Event::ProcessReorder(..) => {}
Event::Collapse(_, target) => {
if !visible(self.project(), diagram, &view, &target) {
return;
+7 -16
View File
@@ -143,21 +143,11 @@ impl App {
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
self.multiselect_event(multiselect::Event::Choose(
multiselect::process_context(context),
crate::diagram_usage::Target::ProcessOccurrence(endpoint),
multiselect::Choice::Replace,
));
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
@@ -293,7 +283,8 @@ impl App {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
Ok(topology::group_reorder::Plan::Noop) => {}
Ok(topology::group_reorder::Plan::Change(command)) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
@@ -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;
@@ -0,0 +1,777 @@
//! Process selection through the real App admission, document and source boundary.
use super::*;
use crate::behavior::topology::group_reorder::Destination;
use crate::behavior::topology::{self, CanvasEvent, CanvasTool};
use oa_core::OccurrenceEndpoint;
struct Fixture {
app: App,
owner: Id,
nodes: [Id; 5],
link: Id,
work: Id,
other_owner: Id,
}
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let mut element = project
.make_element(
Kind::Occurrence,
project.elements[&owner].layer(),
name,
Some(owner),
)
.unwrap();
let Some(SharedBody::Occurrence { body: value, .. }) = element.shared_mut() else {
unreachable!()
};
*value = body;
let id = element.id;
*project = execute_batch(project, &[Command::CreateElement(element)]).unwrap();
id
}
fn open(project: Project, owner: Id) -> App {
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
app.reconcile_multiselect();
assert_eq!(app.topology_context().unwrap().owner, owner);
assert_eq!(
app.multiselect_context().unwrap().scope.surface,
Surface::Process(owner)
);
app
}
fn fixture(layer: Layer) -> Fixture {
let (f, _) = topology::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut nodes = vec![];
for name in ["a", "u", "b", "v", "c"] {
nodes.push(add(
&mut project,
f.owner,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
));
}
let nodes: [Id; 5] = nodes.try_into().unwrap();
let [a, u, b, v, _] = nodes;
let link = project.relate(RelationKind::SequenceLink, "a to u", a, u);
project.relate(RelationKind::SequenceLink, "b to v", b, v);
let other = project
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Other owner",
None,
)
.unwrap();
let other_owner = other.id;
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
validate_candidate(&project).unwrap();
Fixture {
app: open(project, f.owner),
owner: f.owner,
nodes,
link,
work: f.work[0],
other_owner,
}
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::DiagramSelection(event));
}
fn choose(app: &mut App, target: Target, choice: Choice) {
let context = app.multiselect_context().unwrap();
send(app, Event::Choose(context, target, choice));
}
fn pair(f: &mut Fixture) {
choose(&mut f.app, local(f.nodes[4]), Choice::Replace);
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
&[local(f.nodes[4]), local(f.nodes[0])]
);
}
fn source_matches(app: &App) {
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
assert_eq!(&compiled.decoded.project, app.project());
assert_eq!(&compiled.bundle, app.document.bundle());
assert_eq!(
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
*app.document.bundle()
);
}
fn marker(app: &mut App, owner: Id, occurrence: Id) {
use crate::diagram_usage as usage;
let target = usage::collect(
app.project(),
occurrence,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == Surface::Process(owner) && u.target == local(occurrence))
.unwrap();
let context = usage::Context {
document: app.canvas_document,
revision: app.document.revision(),
subject: occurrence,
};
app.pending_diagram_reveal = Some((context, target.clone(), app.canvas_epoch));
app.diagram_navigation_focus = Some(usage::Focus {
context,
usage: target,
selected: app.selected,
});
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
}
#[test]
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let [a, u, b, v, c] = f.nodes;
pair(&mut f);
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.primary(), Some(&local(a)));
send(
&mut f.app,
Event::ProcessReorder(selection.clone(), Destination::After(b)),
);
let mut expected = before.decoded.project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![b, u, a, v, c];
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.selected, a);
let after_selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(after_selection.targets(), selection.targets());
assert_eq!(after_selection.primary(), selection.primary());
source_matches(&f.app);
let after = f.app.document.snapshot();
let reopened = Document::from_bundle(after.bundle.clone(), LIMITS).unwrap();
assert_eq!(reopened.project(), &expected);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(!f.app.document.can_undo());
source_matches(&f.app);
let _ = f.app.update(Message::Redo);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &after.bundle);
assert!(!f.app.document.can_redo());
source_matches(&f.app);
}
}
#[test]
fn process_noop_group_and_legacy_reorder_preserve_history_camera_and_pending_reveal() {
for group in [true, false] {
let mut f = fixture(Layer::Oa);
if group {
pair(&mut f);
} else {
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
}
marker(&mut f.app, f.owner, f.nodes[0]);
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
let pending = f.app.pending_diagram_reveal.clone();
let camera = f.app.behavior.topology.camera_boundary();
let epochs = (f.app.canvas_epoch, f.app.camera_epoch);
f.app.status = "Unchanged order feedback".into();
let message = if group {
Message::DiagramSelection(Event::ProcessReorder(
selection.clone(),
Destination::Before(f.nodes[4]),
))
} else {
Message::TopologyCanvas(CanvasEvent::Reorder {
context: f.app.topology_context().unwrap(),
occurrence: f.nodes[0],
before: f.nodes[2],
})
};
let _ = f.app.update(message);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pending_diagram_reveal, pending);
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert_eq!((f.app.canvas_epoch, f.app.camera_epoch), epochs);
assert_eq!(f.app.status, "Unchanged order feedback");
assert!(!f.app.document.can_undo());
assert!(!f.app.document.can_redo());
}
}
#[test]
fn process_mixed_link_and_cross_column_groups_refuse_whole_operation() {
for layer in [Layer::Oa, Layer::Sa] {
for mixed in [true, false] {
let mut f = fixture(layer);
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let other = if mixed {
Target::ProcessLink(f.link)
} else {
local(f.nodes[1])
};
choose(&mut f.app, other.clone(), Choice::Add);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[local(f.nodes[0]), other]);
let before = f.app.document.snapshot();
send(
&mut f.app,
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert!(!f.app.document.can_undo());
source_matches(&f.app);
}
}
}
#[test]
fn process_stale_document_revision_owner_epoch_and_selection_aba_reject_before_shared_hooks() {
for stale_by in ["document", "revision", "owner", "epoch", "ABA"] {
let mut f = fixture(Layer::Oa);
pair(&mut f);
let stale = f.app.multiselect_snapshot().unwrap();
match stale_by {
"document" => f.app.canvas_document = Id::new_v4(),
"revision" => {
f.app
.document
.execute(Command::Describe(f.work, "New revision".into()))
.unwrap();
}
"owner" => {
f.app.behavior.active_owner = Some(f.other_owner);
f.app.selected = f.other_owner;
f.app.reconcile_multiselect();
}
"epoch" => f.app.canvas_epoch += 1,
_ => {
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
choose(&mut f.app, local(f.nodes[0]), Choice::Toggle);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets(),
stale.targets()
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
stale.primary()
);
}
}
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot();
let camera = f.app.behavior.topology.camera_boundary();
let selected = f.app.selected;
let epochs = (
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch,
);
let history = (f.app.document.can_undo(), f.app.document.can_redo());
f.app.status = "Current owner feedback".into();
f.app.allocation_drag.arrange_next = true;
send(
&mut f.app,
Event::ProcessReorder(stale, Destination::After(f.nodes[2])),
);
assert!(
Arc::ptr_eq(&before, &f.app.document.snapshot()),
"{stale_by}"
);
assert_eq!(f.app.multiselect_snapshot(), selection);
assert_eq!(f.app.selected, selected);
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert_eq!(
(
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(
(f.app.document.can_undo(), f.app.document.can_redo()),
history
);
assert_eq!(f.app.status, "Current owner feedback");
assert!(f.app.allocation_drag.arrange_next);
}
}
#[test]
fn process_pending_properties_source_form_tool_and_busy_never_implicitly_apply_or_change_group() {
for pending in [
"properties",
"invalid properties",
"source",
"form",
"tool",
"busy",
] {
let mut f = fixture(Layer::Oa);
pair(&mut f);
match pending {
"properties" => {
let _ = f.app.update(Message::Field(
"/name".into(),
"Unapplied contribution".into(),
));
}
"invalid properties" => {
let _ = f
.app
.update(Message::Field("/alias".into(), "invalid alias!".into()));
}
"source" => {
let text = format!("{}\n// Unapplied source\n", f.app.source_workspace.text());
let _ = f
.app
.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
}
"form" => {
let context = f.app.topology_context().unwrap();
let _ = f.app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
let _ = f.app.update(Message::Behavior(behavior::Event::Topology(
topology::Event::Name("Unsubmitted form".into()),
)));
assert!(f.app.behavior.has_pending());
}
"tool" => f.app.behavior.topology.choose(CanvasTool::Connect),
_ => f.app.busy = true,
}
if pending == "source" {
assert!(f.app.source_dirty());
}
if pending.contains("properties") {
assert!(f.app.draft_dirty());
}
let selection = f.app.multiselect_snapshot().unwrap();
let before = f.app.document.snapshot();
let source = f.app.source_workspace.bundle();
let draft = f
.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
let form = f.app.behavior.recovery_snapshot();
for event in [
Event::ProcessReorder(selection.clone(), Destination::After(f.nodes[2])),
Event::Clear(selection.context()),
Event::Choose(selection.context(), local(f.nodes[2]), Choice::Replace),
] {
send(&mut f.app, event);
assert!(
Arc::ptr_eq(&before, &f.app.document.snapshot()),
"{pending}"
);
assert_eq!(f.app.source_workspace.bundle(), source);
assert_eq!(
f.app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
assert_eq!(f.app.behavior.recovery_snapshot(), form);
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert!(!f.app.document.can_undo());
}
}
}
#[test]
fn process_queued_singleton_selection_remove_reorder_and_reconnect_cannot_retire_current_group() {
let mut f = fixture(Layer::Oa);
pair(&mut f);
marker(&mut f.app, f.owner, f.nodes[0]);
let context = f.app.topology_context().unwrap();
let before = f.app.document.snapshot();
let selection = f.app.multiselect_snapshot().unwrap();
let pending = f.app.pending_diagram_reveal.clone();
let camera = f.app.behavior.topology.camera_boundary();
let epochs = (
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch,
);
f.app.status = "Group owns input".into();
for event in [
CanvasEvent::Select(context, f.nodes[2]),
CanvasEvent::SelectQualified(
context,
OccurrenceEndpoint {
references: vec![],
occurrence: f.nodes[2],
},
),
CanvasEvent::Remove(context, f.nodes[0]),
CanvasEvent::Reorder {
context,
occurrence: f.nodes[0],
before: f.nodes[4],
},
CanvasEvent::Reconnect {
context,
link: f.link,
end: topology::End::Target,
target: f.nodes[4],
},
] {
let _ = f.app.update(Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), selection);
assert_eq!(f.app.pending_diagram_reveal, pending);
assert!(f.app.behavior.topology.camera_boundary() == camera);
assert_eq!(
(
f.app.canvas_epoch,
f.app.camera_epoch,
f.app.allocation_drag.epoch
),
epochs
);
assert_eq!(f.app.status, "Group owns input");
assert!(!f.app.delete_pending);
assert!(!f.app.document.can_undo());
}
}
fn qualified_fixture() -> (Fixture, Id, Id, Id, Id) {
let mut f = fixture(Layer::Oa);
let mut project = f.app.project().clone();
let sequence = project
.make_element(Kind::Process, Layer::Oa, "Reusable checks", None)
.unwrap();
let sequence_id = sequence.id;
project = execute_batch(&project, &[Command::CreateElement(sequence)]).unwrap();
// Collapsed reuse cards still show four preview entries. Keep the tested
// leaf beyond that bound so collapsing really removes its exact target.
for index in 0..4 {
add(
&mut project,
sequence_id,
&format!("Preview prefix {index}"),
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
}
let leaf = add(
&mut project,
sequence_id,
"Shared check",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
let first = add(
&mut project,
f.owner,
"First call",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let second = add(
&mut project,
f.owner,
"Second call",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
f.app = open(project, f.owner);
let p = f.app.project().clone();
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
assert!(f.app.behavior.topology.expand(&p, f.owner, second));
(f, sequence_id, first, second, leaf)
}
#[test]
fn process_qualified_primary_is_exact_readonly_and_clears_without_semantic_rebinding() {
let (mut f, _, first, second, leaf) = qualified_fixture();
let p = f.app.project().clone();
let endpoint = OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
};
let target = Target::ProcessOccurrence(endpoint.clone());
assert!(f.app.multiselect_target_visible(&target));
choose(&mut f.app, target.clone(), Choice::Replace);
assert_eq!(
f.app.selected, leaf,
"Inspect exact leaf, not the first caller or shared work"
);
assert_eq!(f.app.process_preview_primary(), Some(&target));
assert_eq!(
f.app
.behavior
.topology
.selection_address()
.unwrap()
.endpoint,
endpoint
);
assert!(f.app.draft.is_none());
let before = f.app.document.snapshot();
for message in [
Message::Field("/name".into(), "Forbidden definition rename".into()),
Message::Apply,
Message::Delete,
] {
let _ = f.app.update(message);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(f.app.draft.is_none());
assert!(!f.app.delete_pending);
assert_eq!(f.app.process_preview_primary(), Some(&target));
}
choose(&mut f.app, local(f.nodes[0]), Choice::Add);
let mixed = f.app.multiselect_snapshot().unwrap();
send(
&mut f.app,
Event::ProcessReorder(mixed.clone(), Destination::After(f.nodes[2])),
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert_eq!(f.app.multiselect_snapshot().unwrap(), mixed);
choose(&mut f.app, target.clone(), Choice::Replace);
let current = f.app.multiselect_context().unwrap();
send(&mut f.app, Event::Clear(current));
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(f.app.selected, f.owner);
assert!(f.app.behavior.topology.selection_address().is_none());
choose(&mut f.app, target, Choice::Replace);
assert!(f.app.behavior.topology.expand(&p, f.owner, first));
f.app.reconcile_multiselect();
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(f.app.selected, f.owner);
assert!(f.app.behavior.topology.selection_address().is_none());
assert!(f.app.process_preview_primary().is_none());
assert!(
f.app
.multiselect_target_visible(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![second],
occurrence: leaf
}))
);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
fn menu(app: &mut App, target: Target, label: &str) -> Message {
use crate::context_menu::Entry;
use crate::model_menu;
let object = match &target {
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
_ => panic!("This fixture opens contribution menus"),
};
let mut request = model_menu::Request::object(object, iced::Point::new(180., 150.));
request.process = app.topology_context();
request.selection_target = Some(Box::new(target));
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
let opened = app
.model_menu
.as_ref()
.expect("Captured Process menu must survive the open epoch change");
assert!(opened.valid(app));
assert_eq!(opened.request.process, app.topology_context());
model_menu::popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action {
label: name,
message: Some(message),
..
} if name == label => Some(message),
_ => None,
})
.unwrap_or_else(|| panic!("Missing enabled menu action: {label}"))
}
#[test]
fn process_menu_add_uses_rebound_epoch_and_preserves_existing_primary_membership() {
let mut f = fixture(Layer::Oa);
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let before = f.app.document.snapshot();
let old_context = f.app.topology_context().unwrap();
let action = menu(&mut f.app, local(f.nodes[4]), "Add to selection");
assert_ne!(f.app.topology_context().unwrap().epoch, old_context.epoch);
assert_eq!(f.app.selected, f.nodes[0]);
let _ = f.app.update(action);
let selection = f.app.multiselect_snapshot().unwrap();
assert_eq!(selection.targets(), &[local(f.nodes[0]), local(f.nodes[4])]);
assert_eq!(selection.primary(), Some(&local(f.nodes[4])));
assert_eq!(f.app.selected, f.nodes[4]);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
#[test]
fn process_qualified_nonprimary_menu_opens_definition_from_caller_and_back_returns() {
let (mut f, definition, first, _, leaf) = qualified_fixture();
choose(&mut f.app, local(f.nodes[0]), Choice::Replace);
let before = f.app.document.snapshot();
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
let action = menu(&mut f.app, target, "Open referenced definition");
assert_eq!(f.app.selected, f.nodes[0]);
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
let _ = f.app.update(action);
assert_eq!(f.app.behavior.active_owner, Some(definition));
assert_eq!(f.app.topology_context().unwrap().owner, definition);
let context = f.app.topology_context().unwrap();
let _ = f
.app
.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(f.app.behavior.active_owner, Some(f.owner));
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
#[test]
fn process_open_current_qualified_primary_and_catalog_navigation_restore_editable_draft() {
for catalog in [false, true] {
let (mut f, definition, first, _, leaf) = qualified_fixture();
let target = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![first],
occurrence: leaf,
});
choose(&mut f.app, target.clone(), Choice::Replace);
assert_eq!(f.app.selected, leaf);
assert!(f.app.draft.is_none());
let before = f.app.document.snapshot();
if catalog {
let _ = f.app.update(Message::Page(Page::Catalog));
assert_eq!(f.app.page, Page::Catalog);
} else {
let action = menu(&mut f.app, target, "Open referenced definition");
let _ = f.app.update(action);
assert_eq!(f.app.topology_context().unwrap().owner, definition);
}
assert_eq!(f.app.selected, leaf);
assert!(f.app.process_preview_primary().is_none());
assert!(
f.app.draft.is_some(),
"Leaving a read-only presentation restores normal property editing"
);
let _ = f.app.update(Message::Field(
"/name".into(),
"Definition edit stays unapplied".into(),
));
assert!(f.app.draft_dirty());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
assert!(!f.app.document.can_undo());
}
}
#[test]
fn process_usage_navigation_between_two_preview_paths_keeps_exact_link_identity() {
use crate::diagram_usage as usage;
let (mut f, definition, first, second, leaf) = qualified_fixture();
let mut project = f.app.project().clone();
let next = add(
&mut project,
definition,
"Following check",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: None,
},
);
let relation = project.relate(RelationKind::SequenceLink, "Reusable path", leaf, next);
f.app = open(project, f.owner);
let project = f.app.project().clone();
assert!(f.app.behavior.topology.expand(&project, f.owner, first));
assert!(f.app.behavior.topology.expand(&project, f.owner, second));
let before = f.app.document.snapshot();
for call in [first, second] {
let target = Target::ProcessPreviewLink {
relation,
source: OccurrenceEndpoint {
references: vec![call],
occurrence: leaf,
},
target: OccurrenceEndpoint {
references: vec![call],
occurrence: next,
},
};
let context = usage::Context {
document: f.app.canvas_document,
revision: f.app.document.revision(),
subject: relation,
};
let _ = f
.app
.update(Message::DiagramUsage(usage::Event::Open(context)));
let token = f.app.diagram_usages.as_ref().unwrap().token;
let rows = usage::collect(
f.app.project(),
relation,
&f.app.behavior.topology,
&f.app.behavior.scenario,
);
let row = rows
.iter()
.find(|row| row.surface == Surface::Process(f.owner) && row.target == target)
.unwrap()
.clone();
let _ = f.app.update(Message::DiagramUsagesReady(usage::Completion {
token,
context,
result: Ok(rows),
}));
let _ = f
.app
.update(Message::DiagramUsage(usage::Event::Choose(token, row)));
assert_eq!(f.app.selected, relation);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&target)
);
assert_eq!(f.app.process_preview_primary(), Some(&target));
assert!(f.app.draft.is_none());
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
}
}
@@ -15,6 +15,7 @@ struct Input {
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
modifiers: keyboard::Modifiers,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
@@ -83,7 +84,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
let modifiers = state.modifiers;
*state = Input {
modifiers,
..Input::default()
};
}
if state
.scope
@@ -143,10 +148,29 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<Input>();
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
// Physical modifiers outlive focus, busy-state child replacement,
// and semantic revisions. Observing these emits no camera command.
state.modifiers = *modifiers;
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
return;
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = keyboard::Modifiers::default();
}
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
// A traversal operation is applied after this Iced event batch. Keep
// its focus witness, but retire keyboard admission immediately.
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
@@ -334,6 +358,20 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
} else {
mouse::Cursor::Unavailable
};
if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) {
// A canvas may have been rebuilt while focus/commands were denied.
// Restore held (or released) Shift before this new pointer gesture.
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Keyboard(keyboard::Event::ModifiersChanged(state.modifiers)),
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
@@ -3,6 +3,58 @@ use crate::behavior::canvas_workspace::tests::{Geometry, Mounted};
use iced::Vector;
use iced::advanced::{Layout, layout, widget};
#[tokio::test]
async fn production_scenario_passive_modifiers_do_not_acquire_keyboard_focus_or_mutate_model() {
use crate::behavior::canvas_workspace::tests::{focused, key};
let f = super::super::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let point = bounds.position() + Vector::new(50., 50.);
let camera = app.behavior.scenario.camera_boundary();
let source = app.document.bundle().clone();
let shift = |held| {
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if held {
iced::keyboard::Modifiers::SHIFT
} else {
iced::keyboard::Modifiers::empty()
}))
};
for busy in [false, true, false] {
app.busy = busy;
mounted.settle(&mut app).await;
let messages = mounted
.batch(
&mut app,
&[
shift(true),
shift(false),
key(iced::keyboard::key::Named::ArrowRight),
],
mouse::Cursor::Available(point),
)
.await;
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
);
assert!(!focused(&mut mounted, &app, "viewport"));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &source);
}
mounted
.pan(&mut app, point, point + Vector::new(-15., 25.))
.await;
assert!(app.behavior.scenario.camera_boundary() != camera);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("scenario-viewport"), bounds);
}
#[tokio::test]
async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
+74 -54
View File
@@ -5,8 +5,11 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
pub(crate) mod group_reorder;
mod usages;
mod view;
#[cfg(test)]
pub(crate) use direct::End;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
@@ -909,6 +912,53 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
})
.collect()
}
/// Integer columns shared by rendering and order planning. Edges whose endpoints
/// are absent from the displayed order cannot participate in its layout.
fn column_ranks(order: &[Id], adjacency: &[(Id, Id)]) -> BTreeMap<Id, usize> {
let ids: BTreeSet<_> = order.iter().copied().collect();
let adjacency: Vec<_> = adjacency
.iter()
.copied()
.filter(|(source, target)| ids.contains(source) && ids.contains(target))
.collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for &(_, target) in &adjacency {
*incoming.get_mut(&target).expect("Displayed endpoint") += 1;
}
let mut queue: Vec<_> = order
.iter()
.copied()
.filter(|id| incoming[id] == 0)
.collect();
let mut done = BTreeSet::new();
let mut offset = 0;
while offset < queue.len() {
let id = queue[offset];
offset += 1;
if !done.insert(id) {
continue;
}
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Displayed endpoint");
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(next);
}
}
}
// Preserve deterministic fallback order for loops and cycle-blocked nodes.
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
for id in order {
if !done.contains(id) {
ranks.insert(*id, fallback);
fallback += 1;
}
}
ranks
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewLink {
relation: Id,
@@ -1066,6 +1116,7 @@ struct Graph<'a> {
selected: Id,
selected_preview: Option<OccurrenceEndpoint>,
selected_preview_link: Option<PreviewLink>,
selection: Option<crate::diagram_selection::Snapshot>,
scale: f32,
}
impl<'a> Graph<'a> {
@@ -1084,50 +1135,19 @@ impl<'a> Graph<'a> {
) -> Self {
let context_index = oa_core::topology_context::TopologyIndex::new(p);
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let displayed: BTreeSet<_> = choices.iter().map(|choice| choice.id).collect();
let links: Vec<_> = sequence_links(p, owner)
.into_iter()
.filter(|link| displayed.contains(&link.source()) && displayed.contains(&link.target()))
.collect();
let order = legacy_order(p, owner);
let adjacency: Vec<_> = links
.iter()
.map(|r| (r.source(), r.target()))
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
.collect();
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
for &(_, target) in &adjacency {
*incoming.entry(target).or_default() += 1;
}
let mut queue: Vec<_> = choices
.iter()
.filter(|c| incoming[&c.id] == 0)
.map(|c| c.id)
.collect();
let mut done = BTreeSet::new();
let mut offset = 0;
while offset < queue.len() {
let id = queue[offset];
offset += 1;
if !done.insert(id) {
continue;
}
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
let count = incoming.get_mut(&next).expect("Graph endpoint");
*count = count.saturating_sub(1);
if *count == 0 {
queue.push(next);
}
}
}
// Cycles remain explicit edges; deterministic fallback placement must
// terminate even while users are building a loop or incomplete graph.
let mut fallback = ranks.values().copied().max().unwrap_or(0) + 1;
for c in &choices {
if !done.contains(&c.id) {
ranks.insert(c.id, fallback);
fallback += 1;
}
}
let displayed_order: Vec<_> = choices.iter().map(|choice| choice.id).collect();
let ranks = column_ranks(&displayed_order, &adjacency);
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
@@ -1238,25 +1258,17 @@ impl<'a> Graph<'a> {
}
let ordered_paths = order
.windows(2)
.map(|pair| {
let a = nodes
.iter()
.find(|n| n.id == pair[0])
.expect("ordered source")
.rect;
let b = nodes
.iter()
.find(|n| n.id == pair[1])
.expect("ordered target")
.rect;
OrderedPath {
.filter_map(|pair| {
let a = nodes.iter().find(|n| n.id == pair[0])?.rect;
let b = nodes.iter().find(|n| n.id == pair[1])?.rect;
Some(OrderedPath {
source: pair[0],
target: pair[1],
points: vec![
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
}
})
})
.collect();
let mut edges = vec![];
@@ -1400,6 +1412,7 @@ impl<'a> Graph<'a> {
selected,
selected_preview: None,
selected_preview_link: None,
selection: None,
scale: 1.,
}
}
@@ -1518,9 +1531,16 @@ impl canvas::Program<Message> for Graph<'_> {
EdgeLayer::Contextual
}
}) {
let selected = e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected;
let selected = self.selection.as_ref().map_or(
e.id == self.selected
|| e.source == self.selected
|| e.target == self.selected,
|selection| {
selection
.targets()
.contains(&crate::diagram_usage::Target::ProcessLink(e.id))
},
);
if e.incidence {
f.stroke(
&Path::new(|path| {
@@ -1,5 +1,7 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[path = "direct_selection.rs"]
mod selection;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
@@ -445,27 +447,23 @@ pub(crate) fn reconnect(
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
pub(crate) fn reorder(
p: &Project,
owner: Id,
id: Id,
before: Id,
) -> Result<super::group_reorder::Plan, String> {
super::group_reorder::Prepared::new(
p,
owner,
&[crate::diagram_usage::Target::ProcessOccurrence(
OccurrenceEndpoint {
references: vec![],
occurrence: id,
},
)],
)?
.plan(super::group_reorder::Destination::Before(before))
}
#[derive(Default)]
@@ -476,6 +474,8 @@ pub(super) struct Input {
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
modifiers: iced::keyboard::Modifiers,
group: Option<selection::GroupPress>,
}
#[derive(Clone, Copy)]
enum Press {
@@ -504,6 +504,7 @@ impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
self.group = None;
}
}
impl Graph<'_> {
@@ -592,6 +593,9 @@ impl Graph<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if let Some(action) = self.selection_input(state, event, bounds, cursor) {
return Some(action);
}
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
@@ -835,6 +839,7 @@ impl Graph<'_> {
cursor: mouse::Cursor,
bounds: Rectangle,
) {
self.selection_overlay(state, f, bounds);
let Some((context, tool, editable)) = self.interaction else {
return;
};
@@ -935,7 +940,12 @@ impl Graph<'_> {
);
}
}
if tool == CanvasTool::Select {
if tool == CanvasTool::Select
&& self
.selection
.as_ref()
.is_none_or(|s| s.targets().len() == 1)
{
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
@@ -0,0 +1,474 @@
//! Exact-target selection and an order gesture over one immutable selection.
use super::super::group_reorder::{Destination, Plan, Prepared};
use super::*;
use crate::app::multiselect::{Choice, Event as SelectionEvent, process_context};
use crate::diagram_selection::Snapshot;
use crate::diagram_usage::Target;
pub(super) struct GroupPress {
snapshot: Snapshot,
target: Target,
start: Point,
bounds: Rectangle,
pan: iced::Vector,
scale: f32,
crossed: bool,
retired: bool,
prepared: Result<Prepared, String>,
destination: Option<Destination>,
plan: Option<Result<Plan, String>>,
}
impl Graph<'_> {
fn exact_target_at(&self, point: Point) -> Option<Target> {
// Visible singleton reconnect rings are painted over the card/edge.
// Shift and context selection must resolve that same topmost target.
if self.interaction.is_some_and(|(_, tool, editable)| editable && tool == CanvasTool::Select)
&& self.selection.as_ref().is_some_and(|snapshot| {
snapshot.targets().len() == 1
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
})
&& let Some((link, _)) = self.endpoint_at(point)
{
return Some(Target::ProcessLink(link));
}
if let Some(endpoint) = self.qualified_at(point) {
return Some(Target::ProcessOccurrence(endpoint));
}
for node in &self.nodes {
for link in &node.preview_links {
if node
.preview_link_visible_segment(link)
.is_some_and(|[a, b]| {
crate::diagram::distance_to_segment(point, a, b) < 5. / self.scale
})
{
return Some(Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
});
}
}
}
self.contextual_edge_at(point)
.map(Target::ProcessLink)
.or_else(|| {
self.node_at(point).map(|id| {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
})
})
.or_else(|| self.edge_at(point).map(Target::ProcessLink))
}
fn destination_at(&self, point: Point) -> Option<Destination> {
let id = self.node_at(point)?;
let node = self.nodes.iter().find(|n| n.id == id)?;
Some(if point.y < node.rect.center_y() {
Destination::Before(id)
} else {
Destination::After(id)
})
}
fn update_group(&self, group: &mut GroupPress, pointer: Point) {
if group.retired {
return;
}
if !pointer.x.is_finite() || !pointer.y.is_finite() || !group.bounds.contains(pointer) {
group.retired = true;
group.plan = None;
return;
}
group.crossed |= pointer.distance(group.start) >= 3.;
let world = Point::new(
(pointer.x - group.bounds.x - self.camera_pan.x) / self.scale,
(pointer.y - group.bounds.y - self.camera_pan.y) / self.scale,
);
let destination = self.destination_at(world);
// The captured semantic facts are prepared once. Only a changed object
// anchor/side needs another order plan; motion within a slot is free.
if destination != group.destination {
group.destination = destination;
group.plan = destination.map(|destination| match &group.prepared {
Ok(prepared) => prepared.plan(destination),
Err(reason) => Err(reason.clone()),
});
}
}
pub(super) fn selection_input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
match event {
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
| canvas::Event::Keyboard(KeyEvent::KeyReleased { modifiers, .. }) => {
state.modifiers = *modifiers
}
canvas::Event::Window(iced::window::Event::Unfocused) => {
state.modifiers = iced::keyboard::Modifiers::empty()
}
_ => {}
}
let Some((context, tool, editable)) = self.interaction else {
state.group = None;
return None;
};
let snapshot = self
.selection
.as_ref()
.filter(|s| s.context() == process_context(context));
if let Some(group) = &state.group {
let cancelled = !editable
|| tool != CanvasTool::Select
|| snapshot != Some(&group.snapshot)
|| group.bounds != bounds
|| group.pan != self.camera_pan
|| group.scale != self.scale
|| matches!(
event,
canvas::Event::Window(
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
) | canvas::Event::Mouse(
mouse::Event::CursorLeft
| mouse::Event::WheelScrolled { .. }
| mouse::Event::ButtonPressed(
mouse::Button::Right | mouse::Button::Middle
)
) | canvas::Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(Named::Escape),
..
})
);
if cancelled {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw().and_capture());
}
}
if let Some(group) = &mut state.group {
match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
self.update_group(group, *position);
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let mut group = state.group.take().unwrap();
// An unavailable cursor must never reuse the last inside point.
if let Some(pointer) = cursor.position() {
self.update_group(&mut group, pointer);
} else {
group.retired = true;
}
let result = if group.retired {
None
} else if !group.crossed {
Some(SelectionEvent::Collapse(group.snapshot, group.target))
} else if let Err(reason) = group.prepared {
Some(SelectionEvent::Refuse(group.snapshot, reason))
} else {
match (group.destination, group.plan) {
(Some(destination), Some(Ok(Plan::Change(_)))) => {
Some(SelectionEvent::ProcessReorder(group.snapshot, destination))
}
(_, Some(Err(reason))) => {
Some(SelectionEvent::Refuse(group.snapshot, reason))
}
_ => None,
}
};
return Some(
result
.map_or_else(Action::request_redraw, |event| {
Action::publish(Message::DiagramSelection(event))
})
.and_capture(),
);
}
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => return None,
canvas::Event::Keyboard(_) => return Some(Action::capture()),
_ => {}
}
}
let snapshot = snapshot?;
if !editable {
return None;
}
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
if matches!(
event,
canvas::Event::Keyboard(KeyEvent::KeyPressed {
key: Key::Named(Named::Delete),
repeat: false,
..
})
) && (snapshot.targets().len() > 1
|| snapshot.targets().iter().any(|t| match t {
Target::ProcessPreviewLink { .. } => true,
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
_ => false,
}))
{
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
}
let pointer = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().and(state.pointer.or(cursor.position())),
}?;
if !bounds.contains(pointer) {
return None;
}
let world = Point::new(
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
);
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
) {
state.cancel();
let target = self.exact_target_at(world)?;
let object = match &target {
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => *id,
_ => return None,
};
let mut request = crate::model_menu::Request::object(object, pointer);
request.selection_target = Some(Box::new(target));
request.process = Some(context);
return Some(
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
.and_capture(),
);
}
if !matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
return None;
}
if tool == CanvasTool::Connect && snapshot.targets().len() > 1 {
state.cancel();
return publish(SelectionEvent::Refuse(
snapshot.clone(),
"Connecting a selection as a group is unavailable. Select one contribution first."
.into(),
));
}
if tool != CanvasTool::Select {
return None;
}
let target = self.exact_target_at(world);
if state.modifiers.shift() {
state.cancel();
return target
.and_then(|target| {
publish(SelectionEvent::Choose(
process_context(context),
target,
Choice::Toggle,
))
})
.or_else(|| Some(Action::capture()));
}
let Some(target) = target else {
state.cancel();
// Clearing and starting the existing background camera pan can
// share this press; only semantic object hits capture it.
return Some(Action::publish(Message::DiagramSelection(
SelectionEvent::Clear(process_context(context)),
)));
};
// The ordinary endpoint gesture is valid only for an exact singleton
// local link. A mixed selection must never reconnect just its primary.
if snapshot.targets().len() == 1
&& matches!(snapshot.primary(), Some(Target::ProcessLink(id)) if *id == self.selected)
&& self.endpoint_at(world).is_some()
{
return None;
}
if !snapshot.targets().contains(&target) {
state.cancel();
return publish(SelectionEvent::Choose(
process_context(context),
target,
Choice::Replace,
));
}
state.cancel();
state.context = Some(context);
state.tool = tool;
state.bounds = Some(bounds);
state.pointer = Some(pointer);
state.group = Some(GroupPress {
snapshot: snapshot.clone(),
target,
start: pointer,
bounds,
pan: self.camera_pan,
scale: self.scale,
crossed: false,
retired: false,
prepared: Prepared::new(self.project, context.owner, snapshot.targets()),
destination: None,
plan: None,
});
Some(Action::request_redraw().and_capture())
}
pub(super) fn selection_overlay(&self, state: &Input, frame: &mut Frame, bounds: Rectangle) {
if let Some(selection) = &self.selection {
for target in selection.targets() {
let primary = selection.primary() == Some(target);
let width = if primary { 3.5 } else { 2. };
let color = if primary {
style::TEAL
} else {
Color::from_rgb8(64, 123, 188)
};
match target {
Target::ProcessOccurrence(endpoint) => {
let rect = if endpoint.references.is_empty() {
self.nodes
.iter()
.find(|n| n.id == endpoint.occurrence)
.map(|n| n.rect)
} else {
self.nodes
.iter()
.find(|n| n.preview.iter().any(|(shown, _)| shown == endpoint))
.map(|n| n.endpoint_rect(endpoint))
};
if let Some(rect) = rect {
frame.stroke(
&Path::rounded_rectangle(rect.position(), rect.size(), 5.into()),
Stroke::default().with_color(color).with_width(width),
);
}
}
Target::ProcessPreviewLink {
relation,
source,
target,
} => {
let link = PreviewLink {
relation: *relation,
source: source.clone(),
target: target.clone(),
};
for node in &self.nodes {
if node.preview_links.contains(&link)
&& let Some([a, b]) = node.preview_link_visible_segment(&link)
{
frame.stroke(
&Path::line(a, b),
Stroke::default().with_color(color).with_width(width),
);
}
}
}
_ => {}
}
}
}
let Some(group) = &state.group else {
return;
};
if group.retired
|| !group.crossed
|| group.bounds != bounds
|| self.selection.as_ref() != Some(&group.snapshot)
|| group.pan != self.camera_pan
|| group.scale != self.scale
{
return;
}
let reason = group
.prepared
.as_ref()
.err()
.or_else(|| group.plan.as_ref().and_then(|plan| plan.as_ref().err()));
let color = if reason.is_some() {
Color::from_rgb8(178, 49, 49)
} else {
style::TEAL
};
let mut position = state
.pointer
.map(|p| {
Point::new(
(p.x - bounds.x - self.camera_pan.x) / self.scale + 12.,
(p.y - bounds.y - self.camera_pan.y) / self.scale + 18.,
)
})
.unwrap_or(Point::ORIGIN);
if let Some(destination) = group.destination {
let (id, after) = match destination {
Destination::Before(id) => (id, false),
Destination::After(id) => (id, true),
};
if let Some(node) = self.nodes.iter().find(|n| n.id == id) {
let y = if after {
node.rect.y + node.rect.height + 7.
} else {
node.rect.y - 7.
};
frame.stroke(
&Path::line(
Point::new(node.rect.x, y),
Point::new(node.rect.x + node.rect.width, y),
),
Stroke::default().with_color(color).with_width(3.),
);
position = Point::new(node.rect.x, y + 3.);
}
}
let caption = reason.cloned().unwrap_or_else(|| match &group.plan {
Some(Ok(Plan::Noop)) => "Order unchanged".into(),
Some(Ok(Plan::Change(_))) => format!(
"Insert {} in source order · paths unchanged",
group.snapshot.targets().len()
),
_ => "Point at a card's upper / lower half to insert before / after".into(),
});
draw_text(frame, caption, position, 12. / self.scale, color);
if reason.is_none() {
let mut index = 0;
// Graph nodes are in original steps order; these numbers describe
// the block the exact same cached plan will commit on release.
for node in &self.nodes {
if group
.snapshot
.targets()
.contains(&Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}))
{
index += 1;
draw_text(
frame,
format!("{index}"),
Point::new(node.rect.x + node.rect.width - 18., node.rect.y + 7.),
13.,
style::TEAL,
);
}
}
}
}
}
#[cfg(test)]
#[path = "direct_selection_tests.rs"]
mod tests;
@@ -0,0 +1,401 @@
use super::*;
use crate::behavior::topology::direct_tests::{Fixture, context, fixture};
use crate::diagram_selection::Selection;
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn peers(layer: Layer) -> Fixture {
let mut fixture = fixture(layer);
fixture.p = execute_batch(&fixture.p, &[Command::Delete(fixture.link)]).unwrap();
fixture
}
fn graph<'a>(fixture: &'a Fixture, targets: &[Target]) -> Graph<'a> {
let context = context(fixture.owner);
let mut selection = Selection::new(process_context(context).scope);
for target in targets {
selection.add(target.clone(), |_| true);
}
let mut graph = Graph::new(&fixture.p, fixture.owner, fixture.owner);
graph.interaction = Some((context, CanvasTool::Select, true));
graph.selection = selection.snapshot(process_context(context));
graph
}
fn bounds() -> Rectangle {
Rectangle::new(Point::ORIGIN, Size::new(1800., 1400.))
}
fn point(graph: &Graph<'_>, id: Id, after: bool) -> Point {
let rect = graph.nodes.iter().find(|node| node.id == id).unwrap().rect;
Point::new(
rect.x + 70.,
rect.y + if after { rect.height - 15. } else { 20. },
)
}
fn send(
graph: &Graph<'_>,
input: &mut Input,
event: canvas::Event,
point: Point,
) -> Option<Message> {
graph
.input(input, &event, bounds(), mouse::Cursor::Available(point))
.and_then(|action| action.into_inner().0)
}
fn press(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
point,
);
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
point,
)
}
fn release(graph: &Graph<'_>, input: &mut Input, point: Point) -> Option<Message> {
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: point }),
point,
);
send(
graph,
input,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
point,
)
}
#[test]
fn exact_shift_toggle_and_stationary_group_click_defer_primary() {
let f = peers(Layer::Oa);
let graph = graph(&f, &[local(f.nodes[2]), local(f.nodes[0])]);
let mut input = Input::default();
let at = point(&graph, f.nodes[2], false);
assert!(press(&graph, &mut input, at).is_none());
assert!(
matches!(release(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Collapse(snapshot, target)))
if target == local(f.nodes[2]) && snapshot == graph.selection.clone().unwrap())
);
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
at,
);
assert!(
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == local(f.nodes[2]))
);
assert!(input.group.is_none());
send(
&graph,
&mut input,
canvas::Event::Window(iced::window::Event::Unfocused),
at,
);
assert!(!input.modifiers.shift());
}
#[test]
fn group_drag_uses_midpoint_anchors_and_original_order_and_singleton_after_gap() {
for layer in [Layer::Oa, Layer::Sa] {
let f = peers(layer);
for targets in [
vec![local(f.nodes[2]), local(f.nodes[0])],
vec![local(f.nodes[0])],
] {
let graph = graph(&f, &targets);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
let end = point(&graph, f.nodes[1], true);
assert!(press(&graph, &mut input, start).is_none());
let Some(Message::DiagramSelection(SelectionEvent::ProcessReorder(
snapshot,
destination,
))) = release(&graph, &mut input, end)
else {
panic!("admitted after slot");
};
assert_eq!(snapshot, graph.selection.clone().unwrap());
assert_eq!(destination, Destination::After(f.nodes[1]));
let Plan::Change(Command::SetSteps(owner, steps)) =
Prepared::new(&f.p, f.owner, snapshot.targets())
.unwrap()
.plan(destination)
.unwrap()
else {
panic!("changed steps");
};
assert_eq!(owner, f.owner);
assert_eq!(steps, vec![f.nodes[1], f.nodes[0], f.nodes[2]]);
}
}
}
#[test]
fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion() {
let f = peers(Layer::Oa);
let graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
let away = start + iced::Vector::new(10., 0.);
send(
&graph,
&mut input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: away }),
away,
);
assert!(release(&graph, &mut input, start).is_none());
assert!(input.group.is_none());
press(&graph, &mut input, start);
let outside = Point::new(-1., 100.);
send(
&graph,
&mut input,
canvas::Event::Mouse(mouse::Event::CursorMoved { position: outside }),
outside,
);
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
}
#[test]
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
let f = fixture(Layer::Sa);
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
graph.selected = f.link;
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
assert!(
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], true)), Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2)
);
let key = crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
let iced::Event::Keyboard(key) = key else {
panic!("keyboard");
};
assert!(matches!(
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
));
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
assert!(matches!(
press(&graph, &mut input, start),
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
));
}
#[test]
fn camera_busy_context_bounds_and_escape_retire_captured_gestures() {
let f = peers(Layer::Oa);
for change in 0..6 {
let mut graph = graph(&f, &[local(f.nodes[0]), local(f.nodes[2])]);
let mut input = Input::default();
let start = point(&graph, f.nodes[0], false);
press(&graph, &mut input, start);
match change {
0 => graph.camera_pan.x += 1.,
1 => graph.scale = 2.,
2 => graph.interaction.as_mut().unwrap().2 = false,
3 => graph.interaction.as_mut().unwrap().0.epoch += 1,
4 => {
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
iced::keyboard::key::Named::Escape,
) else {
panic!("keyboard");
};
send(&graph, &mut input, canvas::Event::Keyboard(key), start);
}
_ => {
graph.input(
&mut input,
&canvas::Event::Mouse(mouse::Event::CursorMoved { position: start }),
Rectangle::new(Point::new(1., 0.), bounds().size()),
mouse::Cursor::Available(start),
);
}
}
assert!(release(&graph, &mut input, point(&graph, f.nodes[1], true)).is_none());
assert!(input.group.is_none());
}
}
#[test]
fn qualified_occurrences_and_visible_preview_links_keep_exact_invocation_paths() {
let mut f = peers(Layer::Oa);
let source =
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
.unwrap();
let source_id = source.id;
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
let mut leaves = vec![];
for name in ["One", "Two"] {
let leaf = occurrence(
&f.p,
source_id,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
leaves.push(leaf.id);
f.p = execute_batch(&f.p, &[Command::CreateElement(leaf)]).unwrap();
}
let link = link(&f.p, leaves[0], leaves[1], "");
f.p = execute_batch(&f.p, &[link]).unwrap();
let mut references = vec![];
for name in ["First", "Second"] {
let reference = occurrence(
&f.p,
f.owner,
name,
OccurrenceBody::SequenceReference {
sequence: Some(source_id),
},
);
references.push(reference.id);
f.p = execute_batch(&f.p, &[Command::CreateElement(reference)]).unwrap();
}
let graph = graph(&f, &[]);
let mut targets = vec![];
for reference in references {
let node = graph.nodes.iter().find(|n| n.id == reference).unwrap();
let endpoint = OccurrenceEndpoint {
references: vec![reference],
occurrence: leaves[0],
};
let tile = node.endpoint_rect(&endpoint).center();
assert_eq!(
graph.exact_target_at(tile),
Some(Target::ProcessOccurrence(endpoint.clone()))
);
targets.push(Target::ProcessOccurrence(endpoint));
let link = &node.preview_links[0];
let [a, b] = node.preview_link_visible_segment(link).unwrap();
let at = Point::new((a.x + b.x) / 2., (a.y + b.y) / 2.);
let expected = Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
};
assert_eq!(graph.exact_target_at(at), Some(expected.clone()));
let mut input = Input::default();
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
at,
);
assert!(
matches!(press(&graph, &mut input, at), Some(Message::DiagramSelection(SelectionEvent::Choose(_, target, Choice::Toggle))) if target == expected)
);
assert!(
matches!(send(&graph, &mut input, canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)), at), Some(Message::Menu(crate::model_menu::Event::Open(request))) if request.selection_target.as_deref() == Some(&expected) && request.process == graph.interaction.map(|(context,_,_)| context))
);
}
assert_ne!(targets[0], targets[1]);
assert!(Prepared::new(&f.p, f.owner, &targets).is_err());
}
// The endpoint ring is drawn last, over the underlying contribution card.
// Probe two pixels inside that card: both semantic shapes occupy the point,
// so these tests establish the visible handle's selection precedence.
fn handle_point(graph: &Graph<'_>, link: Id, end: End) -> Point {
let edge = graph.edges.iter().find(|edge| edge.id == link).unwrap();
match end {
End::Source => edge.points[0] + iced::Vector::new(-2., 0.),
End::Target => *edge.points.last().unwrap() + iced::Vector::new(2., 0.),
}
}
#[test]
fn singleton_process_link_snapshot_reconnects_both_ends_but_stationary_release_is_inert() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for end in [End::Source, End::Target] {
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
graph.selected = f.link;
let original = graph.selection.clone().unwrap();
let start = handle_point(&graph, f.link, end);
let mut input = Input::default();
assert!(press(&graph, &mut input, start).is_none());
assert!(input.group.is_none());
assert!(
matches!(input.press, Some(Press::Reconnect { link, end: armed, moved: false, .. }) if link == f.link && armed == end)
);
assert!(release(&graph, &mut input, start).is_none());
assert!(input.press.is_none());
assert_eq!(graph.selection.as_ref(), Some(&original));
assert!(press(&graph, &mut input, start).is_none());
let target = point(&graph, f.nodes[2], false);
assert!(matches!(release(&graph, &mut input, target),
Some(Message::TopologyCanvas(CanvasEvent::Reconnect { context, link, end: actual, target }))
if context == graph.interaction.unwrap().0 && link == f.link && actual == end && target == f.nodes[2]));
assert!(input.group.is_none());
assert_eq!(graph.selection.as_ref(), Some(&original));
}
}
}
#[test]
fn shift_on_visible_singleton_process_link_handle_toggles_that_link_not_underlying_card() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
let mut graph = graph(&f, &[Target::ProcessLink(f.link)]);
graph.selected = f.link;
let start = handle_point(&graph, f.link, end);
assert_eq!(graph.node_at(start), Some(underneath));
let mut input = Input::default();
send(
&graph,
&mut input,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT,
)),
start,
);
assert!(matches!(press(&graph, &mut input, start),
Some(Message::DiagramSelection(SelectionEvent::Choose(_, Target::ProcessLink(id), Choice::Toggle)))
if id == f.link));
assert!(input.group.is_none() && input.press.is_none());
}
}
}
#[test]
fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_handle() {
for layer in [Layer::Oa, Layer::Sa] {
let f = fixture(layer);
for (end, underneath) in [(End::Source, f.nodes[0]), (End::Target, f.nodes[1])] {
let mut graph = graph(&f, &[local(underneath), Target::ProcessLink(f.link)]);
graph.selected = f.link;
let snapshot = graph.selection.clone().unwrap();
let start = handle_point(&graph, f.link, end);
// Multiple selections render no reconnect rings. The visible
// selected card owns this point, while the mixed set stays whole.
assert_eq!(graph.exact_target_at(start), Some(local(underneath)));
let mut input = Input::default();
assert!(press(&graph, &mut input, start).is_none());
assert!(input.group.is_some() && input.press.is_none());
assert!(
matches!(release(&graph, &mut input, point(&graph, f.nodes[2], false)),
Some(Message::DiagramSelection(SelectionEvent::Refuse(captured, _))) if captured == snapshot)
);
assert_eq!(graph.selection.as_ref(), Some(&snapshot));
}
}
}
@@ -360,11 +360,12 @@ fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources(
assert_eq!(doc.bundle(), &reconnected);
doc.undo();
assert_eq!(doc.bundle(), &original);
let reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.unwrap();
let group_reorder::Plan::Change(command) =
direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()
else {
panic!("changed order");
};
let reordered = execute_batch(&f.p, &[command]).unwrap();
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
assert_eq!(reordered.relations, f.p.relations);
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
@@ -0,0 +1,174 @@
//! Immutable, bounded order proposals for local Process/FunctionalChain cards.
use super::*;
use crate::diagram_usage::Target;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Destination {
Before(Id),
After(Id),
}
#[derive(Debug)]
pub(crate) enum Plan {
Noop,
Change(Command),
}
/// Captured semantic facts only: no project clone, projection or routed scene.
/// The adapter must check its document/revision/selection lifetime before commit
/// and apply the returned command through the ordinary validated transaction.
#[derive(Debug)]
pub(crate) struct Prepared {
owner: Id,
steps: Vec<Id>,
adjacency: Vec<(Id, Id)>,
legacy: bool,
ranks: BTreeMap<Id, usize>,
selected: BTreeSet<Id>,
column: usize,
}
impl Prepared {
pub(crate) fn new(project: &Project, owner: Id, targets: &[Target]) -> Result<Self, String> {
let element = project
.elements
.get(&owner)
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.ok_or("Choose a Process or Functional Chain.")?;
let steps = element
.steps()
.ok_or("This owner has no contribution order.")?;
let mut ids = BTreeSet::new();
for id in steps {
if !is_contribution(project, owner, *id) || !ids.insert(*id) {
return Err(
"Every process step must name a unique local contribution before reordering."
.into(),
);
}
}
let mut adjacency: Vec<_> = sequence_links(project, owner)
.into_iter()
.map(|link| (link.source(), link.target()))
.collect();
// Unlisted local occurrences are legal incomplete source. Rendering can
// omit their links, but order editing must not silently ignore them.
if adjacency
.iter()
.any(|(a, b)| !ids.contains(a) || !ids.contains(b))
{
return Err("A sequence link refers to an unlisted contribution; repair the process order before reordering.".into());
}
let legacy = !legacy_order(project, owner).is_empty();
if legacy {
adjacency.extend(steps.windows(2).map(|pair| (pair[0], pair[1])));
}
if targets.is_empty() {
return Err("Select at least one local contribution.".into());
}
let mut selected = BTreeSet::new();
for target in targets {
let Target::ProcessOccurrence(endpoint) = target else {
return Err(
"Reorder requires local contribution cards only; links cannot move with them."
.into(),
);
};
if !endpoint.references.is_empty() {
return Err("Open the referenced definition to reorder its contents.".into());
}
if !ids.contains(&endpoint.occurrence) || !selected.insert(endpoint.occurrence) {
return Err(
"Every selected contribution must occur exactly once in this process.".into(),
);
}
}
let ranks = column_ranks(steps, &adjacency);
let first = *selected.first().expect("Nonempty selection admitted");
let column = ranks[&first];
if selected.iter().any(|id| ranks[id] != column) {
return Err("Reorder within one column; links determine the path columns.".into());
}
Ok(Self {
owner,
steps: steps.clone(),
adjacency,
legacy,
ranks,
selected,
column,
})
}
pub(crate) fn plan(&self, destination: Destination) -> Result<Plan, String> {
let (anchor, after) = match destination {
Destination::Before(id) => (id, false),
Destination::After(id) => (id, true),
};
if self.ranks.get(&anchor) != Some(&self.column) {
return Err(
"Choose a local contribution in the selected column as the destination.".into(),
);
}
// Complete step, adjacency and typed selection admission already ran.
if self.selected.contains(&anchor) {
return Ok(Plan::Noop);
}
let indices: Vec<_> = self
.steps
.iter()
.enumerate()
.filter_map(|(i, id)| (self.ranks[id] == self.column).then_some(i))
.collect();
let block: Vec<_> = indices
.iter()
.map(|&i| self.steps[i])
.filter(|id| self.selected.contains(id))
.collect();
let mut remaining: Vec<_> = indices
.iter()
.map(|&i| self.steps[i])
.filter(|id| !self.selected.contains(id))
.collect();
let gap = remaining
.iter()
.position(|id| *id == anchor)
.ok_or("The destination slot no longer exists.")?
+ usize::from(after);
remaining.splice(gap..gap, block);
let mut ordered = self.steps.clone();
for (index, id) in indices.into_iter().zip(remaining) {
ordered[index] = id;
}
if ordered == self.steps {
return Ok(Plan::Noop);
}
// Legacy adjacency comes from order itself. In valid legacy layouts each
// card has its own column; retain the check instead of assuming that.
let ranks = if self.legacy {
let adjacency: Vec<_> = ordered.windows(2).map(|pair| (pair[0], pair[1])).collect();
column_ranks(&ordered, &adjacency)
} else {
column_ranks(&ordered, &self.adjacency)
};
if ranks != self.ranks {
return Err(
"This reorder would change process columns; the whole move was refused.".into(),
);
}
Ok(Plan::Change(Command::SetSteps(self.owner, ordered)))
}
}
#[cfg(test)]
fn plan(
project: &Project,
owner: Id,
targets: &[Target],
destination: Destination,
) -> Result<Plan, String> {
Prepared::new(project, owner, targets)?.plan(destination)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,407 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn local(id: Id) -> Target {
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: id,
})
}
fn columns(project: &Project, owner: Id) -> BTreeMap<Id, usize> {
let first = project.elements[&owner].steps().unwrap()[0];
Prepared::new(project, owner, &[local(first)])
.unwrap()
.ranks
}
// Literal order [a,u,b,v,c]; a,b,c are peers. u,v are either downstream
// contributions or a separate cycle, so their slots must never change.
fn fixture(layer: Layer, cycle: bool) -> (Project, Id, [Id; 5]) {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut ids = vec![];
for name in ["a", "u", "b", "v", "c"] {
let element = occurrence(
&project,
f.owner,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
let ids: [Id; 5] = ids.try_into().unwrap();
let [a, u, b, v, _] = ids;
let pairs = if cycle {
[(u, v), (v, u)]
} else {
[(a, u), (b, v)]
};
// fresh_alias must see the first link before generating the second; two
// commands built from the same snapshot both choose "sequence_link".
for (source, target) in pairs {
let command = link(&project, source, target, "");
project = execute_batch(&project, &[command]).unwrap();
}
assert_eq!(project.elements[&f.owner].steps().unwrap(), &ids);
(project, f.owner, ids)
}
fn changed(project: &Project, owner: Id, targets: &[Target], drop: Destination) -> Command {
let Plan::Change(command) = plan(project, owner, targets, drop).unwrap() else {
panic!("Expected an actual order change");
};
assert!(matches!(&command, Command::SetSteps(id, _) if *id == owner));
command
}
#[test]
fn process_group_columns_reverse_noncontiguous_selection_preserves_project_and_history() {
for layer in [Layer::Oa, Layer::Sa] {
for cycle in [false, true] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, cycle);
assert_eq!(
columns(&project, owner),
BTreeMap::from([
(a, 0),
(u, 1),
(b, 0),
(v, if cycle { 2 } else { 1 }),
(c, 0)
])
);
// Reverse click order must not reverse the source-ordered a,c block.
let command = changed(
&project,
owner,
&[local(c), local(a)],
Destination::After(b),
);
let mut expected = project.clone();
*expected
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![b, u, a, v, c];
let result = execute_batch(&project, std::slice::from_ref(&command)).unwrap();
assert_eq!(result, expected, "Nothing outside owner steps may change");
assert_eq!(columns(&result, owner), columns(&project, owner));
let mut doc =
Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(command).unwrap();
assert_eq!(doc.project(), &expected);
let after = doc.bundle().clone();
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
assert_eq!(reopened.bundle(), &after);
assert!(doc.undo());
assert_eq!(doc.project(), &project);
assert_eq!(doc.bundle(), &original);
assert!(!doc.can_undo());
assert!(doc.redo());
assert_eq!(doc.project(), &expected);
assert_eq!(doc.bundle(), &after);
assert!(!doc.can_redo());
}
}
}
#[test]
fn process_group_columns_end_gaps_and_selected_anchor_noops() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
for (members, drop, expected) in [
(
vec![local(c), local(b)],
Destination::Before(a),
vec![b, u, c, v, a],
),
(
vec![local(b), local(a)],
Destination::After(c),
vec![c, u, a, v, b],
),
] {
let command = changed(&project, owner, &members, drop);
assert_eq!(
execute_batch(&project, &[command]).unwrap().elements[&owner]
.steps()
.unwrap(),
&expected
);
}
let doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
for (members, drop) in [
(vec![local(b), local(c)], Destination::After(a)),
(vec![local(c), local(a)], Destination::Before(c)),
(vec![local(a), local(c)], Destination::After(a)),
(vec![local(c), local(b), local(a)], Destination::After(b)),
] {
assert!(matches!(
plan(doc.project(), owner, &members, drop),
Ok(Plan::Noop)
));
assert_eq!(doc.bundle(), &original);
assert!(!doc.can_undo());
}
}
}
#[test]
fn process_group_columns_refuse_entire_invalid_typed_sets_before_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, _, _, c]) = fixture(layer, true);
let foreign = super::super::direct_tests::fixture(layer).nodes[0];
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![a],
occurrence: c,
});
let relation = *project.relations.keys().next().unwrap();
for members in [
vec![],
vec![local(a), local(a)],
vec![local(a), local(u)],
vec![local(a), local(foreign)],
vec![local(a), qualified],
vec![local(a), Target::ProcessLink(relation)],
vec![local(a), Target::Placement(c)],
] {
// A selected anchor must not conceal invalid second members.
assert!(plan(&project, owner, &members, Destination::Before(a)).is_err());
}
assert!(plan(&project, owner, &[local(a)], Destination::After(foreign)).is_err());
assert!(plan(&project, a, &[local(a)], Destination::Before(a)).is_err());
assert!(
plan(
&project,
owner,
&[
local(u),
local(project.elements[&owner].steps().unwrap()[3])
],
Destination::Before(u)
)
.is_err()
);
}
}
#[test]
fn process_group_columns_legacy_exchange_order_refuses_cross_rank_group() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let mut ids = vec![];
for (name, exchange) in [("first", f.exchange), ("second", next)] {
let element = occurrence(
&project,
f.owner,
name,
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
operation: None,
},
);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
assert_eq!(legacy_order(&project, f.owner), ids);
assert!(
plan(
&project,
f.owner,
&[local(ids[0]), local(ids[1])],
Destination::Before(ids[0])
)
.is_err()
);
assert!(matches!(
plan(
&project,
f.owner,
&[local(ids[1])],
Destination::After(ids[1])
),
Ok(Plan::Noop)
));
}
}
#[test]
fn process_group_columns_accept_all_local_card_kinds_and_different_heights() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
let mut project = f.p;
let source = project
.make_element(
project.elements[&f.owner].kind(),
layer,
"Reusable definition",
None,
)
.unwrap();
let source_id = source.id;
project = execute_batch(&project, &[Command::CreateElement(source)]).unwrap();
let bodies = [
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
operation: None,
},
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
OccurrenceBody::SequenceReference {
sequence: Some(source_id),
},
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
];
let mut ids = vec![];
for (index, body) in bodies.into_iter().enumerate() {
let element = occurrence(&project, f.owner, &format!("Card {index}"), body);
ids.push(element.id);
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
}
let graph = Graph::new(&project, f.owner, ids[0]);
assert_ne!(graph.nodes[0].rect.height, graph.nodes[3].rect.height);
let selected = vec![local(ids[3]), local(ids[2]), local(ids[1]), local(ids[0])];
let command = changed(&project, f.owner, &selected, Destination::After(ids[4]));
let mut expected = project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![ids[4], ids[0], ids[1], ids[2], ids[3]];
assert_eq!(execute_batch(&project, &[command]).unwrap(), expected);
}
}
#[test]
fn process_group_columns_full_order_admission_precedes_selected_anchor_noop() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
let other = project
.make_element(project.elements[&owner].kind(), layer, "Other", None)
.unwrap();
let other_owner = other.id;
project = execute_batch(&project, &[Command::CreateElement(other)]).unwrap();
let foreign = occurrence(
&project,
other_owner,
"Foreign local occurrence",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let foreign_id = foreign.id;
project = execute_batch(&project, &[Command::CreateElement(foreign)]).unwrap();
assert!(Prepared::new(&project, owner, &[local(a), local(foreign_id)]).is_err());
let prepared = Prepared::new(&project, owner, &[local(a)]).unwrap();
assert!(prepared.plan(Destination::Before(foreign_id)).is_err());
// Corrupt/incomplete imported orders must fail even when only the valid
// card is selected and the requested destination would be a no-op.
for order in [
vec![a, u, b, v, c, c],
vec![a, u, b, v, Id::new_v4()],
vec![a, u, b, v, foreign_id],
vec![a, b, v, c], // local a -> u remains, but u is unlisted
] {
let mut incomplete = project.clone();
*incomplete
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = order;
let original = incomplete.clone();
assert!(plan(&incomplete, owner, &[local(a)], Destination::Before(a)).is_err());
assert_eq!(incomplete, original);
}
}
}
#[test]
fn process_group_columns_renderer_omits_links_to_unlisted_nodes_without_panicking() {
for layer in [Layer::Oa, Layer::Sa] {
let (project, owner, [a, u, b, v, c]) = fixture(layer, false);
for (omitted, expected_ids, expected_ranks) in [
(
u,
vec![a, b, v, c],
BTreeMap::from([(a, 0), (b, 0), (v, 1), (c, 0)]),
),
(
a,
vec![u, b, v, c],
BTreeMap::from([(u, 0), (b, 0), (v, 1), (c, 0)]),
),
] {
// This omission is core-valid incomplete source, not a renderer-only
// manufactured dangling element. The semantic occurrence remains.
let incomplete =
execute_batch(&project, &[Command::SetSteps(owner, expected_ids.clone())]).unwrap();
assert!(incomplete.elements.contains_key(&omitted));
let graph = Graph::new(&incomplete, owner, b);
assert_eq!(
graph.nodes.iter().map(|node| node.id).collect::<Vec<_>>(),
expected_ids
);
assert_eq!(graph.edges.len(), 1);
assert_eq!((graph.edges[0].source, graph.edges[0].target), (b, v));
for node in &graph.nodes {
assert_eq!(node.rect.x, 24. + expected_ranks[&node.id] as f32 * 420.);
}
assert!(Prepared::new(&incomplete, owner, &[local(b)]).is_err());
}
}
}
#[test]
fn process_group_columns_parallel_links_keep_exact_ranks_and_relation_records() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut project, owner, [a, u, b, v, c]) = fixture(layer, false);
project = execute_batch(&project, &[link(&project, a, u, "alternative")]).unwrap();
assert_eq!(
columns(&project, owner),
BTreeMap::from([(a, 0), (u, 1), (b, 0), (v, 1), (c, 0)])
);
let prepared = Prepared::new(&project, owner, &[local(c), local(b)]).unwrap();
let Plan::Change(command) = prepared.plan(Destination::Before(a)).unwrap() else {
panic!("Expected move");
};
let result = execute_batch(&project, &[command]).unwrap();
assert_eq!(
result.elements[&owner].steps().unwrap(),
&vec![b, u, c, v, a]
);
assert_eq!(result.relations, project.relations);
assert_eq!(columns(&result, owner), prepared.ranks);
assert_eq!(Graph::new(&result, owner, b).edges.len(), 3);
}
}
@@ -1,5 +1,278 @@
use super::*;
#[tokio::test]
async fn production_process_multiselect_modifiers_survive_focus_busy_and_clear_on_window_loss() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::keyboard::{Event as KeyEvent, Modifiers};
let mut f = super::super::direct_tests::fixture(Layer::Oa);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let at = |app: &App, id| {
let graph = Graph::new(app.project(), f.owner, id);
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ app.behavior.topology.pan
+ iced::Vector::new(rect.x + 60., rect.y + 20.)
* app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.))
};
let shift = |held| {
iced::Event::Keyboard(KeyEvent::ModifiersChanged(if held {
Modifiers::SHIFT
} else {
Modifiers::empty()
}))
};
let first = at(&app, f.nodes[2]);
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(first))
.await;
mounted.click(&mut app, first).await;
let second = at(&app, f.nodes[0]);
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift before canvas focus reaches both clicks"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[shift(false)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
let third = at(&app, f.nodes[1]);
mounted.click(&mut app, third).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"Shift released during busy is not resurrected"
);
app.busy = true;
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(second))
.await;
app.busy = false;
mounted.settle(&mut app).await;
mounted.click(&mut app, second).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
2,
"Shift held through busy survives child lifetime"
);
mounted
.batch(
&mut app,
&[iced::Event::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
mounted.click(&mut app, first).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
1,
"real window loss clears held modifiers"
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert!(!app.document.can_undo());
let pan = app.behavior.topology.pan;
let source = app.document.bundle().clone();
let blank = bounds.position() + iced::Vector::new(bounds.width - 40., bounds.height - 40.);
let end = blank + iced::Vector::new(-25., -20.);
mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: blank }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(blank),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(app.behavior.topology.pan, pan);
assert_eq!(app.document.bundle(), &source);
// Each batch rebuilds the production tree and applies all App messages.
// Selection Clear must not retire the camera pan started by its press.
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: end,
})],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(end),
)
.await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
assert_eq!(
app.behavior.topology.pan,
pan + iced::Vector::new(-25., -20.)
);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn production_process_multiselect_reverse_click_group_drop_has_one_history_step_and_stable_camera()
{
use crate::behavior::canvas_workspace::tests::Mounted;
for layer in [Layer::Oa, Layer::Sa] {
let mut f = super::super::direct_tests::fixture(layer);
f.p = execute_batch(&f.p, &[Command::Delete(f.link)]).unwrap();
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let graph = Graph::new(app.project(), f.owner, f.owner);
let scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let at = |id, after| {
let rect = graph.nodes.iter().find(|n| n.id == id).unwrap().rect;
bounds.position()
+ iced::Vector::new(
rect.x + 60.,
rect.y + if after { rect.height - 15. } else { 20. },
) * scale
};
let points = [
at(f.nodes[2], false),
at(f.nodes[0], false),
at(f.nodes[1], true),
];
drop(graph);
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::SHIFT),
)],
mouse::Cursor::Available(points[0]),
)
.await;
mounted.click(&mut app, points[0]).await;
mounted.click(&mut app, points[1]).await;
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(
iced::keyboard::Event::ModifiersChanged(iced::keyboard::Modifiers::empty()),
)],
mouse::Cursor::Available(points[1]),
)
.await;
let snapshot = app.multiselect_snapshot().unwrap();
let source = app.document.bundle().clone();
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let pressed = mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[1],
}),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
],
mouse::Cursor::Available(points[1]),
)
.await;
assert!(
!pressed
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
let moved = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: points[2],
})],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
!moved
.iter()
.any(|message| matches!(message, Message::DiagramSelection(_)))
);
assert_eq!(app.multiselect_snapshot().unwrap(), snapshot);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let messages = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(points[2]),
)
.await;
assert!(
messages.iter().any(|m| matches!(
m,
Message::DiagramSelection(crate::app::multiselect::Event::ProcessReorder(_, _))
)),
"{messages:?}"
);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&vec![f.nodes[1], f.nodes[0], f.nodes[2]]
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let changed = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &changed);
}
}
#[tokio::test]
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
@@ -23,6 +23,38 @@ impl UsageSnapshot {
}
impl State {
pub(crate) fn selection_address(&self) -> Option<oa_core::topology_context::ContextAddress> {
self.selected_context.clone()
}
pub(crate) fn selection_targets(&self, p: &Project, owner: Id) -> Vec<Target> {
let graph = self.usage_graph(p, owner);
let mut targets = vec![];
for node in &graph.nodes {
targets.push(Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}));
targets.extend(
node.preview
.iter()
.map(|(endpoint, _)| Target::ProcessOccurrence(endpoint.clone())),
);
for link in &node.preview_links {
let target = Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
};
if graph.usage_bounds(&target).is_some() {
targets.push(target);
}
}
}
targets.extend(graph.edges.iter().map(|edge| Target::ProcessLink(edge.id)));
targets
}
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
identity: self.identity,
@@ -66,7 +66,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
let pending = state.draft.is_some();
let ordered = !legacy_order(p, owner).is_empty();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let clean =
!app.busy && !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
let mut navigation = column![].spacing(6);
if let Some(Relation {
@@ -183,6 +184,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.expanded,
&state.contextual_expanded,
);
graph.selection = app.multiselect_snapshot();
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
@@ -195,6 +197,11 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
== app.selected
})
.map(|address| address.endpoint.clone());
if let Some(crate::diagram_usage::Target::ProcessOccurrence(endpoint)) =
graph.selection.as_ref().and_then(|s| s.primary())
{
graph.selected_preview = (!endpoint.references.is_empty()).then(|| endpoint.clone());
}
graph.selected_preview_link = app
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
.and_then(|usage| match &usage.target {
@@ -255,7 +262,8 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
event(Event::Shelf(Shelf::Selection)),
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
),
selection_controls(app)
]
.spacing(4);
let entries = [
@@ -321,7 +329,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
)
}),
);
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
let details = scrollable(navigation.push(text("Drag selected local cards to the upper or lower half of a card to insert before or after it in the same column. The group keeps source order; paths stay unchanged. Qualified previews remain read-only.").size(11)))
.id("process-details").width(Fill).height(Fill).into();
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
@@ -551,6 +559,42 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn selection_controls(app: &App) -> Element<'_, Message> {
use crate::app::multiselect::Event as SelectionEvent;
let snapshot = app.multiselect_snapshot();
let enabled = !app.busy
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
&& app.behavior.topology.tool == CanvasTool::Select;
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
let mut controls = row![label(format!("{count} selected"))]
.spacing(7)
.align_y(iced::Center)
.height(32);
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
controls = controls.push(button("Clear selection").on_press_maybe(
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
));
controls = controls.push(
label(format!(
"Properties: {}",
short(app.project().named(app.selected), 24)
))
.wrapping(iced::widget::text::Wrapping::None),
);
}
// These two rows exist even with an empty set so selection never moves the
// canvas viewport or changes the camera's interpretation of the pointer.
column![
controls,
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
.wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, iced::Theme, R>,
+72 -8
View File
@@ -18,6 +18,7 @@ pub struct Request {
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<crate::behavior::topology::Context>,
pub source_file: Option<String>,
}
impl Request {
@@ -32,6 +33,7 @@ impl Request {
route_segment: None,
creation_point: None,
canvas: None,
process: None,
source_file: None,
}
}
@@ -61,6 +63,9 @@ impl Opened {
|| request
.canvas
.is_some_and(|c| Some(c) != current_canvas(app))
|| request
.process
.is_some_and(|c| Some(c) != app.topology_context())
{
return None;
}
@@ -88,6 +93,10 @@ impl Opened {
.request
.canvas
.is_none_or(|c| Some(c) == current_canvas(app))
&& self
.request
.process
.is_none_or(|c| Some(c) == app.topology_context())
&& self
.request
.source_file
@@ -101,10 +110,24 @@ fn target_matches(app: &App, request: &Request) -> bool {
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if request.canvas.is_none() || !app.multiselect_target_visible(target) {
if (request.canvas.is_none() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
return false;
}
match target {
Target::ProcessOccurrence(endpoint) => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& endpoint.occurrence == request.object
}
Target::ProcessLink(id) | Target::ProcessPreviewLink { relation: id, .. } => {
request.process.is_some()
&& request.canvas.is_none()
&& request.placement.is_none()
&& *id == request.object
}
Target::Placement(id) => {
request.placement == Some(*id)
&& request
@@ -132,7 +155,9 @@ fn current_canvas(app: &App) -> Option<route_edit::Context> {
fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.state_context().map(|context| context.diagram)
app.topology_context()
.map(|context| context.owner)
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
}
@@ -156,6 +181,7 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context();
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
@@ -233,7 +259,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if request.canvas.is_some()
if (request.canvas.is_some() || request.process.is_some())
&& let Some(context) = app.multiselect_context()
{
use crate::app::multiselect::{Choice, Event as SelectionEvent};
@@ -311,11 +337,20 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
)),
true,
));
entries.push(item(
"Hide selected placements",
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
crate::app::multiselect::placements(&snapshot).is_some(),
));
if request.process.is_some() {
entries.push(Entry::title(
"Drag contributions within one column to reorder",
));
entries.push(Entry::disabled(
"Hide unavailable · cards are model contributions",
));
} else {
entries.push(item(
"Hide selected placements",
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
crate::app::multiselect::placements(&snapshot).is_some(),
));
}
entries.push(Entry::disabled(
"Delete selected from model (not yet available)",
));
@@ -327,6 +362,35 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
entries.push(Entry::separator());
if let Some(context) = request.process {
let endpoint = match request.selection_target.as_deref() {
Some(Target::ProcessOccurrence(endpoint))
if !endpoint.references.is_empty() =>
{
Some(endpoint.clone())
}
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
_ => None,
};
if let Some(endpoint) = endpoint {
entries.push(Entry::title("Reused contents · read-only in this process"));
entries.push(item(
"Open referenced definition",
Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint,
),
),
true,
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
}
}
if app.page == Page::Behavior
+28
View File
@@ -689,6 +689,34 @@ fn diagram(app: &App) -> View<'_, Message> {
fn inspector(app: &App) -> View<'_, Message> {
let p = app.project();
if let Some(target) = app.process_preview_primary() {
let endpoint = match target {
crate::diagram_usage::Target::ProcessOccurrence(endpoint) => endpoint.clone(),
crate::diagram_usage::Target::ProcessPreviewLink { source, .. } => source.clone(),
_ => unreachable!(),
};
let context = app.topology_context().unwrap();
return container(
column![
text(p.named(app.selected)).size(17),
label("Reused contents · read-only here"),
label("Open the referenced definition to edit this contribution or connection."),
action(
"Open referenced definition",
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
context, endpoint
))
),
crate::model_menu::trigger(app),
]
.spacing(12),
)
.padding(14)
.width(320)
.height(Fill)
.style(style::panel)
.into();
}
if app.source_dirty() {
let mut notice = column![
text("Source drafts pending").size(17),
@@ -1,55 +1,119 @@
# Process group adapter candidate
# Process selection and column-order adapter
Read-only Astra investigation against the State follow-up working tree above `7e348d967d1787c052a145b7448636613a6a7054`. This is a proposed next adapter, not implementation, accepted semantics, or verification. Live ownership and priorities remain in Gitea #29. No source, build or GUI change accompanied the investigation.
**Accepted implementation contract; runtime/native acceptance pending.** This document describes the current unpublished Process working source above `bed7f825fd00ec32299032a664a5f5d753aad86d`, implementing the Process portion of the [multiple-selection contract](multiselect-contract-20260923.md). It does not establish a verified release or completion of issue #29. Gitea holds live ownership, priorities and gate results. The initial read-only investigation inspected State follow-up source above `7e348d967d1787c052a145b7448636613a6a7054`; its alternatives, critiques and experiment evidence are retained as history below.
## Representation facts
## Accepted implemented contract
The surface is `Surface::Process(owner)` for an OA Process or SA FunctionalChain. Selection is a transient insertion-ordered exact target set with one primary Inspector target:
| Target | Identity and admitted use |
| --- | --- |
| `ProcessOccurrence(OccurrenceEndpoint { references: [], occurrence })` | One local semantic contribution card. Action, Exchange, Control and SequenceReference cards participate in order planning; these are not saved placements or reusable work definitions. |
| `ProcessOccurrence` with nonempty references | One visible qualified tile with its complete reuse path. Select/inspect; no caller-side reorder or deletion. |
| `ProcessLink(id)` | One local rendered SequenceLink or ContextualLink. Select/inspect; not a member of a contribution reorder. Existing singleton local SequenceLink reconnect remains separate. |
| `ProcessPreviewLink { relation, source, target }` | One actually painted qualified preview segment with exact endpoint paths. Select/inspect; read-only through the caller. |
Different caller paths to the same defining occurrence remain different targets. A qualified primary identifies the **actual occurrence or relation subject**, not the outer reference's properties. Its caller owner/path remains captured; properties are read-only until explicit **Open** navigates to the defining process. Hidden/collapsed targets are pruned exactly without rebinding to another reuse path or outer card. Open/Back and owner changes establish a new surface lifetime. Legacy `OrderedPath` arrows have no relation identity and do not become editable links. Navigation emphasis alone is not mutation authority.
Shift-click toggles the exact hit. Plain press on an unselected target replaces the set. A selected-member press freezes membership and primary until stationary release versus drag; stationary release collapses to that member. Right-click captures a target without replacing the selection; contextual Add/Remove changes membership explicitly. Counts and distinct primary/membership paint describe the set. Ordinary local property edits remain primary-only.
### One admitted column-slot plan
`behavior/topology/group_reorder.rs::Prepared::new(&Project, owner, &[Target])` captures immutable steps, adjacency, integer ranks and selected IDs. It rejects empty/duplicate selections, non-occurrence members, qualified paths, wrong-owner/missing contributions, cross-column roots, or any missing/foreign/duplicate **unselected** step. A local SequenceLink whose endpoint is omitted from owner steps also refuses before any selected-anchor no-op. Such incomplete source can be core-valid; that does not authorize silently ignoring its topology when editing order.
The renderer and planner share `topology.rs::column_ranks`, extracted from the established rank algorithm. The renderer filters direct links to displayed endpoints and skips unavailable legacy path pairs, retaining displayed nodes without panicking. This tolerant display behavior does not weaken planner admission. Parallel SequenceLinks remain distinct; longest-path and deterministic cycle-blocked integer ranks retain their existing meaning.
`Prepared::plan(Destination::Before(id) | Destination::After(id))` requires an anchor in the selected column. After full admission, a selected anchor returns `Plan::Noop`, even for a noncontiguous set. For an unselected anchor: extract the column's order and original step indices; remove selected IDs and form their block in original source order; insert before/after the remaining anchor; write the column permutation back to those same indices. Other-column IDs retain exact indices, and unselected IDs within this column retain relative order. Identical steps return Noop. Otherwise the complete ID-to-rank map is recomputed and must remain exact before one existing `Command::SetSteps` is returned.
Example: `[a,u,b,v,c]`, column `[a,b,c]`, selection C then A, After B yields `[b,u,a,v,c]`. U/V retain indices 1/3, and A/C retain source order. Selected `{b,c}` After A is unchanged. There is no XY schema, control-flow rewiring, reusable-definition edit or unrelated-record change. Legacy exchange-only paths have successive ranks; cycle-blocked nodes receive distinct fallback ranks, so multi-member sets spanning those columns refuse. A cycle elsewhere does not prevent movement of an acyclic peer group.
Semantic facts are prepared once per press and candidate plans are cached by anchor/side. Motion within a slot does not clone the Project, rebuild a Graph or run whole-model validation. App release handling re-admits the live exact selection and uses the same planner before one source-validated transaction. Source failure preserves model/history. Successful commit restores the exact primary without an incidental Fit/reveal. Noop emits no command/history entry.
**Singleton migration is implemented:** `topology/direct.rs::reorder` delegates a singleton typed target to this planner and returns Noop/Change. Moving B before A in `[a,u,b,v,c]` now writes `[b,u,a,v,c]`, replacing the old global splice `[b,a,u,v,c]`. This deliberately changes invisible cross-column source order to preserve unrelated indices consistently for singleton and group operations.
### Pointer feedback and lifetime
`behavior/topology/direct_selection.rs` owns an order-specific press; it does not use the saved-placement XY constructor. The press captures exact selection snapshot, target, screen position, bounds, pan and scale. Its 3-screen-pixel threshold is irreversible: out-and-back movement, refused drops and no-op destinations retain the set rather than becoming stationary clicks. Native pointer order is preserved; an unavailable/outside release cannot reuse an earlier inside point.
Each local card's upper half means Before and its lower half means After. An insertion indicator, refusal/unchanged caption and source-order member numbers describe the cached operation. Cards are not freely translated or reflowed during the drag. Before-first and after-last are reachable through the first/last unselected card. Actual reachability/readability at native zoom/window sizes remains an acceptance gate.
Snapshot/document/revision/surface/selection-generation changes, tool/editability changes, bounds/pan/scale changes, resize/unfocus, wheel, cursor exit, right/middle press and Escape cancel the captured gesture. Escape cancels a live press before selection clearing. Passive modifiers support Shift across editor focus. Stable count/Inspector layout and retained camera geometry are required; selection and qualified inspection do not authorize incidental source application or camera movement.
Shift/context hits on painted reconnect rings resolve the exact link. A mixed set cannot reconnect only its primary, and Connect with several selected targets is refused. Existing singleton tools retain their separate ownership. App envelope validation precedes incidental selection/camera/allocation state changes; dirty source/property/forms or stale packets cannot partially act.
### Current removal boundary and remaining adapters
Process has no saved placements: Hide explicitly refuses instead of deleting contributions or adding hidden-card persistence. Group Delete and qualified-target Delete explicitly refuse. Existing admissible local singleton deletion remains separate; no depicted group falls through to it. Aggregate local-occurrence removal still requires one captured impact review and atomic union deletion, including ContextualLink cleanup and explicit-to-legacy meaning changes described in the historical critique below.
This slice implements exact Process selection and column reorder with whole-set refusals. Aggregate removal, Allocation assignment/Arrange-only groups and Scenario order adapters remain required by the [overall contract](multiselect-contract-20260923.md), outside this Process acceptance claim.
### Implementation and verification boundaries
| Source | Responsibility |
| --- | --- |
| `app/multiselect.rs`, `app/process_multiselect.rs` | Scope/envelope admission, exact reconciliation, actual-subject primary and transaction dispatch. |
| `app/process_canvas.rs`, `app.rs` | Legacy command guards, navigation/tool lifetime and existing Process command integration. |
| `behavior/topology/group_reorder.rs`, `topology.rs::column_ranks` | Immutable admitted facts, column-slot planning and shared production ranks. |
| `behavior/topology/direct_selection.rs`, `direct.rs` | Exact hits/modifiers, order gesture, feedback, singleton migration and separate tool ownership. |
| `behavior/topology.rs`, `topology/view.rs`, `topology/usages.rs` | Exact paint/visibility, stable controls/camera and incomplete-topology rendering. |
| `model_menu.rs`, Inspector/behavior property boundaries | Captured membership menus, qualified read-only subject and explicit Open. |
Planner tests define independent literal OA/SA orders, whole-Project preservation, source roundtrip/Undo/Redo, all four card kinds/differing heights, reverse noncontiguous selection, end gaps/noops, typed refusal, five-node cycle-plus-peer ranks, parallel links and malformed/unlisted steps. App/input tests cover qualified primary, reconciliation, stale messages and gesture boundaries. Their definitions and source inspection alone do not establish current full-suite or native acceptance.
The [native evaluator design](multiselect-process-native-design-20260923.md) and [core example](../crates/oa-core/examples/process_multiselect_native.rs) use an immutable deterministic OA/SA fixture, literal expected `[b,u,a,v,c]` and complete Project comparison. Global splice, reversed-block, primary-only and unrelated-record corruptions are falsifiers. Native Shift/focus, menus, cancellation, insertion reachability, stable layout/camera, real save/reopen and Undo/Redo require matching executable evidence. No acceptance receipt is asserted here.
## Historical investigation and design alternatives
The following sections retain the original reasoning, corrected by the accepted contract above. Statements of proposed work describe that earlier investigation, not the current implementation status.
### Representation facts recorded initially
`diagram_usage.rs` already defines `Surface::Process(owner)`, exact `ProcessOccurrence(OccurrenceEndpoint { references, occurrence })`, local `ProcessLink`, and qualified `ProcessPreviewLink` targets. Local cards are semantic occurrences in owner steps; they reference reusable definitions and are not saved diagram placements. Different reference paths to the same defining occurrence are different selected targets. Legacy derived `OrderedPath` arrows lack relation identities: do not fabricate editable IDs or substitute endpoints.
`topology/usages.rs::Graph::usage_bounds` provides exact displayed-target visibility, including bounded qualified previews. Hidden preview tiles do not fall back to their outer reference. Its approximate reveal rectangles are not adequate edge hit tests. Qualified preview content stays read-only until its definition is explicitly opened.
## Movement candidates
### Movement candidates considered
| Candidate | Assessment |
| --- | --- |
| Persist arbitrary XY | Conflicts with derived process topology and needs new schema; reject for this adapter. |
| Sequential singleton reorder | Produces click-order-dependent layouts and does not define a simultaneous operation; reject. |
| Stable block within one original column | Candidate: preserves existing topology and uses one existing `SetSteps` command. |
| Stable block within one original column | Accepted with the column-slot correction: preserves topology and uses one existing `SetSteps` command. |
| Rewire links during a cross-column drag | Alters process execution meaning; requires a separate capability and explicit interaction. |
Existing `topology/direct.rs::reorder` verifies local contributions and equal original graph X-columns, then validates `SetSteps`. `Graph::expanded_qualified` derives ranks from sequence links and legacy order, with steps supplying vertical order; cyclic fallback can depend on enumeration order.
The former `topology/direct.rs::reorder` verified local contributions and equal graph X-columns, globally spliced steps, then validated `SetSteps`. `Graph::expanded_qualified` derived ranks from sequence links and legacy order, with steps supplying vertical order; cyclic fallback could depend on enumeration order. The shared integer rank helper and singleton migration supersede that implementation.
The proposed pure planner admits the whole nonempty set only if every target is a visible local occurrence with an empty reference path and belongs to this owner. Selected IDs and the destination must share one original column. Build the block in original steps order, then use the column-slot permutation below: preserve other-column indices and unselected order within this column. Object-anchored drops on selected items are no-op; a raw gap is a separate API. Check the complete rank map remains unchanged, especially cycle fallback. Distinguish no-op from a command so it creates no source/history entry. Preview and commit must consume this same planned order; do not freely translate cards.
Before-card only could reuse existing behavior but makes moving to the end awkward. Before/after slots are the preferred candidate, subject to independent review and actual interaction testing. Participation of each occurrence-body type also needs a precise decision.
Before-card only could reuse earlier behavior but made moving to the end awkward. Before/After and all four local contribution kinds were subsequently accepted and implemented; actual interaction acceptance remains pending.
## Hide and removal
### Original Hide and removal proposal
Process has no saved placements and therefore no existing Hide operation. Explain that limitation; do not silently substitute semantic deletion or add persisted hidden cards without a separate design decision.
Homogeneous local-occurrence removal should present one aggregate impact review and apply one `Batch(Delete(...))`. Core deferred deletion already supports union cleanup. Preserve referenced work, exchanges, reused process definitions and opposite-layer counterparts. Initially refuse qualified or mixed sets in full; local-link-only deletion can be a separate extension. Never invoke singleton deletion once per member. Capture document, revision, surface and selection generation through confirmation, revalidate the whole set, and preserve pending property/source/forms.
## Implementation boundaries and falsifiers
### Initial implementation plan and falsifiers
Extend `app/multiselect.rs` with Process dispatch and exact primary paths, avoiding the assumption that every context is a saved diagram. Use `app/process_canvas.rs` for captured adapter commands. The input and reorder planner belong in `behavior/topology/direct.rs`; graph/render changes in `topology.rs`; exact visibility in `topology/usages.rs`; stable controls in `topology/view.rs`. Process currently lacks the needed canvas right-click branch, so contextual Add/Remove needs explicit transport into `model_menu.rs`. Aggregate confirmation belongs at `app/diagram_removal.rs` and its view. Reject stale and legacy singleton commands before App's shared side effects.
The initial plan called for Process dispatch, exact primary paths, right-click membership and whole-set guards rather than assuming every context is a saved diagram. These are implemented across the source boundaries above; the pure planner and input adapter were factored into dedicated modules rather than expanding `direct.rs`. Aggregate confirmation at `app/diagram_removal.rs` remains deferred.
The current shared group gesture requires saved placements and an XY anchor; reuse its lifecycle concepts, not that constructor unchanged. Compare a small shared threshold/cancellation component with a Process order-specific gesture before introducing abstraction.
The saved-view group gesture requires placements and an XY anchor. A Process order-specific gesture was chosen, preserving its lifecycle principles without widening that constructor into an inappropriate generic geometry abstraction.
First useful outcome: exact local/qualified/link selection with whole-set refusals, plus one atomic same-column local-occurrence reorder and one Undo/Redo. Aggregate removal remains separate acceptance if omitted from that outcome.
Tests must independently assert source-order blocks from noncontiguous reverse-click selections; unchanged other-column indices and unselected order within the affected column, relations and reusable definitions; identical referenced work represented by distinct occurrences; distinct qualified paths and collapse visibility; full refusal of cross-column/mixed/stale sets; no-op history; ordered pointer/focus/Shift/cancellation/camera behavior; no primary-only reconnect bypass; OA and SA, legacy exchange order, explicit branches and cyclic fallback. Native slot feedback, stable canvas/camera, source save/reopen and one-step history are required beyond compilation.
## Independent critique: revise before implementation
### Independent critiques that changed the design
A fresh read-only Astra review found a concrete defect in global block splicing. Given steps `[a,u,b,v,c]`, one displayed column `[a,b,c]`, and selected `{b,c}`, dropping after `a` is visually a no-op. The proposed global splice produces `[a,b,c,u,v]`, changing source/history without a visible change. Equivalent before/after positions in the same displayed gap must have one interpretation.
The revised candidate is a **column-slot permutation**: extract this column's order from original steps, remove the selected members, normalize the drop to a gap in the remainder, insert the original-order selected block, then write the resulting column order back into this column's original steps indices. Other-column indices remain exact; unselected relative order within the affected column is preserved. This deliberately replaces the earlier all-unselected-global-order condition. Return no command when column order is unchanged, and verify the complete ID-to-rank map remains unchanged before accepting `SetSteps`. Local Action, Exchange, Control and SequenceReference occurrences are proposed participants; qualified descendants and links are whole-set refusals. This revision still requires an independent pure experiment before UI implementation.
The correction was a **column-slot permutation**, now implemented as specified above. It deliberately replaces the earlier all-unselected-global-order condition with exact other-column indices and within-column unselected order. The independent pure experiment preceded UI implementation; selected-anchor no-op remained distinct from raw-gap normalization.
Scope correction: legacy ordered-path nodes have successive ranks, and cycle-blocked nodes receive distinct fallback ranks (`topology.rs` rank computation). A multiple selection of those nodes cannot satisfy same-column admission. Test refusal and unchanged execution meaning rather than claiming useful reordering of those sets. A graph containing a cycle can still admit a separate acyclic peer group.
Removal has an additional semantic consequence: `behavior.rs` derives explicit-versus-legacy interpretation from remaining occurrence kinds and SequenceLinks. Deleting action/control from `[action,exchange1,control,exchange2]` without links leaves an exchange-only sequence with implicit ordered-linear meaning. Aggregate review must expose this transition or initially refuse it. Include caller-owned ContextualLinks targeting removed occurrences in the impact union (`references.rs` cleanup), not just local links. See the presentation-versus-legacy distinction in `docs/language.rst`.
Removal has an additional semantic consequence: `behavior.rs` derives explicit-versus-legacy interpretation from remaining occurrence kinds and SequenceLinks. Deleting action/control from `[action,exchange1,control,exchange2]` without links leaves an exchange-only sequence with implicit ordered-linear meaning. Aggregate review must expose this transition or initially refuse it. Include caller-owned ContextualLinks targeting removed occurrences in the impact union (`references.rs` cleanup), not just local links. See the presentation-versus-legacy distinction in [language documentation](../docs/language.rst).
Smallest production experiment: independent OA/SA fixtures with interleaved steps and different card heights; reverse-click selection; before-first/after-last and equivalent gaps; already-positioned no-ops; literal expected steps; complete Project equality outside steps; unchanged ranks; exact source roundtrip and one Undo/Redo. Add legacy and cycle refusal plus acyclic peers beside a cycle. Separately demonstrate deletion-induced mode switching before designing confirmation. These Rust/application checks have not run yet.
The original production-evaluator plan became the Rust/App tests and native fixture above. Deletion-induced mode switching still needs a separate removal evaluator. A later source critic found an incomplete-order panic (`a→u` with U omitted from owner steps), prompting complete planner admission and graceful renderer omission. Bounded test results must remain tied to the source and receipts actually exercised.
## Bounded permutation experiment
@@ -57,4 +121,4 @@ Smallest production experiment: independent OA/SA fixtures with interleaved step
An object-anchored Before/After interface can keep selected-target no-op without losing changed destinations: all remainder gaps were reachable using unselected anchors in 2,036 bounded subsets. Raw-gap normalization is different: in `[a,b,c,d]`, selecting `{b,d}` and normalizing Before `d` gives `[a,c,b,d]`; selected-target no-op keeps the original, while After unselected `c` reaches the changed order. This supports the simpler selected-target no-op interaction. Block contiguity applies to an admitted planned move, not to that no-op. Actual pointer feedback, production graph admission, serialization and history remain unverified.
An independent critic matched the experiment's rank algorithm to Rust and found no counterexample under the stated valid-input assumptions. The review prompted an explicit five-node changed group beside a cycle; four-node enumeration could only cover singleton movement or unchanged whole-peer blocks there. It also identified a compatibility choice: current singleton `direct::reorder` globally splices, so moving `b` before `a` in `[a,u,b,v,c]` writes `[b,a,u,v,c]`, while this candidate writes `[b,u,a,v,c]` with the same display. Prefer one column-slot planner for both singleton and group reorder, preserving unrelated column indices consistently. This would intentionally revise singleton source ordering and requires production regression/roundtrip/history checks; no such migration is implemented here. Retaining global singleton splice was rejected as two source transformation rules for the same visible operation. Native anchor reachability and selected-target guard execution remain separate checks.
An independent critic matched the experiment's rank algorithm to Rust and found no counterexample under the stated valid-input assumptions. The review prompted an explicit five-node changed group beside a cycle; four-node enumeration could only cover singleton movement or unchanged whole-peer blocks there. It also identified the singleton global-splice compatibility choice. Retaining that splice was rejected as two source transformation rules for the same visible operation; the implemented migration above intentionally preserves unrelated column indices instead. Native anchor reachability and selected-target guard execution remain separate acceptance checks. These exploratory results support the design but do not replace runtime/native verification.
@@ -0,0 +1,32 @@
# Process multiple-selection native evaluator design
The independent evaluator is `crates/oa-core/examples/process_multiselect_native.rs`. This document describes its intended gates, not successful build/test/native evidence. It was prepared while the parent integrated the desktop adapter; no Cargo or formatting command ran during preparation.
## Fixture and expected operation
Generate a fresh directory with deterministic UUIDs, `baseline.json`, source bundle and `manifest.json`. Generation refuses an existing output directory; every write is create-only. The checker also compares the loaded baseline with its deterministic original fixture, so a reordered snapshot cannot silently become the baseline. Pin the generated files and command in the native receipt before interaction; these checks do not authenticate external evidence by themselves.
Open the generated source, choose Behavior and **01 OA group order**. Its source steps are `[Card A, Downstream U, Card B, Downstream V, Card C]`. Explicit links A→U and B→V produce one column containing A/B/C and another containing U/V. A and C are distinct Action occurrences sharing the same reusable Activity; B is a Choice control. Select C, then Shift-select A, and drop after B. Literal expected source steps are `[B,U,A,V,C]`, retaining U/V in indices 1/3 and A/C in their original internal order despite reverse selection.
The checker has `unchanged` and `reordered` modes. It modifies only the expected OA owner's steps with that literal list, then compares the complete Project. It never calls a desktop planner, renderer, selection helper or inferred pointer geometry. Links/guards, reused work, occurrence bodies, all SA records, trace baseline and unrelated processes must remain exact. JSON and source roundtrips are also checked; completeness warnings are permitted, source errors are not.
The compact **SA chain - preserve** contains a matching five-card FunctionalChain fixture. Its complete records must remain unchanged during OA trials. **02 Read-only preview trial** has two local references to **Reusable request detail**. Their preview tiles show the same defining occurrence through different paths, permitting exact-identity/read-only refusal trials; saving after those refusal trials should satisfy `unchanged` against the original baseline.
## Proposed commands
Use the parent's established toolchain and gate receipts. From the repository root:
```text
cargo test --locked --offline -p oa-core --example process_multiselect_native -- --nocapture
cargo run --locked --offline -p oa-core --example process_multiselect_native -- generate FRESH_DIRECTORY
cargo run --locked --offline -p oa-core --example process_multiselect_native -- check unchanged FRESH_DIRECTORY/baseline.json SAVED_SOURCE_ENTRY
cargo run --locked --offline -p oa-core --example process_multiselect_native -- check reordered FRESH_DIRECTORY/baseline.json SAVED_SOURCE_ENTRY
```
The manifest identifies the emitted source entry. Native windows/load paths and executable hashes must be captured by the actual evaluator run. The example supports JSON saved inputs too.
## Falsifiers and limits
Five evaluator tests exercise deterministic/create-only generation; real validated `SetSteps` output; unchanged/reordered distinction and replaced-baseline refusal; wrong global splicing, reversed block and primary-only results; link guard/endpoint drift, reusable Activity changes, SA semantic/order changes, reused-definition and cross-layer baseline corruption; source compile/roundtrip and valid source drift. Negative Project mutations are asserted core-valid before checker rejection, distinguishing oracle sensitivity from trivial malformed-input rejection.
Native acceptance must separately establish Shift/focus delivery, selection painting/count, selected-anchor no-op, actual Before/After zones, cancellation, context menus, whole-set mixed/qualified refusal, one Undo/Redo, source inspection, stable camera/layout and responsiveness. Whole-Project equality does not establish those interactions or exact source-byte preservation. This fixture does not test aggregate deletion, Allocation or Scenario semantics. `reordered` is specifically the OA operation above; the SA fixture is a preservation sentinel, not an accepted SA native reorder run.