diff --git a/AGENTS.md b/AGENTS.md index 767be18f..e76647f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,12 +4,12 @@ - Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere. - Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value. -- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it. +- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid / invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it. - For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices. - Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls. - Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture. -- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness. -- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability. +- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing / selection, navigation, feedback, recovery, loading / empty / failure states, accessibility, layout, focus / disabled states, and responsiveness. +- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human / AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability. - Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence. ## User input @@ -24,10 +24,10 @@ Keep one authoritative home for each kind of information. Use links and brief sc | Information | Authoritative home | | --- | --- | -| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. | -| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. | +| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues / milestones; linked PRs hold change review and validation. | +| Specifications, architecture, interfaces, and build / run documentation | Established version-controlled project documents. | | Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. | -| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. | +| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit / release, capabilities, and material limitations. No live task ledger. | | Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. | | Published deliverables | Gitea releases, tied to source commits and verification. | @@ -38,9 +38,9 @@ Use Gitea for live coordination and the relevant code version for implementation All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents. 1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling. -2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work. -3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines. -4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task. +2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository / branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work. +3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration / release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files / interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines. +4. At meaningful checkpoints or handoffs, record changed results, commit / PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task. 5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems. 6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete. @@ -64,10 +64,10 @@ Preserve concise records of decisions and rationale, useful experiments, failed Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup. -If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony. +If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions / evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony. ## Delivery -Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps. +Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases / tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps. -Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents. +Include runnable builds or appropriate artifacts, concise release notes, build / run / deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release / upload operation. Do not silently replace published versions with different contents. diff --git a/README.md b/README.md index 5cc5dc70..1823acd6 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file. +In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved. + Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result. For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. -In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram. +In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram. ## Edit the same model as text diff --git a/crates/oa-core/examples/state_multiselect_native.rs b/crates/oa-core/examples/state_multiselect_native.rs new file mode 100644 index 00000000..b087f95e --- /dev/null +++ b/crates/oa-core/examples/state_multiselect_native.rs @@ -0,0 +1,911 @@ +//! Deterministic persisted-State native fixture and complete-Project checker. +//! This invariant evaluator derives delta from saved A, NOT from pointer input. +use oa_core::{source_project::*, *}; +use std::{fs, io::Write, path::Path}; +type Result = std::result::Result>; + +fn id(alias: &str) -> Id { + // Dedicated State view is the smallest diagram UUID, for native opening. + if alias == "state_view" { + return Id::from_u128(0x29_0101); + } + Id::new_v5( + &Id::from_u128(0xc1192026_0923_5000_8000_000000002929), + alias.as_bytes(), + ) +} +fn point(x: f32, y: f32) -> Position { + Position { x, y } +} +fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option) { + 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 diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) { + p.diagrams.insert( + id(alias), + Diagram { + id: id(alias), + alias: alias.into(), + name: name.into(), + description: "Selection changes placement geometry only.".into(), + layer, + projection: ProjectionKind::Overview, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + }, + ); +} +fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) { + p.diagrams.get_mut(&id(view)).unwrap().nodes.insert( + id(alias), + NodePlacement { + id: id(alias), + alias: alias.into(), + element: id(object), + position, + size, + collapsed: false, + port_anchors: vec![], + }, + ); +} +fn route(edge: &str, pins: Vec) -> EdgeRoute { + EdgeRoute { + edge: id(edge), + waypoints: pins, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.25, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.7, + }), + label: Some(EdgeLabel { + fraction: 0.45, + offset: point(7., -19.), + }), + } +} +fn transition( + p: &mut Project, + alias: &str, + name: &str, + source: &str, + target: &str, + kind: TransitionKind, +) -> Result<()> { + let old = p.relate(RelationKind::StateTransition, 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 transition {alias}."); + if let RelationBody::StateTransition { + transition_kind, + guard, + .. + } = &mut relation.body + { + *transition_kind = kind; + *guard = if alias == "between" { + "request_ready" + } else { + "" + } + .into(); + } + Ok(()) +} +fn fixture() -> Result { + let mut p = Project::empty(); + p.id = id("project"); + p.alias = "state_multiselect_native".into(); + p.name = "State group native".into(); + p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into(); + for (alias, name, kind, parent) in [ + ("crew", "Operations crew", Kind::Entity, None), + ( + "machine", + "Request lifecycle", + Kind::StateMachine, + Some(id("crew")), + ), + ("region", "Main region", Kind::Region, Some(id("machine"))), + ("state_a", "A - Processing", Kind::State, Some(id("region"))), + ("state_b", "B - Ready", Kind::State, Some(id("region"))), + ("state_c", "C - Complete", Kind::State, Some(id("region"))), + ( + "inner", + "A nested region", + Kind::Region, + Some(id("state_a")), + ), + ( + "state_leaf", + "A.1 - Classify", + Kind::State, + Some(id("inner")), + ), + ("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))), + ("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))), + ("shared_work", "Record request", Kind::Activity, None), + ] { + element(&mut p, alias, name, kind, Layer::Oa, parent); + } + element( + &mut p, + "sa_function", + "Technical record request", + Kind::Function, + Layer::Sa, + None, + ); + for (alias, name, source, target, kind) in [ + ( + "between", + "Ready\nfor review", + "state_a", + "state_b", + TransitionKind::External, + ), + ( + "external", + "Complete request", + "state_b", + "state_c", + TransitionKind::External, + ), + ( + "entry_route", + "Begin classification", + "entry", + "state_leaf", + TransitionKind::Local, + ), + ( + "exit_route", + "Classification complete", + "state_leaf", + "exit", + TransitionKind::External, + ), + ( + "refresh", + "Refresh record", + "state_a", + "state_a", + TransitionKind::Internal, + ), + ] { + transition(&mut p, alias, name, source, target, kind)?; + } + for (alias, source, phase) in [ + ("action_a", "state_a", StateActionKind::Entry), + ("action_c", "state_c", StateActionKind::Do), + ] { + let old = p.relate( + RelationKind::StateAction, + "Record shared work", + id(source), + id("shared_work"), + ); + reidentify(&mut p, old, id(alias))?; + let relation = p.relations.get_mut(&id(alias)).unwrap(); + relation.alias = alias.into(); + relation.description = format!("Preserve shared-work action {alias}."); + if let RelationBody::StateAction { phase: value, .. } = &mut relation.body { + *value = phase; + } + } + for (alias, name) in [ + ("state_view", "State multiselect - main"), + ("state_other", "Other State view - preserve"), + ] { + diagram(&mut p, alias, name, Layer::Oa); + p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine { + machine: id("machine"), + }; + } + diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa); + // The main machine and outer Region are deliberately unplaced to keep + // the target headers readable in a 1442 x 932 native window after Fit. + // A starts at the authored origin so saved A recovers the exact f32 delta; + // subtracting a nonzero original can round it differently from sibling XY. + for (alias, object, x, y, width, height) in [ + ("node_a", "state_a", 0., 0., 340., 450.), + ("node_b", "state_b", 410., 30., 210., 150.), + ("node_c", "state_c", 410., 330., 210., 140.), + ("node_inner", "inner", 25., 220., 290., 200.), + ("node_leaf", "state_leaf", 60., 290., 180., 90.), + ("node_entry", "entry", -210., 280., 24., 24.), + ("node_exit", "exit", 870., 350., 24., 24.), + ] { + place( + &mut p, + "state_view", + alias, + object, + point(x, y), + Size { width, height }, + ); + } + let size = Size { + width: 224., + height: 94., + }; + place( + &mut p, + "state_other", + "other_a", + "state_a", + point(40., 60.), + Size { + width: 340., + height: 220., + }, + ); + place( + &mut p, + "state_other", + "other_b", + "state_b", + point(460., 80.), + size, + ); + place( + &mut p, + "sa_view", + "sa_node", + "sa_function", + point(100., 110.), + size, + ); + p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![ + route("between", vec![point(365., 70.), point(365., 120.)]), + route("external", vec![point(660., 140.), point(660., 360.)]), + route("entry_route", vec![point(40., 280.), point(40., 320.)]), + route("exit_route", vec![point(290., 335.), point(290., 370.)]), + ]; + p.traces.insert( + id("trace"), + Trace { + id: id("trace"), + alias: "preserved_refinement".into(), + name: "Reviewed recording refinement".into(), + description: + "Moving states or hiding placements must preserve shared work and its refinement." + .into(), + oa: id("shared_work"), + sa: id("sa_function"), + kind: TraceKind::Refinement, + baseline: Some(TraceBaseline { + oa: p.properties(id("shared_work")).unwrap(), + sa: p.properties(id("sa_function")).unwrap(), + semantic: None, + }), + }, + ); + validate_candidate(&p)?; + Ok(p) +} +fn load(path: &Path) -> Result { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) + { + let disk = source_files::load_project(path, ProjectLimits::default())?; + // This deliberately partial engineering fixture has completeness + // warnings (allocation/behavior context). They are not source errors. + // Compilation, candidate validation and whole-Project equality still + // reject malformed source or any unexpected model change. + 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 e = serde_json::to_value(expected)?; + let a = serde_json::to_value(actual)?; + let fields: Vec<_> = e + .as_object() + .unwrap() + .keys() + .filter(|key| e[*key] != a[*key]) + .cloned() + .collect(); + Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into()) +} +fn roundtrip(p: &Project) -> Result { + 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 names = [ + "crew", + "machine", + "region", + "inner", + "entry", + "exit", + "shared_work", + "node_inner", + "node_entry", + "node_exit", + "entry_route", + "exit_route", + "refresh", + "action_a", + "action_c", + "other_b", + "project", + "state_view", + "state_other", + "sa_view", + "state_a", + "state_b", + "state_c", + "state_leaf", + "node_a", + "node_b", + "node_c", + "node_leaf", + "between", + "external", + "trace", + "sa_function", + "sa_node", + "other_a", + ]; + let ids: std::collections::BTreeMap<_, _> = + names.into_iter().map(|name| (name, id(name))).collect(); + write_new( + &out.join("manifest.json"), + &serde_json::to_vec_pretty(&serde_json::json!({ + "fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry, + "move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"], + "translated_routes": ["between", "entry_route", "exit_route"], + "fixed_route": "external", + "window": [1442, 932], + "target_headers_world": {"A": [40, 35], "B": [450, 65]}, + "delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization", + "receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c", + "evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle", + "hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views", + }))?, + )?; + equal(&p, &load(&out.join("baseline.json"))?)?; + equal(&p, &load(&out.join("source/project.arcadia"))?)?; + 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)?; + if before.id != id("project") { + return Err("Not this fixture's baseline project".into()); + } + let mut expected = before.clone(); + let mut delta = None; + match mode { + "unchanged" => {} + "hide" => { + let view = expected + .diagrams + .get_mut(&id("state_view")) + .ok_or("Missing State view")?; + for alias in ["node_a", "node_b"] { + view.nodes + .remove(&id(alias)) + .ok_or("Baseline selected placement missing")?; + } + // references::remove_objects removes routes only when their semantic + // edge is deleted. Hide deletes placements; both routes MUST survive. + } + "moved" => { + let old = before + .diagrams + .get(&id("state_view")) + .ok_or("Missing baseline State view")?; + let new = actual + .diagrams + .get(&id("state_view")) + .ok_or("Missing saved State view")?; + let a = old + .nodes + .get(&id("node_a")) + .ok_or("Missing baseline A")? + .position; + let moved_a = new + .nodes + .get(&id("node_a")) + .ok_or("Missing saved A")? + .position; + let d = point(moved_a.x - a.x, moved_a.y - a.y); + if !d.x.is_finite() + || !d.y.is_finite() + || d.x.abs().max(d.y.abs()) > 300. + || d.x.hypot(d.y) < 0.01 + { + return Err(format!( + "Move delta must have length >=0.01 and each axis <=300 world units: {d:?}" + ) + .into()); + } + let view = expected.diagrams.get_mut(&id("state_view")).unwrap(); + // Independent explicit fixture enumeration: no group/descendant/ + // routing planner is called to predict the result. + for alias in [ + "node_a", + "node_b", + "node_inner", + "node_leaf", + "node_entry", + "node_exit", + ] { + let node = view + .nodes + .get_mut(&id(alias)) + .ok_or("Missing expected moved placement")?; + node.position.x += d.x; + node.position.y += d.y; + } + for alias in ["between", "entry_route", "exit_route"] { + let route = view + .routes + .iter_mut() + .find(|route| route.edge == id(alias)) + .ok_or("Missing explicitly expected moved route")?; + for pin in &mut route.waypoints { + pin.x += d.x; + pin.y += d.y; + } + } + delta = Some(d); + } + _ => return Err("Mode must be moved, hide, 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", "inferred_common_delta": delta, + "limits": "Invariant evaluator only; 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: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + // Produce evidence through real validated commands; the checker itself uses + // explicit fixture enumeration and never calls this movement implementation. + fn moved(before: &Project, delta: Position) -> Project { + let command = diagram_edit::translate_placements( + before, + id("state_view"), + &BTreeSet::from([id("node_a"), id("node_b")]), + delta, + ) + .unwrap(); + let mut session = Session::new(before.clone()); + session.execute(command).unwrap(); + session.project().clone() + } + fn hidden(before: &Project) -> Project { + let mut session = Session::new(before.clone()); + session + .execute(Command::Batch(vec![ + Command::Delete(id("node_a")), + Command::Delete(id("node_b")), + ])) + .unwrap(); + session.project().clone() + } + fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> { + let dir = tempfile::tempdir()?; + let before = dir.path().join("before.json"); + let saved = dir.path().join("saved.json"); + // These assertions distinguish meaningful invariant corruptions from + // malformed projects rejected before the evaluator even sees them. + validate_candidate(baseline).expect("test baseline must be valid"); + validate_candidate(actual).expect("corruption must remain a valid model"); + persistence::save(&before, baseline)?; + persistence::save(&saved, actual)?; + check(mode, &before, &saved) + } + fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) { + let error = through_saved_json(mode, before, actual) + .expect_err(case) + .to_string(); + assert!( + error.contains("Whole Project mismatch"), + "{case}: unexpected rejection: {error}" + ); + } + fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute { + p.diagrams + .get_mut(&id("state_view")) + .unwrap() + .routes + .iter_mut() + .find(|route| route.edge == id(alias)) + .unwrap() + } + + #[test] + fn generated_json_and_real_source_bundle_are_accepted_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("fixture"); + generate(&out).unwrap(); + let baseline = out.join("baseline.json"); + let saved = out.join("saved.json"); + persistence::save(&saved, &fixture().unwrap()).unwrap(); + check("unchanged", &baseline, &saved).unwrap(); + check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap(); + let bytes = fs::read(&baseline).unwrap(); + assert!( + generate(&out).is_err(), + "generation cannot overwrite evidence" + ); + assert_eq!(fs::read(&baseline).unwrap(), bytes); + let invalid = dir.path().join("invalid.arcadia"); + fs::write(&invalid, "project {").unwrap(); + assert!(load(&invalid).is_err(), "source errors must still reject"); + } + + #[test] + fn real_group_command_outputs_pass_and_are_not_unchanged() { + let before = fixture().unwrap(); + for delta in [ + point(7.25, -2.75), + point(-20.5, 30.125), + point(0.25, 0.), + point(0.01, 10.), + point(0.1, -0.2), + ] { + let actual = moved(&before, delta); + through_saved_json("moved", &before, &actual).unwrap(); + mismatch( + "unchanged", + &before, + &actual, + "real movement cannot count as unchanged", + ); + } + } + + #[test] + fn real_hide_command_outputs_pass_before_and_after_movement() { + let initial = fixture().unwrap(); + for before in [initial.clone(), moved(&initial, point(40., 60.))] { + let actual = hidden(&before); + through_saved_json("hide", &before, &actual).unwrap(); + mismatch( + "unchanged", + &before, + &actual, + "real Hide cannot count as unchanged", + ); + } + } + + #[test] + fn omitted_and_double_moved_child_are_rejected() { + let before = fixture().unwrap(); + let delta = point(40., 60.); + let correct = moved(&before, delta); + for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] { + let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position; + for position in [ + initial, + point(initial.x + 2. * delta.x, initial.y + 2. * delta.y), + ] { + let mut bad = correct.clone(); + bad.diagrams + .get_mut(&id("state_view")) + .unwrap() + .nodes + .get_mut(&id(alias)) + .unwrap() + .position = position; + mismatch("moved", &before, &bad, "child did not move exactly once"); + } + } + } + + #[test] + fn internal_pins_left_behind_and_external_pins_moved_are_rejected() { + let mut before = fixture().unwrap(); + let correct = moved(&before, point(40., 60.)); + for alias in ["between", "entry_route", "exit_route"] { + let original_pins = edge(&mut before, alias).waypoints.clone(); + let mut bad = correct.clone(); + edge(&mut bad, alias).waypoints = original_pins; + mismatch("moved", &before, &bad, "internal pins left behind"); + } + let mut bad = correct; + for pin in &mut edge(&mut bad, "external").waypoints { + pin.x += 40.; + pin.y += 60.; + } + mismatch("moved", &before, &bad, "one-ended pins moved"); + } + + #[test] + fn manual_attachments_and_label_data_must_remain_exact() { + let before = fixture().unwrap(); + let correct = moved(&before, point(40., 60.)); + let mutations: [fn(&mut EdgeRoute); 4] = [ + |r| r.source_attachment.as_mut().unwrap().offset = 0.65, + |r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top, + |r| r.label.as_mut().unwrap().offset.x += 40., + |r| r.label.as_mut().unwrap().fraction = 0.75, + ]; + for mutate in mutations { + let mut bad = correct.clone(); + mutate(edge(&mut bad, "between")); + mismatch("moved", &before, &bad, "relative route metadata changed"); + } + } + + #[test] + fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() { + let before = fixture().unwrap(); + let correct = moved(&before, point(40., 60.)); + let mutations: [fn(&mut Project); 9] = [ + |p| p.traces.get_mut(&id("trace")).unwrap().baseline = None, + |p| { + p.elements + .get_mut(&id("shared_work")) + .unwrap() + .description + .push_str(" drift") + }, + |p| { + if let RelationBody::StateAction { phase, .. } = + &mut p.relations.get_mut(&id("action_a")).unwrap().body + { + *phase = StateActionKind::Exit; + } + }, + |p| { + if let RelationBody::StateTransition { guard, .. } = + &mut p.relations.get_mut(&id("refresh")).unwrap().body + { + *guard = "changed_refresh_guard".into(); + } + }, + |p| { + p.elements + .get_mut(&id("state_a")) + .unwrap() + .description + .push_str(" drift") + }, + |p| { + p.diagrams + .get_mut(&id("state_other")) + .unwrap() + .nodes + .get_mut(&id("other_a")) + .unwrap() + .position + .x += 40. + }, + |p| { + p.diagrams + .get_mut(&id("sa_view")) + .unwrap() + .nodes + .get_mut(&id("sa_node")) + .unwrap() + .position + .y += 60. + }, + |p| { + p.diagrams + .get_mut(&id("state_view")) + .unwrap() + .nodes + .get_mut(&id("node_c")) + .unwrap() + .position + .x += 40. + }, + |p| { + p.traces + .get_mut(&id("trace")) + .unwrap() + .description + .push_str(" drift") + }, + ]; + for mutate in mutations { + let mut bad = correct.clone(); + mutate(&mut bad); + mismatch( + "moved", + &before, + &bad, + "record outside permitted movement changed", + ); + } + } + + #[test] + fn zero_and_excessive_moves_cannot_satisfy_move_evidence() { + let before = fixture().unwrap(); + for (delta, reason) in [ + (point(0., 0.), "length >=0.01"), + (point(310., 0.), "each axis <=300"), + ] { + let actual = moved(&before, delta); + let error = through_saved_json("moved", &before, &actual) + .unwrap_err() + .to_string(); + assert!(error.contains(reason), "{delta:?}: {error}"); + } + } + + #[test] + fn hide_must_preserve_child_routes_and_semantic_objects() { + let before = fixture().unwrap(); + let correct = hidden(&before); + let mut missing_child = correct.clone(); + missing_child + .diagrams + .get_mut(&id("state_view")) + .unwrap() + .nodes + .remove(&id("node_leaf")); + mismatch( + "hide", + &before, + &missing_child, + "Hide removed unselected child placement", + ); + let mut missing_routes = correct; + missing_routes + .diagrams + .get_mut(&id("state_view")) + .unwrap() + .routes + .clear(); + mismatch( + "hide", + &before, + &missing_routes, + "Hide removed persistent route records", + ); + let mut semantic_delete = Session::new(before.clone()); + semantic_delete + .execute(Command::Batch(vec![ + Command::Delete(id("state_a")), + Command::Delete(id("state_b")), + ])) + .unwrap(); + mismatch( + "hide", + &before, + semantic_delete.project(), + "Hide became semantic deletion", + ); + } + + #[test] + fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() { + let before = fixture().unwrap(); + let mut actual = before.clone(); + actual + .elements + .get_mut(&id("state_b")) + .unwrap() + .name + .push_str(" edited"); + mismatch( + "unchanged", + &before, + &actual, + "geometry equality is not complete Project equality", + ); + } + + #[test] + fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() { + let before = fixture().unwrap(); + for (mode, actual) in [ + ("moved", moved(&before, point(7.25, -2.75))), + ("hide", hidden(&before)), + ] { + let dir = tempfile::tempdir().unwrap(); + let baseline = dir.path().join("baseline.json"); + persistence::save(&baseline, &before).unwrap(); + let bundle = roundtrip(&actual).unwrap(); + for (path, source) in &bundle.files { + write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap(); + } + check( + mode, + &baseline, + &dir.path().join("saved").join(&bundle.entry), + ) + .unwrap(); + let mut corrupt = actual.clone(); + corrupt.elements.get_mut(&id("shared_work")).unwrap().name = + "Unrequested source edit".into(); + let bundle = roundtrip(&corrupt).unwrap(); + for (path, source) in &bundle.files { + write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap(); + } + let error = check( + mode, + &baseline, + &dir.path().join("corrupt").join(&bundle.entry), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("Whole Project mismatch"), "{error}"); + } + } +} diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index c46a7cbc..2d90852b 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -23,6 +23,10 @@ mod reference_authoring; mod review_navigation_tests; pub(crate) mod source_rename; mod source_symbols; +#[cfg(test)] +mod state_multiselect_focus_tests; +#[cfg(test)] +mod state_multiselect_tests; pub(crate) mod text_workspace; #[cfg(test)] mod transition_context_tests; @@ -1292,11 +1296,36 @@ impl App { pub fn update(&mut self, message: Message) -> Task { let selection_boundary = self.multiselect_boundary(); // Reject delayed gestures before they can retire newer input ownership. - if let Message::DiagramSelection(event) = &message + if let Message::DiagramSelection(event) + | Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message && !self.multiselect_envelope(event) { return Task::none(); } + if let Message::StateDiagram(event) = &message + && let Some(captured) = event.context() + && self.state_context().is_none_or(|current| { + crate::behavior::state_edit::validate_context(self.project(), captured, current) + .is_err() + }) + { + return Task::none(); + } + if matches!( + &message, + Message::StateDiagram( + crate::behavior::state_edit::Event::Select(..) + | crate::behavior::state_edit::Event::Move { .. } + | crate::behavior::state_edit::Event::Remove(_) + ) + ) && self + .multiselect_snapshot() + .is_some_and(|s| s.targets().len() > 1) + { + // A queued singleton cannot retire the current group's navigation + // or allocation ownership before its edit is refused. + 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 { @@ -1387,7 +1416,7 @@ impl App { self.camera_boundary_epoch = self.canvas_epoch; } if self.multiselect_boundary() != selection_boundary - || (self.diagram_selection.is_none() && self.page == Page::Diagram) + || (self.diagram_selection.is_none() && self.multiselect_context().is_some()) { self.reconcile_multiselect(); } @@ -2337,7 +2366,12 @@ impl App { return self.update(*action); } self.select(menu.request.object); - self.selected_node = menu.request.placement; + if self.page == Page::Behavior && menu.request.canvas.is_some() { + self.behavior.diagram.placement = menu.request.placement; + self.selected_node = None; + } else { + self.selected_node = menu.request.placement; + } self.reconcile_multiselect(); self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i)); return self.update(*action); diff --git a/crates/oa-desktop/src/app/multiselect.rs b/crates/oa-desktop/src/app/multiselect.rs index 0d3ab944..a518b4ae 100644 --- a/crates/oa-desktop/src/app/multiselect.rs +++ b/crates/oa-desktop/src/app/multiselect.rs @@ -49,7 +49,12 @@ pub(crate) fn placements(snapshot: &Snapshot) -> Option bool { +fn visible( + project: &Project, + diagram: Id, + view: &projection::ProjectedView, + target: &Target, +) -> bool { match target { Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id), Target::Port { port, placement } => view @@ -57,6 +62,12 @@ fn visible(view: &projection::ProjectedView, target: &Target) -> bool { .iter() .any(|p| p.element == *port && p.owner == *placement), Target::Edge(id) => view.edges.iter().any(|e| e.id == *id), + Target::StateRow { + placement, + relation, + } => crate::behavior::state_canvas::visible_row_target( + project, diagram, view, *placement, *relation, + ), _ => false, } } @@ -70,14 +81,41 @@ impl App { self.active_diagram, self.selected, self.selected_node, + (self.page == Page::Behavior).then(|| self.state_view_identity()), + (self.page == Page::Behavior).then_some(self.behavior.diagram.placement), ) } pub(crate) fn multiselect_context(&self) -> Option { - // Other surfaces retain their existing adapters until their typed group - // operations are implemented; never reuse an Architecture selection there. - (self.page == Page::Diagram) - .then(|| self.route_context().map(context)) - .flatten() + match self.page { + Page::Diagram => self.route_context().map(context), + Page::Behavior => { + let current = self.state_context()?; + crate::behavior::state_edit::validate_context(self.project(), current, current) + .ok()?; + Some(context(current)) + } + _ => None, + } + } + + pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool { + let Some(Context { + scope: + Scope { + surface: Surface::Saved(diagram), + .. + }, + .. + }) = self.multiselect_context() + else { + return false; + }; + let view = projection::project_view_with_index( + self.project(), + diagram, + self.document.view_index(), + ); + visible(self.project(), diagram, &view, target) } pub(crate) fn multiselect_snapshot(&self) -> Option { @@ -100,14 +138,44 @@ impl App { } } - fn legacy_target(&self, view: &projection::ProjectedView) -> Option { - if let Some(id) = self.selected_node + fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option { + let placement = if self.page == Page::Behavior { + self.behavior.diagram.placement + } else { + self.selected_node + }; + if let Some(id) = placement && view.nodes.iter().any(|node| { node.id == id && (node.element == self.selected || node.id == self.selected) }) { return Some(Target::Placement(id)); } + if self.page == Page::Behavior { + // Keep a row's exact occurrence while inspecting its relation. + // Its owner placement is never a node-inspection or Hide target. + if let Some(target @ Target::StateRow { relation, .. }) = + self.diagram_selection.as_ref().and_then(|s| s.primary()) + && *relation == self.selected + && visible(self.project(), diagram, view, target) + { + return Some(target.clone()); + } + if let Some(node) = view.nodes.iter().find(|node| { + crate::behavior::state_canvas::visible_row_target( + self.project(), + diagram, + view, + node.id, + self.selected, + ) + }) { + return Some(Target::StateRow { + placement: node.id, + relation: self.selected, + }); + } + } if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) { return Some(Target::Port { port: port.element, @@ -133,28 +201,39 @@ impl App { diagram, self.document.view_index(), ); - let legacy = self.legacy_target(&view); + let legacy = self.legacy_target(diagram, &view); let lost_primary = self .diagram_selection .as_ref() .filter(|s| s.scope() == current.scope) .and_then(|s| s.primary()) - .is_some_and(|target| !visible(&view, target)); + .is_some_and(|target| !visible(self.project(), diagram, &view, target)); + let primary_placement = if self.page == Page::Behavior { + self.behavior.diagram.placement + } else { + self.selected_node + }; let held_primary = self .diagram_selection .as_ref() .filter(|s| s.scope() == current.scope) .and_then(|s| s.primary()) .is_some_and(|target| match target { - Target::Placement(id) => self.selected_node == Some(*id), + Target::Placement(id) => primary_placement == Some(*id), Target::Port { port, .. } => self.selected == *port, Target::Edge(id) => self.selected == *id, + Target::StateRow { relation, .. } => { + self.selected == *relation && primary_placement.is_none() + } _ => false, }); + let project = self.document.project(); let selection = self .diagram_selection .get_or_insert_with(|| Selection::new(current.scope)); - selection.reconcile(current.scope, |target| visible(&view, target)); + selection.reconcile(current.scope, |target| { + visible(project, diagram, &view, target) + }); // Navigation/property selection remains explicit. A removed exact target // is never rebound through its semantic identity to a different placement. if held_primary || lost_primary { @@ -163,7 +242,7 @@ impl App { } } else if selection.primary() != legacy.as_ref() { if let Some(target) = legacy { - selection.replace(target, |target| visible(&view, target)); + selection.replace(target, |target| visible(project, diagram, &view, target)); } else { selection.clear(); } @@ -171,26 +250,39 @@ impl App { } fn inspect_multiselect_primary(&mut self) { + let diagram = self + .multiselect_context() + .and_then(|c| match c.scope.surface { + Surface::Saved(diagram) => Some(diagram), + _ => None, + }); let primary = self .diagram_selection .as_ref() .and_then(|s| s.primary()) .cloned(); self.selected_node = None; + if self.page == Page::Behavior { + self.behavior.diagram.placement = None; + } match primary { Some(Target::Placement(id)) => { - if let Some(node) = self - .active_diagram + if let Some(node) = diagram .and_then(|d| self.project().diagrams.get(&d)) .and_then(|d| d.nodes.get(&id)) { self.selected = node.element; - self.selected_node = Some(id); + if self.page == Page::Behavior { + self.behavior.diagram.placement = Some(id); + } else { + self.selected_node = Some(id); + } } } Some(Target::Port { port, .. }) => self.selected = port, Some(Target::Edge(id)) => self.selected = id, - _ => self.selected = self.active_diagram.unwrap_or(self.project().id), + Some(Target::StateRow { relation, .. }) => self.selected = relation, + _ => self.selected = diagram.unwrap_or(self.project().id), } self.selected_bend = None; self.selected_attachment = None; @@ -204,6 +296,8 @@ impl App { if !diagram_removal::enabled(self) || self.architecture.is_some() || self.connection.is_some() + || (self.page == Page::Behavior + && self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select) { self.status = "Finish or cancel the active edit before changing the diagram selection.".into(); @@ -220,22 +314,24 @@ impl App { ); match event { Event::Collapse(_, target) => { - if !visible(&view, &target) { + if !visible(self.project(), diagram, &view, &target) { return; } + let project = self.document.project(); self.diagram_selection .as_mut() .unwrap() - .replace(target, |target| visible(&view, target)); + .replace(target, |target| visible(project, diagram, &view, target)); self.inspect_multiselect_primary(); } Event::Choose(_, target, choice) => { - if !visible(&view, &target) { + if !visible(self.project(), diagram, &view, &target) { return; } self.reconcile_multiselect(); + let project = self.document.project(); let selection = self.diagram_selection.as_mut().unwrap(); - let valid = |target: &Target| visible(&view, target); + let valid = |target: &Target| visible(project, diagram, &view, target); match choice { Choice::Replace => { selection.replace(target, valid); @@ -264,13 +360,13 @@ impl App { Event::Refuse(_, reason) => self.status = reason, Event::Move(snapshot, delta) => { let Some(ids) = placements(&snapshot) else { - self.status = "Move requires only placed elements. Remove ports and connections from the selection.".into(); + self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into(); return; }; if !snapshot .targets() .iter() - .all(|target| visible(&view, target)) + .all(|target| visible(self.project(), diagram, &view, target)) { return; } @@ -318,7 +414,7 @@ impl App { if !snapshot .targets() .iter() - .all(|target| visible(&view, target)) + .all(|target| visible(self.project(), diagram, &view, target)) { return; } diff --git a/crates/oa-desktop/src/app/state_attachment_escape_tests.rs b/crates/oa-desktop/src/app/state_attachment_escape_tests.rs index 2f997ee7..6396144e 100644 --- a/crates/oa-desktop/src/app/state_attachment_escape_tests.rs +++ b/crates/oa-desktop/src/app/state_attachment_escape_tests.rs @@ -53,6 +53,7 @@ async fn armed() -> (App, Mounted, Id) { context, object: relation, placement: None, + selection_target: None, anchor: iced::Point::new(450., 400.), bend: None, route_segment: None, diff --git a/crates/oa-desktop/src/app/state_bend_tests.rs b/crates/oa-desktop/src/app/state_bend_tests.rs index 502ac5d0..47128291 100644 --- a/crates/oa-desktop/src/app/state_bend_tests.rs +++ b/crates/oa-desktop/src/app/state_bend_tests.rs @@ -79,6 +79,7 @@ fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action( context, object: relation, placement: None, + selection_target: None, anchor: iced::Point::ORIGIN, bend: Some(1), route_segment: Some((2, point)), diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index 279e8c3c..eccbd6f2 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -114,6 +114,7 @@ impl App { } } match event { + Event::Selection(event) => self.multiselect_event(event), Event::Remove(intent) => self.remove_from_diagram(intent), Event::Viewport(_, size) => { if size.width.is_finite() @@ -156,6 +157,7 @@ impl App { context, object, placement, + selection_target, anchor, bend, route_segment, @@ -163,6 +165,7 @@ impl App { let mut request = crate::model_menu::Request::object(object, anchor); request.canvas = Some(context); request.placement = placement; + request.selection_target = selection_target; request.bend = bend; request.route_segment = route_segment; return self.handle(Message::Menu(crate::model_menu::Event::Open(request))); diff --git a/crates/oa-desktop/src/app/state_multiselect_focus_tests.rs b/crates/oa-desktop/src/app/state_multiselect_focus_tests.rs new file mode 100644 index 00000000..059c5d5a --- /dev/null +++ b/crates/oa-desktop/src/app/state_multiselect_focus_tests.rs @@ -0,0 +1,374 @@ +//! State selection layout and modifier admission on the mounted production App. +use super::*; +use crate::behavior::{ + canvas_workspace::tests::{Mounted, key}, + state_edit, +}; +use crate::diagram_usage::Target; +use iced::advanced::widget::{Operation, operation}; +use iced::{Point, Rectangle, Size, Vector, keyboard, mouse}; + +struct Fixture { + app: App, + diagram: Id, + a: Id, + b: Id, + a_element: Id, + b_element: Id, + edge: Id, + action: Id, +} + +fn fixture() -> Fixture { + let mut p = Project::empty(); + let machine = p + .add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Oa, "Main region", Some(machine)) + .unwrap(); + let a_element = p + .add_element(Kind::State, Layer::Oa, "First state", Some(region)) + .unwrap(); + let b_element = p + .add_element( + Kind::State, + Layer::Oa, + &format!("Second state {}", "with a long primary name ".repeat(20)), + Some(region), + ) + .unwrap(); + let work = p + .add_element(Kind::Activity, Layer::Oa, "Reusable work", None) + .unwrap(); + let action = p.relate(RelationKind::StateAction, "On entry", a_element, work); + let edge = p.relate( + RelationKind::StateTransition, + "Next state", + a_element, + b_element, + ); + let diagram = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine }; + let mut placements = vec![]; + // All State elements, including machine/Region, are initially placed. + // A fixture already missing its containers would not test picker insertion. + for (element, x, y, width, height) in [ + (machine, 0., 0., 800., 500.), + (region, 20., 70., 760., 410.), + (a_element, 60., 160., 230., 180.), + (b_element, 430., 160., 260., 180.), + ] { + let node = p.place(diagram, element, Position { x, y }); + p.diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .size = oa_core::Size { width, height }; + placements.push(node); + } + let a = placements[2]; + let b = placements[3]; + validate_candidate(&p).unwrap(); + assert!(state_edit::unplaced(&p, diagram).is_empty()); + let mut app = App::with_project(p); + app.page = Page::Behavior; + app.layer = Layer::Oa; + app.behavior.active_owner = Some(machine); + app.behavior.diagram = crate::behavior::state_view::State { + diagram: Some(diagram), + fit: true, + ..Default::default() + }; + app.selected = diagram; + app.selected_node = None; + app.window_size = Size::new(1442., 932.); + app.refresh_draft(); + app.reconcile_multiselect(); + assert_eq!(app.state_context().unwrap().diagram, diagram); + Fixture { + app, + diagram, + a, + b, + a_element, + b_element, + edge, + action, + } +} + +fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) { + let geometry = mounted.geometry(app); + let (_, bounds, focused) = geometry + .focused + .into_iter() + .find(|(id, _, _)| { + id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner")) + }) + .expect("real State focus::Owner rectangle must be measurable"); + (bounds, focused) +} +fn camera(app: &App) -> (f32, Vector, bool, Option) { + let state = &app.behavior.diagram; + (state.zoom, state.pan, state.fit, state.viewport) +} +async fn selection(mounted: &mut Mounted, app: &mut App, target: Option) { + let context = app.multiselect_context().unwrap(); + let event = match target { + Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle), + None => multiselect::Event::Clear(context), + }; + let task = app.update(Message::DiagramSelection(event)); + mounted.perform(app, task).await; + mounted.settle(app).await; +} +fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point { + let bounds = owner(mounted, &f.app).0; + let node = &f.app.project().diagrams[&f.diagram].nodes[&placement]; + let state = &f.app.behavior.diagram; + Point::new( + bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x, + bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y, + ) +} +fn modifiers(shift: bool) -> iced::Event { + iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift { + keyboard::Modifiers::SHIFT + } else { + keyboard::Modifiers::default() + })) +} +struct FocusField(Rectangle); +impl Operation for FocusField { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn focusable( + &mut self, + _: Option<&iced::advanced::widget::Id>, + bounds: Rectangle, + state: &mut dyn operation::Focusable, + ) { + if bounds == self.0 { + state.focus(); + } else { + state.unfocus(); + } + } +} + +#[tokio::test] +async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() { + let mut f = fixture(); + let mut mounted = Mounted::new(&mut f.app).await; + let baseline = f.app.document.snapshot(); + let bounds = owner(&mut mounted, &f.app).0; + let fitted = camera(&f.app); + assert!( + fitted.2, + "keep Fit active to expose any resize-triggered camera jump" + ); + assert!(fitted.3.is_some()); + assert!(bounds.width > 400. && bounds.height > 200.); + assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty()); + assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); + for (target, expected) in [ + (Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]), + ( + Some(Target::Placement(f.b)), + vec![Target::Placement(f.a), Target::Placement(f.b)], + ), + ( + Some(Target::Edge(f.edge)), + vec![ + Target::Placement(f.a), + Target::Placement(f.b), + Target::Edge(f.edge), + ], + ), + ( + Some(Target::Edge(f.edge)), + vec![Target::Placement(f.a), Target::Placement(f.b)], + ), + ( + Some(Target::StateRow { + placement: f.a, + relation: f.action, + }), + vec![ + Target::Placement(f.a), + Target::Placement(f.b), + Target::StateRow { + placement: f.a, + relation: f.action, + }, + ], + ), + (None, vec![]), + ] { + selection(&mut mounted, &mut f.app, target).await; + assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected); + assert_eq!( + owner(&mut mounted, &f.app).0, + bounds, + "selection strip changed the measured State canvas rectangle" + ); + assert_eq!( + camera(&f.app), + fitted, + "selection feedback silently refitted the camera" + ); + assert_eq!(f.app.project(), &baseline.decoded.project); + assert_eq!(f.app.document.bundle(), &baseline.bundle); + assert!(!f.app.document.can_undo()); + assert!(!f.app.draft_dirty()); + } + selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await; + selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await; + let captured = f.app.multiselect_snapshot().unwrap(); + let task = f + .app + .update(Message::DiagramSelection(multiselect::Event::Hide( + captured, + ))); + mounted.perform(&mut f.app, task).await; + mounted.settle(&mut f.app).await; + let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram) + .into_iter() + .collect(); + assert_eq!( + missing, + std::collections::BTreeSet::from([f.a_element, f.b_element]) + ); + let mut expected = baseline.decoded.project.clone(); + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .remove(&f.a); + expected + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .remove(&f.b); + assert_eq!(f.app.project(), &expected); + assert_eq!( + owner(&mut mounted, &f.app).0, + bounds, + "the first unplaced picker changed canvas bounds" + ); + assert_eq!(camera(&f.app), fitted); + let hidden_source = f.app.document.bundle().clone(); + let task = f.app.update(Message::Undo); + mounted.perform(&mut f.app, task).await; + mounted.settle(&mut f.app).await; + assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty()); + assert_eq!(f.app.project(), &baseline.decoded.project); + assert_eq!(f.app.document.bundle(), &baseline.bundle); + assert!(!f.app.document.can_undo()); + assert_eq!( + owner(&mut mounted, &f.app).0, + bounds, + "removing the unplaced picker changed canvas bounds" + ); + assert_eq!(camera(&f.app), fitted); + let task = f.app.update(Message::Redo); + mounted.perform(&mut f.app, task).await; + mounted.settle(&mut f.app).await; + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &hidden_source); + assert_eq!(owner(&mut mounted, &f.app).0, bounds); + assert_eq!(camera(&f.app), fitted); +} + +#[tokio::test] +async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() { + let mut f = fixture(); + let mut mounted = Mounted::new(&mut f.app).await; + selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await; + let field = mounted + .geometry(&f.app) + .text_inputs + .into_iter() + .filter(|bounds| bounds.x > f.app.window_size.width - 320.) + .min_by(|a, b| a.y.total_cmp(&b.y)) + .expect("State Inspector field is mounted"); + mounted.operate(&f.app, &mut FocusField(field)); + assert!(!owner(&mut mounted, &f.app).1); + let before = f.app.document.snapshot(); + mounted + .batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable) + .await; + assert!( + !owner(&mut mounted, &f.app).1, + "passive Shift must not acquire State canvas focus" + ); + let at = node_point(&mut mounted, &f, f.b); + let messages = mounted.click(&mut f.app, at).await; + assert!(messages.iter().any(|message| matches!(message, + Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b + )), "StateCanvas must map its typed selection event to the common App boundary"); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a), Target::Placement(f.b)] + ); + assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); + assert_eq!(f.app.selected_node, None); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + assert!(!f.app.draft_dirty()); +} + +#[tokio::test] +async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() { + let mut f = fixture(); + let mut mounted = Mounted::new(&mut f.app).await; + selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await; + // Explicitly revoke focus rather than relying on incidental initial focus. + mounted.operate( + &f.app, + &mut FocusField(Rectangle { + x: -1000., + y: -1000., + width: 1., + height: 1., + }), + ); + let before = f.app.document.snapshot(); + let at = node_point(&mut mounted, &f, f.b); + f.app.busy = true; + mounted.settle(&mut f.app).await; + mounted + .batch( + &mut f.app, + &[modifiers(true), key(keyboard::key::Named::Delete)], + mouse::Cursor::Unavailable, + ) + .await; + mounted.click(&mut f.app, at).await; + assert!(!owner(&mut mounted, &f.app).1); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a)] + ); + assert_eq!(f.app.document.bundle(), &before.bundle); + f.app.busy = false; + mounted.settle(&mut f.app).await; + assert!(!owner(&mut mounted, &f.app).1); + // No second modifier event: the passive suspended update must survive. + mounted.click(&mut f.app, at).await; + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a), Target::Placement(f.b)] + ); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + assert!(!f.app.delete_pending); +} diff --git a/crates/oa-desktop/src/app/state_multiselect_tests.rs b/crates/oa-desktop/src/app/state_multiselect_tests.rs new file mode 100644 index 00000000..26673dfc --- /dev/null +++ b/crates/oa-desktop/src/app/state_multiselect_tests.rs @@ -0,0 +1,1092 @@ +//! Persisted State groups through the real App transaction/admission boundary. +use super::multiselect::{Choice as SelectionChoice, Event as SelectionEvent}; +use super::*; +use crate::behavior::{ + state_edit::{Event as StateEvent, Tool}, + states, +}; +use crate::diagram_usage::Target; + +fn point(x: f32, y: f32) -> Position { + Position { x, y } +} + +struct Fixture { + app: App, + diagram: Id, + other_view: Id, + other_machine: Id, + machine: Id, + a: Id, + b: Id, + child: Id, + a_element: Id, + b_element: Id, + internal: Id, + action: Id, + internal_row: Id, +} + +fn route(edge: Id, pins: Vec) -> EdgeRoute { + EdgeRoute { + edge, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 0.3, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.7, + }), + waypoints: pins, + label: Some(EdgeLabel { + fraction: 0.4, + offset: point(5., -9.), + }), + } +} + +fn fixture() -> Fixture { + let mut p = Project::empty(); + let system = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let machine = p + .add_element( + Kind::StateMachine, + Layer::Sa, + "Operating modes", + Some(system), + ) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Sa, "Main region", Some(machine)) + .unwrap(); + let a_element = p + .add_element(Kind::State, Layer::Sa, "Operating", Some(region)) + .unwrap(); + let b_element = p + .add_element(Kind::State, Layer::Sa, "Standby", Some(region)) + .unwrap(); + let c_element = p + .add_element(Kind::State, Layer::Sa, "Stopped", Some(region)) + .unwrap(); + let inner = p + .add_element(Kind::Region, Layer::Sa, "Nested region", Some(a_element)) + .unwrap(); + let child_element = p + .add_element(Kind::State, Layer::Sa, "Working", Some(inner)) + .unwrap(); + let work = p + .add_element(Kind::Function, Layer::Sa, "Reusable work", None) + .unwrap(); + let action = p.relate(RelationKind::StateAction, "Entry work", a_element, work); + // The row references shared work, not ownership or a placed work target. + let other_action = p.relate( + RelationKind::StateAction, + "Other exit work", + b_element, + work, + ); + if let RelationBody::StateAction { phase, .. } = + &mut p.relations.get_mut(&other_action).unwrap().body + { + *phase = StateActionKind::Exit; + } + let internal_row = p.relate( + RelationKind::StateTransition, + "Internal check", + a_element, + a_element, + ); + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut p.relations.get_mut(&internal_row).unwrap().body + { + *transition_kind = TransitionKind::Internal; + } + let internal = p.relate( + RelationKind::StateTransition, + "Leave operating", + a_element, + b_element, + ); + if let RelationBody::StateTransition { + guard, + trigger, + effect, + .. + } = &mut p.relations.get_mut(&internal).unwrap().body + { + *guard = "temperature nominal\nand pressure stable".into(); + *trigger = "operator request".into(); + *effect = "record transition".into(); + } + let external = p.relate(RelationKind::StateTransition, "Stop", b_element, c_element); + let diagram = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine }; + let a = p.place(diagram, a_element, point(10.25, 20.5)); + let b = p.place(diagram, b_element, point(510.5, 40.25)); + p.place(diagram, c_element, point(900., 400.)); + let child = p.place(diagram, child_element, point(80.5, 240.25)); + let view = p.diagrams.get_mut(&diagram).unwrap(); + view.nodes.get_mut(&a).unwrap().size = oa_core::Size { + width: 360., + height: 420., + }; + view.routes = vec![ + route(internal, vec![point(450.25, 150.5)]), + route(external, vec![point(780.5, 300.25)]), + ]; + let other_view = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&other_view).unwrap().projection = ProjectionKind::StateMachine { machine }; + p.place(other_view, a_element, point(-100., -50.)); + p.place(other_view, b_element, point(400., -50.)); + let other_machine = p + .add_element(Kind::StateMachine, Layer::Sa, "Other machine", Some(system)) + .unwrap(); + let other_region = p + .add_element(Kind::Region, Layer::Sa, "Other region", Some(other_machine)) + .unwrap(); + let other_state = p + .add_element(Kind::State, Layer::Sa, "Other state", Some(other_region)) + .unwrap(); + let other_diagram = p.ensure_diagram(Layer::Sa); + p.diagrams.get_mut(&other_diagram).unwrap().projection = ProjectionKind::StateMachine { + machine: other_machine, + }; + p.place(other_diagram, other_state, point(25., 50.)); + let opposite = p + .add_element(Kind::Activity, Layer::Oa, "Operational context", None) + .unwrap(); + let architecture = p.ensure_diagram(Layer::Oa); + p.place(architecture, opposite, point(50., 75.)); + validate_candidate(&p).unwrap(); + let mut app = App::with_project(p); + app.page = Page::Behavior; + app.layer = Layer::Sa; + // Deliberately retain a different Architecture diagram. State group edits + // must resolve the live State context, never this legacy field. + app.active_diagram = Some(architecture); + app.behavior.active_owner = Some(machine); + app.behavior.diagram = crate::behavior::state_view::State { + diagram: Some(diagram), + ..Default::default() + }; + app.selected = diagram; + app.selected_node = None; + app.refresh_draft(); + app.reconcile_multiselect(); + assert_eq!(app.state_context().unwrap().diagram, diagram); + Fixture { + app, + diagram, + other_view, + other_machine, + machine, + a, + b, + child, + a_element, + b_element, + internal, + action, + internal_row, + } +} + +fn send(app: &mut App, event: SelectionEvent) { + let _ = app.update(Message::DiagramSelection(event)); +} +fn choose(app: &mut App, target: Target, choice: SelectionChoice) { + let context = app.multiselect_context().unwrap(); + send(app, SelectionEvent::Choose(context, target, choice)); +} +fn pair(f: &mut Fixture) { + choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Replace); + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Add); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a), Target::Placement(f.b)] + ); + assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); + assert_eq!(f.app.selected_node, None); +} +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() + ); +} + +#[test] +fn state_fractional_group_move_preserves_full_model_routes_primary_and_one_step_history() { + let mut f = fixture(); + pair(&mut f); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + let mut expected = f.app.project().clone(); + let view = expected.diagrams.get_mut(&f.diagram).unwrap(); + view.nodes.get_mut(&f.a).unwrap().position = point(17.5, 17.); + view.nodes.get_mut(&f.b).unwrap().position = point(517.75, 36.75); + view.nodes.get_mut(&f.child).unwrap().position = point(87.75, 236.75); + view.routes[0].waypoints = vec![point(457.5, 147.)]; + send( + &mut f.app, + SelectionEvent::Move(selection.clone(), point(7.25, -3.5)), + ); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.selected, f.b_element); + assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); + assert_eq!(f.app.selected_node, None); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selection.targets() + ); + source_matches(&f.app); + let after = f.app.document.snapshot(); + 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()); + assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); + 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 state_selected_ancestor_and_child_move_once_then_hide_only_selected_placements() { + let mut f = fixture(); + choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Replace); + choose(&mut f.app, Target::Placement(f.child), SelectionChoice::Add); + let before = f.app.document.snapshot(); + let mut expected = f.app.project().clone(); + let view = expected.diagrams.get_mut(&f.diagram).unwrap(); + view.nodes.get_mut(&f.a).unwrap().position = point(12.75, 24.25); + view.nodes.get_mut(&f.child).unwrap().position = point(83., 244.); + let selection = f.app.multiselect_snapshot().unwrap(); + send( + &mut f.app, + SelectionEvent::Move(selection, point(2.5, 3.75)), + ); + assert_eq!(f.app.project(), &expected); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + pair(&mut f); + let selection = f.app.multiselect_snapshot().unwrap(); + let mut expected = f.app.project().clone(); + let view = expected.diagrams.get_mut(&f.diagram).unwrap(); + view.nodes.remove(&f.a); + view.nodes.remove(&f.b); + send(&mut f.app, SelectionEvent::Hide(selection)); + assert_eq!( + f.app.project(), + &expected, + "Hide preserves the unselected child, routes, semantics and other views" + ); + assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!(f.app.behavior.diagram.placement, None); + assert_eq!(f.app.selected_node, None); + source_matches(&f.app); + let hidden = f.app.document.snapshot(); + 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()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &hidden.bundle); + assert!(!f.app.document.can_redo()); +} + +#[test] +fn state_rows_are_exact_relation_primaries_and_mixed_move_or_hide_refuses_whole_set() { + for kind in ["action", "internal row", "edge"] { + let mut f = fixture(); + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); + let target = match kind { + "action" => Target::StateRow { + placement: f.a, + relation: f.action, + }, + "internal row" => Target::StateRow { + placement: f.a, + relation: f.internal_row, + }, + _ => Target::Edge(f.internal), + }; + assert!(f.app.multiselect_target_visible(&target)); + choose(&mut f.app, target.clone(), SelectionChoice::Add); + let selection = f.app.multiselect_snapshot().unwrap(); + assert_eq!( + selection.targets(), + &[Target::Placement(f.b), target.clone()] + ); + assert_eq!(selection.primary(), Some(&target)); + assert_eq!(f.app.behavior.diagram.placement, None); + assert_eq!(f.app.selected_node, None); + let before = f.app.document.snapshot(); + for event in [ + SelectionEvent::Move(selection.clone(), point(7.25, 8.5)), + SelectionEvent::Hide(selection.clone()), + ] { + send(&mut f.app, event); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert!(!f.app.document.can_undo()); + } + // Even a row selected alone must not use its owner as a placement Hide. + choose(&mut f.app, target, SelectionChoice::Replace); + let row = f.app.multiselect_snapshot().unwrap(); + send(&mut f.app, SelectionEvent::Hide(row)); + assert_eq!(f.app.project(), &before.decoded.project); + assert!(!f.app.document.can_undo()); + } +} + +#[test] +fn state_primary_row_reassignment_or_replacement_preserves_exact_survivor_without_retarget() { + for row_kind in ["action", "internal"] { + for change in ["source", "placement", "collapse"] { + let mut f = fixture(); + let relation = if row_kind == "action" { + f.action + } else { + f.internal_row + }; + let old_row = Target::StateRow { + placement: f.a, + relation, + }; + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); + choose(&mut f.app, old_row.clone(), SelectionChoice::Add); + let stale = f.app.multiselect_snapshot().unwrap(); + let before = f.app.document.snapshot(); + let mut candidate = f.app.project().clone(); + match change { + "source" => match &mut candidate.relations.get_mut(&relation).unwrap().body { + RelationBody::StateAction { source, .. } => *source = f.b_element, + RelationBody::StateTransition { source, target, .. } => { + *source = f.b_element; + *target = f.b_element; + } + _ => unreachable!(), + }, + "placement" => { + let view = candidate.diagrams.get_mut(&f.diagram).unwrap(); + let mut replacement = view.nodes.remove(&f.a).unwrap(); + replacement.id = Id::new_v4(); + replacement.alias = "replacement_operating".into(); + view.nodes.insert(replacement.id, replacement); + } + _ => { + candidate + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&f.a) + .unwrap() + .collapsed = true + } + } + validate_candidate(&candidate).unwrap(); + let _ = f + .app + .update(Message::Command(Command::Replace(candidate.clone()))); + assert_eq!(f.app.project(), &candidate); + assert!(!f.app.multiselect_target_visible(&old_row)); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.b)] + ); + assert_eq!(f.app.selected, f.b_element); + assert_eq!(f.app.behavior.diagram.placement, Some(f.b)); + let live = f.app.multiselect_snapshot().unwrap(); + send(&mut f.app, SelectionEvent::Hide(stale)); + assert_eq!(f.app.project(), &candidate); + assert_eq!(f.app.multiselect_snapshot().unwrap(), live); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.b)] + ); + assert!(!f.app.document.can_undo()); + } + } +} + +#[test] +fn state_primary_placement_removal_or_same_semantic_replacement_preserves_other_member() { + for replace in [false, true] { + let mut f = fixture(); + pair(&mut f); + let mut candidate = f.app.project().clone(); + let view = candidate.diagrams.get_mut(&f.diagram).unwrap(); + let mut old = view.nodes.remove(&f.b).unwrap(); + if replace { + old.id = Id::new_v4(); + old.alias = "replacement_standby".into(); + view.nodes.insert(old.id, old); + } + let _ = f + .app + .update(Message::Command(Command::Replace(candidate.clone()))); + assert_eq!(f.app.project(), &candidate); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a)] + ); + assert_eq!(f.app.selected, f.a_element); + assert_eq!(f.app.behavior.diagram.placement, Some(f.a)); + assert_eq!(f.app.selected_node, None); + let _ = f.app.update(Message::Undo); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + &[Target::Placement(f.a)] + ); + } +} + +#[test] +fn state_stale_group_events_reject_before_status_selection_camera_allocation_or_history_changes() { + for nested in [false, true] { + for stale_by in [ + "document", + "revision", + "view", + "owner", + "tool", + "generation", + ] { + let mut f = fixture(); + 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.a_element, "New revision".into())) + .unwrap(); + } + "view" => { + let _ = f.app.update(Message::BehaviorDiagram(super::Choice { + id: f.other_view, + label: "Other saved view".into(), + })); + } + "owner" => { + let _ = f.app.update(Message::BehaviorOwner(super::Choice { + id: f.other_machine, + label: "Other machine".into(), + })); + } + "tool" => { + let _ = f + .app + .update(Message::StateDiagram(StateEvent::Choose(Tool::Transition))); + } + _ => { + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Toggle); + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Toggle); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + stale.targets() + ); + } + } + f.app.status = "Current feedback".into(); + f.app.allocation_drag.arrange_next = true; + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot(); + let selected = ( + f.app.selected, + f.app.selected_node, + f.app.behavior.diagram.placement, + ); + let epochs = ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch, + ); + let camera = ( + f.app.behavior.diagram.zoom, + f.app.behavior.diagram.pan, + f.app.behavior.diagram.fit, + ); + let history = (f.app.document.can_undo(), f.app.document.can_redo()); + let draft = f + .app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + for event in [ + SelectionEvent::Move(stale.clone(), point(100., 200.)), + SelectionEvent::Hide(stale.clone()), + SelectionEvent::Collapse(stale.clone(), Target::Placement(f.a)), + SelectionEvent::Refuse(stale.clone(), "Stale refusal".into()), + ] { + let message = if nested { + Message::StateDiagram(StateEvent::Selection(event)) + } else { + Message::DiagramSelection(event) + }; + let _ = f.app.update(message); + assert_eq!( + f.app.project(), + &before.decoded.project, + "{stale_by}, nested={nested}" + ); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.multiselect_snapshot(), selection); + assert_eq!( + ( + f.app.selected, + f.app.selected_node, + f.app.behavior.diagram.placement + ), + selected + ); + assert_eq!( + ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch + ), + epochs + ); + assert_eq!( + ( + f.app.behavior.diagram.zoom, + f.app.behavior.diagram.pan, + f.app.behavior.diagram.fit + ), + camera + ); + assert_eq!( + (f.app.document.can_undo(), f.app.document.can_redo()), + history + ); + assert_eq!( + f.app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + assert!(f.app.allocation_drag.arrange_next); + assert_eq!(f.app.status, "Current feedback"); + } + } + } +} + +#[test] +fn state_pending_property_source_and_state_form_inputs_are_preserved_without_implicit_apply() { + for pending in [ + "valid properties", + "invalid properties", + "source", + "State form", + ] { + let mut f = fixture(); + pair(&mut f); + match pending { + "valid properties" => { + let _ = f + .app + .update(Message::Field("/name".into(), "Retained name".into())); + } + "invalid properties" => { + let _ = f + .app + .update(Message::Field("/alias".into(), "invalid alias!".into())); + } + "source" => { + let text = format!( + "{}\n// Retained raw 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)), + ))); + } + _ => { + let _ = f.app.update(Message::Behavior(behavior::Event::States( + states::Event::Edit(f.internal), + ))); + let _ = f.app.update(Message::Behavior(behavior::Event::States( + states::Event::Guard("Retained raw guard".into()), + ))); + } + } + assert!( + f.app.draft_dirty() || f.app.source_dirty() || f.app.behavior.has_pending(), + "{pending}" + ); + let snapshot = 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(); + let selected = ( + f.app.selected, + f.app.selected_node, + f.app.behavior.diagram.placement, + ); + for event in [ + SelectionEvent::Move(snapshot.clone(), point(7.25, 9.5)), + SelectionEvent::Hide(snapshot.clone()), + SelectionEvent::Clear(snapshot.context()), + SelectionEvent::Choose( + snapshot.context(), + Target::Placement(f.a), + SelectionChoice::Toggle, + ), + ] { + send(&mut f.app, event); + assert_eq!(f.app.project(), &before.decoded.project, "{pending}"); + assert_eq!(f.app.document.bundle(), &before.bundle); + 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.selected, + f.app.selected_node, + f.app.behavior.diagram.placement + ), + selected + ); + assert_eq!(f.app.multiselect_snapshot().unwrap(), snapshot); + assert!(!f.app.document.can_undo()); + } + } +} + +#[test] +fn state_nonselect_tool_denies_current_group_selection_and_commands() { + let mut f = fixture(); + pair(&mut f); + let _ = f + .app + .update(Message::StateDiagram(StateEvent::Choose(Tool::Transition))); + assert_eq!(f.app.behavior.diagram.tool, Tool::Transition); + let current = f.app.multiselect_snapshot().unwrap(); + let before = f.app.document.snapshot(); + for event in [ + SelectionEvent::Move(current.clone(), point(7.25, 9.5)), + SelectionEvent::Hide(current.clone()), + SelectionEvent::Clear(current.context()), + SelectionEvent::Choose( + current.context(), + Target::Placement(f.a), + SelectionChoice::Toggle, + ), + ] { + send(&mut f.app, event); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.multiselect_snapshot().unwrap(), current); + assert_eq!(f.app.behavior.diagram.tool, Tool::Transition); + assert!(!f.app.document.can_undo()); + } +} + +#[test] +fn state_queued_singleton_move_and_removal_cannot_edit_only_a_member_of_a_group() { + use crate::diagram_usage::{self as usage, Surface}; + let mut f = fixture(); + pair(&mut f); + // Use a real, currently valid navigation marker so late shared hooks cannot + // explain its retirement as cleanup of an invalid sentinel. + f.app.active_diagram = Some(f.diagram); + let marker = usage::collect( + f.app.project(), + f.b_element, + &f.app.behavior.topology, + &f.app.behavior.scenario, + ) + .into_iter() + .find(|u| u.surface == Surface::Saved(f.diagram) && u.target == Target::Placement(f.b)) + .unwrap(); + let reveal_context = usage::Context { + document: f.app.canvas_document, + revision: f.app.document.revision(), + subject: f.b_element, + }; + f.app.pending_diagram_reveal = Some((reveal_context, marker.clone(), f.app.canvas_epoch)); + f.app.diagram_navigation_focus = Some(usage::Focus { + context: reveal_context, + usage: marker, + selected: f.app.selected, + }); + assert!(f.app.diagram_focus(Surface::Saved(f.diagram)).is_some()); + f.app.status = "Current group feedback".into(); + f.app.allocation_drag.arrange_next = true; + let context = f.app.state_context().unwrap(); + let selection = f.app.multiselect_snapshot().unwrap(); + let before = f.app.document.snapshot(); + let pending = f.app.pending_diagram_reveal.clone(); + let focus = f + .app + .diagram_navigation_focus + .as_ref() + .map(|f| (f.context, f.usage.clone(), f.selected)); + let epochs = ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch, + ); + let camera = ( + f.app.behavior.diagram.zoom, + f.app.behavior.diagram.pan, + f.app.behavior.diagram.fit, + ); + let selected = ( + f.app.selected, + f.app.selected_node, + f.app.behavior.diagram.placement, + ); + // These events deliberately have the live document/view/epoch context. + // Group admission must reject them before navigation and allocation hooks. + for message in [ + Message::StateDiagram(StateEvent::Select(context, f.a)), + Message::StateDiagram(StateEvent::Move { + context, + node: f.b, + position: point(700., 500.), + }), + Message::StateDiagram(StateEvent::Remove(diagram_removal::Intent { + context, + object: f.b_element, + placement: Some(f.b), + model: false, + })), + ] { + let _ = f.app.update(message); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.multiselect_snapshot().unwrap(), selection); + assert_eq!(f.app.pending_diagram_reveal, pending); + assert_eq!( + f.app.diagram_navigation_focus.as_ref().map(|f| ( + f.context, + f.usage.clone(), + f.selected + )), + focus + ); + assert_eq!( + ( + f.app.canvas_epoch, + f.app.camera_epoch, + f.app.allocation_drag.epoch + ), + epochs + ); + assert_eq!( + ( + f.app.behavior.diagram.zoom, + f.app.behavior.diagram.pan, + f.app.behavior.diagram.fit + ), + camera + ); + assert_eq!( + ( + f.app.selected, + f.app.selected_node, + f.app.behavior.diagram.placement + ), + selected + ); + assert_eq!(f.app.status, "Current group feedback"); + assert!(f.app.allocation_drag.arrange_next); + assert!(!f.app.delete_pending); + assert!(!f.app.document.can_undo()); + assert!(!f.app.document.can_redo()); + } + // Retain the existing model-level checks for generic singleton removal. + for message in [ + Message::DiagramRemove(diagram_removal::Intent { + context, + object: f.b_element, + placement: Some(f.b), + model: true, + }), + Message::Delete, + ] { + let _ = f.app.update(message); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selection.targets() + ); + assert!(!f.app.delete_pending); + assert!(!f.app.document.can_undo()); + } +} + +#[test] +fn state_boundary_entry_exit_group_moves_use_authored_positions_and_boundary_owner_pin_rules() { + for kind in [Kind::EntryPoint, Kind::ExitPoint] { + for boundary in ["fixed", "moved", "omitted"] { + let mut f = fixture(); + let mut p = f.app.project().clone(); + let child_element = p.diagrams[&f.diagram].nodes[&f.child].element; + let region = p.elements[&child_element].parent.unwrap(); + let symbol = p + .add_element(kind, Layer::Sa, "Boundary symbol", Some(region)) + .unwrap(); + let edge = if kind == Kind::EntryPoint { + p.relate( + RelationKind::StateTransition, + "Enter", + symbol, + child_element, + ) + } else { + p.relate(RelationKind::StateTransition, "Exit", child_element, symbol) + }; + let placement = p.place(f.diagram, symbol, point(4000.25, 300.5)); + p.diagrams + .get_mut(&f.diagram) + .unwrap() + .routes + .push(route(edge, vec![point(140.25, 320.5)])); + // Keep the authored symbol inside the existing boundary so the + // projection does not independently grow that boundary to enclose it. + p.diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&f.a) + .unwrap() + .size + .width = 5000.; + if boundary == "omitted" { + p.diagrams.get_mut(&f.diagram).unwrap().nodes.remove(&f.a); + } + validate_candidate(&p).unwrap(); + let architecture = f.app.active_diagram; + f.app = App::with_project(p); + f.app.page = Page::Behavior; + f.app.layer = Layer::Sa; + f.app.active_diagram = architecture; + f.app.behavior.active_owner = Some(f.machine); + f.app.behavior.diagram = crate::behavior::state_view::State { + diagram: Some(f.diagram), + ..Default::default() + }; + f.app.selected = f.diagram; + f.app.refresh_draft(); + f.app.reconcile_multiselect(); + choose( + &mut f.app, + Target::Placement(placement), + SelectionChoice::Replace, + ); + choose(&mut f.app, Target::Placement(f.child), SelectionChoice::Add); + if boundary == "moved" { + choose(&mut f.app, Target::Placement(f.a), SelectionChoice::Add); + } + let before = f.app.document.snapshot(); + let projected = projection::project_view(f.app.project(), f.diagram); + let old_symbol = projected + .nodes + .iter() + .find(|n| n.id == placement) + .unwrap() + .position; + if boundary != "omitted" { + assert_ne!( + old_symbol.x, 4000.25, + "authored anchor differs from projected boundary" + ); + } + let mut expected = f.app.project().clone(); + let view = expected.diagrams.get_mut(&f.diagram).unwrap(); + view.nodes.get_mut(&placement).unwrap().position = point(4007.5, 305.25); + view.nodes.get_mut(&f.child).unwrap().position = point(87.75, 245.); + if boundary == "moved" { + view.nodes.get_mut(&f.a).unwrap().position = point(17.5, 25.25); + } + if boundary != "fixed" { + view.routes.last_mut().unwrap().waypoints = vec![point(147.5, 325.25)]; + } + let snapshot = f.app.multiselect_snapshot().unwrap(); + send( + &mut f.app, + SelectionEvent::Move(snapshot, point(7.25, 4.75)), + ); + assert_eq!(f.app.project(), &expected, "{kind:?}/{boundary}"); + let after = projection::project_view(f.app.project(), f.diagram); + let new_symbol = after + .nodes + .iter() + .find(|n| n.id == placement) + .unwrap() + .position; + assert_eq!( + new_symbol.x, + old_symbol.x + if boundary == "fixed" { 0. } else { 7.25 } + ); + source_matches(&f.app); + 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()); + } + } +} + +#[tokio::test] +async fn state_actions_trigger_captures_exact_row_without_owner_placement_and_stale_menu_cannot_retarget() + { + use crate::{ + context_menu::Entry, + model_menu::{self, Event as MenuEvent}, + }; + use iced::advanced::renderer::Headless; + use iced::{Event, Point, Size, mouse}; + use iced_runtime::user_interface::{Cache, UserInterface}; + let mut renderer = ::new(iced::Font::DEFAULT, 16.into(), None) + .await + .expect("State Actions trigger requires the supported headless renderer"); + for internal in [false, true] { + let mut f = fixture(); + let relation = if internal { f.internal_row } else { f.action }; + let row = Target::StateRow { + placement: f.a, + relation, + }; + choose(&mut f.app, Target::Placement(f.b), SelectionChoice::Replace); + choose(&mut f.app, row.clone(), SelectionChoice::Add); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + // Exercise the real Actions button closure, not a reconstruction of its + // Request: the typed row must survive without the owner's placement. + let mut messages = vec![]; + { + let mut ui = UserInterface::build( + model_menu::trigger(&f.app), + Size::new(800., 100.), + Cache::new(), + &mut renderer, + ); + let _ = ui.update( + &[Event::Mouse(mouse::Event::ButtonPressed( + mouse::Button::Left, + ))], + mouse::Cursor::Available(Point::new(5., 5.)), + &mut renderer, + &mut iced::advanced::clipboard::Null, + &mut messages, + ); + } + assert_eq!(messages.len(), 1); + let Message::Menu(MenuEvent::Open(request)) = messages.remove(0) else { + panic!("Actions opens a menu") + }; + assert_eq!(request.object, relation); + assert_eq!(request.placement, None); + assert_eq!(request.selection_target, Some(row.clone())); + assert_eq!(request.canvas, f.app.state_context()); + let mut wrong = request.clone(); + wrong.placement = Some(f.a); + assert!(model_menu::Opened::new(&f.app, wrong, 1).is_none()); + let mut wrong = request.clone(); + wrong.object = f.a_element; + assert!(model_menu::Opened::new(&f.app, wrong, 1).is_none()); + let _ = f + .app + .update(Message::Menu(MenuEvent::Open(request.clone()))); + let opened = f.app.model_menu.as_ref().unwrap(); + assert_eq!(opened.request.selection_target, Some(row.clone())); + assert_eq!(opened.request.placement, None); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selection.targets() + ); + assert_eq!(f.app.multiselect_snapshot().unwrap().primary(), Some(&row)); + assert_eq!(f.app.behavior.diagram.placement, None); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + let entries = model_menu::popup(&f.app).unwrap().entries; + assert!(entries.iter().any(|entry| matches!(entry, Entry::Action { label, message: None, .. } if label == "Hide selected placements"))); + let invoke = entries + .into_iter() + .find_map(|entry| match entry { + Entry::Action { label, message, .. } if label == "Select only this element" => { + message + } + _ => None, + }) + .expect("row group menu exposes exact selection command"); + // Keep the captured menu object alive across a model revision, so this + // checks its validation instead of relying on proactive menu dismissal. + let mut candidate = f.app.project().clone(); + match &mut candidate.relations.get_mut(&relation).unwrap().body { + RelationBody::StateAction { source, .. } => *source = f.b_element, + RelationBody::StateTransition { source, target, .. } => { + *source = f.b_element; + *target = f.b_element; + } + _ => unreachable!(), + } + f.app + .document + .execute(Command::Replace(candidate.clone())) + .unwrap(); + assert!(!f.app.model_menu.as_ref().unwrap().valid(&f.app)); + let source = f.app.document.bundle().clone(); + let current = f.app.multiselect_snapshot().unwrap(); + let _ = f.app.update(invoke); + assert_eq!(f.app.project(), &candidate); + assert_eq!(f.app.document.bundle(), &source); + assert_eq!(f.app.multiselect_snapshot().unwrap(), current); + assert_eq!(f.app.selected, relation); + assert_eq!(f.app.behavior.diagram.placement, None); + assert!(f.app.model_menu.is_none()); + let mut stale_row = request; + stale_row.canvas = f.app.state_context(); + assert!( + model_menu::Opened::new(&f.app, stale_row.clone(), 2).is_none(), + "a fresh envelope cannot rebind the old row occurrence" + ); + stale_row.selection_target = Some(Target::StateRow { + placement: f.b, + relation, + }); + assert!( + model_menu::Opened::new(&f.app, stale_row, 2).is_some(), + "the replacement occurrence is independently visible" + ); + } +} diff --git a/crates/oa-desktop/src/behavior/state_bend_tests.rs b/crates/oa-desktop/src/behavior/state_bend_tests.rs index f4635ab9..cac001f0 100644 --- a/crates/oa-desktop/src/behavior/state_bend_tests.rs +++ b/crates/oa-desktop/src/behavior/state_bend_tests.rs @@ -391,7 +391,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() { motion(&canvas, &mut state, pin, pin); assert!( matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin), - Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None }) + Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. }) if context == canvas.context && object == relation && anchor == pin) ); let (index, midpoint) = bend_segment(&scene, relation); @@ -399,7 +399,7 @@ fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() { motion(&canvas, &mut state, at, at); assert!( matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at), - Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) }) + Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. }) if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001) ); } diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 29be9c4b..0767bac7 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -1,5 +1,11 @@ //! Persisted state-diagram geometry with context-bound, ordered pointer gestures. use super::state_edit::{self, Context, Endpoint, Event, Tool}; +use crate::app::multiselect::{Choice, Event as SelectionEvent, context as selection_context}; +use crate::diagram::group_gesture::{ + DisplacementPolicy, Gesture as GroupGesture, Outcome, Viewport, +}; +use crate::diagram_selection::{Selection, Snapshot}; +use crate::diagram_usage::Target; use crate::style; use iced::{ Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse, @@ -27,6 +33,7 @@ pub struct StateCanvas<'a> { pub diagram: Id, pub context: Context, pub selected: Id, + pub selection: Option<&'a Selection>, /// Presentation-only navigation marker; never an authoring placement. pub navigation_focus: Option, pub tool: Tool, @@ -41,6 +48,8 @@ pub struct State { tool: Option, pointer: Option, press: Option, + group: Option, + modifiers: keyboard::Modifiers, source: Option, preview: Option<(Id, Position)>, attachment_preview: Option, @@ -113,6 +122,7 @@ struct Scene { } struct CachedScene { context: Context, + group_preview: Option<(Snapshot, Position)>, preview: Option<(Id, Position)>, attachment_preview: Option, bend_preview: Option, @@ -121,6 +131,8 @@ struct CachedScene { #[derive(PartialEq)] struct PaintKey { context: Context, + group_preview: Option<(Snapshot, Position)>, + selection: Option, preview: Option<(Id, Position)>, attachment_preview: Option, bend_preview: Option, @@ -132,12 +144,82 @@ struct PaintKey { enum Hit { Node { placement: Id, element: Id }, Transition(Id), - StateAction(Id), + Row { placement: Id, relation: Id }, +} + +impl Hit { + fn target(self) -> Target { + match self { + Self::Node { placement, .. } => Target::Placement(placement), + Self::Transition(id) => Target::Edge(id), + Self::Row { + placement, + relation, + } => Target::StateRow { + placement, + relation, + }, + } + } +} + +/// Unsupported mixtures retain the same click/cancel lifecycle as placement groups. +struct GroupPress { + snapshot: Snapshot, + target: Target, + start: Point, + viewport: Viewport, + movement: Option, + crossed: bool, + retired: bool, +} + +impl GroupPress { + fn move_to(&mut self, pointer: Point) { + if self.retired { + return; + } + if !pointer.x.is_finite() + || !pointer.y.is_finite() + || !self.viewport.bounds.contains(pointer) + { + self.retired = true; + return; + } + self.crossed |= pointer.distance(self.start) > 3.; + if let Some(movement) = &mut self.movement + && !movement.move_to(pointer) + { + self.retired = true; + } + } + + fn release(mut self, pointer: Point) -> Option { + self.move_to(pointer); + if self.retired { + return None; + } + if !self.crossed { + return Some(SelectionEvent::Collapse(self.snapshot, self.target)); + } + let Some(movement) = self.movement else { + return Some(SelectionEvent::Refuse(self.snapshot, + "Move requires only placed elements. Remove rows, ports and transitions from the selection.".into())); + }; + match movement.release(pointer)? { + Outcome::Click(id) => Some(SelectionEvent::Collapse( + self.snapshot, + Target::Placement(id), + )), + Outcome::Move(snapshot, delta) => Some(SelectionEvent::Move(snapshot, delta)), + } + } } impl State { fn cancel_edit(&mut self) { self.press = None; + self.group = None; self.preview = None; self.attachment_preview = None; self.bend = None; @@ -485,6 +567,32 @@ pub(crate) fn visible_row_relation( }) } +/// Validate this occurrence, including the relation's current source. A surviving +/// relation reassigned to another State must not rebound an old row selection. +pub(crate) fn visible_row_target( + project: &Project, + diagram: Id, + view: &ProjectedView, + placement: Id, + relation: Id, +) -> bool { + visible_row_relation(project, diagram, view, relation) + && view + .nodes + .iter() + .find(|node| node.id == placement) + .is_some_and(|node| { + project + .relations + .get(&relation) + .is_some_and(|row| row.source() == node.element) + && state_action_rows(project, node) + .into_iter() + .chain(internal_rows(project, node)) + .any(|(row, _)| row.id == relation) + }) +} + fn state_action_label(project: &Project, relation: &Relation) -> String { let RelationBody::StateAction { target, phase, .. } = relation.body else { return String::new(); @@ -663,6 +771,210 @@ pub(crate) fn diagram_usages( } impl StateCanvas<'_> { + fn current_selection(&self) -> Option<&Selection> { + self.selection + .filter(|s| s.scope() == selection_context(self.context).scope) + } + + fn group_viewport(&self, bounds: Rectangle) -> Viewport { + Viewport { + zoom: self.scale(), + pan: self.pan, + bounds: Rectangle::new( + Point::new(bounds.x, bounds.y + GUIDANCE_HEIGHT), + Self::content(bounds).size(), + ), + } + } + + fn group_current(&self, group: &GroupPress, bounds: Option) -> bool { + !group.retired + && self.editable + && self.tool == Tool::Select + && group.viewport.zoom == self.scale() + && group.viewport.pan == self.pan + && bounds.is_none_or(|b| group.viewport == self.group_viewport(b)) + && self + .current_selection() + .is_some_and(|s| s.matches(&group.snapshot, selection_context(self.context))) + } + + fn group_preview_key( + &self, + state: &State, + bounds: Option, + ) -> Option<(Snapshot, Position)> { + let group = state.group.as_ref()?; + if !self.group_current(group, bounds) || !group.crossed { + return None; + } + Some((group.snapshot.clone(), group.movement.as_ref()?.delta())) + } + + fn group_input( + &self, + state: &mut State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + if let Some(group) = &state.group { + let cancelled = matches!( + event, + canvas::Event::Mouse( + mouse::Event::CursorLeft + | mouse::Event::WheelScrolled { .. } + | mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle) + ) | canvas::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + .. + }) + ); + if !self.group_current(group, Some(bounds)) || cancelled { + state.cancel_edit(); + return Some(Action::request_redraw().and_capture()); + } + } + if let Some(group) = &mut state.group { + match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + group.move_to(*position); + return Some(Action::request_redraw().and_capture()); + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let group = state.group.take().unwrap(); + return Some( + cursor + .position() + .and_then(|p| group.release(p)) + .map_or_else( + || Action::request_redraw().and_capture(), + |event| Action::publish(Event::Selection(event)).and_capture(), + ), + ); + } + canvas::Event::Keyboard(_) + | canvas::Event::Mouse(mouse::Event::ButtonPressed(_)) => { + return Some(Action::capture()); + } + _ => {} + } + } + let selection = self.current_selection()?; + if !self.editable || self.tool != Tool::Select || state.pan_from.is_some() { + return None; + } + if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { + use keyboard::{Key, key::Named}; + if *key == Key::Named(Named::Escape) && state.press.is_none() && state.source.is_none() + { + return Some( + Action::publish(Event::Selection(SelectionEvent::Clear(selection_context( + self.context, + )))) + .and_capture(), + ); + } + if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) { + let snapshot = selection.snapshot(selection_context(self.context))?; + let event = if modifiers.is_empty() + && crate::app::multiselect::placements(&snapshot).is_some() + { + SelectionEvent::Hide(snapshot) + } else { + SelectionEvent::Refuse(snapshot, if modifiers.is_empty() { + "Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group." + } else { + "Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements." + }.into()) + }; + return Some(Action::publish(Event::Selection(event)).and_capture()); + } + } + if !matches!( + event, + canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) { + return None; + } + let window = cursor + .position() + .filter(|p| self.group_viewport(bounds).bounds.contains(*p))?; + let local = Point::new(window.x - bounds.x, window.y - bounds.y); + let scene = self.scene(state)?; + let world = self.world(local); + // Route handles belong to the exact edge, including when an endpoint + // overlaps a node. They cannot bypass Shift/group admission. + let handle = self + .endpoint_at(&scene, world) + .map(|(id, _)| Target::Edge(id)) + .or_else(|| { + self.bend_at(&scene, world) + .map(|_| Target::Edge(self.selected)) + }); + let target = handle.or_else(|| self.hit(&scene, world).map(Hit::target))?; + if state.modifiers.shift() { + state.cancel_edit(); + return Some( + Action::publish(Event::Selection(SelectionEvent::Choose( + selection_context(self.context), + target, + Choice::Toggle, + ))) + .and_capture(), + ); + } + if selection.targets().len() <= 1 || !selection.contains(&target) { + return None; + } + let snapshot = selection.snapshot(selection_context(self.context))?; + let viewport = self.group_viewport(bounds); + let movement = if crate::app::multiselect::placements(&snapshot).is_some() + && let Target::Placement(anchor) = target + { + let original = self + .project + .diagrams + .get(&self.diagram)? + .nodes + .get(&anchor)? + .position; + Some(GroupGesture::new_with_policy( + snapshot.clone(), + anchor, + original, + window, + viewport, + DisplacementPolicy::Unsnapped, + )?) + } else { + None + }; + state.cancel_edit(); + state.group = Some(GroupPress { + snapshot, + target, + start: window, + viewport, + movement, + crossed: false, + retired: false, + }); + Some(Action::request_redraw().and_capture()) + } + + fn select_event(&self, hit: Hit, semantic: Id) -> Event { + if self.current_selection().is_some() { + Event::Selection(SelectionEvent::Choose( + selection_context(self.context), + hit.target(), + Choice::Replace, + )) + } else { + Event::Select(self.context, semantic) + } + } + fn scale(&self) -> f32 { if self.zoom.is_finite() { self.zoom.clamp(0.0001, 3.) @@ -695,6 +1007,7 @@ impl StateCanvas<'_> { return None; } let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds); + let group_preview = self.group_preview_key(state, bounds); let preview = self .gesture_active(state) .then_some(state.preview) @@ -703,6 +1016,7 @@ impl StateCanvas<'_> { let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten(); if let Some(cached) = state.scene.borrow().as_ref() && cached.context == self.context + && cached.group_preview == group_preview && cached.preview == preview && cached.attachment_preview == attachment_preview && cached.bend_preview == bend_preview @@ -710,6 +1024,27 @@ impl StateCanvas<'_> { return Some(cached.scene.clone()); } let mut diagram = self.project.diagrams.get(&self.diagram)?.clone(); + if let Some((snapshot, delta)) = &group_preview + && let Some(ids) = crate::app::multiselect::placements(snapshot) + && let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements( + self.project, + self.diagram, + &ids, + *delta, + ) + { + for command in commands { + match command { + Command::EditNode { node, .. } => { + diagram.nodes.insert(node.id, node); + } + Command::SetEdgeRoute { route, .. } => { + crate::route_edit::with_route(&mut diagram, route) + } + _ => unreachable!("placement planner returned a non-geometry command"), + } + } + } if let Some(route) = &bend_preview { crate::route_edit::with_route(&mut diagram, route.clone()); } @@ -761,6 +1096,7 @@ impl StateCanvas<'_> { }); *state.scene.borrow_mut() = Some(CachedScene { context: self.context, + group_preview, preview, attachment_preview, bend_preview, @@ -823,13 +1159,19 @@ impl StateCanvas<'_> { .into_iter() .find(|(_, r)| r.contains(world)) { - return Some(Hit::StateAction(relation.id)); + return Some(Hit::Row { + placement: node.id, + relation: relation.id, + }); } if let Some((relation, _)) = internal_rows(self.project, node) .into_iter() .find(|(_, r)| r.contains(world)) { - return Some(Hit::Transition(relation.id)); + return Some(Hit::Row { + placement: node.id, + relation: relation.id, + }); } let container = self .project @@ -1148,7 +1490,13 @@ impl StateCanvas<'_> { }, _ => match world.and_then(|p| self.hit(scene, p)) { Some(Hit::Transition(id)) => self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default(), - Some(Hit::StateAction(id)) => self.project.relations.get(&id).map(|r| format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r))).unwrap_or_default(), + Some(Hit::Row { relation: id, .. }) => self.project.relations.get(&id).map(|r| { + if matches!(r.body, RelationBody::StateAction { .. }) { + format!("{} · select the action link to inspect its referenced work", state_action_label(self.project, r)) + } else { + format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r)) + } + }).unwrap_or_default(), _ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(), }, } @@ -1172,6 +1520,15 @@ impl canvas::Program for StateCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + if let canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event { + // Passive observation must not capture the event or grant focus. + state.modifiers = *modifiers; + return None; + } + let retired_group = state.group.is_some() + && (state.context != Some(self.context) + || state.tool != Some(self.tool) + || !self.editable); if state.context != Some(self.context) || state.tool != Some(self.tool) { if state.context != Some(self.context) { state.viewport = None; @@ -1226,6 +1583,7 @@ impl canvas::Program for StateCanvas<'_> { cursor } canvas::Event::Window(iced::window::Event::Unfocused) => { + state.modifiers = keyboard::Modifiers::default(); state.reset(); return None; } @@ -1239,6 +1597,14 @@ impl canvas::Program for StateCanvas<'_> { let window = cursor.position(); let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); let inside = local.is_some_and(|p| Self::content(bounds).contains(p)); + if retired_group { + // The event which retired a held gesture cannot be reinterpreted as + // a fresh keyboard edit against the surviving selection. + return Some(Action::request_redraw().and_capture()); + } + if let Some(action) = self.group_input(state, event, bounds, cursor) { + return Some(action); + } if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event { use keyboard::{Key, key::Named}; if *key == Key::Named(Named::Escape) { @@ -1310,13 +1676,17 @@ impl canvas::Program for StateCanvas<'_> { // Menu commands must target the committed route instead. let scene = self.scene(state)?; let bend = self.bend_at(&scene, world); - let (object, placement) = if bend.is_some() { - (self.selected, None) + let (object, placement, selection_target) = if bend.is_some() { + (self.selected, None, Some(Target::Edge(self.selected))) } else { match self.hit(&scene, world) { - Some(Hit::Node { element, placement }) => (element, Some(placement)), - Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None), - None => (self.diagram, None), + Some(hit @ Hit::Node { element, placement }) => { + (element, Some(placement), Some(hit.target())) + } + Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => { + (id, None, Some(hit.target())) + } + None => (self.diagram, None, None), } }; let route_segment = if bend.is_none() { @@ -1329,6 +1699,7 @@ impl canvas::Program for StateCanvas<'_> { context: self.context, object, placement, + selection_target, anchor: window, bend, route_segment, @@ -1336,12 +1707,17 @@ impl canvas::Program for StateCanvas<'_> { .and_capture(), ); } - if *button != mouse::Button::Left || !self.editable { + if *button != mouse::Button::Left || !self.editable || state.pan_from.is_some() { return None; } match self.tool { Tool::Select => { - if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) { + let singleton = self + .current_selection() + .is_none_or(|s| s.targets().len() <= 1); + if singleton + && let Some((relation, endpoint)) = self.endpoint_at(&scene, world) + { state.press = Some(Press::Reconnect { relation, endpoint, @@ -1349,7 +1725,7 @@ impl canvas::Program for StateCanvas<'_> { }); return Some(Action::capture()); } - if let Some(index) = self.bend_at(&scene, world) { + if singleton && let Some(index) = self.bend_at(&scene, world) { let route = crate::route_edit::stored_route( &self.project.diagrams[&self.diagram], self.selected, @@ -1358,7 +1734,7 @@ impl canvas::Program for StateCanvas<'_> { return Some(Action::capture()); } match self.hit(&scene, world) { - Some(Hit::Node { placement, element }) => { + Some(hit @ Hit::Node { placement, element }) => { let original = self .project .diagrams @@ -1371,13 +1747,11 @@ impl canvas::Program for StateCanvas<'_> { start: window, original, }); - Some( - Action::publish(Event::Select(self.context, element)) - .and_capture(), - ) + Some(Action::publish(self.select_event(hit, element)).and_capture()) } - Some(Hit::Transition(id) | Hit::StateAction(id)) => { - if id == self.selected + Some(hit @ (Hit::Transition(id) | Hit::Row { relation: id, .. })) => { + if singleton + && id == self.selected && let Some(edge) = scene.routed.edges.get(&id) && !Rectangle::new( point(edge.label.bounds.position), @@ -1407,7 +1781,7 @@ impl canvas::Program for StateCanvas<'_> { return Some(Action::capture()); } } - Some(Action::publish(Event::Select(self.context, id)).and_capture()) + Some(Action::publish(self.select_event(hit, id)).and_capture()) } None => { state.pan_from = Some((mouse::Button::Left, window)); @@ -1643,6 +2017,10 @@ impl canvas::Program for StateCanvas<'_> { self.gesture_active(state) && self.route_viewport_current(state, Some(bounds)); let key = PaintKey { context: self.context, + group_preview: self.group_preview_key(state, Some(bounds)), + selection: self + .current_selection() + .and_then(|s| s.snapshot(selection_context(self.context))), preview: self .gesture_active(state) .then_some(state.preview) @@ -1671,23 +2049,40 @@ impl canvas::Program for StateCanvas<'_> { frame.translate(self.offset()); frame.scale(self.scale()); for node in &scene.view.nodes { + let selected = + self.current_selection() + .map_or(self.selected, |s| match s.primary() { + Some(Target::Placement(id)) if *id == node.id => node.element, + Some(Target::StateRow { + placement, + relation, + }) if *placement == node.id => *relation, + _ => Id::nil(), + }); paint_node( frame, self.project, node, - self.selected, + selected, scene.symbol_labels.get(&node.id), ); } // Containers are already painted. Route bodies have shared // clearance from state headers and internal-transition rows. for edge in scene.routed.edges.values() { - let active = edge.id == self.selected; + let active = self + .current_selection() + .map_or(edge.id == self.selected, |s| { + s.contains(&Target::Edge(edge.id)) + }); + let primary = self + .current_selection() + .map_or(active, |s| s.primary() == Some(&Target::Edge(edge.id))); let color = if active { style::TEAL } else { style::INK }; frame.stroke( &polyline(&edge.points), Stroke::default().with_color(color).with_width(if active { - 2.4 + if primary { 3.2 } else { 2.0 } } else { 1.4 }), @@ -1727,7 +2122,11 @@ impl canvas::Program for StateCanvas<'_> { 10., style::MUTED, ); - if active { + if active + && self + .current_selection() + .is_none_or(|s| s.targets().len() <= 1) + { if !edge.custom_suspended && self.tool == Tool::Select { for pin in &edge.pins { let radius = 4. / self.scale(); @@ -1758,6 +2157,42 @@ impl canvas::Program for StateCanvas<'_> { } } } + if let Some(selection) = self.current_selection() { + for target in selection.targets() { + let rect = match target { + Target::Placement(id) => { + scene.view.nodes.iter().find(|n| n.id == *id).map(rectangle) + } + Target::StateRow { + placement, + relation, + } => scene + .view + .nodes + .iter() + .find(|n| n.id == *placement) + .and_then(|node| { + state_action_rows(self.project, node) + .into_iter() + .chain(internal_rows(self.project, node)) + .find(|(row, _)| row.id == *relation) + .map(|(_, rect)| rect) + }), + _ => None, + }; + if let Some(rect) = rect { + let primary = selection.primary() == Some(target); + let rect = + expanded(rect, if primary { 3. } else { 1.5 } / self.scale()); + frame.stroke( + &Path::rectangle(rect.position(), rect.size()), + Stroke::default() + .with_color(style::TEAL) + .with_width(if primary { 3. } else { 1.5 } / self.scale()), + ); + } + } + } }); }); let mut overlay = Frame::new(renderer, bounds.size()); @@ -2159,6 +2594,7 @@ mod tests { use crate::document::Document; include!("state_bend_tests.rs"); + include!("state_multiselect_canvas_tests.rs"); struct Fixture { project: Project, @@ -2312,7 +2748,10 @@ mod tests { let scene = canvas.scene(&State::default()).unwrap(); assert_eq!( canvas.hit(&scene, row.bounds.center()), - Some(Hit::StateAction(action)) + Some(Hit::Row { + placement, + relation: action + }) ); assert_eq!(f.project, before); f.project @@ -2427,6 +2866,7 @@ mod tests { epoch: 3, }, selected: f.machine, + selection: None, navigation_focus: None, tool, zoom, @@ -3245,7 +3685,10 @@ mod tests { for (relation, row) in rows { assert_eq!( canvas.hit(&scene, row.center()), - Some(Hit::StateAction(relation.id)) + Some(Hit::Row { + placement: a.id, + relation: relation.id + }) ); let events = click(&canvas, &mut State::default(), row.center()); assert!( @@ -3306,7 +3749,10 @@ mod tests { for (relation, rect) in rows { assert_eq!( canvas.hit(&scene, rect.center()), - Some(Hit::Transition(relation.id)) + Some(Hit::Row { + placement: a.id, + relation: relation.id + }) ); } let initial = scene diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index e066657a..44ae1447 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -25,6 +25,7 @@ pub enum Tool { #[derive(Debug, Clone)] pub enum Event { + Selection(crate::app::multiselect::Event), Remove(crate::app::diagram_removal::Intent), Choose(Tool), CreateView(Id), @@ -38,6 +39,7 @@ pub enum Event { context: Context, object: Id, placement: Option, + selection_target: Option, anchor: iced::Point, bend: Option, route_segment: Option<(usize, Position)>, @@ -101,7 +103,8 @@ impl Event { pub fn context(&self) -> Option { match self { Self::Remove(intent) => Some(intent.context), - Self::Choose(_) | Self::CreateView(_) => None, + // Group events carry their own exact selection envelope. + Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None, Self::Select(c, _) | Self::Pan(c, _) | Self::Zoom(c, _) diff --git a/crates/oa-desktop/src/behavior/state_multiselect_canvas_tests.rs b/crates/oa-desktop/src/behavior/state_multiselect_canvas_tests.rs new file mode 100644 index 00000000..48860f5a --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_multiselect_canvas_tests.rs @@ -0,0 +1,395 @@ +// Included in state_canvas::tests. Drive production Program input and projection. + +#[test] +fn state_singleton_selection_bend_uses_five_unit_grid_and_escape_keeps_selection() { + for zoom in [0.5, 1., 2.] { + for cancel in [false, true] { + let (f, relation) = bend_fixture(true); + let original = f.project.diagrams[&f.diagram].routes[0].clone(); + let mut canvas = canvas(&f, Tool::Select, zoom); + canvas.selected = relation; + let selection = group_selection(&canvas, [Target::Edge(relation)]); + let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap(); + canvas.selection = Some(&selection); + let mut state = State::default(); + let baseline = canvas.scene(&state).unwrap(); + let start = screen(&canvas, point(original.waypoints[0])); + // A fractional pointer target distinguishes route snapping from the + // unsnapped State placement policy, at every supported test zoom. + let release = screen(&canvas, Point::new(683.75, 362.25)); + let expected_pin = Position { x: 685., y: 360. }; + motion(&canvas, &mut state, start, release); + assert!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), release).is_none()); + assert!(matches!(state.press, Some(Press::Bend { relation: id }) if id == relation)); + assert!(state.group.is_none()); + motion(&canvas, &mut state, release, release); + let mut expected_route = original.clone(); + expected_route.waypoints[0] = expected_pin; + assert_eq!(state.bend_preview.as_ref(), Some(&expected_route)); + assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![expected_pin]); + assert_eq!(f.project.diagrams[&f.diagram].routes[0], original); + if cancel { + let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape, + keyboard::Modifiers::empty()); + assert!(matches!(escape, Some(Event::Choose(Tool::Select))), + "Escape cancels the bend before it can emit selection Clear: {escape:?}"); + assert!(state.press.is_none() && state.bend.is_none() && state.bend_preview.is_none()); + assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, + baseline.routed.edges[&relation].points); + assert!(input(&canvas, &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), release).is_none()); + } else { + let event = input(&canvas, &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), release).unwrap(); + assert!(matches!(&event, Event::SetBends { context, relation: id, waypoints } + if *context == canvas.context && *id == relation && *waypoints == vec![expected_pin])); + let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); + let actual = prepare_command(&f.project, &plan.command).unwrap().project; + let mut expected = f.project.clone(); + expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = expected_route; + assert_eq!(actual, expected); + } + assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot); + assert!(state.group.is_none()); + } + } +} + +#[test] +fn state_singleton_selection_endpoint_reconnect_stationary_cancel_and_commit() { + for outcome in 0..3 { + let mut f = fixture(); + let relation = f.project.relate(RelationKind::StateTransition, "Go standby", f.a, f.b); + let mut canvas = canvas(&f, Tool::Select, 1.); + canvas.selected = relation; + let selection = group_selection(&canvas, [Target::Edge(relation)]); + let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap(); + canvas.selection = Some(&selection); + let mut state = State::default(); + let baseline = canvas.scene(&state).unwrap(); + let handle = screen(&canvas, point(*baseline.routed.edges[&relation].points.last().unwrap())); + let target = screen(&canvas, header(&canvas, f.c)); + motion(&canvas, &mut state, handle, target); + assert!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), target).is_none()); + assert!(matches!(state.press, + Some(Press::Reconnect { relation: id, endpoint: Endpoint::Target, start: Some(at) }) + if id == relation && at == handle)); + assert!(state.group.is_none() && state.bend.is_none()); + if outcome > 0 { motion(&canvas, &mut state, target, target); } + if outcome == 1 { + let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape, + keyboard::Modifiers::empty()); + assert!(matches!(escape, Some(Event::Choose(Tool::Select))), + "Escape cancels reconnect without clearing the exact edge: {escape:?}"); + assert!(state.press.is_none()); + } + let event = input(&canvas, &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), target); + if outcome < 2 { + assert!(event.is_none(), "stationary/cancelled reconnect produced {event:?}"); + } else { + let event = event.unwrap(); + assert!(matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex } + if *context == canvas.context && *id == relation && *vertex == f.c)); + let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); + let actual = prepare_command(&f.project, &plan.command).unwrap().project; + assert_eq!(plan.selected, relation); + assert_eq!(actual.relations[&relation].source(), f.a); + assert_eq!(actual.relations[&relation].target(), f.c); + assert_eq!(actual.elements, f.project.elements); + } + assert_eq!(f.project.relations[&relation].target(), f.b); + assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot); + assert!(state.group.is_none() && state.press.is_none()); + } +} + +#[test] +fn state_singleton_selection_shift_handles_toggle_exact_edge_before_route_tools() { + let (f, relation) = bend_fixture(true); + let mut canvas = canvas(&f, Tool::Select, 1.); + canvas.selected = relation; + let selection = group_selection(&canvas, [Target::Edge(relation)]); + canvas.selection = Some(&selection); + let baseline = canvas.scene(&State::default()).unwrap(); + let edge = &baseline.routed.edges[&relation]; + for world in [edge.pins[0], *edge.points.last().unwrap()] { + let mut state = State::default(); + assert!(canvas::Program::update(&canvas, &mut state, + &canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)), + bounds(), mouse::Cursor::Unavailable).is_none()); + let at = screen(&canvas, point(world)); + motion(&canvas, &mut state, at, at); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), at), + Some(Event::Selection(SelectionEvent::Choose(context, Target::Edge(id), Choice::Toggle))) + if context == selection_context(canvas.context) && id == relation)); + assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none()); + let away = at + Vector::new(35., 20.); + motion(&canvas, &mut state, away, away); + assert!(input(&canvas, &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), away).is_none()); + assert!(state.bend_preview.is_none()); + assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points); + } +} + +fn group_selection(canvas: &StateCanvas<'_>, targets: impl IntoIterator) -> Selection { + let mut selection = Selection::new(selection_context(canvas.context).scope); + for target in targets { selection.add(target, |_| true); } + selection +} + +fn group_key(canvas: &StateCanvas<'_>, state: &mut State, key: keyboard::key::Named, + modifiers: keyboard::Modifiers) -> Option +{ + canvas::Program::update(canvas, state, &canvas::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key), modified_key: keyboard::Key::Named(key), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Delete), + location: keyboard::Location::Standard, modifiers, text: None, repeat: false, + }), bounds(), mouse::Cursor::Unavailable).and_then(|action| action.into_inner().0) +} + +#[test] +fn state_group_ordered_press_preview_and_fractional_release_preserve_primary() { + let (f, relation) = bend_fixture(true); + for zoom in [0.5, 1., 2.] { + let mut canvas = canvas(&f, Tool::Select, zoom); + let b = placement(&f, f.b); + let c = placement(&f, f.c); + let selection = group_selection(&canvas, [Target::Placement(b), Target::Placement(c)]); + canvas.selection = Some(&selection); + canvas.selected = f.c; + let before = selection.snapshot(selection_context(canvas.context)).unwrap(); + let mut state = State::default(); + let start = screen(&canvas, header(&canvas, f.b)); + let end = start + Vector::new(7.25 * zoom, 10.5 * zoom); + motion(&canvas, &mut state, start, end); + assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), end).is_none()); + assert!(state.group.is_some() && state.press.is_none()); + assert_eq!(selection.primary(), Some(&Target::Placement(c))); + motion(&canvas, &mut state, end, end); + let scene = canvas.scene_with_bounds(&state, Some(bounds())).unwrap(); + for id in [b, c] { + let node = scene.view.nodes.iter().find(|n| n.id == id).unwrap(); + let original = &f.project.diagrams[&f.diagram].nodes[&id]; + assert_eq!(node.position, Position { x: original.position.x + 7.25, y: original.position.y + 10.5 }); + } + assert_eq!(scene.routed.edges[&relation].pins, vec![Position { x: 657.5, y: 351.25 }]); + assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start), + Some(Event::Selection(SelectionEvent::Move(snapshot, Position { x: 7.25, y: 10.5 }))) if snapshot == before)); + assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), before); + } +} + +#[test] +fn state_group_stationary_collapses_but_out_and_back_keeps_group() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Select, 1.); + let a = placement(&f, f.a); + let b = placement(&f, f.b); + let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]); + canvas.selection = Some(&selection); + let start = screen(&canvas, header(&canvas, f.a)); + for dragged in [false, true] { + let mut state = State::default(); + motion(&canvas, &mut state, start, start); + assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start).is_none()); + if dragged { + motion(&canvas, &mut state, start + Vector::new(20., 0.), start); + motion(&canvas, &mut state, start, start); + } + let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start); + if dragged { + assert!(matches!(event, Some(Event::Selection(SelectionEvent::Move(_, Position { x: 0., y: 0. }))))); + } else { + assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Placement(id)))) if id == a)); + } + } +} + +#[test] +fn state_group_changed_authority_viewport_or_membership_retires_preview_and_release() { + let f = fixture(); + for change in 0..7 { + let mut canvas = canvas(&f, Tool::Select, 1.); + let a = placement(&f, f.a); + let b = placement(&f, f.b); + let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]); + let mut replacement = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]); + replacement.remove(&Target::Placement(a)); + replacement.add(Target::Placement(a), |_| true); + canvas.selection = Some(&selection); + let mut state = State::default(); + let start = screen(&canvas, header(&canvas, f.a)); + motion(&canvas, &mut state, start, start); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start); + motion(&canvas, &mut state, start + Vector::new(12., 8.), start); + assert!(canvas.group_preview_key(&state, Some(bounds())).is_some()); + let mut changed_bounds = bounds(); + match change { + 0 => canvas.context.revision += 1, + 1 => canvas.context.epoch += 1, + 2 => canvas.context.document = Id::new_v4(), + 3 => canvas.zoom = 1.1, + 4 => canvas.pan.x += 1., + 5 => changed_bounds.x += 1., + _ => canvas.selection = Some(&replacement), + } + assert!(canvas.group_preview_key(&state, Some(changed_bounds)).is_none()); + if change < 3 { + assert!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()).is_none()); + } + assert!(input_at_bounds(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, changed_bounds).is_none()); + assert!(state.group.is_none()); + } +} + +#[test] +fn state_group_mixed_route_handle_refuses_drag_and_preserves_stationary_collapse() { + let (f, relation) = bend_fixture(true); + let mut canvas = canvas(&f, Tool::Select, 1.); + let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b)), Target::Edge(relation)]); + canvas.selection = Some(&selection); + canvas.selected = relation; + let scene = canvas.scene(&State::default()).unwrap(); + let at = screen(&canvas, point(scene.routed.edges[&relation].pins[0])); + for dragged in [false, true] { + let mut state = State::default(); + motion(&canvas, &mut state, at, at); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at); + assert!(state.group.is_some() && state.bend.is_none() && state.press.is_none()); + if dragged { + motion(&canvas, &mut state, at + Vector::new(25., 10.), at); + motion(&canvas, &mut state, at, at); + } + let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at); + if dragged { + assert!(matches!(event, Some(Event::Selection(SelectionEvent::Refuse(_, _))))); + } else { + assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Edge(id)))) if id == relation)); + } + assert!(state.bend_preview.is_none()); + } +} + +#[test] +fn state_group_shift_row_hit_menu_and_reassignment_keep_exact_identity() { + let mut f = fixture(); + let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap(); + let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work); + let internal = f.project.relate(RelationKind::StateTransition, "Internal", f.a, f.a); + if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { + *transition_kind = TransitionKind::Internal; + } + let a = placement(&f, f.a); + for relation in [action, internal] { + let mut canvas = canvas(&f, Tool::Select, 1.); + let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b))]); + canvas.selection = Some(&selection); + let scene = canvas.scene(&State::default()).unwrap(); + let node = scene.view.nodes.iter().find(|n| n.id == a).unwrap(); + let rect = state_action_rows(&f.project, node).into_iter().chain(internal_rows(&f.project, node)) + .find(|(row, _)| row.id == relation).unwrap().1; + assert!(visible_row_target(&f.project, f.diagram, &scene.view, a, relation)); + let target = Target::StateRow { placement: a, relation }; + assert_eq!(canvas.hit(&scene, rect.center()).unwrap().target(), target); + let mut state = State::default(); + let modifiers = canvas::Program::update(&canvas, &mut state, + &canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)), + bounds(), mouse::Cursor::Unavailable); + assert!(modifiers.is_none()); + let at = screen(&canvas, rect.center()); + motion(&canvas, &mut state, at, at); + assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at), + Some(Event::Selection(SelectionEvent::Choose(_, actual, Choice::Toggle))) if actual == target)); + assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at), + Some(Event::ContextMenu { object, placement: None, selection_target: Some(actual), .. }) if object == relation && actual == target)); + canvas::Program::update(&canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Unavailable); + assert!(state.modifiers.is_empty()); + } + if let RelationBody::StateAction { source, .. } = &mut f.project.relations.get_mut(&action).unwrap().body { *source = f.b; } + if let RelationBody::StateTransition { source, target, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *source = f.b; *target = f.b; } + let view = projection::project_diagram(&f.project, &f.project.diagrams[&f.diagram]); + for relation in [action, internal] { + assert!(!visible_row_target(&f.project, f.diagram, &view, a, relation)); + assert!(visible_row_target(&f.project, f.diagram, &view, placement(&f, f.b), relation)); + } +} + +#[test] +fn state_group_delete_and_escape_respect_held_gesture_and_full_set() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Select, 1.); + let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.a)), Target::Placement(placement(&f, f.b))]); + canvas.selection = Some(&selection); + let mut state = State::default(); + use keyboard::key::Named; + assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_))))); + assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _))))); + let at = screen(&canvas, header(&canvas, f.a)); + motion(&canvas, &mut state, at, at); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at); + assert!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()).is_none()); + assert!(state.group.is_some()); + assert!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()).is_none()); + assert!(state.group.is_none()); + assert!(matches!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Clear(_))))); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), at); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at); + assert!(state.group.is_none() && state.press.is_none() && state.pan_from.is_some()); + assert!(matches!(motion(&canvas, &mut state, at + Vector::new(20., 10.), at), Some(Event::Pan(_, _)))); +} + +#[test] +fn state_group_entry_anchor_uses_authored_position_with_unsnapped_delta() { + let mut f = fixture(); + let inner = f.project.elements[&f.leaf].parent.unwrap(); + let entry = f.project.add_element(Kind::EntryPoint, Layer::Sa, "Enter", Some(inner)).unwrap(); + let original = Position { x: 450.25, y: 410.75 }; + let entry_node = f.project.place(f.diagram, entry, original); + let mut canvas = canvas(&f, Tool::Select, 1.); + let selection = group_selection(&canvas, [Target::Placement(entry_node), Target::Placement(placement(&f, f.leaf))]); + canvas.selection = Some(&selection); + let scene = canvas.scene(&State::default()).unwrap(); + let shown = scene.view.nodes.iter().find(|n| n.id == entry_node).unwrap(); + assert_ne!(shown.position.x, original.x); + let at = screen(&canvas, rectangle(shown).center()); + let mut state = State::default(); + motion(&canvas, &mut state, at, at); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at); + assert!(state.group.is_some()); + let end = at + Vector::new(7.25, 8.5); + motion(&canvas, &mut state, end, end); + assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end), + Some(Event::Selection(SelectionEvent::Move(_, Position { x: 7.25, y: 8.5 }))))); + assert_eq!(f.project.diagrams[&f.diagram].nodes[&entry_node].position, original); +} + +#[test] +fn state_group_row_and_port_mixtures_refuse_complete_set_without_owner_promotion() { + let mut f = fixture(); + let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap(); + let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work); + let a = placement(&f, f.a); + for other in [Target::StateRow { placement: a, relation: action }, Target::Port { placement: a, port: work }] { + let mut canvas = canvas(&f, Tool::Select, 1.); + let selection = group_selection(&canvas, [Target::Placement(a), other]); + canvas.selection = Some(&selection); + let mut state = State::default(); + assert!(matches!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()), + Some(Event::Selection(SelectionEvent::Refuse(_, _))))); + let at = screen(&canvas, header(&canvas, f.a)); + motion(&canvas, &mut state, at, at); + input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at); + assert!(state.group.as_ref().is_some_and(|g| g.movement.is_none())); + let end = at + Vector::new(12., 8.); + motion(&canvas, &mut state, end, end); + assert!(canvas.group_preview_key(&state, Some(bounds())).is_none()); + assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end), + Some(Event::Selection(SelectionEvent::Refuse(snapshot, _))) if snapshot.targets().len() == 2)); + assert!(state.press.is_none() && state.preview.is_none()); + } +} diff --git a/crates/oa-desktop/src/behavior/state_view.rs b/crates/oa-desktop/src/behavior/state_view.rs index 0c17c400..e7d5785f 100644 --- a/crates/oa-desktop/src/behavior/state_view.rs +++ b/crates/oa-desktop/src/behavior/state_view.rs @@ -41,7 +41,10 @@ pub fn diagram(app: &App, machine: Id) -> Option { } fn event(e: Event) -> Message { - Message::StateDiagram(e) + match e { + Event::Selection(selection) => Message::DiagramSelection(selection), + other => Message::StateDiagram(other), + } } pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { @@ -143,8 +146,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem .collect::>(); // Keep the optional picker in its own stable slot. Inserting it as a // sibling before the canvas would replace the positional Iced focus tree. - let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 }; - let mut missing_controls = Column::new(); + // Reserve the picker height even when all elements are placed. Group Hide + // and Undo must not resize the canvas and silently refit its camera. + let mut missing_controls = Column::new().height(34); if !missing.is_empty() { let position = Position { x: (48. - state.pan.x) / state.zoom, @@ -169,6 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem diagram: id, context, selected: app.selected, + selection: app.diagram_selection.as_ref(), navigation_focus: app .diagram_focus(crate::diagram_usage::Surface::Saved(id)) .map(|usage| usage.bounds), @@ -183,6 +188,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem out = out.push( column![ missing_controls, + selection_controls(app), crate::diagram::focus::wrap( canvas.map(event), context, @@ -191,7 +197,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem Message::DiagramTraverse, ) ] - .spacing(missing_spacing) + .spacing(8) .height(Fill), ); let base: Element<'_, Message> = container(out.spacing(8)) @@ -202,6 +208,47 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem with_form(app, machine, base) } +fn selection_controls(app: &App) -> Element<'_, Message> { + use crate::app::multiselect::{Event as SelectionEvent, placements}; + let snapshot = app.multiselect_snapshot(); + 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().len() > 1) { + if placements(&snapshot).is_some() { + controls = controls.push(action( + "Hide selected", + Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())), + )); + } + controls = controls + .push(action( + "Clear selection", + Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())), + )) + .push( + label(format!( + "Properties edit only {}", + crate::views::short(app.project().named(app.selected), 18) + )) + .wrapping(iced::widget::text::Wrapping::None), + ); + } + let hint = if app.behavior.diagram.tool == Tool::Select { + "Shift+click adds or removes from selection" + } else { + "Use Select / move to select multiple elements" + }; + column![ + controls, + label(hint).wrapping(iced::widget::text::Wrapping::None) + ] + .height(56) + .into() +} + fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> { action( "New transition with details…", diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 1b069d54..3c24518f 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -39,7 +39,7 @@ mod port_labels; pub use port_labels::PortLabels; #[path = "diagram/focus.rs"] pub(crate) mod focus; -mod group_gesture; +pub(crate) mod group_gesture; mod multiselect; #[cfg(test)] mod multiselect_tests; diff --git a/crates/oa-desktop/src/diagram/group_gesture.rs b/crates/oa-desktop/src/diagram/group_gesture.rs index 48cac230..7e8a3bf8 100644 --- a/crates/oa-desktop/src/diagram/group_gesture.rs +++ b/crates/oa-desktop/src/diagram/group_gesture.rs @@ -12,6 +12,15 @@ pub(crate) struct Viewport { pub bounds: Rectangle, } +/// Node displacement policy; route-bend snapping remains outside this helper. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DisplacementPolicy { + /// Preserve the ordinary saved-view ten-unit anchor grid. + Grid10, + /// Preserve State views' fractional authored placement displacement. + Unsnapped, +} + #[derive(Debug)] pub(crate) struct Gesture { snapshot: Snapshot, @@ -19,6 +28,7 @@ pub(crate) struct Gesture { original: Position, press: Point, viewport: Viewport, + displacement: DisplacementPolicy, crossed_threshold: bool, delta: Position, retired: bool, @@ -37,6 +47,27 @@ impl Gesture { original: Position, press: Point, viewport: Viewport, + ) -> Option { + Self::new_with_policy( + snapshot, + anchor, + original, + press, + viewport, + DisplacementPolicy::Grid10, + ) + } + + /// `original` is the authored placement position, not its projected symbol + /// position (State entry/exit symbols can be displayed on another boundary). + /// Pointer displacement comes from frozen screen geometry and zoom. + pub fn new_with_policy( + snapshot: Snapshot, + anchor: Id, + original: Position, + press: Point, + viewport: Viewport, + displacement: DisplacementPolicy, ) -> Option { if !matches!(snapshot.context().scope.surface, Surface::Saved(_)) || !snapshot.targets().contains(&Target::Placement(anchor)) @@ -57,6 +88,7 @@ impl Gesture { original, press, viewport, + displacement, crossed_threshold: false, delta: Position { x: 0., y: 0. }, retired: false, @@ -82,7 +114,7 @@ impl Gesture { self.crossed_threshold } - /// Common snapped world delta for preview and release; zero before a drag. + /// Common policy-adjusted world delta for preview and release; zero before a drag. pub fn delta(&self) -> Position { self.delta } @@ -102,9 +134,13 @@ impl Gesture { if !self.crossed_threshold { return true; } + let unsnapped = Position { + x: screen.x / self.viewport.zoom, + y: screen.y / self.viewport.zoom, + }; let desired = Position { - x: self.original.x + screen.x / self.viewport.zoom, - y: self.original.y + screen.y / self.viewport.zoom, + x: self.original.x + unsnapped.x, + y: self.original.y + unsnapped.y, }; if !finite_position(desired) { self.retire(); @@ -112,13 +148,15 @@ impl Gesture { } // Returning to the original position must not align an off-grid anchor // as an accidental edit. It is still a drag, so selection stays grouped. - let delta = if desired == self.original { - Position { x: 0., y: 0. } - } else { - Position { + let delta = match self.displacement { + // Use the pointer delta directly, avoiding precision loss from + // subtracting a large authored original from its desired position. + DisplacementPolicy::Unsnapped => unsnapped, + DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. }, + DisplacementPolicy::Grid10 => Position { x: (desired.x / 10.).round() * 10. - self.original.x, y: (desired.y / 10.).round() * 10. - self.original.y, - } + }, }; if !finite_position(delta) { self.retire(); @@ -307,6 +345,133 @@ mod tests { assert!(gesture.crossed_threshold()); } + #[test] + fn unsnapped_fractional_pointer_delta_preserves_authored_offset() { + let authored = Position { + x: 1024.125, + y: -321.5, + }; + let mut gesture = Gesture::new_with_policy( + snapshot([Target::Placement(id(10)), Target::Placement(id(11))]), + id(10), + authored, + press(), + Viewport { + zoom: 2., + ..viewport() + }, + DisplacementPolicy::Unsnapped, + ) + .unwrap(); + let captured = gesture.snapshot().clone(); + let pointer = Point::new(164.5, 114.5); + assert!(gesture.move_to(pointer)); + assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 }); + let translated_authored = Position { + x: authored.x + gesture.delta().x, + y: authored.y + gesture.delta().y, + }; + assert_eq!( + translated_authored, + Position { + x: 1031.375, + y: -324.25 + } + ); + assert_eq!( + gesture.release(pointer), + Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })), + ); + } + + #[test] + fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() { + let make = || { + Gesture::new_with_policy( + snapshot([Target::Placement(id(10)), Target::Placement(id(11))]), + id(10), + Position { x: 13.25, y: -7.5 }, + press(), + viewport(), + DisplacementPolicy::Unsnapped, + ) + .unwrap() + }; + let mut click = make(); + assert!(click.move_to(Point::new(153., 120.))); + assert!(!click.crossed_threshold()); + assert_eq!(click.delta(), Position { x: 0., y: 0. }); + assert_eq!( + click.release(Point::new(153., 120.)), + Some(Outcome::Click(id(10))) + ); + let mut drag = make(); + let captured = drag.snapshot().clone(); + assert!(drag.move_to(Point::new(157.25, 117.25))); + assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 }); + assert!(drag.move_to(press())); + assert!(drag.crossed_threshold()); + assert_eq!(drag.delta(), Position { x: 0., y: 0. }); + assert_eq!( + drag.release(press()), + Some(Outcome::Move(captured, Position { x: 0., y: 0. })) + ); + } + + #[test] + fn explicit_grid_and_default_constructor_keep_existing_quantization() { + let original = Position { x: 13., y: -7. }; + let mut default = gesture(original); + let mut explicit = Gesture::new_with_policy( + default.snapshot().clone(), + id(10), + original, + press(), + viewport(), + DisplacementPolicy::Grid10, + ) + .unwrap(); + let pointer = Point::new(167., 133.); + assert!(default.move_to(pointer)); + assert!(explicit.move_to(pointer)); + // Existing ordinary behavior: desired (30,6), grid (30,10). + assert_eq!(default.delta(), Position { x: 17., y: 17. }); + assert_eq!(explicit.delta(), Position { x: 17., y: 17. }); + assert_eq!(default.release(pointer), explicit.release(pointer)); + } + + #[test] + fn unsnapped_release_only_movement_and_invalid_pointer_retirement() { + let make = || { + Gesture::new_with_policy( + snapshot([Target::Placement(id(10))]), + id(10), + Position { + x: 20.125, + y: 40.25, + }, + press(), + viewport(), + DisplacementPolicy::Unsnapped, + ) + .unwrap() + }; + let release_only = make(); + let captured = release_only.snapshot().clone(); + assert_eq!( + release_only.release(Point::new(154.25, 120.)), + Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })), + ); + for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] { + let mut gesture = make(); + assert!(gesture.move_to(Point::new(154.25, 120.))); + assert!(!gesture.move_to(invalid)); + assert!(!gesture.viewport_matches(viewport())); + assert_eq!(gesture.delta(), Position { x: 0., y: 0. }); + assert_eq!(gesture.release(press()), None); + } + } + #[test] fn full_typed_selection_and_saved_surface_are_required() { for targets in [ diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 5e2caf34..e34b54a8 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -12,6 +12,7 @@ pub struct Request { pub anchor: Point, pub object: Id, pub placement: Option, + pub selection_target: Option, pub bend: Option, pub route_point: Option, pub route_segment: Option<(usize, Position)>, @@ -25,6 +26,7 @@ impl Request { anchor, object, placement: None, + selection_target: None, bend: None, route_point: None, route_segment: None, @@ -55,6 +57,7 @@ pub struct Opened { impl Opened { pub fn new(app: &App, request: Request, token: u64) -> Option { if !app.project().contains(request.object) + || !target_matches(app, &request) || request .canvas .is_some_and(|c| Some(c) != current_canvas(app)) @@ -80,6 +83,7 @@ impl Opened { && self.layer == app.layer && self.diagram == current_diagram(app) && app.project().contains(self.request.object) + && target_matches(app, &self.request) && self .request .canvas @@ -92,6 +96,32 @@ impl Opened { } } +fn target_matches(app: &App, request: &Request) -> bool { + use crate::diagram_usage::Target; + let Some(target) = &request.selection_target else { + return true; + }; + if request.canvas.is_none() || !app.multiselect_target_visible(target) { + return false; + } + match target { + Target::Placement(id) => { + request.placement == Some(*id) + && request + .canvas + .and_then(|context| app.project().diagrams.get(&context.diagram)) + .and_then(|diagram| diagram.nodes.get(id)) + .is_some_and(|node| node.element == request.object || node.id == request.object) + } + Target::Port { port, .. } => request.placement.is_none() && *port == request.object, + Target::Edge(id) => request.placement.is_none() && *id == request.object, + Target::StateRow { relation, .. } => { + request.placement.is_none() && *relation == request.object + } + _ => false, + } +} + fn current_canvas(app: &App) -> Option { if app.page == Page::Behavior { app.state_context() @@ -126,7 +156,22 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> { request.canvas = app.route_context(); } else if app.page == Page::Behavior { request.canvas = app.state_context(); - if let Some(context) = request.canvas + if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) { + request.selection_target = Some(target.clone()); + request.placement = match target { + crate::diagram_usage::Target::Placement(id) => { + if let Some(node) = request + .canvas + .and_then(|c| app.project().diagrams.get(&c.diagram)) + .and_then(|d| d.nodes.get(id)) + { + request.object = node.element; + } + Some(*id) + } + _ => None, + }; + } else if let Some(context) = request.canvas && let Some(node) = app.project().diagrams[&context.diagram] .nodes .values() @@ -187,24 +232,20 @@ pub fn popup(app: &App) -> Option> { on_close: Message::Menu(Event::Close), }); } - if app.page == Page::Behavior - && let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean) - { - return Some(menu); - } let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))]; - if app.page == Page::Diagram - && request.canvas.is_some() + if request.canvas.is_some() && let Some(context) = app.multiselect_context() { use crate::app::multiselect::{Choice, Event as SelectionEvent}; use crate::diagram_usage::Target; - let view = app - .active_diagram + let view = request + .canvas + .map(|context| context.diagram) .map(|d| projection::project_view_with_index(p, d, app.document.view_index())); let target = request - .placement - .map(Target::Placement) + .selection_target + .clone() + .or_else(|| request.placement.map(Target::Placement)) .or_else(|| { view.as_ref()? .ports @@ -287,6 +328,15 @@ pub fn popup(app: &App) -> Option> { entries.push(Entry::separator()); } } + if app.page == Page::Behavior + && let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean) + { + if entries.len() > 1 { + entries.extend(menu.entries.into_iter().skip(1)); + menu.entries = entries; + } + return Some(menu); + } if !clean { entries.push(Entry::title("Apply or revert pending edits first")); } diff --git a/harness/multiselect-process-adapter-design-20260923.md b/harness/multiselect-process-adapter-design-20260923.md new file mode 100644 index 00000000..8926dd33 --- /dev/null +++ b/harness/multiselect-process-adapter-design-20260923.md @@ -0,0 +1,52 @@ +# Process group adapter candidate + +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. + +## Representation facts + +`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 + +| 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. | +| 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 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, remove its IDs once, then insert at an explicit before/after unselected destination slot. Preserve all unselected relative order. Selected-target drops are no-op unless an external slot is explicit. Check the candidate's columns remain 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. + +## Hide and removal + +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 + +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 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. + +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 unselected order, 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 + +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. + +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`. + +Smallest 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. No such experiment has run yet. diff --git a/harness/state-multiselect-native-design-20260923.md b/harness/state-multiselect-native-design-20260923.md new file mode 100644 index 00000000..5ad0a8c1 --- /dev/null +++ b/harness/state-multiselect-native-design-20260923.md @@ -0,0 +1,43 @@ +# State multiple-selection native fixture and invariant evaluator + +Implementation candidate: `crates/oa-core/examples/state_multiselect_native.rs`, based on the existing ordinary fixture CLI. Written on branch `codex/multiselect-state-9e8r6t-20260923` after `7e348d9`. Compilation, unit tests and native inspection have **not** been run for this candidate. Parent owns these gates and a fresh oracle review. + +## Entry and targets + +The generator refuses an existing output directory and writes `baseline.json`, `manifest.json`, and canonical source under `source/`. All object IDs derive from the fixed UUID-v5 namespace `c1192026-0923-5000-8000-000000002929` and the aliases in the manifest, except `state_view`, fixed to `00000000-0000-0000-0000-000000290101`. It is the smallest diagram ID, and is a dedicated State-machine view rather than Architecture. The fixture does not persist a transient selection; select A, then Shift-click B in the native application. + +| Alias / target | Authored world geometry / purpose | +| --- | --- | +| `node_a` / `state_a` | A – Processing: (0,0), 340×450; click header near (40,35) | +| `node_b` / `state_b` | B – Ready: (410,30), 210×150; click header near (450,65) | +| `node_c` / `state_c` | C – Complete: (410,330), 210×140; must remain fixed | +| `node_inner` / `inner` | A nested Region: (25,220), 290×200 | +| `node_leaf` / `state_leaf` | A.1 – Classify: (60,290), 180×90 | +| `node_entry` / `entry` | Authored (-210,280); State projection attaches symbol X to A's left boundary | +| `node_exit` / `exit` | Authored (870,350); State projection attaches symbol X to A's right boundary | + +The outer machine/Region are unplaced in the main view to keep these targets readable after Fit at a 1442×932 native window. Readability is a proposed layout until visually verified. World header coordinates are not screen coordinates; account for the actual camera and canvas origin. + +A and C contain StateAction rows referencing the same `shared_work` Activity. A has the `refresh` InternalTransition row. The Activity has a refinement trace, including recorded baseline properties, to an unrelated SA Function. A second State view and SA view provide preservation sentinels. Main-view routes have authored hard pins, explicit attachment sides/fractions and relative label offset/fraction; `between` also has a guard and multiline label. + +## Expected mutations and limits + +`moved` infers a common displacement from the saved A placement. The generated baseline puts A at authored (0,0), so its saved f32 position is the original f32 delta without lossy subtraction of a nonzero anchor. All main-view placements and route pins were translated equally by (-30,-100); sizes, relative geometry, route label offsets, other State/SA views and IDs are unchanged. Use that externally pinned origin baseline for moved checks, not an already-moved baseline. Positive command candidates include (0.01f32,10) and (0.1f32,-0.2f32) to expose nonbinary-delta rounding regressions. It independently enumerates exactly six moved placements: A, B, nested Region, nested State, entry and exit. It translates only `between` (A→B), `entry_route` (entry→leaf), and `exit_route` (leaf→exit) pins. `external` (B→C) pins remain exact. No production movement, descendant or routing helper contributes to the expected Project. + +The delta must be finite, have length at least 0.01 and each axis at most 300 world units in magnitude. Fractional values are accepted without a 10-unit grid requirement. This is a bounded native-evidence acceptance threshold, not a new movement/product limit. All comparison is complete Project equality after explicit edits to a baseline clone; JSON/source roundtrips are also required. + +`hide` removes exactly `node_a` and `node_b`, leaving their descendants' placements, all authored routes, engineering objects, shared activities, StateAction/InternalTransition records, other views, opposite-layer objects and refinement baseline intact. `unchanged` requires complete model equality. Authored external route equality does not require its recomputed path or labels to stay at identical screen positions. + +The checker accepts JSON or `.arcadia` inputs, rejects source errors, and allows ordinary engineering-completeness warnings. Pin the generated baseline, manifest, generator source version and generation command in an external evidence receipt **before** native interaction. Project identity alone does not authenticate the baseline. A moved output passing this checker cannot prove pointer intent, focus, stationary-click behavior, selection paint, exact original source bytes or single-step history; observe those independently in the native workflow. + +## Parent verification commands + +```text +cargo test -p oa-core --example state_multiselect_native +cargo run -p oa-core --example state_multiselect_native -- generate FRESH_DIR +cargo run -p oa-core --example state_multiselect_native -- check unchanged FRESH_DIR/baseline.json FRESH_DIR/source/project.arcadia +cargo run -p oa-core --example state_multiselect_native -- check moved FRESH_DIR/baseline.json SAVED_MOVED_JSON_OR_ARCADIA +cargo run -p oa-core --example state_multiselect_native -- check hide FRESH_DIR/baseline.json SAVED_HIDE_JSON_OR_ARCADIA +``` + +For Hide performed after movement, use the externally pinned moved snapshot as that operation's baseline. Unit positives use the validated production command only to generate candidates; the oracle remains explicit. Negative tests require valid-model corruption where applicable and cover missing/double movement of each descendant, internal/boundary pins left behind, external pin drift, attachment/label metadata, StateAction phase, internal guard, shared work, fixed C, other views, trace metadata/baseline, destructive Hide and malformed source. Source-file positives and valid unexpected source edits exercise the same complete-model check. diff --git a/harness/state-multiselect-review-20260923.md b/harness/state-multiselect-review-20260923.md new file mode 100644 index 00000000..e0bfa336 --- /dev/null +++ b/harness/state-multiselect-review-20260923.md @@ -0,0 +1,23 @@ +# State multiple-selection independent review + +Scope: uncommitted State adapter above `7e348d967d1787c052a145b7448636613a6a7054` on `codex/multiselect-state-9e8r6t-20260923`. This is branch-specific review evidence, not integrated acceptance. The reviewer was a fresh GPT-6 Astra agent and did not author the adapter or checker. Parent owns verification and integration. + +The read-only review covered App admission, exact State row identity, context-menu ownership, the unsnapped gesture policy, diagram-only simultaneous preview, fixed State controls, and the independent native checker. No Cargo or native GUI execution was performed by the reviewer. + +## Findings + +1. **Checker rounding false negative.** Recovering the displacement from A's nonzero `(30,100)` origin can round it differently from the original `f32` displacement. For actual `(0.01f32,10f32)`, saved A's X is `30.0100002289`, giving recovered `0.010000229`; an inner node initially at 55 actually becomes `55.0099983215`, while replaying the recovered delta predicts `55.0100021362`. The reviewer reproduced this with single-precision arithmetic. The strict whole-Project oracle should retain exact comparison and use an origin anchor, with nonbinary fractional positive cases. This is a checker defect, not evidence of incorrect product movement. + +2. **Late refusal changes unrelated input state.** Same-context legacy State `Select`, `Move`, or `Remove` events under a multiple selection were refused in `edit_state_diagram`, after `App::update` had reconciled pending reveal and retired allocation input. Static tracing showed that the rejected event could clear navigation focus/pending reveal, advance allocation epoch, and clear `arrange_next`. Refusal must occur before those hooks. Model/source/history-only assertions do not cover this failure; the regression must include the shared state and a queued `Select`. + +The reviewer found the inspected exact-row visibility, typed menu target checks, planner-backed preview, authored boundary-symbol anchors and fixed-height control design consistent with the intended contract. This bounded review does not establish correctness by itself. + +## Required verification identified by review + +Run current-State App and mounted focus tests, existing State bend/reconnect/attachment tests, and singleton route interactions with `StateCanvas.selection = Some(singleton)`. Legacy canvas constructors using `None` bypass the new selection admission path. Verify actual native group movement, Hide, history, source preservation and reopen on the recorded Windows build. Linux verification remains user-paused. + +## Follow-up review and first corrected gate + +The same independent reviewer inspected both corrections and found them addressed statically: the main fixture positions, pins and recorded header points shift consistently by `(-30,-100)`; A is at the origin; `(0.01,10)` and `(0.1,-0.2)` are real-command positive cases. The legacy State rejection now precedes the shared hooks, with a regression comparing valid navigation markers, full selection, model/source, camera, epochs, feedback and history. Three singleton-selection route tests cover snapping, commit/cancel, stationary reconnect and Shift priority. No new blocking finding was reported. A minor fidelity note remains: the legacy queued Select probe uses a placement UUID rather than the semantic UUID normally emitted by the canvas; the admission rejects the whole legacy variant independently of that UUID. + +Parent Windows `core-2` ran all 11 State native-checker tests successfully with 733 unchanged captured inputs (input-map SHA-256 `9cf85879dfb0bb5ec3b15c66b23df316e6bfe8451b6cf0c895bf7b9b5fbdde4a`). This includes the new nonbinary fractions. The full desktop gate and native trial remain separate requirements; this result is not their acceptance.