Files
ARCADIA-Studio/harness/multiselect-state-adapter-design-20260923.md

22 KiB

Persisted State multiple-selection adapter

Scope: next required adapter for issue 29, following the selection contract. This is a code-grounded proposal, not implementation or acceptance evidence. Inspected local branch codex/multiselect-9e8r6t-20260923, base b8685416f56e53e30bd11ecf93a54cea10ac8458, including uncommitted ordinary-selection work on 2026-09-23. Parent owns integration; no build or GUI checks were run for this investigation.

Verified interfaces and present workflow

  • diagram_usage::{Surface, Target} already represents this surface as Surface::Saved(diagram) and a state node as Target::Placement(placement). There is no StatePlacement variant and none is needed. Action and internal-transition rows have Target::StateRow { placement, relation }; rendered transition edges have Target::Edge(relation).
  • behavior/state_view.rs::view constructs StateCanvas using state_context(), semantic app.selected, its own camera/tool, and existing draft guards. It wraps the mapped canvas in the same production diagram::focus::wrap as Architecture, including app.busy suspension. Its optional unplaced-elements picker deliberately occupies a stable slot to preserve the widget focus tree.
  • State primary placement lives in app.behavior.diagram.placement, not app.selected_node. app/state_diagram.rs::edit_state_diagram selects semantic identities and clears selected_node. App::refresh_draft rediscovers the State placement from the semantic identity; therefore exact-target reconciliation must override that discovery when an occurrence disappears or is replaced.
  • state_canvas::{hit, node_at} respects symbol hit shapes, labels, nesting and row priority. Hit::Node preserves placement and element; Hit::StateAction(Id) and internal rows represented by Hit::Transition(Id) currently discard the row's placement identity. Do not infer a group target from semantic kind alone.
  • StateCanvas::update uses ordered window coordinates, a 3-pixel movement threshold, and stored press geometry. It currently selects on a node press and emits one state_edit::Event::Move on release. It has distinct attachment, bend, transition, reconnect, creation and pan gestures. Escape resets these gestures and selects the Select tool. Right-click resets provisional geometry before capturing the menu target.
  • state_canvas::scene_with_bounds currently clones the diagram, applies a singleton movement plus descendants and translated_routes, then projects and routes the result. Attachment and bend previews are independently cached; bend preview reroutes only its edge against a baseline. state_edit::plan(Event::Move) commits through the existing singleton Command::MoveNode.
  • state_menu::popup runs before the ordinary group-menu branch in model_menu::popup; merely broadening the ordinary App selection context will not provide State group menus. Current State menu offers singleton Hide, semantic deletion and route tools. app/diagram_removal.rs also distinguishes a placement Hide from a row/edge's semantic deletion confirmation.

User workflows to preserve: choose a machine and saved view; inspect state headers, entry/do/exit rows and transitions; select tools to add symbols or reconnect transitions; move composite states with folded children; adjust hard route pins and attachments; Hide placements and recover them through the unplaced picker or Undo. Group selection must coexist with these workflows, not silently reinterpret rows as nodes or alter route tooling.

Use the existing transient Selection and its snapshot generation without adding model/source schema. Share the App's Saved-view group operations, but make diagram resolution, primary inspection and visibility surface-aware. A State view remains a Saved surface; switching between pages or behavior owners explicitly clears the set even if both resolve to the same diagram UUID.

Select-tool hit mapping:

Rendered hit Exact target Initial group movement/Hide capability
State, machine, region or pseudostate placement Placement(node.id) Placement-only set admitted
Routed transition Edge(relation.id) Select/inspect; refuse group placement actions
Entry/do/exit action or internal-transition row StateRow { placement, relation } Select/inspect; refuse group placement actions
Navigation highlight / empty content No edit target Never promote to selection

Keep individual route editing available after choosing a singleton edge. When several targets are selected, selected-edge handles must not bypass the group operation contract: a mixed drag is refused as a whole, and a stationary selected-member click can collapse to that edge before its existing tools are used. Shift-click takes precedence over singleton bend insertion/reconnect hit paths in Select mode. Other active tools keep their existing ownership; switching tools retires gesture snapshots through the canvas epoch.

Use diagram_edit::translate_placements once for the full captured placement set. Apply its returned Batch(EditNode, SetEdgeRoute) through Document::execute once; after success restore the typed primary instead of adopting the Batch's final selected edge/node. Preview applies those same node/route commands to a cloned diagram and projects with the unchanged project semantics. Do not call prepare_command on a cloned whole project for every pointer frame. The ordinary preview already follows this bounded approach and is the reference to factor if sharing becomes simpler than two tiny adapters.

Entry/exit geometry is a required distinction

State projection constrains EntryPoint/ExitPoint display geometry to their owner's boundary, while authored placement positions remain stored. The movement planner must use authored positions and let projection::project_diagram derive displayed geometry. projection::translated_routes already calls state::boundary_moves_with: moving two endpoints does not translate pins if their entry/exit owner's boundary placement remains fixed. Moving the boundary and those descendants together translates pins once. A partial view without the boundary must retain the helper's existing rule; do not invent an implicit boundary placement.

Do not promise that moving an entry symbol's authored X coordinate causes the same displayed X displacement when its boundary is fixed. Preserve current State projection behavior and verify stored positions, projected symbols and route pins separately. This adapter does not change symbol sizing, container resizing, or route/traceability policies.

Pointer helper reuse has one consequential limit

diagram/group_gesture.rs::Gesture provides snapshot capture, viewport validity, irreversible threshold crossing, stationary collapse and return-to-origin no-op handling. Its current displacement is explicitly snapped to a 10-unit grid. Existing State node movement is unsnapped; State bend movement separately snaps to 5 units. Blindly reusing its constructor changes State movement behavior.

Preferred reversible design: reuse the lifecycle helper with an explicit small displacement policy (Unsnapped for State, existing grid behavior for Architecture), or separate displacement calculation from that helper. Preserve bend snapping unchanged. Avoid implementing another subtly different threshold/cancellation state machine merely to preserve the State delta calculation. Shared helper tests must cover both policies, off-grid starting points and out-and-back drags. This is an implementation choice, not a request for user approval.

Concrete touch map

File / function Required change Reusable boundary
app/multiselect.rs::multiselect_context Accept Page::Behavior only when state_context() resolves a real State view; retain Architecture path Same Context, Scope, Saved ID, snapshot generation
app/multiselect.rs::multiselect_boundary Include resolved State view identity and State primary placement so owner/view changes trigger reconciliation Existing revision/document/page invalidation
app/multiselect.rs::{visible, legacy_target, inspect_multiselect_primary, reconcile_multiselect} Resolve diagram from live context, use State placement field, admit exact visible State rows; retain lost-primary survivor behavior against semantic remapping Existing exact placement/edge handling and stale-generation checks
app/multiselect.rs::multiselect_event Require State Select tool for group selection/move/hide, preserve pending behavior/property/source guards, clear State bend/attachment primary details appropriately Existing placements, atomic movement and Hide transaction
behavior/state_edit.rs::Event and state_view.rs::event Transport typed selection events from Canvas<Event> to App; use a small event wrapper rather than duplicating selection commands Existing Message::DiagramSelection handler
app.rs::update early admission If selection events remain wrapped in Message::StateDiagram, validate their envelope before allocation/camera/reveal boundaries; alternatively map them to Message::DiagramSelection at the widget boundary and still guard any directly constructible nested variant Ordinary stale-operation guard
app/state_diagram.rs::edit_state_diagram Route wrapper to group handler without calling generic apply; preserve singleton state mutation plans; reconcile after accepted create/delete/Undo Existing validate_context, execute, state form guards
app.rs navigation / reset_state_view_after_navigation Clear set on BehaviorDiagram, owner navigation and explicit page transitions; detect implicit fallback to another saved State view Existing explicit Architecture selection reset
behavior/state_canvas.rs::{State, Press, update} Passive modifier tracking/reset, exact hit targets, group press lifecycle, no primary changes during selected-member press, Escape cancel-before-clear Focus wrapper and shared group lifecycle
behavior/state_canvas.rs::{Hit, hit, visible_row_relation} Preserve row placement in hit result; add exact row visibility predicate that checks this placement and relation, not any row in the diagram Existing state_action_rows / internal_rows geometry
behavior/state_canvas.rs::{scene_with_bounds, CachedScene, PaintKey, draw} Group planned-diagram preview; membership/primary-aware paint keys and rings for nodes, edges and rows; retain separate bend cache Existing State projection, boundary rules and routing
behavior/state_view.rs::view Pass selection into StateCanvas; show count and group actions without unstable sibling insertion before canvas Existing stable missing-controls slot, focus wrapper
behavior/state_menu.rs::popup Add/Remove exact target before singleton actions; selected-member multi-menu returns explicit group actions and suppresses singleton destructive/route actions Tokenized menu dispatch already preserves set for DiagramSelection events
app/diagram_removal.rs / State Delete dispatch For multi-selection emit full-set Hide or explicit unsupported mixed/destructive reason; never fall through to primary-only deletion Existing single-placement/row semantics remain for singleton

Context-menu rows need exact placement metadata as well as semantic relation ID. model_menu::Request::placement currently identifies the node to Hide, so do not overload it to mean a row owner without auditing singleton invocation. Prefer adding an optional typed selection target to the request (or a dedicated State capture field) while preserving existing semantic object/placement fields used by old actions. This is a boundary change justified by row occurrence identity, not a new serialization format.

Placement-only Hide removes only explicitly selected placements, not the movement descendant union; hidden descendant placements and engineering objects survive, as in current ordinary tests. Group semantic deletion remains a separate aggregate-impact transaction/review requirement from the contract. Do not ship an enabled primary-only Delete action under a group label. A first coherent State adapter may explicitly refuse unsupported mixtures and group semantic deletion while the shared aggregate-removal implementation is developed; record that limitation, rather than treating it as full issue completion.

Independent falsifiers and acceptance tasks

  1. Two unrelated states with internal hard pins: independently enumerate expected node and pin positions. One simultaneous move translates pins; one-end route remains fixed. Assert the entire expected Project, exact source recompile, one Undo/Redo, other State views and opposite-layer records unchanged.
  2. Select composite plus visible descendant, then fold the composite. Reconciliation prunes invisible selected targets while moving the still-selected composite carries hidden descendants exactly once. Test a directly captured nested set before folding as well.
  3. Select EntryPoint and inner leaf with fixed boundary: pins stay in world coordinates. Select the boundary as well: pins translate once. Include a partial view omitting the boundary and an ExitPoint case. Verify authored versus projected position separately.
  4. Shift held while Inspector owns focus, then first State click: exact target toggles. Passive modifiers during busy I/O do not acquire focus; window unfocus resets them. Typing/Delete/F2 while unfocused remain denied. Use the mounted App's production focus wrapper, not only Canvas Program tests.
  5. Native event batches: press on a selected member, inspector would otherwise change canvas layout, then motion/release. No primary mutation before gesture outcome; no singleton command. Stationary release collapses; threshold-crossing out-and-back/no-op retains set and creates no history. Fit/pan/viewport change retires stale geometry without redefining resize policy.
  6. Shift-click entry-action and internal-transition rows: target includes exact owner placement. Mixed row/node and edge/node movement or Hide refuses the whole set. A row cannot silently select/move its reusable work target or owning state.
  7. Select transition alone and exercise existing bend, endpoint and attachment interactions. Repeat with the transition in a group: group admission takes precedence. Cancel a provisional bend, right-click and verify committed pin indices remain unchanged. Reuse all existing State bend/attachment gates.
  8. Old Move/Hide/Collapse/Refuse snapshots after view switch, owner change, revision, remove/re-add ABA, tool change, same-semantic placement replacement: no model/source/history/selection/status/allocation side effects. Validate top-level App admission, not only state-edit planner rejection.
  9. Open menu on an unselected row/node, dismiss: preserve set/primary. Add retains prior members. Selected-member menu hides the complete captured placement set and offers no singleton Delete. Menus from former views never apply after navigation.
  10. Pending valid and invalid property forms, source drafts and State forms: preserve raw input, model and source; do not auto-apply before group edit. Hide/Undo restores exact authored source and geometry; primary-survivor rules do not resurrect removed selection membership.

Resolved questions and remaining bounded inspection

Resolved from code: no new target type is needed for State placements; row hits need stronger occurrence identity; State has unsnapped node moves; boundary route behavior is already in core; menu dispatch and App selection boundary must be extended beyond a page test.

Before implementation, inspect the actual then-current ordinary adapter and group_gesture API (they are still local work), and enumerate all StateCanvas struct literals in existing tests for the added selection field. Inspect behavior::selected_owner, BehaviorDiagram/owner navigation and any early-return App paths together when extending reconciliation; owner changes must not survive because active_diagram stayed constant. These are discoverable code questions, not user decisions. No additional external research or schema change is indicated.

Follow-up falsification review of the proposal

Read-only follow-up against the current local implementation confirms the approach, with these tighter constraints. This is a second pass by the same investigator, not an independent-agent approval or execution evidence.

  1. Row visibility must be occurrence-exact, not merely relation-valid. visible_row_relation finds the relation's current source and then accepts any rendered row for that source. It is adequate for existing singleton removal, but not sufficient to validate StateRow { placement, relation }. A relation can survive while being reassigned to another State or while its owner's placement is replaced. The group predicate must require the specified projected placement, the relation's current source equal to that placement's element, and membership in that placement's currently rendered state_action_rows / internal_rows. Falsifier: preserve the relation UUID, reassign its source to another placed State, then deliver the old row snapshot; never rebound selection to the new row. Both row kinds currently lose placement at hit and right-click capture, so fixing only one path is incomplete.

  2. Existing primary fallback is now stronger than the initial ordinary adapter. Current app/multiselect.rs preserves the reconciled survivor when held_primary || lost_primary, regardless of a legacy semantic-to-placement remap. Keep this rule for State; do not restore the older lost_primary && legacy.is_none() condition. State additionally needs an exact row primary marker because both an internal row and an edge inspect a relation UUID. behavior.diagram.placement remains a node-inspection field; putting a row owner there would allow legacy Hide to delete its state placement. The typed selection is authoritative for rows, while the inspector still addresses the relation. Falsifier: remove a primary row's occurrence, keep its relation/model work alive, and retain an unrelated selected node; that node becomes primary with one coherent inspector, including after Undo.

  3. Unchanged authored route does not imply unchanged rendered route or labels. translated_routes changes only pins when all actual endpoint owners and any required boundary move together; it preserves manual attachments and EdgeLabel fraction/offset. An external route's stored record stays byte-for-byte equal, while its automatic path and computed label position may change as one endpoint moves. State action/internal rows follow their projected state, and symbol_labels recomputes collision-aware derived positions after routing. Do not add a second label translation or freeze their old screen positions. Independent assertions must separate authored EdgeRoute equality from rendered geometry and symbol-label ownership. Include guards and multi-line transition labels, a State action referencing work shared by another State, and an external route with a manually offset label.

  4. Projected boundary geometry cannot be used as the movement anchor's original authored position. EntryPoint/ExitPoint X is snapped to the owner boundary by projection; Y is clamped below the header and above the bottom. StateCanvas currently captures the original from diagram.nodes[placement].position, correctly. A reusable group helper initialized from ProjectedNode.position can introduce a large unintended first delta even with an unsnapped policy. Falsifier: stored entry position differs substantially from its displayed boundary position; drag by 7.25 world units and require precisely that stored displacement, with independent projected-boundary expectations. Preserve unsnapped State node movement; bend snapping remains 5 units and ordinary movement 10.

  5. One shared plan is a contract, not sequential reuse of singleton movement. diagram::multiselect::group_preview already consumes only EditNode/SetEdgeRoute members of the pure planner's Batch into a cloned diagram. State can reuse that small application pattern. Neither preview nor commit may loop through state_edit::plan(Event::Move) or Command::MoveNode: each sees an incomplete movement map and misses internal pins between unrelated roots. Recheck all selection members against one original snapshot before constructing the plan; commit once through Document. A refused/out-of-bounds member must leave every other member, source and history unchanged.

Smallest safe implementation split

  • First: exact selection boundary and inspection, including State context, row hit identity, primary reconciliation, passive modifiers, menu Add/Remove/dismissal and visible membership. Until movement exists, intercept group drags/Delete with an explicit full-set refusal so no singleton fallback is reachable. Test actual App admission before shared side effects, plus same-semantic replacement and row reassignment. This is useful foundation, not completed State group support.
  • Second: placement movement, integrating unsnapped captured displacement, common atomic planner, diagram-only preview and paint-cache keys. Run independent whole-Project/source/history oracles plus mounted ordered-pointer/Inspector-relayout workflows. Re-run existing State bend/reconnect/attachment checks because hit-priority changes can regress them even without editing their planners.
  • Third: placement Hide and removal boundary, captured-set Hide with one Undo/Redo, survivor primary and unplaced-picker recovery. Mixed rows/edges and semantic group deletion must remain explicit refusal until the separate aggregate impact-review path exists. A group menu cannot expose enabled singleton destructive actions during any split.

Every split must reject pending property/source/State drafts without calling apply, and reject stale snapshots before status, camera, allocation or selection mutation. Advancing source history must retire old snapshots even if semantic/placement UUIDs are unchanged. Reuse existing ordinary stale-event loop for State Move/Hide/Collapse/Refuse with view, owner, tool, revision and selection-generation changes; add the row-source reassignment and entry authored-versus-projected tests above.