Record verified canvas union gates and allocation drag contract

This commit is contained in:
2026-09-11 11:24:41 -07:00
parent abffa4b3f0
commit 88c17a448f
54 changed files with 38021 additions and 14 deletions
+1
View File
@@ -61,3 +61,4 @@ harness/experiments/scenario-native-3c9e6ee-20260911/** -text whitespace=-blank-
harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/canvas-union-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
+20 -14
View File
@@ -11,8 +11,8 @@ Completed Linux evidence remains historical; the VPN remains untouched.
## Verified implementation ## Verified implementation
Production source **3c9e6eeb64ce3efc03b29890b82199a42c21f335** on Previous native acceptance uses source **3c9e6eeb64ce3efc03b29890b82199a42c21f335** on
`codex/behavior-authoring-20260910` passes720/720 Windows desktop tests and strict `codex/behavior-authoring-20260910`. It passes 720/720 Windows desktop tests and strict
all-target desktop Clippy. It includes direct scenario/process authoring, all-target desktop Clippy. It includes direct scenario/process authoring,
parent-focus and editing-session/chooser corrections, and boxed workflow events. parent-focus and editing-session/chooser corrections, and boxed workflow events.
Evidence checkpoint e7491fb preserves exact logs. Instruction-only merge828c50c Evidence checkpoint e7491fb preserves exact logs. Instruction-only merge828c50c
@@ -22,31 +22,34 @@ was read without overwriting the user's local specification.
The matching GUI built warning-free and passed recovered SA State, clicked Reuse, The matching GUI built warning-free and passed recovered SA State, clicked Reuse,
and ALT Fragment creation with exact Save/Undo/Redo and final native Open/Save at and ALT Fragment creation with exact Save/Undo/Redo and final native Open/Save at
the1100x700 client minimum. The whole Project, all identities and both source files the 1100x700 client minimum. The whole Project, all identities and both source files
match every required history checkpoint. The native session is closed. match every required history checkpoint. The native session is closed.
- [Windows regression/lint evidence](release-chooser-correction-gate-20260911.md) - [Windows regression/lint evidence](release-chooser-correction-gate-20260911.md)
- [Current native SA evidence](release-scenario-native-3c9e6ee-20260911.md) - [Previous native SA evidence](release-scenario-native-3c9e6ee-20260911.md)
- [Earlier native OA/SA Action evidence, scoped to80cf197](release-scenario-native-20260911.md) - [Earlier native OA/SA Action evidence, scoped to 80cf197](release-scenario-native-20260911.md)
- [Scenario oracle and reproducible procedure](fixtures/scenario-authoring/README.md) - [Scenario oracle and reproducible procedure](fixtures/scenario-authoring/README.md)
## Isolated contributions and material limits ## Isolated contributions and material limits
Core candidate **847b1caa7e75bb82f6ce5193c45bcfc45973ad1a** is on the separate Core candidate **847b1caa7e75bb82f6ce5193c45bcfc45973ad1a** is on the separate
`codex/analysis-core-956atf-20260911` publication branch. Windows execution passes `codex/analysis-core-956atf-20260911` publication branch. Windows execution passes
the128KiB-thread lifecycle subprocess, all6 codec tests,28 analysis tests,26 numeric the 128 KiB-thread lifecycle subprocess, all 6 codec tests, 28 analysis tests, 26 numeric
tests and strict all-target core Clippy. These counts are separate scopes; the tests and strict all-target core Clippy. These counts are separate scopes; the
subprocess driver is included in the6. Production `DEPTH_128_VALIDATED` remains subprocess driver is included in the 6. Production `DEPTH_128_VALIDATED` remains
false. Legacy/migration/save integration, release memory measurements and complete false. Legacy/migration/save integration, release memory measurements and complete
source8/transport/evaluator acceptance remain open. source8/transport/evaluator acceptance remain open.
[Windows codec evidence](release-analysis-codec-windows-gate-20260911.md). [Windows codec evidence](release-analysis-codec-windows-gate-20260911.md).
The isolated R123 canvas source has no remaining material finding in its bounded The integrated R123 canvas at **ff0175488ac57a92d401b00a0e4542582a82d8e9** passes
[independent review](release-behavior-canvas-r123-review-20260911.md). 729/729 desktop tests with a warning-free compiler, including the 17 new mounted
Its17 production mounted and2 shared tests are still unexecuted and it is not in cases and preserved parent-focus/session gates. The guide refresh at 82fd0ac
the accepted GUI. Parent/child native focus, the complete direct-manipulation passes 3 guide tests. Strict Clippy at abffa4b identifies four obsolete canvas
matrix, persistent process placement/lanes and coherent B4/source integration are variants; no post-cleanup or current GUI pass is included in this checkpoint.
not established by the scoped SA creation test. [Closed gate and exact evidence](release-behavior-canvas-windows-gate-20260911.md),
[independent union review](release-behavior-canvas-union-review-20260911.md).
Parent/child native focus, complete direct manipulation, persistent process
placement/lanes and coherent B4/source integration remain unverified here.
Complete quantities/calculations/constraints, maintained libraries, graphical/ Complete quantities/calculations/constraints, maintained libraries, graphical/
source/REST parity, transitions and engineering-task usability, aligned reST/help, source/REST parity, transitions and engineering-task usability, aligned reST/help,
@@ -55,8 +58,11 @@ prototypes and published previews are intermediate results.
## Authoritative records ## Authoritative records
- [Live priorities, dependencies, ownership and migration record](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-412) - [Project overview and historical coordination](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1)
- [Direct process/scenario canvas work](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2) - [Direct process/scenario canvas work](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2)
- [Activity allocation drag](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3)
- [Analysis, codec and source/API integration](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/7)
- [Maintained reusable libraries](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/8)
- [Decisions and reusable evidence](DECISIONS.md) - [Decisions and reusable evidence](DECISIONS.md)
- [Shared overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/wiki/BehaviorAuthoring20260911) - [Shared overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/wiki/BehaviorAuthoring20260911)
- [Published milestones and their exact limitations](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases) - [Published milestones and their exact limitations](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases)
@@ -0,0 +1 @@
fb1da912c9951f33feed2fad9ad689bc46cd20d0
@@ -0,0 +1,26 @@
Checking oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
error: this `if` statement can be collapsed
--> crates\oa-desktop\src\behavior\canvas_workspace\control.rs:158:13
|
158 | / if matches!(
159 | | event,
160 | | Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
161 | | ) {
... |
169 | | }
| |_____________^
|
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#collapsible_if
= note: `-D clippy::collapsible-if` implied by `-D warnings`
= help: to override `-D warnings` add `#[allow(clippy::collapsible_if)]`
help: collapse nested if block
|
161 ~ )
162 ~ && !layout
163 | .bounds()
...
167 | state.focused = false;
168 ~ }
|
error: could not compile `oa-desktop` (bin "arcadia-studio" test) due to 1 previous error
@@ -0,0 +1,7 @@
{
"source": "82fd0acf64d87f322e12602154f14a565b29de4c",
"start": "2026-09-11T18:06:04.2394485Z",
"end": "2026-09-11T18:07:10.7060485Z",
"exit": 101,
"command": "cargo clippy --locked -p oa-desktop --all-targets -- -D warnings"
}
@@ -0,0 +1,44 @@
Checking oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
error: variant `First` is never constructed
--> crates\oa-desktop\src\behavior\scenario.rs:99:5
|
98 | pub enum CanvasEvent {
| ----------- variant in this enum
99 | First(Context, Tool),
| ^^^^^
|
= note: `CanvasEvent` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
= note: `-D dead-code` implied by `-D warnings`
= help: to override `-D warnings` add `#[expect(dead_code)]` or `#[allow(dead_code)]`
error: variant `Canvas` is never constructed
--> crates\oa-desktop\src\behavior\scenario.rs:133:5
|
132 | pub enum ScrollArea {
| ---------- variant in this enum
133 | Canvas,
| ^^^^^^
|
= note: `ScrollArea` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
error: variant `Tools` is never constructed
--> crates\oa-desktop\src\behavior\scenario\workspace.rs:10:5
|
9 | pub enum Shelf {
| ----- variant in this enum
10 | Tools,
| ^^^^^
|
= note: `Shelf` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
error: variant `Tools` is never constructed
--> crates\oa-desktop\src\behavior\topology.rs:62:5
|
61 | pub enum Shelf {
| ----- variant in this enum
62 | Tools,
| ^^^^^
|
= note: `Shelf` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
error: could not compile `oa-desktop` (bin "arcadia-studio") due to 4 previous errors
@@ -0,0 +1,7 @@
{
"source": "abffa4b3f0a859d7439103100f59805804d46e55",
"start": "2026-09-11T18:08:58.7613771Z",
"end": "2026-09-11T18:10:49.3969111Z",
"exit": 101,
"command": "cargo clippy --locked -p oa-desktop --all-targets -- -D warnings"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
warning: type `traceability::Event` is more private than the item `app::Message::Traceability::0`
--> crates\oa-desktop\src\app.rs:339:18
|
339 | Traceability(crate::views::traceability::Event),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::Message::Traceability::0` is reachable at visibility `pub`
|
note: but type `traceability::Event` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:12:1
|
12 | pub(crate) enum Event {
| ^^^^^^^^^^^^^^^^^^^^^
= note: `#[warn(private_interfaces)]` on by default
warning: type `traceability::State` is more private than the item `app::App::traceability`
--> crates\oa-desktop\src\app.rs:467:5
|
467 | pub traceability: crate::views::traceability::State,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::App::traceability` is reachable at visibility `pub`
|
note: but type `traceability::State` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:17:1
|
17 | pub(crate) struct State {
| ^^^^^^^^^^^^^^^^^^^^^^^
warning: `oa-desktop` (bin "arcadia-studio" test) generated 2 warnings
Finished `test` profile [unoptimized] target(s) in 3m 45s
Running unittests src\main.rs (target\debug\deps\arcadia_studio-e358a5ed59b1de41.exe)
running 2 tests
test behavior::canvas_workspace::tests::relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs ... ok
test behavior::canvas_workspace::tests::viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 727 filtered out; finished in 0.01s
@@ -0,0 +1,8 @@
{
"source": "06ec9621297627a1f7d5c3bc65efb237d1c9923e",
"start": "2026-09-11T17:48:56.3690729Z",
"end": "2026-09-11T17:52:41.9776419Z",
"exit": 0,
"inputs": 281,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio canvas_workspace::tests -- --test-threads=1 --nocapture"
}
@@ -0,0 +1,62 @@
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
error[E0061]: this method takes 2 arguments but 3 arguments were supplied
--> crates\oa-desktop\src\behavior\canvas_workspace\tests.rs:268:23
|
268 | self.renderer.screenshot(
| ^^^^^^^^^^
269 | Size::new(app.window_size.width as u32, app.window_size.height as u32),
270 | 1.,
| -- unexpected argument #2 of type `{float}`
|
note: expected `&Viewport`, found `Size<u32>`
--> crates\oa-desktop\src\behavior\canvas_workspace\tests.rs:269:13
|
269 | Size::new(app.window_size.width as u32, app.window_size.height as u32),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= note: expected reference `&iced::advanced::iced_graphics::Viewport`
found struct `iced::Size<u32>`
note: method defined here
--> C:\Users\User\Documents\Git\ARCADIA-Studio\.local\toolchain\cargo\registry\src\index.crates.io-1949cf8c6b5b557f\iced_wgpu-0.14.0\src\lib.rs:183:12
|
183 | pub fn screenshot(
| ^^^^^^^^^^
help: remove the extra argument
|
269 - Size::new(app.window_size.width as u32, app.window_size.height as u32),
270 - 1.,
269 + /* &iced::advanced::iced_graphics::Viewport */,
|
error[E0308]: mismatched types
--> crates\oa-desktop\src\behavior\topology\layout_tests.rs:726:82
|
726 | let task = app.update(Message::Workflow(crate::workflow::Event::Semantic(message)));
| -------------------------------- ^^^^^^^ expected `Box<Event>`, found `Event`
| |
| arguments to this enum variant are incorrect
|
= note: expected struct `Box<semantic_review::batch::Event>`
found enum `semantic_review::batch::Event`
= note: for more on the distinction between the stack and the heap, read https://doc.rust-lang.org/book/ch15-01-box.html, https://doc.rust-lang.org/rust-by-example/std/box.html, and https://doc.rust-lang.org/std/boxed/index.html
note: tuple variant defined here
--> crates\oa-desktop\src\workflow.rs:22:5
|
22 | Semantic(Box<crate::semantic_review::Event>),
| ^^^^^^^^
help: store this in the heap by calling `Box::new`
|
726 | let task = app.update(Message::Workflow(crate::workflow::Event::Semantic(Box::new(message))));
| +++++++++ +
warning: unused import: `iced::advanced::renderer::Headless`
--> crates\oa-desktop\src\behavior\canvas_workspace\tests.rs:239:13
|
239 | use iced::advanced::renderer::Headless;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
Some errors have detailed explanations: E0061, E0308.
For more information about an error, try `rustc --explain E0061`.
warning: `oa-desktop` (bin "arcadia-studio" test) generated 1 warning
error: could not compile `oa-desktop` (bin "arcadia-studio" test) due to 2 previous errors; 1 warning emitted
@@ -0,0 +1,7 @@
{
"source": "6969c825a8340980b6a419524ade3bb687a6051f",
"start": "2026-09-11T17:46:35.5365176Z",
"end": "2026-09-11T17:48:07.8569937Z",
"exit": 101,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio canvas_workspace::tests -- --test-threads=1 --nocapture"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,737 @@
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
Finished `test` profile [unoptimized] target(s) in 23.89s
Running unittests src\main.rs (target\debug\deps\arcadia_studio-e358a5ed59b1de41.exe)
running 729 tests
test app::architecture_authoring::tests::architecture_allocated_work_is_root_owned_and_one_exact_history_change ... ok
test app::architecture_authoring::tests::architecture_blank_names_and_escape_never_accept_placeholder_or_rename ... ok
test app::architecture_authoring::tests::architecture_captured_escape_does_not_cancel_other_controls_or_placement ... ok
test app::architecture_authoring::tests::architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo ... ok
test app::architecture_authoring::tests::architecture_first_focused_input_escape_cancels_create_and_rename ... ok
test app::architecture_authoring::tests::architecture_invalid_click_cannot_poison_inline_or_checkpoint_geometry ... ok
test app::architecture_authoring::tests::architecture_stale_submit_keeps_create_and_rename_visible_and_exact ... ok
test app::architecture_authoring_tests::architecture_cancelled_gesture_cannot_commit_a_later_named_draft ... ok
test app::architecture_authoring_tests::architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan ... ok
test app::architecture_authoring_tests::architecture_checkpoint_retains_exact_name_point_and_fresh_binding_without_commands ... ok
test app::architecture_authoring_tests::architecture_context_choices_are_small_and_system_is_singleton ... ok
test app::architecture_authoring_tests::architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo ... ok
test app::architecture_authoring_tests::architecture_creation_names_before_commit_preserves_camera_and_undoes_once ... ok
test app::architecture_authoring_tests::architecture_existing_inventory_is_contextual_and_placement_never_implies_allocation ... ok
test app::architecture_authoring_tests::architecture_f2_rename_preserves_identity_placement_and_atomic_history ... ok
test app::architecture_authoring_tests::architecture_name_survives_rejected_click_resize_navigation_and_escape_is_explicit ... ok
test app::architecture_authoring_tests::architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored ... ok
test app::architecture_authoring_tests::basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture ... ok
test app::behavior_lifecycle_tests::incomplete_behavior_form_is_dirty_close_save_retains_it_and_cancel_allows_switching ... ok
test app::behavior_lifecycle_tests::passive_behavior_canvas_events_do_not_apply_property_drafts ... ok
test app::behavior_lifecycle_tests::pending_behavior_blocks_navigation_property_edits_and_model_history ... ok
test app::behavior_lifecycle_tests::pending_behavior_blocks_source_creation_and_source_history_replay ... ok
test app::behavior_lifecycle_tests::save_prepares_behavior_before_opening_destination_and_cancel_keeps_one_undo ... ok
test app::behavior_lifecycle_tests::source_writeback_failure_keeps_typed_behavior_form_for_repair_and_one_undo ... ok
test app::checkpoints::tests::checkpoint_completion_releases_the_writer_while_a_context_menu_is_open ... ok
test app::checkpoints::tests::checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history ... ok
test app::checkpoints::tests::checkpoint_noninline_contexts_keep_other_focus_and_still_lint ... ok
test app::checkpoints::tests::discard_and_close_waits_for_the_late_checkpoint_then_retires_it ... ok
test app::checkpoints::tests::home_resume_keeps_state_view_while_explicit_architecture_remains_reachable ... ok
test app::checkpoints::tests::home_resume_retains_invalid_source_and_property_drafts ... ok
test app::checkpoints::tests::home_resume_returns_to_each_actual_workspace_without_model_edits ... ok
test app::checkpoints::tests::malformed_checkpoint_does_not_replace_the_current_model ... ok
test app::checkpoints::tests::recent_and_recovery_navigation_respect_unsaved_decision ... ok
test app::checkpoints::tests::restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding ... ok
test app::checkpoints::tests::saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work ... ok
test app::checkpoints::tests::stale_write_completion_cannot_recover_a_discarded_document ... ok
test app::checkpoints::tests::walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard ... ok
test app::data_paging_lifecycle_tests::paging_preserves_same_context_and_resets_on_layer_or_document_replacement ... ok
test app::diagram_selection_tests::changing_visible_view_follows_its_clean_inspector_without_a_model_edit ... ok
test app::diagram_selection_tests::diagram_switch_preserves_an_independent_semantic_inspection ... ok
test app::diagram_selection_tests::diagram_uuid_edit_follows_the_same_view_or_a_different_picker_target ... ok
test app::diagram_selection_tests::invalid_view_draft_blocks_the_switch_and_retains_raw_input ... ok
test app::diagram_selection_tests::stale_diagram_choice_never_applies_an_unrelated_valid_draft ... ok
test app::diagram_selection_tests::valid_pending_view_edit_applies_to_old_view_then_inspects_the_new_view ... ok
test app::direct_scenario_tests::direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once ... ok
test app::direct_scenario_tests::direct_scenario_rejects_every_stale_context_and_preserves_property_drafts ... ok
test app::direct_scenario_tests::direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context ... ok
test app::direct_scenario_tests::direct_scenario_source_failure_and_navigation_keep_form_until_success ... ok
test app::hosted::tests::hosted_actual_app_tasks_publish_edit_conflict_correct_credentials_reload_and_open ... ok
test app::hosted::tests::hosted_catalog_invalidation_follows_service_writes_and_observed_revision_changes ... ok
test app::hosted::tests::hosted_catalog_search_credentials_and_failed_refresh_never_mix_result_scopes ... ok
test app::hosted::tests::hosted_changed_bound_head_does_not_invalidate_another_services_empty_filter ... ok
test app::hosted::tests::hosted_conflict_fetches_review_without_adoption_and_reload_is_undoable ... ok
test app::hosted::tests::hosted_detached_drafts_and_recovery_remain_available_after_rejected_publish ... ok
test app::hosted::tests::hosted_failed_cross_service_publication_checks_and_reloads_its_own_resource ... ok
test app::hosted::tests::hosted_failed_or_cancelled_local_open_keeps_the_existing_document_and_destination ... ok
test app::hosted::tests::hosted_invalid_and_detached_source_drafts_never_start_network_writes ... ok
test app::hosted::tests::hosted_invalid_local_open_repair_saves_locally_and_never_replaces_old_resource ... ok
test app::hosted::tests::hosted_modal_blocks_underlying_edits_and_credentials_are_redacted ... ok
test app::hosted::tests::hosted_new_revision_invalidates_filtered_catalog_even_without_a_displayed_row ... ok
test app::hosted::tests::hosted_only_successful_source_save_as_changes_the_active_destination ... ok
test app::hosted::tests::hosted_open_waits_for_unsaved_guard_and_failed_open_preserves_current_document ... ok
test app::hosted::tests::hosted_publish_acknowledges_only_the_sent_snapshot_and_retains_undo ... ok
test app::hosted::tests::hosted_stale_completions_do_not_clear_current_busy_or_adopt_another_document ... ok
test app::hosted::tests::hosted_unknown_save_is_acknowledged_by_exact_readback_without_retry ... ok
test app::lint_tests::committed_check_cache_follows_edit_and_undo_while_drafts_stay_separate ... ok
test app::lint_tests::lint_coalesces_work_and_rejects_obsolete_results ... ok
test app::lint_tests::prior_document_lint_cannot_enter_reopened_model_or_block_next_checks ... ok
test app::port_view_tests::catalog_creation_follows_the_new_port_and_keeps_group_browsing_explicit ... ok
test app::port_view_tests::filtered_active_view_cannot_hide_a_created_port_when_eligible_owner_view_is_folded ... ok
test app::process_canvas::direct_process_dirty_source_blocks_canvas_creation_without_losing_text ... ok
test app::process_canvas::tests::direct_process_app_open_source_and_back_preserve_local_reference_identity ... ok
test app::process_canvas::tests::direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it ... ok
test app::process_canvas::tests::direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin ... ok
test app::process_canvas::tests::direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence ... ok
test app::process_canvas::tests::direct_process_app_stale_context_dirty_source_and_properties_never_mutate ... ok
test app::process_canvas::tests::direct_process_append_empty_first_second_exchange_has_exact_source_history ... ok
test app::process_canvas::tests::process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore ... ok
test app::reference_authoring::tests::chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out ... ok
test app::reference_authoring::tests::inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo ... ok
test app::reference_authoring::tests::inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear ... ok
test app::reference_authoring::tests::mounted::mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form ... ok
test app::reference_authoring::tests::mounted::mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus ... ok
test app::reference_authoring::tests::mounted::mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again ... ok
test app::reference_authoring::tests::mounted::mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field ... ok
test app::reference_authoring::tests::mounted::mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields ... ok
test app::reference_authoring::tests::mounted::mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form ... ok
test app::reference_authoring::tests::mounted::mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive ... ok
test app::reference_authoring::tests::mounted::mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard ... ok
test app::reference_authoring::tests::reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field ... ok
test app::reference_authoring::tests::review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus ... ok
test app::reference_authoring::tests::review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet ... ok
test app::review_navigation_tests::rejected_semantic_source_writeback_retains_review_camera_and_original_navigation ... ok
test app::review_navigation_tests::review_already_showing_the_target_keeps_its_camera_and_query ... ok
test app::review_navigation_tests::semantic_name_take_selects_the_counterpart_view_in_both_directions_with_one_exact_history_entry ... ok
test app::review_navigation_tests::semantic_review_without_a_destination_view_clears_the_old_layer_canvas_without_creating_a_view ... ok
test app::save_as_tests::save_as_late_receipt_preserves_newer_raw_source_draft_and_destination ... ok
test app::save_as_tests::save_as_preflight_rejects_historical_collision_before_starting_io ... ok
test app::save_as_tests::save_as_receipt_then_one_undo_restores_meaning_and_saving_preserves_redo ... ok
test app::source_project_tests::closed_detached_file_keeps_first_save_conflict_then_can_be_reloaded_fresh ... ok
test app::source_project_tests::inactive_invalid_draft_blocks_graphical_changes_and_diagnostic_switches_files ... ok
test app::source_project_tests::json_export_and_rejected_save_as_preserve_source_location_history_and_bytes ... ok
test app::source_project_tests::missing_include_open_keeps_old_model_and_can_be_repaired_and_saved ... ok
test app::source_project_tests::recovery_after_first_save_failure_retains_target_then_retries_without_reentering_path ... ok
test app::source_project_tests::recovery_of_previous_save_as_target_does_not_redirect_current_project ... ok
test app::source_project_tests::recovery_open_finds_first_save_journal_without_an_entry_file ... ok
test app::source_project_tests::recovery_target_survives_edit_lint_apply_and_rejected_recovery_until_success ... ok
test app::source_project_tests::source3_native_open_preserves_report_comments_nesting_and_save_reopen ... ok
test app::source_project_tests::two_file_apply_graphical_edit_exact_undo_save_as_and_reopen ... ok
test app::state_diagram_tests::all_context_dimensions_reject_stale_selection_camera_and_model_messages ... ok
test app::state_diagram_tests::create_view_places_only_its_machine_and_restores_exact_source_in_one_undo ... ok
test app::state_diagram_tests::direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history ... ok
test app::state_diagram_tests::fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move ... ok
test app::state_diagram_tests::pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs ... ok
test app::state_diagram_tests::real_view_tool_and_history_changes_invalidate_old_release_events ... ok
test app::state_diagram_tests::rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry ... ok
test app::state_diagram_tests::same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera ... ok
test app::state_diagram_tests::shared_semantic_states_keep_architecture_and_behavior_placements_independent ... ok
test app::state_diagram_tests::state_diagram_routes_constructor_and_source_open_to_the_existing_workspace_without_edits ... ok
test app::state_diagram_tests::state_diagram_routes_picker_and_reveal_but_explicit_architecture_does_not_bounce ... ok
test app::state_diagram_tests::state_diagram_routes_preserve_selected_raw_properties_and_pending_behavior_guards ... ok
test app::state_diagram_tests::state_navigation_catalog_selection_across_layers_resets_camera_and_tool ... ok
test app::state_diagram_tests::state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool ... ok
test app::state_diagram_tests::state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size ... ok
test app::state_diagram_tests::state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode ... ok
test app::state_diagram_tests::state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool ... ok
test app::transition_context_tests::explicit_behavior_internal_and_process_roots_keep_their_workspace_without_creating_a_view ... ok
test app::transition_context_tests::explicit_destination_is_honored_and_ordinary_work_transition_still_places_in_architecture ... ok
test app::transition_context_tests::machine_and_typed_action_transitions_resolve_the_owner_and_reusing_that_context_retains_camera ... ok
test app::transition_context_tests::rejected_source_writeback_keeps_review_and_navigation_and_invalid_draft_still_blocks_start ... ok
test app::transition_context_tests::scenario_transition_focuses_requested_counterpart_and_preserves_unrelated_views_in_both_directions ... ok
test app::view_copy_tests::copying_architecture_keeps_architecture_even_with_a_retained_state_view ... ok
test app::view_copy_tests::copying_the_current_state_view_uses_its_own_identity_and_is_one_source_undo ... ok
test app::view_copy_tests::duplicate_rejection_preserves_an_incomplete_source_buffer ... ok
test app::view_copy_tests::duplicate_rejection_preserves_invalid_property_and_pending_behavior_drafts ... ok
test app::view_copy_tests::stale_copy_does_not_apply_an_unrelated_valid_property_draft ... ok
test behavior::canvas_workspace::tests::relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs ... ok
test behavior::canvas_workspace::tests::viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion ... ok
test behavior::contextual_source_tests::contextual_source_app_checkpoint_restores_inner_raw_draft_without_model_adoption ... ok
test behavior::contextual_source_tests::contextual_source_app_empty_actions_and_child_open_refuse_other_raw_editors ... ok
test behavior::contextual_source_tests::contextual_source_app_modal_escape_then_child_back_then_parent_cancel_preserve_accepted_source ... ok
test behavior::contextual_source_tests::contextual_source_back_remains_available_after_owner_deletion_and_with_dirty_editors ... ok
test behavior::contextual_source_tests::contextual_source_back_retains_parent_and_rejects_late_child_event ... ok
test behavior::contextual_source_tests::contextual_source_default_consumer_name_has_stable_alias_and_correlated_selection ... ok
test behavior::contextual_source_tests::contextual_source_deleted_owner_view_retains_draft_instead_of_panicking_or_using_fallback ... ok
test behavior::contextual_source_tests::contextual_source_empty_history_reports_no_change_and_success_requires_a_real_history_step ... ok
test behavior::contextual_source_tests::contextual_source_existing_choice_prunes_staged_dependency ... ok
test behavior::contextual_source_tests::contextual_source_failed_source_budget_preserves_both_drafts_and_accepted_bytes ... ok
test behavior::contextual_source_tests::contextual_source_incomplete_recovered_kind_reports_error_without_panicking ... ok
test behavior::contextual_source_tests::contextual_source_inserts_work_on_the_exact_guarded_link_and_refuses_exchange_identity_conflict ... ok
test behavior::contextual_source_tests::contextual_source_lifelines_stage_exact_separate_records_and_one_history_entry ... ok
test behavior::contextual_source_tests::contextual_source_machine_before_any_owner_creates_optional_view_atomically ... ok
test behavior::contextual_source_tests::contextual_source_mounted_small_editor_keeps_back_and_use_outside_field_scroll ... ok
test behavior::contextual_source_tests::contextual_source_nested_semantic_parent_is_explicit_and_revalidated ... ok
test behavior::contextual_source_tests::contextual_source_new_callee_lifeline_preserves_caller_bindings_as_an_explicit_warning ... ok
test behavior::contextual_source_tests::contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer ... ok
test behavior::contextual_source_tests::contextual_source_original_document_remains_bound_after_back_or_choosing_existing ... ok
test behavior::contextual_source_tests::contextual_source_owner_first_can_return_to_an_unnamed_machine_without_losing_source ... ok
test behavior::contextual_source_tests::contextual_source_recovery_keeps_inner_raw_fields_and_refuses_deleted_or_changed_owner ... ok
test behavior::contextual_source_tests::contextual_source_second_system_and_role_machine_owner_refuse_without_fallback ... ok
test behavior::contextual_source_tests::contextual_source_work_uses_reserved_contribution_and_link_ids_and_one_undo ... ok
test behavior::creation::app_tests::app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft ... ok
test behavior::creation::app_tests::app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior ... ok
test behavior::creation::app_tests::app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation ... ok
test behavior::creation::app_tests::app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft ... ok
test behavior::creation::app_tests::app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction ... ok
test behavior::creation::app_tests::app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts ... ok
test behavior::creation::app_tests::app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts ... ok
test behavior::creation::app_tests::app_stale_layer_open_never_starts_or_replaces_a_draft ... ok
test behavior::creation::tests::all_four_viewpoints_are_explicit_and_survive_source_history_reopen ... ok
test behavior::creation::tests::cancel_and_illegal_family_layer_switches_never_create_model_objects ... ok
test behavior::creation::tests::corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values ... ok
test behavior::creation::tests::creation_selection_rejects_changed_scenario_body_and_post_creation_operations ... ok
test behavior::creation::tests::machine_requires_valid_owner_and_first_view_adds_no_region_or_state ... ok
test behavior::creation::tests::mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates ... ok
test behavior::creation::tests::oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry ... ok
test behavior::creation::tests::optional_context_collapses_without_losing_authored_reference_or_raw_search ... ok
test behavior::creation::tests::owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog ... ok
test behavior::creation::tests::post_commit_selection_is_correlated_with_exact_created_behavior_and_view ... ok
test behavior::creation::tests::recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner ... ok
test behavior::creation::tests::rejected_source_transaction_keeps_draft_and_every_accepted_source_byte ... ok
test behavior::creation::tests::repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias ... ok
test behavior::direct_canvas_tests::direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand ... ok
test behavior::direct_canvas_tests::direct_bounds_change_during_held_drag_never_commits_old_slot ... ok
test behavior::direct_canvas_tests::direct_clicked_occurrence_capture_cancels_on_view_and_context_changes ... ok
test behavior::direct_canvas_tests::direct_context_escape_unfocus_and_pending_form_cancel_gestures ... ok
test behavior::direct_canvas_tests::direct_drag_and_click_click_use_ordered_sources_and_publish_once ... ok
test behavior::direct_canvas_tests::direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call ... ok
test behavior::direct_canvas_tests::direct_scroll_resize_and_bounds_changes_cancel_captured_slots ... ok
test behavior::direct_canvas_tests::direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot ... ok
test behavior::direct_canvas_tests::direct_selected_handle_reorders_and_hits_keep_semantic_identity ... ok
test behavior::direct_canvas_tests::reused_exchange_messages_display_their_distinct_occurrence_names ... ok
test behavior::scenario::data_contract_tests::large_payload_has_one_editor_with_reachable_field_and_authored_pages ... ok
test behavior::scenario::data_contract_tests::recursive_browsing_up_and_root_keep_values_and_report_the_depth_limit ... ok
test behavior::scenario::data_contract_tests::recursive_value_focus_exposes_authored_leaf_and_phase_changes_retain_surviving_keys ... ok
test behavior::scenario::data_contract_tests::reply_call_picker_previews_the_same_value_reset_as_selecting_another_exchange ... ok
test behavior::scenario::data_contract_tests::scenario_form_commits_two_reused_record_values_and_correlated_reply_atomically ... ok
test behavior::scenario::direct_tests::direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture ... ok
test behavior::scenario::direct_tests::direct_clicked_occurrence_rejects_stale_slots_and_retains_recoverable_drafts ... ok
test behavior::scenario::direct_tests::direct_clicked_occurrences_use_root_and_operand_slots_with_exact_source_history ... ok
test behavior::scenario::direct_tests::direct_exchange_compatibility_browsing_preserves_final_slot_validation ... ok
test behavior::scenario::direct_tests::direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values ... ok
test behavior::scenario::direct_tests::direct_five_hundred_exchange_choice_render_probe ... ok
test behavior::scenario::direct_tests::direct_form_keeps_the_same_canvas_in_the_widget_tree ... ok
test behavior::scenario::direct_tests::direct_middle_pair_insertion_is_one_exact_source_and_model_undo ... ok
test behavior::scenario::direct_tests::direct_operand_pair_keeps_parent_sequence_and_cannot_reorder_across_operands ... ok
test behavior::scenario::direct_tests::direct_reply_choices_cache_keeps_typing_fast_and_revalidates_changed_contexts ... ok
test behavior::scenario::direct_tests::direct_reply_choices_validate_the_actual_slot_and_reorder_rejects_reverse_order ... ok
test behavior::scenario::direct_tests::direct_scenario_reference_navigation_blocks_pending_drafts_and_stale_contexts ... ok
test behavior::scenario::direct_tests::direct_scenario_reference_navigation_retains_each_exact_caller_without_history ... ok
test behavior::scenario::direct_tests::direct_scenario_reference_return_survives_rename_and_rejects_deleted_or_changed_use ... ok
test behavior::scenario::operation_tests::operation_gui_async_picker_uses_request_root_and_nested_directions_without_erasing_rejected_values ... ok
test behavior::scenario::operation_tests::operation_gui_selects_two_parameterless_allocations_without_inventing_refinement_and_reply_derives_call ... ok
test behavior::scenario::operation_tests::operation_gui_unresolved_and_disabled_selection_recovery_preserves_rejected_raw_values ... ok
test behavior::scenario::tests::fragment_operands_accept_paired_messages_through_normal_destination_picker ... ok
test behavior::scenario::tests::graphical_call_reply_is_atomic_correlated_and_source_equivalent ... ok
test behavior::scenario::tests::invalid_lifecycle_edit_keeps_draft_and_timer_has_no_global_exchange ... ok
test behavior::scenario::tests::lifeline_picker_uses_core_viewpoint_contract ... ok
test behavior::scenario::tests::reverse_communication_canvas_choices_and_correlated_reply_share_core_direction ... ok
test behavior::scenario::tests::scenario_reuse_requires_explicit_compatible_bindings ... ok
test behavior::scenario::tests::tagged_scenario_commit_is_not_redirected_by_other_editor_events ... ok
test behavior::scenario::workspace::tests::new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session ... ok
test behavior::scenario::workspace::tests::production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas ... ok
test behavior::scenario::workspace::tests::production_scenario_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_pan_invalidates_a_pressed_creation_and_caller_restores_once ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_enters_activates_draws_focus_and_returns_to_inspector ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_reveals_and_activates_initially_offscreen_last_tool ... ok
test behavior::scenario::workspace::tests::production_scenario_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes ... ok
test behavior::scenario::workspace::tests::production_scenario_whole_glyph_drag_moves_one_occurrence_and_rejects_other_operand ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_chained_apply_rechecks_live_review_owner ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals ... ok
test behavior::scenario::workspace::tests::scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors ... ok
test behavior::scenario::workspace::tests::scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs ... ok
test behavior::scenario::workspace::tests::scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after_preselection ... ok
test behavior::scenario::workspace::tests::scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport ... ok
test behavior::scenario::workspace::tests::scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints ... ok
test behavior::scenario::workspace::tests::scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_identity ... ok
test behavior::scenario::workspace::tests::scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms ... ok
test behavior::scenario_lifecycle::tests::alternative_death_does_not_kill_its_displayed_sibling ... ok
test behavior::scenario_lifecycle::tests::conditional_birth_does_not_infer_absence_in_siblings_or_ancestors ... ok
test behavior::scenario_lifecycle::tests::explicit_receiver_is_authoritative_even_with_missing_exchange ... ok
test behavior::scenario_lifecycle::tests::lifecycle_free_fragments_do_not_poison_other_lanes ... ok
test behavior::scenario_lifecycle::tests::loop_lifetime_is_local_and_warning_bearing_loops_remain_drawable ... ok
test behavior::scenario_lifecycle::tests::missing_geometry_is_reported_without_nonfinite_or_guessed_markers ... ok
test behavior::scenario_lifecycle::tests::optional_cleanup_inside_loop_does_not_claim_balanced_or_definite_exit ... ok
test behavior::scenario_lifecycle::tests::projection_retains_exact_authored_source_and_does_not_enter_history ... ok
test behavior::scenario_lifecycle::tests::root_lifetime_has_exact_birth_death_boundaries_without_source_mutation ... ok
test behavior::scenario_lifecycle::tests::scenario_use_is_opaque_and_never_imports_callee_lifecycle_events ... ok
test behavior::scenario_scroll::tests::direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt ... ok
test behavior::scenario_scroll::tests::direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view ... ok
test behavior::scenario_scroll::tests::direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report ... ok
test behavior::scenario_scroll::tests::direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once ... ok
test behavior::scenario_scroll::tests::independent_process_owner_change_resets_actual_scroll_offsets ... ok
test behavior::state_canvas::tests::context_menu_keeps_window_anchor_and_explicit_placement_identity ... ok
test behavior::state_canvas::tests::context_tool_and_pending_form_changes_cancel_mutating_gestures ... ok
test behavior::state_canvas::tests::creation_uses_the_explicit_deepest_owner_and_never_a_fallback_ancestor ... ok
test behavior::state_canvas::tests::drag_uses_ordered_release_at_each_zoom_and_moves_descendants_in_one_undo ... ok
test behavior::state_canvas::tests::escape_and_view_switch_discard_the_armed_transition_source ... ok
test behavior::state_canvas::tests::internal_rows_and_pseudostate_hits_use_shared_persisted_geometry ... ok
test behavior::state_canvas::tests::ordered_stationary_click_does_not_move_on_later_motion_in_the_same_batch ... ok
test behavior::state_canvas::tests::pan_is_button_scoped_read_only_and_hover_does_not_reproject_the_scene ... ok
test behavior::state_canvas::tests::pseudostate_names_avoid_rightward_and_downward_routes_and_share_hit_bounds ... ok
test behavior::state_canvas::tests::relayout_does_not_turn_a_transition_click_into_a_drag_or_pan ... ok
test behavior::state_canvas::tests::selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion ... ok
test behavior::state_canvas::tests::selection_relayout_does_not_move_nodes_or_change_drag_distance ... ok
test behavior::state_canvas::tests::stacked_symbol_names_avoid_each_other_and_are_independent_of_input_order ... ok
test behavior::state_canvas::tests::transition_supports_click_click_and_drag_without_coalesced_accidental_edges ... ok
test behavior::state_canvas::tests::typed_state_actions_are_named_selectable_rows_before_internal_transitions_and_regions ... ok
test behavior::state_canvas::tests::viewport_uses_full_canvas_coordinates_and_republishes_for_a_new_context ... ok
test behavior::state_edit::tests::an_empty_machine_has_an_explicit_visible_owner_for_first_region_creation ... ok
test behavior::state_edit::tests::connect_preserves_self_loops_and_rejects_hidden_or_incompatible_vertices ... ok
test behavior::state_edit::tests::creation_places_missing_ancestry_and_unfolds_existing_owners_atomically ... ok
test behavior::state_edit::tests::every_context_component_and_invalid_ownership_are_checked_before_planning ... ok
test behavior::state_edit::tests::placing_existing_content_and_moving_a_folded_group_preserve_other_views_and_semantics ... ok
test behavior::state_edit::tests::reconnect_preserves_the_complete_record_and_replaces_cardinality_in_place ... ok
test behavior::state_edit::tests::view_initializes_only_its_machine_in_one_source_and_model_history_entry ... ok
test behavior::states::tests::internal_choice_targets_self_and_edit_preserves_identity_and_description ... ok
test behavior::states::tests::invalid_pseudo_trigger_keeps_draft_and_does_not_create_orphan_event ... ok
test behavior::states::tests::selected_composite_owns_new_region_and_history_uses_selected_region ... ok
test behavior::states::tests::timeout_and_work_trigger_commit_together_roundtrip_and_undo ... ok
test behavior::tests::failed_document_commit_retains_typed_transition_and_time_event_for_repair ... ok
test behavior::tests::legacy_process_and_chain_keep_their_ordered_exchange_scene ... ok
test behavior::tests::one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit ... ok
test behavior::tests::recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically ... ok
test behavior::tests::recovery_retains_each_incomplete_behavior_form_without_executing_it ... ok
test behavior::tests::selecting_state_transition_keeps_its_behavior_context ... ok
test behavior::topology::contextual::tests::qualified_link_gui_deleted_transient_selection_does_not_seed_or_hide_retained_authored_endpoint ... ok
test behavior::topology::contextual::tests::qualified_link_gui_nested_preview_folds_only_the_exact_reference_instance ... ok
test behavior::topology::contextual::tests::qualified_link_gui_rejected_endpoint_keeps_exact_raw_intent_after_recovery ... ok
test behavior::topology::contextual::tests::qualified_link_gui_repeated_sources_remain_distinct_and_apply_has_exact_source_history ... ok
test behavior::topology::contextual::tests::qualified_link_gui_scope_paging_and_search_reach_tail_beyond_512_without_losing_endpoints ... ok
test behavior::topology::contextual_routing::tests::contextual_route_native_x_to_first_b_keeps_visible_arrow_above_background_and_clear_of_other_references ... ok
test behavior::topology::contextual_routing::tests::contextual_route_nested_visible_hidden_and_parallel_paths_keep_exact_addresses_and_legacy_routes ... ok
test behavior::topology::contextual_routing::tests::contextual_route_shared_anchor_preserves_selected_direct_reconnect_at_fit_and_full_scale ... ok
test behavior::topology::direct_tests::direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange ... ok
test behavior::topology::direct_tests::direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics ... ok
test behavior::topology::direct_tests::direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer ... ok
test behavior::topology::direct_tests::direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic ... ok
test behavior::topology::direct_tests::direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft ... ok
test behavior::topology::direct_tests::direct_process_nested_source_reopen_and_api_command_use_one_transaction ... ok
test behavior::topology::direct_tests::direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link ... ok
test behavior::topology::direct_tests::direct_process_palette_preserves_fit_and_manual_camera ... ok
test behavior::topology::direct_tests::direct_process_production_click_click_drag_insert_and_scroll_coordinates ... ok
test behavior::topology::direct_tests::direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity ... ok
test behavior::topology::direct_tests::direct_process_production_stale_focus_scroll_bounds_and_pending_cancel ... ok
test behavior::topology::direct_tests::direct_process_readable_default_fit_expansion_and_bounded_choices ... ok
test behavior::topology::direct_tests::direct_process_real_picker_stays_bounded_and_search_keeps_selection ... ok
test behavior::topology::direct_tests::direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources ... ok
test behavior::topology::direct_tests::direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected ... ok
test behavior::topology::direct_tests::direct_process_scaled_control_and_link_gestures_use_visible_positions ... ok
test behavior::topology::direct_tests::direct_process_specific_parallel_link_and_incompatible_adjacency ... ok
test behavior::topology::direct_tests::direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion ... ok
test behavior::topology::tests::cycle_projection_terminates_and_retains_actual_links ... ok
test behavior::topology::tests::empty_process_can_start_through_normal_authoring_events ... ok
test behavior::topology::tests::fitting_a_branch_keeps_all_nodes_visible_and_selection_uses_scaled_coordinates ... ok
test behavior::topology::tests::guarded_branch_is_one_transaction_with_distinct_source_identities ... ok
test behavior::topology::tests::insertion_picker_only_offers_work_matching_adjacent_exchanges ... ok
test behavior::topology::tests::legacy_branch_insertion_replaces_only_the_selected_implied_link ... ok
test behavior::topology::tests::legacy_edit_preserves_order_and_new_work_in_one_undo ... ok
test behavior::topology::tests::long_engineering_identifiers_stay_inside_label_width ... ok
test behavior::topology::tests::process_fit_canvas_selection_ignores_retained_ordered_page_scroll ... ok
test behavior::topology::tests::process_fit_disclosures_and_focus_preserve_rejected_raw_draft_and_accepted_source ... ok
test behavior::topology::tests::process_fit_uses_measured_height_and_reports_minimum_without_changing_manual_zoom ... ok
test behavior::topology::tests::reusable_preview_preserves_identity_and_filters_recursive_sources ... ok
test behavior::topology::tests::stale_or_invalid_submit_preserves_inputs_and_creates_nothing ... ok
test behavior::topology::tests::unlinked_contributions_do_not_gain_fictional_edges ... ok
test behavior::topology::view::layout_tests::production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners ... ok
test behavior::topology::view::layout_tests::production_process_caller_return_rejects_its_previous_camera_lifetime ... ok
test behavior::topology::view::layout_tests::production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags ... ok
test behavior::topology::view::layout_tests::production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only ... ok
test behavior::topology::view::layout_tests::production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds ... ok
test behavior::topology::view::layout_tests::production_process_form_replacement_resets_scroll_without_a_closed_render ... ok
test behavior::topology::view::layout_tests::production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector ... ok
test behavior::topology::view::layout_tests::production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool ... ok
test behavior::topology::view::layout_tests::production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes ... ok
test connection_edit::tests::cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection ... ok
test connection_edit::tests::visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history ... ok
test connection_tool::tests::boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind ... ok
test connection_tool::tests::boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks ... ok
test connection_tool::tests::boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map ... ok
test connection_tool::tests::direct_connections_share_exact_source_history_and_model_parity ... ok
test connection_tool::tests::explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items ... ok
test connection_tool::tests::final_command_validation_rejects_invalid_models_without_mutation ... ok
test connection_tool::tests::function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous ... ok
test connection_tool::tests::mismatched_port_owners_layers_and_contract_kinds_are_not_candidates ... ok
test connection_tool::tests::operational_communication_uses_participants_without_creating_ports ... ok
test connection_tool::tests::operational_interaction_targets_only_distinct_oa_activities ... ok
test context_menu::tests::a_new_context_token_cannot_reuse_the_previous_mouse_press_or_scroll ... ok
test context_menu::tests::actions_area_does_not_swallow_first_independent_click_after_overlay_consumed_its_release ... ok
test context_menu::tests::anchors_outside_the_window_and_nonfinite_input_stay_bounded ... ok
test context_menu::tests::clicking_disabled_or_dragging_off_an_action_never_executes_it ... ok
test context_menu::tests::coalesced_dismissal_release_does_not_swallow_the_next_independent_click ... ok
test context_menu::tests::empty_and_disabled_only_menus_still_capture_keys_and_escape_closes ... ok
test context_menu::tests::keyboard_skips_headers_separators_and_disabled_actions_and_invokes_once ... ok
test context_menu::tests::outside_dismissal_consumes_press_and_release_across_controlled_close ... ok
test context_menu::tests::reconstructed_overlay_uses_retained_layout_for_rows_and_hit_testing ... ok
test context_menu::tests::retained_overlay_layout_keeps_measured_width_and_scrollbar_after_reconstruction ... ok
test context_menu::tests::tall_menu_clamps_to_viewport_and_keyboard_wheel_and_scrollbar_reach_the_end ... ok
test data_values::tests::phase_choices_keep_reused_record_addresses_distinct ... ok
test data_values::tests::recursive_picker_starts_with_distinct_leaf_names_and_sorts_them ... ok
test data_values::tests::recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve ... ok
test data_values::tests::reference_parameter_stops_descent_and_unspecified_direction_remains_visible ... ok
test data_values::tests::roots_and_one_hop_children_do_not_expand_unrelated_payload_branches ... ok
test data_view::tests::allocation_rows_edit_relationship_identity_and_remove_only_that_allocation ... ok
test data_view::tests::association_creation_is_a_separate_relation_with_editable_end_contracts ... ok
test data_view::tests::browsing_pages_preserves_invalid_numeric_draft_and_inherited_allocation_identity ... ok
test data_view::tests::contextual_data_actions_use_typed_defaults_and_one_undo ... ok
test data_view::tests::large_inherited_dictionary_has_bounded_widgets_and_every_original_field_is_reachable ... ok
test diagram::lifecycle_tests::menu_reset_and_draw_use_fresh_open_context ... ok
test diagram::lifecycle_tests::middle_and_arrow_panning_preserve_source_and_history ... ok
test diagram::lifecycle_tests::motion_after_release_must_not_move_clicked_port ... ok
test diagram::lifecycle_tests::motion_after_source_click_release_must_not_create_connection ... ok
test diagram::pointer_tests::coalesced_fe_ports_commit_once_through_app_and_source_history ... ok
test diagram::pointer_tests::coalesced_port_drag_commits_once_through_app_and_source_history ... ok
test diagram::pointer_tests::drag_from_already_selected_stationary_port_keeps_source_between_batches ... ok
test diagram::pointer_tests::menu_captured_motion_must_not_turn_following_click_into_old_port_drag ... ok
test diagram::pointer_tests::modal_and_stale_epoch_block_queued_connection_and_port_commits ... ok
test diagram::port_hit_tests::coincident_ports_keep_selection_then_use_stable_identity ... ok
test diagram::port_hit_tests::nearest_port_center_wins_on_all_borders_and_zoom_levels ... ok
test diagram::port_hit_tests::nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port ... ok
test diagram::port_labels::tests::leaving_and_reentering_keeps_suppression_until_each_held_button_releases ... ok
test diagram::port_labels::tests::menu_epoch_clears_suppression_when_the_overlay_consumes_release ... ok
test diagram::port_labels::tests::outside_presses_do_not_suppress_and_cancel_or_focus_loss_clear_held_buttons ... ok
test diagram::port_labels::tests::passive_layer_leaves_port_drag_selection_and_commit_with_the_interactive_canvas ... ok
test diagram::tests::association_end_contracts_share_svg_geometry_and_select_without_moving_name_label ... ok
test diagram::tests::bend_gestures_use_screen_hit_radius_and_commit_one_undo_at_three_zooms ... ok
test diagram::tests::coincident_line_hit_retains_the_selected_connection ... ok
test diagram::tests::connection_gestures_pick_real_ports_and_commit_coalesced_drag_at_every_zoom ... ok
test diagram::tests::detached_label_selects_connection_and_occluding_card_owns_its_hit ... ok
test diagram::tests::dragging_a_child_previews_the_same_enclosure_and_ports_as_commit ... ok
test diagram::tests::fast_drags_commit_the_release_cursor_without_a_final_motion_event ... ok
test diagram::tests::implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors ... ok
test diagram::tests::route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit ... ok
test diagram::tests::routed_svg_has_same_polyline_label_and_bounds_as_canvas_scene ... ok
test diagram::tests::svg_distinguishes_inferred_work_allocation_from_direct_allocation ... ok
test diagram::tests::svg_preserves_visible_exchange_labels_and_capability_stereotypes ... ok
test diagram::tests::typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg ... ok
test diagram::view_options_tests::communication_svg_uses_saved_visibility_labels_and_direction ... ok
test document::save_as_tests::save_as_preserves_explicit_source_entry_changes_in_history ... ok
test document::save_as_tests::save_as_rebases_uniform_history_without_an_undo_step_or_lost_redo ... ok
test document::save_as_tests::save_as_rejects_included_filename_collisions_in_past_and_future_without_mutation ... ok
test document::save_as_tests::save_as_rejects_stale_or_content_changing_receipt_atomically ... ok
test document::tests::accepted_history_keeps_the_latest_hundred_complete_snapshots ... ok
test document::tests::altered_compiled_model_or_index_is_rejected_without_mutation ... ok
test document::tests::asynchronous_save_marks_its_snapshot_without_cleaning_later_edits ... ok
test document::tests::byte_budget_trims_oldest_history_without_discarding_a_large_current ... ok
test document::tests::cached_view_index_tracks_accepted_creation_undo_and_redo ... ok
test document::tests::comment_and_semantic_history_restore_exact_bytes_and_branch_together ... ok
test document::tests::creation_selects_the_single_uuid_persisted_in_source ... ok
test document::tests::invalid_project_is_rejected_before_format_can_normalize_it ... ok
test document::tests::nested_cross_file_reparent_and_source_edit_share_exact_history ... ok
test document::tests::no_op_command_or_source_apply_preserves_history_revision_and_redo ... ok
test document::tests::omitted_id_stabilization_remains_dirty_until_exact_source_is_saved ... ok
test document::tests::recreated_equal_record_does_not_inherit_deleted_source_ownership ... ok
test document::tests::rejected_compile_command_and_writeback_preserve_state_and_redo ... ok
test explorer::tests::collapse_and_search_retain_context_without_changing_collapse_state ... ok
test explorer::tests::deep_containment_and_search_keep_the_full_path_without_recursion ... ok
test explorer::tests::equal_names_have_stable_alias_and_identity_tie_breakers ... ok
test explorer::tests::perspective_isolation_and_allocations_do_not_change_containment ... ok
test explorer::tests::prefix_names_cannot_separate_a_parent_from_its_children ... ok
test forms::data_fields::tests::defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode ... ok
test forms::data_fields::tests::field_browser_pages_every_direct_field_without_flattening_other_records ... ok
test forms::data_fields::tests::independent_review_combined_take_type_informs_actual_default_form_in_both_directions ... ok
test forms::data_fields::tests::numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state ... ok
test forms::data_fields::tests::selecting_qualified_path_preserves_expression_typed_before_the_next_render ... ok
test forms::data_fields::tests::semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep ... ok
test forms::data_fields::tests::supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity ... ok
test forms::history_fields::tests::history_absent_baseline_and_empty_history_use_explicit_facts_and_exact_document_history ... ok
test forms::history_fields::tests::history_clear_discards_inactive_invalid_constructors_before_reusing_the_same_row ... ok
test forms::history_fields::tests::history_compound_value_and_constructor_variants_are_editable_without_live_context ... ok
test forms::history_fields::tests::history_deleted_keys_and_observation_uuids_remain_exact_and_duplicate_history_rejects ... ok
test forms::history_fields::tests::history_every_descriptor_fact_choice_matches_actual_closed_types_and_source_contract ... ok
test forms::history_fields::tests::history_exact_strings_do_not_confuse_uuid_shaped_prose_or_literal_keys_with_references ... ok
test forms::history_fields::tests::history_invalid_numbers_survive_constructor_switch_reorder_removal_and_recovery ... ok
test forms::history_fields::tests::history_missing_semantic_field_can_be_added_and_removed_without_touching_prose_or_live_objects ... ok
test forms::history_fields::tests::history_nested_optional_number_events_switch_absent_present_and_recover_invalid_raw ... ok
test forms::history_fields::tests::history_nested_uuid_and_list_events_preserve_present_values_and_tagged_item_branches ... ok
test forms::history_fields::tests::history_optional_uuid_and_text_seed_absence_without_inventing_observations ... ok
test forms::history_fields::tests::history_primitive_type_choices_remain_visible_inside_the_tagged_union ... ok
test forms::history_fields::tests::history_retained_text_constructors_follow_exact_mode_without_changing_the_observation ... ok
test forms::port_fields::tests::all_demo_records_roundtrip_through_graphical_and_escaped_string_drafts ... ok
test forms::port_fields::tests::boundary_choices_honor_draft_direction_same_kind_and_direct_interface_items ... ok
test forms::port_fields::tests::contract_choices_follow_owner_and_never_offer_legacy_or_oa_typed_ports ... ok
test forms::port_fields::tests::failed_shape_keeps_incomplete_text_and_saved_contract_payloads ... ok
test forms::port_fields::tests::functional_endpoints_require_owner_direction_and_items_but_retain_bad_current_choice ... ok
test forms::port_fields::tests::interface_roles_offer_participants_and_component_ports_independent_of_orientation ... ok
test forms::port_fields::tests::port_allocation_choices_use_actual_owner_direction_and_ignore_edited_relation ... ok
test forms::port_fields::tests::standard_switch_retains_incompatible_orientation_until_explicit_repair ... ok
test forms::port_fields::tests::switching_function_direction_preserves_invalid_items_and_each_authored_payload ... ok
test forms::reference_chooser::tests::captured_choice_cannot_cross_document_revision_draft_record_or_array_path ... ok
test forms::reference_chooser::tests::catalog_search_disambiguates_repeated_names_containment_alias_and_exact_placement_identity ... ok
test forms::reference_chooser::tests::dismissal_swallows_matching_release_across_closed_rebuild_but_next_click_reaches_parent ... ok
test forms::reference_chooser::tests::empty_search_escape_tab_and_optional_clear_do_not_silently_choose_nil ... ok
test forms::reference_chooser::tests::historical_paths_never_open_and_only_explicit_choice_or_clear_is_a_write ... ok
test forms::reference_chooser::tests::mounted_clipboard_paste_and_ime_commit_preserve_whole_strings_without_selecting ... ok
test forms::reference_chooser::tests::mounted_filtered_row_click_uses_the_displayed_identity_and_scoped_write_intent ... ok
test forms::reference_chooser::tests::mounted_input_preserves_rapid_same_batch_typing_and_cursor_across_controlled_rebuilds ... ok
test forms::reference_chooser::tests::mounted_keyboard_pages_and_selects_only_the_highlighted_visible_candidate_once ... ok
test forms::reference_chooser::tests::mounted_overlay_clamps_at_three_field_positions_and_keeps_results_and_query_bounded ... ok
test forms::reference_chooser::tests::mounted_query_change_rejects_old_release_and_old_result_events_without_locking_popup ... ok
test forms::reference_chooser::tests::mounted_query_then_escape_in_one_batch_closes_and_relative_keys_keep_input_order ... ok
test forms::reference_chooser::tests::out_of_order_query_serials_cannot_restore_obsolete_results ... ok
test forms::tests::data_and_port_choices_are_typed_but_retain_invalid_current_values ... ok
test forms::tests::editing_allocation_offers_other_performers_without_counting_itself_twice ... ok
test forms::tests::moving_placement_and_changing_identity_is_one_lossless_undoable_edit ... ok
test forms::tests::parent_choices_reject_cycles_without_removing_clear_or_current_choice ... ok
test forms::tests::reply_and_reuse_forms_offer_semantically_compatible_references ... ok
test forms::tests::scenario_choices_respect_owner_and_exchange_layer ... ok
test forms::tests::semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items ... ok
test forms::tests::shared_perspective_change_keeps_payload_and_rejects_incompatible_dependencies ... ok
test forms::tests::state_observation_picker_keeps_work_lifelines_allocated_to_machine_owner ... ok
test forms::tests::state_placement_form_uses_draft_destination_membership_without_changing_other_views ... ok
test forms::tests::state_projection_form_keeps_string_variants_and_requires_a_typed_context ... ok
test forms::view_fields::tests::category_family_change_retains_members_until_explicit_correction ... ok
test forms::view_fields::tests::selective_view_property_apply_recovery_and_undo_preserve_semantics_and_empty_choices ... ok
test guide::tests::filtering_and_related_navigation_keep_selection_visible ... ok
test guide::tests::guide_topic_navigation_resets_only_article_and_same_topic_search_keeps_scroll ... ok
test guide::tests::search_finds_concepts_in_walkthroughs_and_api_reference ... ok
test home::tests::corrupt_future_and_oversized_history_are_preserved_with_usable_session_history ... ok
test home::tests::failed_metadata_replace_keeps_previous_history_and_reports_memory_only_entry ... ok
test home::tests::first_and_replaced_metadata_are_parseable_and_contain_no_model_bytes ... ok
test home::tests::forgotten_and_missing_files_never_change_the_model_or_other_entries ... ok
test home::tests::loading_missing_history_does_not_create_files_or_directories ... ok
test home::tests::malformed_entries_and_unknown_fields_never_become_open_requests ... ok
test home::tests::successful_local_history_is_bounded_ordered_deduplicated_and_durable ... ok
test home::tests::unavailable_location_can_reappear_on_refresh_without_losing_identity ... ok
test home::tests::walkthrough_uses_packaged_bundle_before_development_and_reports_missing_assets ... ok
test hosted::client::tests::hosted_endpoint_validation_redacts_secrets_and_preserves_proxy_paths ... ok
test hosted::client::tests::hosted_lost_publication_response_is_uncertain_without_retry ... ok
test hosted::client::tests::hosted_open_rejects_incoherent_envelopes_missing_etags_and_unstable_ids ... ok
test hosted::client::tests::hosted_oversized_local_bundle_is_rejected_before_network_admission ... ok
test hosted::client::tests::hosted_production_auth_proxy_prefix_and_located_rejection ... ok
test hosted::client::tests::hosted_production_roundtrip_preserves_comments_noops_and_conflicts ... ok
test hosted::client::tests::hosted_publication_race_keeps_receipt_identity_for_review ... ok
test hosted::client::tests::hosted_publication_retains_known_resource_after_failed_followup ... ok
test hosted::client::tests::hosted_put_preserves_an_opaque_etag_and_compiles_the_accepted_bundle ... ok
test hosted::client::tests::hosted_redirect_is_not_followed_or_retried_and_problem_tokens_are_redacted ... ok
test hosted::client::tests::hosted_response_limits_cover_declared_and_chunked_bodies ... ok
test hosted::client::tests::hosted_structured_mutation_failures_distinguish_precommit_rejections ... ok
test hosted::client::tests::hosted_timeout_and_disconnect_leave_mutation_outcomes_explicit ... ok
test model_menu::tests::add_bend_here_uses_context_point_and_one_source_aware_history_change ... ok
test model_menu::tests::late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context ... ok
test model_menu::tests::menu_preserves_pending_properties_and_rejects_delayed_mutations ... ok
test model_menu::tests::pending_source_allows_file_navigation_but_disables_model_actions ... ok
test model_menu::tests::right_click_targets_actor_without_changing_selection_until_action ... ok
test model_menu::tests::show_from_explorer_places_unfolds_and_keeps_semantic_selection ... ok
test model_menu::tests::state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views ... ok
test model_menu::tests::state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties ... ok
test navigation::tests::semantic_browser_finds_behavior_users_without_confusing_containment ... ok
test port_edit::tests::all_border_drags_commit_exact_preview_and_restore_automatic_anchors_in_history ... ok
test port_edit::tests::graphical_port_reparent_clears_old_anchors_in_the_same_history_operation ... ok
test port_preview::tests::functional_creation_identifies_new_and_reused_endpoints_and_owners ... ok
test port_preview::tests::legacy_split_discloses_endpoint_routing_item_origins_and_retained_evidence ... ok
test port_preview::tests::reused_boundary_shows_item_union_and_existing_allocating_facts ... ok
test port_preview::tests::synchronization_preview_exposes_contract_shrinkage_and_current_declaration_origin ... ok
test port_workflow::recovery_tests::port_recovery_retains_authored_contract_and_identity_but_rebuilds_preview ... ok
test port_workflow::tests::creating_typed_port_and_rejecting_stale_preview_preserve_drafts_and_identity ... ok
test port_workflow::tests::graphical_function_exchange_and_component_generation_commit_and_undo_whole_contracts ... ok
test port_workflow::tests::legacy_resolution_preserves_original_record_and_applies_as_one_undo ... ok
test port_workflow::tests::port_creation_keeps_owner_diagram_and_unfolds_in_the_same_undo ... ok
test port_workflow::tests::synchronization_uses_active_contract_instead_of_preserved_direction ... ok
test recovery::source5::tests::source5_fingerprint_preserves_historical_field_order_and_diagram_shape ... ok
test recovery::source5_tests::source4_checkpoint_remains_explicitly_unsupported_without_touching_disk ... ok
test recovery::source5_tests::source5_checkpoint_does_not_refresh_unrelated_clean_buffer_baseline ... ok
test recovery::source5_tests::source5_checkpoint_missing_identity_is_not_a_lossless_upgrade ... ok
test recovery::source5_tests::source5_checkpoint_old_diagram_property_shape_remains_editable ... ok
test recovery::source5_tests::source5_checkpoint_port_draft_replans_and_rejects_stale_base ... ok
test recovery::source5_tests::source5_checkpoint_preserves_dirty_invalid_text_and_baseline_exactly ... ok
test recovery::source5_tests::source5_checkpoint_preserves_distinct_unapplied_open_workspace ... ok
test recovery::source5_tests::source5_checkpoint_property_preserves_invalid_raw_input ... ok
test recovery::source5_tests::source5_checkpoint_upgrade_never_rewrites_checkpoint_or_origin ... ok
test recovery::source5_tests::source5_checkpoint_upgrades_only_header_and_clean_editor_baseline ... ok
test recovery::source5_tests::source5_checkpoint_workflow_rebinds_only_the_proven_old_revision ... ok
test recovery::source7_tests::source7_current_combined_contextual_and_operation_drafts_survive_without_execution ... ok
test recovery::source7_tests::source7_explicit_new_fields_cannot_enter_frozen_old_model_profiles ... ok
test recovery::source7_tests::source7_legacy_inactive_constructor_path_is_structural_but_raw_prose_is_not ... ok
test recovery::source7_tests::source7_legacy_profile_workflow_and_semantic_drafts_replan_with_exact_base ... ok
test recovery::source7_tests::source7_legacy_profiles_preserve_dirty_source_property_and_behavior_raw_fields ... ok
test recovery::source7_tests::source7_legacy_structural_intent_rejects_even_null_but_never_scans_raw_strings ... ok
test recovery::source7_tests::source7_legacy_view_selector_refuses_new_relation_kind_without_rewriting_checkpoint ... ok
test recovery::source7_tests::source7_recovers_nonempty_frozen_5_and_6_profiles_with_exact_historical_binding ... ok
test recovery::tests::altered_accepted_identity_and_unrecognized_raw_property_fields_reject ... ok
test recovery::tests::exact_source_and_unfinished_invalid_buffers_restore_without_model_overwrite ... ok
test recovery::tests::excess_retained_buffers_cannot_replace_the_last_recoverable_checkpoint ... ok
test recovery::tests::invalid_open_retains_distinct_accepted_model_and_repair_workspace ... ok
test recovery::tests::missing_includes_remain_reachable_repair_targets_without_fabricated_buffers ... ok
test recovery::tests::multiple_sessions_replace_only_their_own_checkpoint_and_corruption_is_retained ... ok
test recovery::tests::raw_invalid_property_number_survives_and_stays_rejected_until_repaired ... ok
test semantic_review::artifacts_recovery_falsifier_tests::forged_artifact_draft_cannot_target_an_unreserved_or_non_artifact_identity ... ok
test semantic_review::artifacts_recovery_falsifier_tests::forged_artifact_raw_variants_duplicate_addresses_and_unknown_fields_are_refused ... ok
test semantic_review::artifacts_recovery_falsifier_tests::forged_artifact_reservations_cannot_duplicate_or_claim_an_existing_identity_or_another_owner ... ok
test semantic_review::artifacts_recovery_falsifier_tests::legitimate_invalid_artifact_checkpoint_round_trips_and_prepares_only_after_explicit_repair ... ok
test semantic_review::artifacts_tests::artifact_candidate_is_a_complete_record_not_a_tombstone_or_unattached_creation_shortcut ... ok
test semantic_review::artifacts_tests::artifact_creation_and_identified_source_deletion_follow_core_metadata_and_history_boundary_both_directions ... ok
test semantic_review::artifacts_tests::artifact_gui_reserves_recovers_and_creates_only_after_all_seven_metadata_reviews_both_directions ... ok
test semantic_review::artifacts_tests::artifact_metadata_apply_and_exact_history_reset_are_one_source_preserving_undo_redo ... ok
test semantic_review::artifacts_tests::artifact_move_metadata_none_empty_and_invalid_raw_recovery_are_independent_both_directions ... ok
test semantic_review::artifacts_tests::artifact_moved_source_absence_deletes_but_destination_only_take_retains_both_directions ... ok
test semantic_review::attachments::tests::absent_attachment_payloads_migrate_v1_and_v2_and_source5_upgrade_requires_exact_legacy_base ... ok
test semantic_review::attachments::tests::addressed_attachment_candidates_equal_direct_core_oracle_both_directions ... ok
test semantic_review::attachments::tests::attachment_apply_is_one_exact_source_and_history_undo_redo ... ok
test semantic_review::attachments::tests::attachment_candidate_and_direct_record_keep_conflict_on_actual_slot_without_losing_drafts ... ok
test semantic_review::attachments::tests::attachment_pair_drafts_survive_family_switch_and_restore_without_mutating_model ... ok
test semantic_review::attachments::tests::functional_incident_source_requires_explicit_matching_port_review_in_one_atomic_candidate ... ok
test semantic_review::attachments::tests::parent_and_each_endpoint_candidate_preserve_other_fields_and_match_core_both_directions ... ok
test semantic_review::attachments::tests::raw_rejected_attachment_reference_survives_recovery_and_wrong_scope_is_rejected ... ok
test semantic_review::attachments::tests::source_only_absence_and_explicit_absent_endpoint_are_different ... ok
test semantic_review::attachments::tests::two_incident_endpoint_candidates_form_one_complete_record_and_recover_invalid_excluded_draft ... ok
test semantic_review::batch_tests::batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip ... ok
test semantic_review::batch_tests::batch_candidate_conflict_keeps_both_raw_values_and_keep_is_a_real_constraint ... ok
test semantic_review::batch_tests::batch_complete_constructor_cannot_erase_another_rows_new_literal ... ok
test semantic_review::batch_tests::batch_constructor_retirement_conflicts_with_another_rows_kept_bound_in_both_orders ... ok
test semantic_review::batch_tests::batch_delayed_text_targets_its_original_row_and_facet_without_switching_focus ... ok
test semantic_review::batch_tests::batch_excluded_invalid_row_survives_focus_reopen_and_v2_recovery ... ok
test semantic_review::batch_tests::batch_first_equal_constructor_acknowledgement_allows_an_independent_bound ... ok
test semantic_review::batch_tests::batch_joint_bounds_use_one_candidate_and_match_an_independent_atomic_core_oracle ... ok
test semantic_review::batch_tests::batch_reverse_distinct_targets_work_and_reverse_convergence_is_explicit ... ok
test semantic_review::batch_tests::batch_v2_rejects_duplicate_included_sources_bad_addresses_and_stale_model ... ok
test semantic_review::batch_tests::category_family_and_members_are_independent_core_checked_facets_both_directions ... ok
test semantic_review::batch_tests::recovered_trace_picker_names_existing_trace_instead_of_deleted_reference ... ok
test semantic_review::batch_tests::rejected_direction_switch_at_row_limit_preserves_every_recoverable_draft ... ok
test semantic_review::compose::diagnostic_tests::distinct_review_conflict_keeps_trace_aliases_even_when_names_are_equal ... ok
test semantic_review::compose::diagnostic_tests::reserved_creation_conflict_uses_its_rows_seed_instead_of_a_deleted_reference ... ok
test semantic_review::compose::diagnostic_tests::same_review_presence_and_owner_conflict_names_both_facets_and_owners_and_retains_drafts ... ok
test semantic_review::compose::diagnostic_tests::typed_owner_display_does_not_reinterpret_uuid_shaped_artifact_text ... ok
test semantic_review::constructor_tests::closed_data_and_event_take_composition_matches_core_across_every_kind_pair_and_direction ... ok
test semantic_review::constructor_tests::data_group_candidates_cover_every_definition_and_companion_default_both_directions ... ok
test semantic_review::constructor_tests::data_group_retains_invalid_sibling_drafts_companion_default_and_variant_editor_buffers ... ok
test semantic_review::constructor_tests::exchange_constructor_takes_qualified_annotation_addresses_without_remapping_expression_text ... ok
test semantic_review::constructor_tests::existing_fragment_and_branch_group_candidates_edit_complete_owned_order_without_deletion ... ok
test semantic_review::constructor_tests::group_take_reference_payload_and_scalar_candidate_match_core_and_one_document_batch ... ok
test semantic_review::constructor_tests::historical_event_keep_retires_only_obsolete_branch_and_unchanged_kind_acknowledges_no_payload ... ok
test semantic_review::constructor_tests::historical_same_kind_take_preserves_target_only_literals_when_composed_with_candidate ... ok
test semantic_review::constructor_tests::occurrence_and_event_candidate_variants_cover_closed_families_without_touching_headers ... ok
test semantic_review::constructor_tests::outer_and_nested_constructor_candidates_require_explicit_resolution_and_retain_both_drafts ... ok
test semantic_review::constructor_tests::port_group_companions_typed_owner_context_and_direction_restrictions_are_explicit ... ok
test semantic_review::constructor_tests::reverse_existing_typed_port_reviews_keep_or_candidate_legacy_without_permitting_typed_oa_write ... ok
test semantic_review::constructor_tests::scenario_use_group_takes_typed_binding_references_and_preserves_source_text_both_directions ... ok
test semantic_review::keyed_rows_tests::annotation_address_browser_descends_qualified_fields_without_changing_rows ... ok
test semantic_review::keyed_rows_tests::annotation_observations_keep_uuid_shaped_expression_text_in_both_sides_and_history ... ok
test semantic_review::keyed_rows_tests::batch_keyed_rows_compare_exact_target_slots_and_retain_independent_values ... ok
test semantic_review::keyed_rows_tests::binding_choices_separate_reused_keys_from_local_lifelines ... ok
test semantic_review::keyed_rows_tests::binding_local_picker_follows_a_candidate_parent_before_the_review_is_valid ... ok
test semantic_review::keyed_rows_tests::binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenario ... ok
test semantic_review::keyed_rows_tests::independent_rows_keep_take_candidate_match_core_and_preserve_siblings_both_directions ... ok
test semantic_review::keyed_rows_tests::keyed_recovery_retains_raw_rows_and_staged_pairs_both_directions_without_applying ... ok
test semantic_review::keyed_rows_tests::keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_staging ... ok
test semantic_review::keyed_rows_tests::keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exact_reset ... ok
test semantic_review::keyed_rows_tests::multiple_new_rows_compose_in_empty_collections_using_destination_key_order ... ok
test semantic_review::keyed_rows_tests::row_review_apply_preserves_exact_source_and_semantic_history_through_undo_redo ... ok
test semantic_review::keyed_tests::deleted_member_source_observation_removes_only_mapped_membership_both_directions ... ok
test semantic_review::keyed_tests::exact_literal_new_rows_and_removals_commit_both_directions_without_default_loss ... ok
test semantic_review::keyed_tests::exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directions ... ok
test semantic_review::keyed_tests::function_port_members_use_existing_typed_choices_and_preserve_oa_legacy ... ok
test semantic_review::keyed_tests::historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap_resolution ... ok
test semantic_review::keyed_tests::invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repairable ... ok
test semantic_review::keyed_tests::member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys ... ok
test semantic_review::keyed_tests::reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_draft ... ok
test semantic_review::keyed_tests::staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_presence ... ok
test semantic_review::keyed_tests::stale_or_source_failed_reset_and_membership_apply_retain_exact_choices ... ok
test semantic_review::keyed_tests::unordered_members_keep_take_candidate_preserve_other_rows_both_directions ... ok
test semantic_review::keyed_tests::unsupported_independent_families_and_missing_identities_do_not_gain_edit_authority ... ok
test semantic_review::message_kind_tests::message_group_candidate_closed_kinds_are_core_validated_in_both_directions ... ok
test semantic_review::message_kind_tests::message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_explicit ... ok
test semantic_review::message_kind_tests::message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_timer_agreement ... ok
test semantic_review::message_kind_tests::message_group_keep_historical_switch_retires_timer_and_completed_no_switch_rejects ... ok
test semantic_review::message_kind_tests::message_group_rejects_protected_edits_stale_plan_and_source_failure_without_losing_timer ... ok
test semantic_review::message_kind_tests::message_group_reply_and_reference_take_require_explicit_call_mapping_both_directions ... ok
test semantic_review::message_kind_tests::message_group_take_full_timer_and_scalar_candidate_are_one_source_history_change_both_directions ... ok
test semantic_review::message_kind_tests::message_group_timer_does_not_erase_exchange_without_its_own_selected_decision ... ok
test semantic_review::ordered_tests::ambiguous_order_preserves_local_member_and_requires_explicit_candidate_positions ... ok
test semantic_review::ordered_tests::historical_pair_restore_uses_exact_member_keys_and_never_zips_order_observations ... ok
test semantic_review::ordered_tests::order_take_preserves_destination_only_positions_and_order_alone_cannot_add_members ... ok
test semantic_review::ordered_tests::ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes ... ok
test semantic_review::ordered_tests::ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_take_reorders_only ... ok
test semantic_review::ordered_tests::ordered_review_apply_is_exact_source_history_transaction_with_reopen_and_undo_redo ... ok
test semantic_review::ordered_tests::ordered_take_uses_unique_anchors_and_matches_core_with_unrelated_candidate_both_directions ... ok
test semantic_review::ordered_tests::raw_order_survives_membership_changes_decisions_and_recovery_until_explicit_reconcile ... ok
test semantic_review::ordered_tests::several_incoming_members_follow_source_order_despite_independent_identity_sorting ... ok
test semantic_review::recovery::tests::raw_scalar_and_closed_constructor_drafts_recover_against_exact_model_only ... ok
test semantic_review::recovery::tests::version_one_without_orders_restores_raw_intent_and_replans_without_execution ... ok
test semantic_review::source7_tests::contextual_incident_leaf_keep_allows_path_change_but_conflicts_with_changed_leaf_both_directions ... ok
test semantic_review::source7_tests::reverse_operation_omission_gui_requires_raw_rationale_and_matches_one_core_batch_after_recovery ... ok
test semantic_review::source7_tests::semantic_operation_virtual_none_candidate_uses_exact_allocation_and_preserves_accepted_bytes ... ok
test semantic_review::tests::candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing ... ok
test semantic_review::tests::deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar ... ok
test semantic_review::tests::explicit_trace_selection_updates_only_that_history ... ok
test semantic_review::tests::first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves ... ok
test semantic_review::tests::independent_review_clear_bound_ignores_retained_invalid_other_facet ... ok
test semantic_review::tests::independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity ... ok
test semantic_review::tests::inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval ... ok
test semantic_review::tests::invalid_numeric_draft_and_protected_edits_retain_workflow_and_source ... ok
test semantic_review::tests::read_only_structured_facts_and_partial_rollup_are_honest ... ok
test semantic_review::tests::relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds ... ok
test semantic_review::tests::scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions ... ok
test semantic_review::tests::scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers ... ok
test semantic_review::tests::stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history ... ok
test semantic_review::tests::state_kind_scalar_uses_typed_candidate_and_core_validation ... ok
test semantic_review::view::reserved_artifact_tests::reserved_artifact_facet_and_reference_labels_survive_recovery_without_calling_it_deleted ... ok
test source_navigation::tests::search_uses_utf8_columns_wraps_and_refreshes_after_edits ... ok
test source_tree::tests::active_scroll_offset_uses_the_visible_order_and_retains_ancestor_context ... ok
test source_tree::tests::collapsed_folders_retain_active_dirty_and_diagnostic_markers ... ok
test source_tree::tests::deep_paths_build_expand_and_drop_on_a_small_stack ... ok
test source_tree::tests::detached_buffers_keep_paths_and_only_clean_buffers_are_closable ... ok
test source_tree::tests::exact_unicode_spaces_and_file_folder_name_collisions_are_not_normalized ... ok
test source_tree::tests::folders_are_compact_and_sorted_before_files_without_losing_duplicate_basenames ... ok
test source_workspace::tests::draft_history_budget_covers_inactive_files_without_discarding_drafts ... ok
test source_workspace::tests::switching_retains_invalid_drafts_and_independent_history ... ok
test tests::actor_port_creation_updates_containment_and_locate_reveals_its_owner ... ok
test tests::adding_bend_between_close_ports_stays_outside_owner_clearance ... ok
test tests::behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_views ... ok
test tests::bidirectional_transition_ui_commits_once_and_preserves_other_diagrams ... ok
test tests::counterpart_creation_focuses_visible_target_and_reuse_keeps_views ... ok
test tests::creation_from_capabilities_routes_work_to_architecture_and_folded_allocation_inserts_below_existing_work ... ok
test tests::dragging_keeps_semantic_selection_and_same_node_remains_editable ... ok
test tests::escaped_string_mode_preserves_exact_control_characters ... ok
test tests::every_demo_record_has_a_lossless_graphical_draft ... ok
test tests::explicit_relationship_builder_never_mutates_on_preview ... ok
test tests::explorer_and_creation_selection_follow_their_own_placement ... ok
test tests::fit_adapts_to_canvas_returning_after_help_but_preserves_manual_navigation ... ok
test tests::fit_uses_measured_canvas_bounds_and_preserves_them_on_open ... ok
test tests::focused_explorer_follows_reparenting_without_changing_identity ... ok
test tests::graphical_rename_keeps_aliases_and_references_and_undo ... ok
test tests::identity_property_change_is_atomic_and_rewrites_all_live_references ... ok
test tests::invalid_numeric_draft_survives_navigation_save_and_help ... ok
test tests::invalid_save_and_continue_returns_to_repairable_draft ... ok
test tests::malformed_source_preserves_model_and_is_recoverable ... ok
test tests::manual_trace_requires_target_and_selection_normalizes_creation_kind ... ok
test tests::mission_capability_authoring_is_contextual_atomic_and_text_equivalent ... ok
test tests::modal_owns_background_commands_and_shortcuts ... ok
test tests::nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state ... ok
test tests::opening_and_creating_models_preserve_the_actual_window_size_for_fit ... ok
test tests::opening_legacy_source_surfaces_report_without_modifying_original_bytes ... ok
test tests::pending_source_prevents_property_draft_collision_and_preserves_both_if_present ... ok
test tests::placement_identity_edit_then_drag_and_diagram_identity_then_arrange ... ok
test tests::placement_relocation_follows_cross_layer_traceability_view ... ok
test tests::property_enum_and_optional_reference_roundtrip ... ok
test tests::reopening_canvas_for_a_modal_preserves_viewport_after_route_edits ... ok
test tests::revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner ... ok
test tests::routing_actions_are_atomic_guarded_and_fit_the_authored_detour ... ok
test tests::selecting_current_constructors_preserves_payloads ... ok
test tests::source_find_navigates_an_invalid_draft_without_applying_or_losing_it ... ok
test tests::source_navigation_waits_for_editor_layout_and_cancels_on_edit ... ok
test tests::system_only_models_open_the_populated_perspective ... ok
test tests::transition_resolutions_belong_to_the_effective_target_and_kind ... ok
test view_copy::tests::duplicate_retains_ports_routes_options_and_artifact_claims_with_exact_undo_redo ... ok
test view_copy::tests::duplicate_state_view_preserves_machine_nesting_and_collapse_with_unique_aliases ... ok
test view_copy::tests::missing_view_does_not_propose_an_empty_replacement ... ok
test views::traceability::tests::traceability_browsing_retains_invalid_property_draft_and_existing_select_guard ... ok
test views::traceability::tests::traceability_filter_reaches_artifacts_after_68_traces_and_searches_all_metadata ... ok
test workflow::communication::tests::communication_requires_two_explicit_ports_and_recovers_the_partial_choice ... ok
test workflow::port_authoring_tests::boundary_picker_matches_exact_commands_across_contracts_and_ownership ... ok
test workflow::port_authoring_tests::boundary_picker_recomputes_for_unoriented_reverse_endpoints_without_relaxing_contracts ... ok
test workflow::port_authoring_tests::duplicate_port_names_show_owner_contract_and_unique_alias ... ok
test workflow::port_authoring_tests::fresh_functions_are_actual_picker_targets_and_plan_enables_apply ... ok
test workflow::port_authoring_tests::generic_component_port_connection_keeps_its_endpoint_and_direction ... ok
test workflow::port_authoring_tests::ordinary_standard_connections_default_unoriented_and_new_roles_exclude_legacy ... ok
test workflow::recovery::tests::incomplete_relationship_and_trace_recover_raw_fields_and_reject_changed_model ... ok
test workflow::recovery::tests::transition_recovery_replans_typed_request_without_persisted_commands ... ok
test workflow::transition_tests::basic_transition_boundary_choice_is_required_without_an_inferred_system ... ok
test workflow::transition_tests::basic_transition_creates_overview_when_existing_views_cannot_show_result ... ok
test workflow::transition_tests::basic_transition_explicit_destination_appends_below_existing_bounds_and_is_one_source_edit ... ok
test workflow::transition_tests::basic_transition_invalid_explicit_destination_retains_choice_and_refuses ... ok
test workflow::transition_tests::basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_exact ... ok
test workflow::transition_tests::basic_transition_skips_lowest_uuid_state_view_and_preserves_oa ... ok
test result: ok. 729 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 257.42s
@@ -0,0 +1,7 @@
{
"source": "ff0175488ac57a92d401b00a0e4542582a82d8e9",
"start": "2026-09-11T17:56:02.6244756Z",
"end": "2026-09-11T18:00:44.2553735Z",
"exit": 0,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio -- --test-threads=1"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\crates\oa-desktop)
Finished `test` profile [unoptimized] target(s) in 31.49s
Running unittests src\main.rs (target\debug\deps\arcadia_studio-e358a5ed59b1de41.exe)
running 3 tests
test guide::tests::filtering_and_related_navigation_keep_selection_visible ... ok
test guide::tests::guide_topic_navigation_resets_only_article_and_same_topic_search_keeps_scroll ... ok
test guide::tests::search_finds_concepts_in_walkthroughs_and_api_reference ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 726 filtered out; finished in 0.10s
@@ -0,0 +1,7 @@
{
"source": "82fd0acf64d87f322e12602154f14a565b29de4c",
"start": "2026-09-11T18:03:59.0931875Z",
"end": "2026-09-11T18:04:31.1587860Z",
"exit": 0,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio guide::tests -- --test-threads=1"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
error: no library targets found in package `oa-desktop`
@@ -0,0 +1,7 @@
{
"source": "6969c825a8340980b6a419524ade3bb687a6051f",
"start": "2026-09-11T17:46:09.2871806Z",
"end": "2026-09-11T17:46:12.8288903Z",
"exit": 101,
"inputs": 281
}
@@ -0,0 +1,230 @@
{
"scope": "Closed Windows canvas union gates through abffa4b; native and post-cleanup acceptance not included. Test executables remain local; their fingerprints are metadata only.",
"files": [
{
"path": "record.json",
"bytes": 3045,
"sha256": "6ae70f3067bfd32628dde484ac4fec62ed10fdd598b95ce78d4da6f331662563"
},
{
"path": "candidate-commit.txt",
"bytes": 42,
"sha256": "0abdb11bc01a9588b073d13d8536f455a429f84e35c36fc872aad711ff5b736f"
},
{
"path": "compile-inputs-6969c82.json",
"bytes": 48790,
"sha256": "02456a1c1260fd40ecbe8096ee6af3583749f6cd862586a4dd8044ed9ec1f797"
},
{
"path": "compile-inputs-06ec962.json",
"bytes": 48790,
"sha256": "dd043656df69a8b25c5ba5bf5fc1aca137523d7920f048510e7cb3a061e3c806"
},
{
"path": "full-inputs-ff01754.json",
"bytes": 96552,
"sha256": "966b125ec8862d0e48ed98692bc3dc380f0242007eb4c018621a9fde844562eb"
},
{
"path": "guide-inputs-82fd0ac.json",
"bytes": 96552,
"sha256": "28e1a0c78997e507fe0435cffc926adfbd0c9ee3d9e9affffd38f061ec19b975"
},
{
"path": "final-inputs-abffa4b.json",
"bytes": 96552,
"sha256": "1d4f5640e6b96ef1914fce416c2148e868f2e89d17edc890dec1f69f8812f635"
},
{
"path": "tested-06ec962/manifest.json",
"bytes": 6028,
"sha256": "756c6049ebb6e595d30c384e0dbfa1a922a12dd93f38369723c883efbc582d87"
},
{
"path": "tested-ff01754/artifact-manifest.json",
"bytes": 764,
"sha256": "cabd336b7f00e7412c10b8cba187f43b441d554b88c96453fb43bbc4cbd89631"
},
{
"path": "initial-compile-shared-6969c82.log",
"bytes": 57,
"sha256": "97570773eda98d01ee18d3a67732336122f831acbe1c2429571201643a6a45cd"
},
{
"path": "initial-compile-shared-6969c82.result.json",
"bytes": 182,
"sha256": "ee38e9b2768d6cd30e76e8f380c627c2196a1af1e2dc62d83b9678b6d9233b15"
},
{
"path": "compile-shared-6969c82.log",
"bytes": 3378,
"sha256": "75e1c5506f1b128fdc19ab9b2e232d1b44eb1dbeedf01549ca6a617f9107d0ad"
},
{
"path": "compile-shared-6969c82.result.json",
"bytes": 292,
"sha256": "7922c007d2efc44d048b06927f6382b1df69bdecb617ac6103c9f1d7cee4d9bd"
},
{
"path": "compile-shared-06ec962.log",
"bytes": 1857,
"sha256": "3aeddd7b828ba9a73d98b4db2dd2fc3bc6909f1cde1887c378118004f9fe5f75"
},
{
"path": "compile-shared-06ec962.result.json",
"bytes": 308,
"sha256": "6687346a818826bca3bb28942705092d6a51f645775d55b10d85a548bc792586"
},
{
"path": "production17-06ec962.log",
"bytes": 4359,
"sha256": "977646806e102a1b1161faa61b2b1a5c44e23ef4cfb63414b166ce2116aee2b7"
},
{
"path": "production17-06ec962.result.json",
"bytes": 278,
"sha256": "a6bef9638ac8393e802a15743e590e928f660c23c026277dd87f2be226086203"
},
{
"path": "scenario21-06ec962.log",
"bytes": 4287,
"sha256": "0b58c048656616d49e6d3d4a340537570b54736664671f28103c3a7af7259255"
},
{
"path": "scenario21-06ec962.result.json",
"bytes": 305,
"sha256": "bfb008a048cd8a540c54475112dca862495d30dbb11d621c925c9cd62ec10b24"
},
{
"path": "full-ff01754.log",
"bytes": 83584,
"sha256": "a6d2e0d39b45246d88f1f5b61117cdb0897b23a0d072238eca7a5550495bd421"
},
{
"path": "full-ff01754.result.json",
"bytes": 254,
"sha256": "c448190de3c3768e43c0e191274d81074927a74c91c6ee7e4dc9ad5369c2b92d"
},
{
"path": "guide-82fd0ac.log",
"bytes": 641,
"sha256": "ad151c1ee3bac807f87d192a80015b7a1e659cd0bc7ee523ecff53a62c0e75a7"
},
{
"path": "guide-82fd0ac.result.json",
"bytes": 267,
"sha256": "90203fdb4422730d946a7e5b2f21022586775e69de22a60791d0d25612d09bbd"
},
{
"path": "clippy-82fd0ac.log",
"bytes": 1038,
"sha256": "941c49a48f1648000015bb2278cf490d66d00781774df7740c800f667271f210"
},
{
"path": "clippy-82fd0ac.result.json",
"bytes": 246,
"sha256": "535e701e8e9d77c7d306c2985741e039b096eb6f5c9489f18b4c5ef6e7421835"
},
{
"path": "clippy-abffa4b.log",
"bytes": 1786,
"sha256": "b2f21d94e7e6221210beec871eb74f2d255552ce6c3971df5125cf36cad07567"
},
{
"path": "clippy-abffa4b.result.json",
"bytes": 246,
"sha256": "4f5339fcac106076619aff5676f981e454a43966c55d6e5f81c104996a5cd76e"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/app/process_canvas.rs",
"bytes": 30660,
"sha256": "53d4a422d3066e07cccb9e8ef2ad08ea95e451b9b7cbef29a27e6634e035ed97"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/app/reference_authoring.rs",
"bytes": 11250,
"sha256": "38e033e23e8135032f70117a376a5fe7bc68127c466559a35d7ec200932e3f77"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/app.rs",
"bytes": 220005,
"sha256": "aa2e4853b25bb7e185b1de49eda42028523c8ebf9599bfe6b7cb2a2b2a688090"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/canvas_workspace/control.rs",
"bytes": 7860,
"sha256": "851af1e9b155d6d0d079d9164c5616fadc33e961391a6604a43eabf5158957b3"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/canvas_workspace/focus.rs",
"bytes": 8943,
"sha256": "cc58cb3838f546150d22fe4dd83a3ffd52504c279febaeb8d0b4a6a3b8a73950"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs",
"bytes": 27020,
"sha256": "d84d022574a6f1d69f203d9383502227fb9fc23605c6fb48779ca9f09bee546d"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs",
"bytes": 14436,
"sha256": "af1c7c1e802d1207ee6c7bd66b703811a250d7b56511512af6c0945c776e68a7"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/canvas_workspace.rs",
"bytes": 7744,
"sha256": "096d667c4c8ee3732d52668a9562c665f2d9a192abda4e5487fcad7621c8dbfd"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs",
"bytes": 79985,
"sha256": "950cf4347a3edea615b1ac71d9a681c32b446b5109fcfd0f03f3ae2b147360bf"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/scenario/workspace.rs",
"bytes": 26006,
"sha256": "284f5a7da09bdc77d70c6788a091d57927150c796ffc10d786793cff7da4b7c6"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/scenario.rs",
"bytes": 152263,
"sha256": "fb1da03e8df05a59253f7fdf34391e30abd8aea427b7d0a12b86b80ff3bf9eca"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology/contextual.rs",
"bytes": 28432,
"sha256": "5d0ac5b3e258fc70987a00ede1e603681d99746596f2fcc46ad2b3667d65203a"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology/direct.rs",
"bytes": 36301,
"sha256": "9309b8978ec2586ff4614236c2f624af6d934d117363e917b8c1cf9f2b1295eb"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology/direct_tests.rs",
"bytes": 50720,
"sha256": "733f8badafb9ba87e3f861f03f78809450eee72cc118341eb03b24904790144a"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology/layout_tests.rs",
"bytes": 36856,
"sha256": "a990947cb39b2fb15c21dfbc95ee16f92bf6be749d3872c6904762b9bde37089"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology/view.rs",
"bytes": 23136,
"sha256": "b2d502c11426bffb8763515f2a56aa4c9f8e203bb36d65eb34f81c123fc406a6"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior/topology.rs",
"bytes": 84407,
"sha256": "5723e51a7da4eb224d51c1659ebbfa4dc8b11557bc8bf70f6e3e1649bad24449"
},
{
"path": "tested-06ec962/raw-inputs/crates/oa-desktop/src/behavior.rs",
"bytes": 132668,
"sha256": "5e7cecb54bfca64e4462f7881f460900abd925ba2cfe9451ce2b0c78af4bc850"
}
]
}
@@ -0,0 +1,55 @@
warning: type `traceability::Event` is more private than the item `app::Message::Traceability::0`
--> crates\oa-desktop\src\app.rs:339:18
|
339 | Traceability(crate::views::traceability::Event),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::Message::Traceability::0` is reachable at visibility `pub`
|
note: but type `traceability::Event` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:12:1
|
12 | pub(crate) enum Event {
| ^^^^^^^^^^^^^^^^^^^^^
= note: `#[warn(private_interfaces)]` on by default
warning: type `traceability::State` is more private than the item `app::App::traceability`
--> crates\oa-desktop\src\app.rs:467:5
|
467 | pub traceability: crate::views::traceability::State,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::App::traceability` is reachable at visibility `pub`
|
note: but type `traceability::State` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:17:1
|
17 | pub(crate) struct State {
| ^^^^^^^^^^^^^^^^^^^^^^^
warning: `oa-desktop` (bin "arcadia-studio" test) generated 2 warnings
Finished `test` profile [unoptimized] target(s) in 1.30s
Running unittests src\main.rs (target\debug\deps\arcadia_studio-e358a5ed59b1de41.exe)
running 22 tests
test behavior::scenario::workspace::tests::production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas ... ok
test behavior::scenario::workspace::tests::production_scenario_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_pan_invalidates_a_pressed_creation_and_caller_restores_once ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_enters_activates_draws_focus_and_returns_to_inspector ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_reveals_and_activates_initially_offscreen_last_tool ... ok
test behavior::scenario::workspace::tests::production_scenario_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes ... ok
test behavior::scenario::workspace::tests::production_scenario_whole_glyph_drag_moves_one_occurrence_and_rejects_other_operand ... ok
test behavior::topology::direct_tests::direct_process_production_click_click_drag_insert_and_scroll_coordinates ... ok
test behavior::topology::direct_tests::direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity ... ok
test behavior::topology::direct_tests::direct_process_production_stale_focus_scroll_bounds_and_pending_cancel ... ok
test behavior::topology::view::layout_tests::production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners ... ok
test behavior::topology::view::layout_tests::production_process_caller_return_rejects_its_previous_camera_lifetime ... ok
test behavior::topology::view::layout_tests::production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags ... ok
test behavior::topology::view::layout_tests::production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only ... ok
test behavior::topology::view::layout_tests::production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds ... ok
test behavior::topology::view::layout_tests::production_process_form_replacement_resets_scroll_without_a_closed_render ... ok
test behavior::topology::view::layout_tests::production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector ... ok
test behavior::topology::view::layout_tests::production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool ... ok
test behavior::topology::view::layout_tests::production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes ... ok
test hosted::client::tests::hosted_production_auth_proxy_prefix_and_located_rejection ... ok
test hosted::client::tests::hosted_production_roundtrip_preserves_comments_noops_and_conflicts ... ok
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 707 filtered out; finished in 21.47s
@@ -0,0 +1,7 @@
{
"source": "06ec9621297627a1f7d5c3bc65efb237d1c9923e",
"start": "2026-09-11T17:53:07.9157207Z",
"end": "2026-09-11T17:53:31.1438144Z",
"exit": 0,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio production_ -- --test-threads=1 --nocapture"
}
@@ -0,0 +1,83 @@
{
"root_before": "638d06c4e33a1f54203b234d0b49c3ecf551d27d",
"candidate_before": "e511c4153fb5bb484652434340e0e4c7000c93d0",
"common": "c0e66f94e3412b5c3495e96f7934ae94116505c9",
"raw_inputs": [
[
"crates/oa-desktop/src/app.rs",
"F059158240FBD41491C65D7BDDE318B0089A6DFED2688A741172535154078236"
],
[
"crates/oa-desktop/src/app/process_canvas.rs",
"8B80661A021E2F9F8E83BD9B0858D707F067C2E42CB180A3CDFA59A1467B3DF9"
],
[
"crates/oa-desktop/src/app/reference_authoring.rs",
"CFD96F4A38754C5FDA1B176B342162B7BFA7BFE0DCAE2A8309953A40395D92BF"
],
[
"crates/oa-desktop/src/behavior.rs",
"A35D03A1A63BB8FD6D2E5D394CC41AB93A6F306F05BDD89353C6B79503F92E64"
],
[
"crates/oa-desktop/src/behavior/canvas_workspace.rs",
"096D667C4C8EE3732D52668A9562C665F2D9A192ABDA4E5487FCAD7621C8DBFD"
],
[
"crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs",
"AF1C7C1E802D1207EE6C7BD66B703811A250D7B56511512AF6C0945C776E68A7"
],
[
"crates/oa-desktop/src/behavior/canvas_workspace/control.rs",
"851AF1E9B155D6D0D079D9164C5616FADC33E961391A6604A43EABF5158957B3"
],
[
"crates/oa-desktop/src/behavior/canvas_workspace/focus.rs",
"CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950"
],
[
"crates/oa-desktop/src/behavior/canvas_workspace/tests.rs",
"0272F78DAA97FAB75E971F1548A3E224C2F2D0A4D9D603DB90DBEB9F6D673C1E"
],
[
"crates/oa-desktop/src/behavior/form_session.rs",
"F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61"
],
[
"crates/oa-desktop/src/behavior/scenario.rs",
"59F859B246C3186AC5B156B46AEE3A0DDC38F79C1CF332C4FB03092214A43A93"
],
[
"crates/oa-desktop/src/behavior/scenario/workspace.rs",
"FED77DDF304FD831CC163F6B8079BB6C17AC755BF141A5D30D5E64C04CBD9677"
],
[
"crates/oa-desktop/src/behavior/scenario/workspace/tests.rs",
"851772CE7C1DA0479DA7D6FB16EF17038653B43DB3B8EDF5E88605AAE4830C89"
],
[
"crates/oa-desktop/src/behavior/topology.rs",
"5723E51A7DA4EB224D51C1659EBBFA4DC8B11557BC8BF70F6E3E1649BAD24449"
],
[
"crates/oa-desktop/src/behavior/topology/view.rs",
"B2D502C11426BFFB8763515F2A56AA4C9F8E203BB36D65EB34F81C123FC406A6"
],
[
"crates/oa-desktop/src/behavior/topology/direct.rs",
"6102219487A0717C66190153C435B5B05226D156C59C7EAAB649249ADEF73644"
],
[
"crates/oa-desktop/src/behavior/topology/direct_tests.rs",
"D3CD44477BDEB31256BA52AA70383CEFC9805A8B172271D357884C95DDEF11D8"
],
[
"crates/oa-desktop/src/behavior/topology/layout_tests.rs",
"39D9C28201EC1B044EAE848B09AB276FF7FCF9F69FE6B5E39A6863F56A0111CE"
],
[
"crates/oa-desktop/src/behavior/topology/contextual.rs",
"5D0AC5B3E258FC70987A00EDE1E603681D99746596F2FCC46AD2B3667D65203A"
]
]
}
@@ -0,0 +1,54 @@
warning: type `traceability::Event` is more private than the item `app::Message::Traceability::0`
--> crates\oa-desktop\src\app.rs:339:18
|
339 | Traceability(crate::views::traceability::Event),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::Message::Traceability::0` is reachable at visibility `pub`
|
note: but type `traceability::Event` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:12:1
|
12 | pub(crate) enum Event {
| ^^^^^^^^^^^^^^^^^^^^^
= note: `#[warn(private_interfaces)]` on by default
warning: type `traceability::State` is more private than the item `app::App::traceability`
--> crates\oa-desktop\src\app.rs:467:5
|
467 | pub traceability: crate::views::traceability::State,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ field `app::App::traceability` is reachable at visibility `pub`
|
note: but type `traceability::State` is only usable at visibility `pub(crate)`
--> crates\oa-desktop\src\views\traceability.rs:17:1
|
17 | pub(crate) struct State {
| ^^^^^^^^^^^^^^^^^^^^^^^
warning: `oa-desktop` (bin "arcadia-studio" test) generated 2 warnings
Finished `test` profile [unoptimized] target(s) in 1.10s
Running unittests src\main.rs (target\debug\deps\arcadia_studio-e358a5ed59b1de41.exe)
running 21 tests
test behavior::scenario::workspace::tests::new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session ... ok
test behavior::scenario::workspace::tests::production_empty_scenario_lifeline_tool_keeps_staged_source_inside_stable_canvas ... ok
test behavior::scenario::workspace::tests::production_scenario_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_keeps_bounds_draft_identity_and_camera_through_editing ... ok
test behavior::scenario::workspace::tests::production_scenario_canvas_pan_invalidates_a_pressed_creation_and_caller_restores_once ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_enters_activates_draws_focus_and_returns_to_inspector ... ok
test behavior::scenario::workspace::tests::production_scenario_keyboard_reveals_and_activates_initially_offscreen_last_tool ... ok
test behavior::scenario::workspace::tests::production_scenario_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes ... ok
test behavior::scenario::workspace::tests::production_scenario_whole_glyph_drag_moves_one_occurrence_and_rejects_other_operand ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_chained_apply_rechecks_live_review_owner ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_missing_exact_target_leaves_other_inputs_untouched ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_returns_from_back_use_and_escape_after_initial_focus_is_consumed ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_saved_action_is_inert_on_replacement_child_review_and_changed_context ... ok
test behavior::scenario::workspace::tests::scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals ... ok
test behavior::scenario::workspace::tests::scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors ... ok
test behavior::scenario::workspace::tests::scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs ... ok
test behavior::scenario::workspace::tests::scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after_preselection ... ok
test behavior::scenario::workspace::tests::scenario_workspace_same_size_owner_and_document_changes_remeasure_fit_viewport ... ok
test behavior::scenario::workspace::tests::scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints ... ok
test behavior::scenario::workspace::tests::scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_identity ... ok
test behavior::scenario::workspace::tests::scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms ... ok
test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 708 filtered out; finished in 20.08s
@@ -0,0 +1,7 @@
{
"source": "06ec9621297627a1f7d5c3bc65efb237d1c9923e",
"start": "2026-09-11T17:54:10.3071064Z",
"end": "2026-09-11T17:54:32.0128367Z",
"exit": 0,
"command": "cargo test --locked -p oa-desktop --bin arcadia-studio behavior::scenario::workspace::tests:: -- --test-threads=1 --nocapture"
}
@@ -0,0 +1,141 @@
{
"source": "06ec9621297627a1f7d5c3bc65efb237d1c9923e",
"records": [
{
"path": "crates/oa-desktop/src/app.rs",
"bytes": 220005,
"sha256": "aa2e4853b25bb7e185b1de49eda42028523c8ebf9599bfe6b7cb2a2b2a688090",
"git_sha256": "aa2e4853b25bb7e185b1de49eda42028523c8ebf9599bfe6b7cb2a2b2a688090",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/app/process_canvas.rs",
"bytes": 30660,
"sha256": "53d4a422d3066e07cccb9e8ef2ad08ea95e451b9b7cbef29a27e6634e035ed97",
"git_sha256": "53d4a422d3066e07cccb9e8ef2ad08ea95e451b9b7cbef29a27e6634e035ed97",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/app/reference_authoring.rs",
"bytes": 11250,
"sha256": "38e033e23e8135032f70117a376a5fe7bc68127c466559a35d7ec200932e3f77",
"git_sha256": "38e033e23e8135032f70117a376a5fe7bc68127c466559a35d7ec200932e3f77",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior.rs",
"bytes": 132668,
"sha256": "5e7cecb54bfca64e4462f7881f460900abd925ba2cfe9451ce2b0c78af4bc850",
"git_sha256": "5e7cecb54bfca64e4462f7881f460900abd925ba2cfe9451ce2b0c78af4bc850",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/canvas_workspace.rs",
"bytes": 7744,
"sha256": "096d667c4c8ee3732d52668a9562c665f2d9a192abda4e5487fcad7621c8dbfd",
"git_sha256": "096d667c4c8ee3732d52668a9562c665f2d9a192abda4e5487fcad7621c8dbfd",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/canvas_workspace/control.rs",
"bytes": 7860,
"sha256": "851af1e9b155d6d0d079d9164c5616fadc33e961391a6604a43eabf5158957b3",
"git_sha256": "851af1e9b155d6d0d079d9164c5616fadc33e961391a6604a43eabf5158957b3",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/canvas_workspace/focus.rs",
"bytes": 8943,
"sha256": "cc58cb3838f546150d22fe4dd83a3ffd52504c279febaeb8d0b4a6a3b8a73950",
"git_sha256": "cc58cb3838f546150d22fe4dd83a3ffd52504c279febaeb8d0b4a6a3b8a73950",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/canvas_workspace/tests.rs",
"bytes": 27020,
"sha256": "d84d022574a6f1d69f203d9383502227fb9fc23605c6fb48779ca9f09bee546d",
"git_sha256": "d84d022574a6f1d69f203d9383502227fb9fc23605c6fb48779ca9f09bee546d",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs",
"bytes": 14436,
"sha256": "af1c7c1e802d1207ee6c7bd66b703811a250d7b56511512af6c0945c776e68a7",
"git_sha256": "af1c7c1e802d1207ee6c7bd66b703811a250d7b56511512af6c0945c776e68a7",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/scenario.rs",
"bytes": 152263,
"sha256": "fb1da03e8df05a59253f7fdf34391e30abd8aea427b7d0a12b86b80ff3bf9eca",
"git_sha256": "fb1da03e8df05a59253f7fdf34391e30abd8aea427b7d0a12b86b80ff3bf9eca",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/scenario/workspace.rs",
"bytes": 26006,
"sha256": "284f5a7da09bdc77d70c6788a091d57927150c796ffc10d786793cff7da4b7c6",
"git_sha256": "284f5a7da09bdc77d70c6788a091d57927150c796ffc10d786793cff7da4b7c6",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/scenario/workspace/tests.rs",
"bytes": 79985,
"sha256": "950cf4347a3edea615b1ac71d9a681c32b446b5109fcfd0f03f3ae2b147360bf",
"git_sha256": "950cf4347a3edea615b1ac71d9a681c32b446b5109fcfd0f03f3ae2b147360bf",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology.rs",
"bytes": 84407,
"sha256": "5723e51a7da4eb224d51c1659ebbfa4dc8b11557bc8bf70f6e3e1649bad24449",
"git_sha256": "5723e51a7da4eb224d51c1659ebbfa4dc8b11557bc8bf70f6e3e1649bad24449",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology/contextual.rs",
"bytes": 28432,
"sha256": "5d0ac5b3e258fc70987a00ede1e603681d99746596f2fcc46ad2b3667d65203a",
"git_sha256": "5d0ac5b3e258fc70987a00ede1e603681d99746596f2fcc46ad2b3667d65203a",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology/direct.rs",
"bytes": 36301,
"sha256": "9309b8978ec2586ff4614236c2f624af6d934d117363e917b8c1cf9f2b1295eb",
"git_sha256": "9309b8978ec2586ff4614236c2f624af6d934d117363e917b8c1cf9f2b1295eb",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology/direct_tests.rs",
"bytes": 50720,
"sha256": "733f8badafb9ba87e3f861f03f78809450eee72cc118341eb03b24904790144a",
"git_sha256": "733f8badafb9ba87e3f861f03f78809450eee72cc118341eb03b24904790144a",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology/layout_tests.rs",
"bytes": 36856,
"sha256": "a990947cb39b2fb15c21dfbc95ee16f92bf6be749d3872c6904762b9bde37089",
"git_sha256": "a990947cb39b2fb15c21dfbc95ee16f92bf6be749d3872c6904762b9bde37089",
"newline_only_difference": false
},
{
"path": "crates/oa-desktop/src/behavior/topology/view.rs",
"bytes": 23136,
"sha256": "b2d502c11426bffb8763515f2a56aa4c9f8e203bb36d65eb34f81c123fc406a6",
"git_sha256": "b2d502c11426bffb8763515f2a56aa4c9f8e203bb36d65eb34f81c123fc406a6",
"newline_only_difference": false
},
{
"path": "arcadia-studio-tests.exe",
"bytes": 139577856,
"sha256": "0d5009baa165bb7617ec90e2a387c474c872cc876ed4e80aaea9e5fa108ba304"
},
{
"path": "libunwind.dll",
"bytes": 190464,
"sha256": "aa282fa47bb2c4e210cea5e470cdbdca58568f0be0bc73e404d86ab9cd95ef6a"
}
]
}
@@ -0,0 +1,715 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
if let Some(context) = self.topology_context() {
let mut state = std::mem::take(&mut self.behavior.topology);
if state.reveal_selection(self.project(), context.owner, self.selected) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.topology = state;
}
Task::none()
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
Event::Focus
| Event::Traverse(_)
| Event::ControlFocus(_)
| Event::ToggleFocus
| Event::Leave => {
return canvas_workspace::focus_task(scope, input);
}
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
input => {
if self.behavior.topology.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::ExpandQualified(_, endpoint) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand_context(self.project(), context.owner, endpoint);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::OpenQualified(_, endpoint) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let resolved = oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint);
if let Ok(resolved) = resolved {
self.behavior.topology.remember(
context.owner,
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::SelectQualified(_, endpoint) => {
if !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
&& let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(self.project())
.resolve(context.owner, &endpoint)
{
let reference = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
self.select(reference);
self.behavior.topology.select_context(resolved.address);
}
}
CanvasEvent::Focus(_) => {
self.behavior.topology.focused = !self.behavior.topology.focused;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
self.behavior.topology.clear_context();
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
let mut state = std::mem::take(&mut self.behavior.topology);
state.camera_zoom(self.project(), context.owner, zoom);
self.behavior.topology = state;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
let (mut app, f) = setup();
let original = app.document.snapshot();
let initial = app.selected;
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Name(" unfinished contextual work ".into()),
);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(app.behavior.topology.focused);
let raw = app.behavior.recovery_snapshot().unwrap();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[1],
)));
assert_eq!(app.selected, initial);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(!app.behavior.topology.focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
@@ -0,0 +1,304 @@
//! One transient chooser for the currently mounted Inspector or review field.
//! Browsing never calls a draft setter or semantic planner.
use super::{App, Message, Page, Pending};
use crate::{
forms::{
Draft,
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
},
navigation::InspectorTab,
semantic_review::{self, RowAddress, RowEvent},
workflow,
};
use iced::{Element, Rectangle, Task};
use oa_core::{FacetSelector, Id, Layer};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Host {
Inspector,
Review {
address: Option<RowAddress>,
facet: FacetSelector,
},
}
impl Host {
fn message(&self, event: chooser::Event) -> Message {
match self {
Self::Inspector => Message::FieldReferenceEvent(event),
Self::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceEvent(event),
}))
}
}
}
}
struct Opened {
host: Host,
request: adapter::Open,
document_session: Id,
page: Page,
layer: Layer,
chooser: chooser::State,
}
#[derive(Default)]
pub(super) struct State {
opened: Option<Opened>,
}
impl App {
pub(crate) fn reference_input_active(&self) -> bool {
self.reference_authoring.opened.is_some()
}
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
match host {
Host::Inspector
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
{
self.draft.as_ref()
}
Host::Review { address, facet } => match self.workflow.as_ref()? {
workflow::Workflow::Semantic(state) => {
state.reference_draft(self.project(), *address, facet, path)
}
_ => None,
},
_ => None,
}
}
fn reference_available(&self) -> bool {
!self.busy
&& !self.hosted.visible
&& self.architecture.is_none()
&& self.model_menu.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.pending.is_none()
&& !self.source_dirty()
&& !self.behavior.has_pending()
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
}
fn reference_valid(&self, opened: &Opened) -> bool {
self.reference_available()
&& self.canvas_document == opened.document_session
&& self.page == opened.page
&& self.layer == opened.layer
&& self.document.revision() == opened.chooser.stamp().context.revision
&& self.project().id == opened.request.document
&& self
.reference_draft(&opened.host, &opened.request.path)
.is_some_and(|draft| draft.reference_matches(&opened.request))
}
pub(super) fn reconcile_reference_chooser(&mut self) {
if self
.reference_authoring
.opened
.as_ref()
.is_some_and(|opened| !self.reference_valid(opened))
{
self.reference_authoring
.opened
.as_mut()
.unwrap()
.chooser
.invalidate();
self.reference_authoring.opened = None;
}
}
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
if !self.reference_available() {
return Task::none();
}
let Some(draft) = self.reference_draft(&host, &request.path) else {
return Task::none();
};
let Ok(chooser) =
draft.reference_catalog(self.project(), &request, self.document.revision())
else {
return Task::none();
};
self.reference_authoring.opened = Some(Opened {
host,
request,
document_session: self.canvas_document,
page: self.page,
layer: self.layer,
chooser,
});
Task::none()
}
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
let Some(mut opened) = self.reference_authoring.opened.take() else {
return Task::none();
};
if !self.reference_valid(&opened) {
return Task::none();
}
if opened.host != host {
self.reference_authoring.opened = Some(opened);
return Task::none();
}
match opened.chooser.update(event) {
Outcome::Ignored | Outcome::Browsed => {
self.reference_authoring.opened = Some(opened);
Task::none()
}
Outcome::Closed(reason) => self.reference_focus(
&opened,
match reason {
Close::Escape => Some(0),
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
Close::Outside | Close::WindowChanged => None,
},
),
Outcome::Write { context, value } => {
let intent = adapter::Set {
context,
generation: opened.request.generation,
value,
};
let result = match &opened.host {
Host::Inspector => self
.draft
.as_mut()
.ok_or_else(|| "The property editor closed.".to_owned())
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
Host::Review { address, facet } => match self.workflow.as_mut() {
Some(workflow::Workflow::Semantic(state)) => {
state.set_reference(self.document.project(), *address, facet, intent)
}
_ => Err("The review editor closed.".into()),
},
};
if let Err(error) = result {
self.status = error;
}
// A scoped write rotates field generation. The task targets the
// newly rendered field, while a later structural edit makes it inert.
self.reference_focus(&opened, Some(0))
}
}
}
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
let Some(step) = step else {
return Task::none();
};
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
return Task::none();
};
if draft.reference_session() != opened.request.session {
return Task::none();
}
trigger::focus(
draft.reference_session(),
draft.reference_generation(),
&opened.request.path,
step,
)
}
/// Called before generic modal dispatch: the review owns a modal, and this
/// chooser owns only its active field. All browser events stop here.
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
self.reconcile_reference_chooser();
match message {
Message::FieldReferenceOpen(request) => {
Some(self.open_reference(Host::Inspector, request.clone()))
}
Message::FieldReferenceEvent(event) => {
Some(self.reference_event(Host::Inspector, event.clone()))
}
Message::Workflow(workflow::Event::Semantic(review_event)) => {
let semantic_review::Event::Row {
address,
facet: Some(facet),
event,
} = review_event.as_ref()
else {
return None;
};
let host = Host::Review {
address: *address,
facet: facet.clone(),
};
match event {
RowEvent::ReferenceOpen(request) => {
Some(self.open_reference(host, request.clone()))
}
RowEvent::ReferenceEvent(event) => {
Some(self.reference_event(host, event.clone()))
}
_ => None,
}
}
Message::Escape | Message::Request(Pending::Close(_))
if self.reference_authoring.opened.is_some() =>
{
let opened = self.reference_authoring.opened.as_ref().unwrap();
let host = opened.host.clone();
let event = opened.chooser.event(Action::Close(Close::Escape));
Some(self.reference_event(host, event))
}
// Global shortcuts are also emitted for keys captured by the search
// input. They must not apply drafts, navigate, or undo the model.
Message::Save(_)
| Message::ShortcutUndo(_)
| Message::Request(Pending::Open | Pending::New)
| Message::SourceNavigation(_)
| Message::Page(Page::Guide)
if self.reference_authoring.opened.is_some() =>
{
Some(Task::none())
}
_ => None,
}
}
pub(super) fn reference_overlay<
'a,
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
>(
&'a self,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
let opened = self
.reference_authoring
.opened
.as_ref()
.filter(|opened| self.reference_valid(opened));
let host = opened
.map(|opened| opened.host.clone())
.unwrap_or(Host::Inspector);
let focus_scope = if self.reference_available() {
match self.workflow.as_ref() {
None if self.inspector_tab == InspectorTab::Properties => {
self.draft.as_ref().map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: false,
})
}
Some(workflow::Workflow::Semantic(state)) => state
.reference_form(self.project())
.map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: true,
}),
_ => None,
}
} else {
None
};
chooser::widget::Popup::new(
content,
opened.map(|opened| &opened.chooser),
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
move |event| host.message(event),
)
.focus_scope(focus_scope)
.into()
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,243 @@
//! Shared presentation and input contract; contains no model commands.
use crate::app::{App, Message};
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
use iced::{Element, Length::Fill, Point, Theme, Vector};
use oa_core::Id;
mod control;
mod focus;
mod viewport;
pub use control::Control;
pub use viewport::Viewport;
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
!app.busy
&& !app.hosted.visible
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& !app.reference_input_active()
}
pub fn controls<'a>(
scope: Scope,
keyboard: bool,
scale: f32,
details: Message,
focus: Option<Message>,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
let message = message.map(|message| intent(scope, name, message));
Control::new(
button(text(label).size(12))
.padding(6)
.on_press_maybe(message.clone()),
scope,
name,
keyboard,
message,
publish,
)
};
row![
control("Details", "Details", Some(details)),
tooltip(control("Canvas", "Canvas", focus),
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(format!("{:.0}%", scale * 100.)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
].spacing(4).align_y(iced::Center).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
impl Scope {
pub fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
})
} else {
let context = app.topology_context()?;
Some(Self {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
})
}
}
pub fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
{
Self::current(app)
} else {
None
}
}
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
))
}
}
/// Only a live presentation intent may carry a toolbar action across its own pan.
#[derive(Debug, Clone)]
pub struct Intent {
pub scope: Scope,
pub name: &'static str,
pub message: Box<Message>,
}
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
Message::BehaviorControl(Intent {
scope,
name,
message: Box::new(message),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Focus,
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
Leave,
}
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
if next.x.is_finite() && next.y.is_finite() && next != *pan {
*pan = next;
true
} else {
false
}
}
Event::Zoom { factor, at, base }
if factor.is_finite()
&& factor > 0.
&& base.is_finite()
&& base > 0.
&& at.x.is_finite()
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if old == next {
return false;
}
let anchor = Vector::new(at.x, at.y);
let next_pan = anchor - (anchor - *pan) * (next / old);
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
return false;
}
*pan = next_pan;
*zoom = Some(next);
*fit = false;
true
}
_ => false,
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
keyboard: bool,
publish: impl Fn(Event) -> Message + Copy + 'a,
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> Element<'a, Message> {
let mut tools = column![].spacing(3);
for (glyph, label, selected, message) in entries {
let message = message.map(|message| intent(scope, glyph, message));
tools = tools.push(tooltip(
container(Control::new(
button(text(glyph).size(12))
.width(34)
.height(30)
.padding(3)
.style(if selected {
button::primary
} else {
crate::style::nav
})
.on_press_maybe(message.clone()),
scope,
glyph,
keyboard,
message,
publish,
))
.id(format!("behavior-tool:{glyph}")),
container(text(label).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
));
}
container(scrollable(tools).id(scope.control_id("palette-scroll")))
.width(40)
.height(Fill)
.padding(3)
.style(crate::style::panel)
.into()
}
/// Header and palette are permanent. Details/editors overlay the same viewport.
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
header: Element<'a, Message, Theme, R>,
palette: Element<'a, Message, Theme, R>,
canvas: Element<'a, Message, Theme, R>,
editor: Option<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
let mut body = stack![canvas];
if let Some(editor) = editor {
body = body.push(
container(opaque(
container(editor)
.width(300)
.height(Fill)
.style(crate::style::card),
))
.align_right(Fill)
.height(Fill)
.padding(8),
);
}
column![header, row![body, palette].height(Fill)]
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
pub(crate) mod tests;
@@ -0,0 +1,258 @@
//! A real focusable keyboard target around the existing styled pointer button.
use super::{Event as CameraEvent, Scope, focus::Member};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
#[derive(Default)]
struct Input {
identity: Option<iced::advanced::widget::Id>,
focused: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
}
}
pub struct Control<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
name,
enabled: enabled && activate.is_some(),
activate,
publish: Box::new(publish),
}
}
}
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
identity: Some(self.scope.control_id(self.name)),
focused: false,
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
let identity = self.scope.control_id(self.name);
if state.identity.as_ref() != Some(&identity) || !self.enabled {
*state = Input {
identity: Some(identity),
focused: false,
};
}
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
let id = self.scope.control_id(self.name);
operation.container(Some(&id), layout.bounds());
if self.enabled {
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
operation.traverse(&mut |operation| {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
});
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
if self.enabled {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
repeat,
..
}) = event
&& state.focused
{
use keyboard::{Key, key::Named};
let action = match key.as_ref() {
Key::Named(Named::Tab) => {
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
}
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
Some((self.publish)(CameraEvent::ToggleFocus))
}
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
self.activate.clone()
}
_ => None,
};
if let Some(action) = action {
shell.publish(action);
shell.capture_event();
shell.request_redraw();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) {
if !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.focused = false;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
if self.enabled && tree.state.downcast_ref::<Input>().focused {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::INK,
width: 2.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(control: Control<'a, Message, R>) -> Self {
Element::new(control)
}
}
@@ -0,0 +1,293 @@
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
use super::{Event, Scope};
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
pub(super) struct Member(pub Scope);
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
iced::advanced::widget::operate(Collect {
scope,
valid: false,
event,
members: vec![],
focused: None,
palette: None,
})
}
struct Collect {
scope: Scope,
valid: bool,
event: Event,
members: Vec<(Id, Rectangle)>,
focused: Option<usize>,
palette: Option<(Rectangle, Rectangle)>,
}
impl<T: Send + 'static> Operation<T> for Collect {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
if self.valid
&& value
.downcast_ref::<Member>()
.is_some_and(|m| m.0 == self.scope)
&& let Some(id) = id
{
self.members.push((id.clone(), bounds));
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
if value.is_focused() {
self.focused = self
.members
.iter()
.position(|(member, _)| Some(member) == id)
.or(self.focused);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
_: &mut dyn operation::Scrollable,
) {
if id == Some(&self.scope.control_id("palette-scroll")) {
self.palette = Some((bounds, content));
}
}
fn finish(&self) -> operation::Outcome<T> {
let canvas = self.scope.control_id("viewport");
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
// owners expose no members; a delayed request cannot clear their focus.
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
return operation::Outcome::None;
}
let target = match self.event {
Event::Focus => Some(canvas),
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
Event::Traverse(back) => {
let Some(current) = self.focused else {
return operation::Outcome::None;
};
let next = if back {
(current + self.members.len() - 1) % self.members.len()
} else {
(current + 1) % self.members.len()
};
Some(self.members[next].0.clone())
}
_ => return operation::Outcome::None,
};
if target
.as_ref()
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
{
return operation::Outcome::None;
}
let reveal = target.as_ref().and_then(|target| {
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
let (bounds, content) = self.palette?;
// Only the palette has member controls inside this scrollable.
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
(field.center_y() - bounds.y - bounds.height / 2.)
.clamp(0., (content.height - bounds.height).max(0.))
})
});
operation::Outcome::Chain(Box::new(Apply {
scope: self.scope,
valid: false,
target,
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
palette: self.scope.control_id("palette-scroll"),
reveal,
}))
}
}
struct Apply {
scope: Scope,
valid: bool,
target: Option<Id>,
members: Vec<Id>,
palette: Id,
reveal: Option<f32>,
}
impl<T> Operation<T> for Apply {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if !self.valid {
return;
}
if self
.target
.as_ref()
.is_some_and(|target| Some(target) == id)
{
state.focus();
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
state.unfocus();
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& id == Some(&self.palette)
&& let Some(y) = self.reveal
{
state.scroll_to(operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
}
}
}
// Every Collect/Apply pass observes current ownership before touching any field.
struct Owner(Option<Scope>);
struct Root<'a, Message, R: renderer::Renderer> {
owner: Option<Scope>,
content: Element<'a, Message, Theme, R>,
}
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Scope>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
owner,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -0,0 +1,757 @@
use super::*;
use crate::app::{App, Message};
use iced::advanced::{Layout, Shell, layout, widget};
use iced::futures::{
StreamExt,
stream::{BoxStream, SelectAll},
};
use iced::{Rectangle, Size, mouse};
use iced_runtime::{
Action,
user_interface::{Cache, UserInterface},
};
use std::collections::HashMap;
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
// iced_winit drains messages while collecting immediately ready actions. A
// pending Task stream survives until its widget operation sends its result.
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
loop {
match stream.poll_next_unpin(&mut context) {
std::task::Poll::Ready(Some(action)) => ready.push(action),
std::task::Poll::Ready(None) => break,
std::task::Poll::Pending => {
pending.push(stream);
break;
}
}
}
}
}
#[derive(Default)]
pub(crate) struct Geometry {
pub containers: HashMap<widget::Id, Rectangle>,
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
pub text_inputs: Vec<Rectangle>,
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.focused.push((id.cloned(), bounds, state.is_focused()));
}
fn text_input(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
self.text_inputs.push(bounds);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some(id) = id {
self.containers.insert(id.clone(), bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if let Some(id) = id {
self.scrolls.insert(id.clone(), (bounds, offset));
}
}
}
impl Geometry {
pub fn bounds(&self, id: &str) -> Rectangle {
self.containers[&widget::Id::from(id.to_owned())]
}
}
/// Mount the actual App root, production widgets and renderer. Events are
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
/// This deliberately does not seed any private canvas gesture state.
pub(crate) struct Mounted {
pub renderer: iced::Renderer,
cache: Cache,
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
mounted.settle(app).await;
mounted
}
pub fn geometry(&mut self, app: &App) -> Geometry {
let mut geometry = Geometry::default();
self.operate(app, &mut geometry);
geometry
}
/// One operation pass, including every nested overlay, via installed Iced.
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
ui.operate(&self.renderer, operation);
self.cache = ui.into_cache();
}
pub async fn settle(&mut self, app: &mut App) {
for _ in 0..3 {
self.batch(
app,
&[iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))],
mouse::Cursor::Unavailable,
)
.await;
}
}
pub async fn batch(
&mut self,
app: &mut App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let messages = self.input(app, events, cursor);
let mut ready = vec![];
let mut pending = Pending::new();
for message in messages.iter().cloned() {
enqueue(app.update(message), &mut ready, &mut pending);
}
self.drain(app, ready, pending).await;
messages
}
pub fn input(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
events,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
self.cache = ui.into_cache();
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
let mut pending = Pending::new();
enqueue(task, &mut ready, &mut pending);
self.drain(app, ready, pending).await;
}
async fn drain(
&mut self,
app: &mut App,
mut ready: Vec<Action<Message>>,
mut pending: Pending,
) {
loop {
let mut outputs = vec![];
if !ready.is_empty() {
// Build only after all App messages in this cycle have run.
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
for action in ready.drain(..) {
match action {
Action::Output(message) => outputs.push(message),
Action::Widget(mut operation) => {
self.operations += 1;
loop {
ui.operate(&self.renderer, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(next) => operation = next,
widget::operation::Outcome::None
| widget::operation::Outcome::Some(()) => break,
}
}
}
action => {
panic!("Unexpected native action in mounted App harness: {action:?}")
}
}
}
self.cache = ui.into_cache();
}
for message in outputs {
enqueue(app.update(message), &mut ready, &mut pending);
}
if !ready.is_empty() {
continue;
}
let Some(action) = pending.next().await else {
break;
};
ready.push(action);
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
ready.push(action);
}
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
// update initializes the nested overlay layout used by draw.
let mut messages = vec![];
let _ = ui.update(
&[],
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"An empty draw preparation must not drop App messages"
);
ui.draw(
&mut self.renderer,
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
Headless::screenshot(
&mut self.renderer,
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
)
}
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(end),
)
.await
}
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::from(id.to_owned()),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
self.operate(app, &mut operation);
}
}
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard::{self, Key};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
#[test]
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
for scale in [0.05, 0.5, 1., 2.] {
let mut pan = Vector::new(-130., 75.);
let mut zoom = Some(scale);
let mut fit = false;
let at = Point::new(171., 93.);
let world = (at - Point::ORIGIN - pan) / scale;
change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 1.25,
at,
base: scale,
},
);
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
let before = (pan, zoom, fit);
for input in [
Event::Pan(Vector::new(f32::INFINITY, 0.)),
Event::Zoom {
factor: f32::NAN,
at,
base: scale,
},
Event::Zoom {
factor: 0.,
at,
base: scale,
},
] {
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
assert_eq!((pan, zoom, fit), before);
}
}
}
#[test]
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
epoch: 0,
};
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
Space::new().width(Fill).height(Fill),
scope,
1.,
true,
|e| e,
)
.into();
let mut tree = widget::Tree::new(view.as_widget());
let node = view
.as_widget_mut()
.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
)
.move_to(Point::new(30., 80.));
let start = Point::new(90., 160.);
let middle = Point::new(125., 175.);
let end = Point::new(145., 185.);
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: middle },
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(end),
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(Size::new(700., 600.)),
);
}
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
let total = messages
.iter()
.fold(Vector::ZERO, |total, event| match event {
Event::Pan(delta) => total + *delta,
_ => total,
});
assert_eq!(total, end - start);
}
pub(crate) fn scope(app: &App) -> Scope {
if let Some(context) = app.scenario_context() {
Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
}
} else {
let context = app.topology_context().expect("mounted behavior owner");
Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
}
}
}
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
let id = scope(app).control_id(name);
mounted
.geometry(app)
.focused
.iter()
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
}
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
for _ in 0..32 {
if focused(mounted, app, name) {
return;
}
mounted
.batch(
app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
}
panic!("Keyboard traversal did not reach {name}");
}
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
messages.iter().find(|m| matches!(m,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
)).expect("actual nonzero camera delta from a mounted gesture").clone()
}
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await;
}
pub(crate) async fn release_middle(
mounted: &mut Mounted,
app: &mut App,
point: Point,
) -> Vec<Message> {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await
}
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
let width = size.width as usize;
assert_eq!(before.len(), width * size.height as usize * 4);
assert_eq!(after.len(), before.len());
let mut changed = 0;
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
if x as f32 - bounds.x > 2.
&& bounds.x + bounds.width - x as f32 > 2.
&& y as f32 - bounds.y > 2.
&& bounds.y + bounds.height - y as f32 > 2.
{
continue;
}
let start = (y * width + x) * 4;
if before[start..start + 3] != after[start..start + 3] {
changed += 1;
}
}
}
assert!(
changed >= bounds.width.min(bounds.height) as usize,
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
);
}
pub(crate) fn character(value: &str) -> iced::Event {
let mut event = key(iced::keyboard::key::Named::Space);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modified_key,
text,
..
}) = &mut event
{
*key = iced::keyboard::Key::Character(value.into());
*modified_key = key.clone();
*text = Some(value.into());
}
event
}
/// Inspect the action without stripping the lifetime from retained packets.
pub(crate) fn intent_action(message: &Message) -> &Message {
match message {
Message::BehaviorControl(intent) => &intent.message,
message => message,
}
}
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
pub(crate) async fn retained_focus_apply(
mounted: &mut Mounted,
app: &mut App,
) -> Box<dyn widget::Operation> {
let entry = mounted.input(
app,
&[key(iced::keyboard::key::Named::F6)],
mouse::Cursor::Unavailable,
);
assert_eq!(
entry.len(),
1,
"actual initial F6 must emit one canvas focus request"
);
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
.expect("F6 returns a widget Task");
let Some(Action::Widget(mut operation)) = stream.next().await else {
panic!("F6 first action must be Collect");
};
mounted.operate(app, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(apply) => apply,
_ => panic!("Live Collect must produce its guarded Apply pass"),
}
}
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
use iced::keyboard::key::Named;
let geometry = mounted.geometry(app);
let last_bounds = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert_eq!(offset.y, 0.);
assert!(
last_bounds.y >= rail.y + rail.height,
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
);
mounted
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
tab_to(mounted, app, last).await;
let geometry = mounted.geometry(app);
let mut visible = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
visible.x -= offset.x;
visible.y -= offset.y;
assert!(
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
);
let messages = mounted
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(
messages.iter().any(
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
),
"the revealed last tool is keyboard activated"
);
assert!(focused(mounted, app, last));
let on = mounted.pixels(app);
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let off = mounted.pixels(app);
assert_drawn_focus(&off, &on, app.window_size, visible);
}
pub(crate) async fn retained_toolbar_boundaries(
mounted: &mut Mounted,
app: &mut App,
tool: &'static str,
) {
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
let epoch = app.canvas_epoch;
let process = app.behavior.topology.camera_boundary();
let scenario = app.behavior.scenario.camera_boundary();
match boundary {
"Chooser" => {
let draft = app.draft.as_ref().unwrap();
let id = crate::forms::reference_chooser::trigger::field_id(
draft.reference_generation(),
"/parent",
);
let bounds = mounted
.geometry(app)
.focused
.iter()
.find(|(field, _, _)| field.as_ref() == Some(&id))
.expect("real Inspector parent reference trigger")
.1;
let request = draft.reference_open(app.project(), "/parent", bounds);
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(app, task).await;
assert!(app.reference_input_active());
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!app.reference_input_active());
}
"Dirty" => {
let task = app.update(Message::Field(
"/name".into(),
"Unsaved camera boundary".into(),
));
mounted.perform(app, task).await;
assert!(app.draft_dirty());
// Every retained packet is also rejected while the owner is dirty.
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert!(app.draft_dirty());
let task = app.update(Message::RevertProperties);
mounted.perform(app, task).await;
assert!(!app.draft_dirty());
}
"Picker" => {
// Public transitions, with cancellation injected at the external
// picker boundary. Never poll the native dialog future here.
let picker = app.update(Message::Save(true));
if cfg!(target_os = "linux") {
assert!(app.file_prompt.is_some());
mounted.perform(app, picker).await;
let task = app.update(Message::CancelFile);
mounted.perform(app, task).await;
} else {
assert!(app.busy, "Save As enters the real native-picker busy state");
drop(picker);
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
let task = app.update(Message::SavePath(None));
mounted.perform(app, task).await;
assert!(!app.busy);
}
}
_ => unreachable!(),
}
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} changes the presentation lifetime"
);
assert_eq!(
app.canvas_epoch, epoch,
"{boundary} cycle leaves semantic context unchanged"
);
let focus_before = mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
let current_lifetime = app.camera_epoch;
for packet in packets {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} retained intents cannot start a new lifetime"
);
assert!(
app.behavior.topology.camera_boundary() == process,
"{boundary} retained toolbar packet changed process presentation"
);
assert!(
app.behavior.scenario.camera_boundary() == scenario,
"{boundary} retained toolbar packet changed scenario presentation"
);
assert_eq!(
mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
focus_before,
"{boundary} retained toolbar packet stole field focus"
);
}
}
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
mounted
.input(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.into_iter()
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
.expect("actual production toolbar packet")
}
@@ -0,0 +1,426 @@
use super::{Event as CameraEvent, Scope};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
use iced::{keyboard, mouse};
#[derive(Default)]
struct Input {
scope: Option<Scope>,
focused: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
self.pan = None;
self.space = false;
}
}
pub struct Viewport<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
scale: f32,
keyboard: bool,
blank_pan: bool,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
scale: f32,
keyboard: bool,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
scale,
keyboard,
blank_pan: false,
publish: Box::new(publish),
}
}
pub fn pan_background(mut self, enabled: bool) -> Self {
self.blank_pan = enabled;
self
}
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
scope: Some(self.scope),
..Default::default()
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
if state.scope.is_none_or(|s| {
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
}
if state
.scope
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|| !self.keyboard
{
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn Operation,
) {
if self.keyboard {
let id = self.scope.control_id("viewport");
operation.custom(
Some(&id),
layout.bounds(),
&mut super::focus::Member(self.scope),
);
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
state.bounds = Some(layout.bounds());
if changed_bounds || !self.keyboard {
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
let pointer = match event {
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::ButtonReleased(_)
| mouse::Event::WheelScrolled { .. },
) => state.pointer.or_else(|| cursor.position()),
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().or(state.pointer),
};
let visible = layout.bounds().intersection(viewport);
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
&& cursor.position().is_some();
if !self.keyboard {
state.space = false;
}
let mut cancel = changed_bounds;
let mut camera = None;
let mut capture = false;
match event {
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
state.pan = None;
state.space = false;
state.focused = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
state.pointer = None;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
state.pan = None;
state.space = false;
cancel = true;
}
state.focused = inside && self.keyboard;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
|| (*button == mouse::Button::Left && state.space))
{
state.pan = pointer.map(|p| (*button, p));
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some((button, previous)) = state.pan {
state.pan = Some((button, *position));
camera = Some(CameraEvent::Pan(*position - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
if let Some((held, previous)) = state.pan
&& held == *button
{
state.pan = None;
camera = pointer
.filter(|_| inside)
.map(|p| CameraEvent::Pan(p - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
state.pan = None;
cancel = true;
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
};
if y != 0. {
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
camera = Some(CameraEvent::Zoom {
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
at,
base: self.scale,
});
capture = true;
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F6),
modifiers,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
}
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if state.focused && self.keyboard =>
{
use keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::Space) => {
state.space = true;
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
Key::Named(Named::Tab) => {
camera = Some(CameraEvent::Traverse(modifiers.shift()));
capture = true;
}
Key::Named(Named::Escape) => {
state.pan = None;
state.space = false;
camera = Some(CameraEvent::Leave);
capture = true;
}
Key::Named(key) if modifiers.is_empty() => {
let delta = match key {
Named::ArrowLeft => Some(Vector::new(60., 0.)),
Named::ArrowRight => Some(Vector::new(-60., 0.)),
Named::ArrowUp => Some(Vector::new(0., 60.)),
Named::ArrowDown => Some(Vector::new(0., -60.)),
_ => None,
};
camera = delta.map(CameraEvent::Pan);
capture = camera.is_some();
cancel = capture;
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
{
state.space = false;
}
_ => {}
}
if cancel {
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if let Some(event) = camera {
shell.publish((self.publish)(event));
}
if capture {
shell.capture_event();
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
return;
}
let child_cursor = if inside {
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
// A semantic hit or armed creation tool gets first refusal. In Select,
// an otherwise unused blank press starts the same camera gesture.
if self.blank_pan
&& self.keyboard
&& inside
&& !shell.is_event_captured()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
{
state.pan = pointer.map(|p| (mouse::Button::Left, p));
shell.publish((self.publish)(CameraEvent::CancelGesture));
shell.capture_event();
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
renderer.with_layer(layout.bounds(), |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::TEAL,
width: 1.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if tree.state.downcast_ref::<Input>().pan.is_some() {
mouse::Interaction::Grabbing
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(viewport: Viewport<'a, Message, R>) -> Self {
Element::new(viewport)
}
}
@@ -0,0 +1,697 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::{form_session, scenario_scroll};
use super::*;
use iced::widget::{Space, column, sensor};
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
|| !size.height.is_finite()
|| size.width <= 0.
|| size.height <= 0.
|| self.viewport == Some(size)
{
return false;
}
self.viewport = Some(size);
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
pub(super) fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
}
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let state = &app.behavior.scenario;
let presentation = &state.presentation;
let pending = state
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let context = app.scenario_context().expect("visible scenario context");
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
scene.pending_placement = state.pending_placement(owner);
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(tool == CanvasTool::Select),
)
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let mut navigation = row![
out.width(Fill),
super::super::canvas_workspace::Control::new(
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
scope,
"Panels",
keyboard,
Some(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
camera
)
]
.spacing(6);
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
let header = column![
navigation,
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
scope.epoch,
super::super::canvas_workspace::Event::Focus
))),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"L",
"Lifeline: click to choose represented work or participant",
CanvasTool::Add(Tool::Lifeline),
),
(
"C/R",
"Call and correlated reply",
CanvasTool::Message(MessageStyle::CallReply),
),
(
"M",
"Asynchronous message",
CanvasTool::Message(MessageStyle::Asynchronous),
),
(
"C",
"Synchronous call",
CanvasTool::Message(MessageStyle::Call),
),
(
"R",
"Reply to existing call",
CanvasTool::Message(MessageStyle::Reply),
),
(
"+L",
"Create participant",
CanvasTool::Message(MessageStyle::Create),
),
(
"-L",
"Delete participant",
CanvasTool::Message(MessageStyle::Delete),
),
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
(
"A",
"Action: click a sequence position",
CanvasTool::Add(Tool::Action),
),
(
"St",
"State observation: click a sequence position",
CanvasTool::Add(Tool::Observation),
),
(
"ref",
"Reuse scenario: click a sequence position",
CanvasTool::Add(Tool::Reuse),
),
(
"alt",
"Fragment: click a sequence position",
CanvasTool::Add(Tool::Fragment),
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
context,
app.selected,
))),
));
}
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
sequence_rows(p, context, owner, details, clean).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
Space::new().height(0).into(),
Space::new().height(0).into(),
);
if let Some(d) = app
.behavior
.scenario
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
if let Some(child) = d.source.view(p) {
form_panel = column![
text(format!(
"Consumer name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
child.map(|e| event(Event::Source(e)))
]
.spacing(6)
.height(Fill)
.into();
} else {
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.id(focus::name_id(
state.focus_stamp(context).expect("visible parent form")
))
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.spacing(9);
if d.tool != Tool::Lifeline {
form = form.push(label(format!(
"Insert: {}",
slot_label(
p,
Slot {
parent: d.parent,
before: d.before
}
)
)));
if d.before.is_some() {
form = form.push(
button("Append to this sequence instead")
.on_press(event(Event::AppendInstead)),
);
}
form = form.push(picker(
parents(p, owner),
Some(d.parent),
"Destination sequence / operand…",
Event::Parent,
));
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Lifeline {
if let Some(title) = d.source.label(d.target) {
form = form.push(label(title)).push(label(
"Create commits one new source and this lifeline together.",
));
}
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
form = form.push(button("Create new source…").on_press(event(Event::Source(
super::super::source_draft::Event::Open(d.invocation),
))));
} else {
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
}
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
));
if d.call.is_some() {
form = form.push(label(
"Reply reuses the call exchange in reverse and retains its correlation.",
));
}
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"Choose the exchange…",
Event::Exchange,
));
form = form.push(label(
"Apply checks that the message is valid at this insertion position.",
));
}
form = form.push(picker(
d.line_choices(p, true),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
));
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
}
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
let session = state.form_session().expect("visible scenario draft");
let mount = state.form_mount().expect("visible parent form");
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
.key(mount)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
.into();
}
}
let editor = if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn sequence_action<'a>(
title: &str,
message: Message,
enabled: bool,
) -> iced::widget::Button<'a, Message> {
button(text(title.to_owned()).size(12))
.padding(8)
.style(style::nav)
.on_press_maybe(enabled.then_some(message))
}
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
details: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
scenario_scroll::observe(
context,
ScrollArea::Page,
false,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(Fill),
)
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, Theme, R>,
fields: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
column![
container(actions).id("scenario-actions").width(Fill),
form_session::reset_on_change(
session,
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
),
]
.spacing(6),
)
.padding(10)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
#[cfg(all(test, debug_assertions))]
mod tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
owner: Id,
mut out: Column<'a, Message>,
enabled: bool,
) -> Column<'a, Message> {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get(&owner).and_then(|e| e.shared())
{
for branch in branches {
out = out.push(sequence_action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
enabled,
));
out = sequence_rows(p, context, *branch, out, enabled);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
for (i, id) in steps.iter().enumerate() {
out = out.push(
row![
sequence_action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id),
enabled
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(enabled && i + 1 < steps.len())
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
)
]
.spacing(6),
);
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::ScenarioUse {
scenario: Some(target),
..
},
..
}) = p.elements[id].shared()
{
out = out.push(sequence_action(
&format!("Open {} →", p.named(*target)),
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
enabled,
));
}
out = sequence_rows(p, context, *id, out, enabled);
}
}
out
}
@@ -0,0 +1,791 @@
//! Lazy qualified authoring over the core address resolver. A folded reference
//! never replaces its retained endpoint with the reference or a same-named leaf.
use super::*;
use iced::widget::column;
use oa_core::topology_context::TopologyIndex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum Purpose {
#[default]
Precedence,
Exchange,
CommonWork,
}
impl std::fmt::Display for Purpose {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Precedence => "Precedence",
Self::Exchange => "Exchange assembly",
Self::CommonWork => "Common work incidence",
})
}
}
#[derive(Debug, Clone)]
pub enum Event {
Side(bool),
Pick(OccurrenceEndpoint),
Expand(OccurrenceEndpoint),
Up,
Root,
Purpose(Purpose),
Evidence(Choice),
Guard(String),
Search(String),
Page(usize),
}
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Draft {
edit: Option<Id>,
source: Option<OccurrenceEndpoint>,
target: Option<OccurrenceEndpoint>,
target_side: bool,
purpose: Purpose,
evidence: Option<Id>,
guard: String,
search: String,
page: usize,
#[serde(default)]
scope: Vec<Id>,
}
impl Draft {
pub(super) fn seed_source(&mut self, endpoint: OccurrenceEndpoint) {
if self.edit.is_none() {
self.source = Some(endpoint);
}
}
pub(super) fn new(p: &Project, owner: Id, selected: Option<Id>) -> Self {
if let Some(r) = selected.and_then(|id| p.relations.get(&id))
&& let RelationBody::ContextualLink {
owner: caller,
source,
target,
purpose,
} = &r.body
&& *caller == owner
{
let (purpose, evidence, guard) = match purpose {
ContextualLinkPurpose::Precedence { guard } => {
(Purpose::Precedence, None, guard.clone())
}
ContextualLinkPurpose::Exchange { exchange } => {
(Purpose::Exchange, Some(*exchange), String::new())
}
ContextualLinkPurpose::CommonWork { work } => {
(Purpose::CommonWork, Some(*work), String::new())
}
};
return Self {
edit: Some(r.id),
source: Some(source.clone()),
target: Some(target.clone()),
purpose,
evidence,
guard,
..Self::default()
};
}
Self {
source: selected
.filter(|id| is_contribution(p, owner, *id))
.map(|occurrence| OccurrenceEndpoint {
references: vec![],
occurrence,
}),
..Self::default()
}
}
pub(super) fn update(&mut self, event: Event) {
match event {
Event::Side(target) => self.target_side = target,
Event::Pick(endpoint) => {
if self.target_side {
self.target = Some(endpoint);
} else {
self.source = Some(endpoint);
}
}
Event::Expand(endpoint) => {
self.scope = endpoint.references;
self.scope.push(endpoint.occurrence);
self.search.clear();
self.page = 0;
}
Event::Up => {
self.scope.pop();
self.search.clear();
self.page = 0;
}
Event::Root => {
self.scope.clear();
self.search.clear();
self.page = 0;
}
Event::Purpose(purpose) => self.purpose = purpose,
Event::Evidence(choice) => self.evidence = Some(choice.id),
Event::Guard(raw) => self.guard = raw,
Event::Search(raw) => {
self.search = raw;
self.page = 0;
}
Event::Page(page) => self.page = page,
}
}
fn purpose(&self) -> Result<ContextualLinkPurpose, String> {
Ok(match self.purpose {
Purpose::Precedence => ContextualLinkPurpose::Precedence {
guard: self.guard.clone(),
},
Purpose::Exchange => ContextualLinkPurpose::Exchange {
exchange: self
.evidence
.ok_or("Choose the exact exchange used for assembly.")?,
},
Purpose::CommonWork => ContextualLinkPurpose::CommonWork {
work: self.evidence.ok_or("Choose the shared work identity.")?,
},
})
}
pub(super) fn command(&self, p: &Project, owner: Id, name: &str) -> Result<Command, String> {
let source = self
.source
.as_ref()
.ok_or("Choose the qualified source contribution.")?;
let target = self
.target
.as_ref()
.ok_or("Choose the qualified target contribution.")?;
let purpose = self.purpose()?;
TopologyIndex::new(p)
.validate_link(owner, source, target, &purpose)
.map_err(|e| e.to_string())?;
let mut relation = if let Some(id) = self.edit {
p.relations.get(&id).filter(|r| matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller==owner)).ok_or("This caller-owned link no longer exists.")?.clone()
} else {
let id = Id::new_v4();
Relation {
id,
alias: p.fresh_alias("contextual_link", "relation", id),
name: self.purpose.to_string(),
description: String::new(),
body: RelationBody::ContextualLink {
owner,
source: source.clone(),
target: target.clone(),
purpose: purpose.clone(),
},
}
};
if !name.trim().is_empty() {
relation.name = name.to_owned();
}
relation.body = RelationBody::ContextualLink {
owner,
source: source.clone(),
target: target.clone(),
purpose,
};
let command = if self.edit.is_some() {
Command::EditRelation(relation.id, relation)
} else {
Command::CreateRelation(relation)
};
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
}
pub(crate) fn address_label(p: &Project, owner: Id, endpoint: &OccurrenceEndpoint) -> String {
std::iter::once(&owner)
.chain(endpoint.references.iter())
.chain(std::iter::once(&endpoint.occurrence))
.map(|id| {
p.elements
.get(id)
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| format!("Unavailable {id}"))
})
.collect::<Vec<_>>()
.join(" → ")
}
struct AddressRow {
endpoint: OccurrenceEndpoint,
reference: bool,
}
fn rows(p: &Project, owner: Id, scope: &[Id], search: &str) -> Result<Vec<AddressRow>, String> {
let index = TopologyIndex::new(p);
let sequence = index
.resolve_scope(owner, scope)
.map_err(|e| e.to_string())?;
let query = search.trim().to_lowercase();
Ok(index
.contributions(sequence)
.into_iter()
.filter_map(|occurrence| {
let endpoint = OccurrenceEndpoint {
references: scope.to_vec(),
occurrence,
};
if !address_label(p, owner, &endpoint)
.to_lowercase()
.contains(&query)
{
return None;
}
let reference = matches!(
p.elements.get(&occurrence).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
..
})
);
Some(AddressRow {
endpoint,
reference,
})
})
.collect())
}
fn event(value: Event) -> Message {
super::event(super::Event::Contextual(value))
}
pub(super) fn preview(
index: &TopologyIndex<'_>,
p: &Project,
owner: Id,
reference: Id,
expanded: &BTreeSet<OccurrenceEndpoint>,
limit: usize,
) -> Vec<OccurrenceEndpoint> {
let children = |endpoint: &OccurrenceEndpoint| {
if index.resolve(owner, endpoint).is_err() {
return vec![];
}
let sequence = p
.elements
.get(&endpoint.occurrence)
.and_then(|e| e.shared())
.and_then(|body| match body {
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
});
let Some(sequence) = sequence else {
return vec![];
};
let available: BTreeSet<_> = index.contributions(sequence).into_iter().collect();
let mut references = endpoint.references.clone();
references.push(endpoint.occurrence);
p.elements
.get(&sequence)
.and_then(|e| e.steps())
.into_iter()
.flatten()
.filter(|id| available.contains(id))
.map(|occurrence| OccurrenceEndpoint {
references: references.clone(),
occurrence: *occurrence,
})
.collect::<Vec<_>>()
};
let root = OccurrenceEndpoint {
references: vec![],
occurrence: reference,
};
let mut pending = children(&root);
pending.reverse();
let mut visible = vec![];
while let Some(endpoint) = pending.pop() {
if visible.len() == limit {
break;
}
if index.resolve(owner, &endpoint).is_err() {
continue;
}
if expanded.contains(&endpoint) {
pending.extend(children(&endpoint).into_iter().rev());
}
visible.push(endpoint);
}
visible
}
pub(super) fn view<'a>(p: &'a Project, owner: Id, d: &'a Draft) -> Element<'a, Message> {
let mut out = column![
text("Choose each endpoint in its caller context. Enter a reference to browse all its contributions; repeated uses stay distinct. Browsing preserves both selected endpoints.").size(12),
row![button("Choose source").style(if !d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(false))), button("Choose target").style(if d.target_side { button::primary } else { style::nav }).on_press(event(Event::Side(true)))].spacing(6),
text(format!("Source: {}", d.source.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
text(format!("Target: {}", d.target.as_ref().map(|a| address_label(p,owner,a)).unwrap_or("Choose a contribution".into()))).size(12),
pick_list(vec![Purpose::Precedence,Purpose::Exchange,Purpose::CommonWork],Some(d.purpose),|p| event(Event::Purpose(p))).width(Fill).text_size(12).padding(8)
].spacing(7);
match d.purpose {
Purpose::Precedence => {
out = out.push(
text_input("Precedence guard (optional)", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
)
}
purpose => {
let choices = if purpose == Purpose::Exchange {
targets(p, owner, Tool::Exchange)
} else {
targets(p, owner, Tool::Work)
};
let selected = d.evidence.map(|id| Choice {
id,
label: p.named(id).to_owned(),
});
out = out
.push(
pick_list(choices, selected, |c| event(Event::Evidence(c)))
.placeholder("Choose exact engineering identity…")
.width(Fill)
.text_size(12)
.padding(8),
)
.push(
text("Assembly incidence does not imply execution order or fuse executions.")
.size(11),
);
}
}
out = out.push(
text_input("Search all contributions in this scope…", &d.search)
.on_input(|v| event(Event::Search(v)))
.padding(7)
.size(12),
);
let scope_label = std::iter::once(&owner)
.chain(d.scope.iter())
.map(|id| {
p.elements
.get(id)
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| format!("Unavailable {id}"))
})
.collect::<Vec<_>>()
.join(" → ");
out = out
.push(text(format!("Browsing: {scope_label}")).size(12))
.push(
row![
button("Root").on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Root))),
button("Up one reference")
.on_press_maybe((!d.scope.is_empty()).then(|| event(Event::Up)))
]
.spacing(6),
);
let rows = match rows(p, owner, &d.scope, &d.search) {
Ok(rows) => rows,
Err(error) => return out.push(text(error).size(12)).into(),
};
let range = super::view::choice_page(rows.len(), d.page);
let page = range.start / 24;
out = out.push(
row![
button("Previous").on_press_maybe((page > 0).then(|| event(Event::Page(page - 1)))),
text(format!(
"{} matching contributions · page {}",
rows.len(),
page + 1
))
.size(11),
button("Next")
.on_press_maybe((range.end < rows.len()).then(|| event(Event::Page(page + 1))))
]
.spacing(6),
);
for r in &rows[range] {
let mut line = row![
button(text(address_label(p, owner, &r.endpoint)).size(12))
.width(Fill)
.on_press(event(Event::Pick(r.endpoint.clone())))
]
.spacing(5);
if r.reference {
line = line
.push(button("Enter reference").on_press(event(Event::Expand(r.endpoint.clone()))));
}
out = out.push(line);
}
out.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id, Id) {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let sequence = p
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Reusable check",
None,
)
.unwrap();
let sequence_id = sequence.id;
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
let leaf = occurrence(
&p,
sequence_id,
"Verify pressure",
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
let leaf_id = leaf.id;
p = execute_batch(&p, &[Command::CreateElement(leaf)]).unwrap();
let a = occurrence(
&p,
f.owner,
"First check",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let b = occurrence(
&p,
f.owner,
"Second check",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let (a_id, b_id) = (a.id, b.id);
p = execute_batch(&p, &[Command::CreateElement(a), Command::CreateElement(b)]).unwrap();
(p, f.owner, a_id, b_id, leaf_id, f.work[0])
}
#[test]
fn qualified_link_gui_repeated_sources_remain_distinct_and_apply_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (p, owner, a, b, leaf, work) = fixture(layer);
let mut state = State::default();
state
.update(
&p,
super::super::Event::Start(owner, Tool::Contextual, None),
)
.unwrap();
let source = OccurrenceEndpoint {
references: vec![a],
occurrence: leaf,
};
let target = OccurrenceEndpoint {
references: vec![b],
occurrence: leaf,
};
for event in [
Event::Pick(source.clone()),
Event::Side(true),
Event::Pick(target.clone()),
Event::Purpose(Purpose::CommonWork),
Event::Evidence(Choice {
id: work,
label: String::new(),
}),
] {
state
.update(&p, super::super::Event::Contextual(event))
.unwrap();
}
let raw = state.recovery_snapshot().unwrap().unwrap();
let restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw));
let command = restored.prepare_pending(&p).unwrap();
let Command::CreateRelation(record) = &command else {
panic!("one caller-owned record")
};
assert_eq!(
record.body,
RelationBody::ContextualLink {
owner,
source: source.clone(),
target: target.clone(),
purpose: ContextualLinkPurpose::CommonWork { work }
}
);
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
doc.execute(command.clone()).unwrap();
assert_eq!(doc.project(), &expected);
assert_eq!(
doc.project().elements,
p.elements,
"no copied work or source occurrences"
);
let after = doc.bundle().clone();
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
assert_ne!(
address_label(&p, owner, &source),
address_label(&p, owner, &target)
);
assert!(
Graph::new(&expected, owner, record.id)
.edges
.iter()
.any(|edge| edge.id == record.id && edge.incidence)
);
}
}
#[test]
fn qualified_link_gui_rejected_endpoint_keeps_exact_raw_intent_after_recovery() {
let (p, owner, a, _, leaf, _) = fixture(Layer::Oa);
let mut state = State::default();
state
.update(
&p,
super::super::Event::Start(owner, Tool::Contextual, None),
)
.unwrap();
for event in [
Event::Pick(OccurrenceEndpoint {
references: vec![a],
occurrence: leaf,
}),
Event::Side(true),
Event::Pick(OccurrenceEndpoint {
references: vec![Id::new_v4()],
occurrence: leaf,
}),
Event::Guard(" pressure > && ".into()),
] {
state
.update(&p, super::super::Event::Contextual(event))
.unwrap();
}
let before = state.recovery_snapshot().unwrap();
assert!(state.prepare_pending(&p).is_err());
assert_eq!(state.recovery_snapshot().unwrap(), before);
let restored = State::from_recovery(before.clone().unwrap()).unwrap();
assert!(restored.prepare_pending(&p).is_err());
assert_eq!(restored.recovery_snapshot().unwrap(), before);
}
#[test]
fn qualified_link_gui_deleted_transient_selection_does_not_seed_or_hide_retained_authored_endpoint()
{
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
let endpoint = OccurrenceEndpoint {
references: vec![reference],
occurrence: leaf,
};
let mut state = State::default();
state.select_context(oa_core::topology_context::ContextAddress {
owner,
endpoint: endpoint.clone(),
});
state
.update(
&p,
super::super::Event::Start(owner, Tool::Contextual, None),
)
.unwrap();
let before = state.recovery_snapshot().unwrap();
let deleted = execute_batch(&p, &[Command::Delete(leaf)]).unwrap();
assert!(state.qualified_selection(&deleted, owner).is_none());
assert_eq!(
state.recovery_snapshot().unwrap(),
before,
"Authored endpoint is retained for explicit repair"
);
assert!(state.prepare_pending(&deleted).is_err());
state.remember(
owner,
reference,
super::super::direct::referenced(&p, owner, reference).unwrap(),
);
assert_eq!(state.back(&deleted), Some((owner, reference)));
assert!(state.selected_context.is_none());
state.draft = None;
state
.update(
&deleted,
super::super::Event::Start(owner, Tool::Contextual, None),
)
.unwrap();
assert!(
state
.draft
.as_ref()
.unwrap()
.contextual
.as_ref()
.unwrap()
.source
.is_none()
);
}
#[test]
fn qualified_link_gui_scope_paging_and_search_reach_tail_beyond_512_without_losing_endpoints() {
let (p, owner, reference, _, leaf, _) = fixture(Layer::Oa);
let sequence = p.elements[&leaf].parent.unwrap();
let mut commands = Vec::new();
let mut tails = Vec::new();
for (scope_index, scope) in [owner, sequence].into_iter().enumerate() {
for n in 0..520 {
let mut record = occurrence(
&p,
scope,
&format!("Contribution {n:04}"),
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
record.alias = format!("scope_{scope_index}_contribution_{n:04}");
if n == 519 {
tails.push(record.id);
}
commands.push(Command::CreateElement(record));
}
}
let p = execute_batch(&p, &commands).unwrap();
let all = rows(&p, owner, &[], "").unwrap();
assert!(all.len() > 512);
let last_page = super::super::view::choice_page(all.len(), usize::MAX);
assert!(
all[last_page]
.iter()
.any(|row| row.endpoint.occurrence == tails[0])
);
let found = rows(&p, owner, &[], "Contribution 0519").unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].endpoint.occurrence, tails[0]);
let mut draft = Draft::default();
draft.update(Event::Pick(OccurrenceEndpoint {
references: vec![reference],
occurrence: leaf,
}));
draft.update(Event::Side(true));
draft.update(Event::Pick(found[0].endpoint.clone()));
let chosen = (draft.source.clone(), draft.target.clone());
draft.update(Event::Expand(OccurrenceEndpoint {
references: vec![],
occurrence: reference,
}));
draft.update(Event::Search("Contribution 0519".into()));
let nested = rows(&p, owner, &draft.scope, &draft.search).unwrap();
assert_eq!(nested.len(), 1);
assert_eq!(
nested[0].endpoint,
OccurrenceEndpoint {
references: vec![reference],
occurrence: tails[1]
}
);
let saved = serde_json::to_value(&draft).unwrap();
let mut restored: Draft = serde_json::from_value(saved.clone()).unwrap();
assert_eq!(serde_json::to_value(&restored).unwrap(), saved);
restored.update(Event::Up);
assert!(restored.scope.is_empty());
assert_eq!((restored.source.clone(), restored.target.clone()), chosen);
assert!(restored.command(&p, owner, "Tail constraint").is_ok());
let mut historical = saved;
historical.as_object_mut().unwrap().remove("scope");
let restored: Draft = serde_json::from_value(historical).unwrap();
assert!(restored.scope.is_empty());
assert_eq!((restored.source, restored.target), chosen);
}
#[test]
fn qualified_link_gui_nested_preview_folds_only_the_exact_reference_instance() {
let (mut p, owner, a, b, leaf, _) = fixture(Layer::Oa);
let source = p.elements[&leaf].parent.unwrap();
let deep = p
.make_element(Kind::Process, Layer::Oa, "Nested definition", None)
.unwrap();
let deep_id = deep.id;
p = execute_batch(&p, &[Command::CreateElement(deep)]).unwrap();
let deep_leaf = occurrence(
&p,
deep_id,
"Nested control",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let deep_leaf_id = deep_leaf.id;
let nested = occurrence(
&p,
source,
"Nested use",
OccurrenceBody::SequenceReference {
sequence: Some(deep_id),
},
);
let nested_id = nested.id;
p = execute_batch(
&p,
&[
Command::CreateElement(deep_leaf),
Command::CreateElement(nested),
],
)
.unwrap();
let path = OccurrenceEndpoint {
references: vec![a],
occurrence: nested_id,
};
let mut state = State::default();
assert!(state.expand_context(&p, owner, path.clone()));
state.expanded.insert(b);
let graph = Graph::expanded_qualified(
&p,
owner,
owner,
&state.expanded,
&state.contextual_expanded,
);
assert!(
graph
.nodes
.iter()
.find(|n| n.id == a)
.unwrap()
.preview
.iter()
.any(|(v, _)| v.references == vec![a, nested_id] && v.occurrence == deep_leaf_id)
);
assert!(
!graph
.nodes
.iter()
.find(|n| n.id == b)
.unwrap()
.preview
.iter()
.any(|(v, _)| v.occurrence == deep_leaf_id)
);
assert!(state.expand_context(&p, owner, path));
let graph = Graph::expanded_qualified(
&p,
owner,
owner,
&state.expanded,
&state.contextual_expanded,
);
assert!(
!graph
.nodes
.iter()
.any(|n| n.preview.iter().any(|(v, _)| v.occurrence == deep_leaf_id))
);
}
}
@@ -0,0 +1,988 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Insertion {
pub link: Option<Id>,
pub before: Option<Id>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CanvasTool {
#[default]
Select,
Add(Tool, ControlKind),
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Source,
Target,
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
SelectQualified(Context, OccurrenceEndpoint),
ExpandQualified(Context, OccurrenceEndpoint),
OpenQualified(Context, OccurrenceEndpoint),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
},
Connect {
context: Context,
source: Id,
target: Id,
},
Reconnect {
context: Context,
link: Id,
end: End,
target: Id,
},
Reorder {
context: Context,
occurrence: Id,
before: Id,
},
Expand(Context, Id),
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Camera(Context, u64, super::super::canvas_workspace::Event),
Focus(Context),
Viewport(Context, Size),
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(c, ..)
| Self::Choose(c, ..)
| Self::Select(c, ..)
| Self::SelectQualified(c, ..)
| Self::ExpandQualified(c, ..)
| Self::OpenQualified(c, ..)
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Camera(c, ..)
| Self::Focus(c)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
| Self::Connect { context, .. }
| Self::Reconnect { context, .. }
| Self::Reorder { context, .. } => *context,
}
}
}
pub(super) struct ReturnFrame {
identity: Option<(Id, Id)>,
owner: Id,
occurrence: Id,
target: Id,
address: Option<oa_core::topology_context::ContextAddress>,
pan: iced::Vector,
zoom: Option<f32>,
fit: bool,
viewport: Option<Size>,
expanded: BTreeSet<Id>,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
}
impl State {
pub(crate) fn select_context(&mut self, address: oa_core::topology_context::ContextAddress) {
self.selected_context = Some(address);
}
pub(crate) fn clear_context(&mut self) {
self.selected_context = None;
}
pub(super) fn qualified_selection(
&self,
p: &Project,
owner: Id,
) -> Option<&oa_core::topology_context::ContextAddress> {
self.selected_context.as_ref().filter(|address| {
address.owner == owner
&& oa_core::topology_context::TopologyIndex::new(p)
.resolve(owner, &address.endpoint)
.is_ok()
})
}
pub(crate) fn expand_context(
&mut self,
p: &Project,
owner: Id,
endpoint: OccurrenceEndpoint,
) -> bool {
if oa_core::topology_context::TopologyIndex::new(p)
.resolve(owner, &endpoint)
.is_err()
|| !matches!(
p.elements
.get(&endpoint.occurrence)
.and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: Some(_) },
..
})
)
{
return false;
}
if let Some(root) = endpoint.references.first() {
self.expanded.insert(*root);
}
if !self.contextual_expanded.remove(&endpoint) {
self.contextual_expanded.insert(endpoint);
}
true
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.pan = iced::Vector::ZERO;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
#[cfg(test)]
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
// Choosing how to edit must retain the user's current view.
self.tool = tool;
}
pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool {
super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event)
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.05, 3.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
|| !size.height.is_finite()
|| size.width <= 0.
|| size.height <= 0.
{
return false;
}
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
}
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
if !is_contribution(p, owner, id)
|| !matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
)
{
return false;
}
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id, target: Id) {
if self.return_to.len() == 32 {
self.return_to.remove(0);
}
self.return_to.push(ReturnFrame {
identity: self.identity,
owner,
occurrence,
target,
address: self.selected_context.clone(),
pan: self.pan,
zoom: self.zoom,
fit: self.fit,
viewport: self.viewport,
expanded: self.expanded.clone(),
contextual_expanded: self.contextual_expanded.clone(),
});
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some(frame) = self.return_to.pop() {
if frame.identity.map(|i| i.0) != self.identity.map(|i| i.0) {
continue;
}
let valid = referenced(p, frame.owner, frame.occurrence) == Some(frame.target)
|| if let Some(address) = &frame.address {
oa_core::topology_context::TopologyIndex::new(p)
.resolve(frame.owner, &address.endpoint)
.is_ok_and(|r| r.defining_sequence == frame.target)
} else {
false
};
if valid && is_contribution(p, frame.owner, frame.occurrence) {
self.identity = frame.identity;
self.selected_context = frame.address.filter(|a| {
oa_core::topology_context::TopologyIndex::new(p)
.resolve(frame.owner, &a.endpoint)
.is_ok()
});
self.pan = frame.pan;
self.zoom = frame.zoom;
self.fit = frame.fit;
self.viewport = frame.viewport;
self.expanded = frame.expanded;
self.contextual_expanded = frame.contextual_expanded;
return Some((frame.owner, frame.occurrence));
}
}
None
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) {
let graph =
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded);
let old = self.scale(graph.size, 500.);
if let Some(next) = zoom {
let size = self.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(size.width / 2., size.height / 2.),
base: old,
});
} else {
self.zoom(None);
let fitted = self.scale(graph.size, 500.);
self.zoom(Some(fitted));
self.pan = iced::Vector::ZERO;
}
}
pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
if self
.draft
.as_ref()
.is_some_and(|d| d.source.label(d.target).is_some())
&& self.prepare_pending(p).ok().as_ref() != Some(command)
{
return None;
}
if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command
&& matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner)
{
return Some(r.id);
}
let Command::Batch(commands) = command else {
return None;
};
commands.iter().find_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
_ => None,
})
}
pub(crate) fn begin(
&mut self,
p: &Project,
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !matches!(
tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
) {
return Err("Choose a contribution tool.".into());
}
let order = legacy_order(p, context.owner);
let link = insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, context.owner, r.source())
&& is_contribution(p, context.owner, r.target())
})
.ok_or("Choose a sequence link in this process.")
})
.transpose()?;
if insertion
.before
.is_some_and(|id| !is_contribution(p, context.owner, id))
{
return Err("Choose a presentation slot in this process.".into());
}
self.update(p, Event::Start(context.owner, tool, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.insertion = insertion;
d.control = control;
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
} else if !order.is_empty() {
let index = insertion
.before
.and_then(|id| order.iter().position(|v| *v == id))
.unwrap_or(order.len());
d.before = index.checked_sub(1).map(|i| order[i]);
d.after = order.get(index).copied();
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
owner: Id,
source: Id,
target: Id,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !is_contribution(p, owner, source)
|| !is_contribution(p, owner, target)
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
{
return Err("Choose compatible contributions directly owned by this process.".into());
}
self.update(p, Event::Start(owner, Tool::Connect, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.before = Some(source);
d.after = Some(target);
Ok(())
}
}
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
if !is_contribution(p, owner, occurrence) {
return None;
}
match p.elements[&occurrence].shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
}
}
pub(crate) fn reconnect(
p: &Project,
owner: Id,
id: Id,
end: End,
endpoint: Id,
) -> Result<Command, String> {
let mut r = p
.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.ok_or("Choose a sequence link in this process.")?
.clone();
if !is_contribution(p, owner, endpoint) {
return Err("Choose a local contribution endpoint.".into());
}
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
match end {
End::Source => *source = endpoint,
End::Target => *target = endpoint,
}
}
let command = Command::EditRelation(id, r);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
#[derive(Default)]
pub(super) struct Input {
context: Option<Context>,
tool: CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
}
#[derive(Clone, Copy)]
enum Press {
Connect {
source: Id,
first: bool,
start: Point,
moved: bool,
},
Reconnect {
link: Id,
end: End,
start: Point,
moved: bool,
},
Reorder {
occurrence: Id,
start: Point,
moved: bool,
},
Add {
insertion: Insertion,
},
}
impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Graph<'_> {
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
self.ordered_paths.iter().find(|path| {
path.points.windows(2).any(|pair| {
crate::diagram::distance_to_segment(point, pair[0], pair[1]) < 8. / self.scale
})
})
}
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
.rev()
.find(|n| n.rect.contains(point))
.map(|n| n.id)
}
fn qualified_at(&self, point: Point) -> Option<OccurrenceEndpoint> {
self.nodes.iter().find_map(|node| {
node.preview.iter().enumerate().find_map(|(i, (id, _))| {
let rect = Rectangle::new(
Point::new(
node.rect.x + 12. + (i % 2) as f32 * 125.,
node.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
);
rect.contains(point).then(|| id.clone())
})
})
}
fn edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points.windows(2).any(|v| {
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
})
})
.map(|e| e.id)
}
fn contextual_edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.filter(|edge| edge.layer == EdgeLayer::Contextual)
.find(|edge| {
edge.label.contains(point)
|| edge.points.windows(2).any(|segment| {
crate::diagram::distance_to_segment(point, segment[0], segment[1])
< 8. / self.scale
})
})
.map(|edge| edge.id)
}
fn handle(&self, id: Id) -> Option<Rectangle> {
let n = self.nodes.iter().find(|n| n.id == id)?;
Some(Rectangle::new(
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
Size::new(23., 24.),
))
}
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
if self.project.relations.get(&self.selected)?.kind() != RelationKind::SequenceLink {
return None;
}
let e = self.edges.iter().find(|e| e.id == self.selected)?;
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
.into_iter()
.find(|(p, _)| p.distance(point) < 10. / self.scale)
.map(|(_, end)| (e.id, end))
}
fn connectable(&self, source: Id, target: Id) -> bool {
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
}
pub(super) fn input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context
|| state.tool != tool
|| state.bounds.is_some_and(|b| b != bounds)
{
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
let visible = cursor.position().is_some();
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(
Press::Connect { start, moved, .. }
| Press::Reconnect { start, moved, .. }
| Press::Reorder { start, moved, .. },
) = &mut state.press
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
) => {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw());
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
state.cancel();
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
};
let world = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
let point = world?;
let Some(context) = context else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.map(|id| Action::publish(Message::Select(id)).and_capture());
};
if !editable {
return None;
}
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
insertion: Insertion {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point).or_else(|| {
self.ordered_path_at(point).map(|path| path.target)
}),
},
});
}
CanvasTool::Connect => {
let first = state.source.is_none();
let source = state.source.or_else(|| self.node_at(point))?;
state.source = Some(source);
state.press = Some(Press::Connect {
source,
first,
start,
moved: false,
});
}
CanvasTool::Select => {
if let Some(endpoint) = self.qualified_at(point) {
return publish(CanvasEvent::SelectQualified(context, endpoint));
} else if let Some((link, end)) = self.endpoint_at(point) {
state.press = Some(Press::Reconnect {
link,
end,
start,
moved: false,
});
} else if let Some(link) = self.contextual_edge_at(point) {
return publish(CanvasEvent::Select(context, link));
} else if self
.handle(self.selected)
.is_some_and(|r| r.contains(point))
{
state.press = Some(Press::Reorder {
occurrence: self.selected,
start,
moved: false,
});
} else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.and_then(|id| publish(CanvasEvent::Select(context, id)));
}
}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
Press::Add { insertion } if world.is_some() => {
if let CanvasTool::Add(tool, control) = tool {
state.cancel();
return publish(CanvasEvent::Begin {
context,
tool,
control,
insertion,
});
}
}
Press::Connect {
source,
first,
moved,
..
} if moved || !first => {
if let Some(target) = world
.and_then(|p| self.node_at(p))
.filter(|target| self.connectable(source, *target))
{
state.cancel();
return publish(CanvasEvent::Connect {
context,
source,
target,
});
}
}
Press::Reconnect {
link,
end,
moved: true,
..
} => {
if let Some(target) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reconnect {
context,
link,
end,
target,
});
}
}
Press::Reorder {
occurrence,
moved: true,
..
} => {
if let Some(before) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reorder {
context,
occurrence,
before,
});
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
Some(Action::request_redraw())
}
_ => None,
}
}
pub(super) fn overlay(
&self,
state: &Input,
f: &mut Frame,
cursor: mouse::Cursor,
bounds: Rectangle,
) {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if let Some(insertion) = self.pending_insertion {
if let Some(edge) = insertion
.link
.and_then(|id| self.edges.iter().find(|e| e.id == id))
{
draw_arrow(f, &edge.points, style::TEAL, 3.);
} else if let Some(node) = insertion
.before
.and_then(|id| self.nodes.iter().find(|n| n.id == id))
{
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
}
}
if !editable {
return;
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| {
Point::new(
(p.x - bounds.x - self.camera_pan.x) / self.scale,
(p.y - bounds.y - self.camera_pan.y) / self.scale,
)
});
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
for n in &self.nodes {
if n.id == source || self.connectable(source, n.id) {
f.stroke(
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
draw_arrow(
f,
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
style::TEAL,
2.,
);
}
}
if live
&& let Some(Press::Reconnect { link, end, .. }) = state.press
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
{
for node in &self.nodes {
let (source, target) = match end {
End::Source => (node.id, edge.target),
End::Target => (edge.source, node.id),
};
if self.connectable(source, target) {
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let Some(point) = point {
let (from, to) = match end {
End::Source => (point, *edge.points.last().unwrap()),
End::Target => (edge.points[0], point),
};
draw_arrow(f, &[from, to], style::TEAL, 2.5);
}
}
if live
&& let Some(Press::Reorder { occurrence, .. }) = state.press
&& let Some(point) = point
&& let Some(target) = self.node_at(point)
{
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
if source.rect.x == target.rect.x {
f.stroke(
&Path::line(
Point::new(target.rect.x, target.rect.y - 7.),
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
draw_text(
f,
"Order before · paths unchanged".into(),
Point::new(target.rect.x, target.rect.y - 24.),
12.,
style::TEAL,
);
}
}
if tool == CanvasTool::Select {
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
}
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
for p in [e.points[0], *e.points.last().unwrap()] {
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
}
}
}
if let CanvasTool::Add(..) = tool
&& let Some(point) = point
{
let at = self
.edge_at(point)
.filter(|_| self.node_at(point).is_none());
let caption = if let Some(id) = at {
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if let Some(path) = self.ordered_path_at(point) {
draw_arrow(f, &path.points, style::TEAL, 3.);
"Insert on ordered path · preserve its order"
} else if self.ordered && self.node_at(point).is_some() {
"Insert before this contribution · preserve path"
} else if self.ordered {
"Continue the ordered path"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
"Append contribution · unconnected"
};
draw_text(
f,
caption.into(),
Point::new(point.x + 12., point.y + 16.),
12. / self.scale,
style::TEAL,
);
}
}
}
impl State {
pub(crate) fn reveal_selection(&mut self, p: &Project, owner: Id, id: Id) -> bool {
let graph =
Graph::expanded_qualified(p, owner, id, &self.expanded, &self.contextual_expanded);
let scale = self.scale(graph.size, 500.);
let rect = graph
.nodes
.iter()
.find(|n| n.id == id)
.map(|n| {
self.qualified_selection(p, owner)
.map_or(n.rect, |a| n.endpoint_rect(&a.endpoint))
})
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label));
let Some(rect) = rect else {
return false;
};
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
let x = rect.x * scale + self.pan.x;
let y = rect.y * scale + self.pan.y;
let delta = iced::Vector::new(
if x < 12. || x + rect.width * scale > viewport.width - 12. {
12. - x
} else {
0.
},
if y < 12. || y + rect.height * scale > viewport.height - 12. {
12. - y
} else {
0.
},
);
self.camera(super::super::canvas_workspace::Event::Pan(delta))
}
}
@@ -0,0 +1,887 @@
use super::*;
#[tokio::test]
async fn production_process_canvas_scaled_path_creation_preserves_source_and_editor_bounds() {
use crate::behavior::canvas_workspace::{self, tests::Mounted};
use iced::Vector;
use iced::advanced::widget;
let initial = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(initial.p);
let _ = app.update(Message::Select(initial.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let mut mounted = Mounted::new(&mut app).await;
for layer in [Layer::Oa, Layer::Sa] {
for size in [Size::new(1100., 700.), Size::new(1440., 900.)] {
let f = super::super::direct_tests::fixture(layer);
app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = size;
mounted.settle(&mut app).await;
for scale in [0.5, 1., 2.] {
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale),
)));
let geometry = mounted.geometry(&app);
let bounds = geometry.bounds("process-viewport");
assert!(bounds.height > 350.);
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("process-canvas"))
);
let graph = Graph::new(app.project(), f.owner, f.owner);
let edge = graph.edges.iter().find(|edge| edge.id == f.link).unwrap();
let segment = edge
.points
.windows(2)
.max_by(|a, b| a[0].distance(a[1]).total_cmp(&b[0].distance(b[1])))
.unwrap();
let world = Point::new(
(segment[0].x + segment[1].x) / 2.,
(segment[0].y + segment[1].y) / 2.,
);
let desired = Vector::new(85., 130.);
let next_pan = desired - Vector::new(world.x, world.y) * scale;
let delta = next_pan - app.behavior.topology.pan;
let start = bounds.position() + Vector::new(130., 70.);
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
mounted.pan(&mut app, start, start + delta).await;
let diff = app.behavior.topology.pan - next_pan;
assert!(diff.x.hypot(diff.y) < 0.01);
let tool = mounted.geometry(&app).bounds("behavior-tool:OR").center();
mounted.click(&mut app, tool).await;
let messages = mounted.click(&mut app, bounds.position() + desired).await;
assert!(
messages.iter().any(
|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Begin {
tool: Tool::Control, control: ControlKind::Choice, insertion, ..
}) if insertion.link == Some(f.link) && insertion.before.is_none())
),
"{layer:?} {scale}: {messages:?}"
);
let invocation = app.behavior.topology.draft.as_ref().unwrap().invocation;
let _ = app.update(event(Event::Name(" raw choice 雨 ".into())));
let raw = app.behavior.recovery_snapshot().unwrap();
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert_eq!(shown.bounds("process-viewport"), bounds);
let actions = shown.bounds("process-actions");
let form = shown.scrolls[&widget::Id::new("process-form")].0;
assert!(actions.y >= bounds.y && actions.y + actions.height <= form.y);
assert!(form.y + form.height <= bounds.y + bounds.height && form.height > 250.);
let pan = app.behavior.topology.pan;
mounted
.batch(
&mut app,
&[canvas_workspace::tests::key(
iced::keyboard::key::Named::ArrowRight,
)],
mouse::Cursor::Available(bounds.position() + desired),
)
.await;
assert_eq!(app.behavior.topology.pan, pan);
mounted.scroll(&app, "process-form", 500.);
assert_eq!(mounted.geometry(&app).bounds("process-actions"), actions);
for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] {
let _ = app.update(event(Event::Shelf(shelf)));
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
invocation
);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
let _ = app.update(event(Event::Cancel));
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
app.behavior.topology.shelf = None;
}
}
}
}
#[tokio::test]
async fn production_process_canvas_focus_wheel_fit_and_stale_camera_are_presentation_only() {
use crate::behavior::canvas_workspace::{
Event as Camera,
tests::{Mounted, key},
};
use iced::Vector;
let f = super::super::direct_tests::fixture(Layer::Sa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + Vector::new(90., 120.);
let accepted = app.document.snapshot();
let bundle = app.document.bundle().clone();
mounted
.pan(&mut app, point, point + Vector::new(-20., 25.))
.await;
let pan = app.behavior.topology.pan;
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::ArrowRight)],
mouse::Cursor::Available(point),
)
.await;
assert_eq!(app.behavior.topology.pan, pan + Vector::new(-60., 0.));
let local = point - bounds.position();
let pan = app.behavior.topology.pan;
let old_scale = app.behavior.topology.zoom.unwrap_or(1.);
let before_world = (local - pan) / old_scale;
mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}),
],
mouse::Cursor::Available(point),
)
.await;
let after_world = (local - app.behavior.topology.pan) / app.behavior.topology.zoom.unwrap();
let diff = after_world - before_world;
assert!(diff.x.hypot(diff.y) < 0.001);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None)));
let fitted = (app.behavior.topology.pan, app.behavior.topology.zoom);
let _ = app.update(event(Event::Shelf(Shelf::Selection)));
mounted.settle(&mut app).await;
app.window_size = Size::new(1440., 900.);
mounted.settle(&mut app).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted,
"Fit is a one-time camera action"
);
for mismatch in 0..3 {
let mut stale = app.topology_context().unwrap();
match mismatch {
0 => stale.document = Id::new_v4(),
1 => stale.owner = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
stale,
app.camera_epoch,
Camera::Pan(Vector::new(400., 400.)),
)));
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted
);
}
app.hosted.visible = true;
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
Camera::Pan(Vector::new(30., 40.)),
)));
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
fitted
);
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_camera_external_controls_bounds_and_retained_deltas_cancel_old_drags() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
for boundary in ["Fit", "OR", "Details", "Panels", "Bounds"] {
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(95., 90.);
let lifetime = app.camera_epoch;
let pan = app.behavior.topology.pan;
let first = point + iced::Vector::new(13., 9.);
let last = first + iced::Vector::new(17., -3.);
let emitted = mounted
.batch(
&mut app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: first }),
iced::Event::Mouse(mouse::Event::CursorMoved { position: last }),
],
mouse::Cursor::Available(last),
)
.await;
assert_eq!(app.behavior.topology.pan, pan + (last - point));
assert_eq!(
app.camera_epoch, lifetime,
"own ordered deltas retain their lifetime"
);
let retained = camera_delta(&emitted);
if boundary == "Bounds" {
let size = Size::new(app.window_size.width + 90., app.window_size.height + 35.);
let task = app.update(Message::WindowResized(size));
mounted.perform(&mut app, task).await;
} else {
let id = scope(&app).control_id(boundary);
let at = mounted.geometry(&app).containers[&id].center();
let clicked = mounted.click(&mut app, at).await;
assert!(
focused(&mut mounted, &app, boundary),
"same-batch pointer activation focuses the resulting live control without F6"
);
if boundary == "Fit" {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
assert!(
focused(&mut mounted, &app, "ZoomOut"),
"Tab after the real press/release batch continues from Fit"
);
assert!(clicked.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Zoom(_, None))
)));
assert_eq!(
app.behavior.topology.pan,
iced::Vector::ZERO,
"Fit emitted after this same batch's old pan must still execute"
);
}
}
mounted.settle(&mut app).await;
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} ends the old input lifetime"
);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let bounds = mounted.geometry(&app).bounds("process-viewport");
let released = release_middle(
&mut mounted,
&mut app,
bounds.position() + iced::Vector::new(130., 145.),
)
.await;
assert!(!released.iter().any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(_, _, crate::behavior::canvas_workspace::Event::Pan(delta))) if *delta != iced::Vector::ZERO)),
"{boundary} must cancel the retained widget drag");
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"{boundary} must reject its pre-boundary App delta"
);
let current_lifetime = app.camera_epoch;
let task = app.update(toolbar);
mounted.perform(&mut app, task).await;
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} rejects the retained toolbar lifetime"
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"{boundary} retained Fit cannot alter the camera"
);
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_keyboard_enters_activates_draws_focus_and_returns_to_inspector() {
use crate::behavior::canvas_workspace::tests::*;
use iced::keyboard::key::Named;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert!(!focused(&mut mounted, &app, "viewport"));
let operations = mounted.operations;
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted.operations > operations,
"entry executes the returned App widget Task"
);
assert!(focused(&mut mounted, &app, "viewport"));
let mut backwards = key(Named::Tab);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) =
&mut backwards
{
*modifiers = iced::keyboard::Modifiers::SHIFT;
}
mounted
.batch(&mut app, &[backwards], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "ActualSize"));
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "viewport"));
assert_eq!(
mounted
.geometry(&app)
.focused
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1
);
tab_to(&mut mounted, &mut app, "OR").await;
let chosen = mounted
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(chosen.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Choose(..))
)));
assert!(
focused(&mut mounted, &app, "OR"),
"tool activation retains its keyboard target"
);
// The selected palette tool must still have a visibly distinct focus ring.
let tool_on = mounted.pixels(&app);
let geometry = mounted.geometry(&app);
let mut tool_bounds = geometry.containers[&scope(&app).control_id("OR")];
let offset = geometry.scrolls[&scope(&app).control_id("palette-scroll")].1;
tool_bounds.x -= offset.x;
tool_bounds.y -= offset.y;
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let tool_off = mounted.pixels(&app);
assert_drawn_focus(&tool_off, &tool_on, app.window_size, tool_bounds);
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
// Focus alone changes the visible border of the real production Fit control.
let before = mounted.pixels(&app);
tab_to(&mut mounted, &mut app, "Fit").await;
let id = scope(&app).control_id("Fit");
let bounds = mounted.geometry(&app).containers[&id];
let after = mounted.pixels(&app);
assert_drawn_focus(&before, &after, app.window_size, bounds);
let fitted = mounted
.batch(&mut app, &[key(Named::Space)], mouse::Cursor::Unavailable)
.await;
assert!(fitted.iter().any(|m| matches!(
intent_action(m),
Message::TopologyCanvas(CanvasEvent::Zoom(_, None))
)));
assert_eq!(app.behavior.topology.pan, iced::Vector::ZERO);
assert!(focused(&mut mounted, &app, "Fit"));
tab_to(&mut mounted, &mut app, "Canvas").await;
mounted
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(_, _, focused)| *focused),
"ordinary Tab resumes the production Inspector's own entry policy"
);
assert!(!focused(&mut mounted, &app, "viewport"));
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(focused(&mut mounted, &app, "viewport"));
assert_eq!(
mounted
.geometry(&app)
.focused
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1,
"explicit canvas entry releases Inspector keyboard ownership"
);
mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(!focused(&mut mounted, &app, "viewport"));
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
// Typing after exit belongs to Inspector. Replaying the prior clean tool
// action must retain those raw fields, never implicitly Apply them.
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
mounted
.batch(&mut app, &[character("x")], mouse::Cursor::Unavailable)
.await;
assert!(app.draft_dirty());
let dirty_raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
assert_ne!(dirty_raw, raw);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
for message in chosen {
let task = app.update(message);
mounted.perform(&mut app, task).await;
}
let emitted = mounted
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
dirty_raw
);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_caller_return_rejects_its_previous_camera_lifetime() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut project = f.p;
let target = project
.add_element(Kind::Process, Layer::Oa, "Reusable check", None)
.unwrap();
let reference = occurrence(
&project,
f.owner,
"Local use",
OccurrenceBody::SequenceReference {
sequence: Some(target),
},
);
let use_id = reference.id;
project = execute_batch(&project, &[Command::CreateElement(reference)]).unwrap();
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 700.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(80., 100.);
let emitted = mounted
.pan(&mut app, point, point + iced::Vector::new(35., -25.))
.await;
let retained = camera_delta(&emitted);
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
let task = app.update(Message::TopologyCanvas(CanvasEvent::Open(
app.topology_context().unwrap(),
use_id,
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.topology_context().unwrap().owner, target);
let target_bounds = mounted.geometry(&app).bounds("process-viewport");
let point = target_bounds.position() + iced::Vector::new(70., 90.);
mounted
.pan(&mut app, point, point + iced::Vector::new(-50., 20.))
.await;
let task = app.update(Message::TopologyCanvas(CanvasEvent::Back(
app.topology_context().unwrap(),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert_eq!(app.selected, use_id);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
let lifetime = app.camera_epoch;
let task = app.update(toolbar);
mounted.perform(&mut app, task).await;
assert_eq!(
app.camera_epoch, lifetime,
"A to B and Back cannot revive toolbar intent"
);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
mounted
.pan(&mut app, point, point + iced::Vector::new(12., -8.))
.await;
assert_eq!(
app.behavior.topology.pan,
camera.0 + iced::Vector::new(12., -8.)
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_review_owners() {
use crate::behavior::canvas_workspace::tests::*;
use iced::keyboard::key::Named;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut project = f.p;
let target = project
.add_element(Kind::FunctionalChain, Layer::Sa, "Counterpart", None)
.unwrap();
let trace = Id::new_v4();
project.traces.insert(
trace,
Trace {
id: trace,
alias: "canvas_focus_trace".into(),
name: "Review camera ownership".into(),
description: String::new(),
oa: f.owner,
sa: target,
kind: TraceKind::Manual,
baseline: None,
},
);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
assert_eq!(entry.len(), 1);
let delayed = app.update(entry[0].clone());
let draft = app.draft.as_ref().unwrap();
let parent_id =
crate::forms::reference_chooser::trigger::field_id(draft.reference_generation(), "/parent");
let parent_bounds = mounted
.geometry(&app)
.focused
.iter()
.find(|(id, _, _)| id.as_ref() == Some(&parent_id))
.expect("actual Inspector reference trigger is mounted")
.1;
let request = draft.reference_open(app.project(), "/parent", parent_bounds);
let point =
mounted.geometry(&app).bounds("process-viewport").position() + iced::Vector::new(80., 100.);
press_middle(&mut mounted, &mut app, point).await;
let moved = point + iced::Vector::new(12., 8.);
let emitted = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: moved,
})],
mouse::Cursor::Available(moved),
)
.await;
let retained = camera_delta(&emitted);
let root_inputs = mounted.geometry(&app).text_inputs;
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(&mut app, task).await;
assert!(
app.reference_input_active(),
"the real App chooser must open"
);
let popup_focus = mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
assert!(
popup_focus
.iter()
.any(|(id, bounds, focused)| id.is_none() && *focused && !root_inputs.contains(bounds)),
"the newly mounted nested chooser search, absent from root inputs, must be focused"
);
mounted.perform(&mut app, delayed).await;
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
popup_focus,
"both delayed Collect and deliberately retained Apply respect the actual nested overlay owner"
);
assert!(!focused(&mut mounted, &app, "viewport"));
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
for key_name in [Named::F6, Named::Tab, Named::ArrowRight] {
let emitted = mounted
.batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
}
// Tab may dismiss the chooser to its owning field; Escape closes it if still open.
if app.reference_input_active() {
mounted
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
}
assert!(!app.reference_input_active());
let returned_focus = mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted
.geometry(&app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
returned_focus,
"the same owner after chooser Back has a different lifetime; retained Apply must stay inert"
);
release_middle(&mut mounted, &mut app, point + iced::Vector::new(50., 35.)).await;
let task = app.update(retained);
mounted.perform(&mut app, task).await;
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera,
"a disabled-and-reenabled canvas cannot resume its pre-chooser pan"
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
let mut retained_apply = retained_focus_apply(&mut mounted, &mut app).await;
let entry = mounted.input(&app, &[key(Named::F6)], mouse::Cursor::Unavailable);
let delayed = app.update(
entry
.into_iter()
.find(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
.unwrap(),
);
let task = app.update(Message::StartSemanticReview(trace));
mounted.perform(&mut app, task).await;
let facet = FacetSelector::Header(HeaderFacet::Parent);
for row in [
crate::semantic_review::RowEvent::Select(facet.clone()),
crate::semantic_review::RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
] {
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("actual Review dialog opens");
};
let message = state.address_event(row);
let task = app.update(Message::Workflow(crate::workflow::Event::semantic(message)));
mounted.perform(&mut app, task).await;
}
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review remains open");
};
let review = state.recovery_snapshot().unwrap();
let review_id = crate::forms::reference_chooser::trigger::field_id(
state
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
mounted
.batch(&mut app, &[key(Named::Tab)], mouse::Cursor::Unavailable)
.await;
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused)
);
let scrolls = mounted.geometry(&app).scrolls;
mounted.perform(&mut app, delayed).await;
mounted.operate(&app, &mut *retained_apply);
assert_eq!(
mounted.geometry(&app).scrolls,
scrolls,
"a retained Apply must not scroll the new owner"
);
for key_name in [Named::F6, Named::ArrowRight] {
let emitted = mounted
.batch(&mut app, &[key(key_name)], mouse::Cursor::Unavailable)
.await;
assert!(
!emitted
.iter()
.any(|m| matches!(m, Message::TopologyCanvas(CanvasEvent::Camera(..))))
);
}
assert!(
mounted
.geometry(&app)
.focused
.iter()
.any(|(id, _, focused)| id.as_ref() == Some(&review_id) && *focused)
);
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review retains ownership");
};
assert_eq!(state.recovery_snapshot().unwrap(), review);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
raw
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_form_replacement_resets_scroll_without_a_closed_render() {
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 520.);
let mut mounted = Mounted::new(&mut app).await;
let source = app.document.bundle().clone();
let snapshot = app.document.snapshot();
let bounds = mounted.geometry(&app).bounds("process-viewport");
let point = bounds.position() + iced::Vector::new(65., 80.);
mounted
.pan(&mut app, point, point + iced::Vector::new(-15., 24.))
.await;
let camera = (app.behavior.topology.pan, app.behavior.topology.zoom);
let task = app.update(event(Event::Start(f.owner, Tool::Branch, None)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
let session = app.behavior.topology.draft.as_ref().unwrap().invocation;
mounted.scroll(&app, "process-form", 700.);
let offset = mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1;
assert!(
offset.y > 0.,
"real production Branch fields must overflow this viewport"
);
let task = app.update(event(Event::Name("Preserve this same session".into())));
mounted.perform(&mut app, task).await;
assert_eq!(
mounted.geometry(&app).scrolls[&widget::Id::new("process-form")].1,
offset
);
assert_eq!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
session
);
let task = app.update(event(Event::Cancel));
mounted.perform(&mut app, task).await;
let task = app.update(event(Event::Start(f.owner, Tool::Branch, None)));
mounted.perform(&mut app, task).await;
assert_ne!(
app.behavior.topology.draft.as_ref().unwrap().invocation,
session
);
// No closed-state App::view was rendered between Cancel and Start.
let geometry = mounted.geometry(&app);
assert_eq!(
geometry.scrolls[&widget::Id::new("process-form")].1,
iced::Vector::ZERO
);
assert_eq!(geometry.bounds("process-viewport"), bounds);
assert_eq!(
(app.behavior.topology.pan, app.behavior.topology.zoom),
camera
);
assert_eq!(app.document.bundle(), &source);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_retained_toolbar_intents_reject_chooser_dirty_and_picker_lifetimes() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
retained_toolbar_boundaries(&mut mounted, &mut app, "OR").await;
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn production_process_keyboard_reveals_and_activates_initially_offscreen_last_tool() {
use crate::behavior::canvas_workspace::tests::*;
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
app.window_size = Size::new(1100., 420.);
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
overflow_last_tool(&mut mounted, &mut app, "->").await;
assert_eq!(app.behavior.topology.tool, CanvasTool::Connect);
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -0,0 +1,595 @@
use super::*;
use iced::widget::column;
pub(super) fn picker<'a>(
mut choices: Vec<Choice>,
d: &'a Draft,
index: usize,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
let browser = &d.browsers[index];
let query = browser.search.trim().to_lowercase();
choices.retain(|c| c.label.to_lowercase().contains(&query));
let range = choice_page(choices.len(), browser.page);
let page = range.start / 24;
let mut out = column![
text_input("Search compatible choices…", &browser.search)
.on_input(move |v| event(Event::Search(index, v)))
.padding(7)
.size(12)
]
.spacing(5);
if let Some(selected) = selected
.as_ref()
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
{
out = out.push(label(format!("Selected: {}", selected.label)));
}
out = out.push(
row![
button("Previous")
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
text(format!(
"{} choices · {}/{}",
choices.len(),
page + 1,
choices.len().div_ceil(24).max(1)
))
.size(11),
button("Next").on_press_maybe(
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
)
]
.spacing(6),
);
out.push(
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(8)
.text_size(12)
.width(Fill),
)
.into()
}
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let ordered = !legacy_order(p, owner).is_empty();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
let mut navigation = column![].spacing(6);
if let Some(Relation {
body:
RelationBody::ContextualLink {
owner: caller,
source,
target,
purpose,
},
..
}) = p.relations.get(&app.selected)
&& *caller == owner
{
navigation=navigation.push(text(format!("From: {}",super::contextual::address_label(p,owner,source))).size(12))
.push(text(format!("To: {}",super::contextual::address_label(p,owner,target))).size(12))
.push(text(match purpose {ContextualLinkPurpose::Precedence {..}=>"Precedence · hidden contributions attach visually to their folded reference",_=>"Assembly incidence · no execution order is implied"}).size(11));
}
if let Some(address) = state.qualified_selection(p, owner) {
navigation=navigation.push(text(super::contextual::address_label(p,owner,&address.endpoint)).size(12))
.push(text("Qualified contribution · definition properties affect every use of the source.").size(11))
.push(button("Open contribution definition").on_press(Message::TopologyCanvas(CanvasEvent::OpenQualified(context,address.endpoint.clone()))));
if matches!(
p.elements
.get(&address.endpoint.occurrence)
.and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
) {
navigation = navigation.push(
button(if state.contextual_expanded.contains(&address.endpoint) {
"Fold nested reference"
} else {
"Expand nested reference"
})
.on_press(Message::TopologyCanvas(CanvasEvent::ExpandQualified(
context,
address.endpoint.clone(),
))),
);
}
}
if let Some(id) = selected {
let mut actions =
row![button("Contribution properties").on_press(Message::EditProperties(id))]
.spacing(6);
if let Some(target) = direct::referenced(p, owner, id) {
actions = actions.push(
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
);
if matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
) {
actions = actions.push(
button(if state.expanded.contains(&id) {
"Collapse"
} else {
"Expand"
})
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
);
}
}
navigation = navigation.push(actions);
} else if sequence_links(p, owner)
.iter()
.any(|r| r.id == app.selected)
{
navigation = navigation.push(
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
);
}
if !state.return_to.is_empty() {
navigation = navigation.push(
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
navigation = navigation
.push(
button("Two paths...").on_press_maybe(clean.then_some(event(Event::Start(
owner,
Tool::Branch,
selected,
)))),
)
.push(
button("Qualified link / assembly...").on_press_maybe(clean.then_some(event(
Event::Start(owner, Tool::Contextual, Some(app.selected)),
))),
);
if ordered {
navigation = navigation.push(button("Append exchange...").on_press_maybe(clean.then_some(
Message::TopologyCanvas(CanvasEvent::AppendExchange(context)),
)));
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
navigation = navigation.push(
button("Edit path links and guards")
.on_press_maybe(clean.then_some(Message::Command(unique_batch(p, commands)))),
);
}
}
let mut graph = Graph::expanded_qualified(
p,
owner,
app.selected,
&state.expanded,
&state.contextual_expanded,
);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.camera_pan = state.pan;
graph.pending_insertion = state
.draft
.as_ref()
.filter(|d| d.owner == owner && d.fixed)
.map(|d| d.insertion);
graph.interaction = Some((context, state.tool, clean));
let scale = graph.scale;
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::TopologyCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
iced::widget::sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(graph).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(state.tool == CanvasTool::Select),
)
.key((context.document, owner))
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
)
.id("process-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let header = column![
row![
out.width(Fill),
super::super::canvas_workspace::Control::new(
button(
text(if state.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::TopologyCanvas(CanvasEvent::Focus(context))
)),
scope,
"Panels",
keyboard,
Some(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::TopologyCanvas(CanvasEvent::Focus(context))
)),
camera
)
]
.spacing(6),
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
event(Event::Shelf(Shelf::Selection)),
clean.then_some(Message::TopologyCanvas(CanvasEvent::Camera(
context,
scope.epoch,
super::super::canvas_workspace::Event::Focus
))),
move |zoom| Message::TopologyCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
let entries = [
("S", "Select contributions or paths", CanvasTool::Select),
(
"W",
"Work: click a path or empty space",
CanvasTool::Add(Tool::Work, ControlKind::Choice),
),
(
"E",
"Exchange: click a path or empty space",
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
),
(
"ref",
"Reuse process / chain",
CanvasTool::Add(Tool::Reuse, ControlKind::Choice),
),
(
"OR",
"Choice",
CanvasTool::Add(Tool::Control, ControlKind::Choice),
),
(
"M",
"Merge",
CanvasTool::Add(Tool::Control, ControlKind::Merge),
),
(
"AND",
"Fork",
CanvasTool::Add(Tool::Control, ControlKind::Fork),
),
(
"J",
"Join",
CanvasTool::Add(Tool::Control, ControlKind::Join),
),
(
"IT",
"Loop",
CanvasTool::Add(Tool::Control, ControlKind::Loop),
),
(
"->",
"Sequence link: click or drag between contributions",
CanvasTool::Connect,
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == state.tool,
clean.then_some(Message::TopologyCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let details = scrollable(navigation.push(text("Layout is derived. The reorder handle changes display order within its column; it does not author a position.").size(11)))
.id("process-details").width(Fill).height(Fill).into();
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
if let Some(child) = d.source.view(p) {
form_panel = column![
text(format!(
"Consumer name: {}",
crate::views::short(&d.name, 60)
))
.size(12),
child.map(|e| event(Event::Source(e)))
]
.spacing(6)
.height(Fill)
.into();
} else {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(contextual) = &d.contextual {
form = form
.push(
text_input("Link name (retain current when blank)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
)
.push(super::contextual::view(p, owner, contextual));
} else {
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed && ordered {
form = form.push(label(match (d.before, d.after) {
(Some(before), Some(after)) => format!(
"Insert between {} → {} · preserve the ordered path",
p.named(before),
p.named(after)
),
(Some(before), None) => format!("Continue after {}", p.named(before)),
(None, Some(after)) => format!("Insert before {}", p.named(after)),
_ => "Start the ordered path".into(),
}));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if d.tool == Tool::Work {
if let Some(title) = d.source.label(d.target) {
form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position."));
}
form = form.push(button("Create new source…").on_press(event(Event::Source(
super::super::source_draft::Event::Open(d.invocation),
))));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
if d.tool == Tool::Branch {
form = form.push(label("First path work"));
}
form = form.push(picker(
choices,
d,
0,
d.target,
if d.tool == Tool::Branch {
"Choose first path work…"
} else {
"Choose existing source…"
},
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(label("Second path work")).push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(label("First path guard"))
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(label("Second path guard"))
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(label("Preceding contribution"))
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(label("Following contribution"))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button(if d.contextual.is_some() {
"Apply link"
} else {
"Create"
})
.on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(d.invocation, actions.into(), form.into());
}
}
let editor = if pending {
Some(form_panel)
} else {
state.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, iced::Theme, R>,
fields: iced::Element<'a, Message, iced::Theme, R>,
) -> iced::Element<'a, Message, iced::Theme, R> {
container(
container(
column![
container(actions).id("process-actions"),
super::super::form_session::reset_on_change(
session,
scrollable(fields).id("process-form").height(Fill)
)
]
.spacing(8),
)
.width(Fill)
.height(Fill)
.padding(12)
.style(style::card),
)
.width(Fill)
.height(Fill)
.padding(iced::Padding {
top: 0.,
right: 18.,
bottom: 18.,
left: 18.,
})
.into()
}
#[cfg(all(test, debug_assertions))]
#[path = "layout_tests.rs"]
mod layout_tests;
@@ -0,0 +1,27 @@
[
{
"name": "arcadia-studio-tests.exe",
"bytes": 139577856,
"sha256": "63DE2FF7FB4C5E49C1E59378EAE24D77471AF35FADADFAEC5871956E7B2E0D8D"
},
{
"name": "full-ff01754.log",
"bytes": 83584,
"sha256": "A6D2E0D39B45246D88F1F5B61117CDB0897B23A0D072238ECA7A5550495BD421"
},
{
"name": "full-ff01754.result.json",
"bytes": 254,
"sha256": "C448190DE3C3768E43C0E191274D81074927A74C91C6EE7E4DC9AD5369C2B92D"
},
{
"name": "full-inputs-ff01754.json",
"bytes": 96552,
"sha256": "966B125EC8862D0E48ED98692BC3DC380F0242007EB4C018621A9FDE844562EB"
},
{
"name": "libunwind.dll",
"bytes": 190464,
"sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A"
}
]
@@ -0,0 +1,122 @@
# Allocation drag contract v2 — 2026-09-11
Codex-956atf `/root/capella_process_gap`. Implementation-ready proposal for root ratification of [issue 3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3). The [original design](release-activity-allocation-drag-design-20260911.md) and [independent review](release-activity-allocation-drag-review-20260911.md) remain immutable. Root accepted all five review corrections; this document resolves them without claiming implementation, issue closure or full-goal completion.
Source inspected at **ff0175488ac57a92d401b00a0e4542582a82d8e9** on `codex/behavior-authoring-20260910`. [Peer issue 4 update 446](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4#issuecomment-446) now transfers both Allocation and RoleAssignment identities to the first child; that remains peer-owned. This task creates only this report. No production/test/Git/index/compiler/native/Linux/WSL/VPN changes or agents.
## Decisions from the review
| Finding | Binding v2 resolution | Revisit criterion |
| --- | --- | --- |
| 1. Same-performer role-only drag could create Allocation | Ordinary drop onto the unique current effective performer is pure layout, for leaves and uniformly assigned composites, regardless of direct/role provenance. Explicit Assign is a separate operation. Unsupported role exits refuse without moving. | Add a deliberate role/assignment editing workflow with an exact preview; never reinterpret ordinary arrangement. |
| 2. Deleted-ID checks miss surviving-record changes | Compare the entire prepared candidate against an expected Project containing only intended allocation edits and permitted presentation changes. Constraint cleanup, trace/artifact edits and cascading mappings fail this gate. | A specific dependency-repair workflow defines and displays its additional edits. |
| 3. Press selection can auto-apply dirty properties | New clean-specific work selection and gesture admission; no `SelectNode`, `select()`, `apply()` or legacy MoveNode fallthrough. A dirty ordinary click on eligible work preserves current selection/forms and explains refusal. | Any broader selection redesign must explicitly preserve draft identity and may not silently apply it. |
| 4. Mixed composite geometry is not solved by translation/reflow | Distinct semantic work closure and active-diagram placement closure; first slice translates a coherent uniform subtree as one block. Distributed/mixed convergence and unreadable candidates refuse. No invented placements or automatic sibling packing. | A bounded placement algorithm preserves identity, shows its exact changes and passes the mixed P/x/y counterexample. |
| 5. Target rectangles and semantic preview can disagree | Frozen, visibly outlined target hit regions with semantic ancestor precedence and explicit occlusion. Candidate Project uses its own ViewIndex; cache includes destination/semantic plan identity. | Relax only with evidence that displayed targets, hit resolution, candidate grouping and final state remain identical. |
The primary choice remains ordinary context-sensitive work dragging in Allocation views. A dedicated assignment tool is easier to isolate but does not fix the user's natural gesture. Adopt ordinary dragging with a transient Arrange only alternative. Likewise, immediate mixed-subtree packing would broaden this slice; a specific visible refusal is preferable until a real placement algorithm exists. These are staged implementation boundaries, not reduced OA/SA requirements.
## Admission, selection and lifetime
Eligible pointer bodies are Activity/Function placements in `ProjectionKind::Allocation`. Ports, edges and controls retain their own gesture priority. Every eligible work-body press enters this contract, including stationary clicks and Arrange only; refusal must never fall back to legacy movement.
A **clean owner** means the current Diagram page and active allocation diagram, accepted document, no dirty property/source/behavior draft, no architecture creation/connection session, and no modal/menu/chooser/file/save/hosted operation owning input. App supplies a render-time capability but rechecks it when receiving press, preview and release events.
- **Clean stationary press:** select the represented work and exact placement through `select_work_clean`. It checks the capability, sets selection, and may rebuild a clean property display. It does not call any applying selection helper, change source/history/revision, run Fit or invalidate this drag's lifetime.
- **Dirty stationary press or attempted drag:** preserve selection, draft contents, cursor/focus and source exactly. Do not arm movement; show “Finish or cancel the current edit before selecting or moving work.” Clicking the already selected work also preserves its dirty form. Within the Allocation canvas, intercept dirty left-button object presses before legacy node/port selection, so a nearby port hit cannot auto-apply the same draft. This is a local pointer-selection policy; explorer/text navigation is outside this change. An input-owning overlay keeps its own focus.
- **Clean drag:** capture a fresh gesture token, document instance/revision, diagram/projection, input epoch, exact canvas bounds/pan/zoom, work/placement IDs, original scope/provenance and frozen hit geometry. App registers the token on the clean press event. Selection alone must not retire it.
- Owner/document/revision/projection, source/property/behavior dirtiness, modal/busy ownership, tool, bounds, camera, window focus loss or Escape retires the token. Even a Fit/tool action that returns to the same numeric values retires it. No stale packet becomes valid again when a modal closes or Undo restores prior values.
Retain ordered native pointer positions and the existing 3-screen-pixel threshold. A fast press→motion→release batch must work without waiting for a Task/round-trip acknowledgement: App processes the stamped press before its stamped release and verifies their common token. Outside/hidden release cancels. There is at most one final command per token.
**Arrange only:** a visible “Arrange next drag” control arms one gesture and displays an armed indicator. Keyboard activation is supported; Escape, cancellation, completion or owner/lifetime change clears it. Capture the mode at press and consume it then; it cannot remain sticky after a gesture or switch interpretation midway through one. It changes presentation only.
## Scope and semantic destination
For selected work `w`, let **S** contain w and all recursive same-kind semantic work children; **L** is the nonempty subset with no such child. Traverse accepted semantic parents, not rendered groups, and include folded/unplaced leaves. Detect stale/missing/cyclic scope before planning. Let **Q** be every existing placement in the active diagram whose element is in S, including hidden placements. The selected placement belongs to Q. Do not create placements, move other diagrams' nodes, or include unrelated work merely because it is geometrically enclosed.
Classify each leaf by exact direct Allocation identity (zero/one), RoleAssignment/RoleRealization references, and current `effective_performer`. The supported uniform cases are:
- **Assigned(P):** every leaf has the same unique effective performer P, including role-only and mixed direct/role provenance.
- **Unassigned:** every leaf has neither direct Allocation nor RoleAssignment.
- Otherwise **MixedOrUnresolved**. This is a valid modeling state where admitted by core. Composite group dragging, including Arrange only, is a named `MixedLayoutRequired` refusal in this slice; select a specific leaf instead. A leaf with unresolved role responsibility may still be arranged or explicitly dropped onto B: B is chosen by the user, not inferred from its roles. Its outward role exit remains refused below.
Ordinary destination means `Performer(P)` or `OutsidePerformers`; Arrange only means `LayoutOnly` independent of the pointed performer.
| Operation | Intended semantic change |
| --- | --- |
| LayoutOnly, or Assigned(P) dropped onto P | None. In particular, create no direct Allocation for a role-only leaf. |
| Unassigned, Assigned(A) dropped onto a different B, or a single leaf without a unique performer dropped onto B | For each leaf: edit an existing Allocation's source only, preserving complete header/identity/target; otherwise create one B → leaf relation with an identity allocated once per gesture/leaf. No parent Allocation. Preserve all role relationships; existing advisory role warnings stay advisory. If roles were unresolved, label the explicit assignment and retained role responsibility. |
| Assigned(A) dropped outside; every leaf has one direct allocation and removing them leaves no role responsibility | Delete exactly those Allocation IDs. Keep work decomposition and work/port identities. Label “Remove assignment — decomposition retained.” |
| Outside drop with role-only responsibility, or removal revealing retained/ambiguous role responsibility | `RoleExitRequired`: unchanged model **and placement**. Name the retained role/performer. If direct A would reveal B, say “Removing direct assignment would place work in B through its role”; do not call it Unallocated. |
| Unassigned dropped outside | Layout only; no relation added/deleted. |
Explicitly assigning direct Allocation to an already effective performer remains an existing editor/API action, not ordinary dragging. For a semantic child, “outside performer” does not promise source-root or diagram-root placement: its work parent remains intact, and resulting ancestor groups must be shown.
## Stable target resolution
Freeze **visible accepted** performer rectangles, header/body bounds, semantic performer ancestry, card occluders and viewport clip at press. During preview render the active frozen target outline above the ghost, distinctly from the candidate's enclosure; retain all candidate-group feedback. Hidden performers are not targets. A collapsed performer is targetable only by its visible header; successful semantic assignment may unfold that target placement in the same Batch.
Use the ordered release pointer in the frozen transform:
1. Outside the captured viewport or on an overlay/control/port/edge handle is invalid; it is never an outgoing drop.
2. Exclude both Q's accepted rectangles and its moving ghost from occluders. A different visible work or other non-performer card blocks target selection in its own painted body, including an underlying performer; it never implies reparenting. This rule requires aiming at an exposed performer header/body. Blank areas between its child cards remain targetable.
3. Collect compatible performer regions containing the pointer. Use half-open rectangles (left/top included, right/bottom excluded) for exact boundary behavior. If all candidates form one chain in **semantic parent ancestry**, choose its deepest candidate. Geometric rectangle containment does not establish this relationship.
4. Two incomparable candidates mean `AmbiguousTarget`, even when one rectangle geometrically contains the other. Highlight both and ask the user to use an exposed region; do not choose by paint order, size or UUID.
5. No performer hit and no blocking card means OutsidePerformers. Current-performer body remains an ordinary same-effective layout destination.
Camera/bounds change invalidates rather than rebasing frozen targets. Candidate enclosure changes never change this resolver. Active target, status wording and committed destination must match, including when the pointer coordinates remain constant.
## Placement and readable candidate rule
First-slice geometry is **one uniform translation**, not packing:
- Compute the requested delta from the selected placement's original position, press offset and current ordered pointer. Snap that root position to the existing 10-world-unit grid, then apply the identical delta to every Q position, including hidden children. Retain each node's size, anchors and fold state except the explicitly shown target unfold.
- Before permitting a composite semantic move, require its currently placed work closure to form one projected subtree under the selected root when that root's folded subtree is expanded in a read-only geometry projection. A distributed mixed closure is not made coherent merely by applying a common delta.
- Use `translated_routes` with that **captured Q delta map**, not a new descendant walk after changing allocation. This translates an active-diagram route only under the existing both-endpoint/same-delta rule; one-ended pins and label offsets retain their current contract. Do not move target contents or unrelated siblings.
- Project the complete candidate, including ancestors whose allocation summaries change. All coordinates/dimensions must pass current core bounds. The moved expanded subtree's internal relative positions and footprint remain unchanged by translation; unplaced leaves do not add ghost cards.
- Reject `LayoutRequired` if the result introduces or enlarges positive-area overlap between unrelated projected card interiors (compare the same pair before/after; ancestor/descendant enclosure is exempt). Check the read-only expanded moved subtree as well as the visible scene, so folding cannot hide a newly introduced collision. No automatic `insertion_adjustments` packing is promised here.
- The selected work header and, when targeting a performer, its active frozen target label must be visible in the viewport at the actual drag zoom. Outside/layout-only status remains visible beside the ghost or in the fixed canvas status. If the candidate hides the required header/label, refuse with “Make room or adjust the view, then drag again.” Keep the camera fixed; do not silently Fit an unreadable result. Larger preexisting diagrams are allowed; this does not require the whole diagram to fit.
These are deterministic first-slice gates, not evidence that all large-model arrangements are usable. Native review must verify that they allow the common empty/incoming/between/outgoing workflows. If normal target contents make refusal frequent, implement a bounded **visible** block-placement algorithm and revisit finding 4 before broadening acceptance.
## Exact candidate/diff gate
Build an **expected Project** from the accepted one by applying only the selected allocation additions/source edits/removals directly to a scratch copy and the enumerated permitted presentation edits. Separately obtain the actual candidate by `prepare_command`/`execute_batch` using ordinary commands, including their deferred cleanup and full core validation. Require equality of all Project fields/maps with the expected Project.
Permitted presentation changes are only Q node positions; the active target's displayed unfold; exact active-diagram route changes from the captured delta map; and removal of routes whose `edge` is an intentionally deleted Allocation ID. **Own-route cleanup is allowed in any diagram** because the route cannot survive its deleted relation; all other inactive-diagram geometry remains identical. Do not whitelist general view-option cleanup or other saved-view changes.
Consequently all element bodies/parents, non-target relation records, project headers/schema, artifacts, traces/baselines and untouched diagram fields must be identical. Check surviving records as well as missing IDs. A Constraint losing its `constrained` reference, dependent PortAllocation/ExchangeAllocation deletion, or trace/artifact mutation yields `CollateralChangeRequired` with named changed records and no accepted movement. This is a scoped equality gate, not a new generic impact framework.
Core errors produce `InvalidCandidate`; permitted warnings remain visible and do not become new errors. Source compilation must succeed before commit. Recheck the exact gate at release; no preview grants mutation authority. Execute the same stable-ID Batch through `Document::execute` once. Source refusal preserves accepted Project/SourceBundle/history and displays a retryable reason; retry recomputes against the live revision. A snapped no-op emits no semantic edits and no history entry.
## Concrete interfaces and preview/cache ownership
New names are proposed seams, not existing APIs:
| Boundary | Contract |
| --- | --- |
| `capture(project, diagram, work, placement)` | Pure Scope: S/L/Q IDs, exact original records/provenance, uniform class; no model mutation or GUI selection. |
| `plan_semantics(project, scope, destination, allocation_ids)` | Pure `SemanticPlan` or typed Refusal. Stable intended edits, label, complete semantic candidate Project and candidate `ViewIndex::new(candidate)`. IDs are allocated once outside the pure function and reused when revisiting a target. |
| `place(plan, snapshot, delta)` | Candidate Diagram + moved-route edits, projection, readability result and final Batch. It reuses the semantic candidate/index; it does not revalidate/clone all semantics per pointer pixel. |
| `WorkDragEvent::PressClean(stamp, token, node)` | App rechecks clean owner, registers token/scope and performs clean-specific selection. Never translates to legacy SelectNode. |
| `WorkDragEvent::Preview(stamp, token, destination, point)` | App checks token, resolves/cross-checks destination against frozen geometry, and returns/displays the keyed plan/placement or refusal. Ignore old responses. |
| `WorkDragEvent::Release(stamp, token, destination, point)` | Recheck clean owner, token, captured facts and current target; recompute final geometry/diff; execute one Batch or visible refusal. Consumes token. No fallback MoveNode/apply. |
| `Cancel(token)`, `Refused(stamp, reason)`, `ArrangeNext` | Presentation only. Refusal never changes selection/forms; cancel retires token and one-shot mode. |
Keep gesture-local ordered input/ghost state in `diagram.rs`; keep command admission and clean selection in a narrow root-owned App handler; use a small `allocation_drag.rs` planner. No edits to peer CreateContextElement logic, allocation validation, source grammar/schema or a new generic draft framework.
Cache semantic work by **(document/revision, gesture token, captured scope/provenance identity, mode, semantic destination)**. A destination change rebuilds candidate semantics/index even if snapped position is unchanged. Within one plan, geometric cache keys additionally include snapped delta, target unfold and preview geometry identity. Draw, ports, route eligibility, routing, hit-related candidate feedback and export-style projection must all use the same candidate Project/index. The accepted semantic index may be reused only for a plan with identical semantics. All caches are transient and invalidated with the token/lifetime.
## Required nontrivial verification
No tests were run or added by this task. Before claiming this slice:
- **Role counterexamples:** same-effective role-only leaf and uniform composite arrangement leave Allocation empty. Direct/role mixture under A stays byte-identical semantically on ordinary drop in A. Different B creates/retargets exact intended leaf records and retains advisory warnings. Role-only outward and A→role-B fallback produce truthful refusal with unchanged placement.
- **Hidden semantic edits:** delete Allocation referenced by a surviving Constraint; candidate remains core-valid but must fail exact diff. Repeat artifacts/traces and SA mapping cascades. Conversely a deleted Allocation's own stored routes can disappear across diagrams while every other saved-view field stays exact. OA communication incompleteness that is warning-only remains warning-only.
- **Mounted admission:** dirty property form before actual press→selection→move→release; dirty source/behavior and modal/save ownership; no apply/history/selection change. Clean stationary click selects correctly without revision change; full same-batch clean drag commits once. Replay captured press/preview/release after return from modal, Fit/resize or revision change and verify rejection.
- **Scope/layout:** uniform first/second/grandchild trees, folded children, unplaced leaf, other diagram with different placement; move exactly Q while editing every intended L allocation. Peer first-child transfer of Allocation **and RoleAssignment** invalidates old scope. Mixed P with x under A and y under B refuses rather than stretching C. Check active ancestor regrouping and introduced overlaps.
- **Targets/cache:** nested semantic performers versus overlapping/included peers, exact shared borders, work-card occlusion, collapsed headers, outgoing blank region and outside viewport. Same snapped point with destination A→B rebuilds semantic preview/index; rendered target and committed target agree. Repeated motion within B reuses its semantic plan.
- **End-to-end OA/SA:** ordinary leaf in→between→out and uniform composite move at 1100×700 and wider; full Project/SourceBundle Save/reopen and exact one-step Undo/Redo, including relation metadata, hidden placements, routes and source comments. Escape, unfocus, snapped no-op and source refusal leave no partial result. Verify actual packaged Windows target visibility/readability; compilation or planner passes are insufficient.
Mixed-layout convergence, deliberate role exit and collateral dependency repair remain required named continuations with explicit current refusals. The full OA/SA and cross-platform goals remain unchanged; Linux/WSL remains user-paused.
@@ -0,0 +1,104 @@
# Activity/function allocation drag proposal — 2026-09-11
Codex-956atf `/root/capella_process_gap`, advisory diagnosis/design for [issue 3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3), under [root scope 432](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3#issuecomment-432).
The user wants existing activities to move from the canvas root into entities/actors and back out by dragging. The proposal below also covers SA functions/system actors. It changes allocation and placement, never work decomposition. This is a proposal for root review, not ratified specification, implementation or native acceptance.
Read current AGENTS/PROJECT_GOAL, issue 3/4 and current diagram, App, projection, validation, command and reference-deletion source. Baseline anchors identify **638d06c4e33a1f54203b234d0b49c3ecf551d27d**. Root's canvas integration currently has local staged/unstaged changes and merge conflicts; none was modified. [Issue 4 comment 431](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4#issuecomment-431) assigns the child-work correction to ytr83r: first-child creation transfers the existing leaf allocation to the child, preserving identity/metadata. This report does not acquire that implementation, B4/header or source-navigation work. Only this report was created; no compiler/test/native execution, Git/index/Wiki mutation, agents, Linux/WSL or VPN activity.
## Exact cause of the current interaction
1. [Diagram drag state][S1] stores placement ID, pointer origin and original position. Press starts a placement drag; release emits only `Message::MoveNode(id, position)`. There is no performer destination or allocation intent.
2. [App's MoveNode handler][S2] applies pending properties and executes `Command::MoveNode { with_descendants: true }`. [Core movement][S3] updates node positions and eligible route waypoints. It never adds, edits or removes Allocation.
3. In an allocation diagram, [projection parent computation][S4] groups work under its computed performer, or under a visible work ancestor with the same performer. Parentage comes from semantic records, not geometric overlap.
4. [Enclosure][S5] expands/moves a projected parent to surround its projected children. The [drag preview][S6] retains the accepted Project's allocation relationships, so dragging work outward keeps it inside the same expanding enclosure.
Thus incoming drag cannot create allocation; outgoing drag cannot remove it. The continued enclosure is expected projection behavior, not a coordinate-clamping defect. Fixing coordinates, weakening enclosure, or enabling arbitrary `Reparent` would address the wrong layer. This diagnosis follows executed source paths; it is not a fresh observation of the native binary.
## Required meaning and less obvious cases
The existing [language contract][S7] distinguishes Activity → Activity / Function → Function decomposition from participant → leaf-work Allocation. Non-leaf Allocation is invalid; each leaf has at most one direct performer. Actor graphical containment must therefore not become `Element.parent = actor`. [The earlier native Capella benchmark](release-capella-basic-editing-20260907.md) independently found an activity visually inside an entity while the activity remained in its work hierarchy with a separate allocation. Capella also separates [allocation management](https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/doc/plugins/org.polarsys.capella.ui.doc/html/07.%20Modelling%20Wizards%20and%20Accelerators/7.2.%20Contextual%20Allocations.mediawiki) from [layout-only work](https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/doc/plugins/org.polarsys.capella.ui.doc/html/04.%20User%20Interface/4.05.%20Diagram%20Editor.mediawiki#L84-L105).
Two existing semantics prevent treating every outward drag as “delete one relation”:
- [`effective_performer`][S8] falls back from direct Allocation to RoleAssignment/RoleRealization when that resolves to one performer. Removing a direct allocation can leave the same grouping, reveal another role-derived performer, or leave ambiguity. `allocation_summary == None` can mean mixed/unallocated leaves; it does not identify a unique old performer.
- [Allocation deletion impact][S9] can remove dependent SA PortAllocations and then ExchangeAllocations. Other references such as trace/artifact ownership may also be affected. Editing an Allocation's source preserves its identity but can invalidate existing mappings or participant scenarios. Validate the entire candidate and show real impact; never hide collateral deletion behind a successful drop.
“Out to root” means outside a performer for this task. Source-root work with no effective performer becomes diagram-root work. A decomposed child can still remain inside its work parent. Label that destination **Unallocated — decomposition retained**, and preview actual resulting groups. Moving a child out of its semantic decomposition is a separate explicit action.
## Interaction candidates
Criteria: perform the user's ordinary move directly, preserve domain meaning, make consequences visible before commitment, and use one existing source/history transaction.
| Candidate | Benefits | Tradeoffs |
| --- | --- | --- |
| Dedicated “Assign performer” tool or relationship editor; ordinary drag stays layout-only | Clear mode, straightforward target validation | Adds a mode/menu before the exact gesture the user requested; it leaves ordinary incoming/outgoing drag apparently ineffective. Keep as a keyboard/accessibility alternative, not the primary workflow. |
| **Context-sensitive body drag in Allocation views** | Incoming, between-performer and outgoing movement use one familiar gesture; same-performer movement remains arrangement | Needs stable target hit-testing, explicit preview and ambiguity handling. Recommended. |
| Geometric nesting becomes semantic containment | Superficially simple implementation | Violates work/performer ownership rules, destroys decomposition, and confuses allocation with containment. Reject. |
| Allow composite-parent Allocation directly | Makes bulk dragging appear cheap | Contradicts current source/core semantics and peer issue 4's correction. Reject. |
### Recommended gesture
Ordinary work-body drag in **ProjectionKind::Allocation** selects a semantic destination while moving a ghost. Other projections retain their established layout movement; an explicit Assign/Unassign action can enter the same operation when needed. Provide a discoverable **Arrange only** affordance, also keyboard accessible, so review-layout movement cannot accidentally change allocation.
Capture the accepted document, revision, diagram, canvas epoch, placement/work ID, original geometry, selected leaf set and direct-allocation identities at press. Respect the existing screen-space threshold. Use the press-time projected performer rectangles for hit-testing, excluding the dragged work and its moving subtree: the preview's expanded old enclosure must not chase the pointer and make exit impossible.
A compatible performer header or exposed body highlights with **Assign to Rescue team**, **Move from A to B**, or **Assign 4 leaf activities to B**. Same performer says **Arrange within B**. Exposed blank canvas outside all performer groups is the outgoing destination. A drop over an incompatible object, outside the viewport, or over unrelated overlapping performer rectangles is not an implicit root drop. Nested performers resolve to the most specific unambiguous visible performer; arbitrary paint order must not choose between overlapping peers. The pointer is the destination anchor; show the chosen destination before release, rather than infer it from how much of the dragged box overlaps.
Hovering a work card does not reparent into that work. Ports, links, other-layer performers and hidden objects are not allocation targets. A collapsed performer may be targeted via its visible header; successful drop expands that one target in the same presentation transaction so the work does not disappear. Cancellation preserves the prior fold state.
The preview must show **the candidate semantic grouping**, including any affected composite ancestors. For a single leaf changing allocation, do not relocate an entire target performer's existing contents. Pointer motion changes no accepted data. A valid ordinary release commits one prepared Batch; Escape, hidden/outside release, unfocus or invalid target commits none. Same allocation with changed coordinates is one layout operation; a true no-op adds no history.
## Allocation command semantics
Compute scope from the selected work definition's current same-kind descendant leaves. Do not use visual descendants as the semantic leaf list: folding, unplaced children and mixed performer groups can hide relevant leaves. Preserve every work parent, ID, name, alias, description, work exchange, role assignment, trace, scenario use and unrelated allocation unless a separately shown dependency repair explicitly changes it.
| Starting state | Drop onto performer B | Drop outside performer grouping |
| --- | --- | --- |
| Leaf, no direct allocation | Create one Allocation B → leaf with a fresh stable ID allocated once during preparation. If role-derived responsibility exists, show it; B is an explicit user choice, not inferred responsibility. | No direct allocation to remove. If role-derived grouping remains, show that exact reason and offer its existing role-assignment editing path; do not pretend the work became unallocated. |
| Leaf, direct A → leaf | Edit the existing relation's source to B, retaining relation ID, alias/name/description and target. If A=B, only layout changes. | Delete that exact Allocation after impact analysis. The predicted root/group result must be truthful about any remaining role-derived responsibility. |
| Composite with all current leaves unallocated, or all with one unambiguous direct performer | Apply the preceding leaf rules to **all current leaves** in one Batch. Preview leaf count; keep the composite without direct Allocation. | Remove direct allocations across that leaf set in one Batch, subject to role and dependency checks. Do not delete the work or its decomposition. |
| Composite with mixed performers, partly assigned leaves, or role-derived responsibility | Retain a bounded local impact draft after release: show counts grouped by old performer/unallocated/role-derived status and exact destination. Explicit Apply means assign the displayed leaf set to B. | Retain a bounded draft naming which direct allocations will be removed and which role-derived responsibilities remain. Do not select an arbitrary performer or silently clear role relations. |
Mixed context is not an invalid model, and an explicit destination can resolve it. A source draft containing duplicate direct allocations is different: it must not be accepted as a valid canvas baseline, and a stale drop must not repair it opportunistically.
For direct moves without extra impact, no extra confirmation is needed. If standard Delete would remove IDs beyond the intended Allocation set, or a source change invalidates port/exchange/scenario dependencies, retain a local impact/refusal state with named reasons and a route to the existing repair editors. Never silently delete these dependents or invent replacement ports/channels. Root can later extend that draft to support an explicitly reviewed repair Batch. A role warning allowed by current validation remains advisory; do not turn it into a new metamodel prohibition.
Prepare the entire semantic-plus-placement candidate before acceptance. Compute explicit moved-placement IDs and route deltas from the captured work subtree; do not issue `MoveNode(with_descendants=true)` blindly *after* allocation changes, since its newly computed visual descendants can differ. Preserve hidden subtree placement offsets where applicable. Project the candidate and use existing bounded [insertion adjustment][S11] behavior only for affected collisions; include any resulting node/route/fold edits in the same Batch. If the result still produces an unreadable giant enclosure, the preview fails acceptance and requires local arrangement work, not a misleading success message.
Use existing CreateRelation/EditRelation/Delete/EditNode/SetEdgeRoute/Batch commands and `document.execute`; no schema change or persisted desktop-only grouping map is needed. Creation IDs are reused by retry/Redo. Exact source and Project Undo/Redo must include relations, layout, fold state and authorized impact together. API/text users retain the same semantic operations.
## Production seams and ownership
- **New bounded planner module**, preferably `crates/oa-desktop/src/allocation_drag.rs`: request/destination types, leaf traversal, direct-versus-role provenance, candidate/impact computation and command composition using existing core validators. Keep it independent of Iced input so behavior can be checked directly. This is UI proposal composition; existing core remains authoritative.
- **`diagram.rs`**: replace the bare movement tuple for allocation-capable work with stamped gesture state; stable destination hits, candidate projection, ghost/highlight/text and release intent. Preserve ports, connections, pan and ordinary route editing. Current [release event][S1] has no captured context.
- **Root-owned `app.rs` dispatch**, ideally a small new `app/allocation_drag.rs` handler: admit only the current clean Diagram owner, recheck captured context and exact identities, recompute the candidate against current data, then execute once. The current [MoveNode handler][S2] calls `apply()`; the new semantic drop must **not** auto-apply unrelated dirty properties or source. Reuse the [route context shape][S10], but add view/bounds/camera-lifetime invalidation where needed.
- Existing `projection.rs`, `validation.rs`, `references.rs` and `session.rs` supply semantics and geometry. No planned change to leaf validation or allocation summary. Add focused language/help wording only after root accepts the interaction.
- Coordinate any palette/menu placement with root. `model_menu.rs` dispatches Create child work through `Message::CreateContextElement(work, true)`; leave that peer-owned issue 4 correction and any helpers it introduces alone. `architecture_authoring.rs` separately handles performer-context allocated creation and does not need changes for dragging existing work. Root must keep the new App drag dispatch separate from the peer creation branch when integrating. In particular, transfer of an allocation to the first child must invalidate a previously captured leaf drag. No syntax/source-navigation ownership is acquired.
A source refusal or stale drop retains the accepted model/source/history. A retryable impact/source failure may retain its bounded draft; after revision change, require recomputation and a renewed visible result rather than replaying old commands. Canvas errors must remain visible near the destination and in status, not depend on an offscreen Inspector.
## OA/SA user acceptance
Run the same fixture pattern with OA Entity/Actor + Activity and SA System/SystemActor + Function, at 1100×700 client and a wider window. These are proposed checks, not passing tests.
1. **Incoming/outgoing leaf.** Drag a source-root unallocated leaf into A; see the exact target highlight and nested result. Save/reopen. Drag it out to blank canvas; see A release its enclosure and the leaf remain. Verify decomposition unchanged, one Allocation created then removed, and exact source/model Undo/Redo for each operation.
2. **Reassign and arrange.** Drag A's leaf into B; original Allocation UUID and metadata survive with source B. Move within B and use Arrange only across a group boundary; allocation remains unchanged. Check all other performers, work uses, routes and sibling positions. A stationary click and release on an invalid object add no history.
3. **Decompose then move.** After peer issue 4 creates first/second/grandchild work, move the uniformly allocated composite A → B. Only leaf allocations change; parent allocation remains absent, decomposition and all UUIDs survive. Move one child out: preview and final result must agree about changed ancestor summaries. A semantically nested child is not falsely advertised as source-root.
4. **Mixed and role-derived work.** Drag a composite containing A/B/unallocated leaves; the bounded impact draft shows all three categories. Cancel changes nothing; Apply to a chosen performer affects the exact displayed set. Remove direct allocation from a role-assigned leaf and verify the residual role grouping is explained. Ambiguous roles never select a performer automatically.
5. **SA technical and OA communication dependencies.** Include SA PortAllocation/ExchangeAllocation and participant-bound scenarios, plus OA exchange allocation/role cases. Retarget/remove only when the whole candidate is valid. Extra cascade or invalidated mapping is visible and retained for repair; there is no hidden dependent loss. Exact cancellation preserves every source byte.
6. **Stale and source refusal.** Press, then change owner/view/revision, resize/Fit, open an editor/dialog, begin save or create the child that retires the captured leaf allocation; deliver the old release. No mutation. Inject a source-edit/write refusal; no model/layout/history partial commit, pending unrelated edits survive, and retry uses a freshly validated candidate. Exercise Escape, unfocus and release outside the viewport with actual input.
Native acceptance must verify target visibility, no enclosure chasing, readability, focus and correct recovery in the actual packaged build. Preserve the broader OA/SA goal; closing this specific interaction does not establish complete architecture usability or issue 2 completion.
[S1]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/diagram.rs#L936
[S2]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/app.rs#L2780
[S3]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/session.rs#L771
[S4]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/projection.rs#L81
[S5]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/projection.rs#L541
[S6]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/diagram.rs#L410
[S7]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/docs/language.rst#L424
[S8]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/validation.rs#L2850
[S9]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/references.rs#L270
[S10]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/route_edit.rs#L6
[S11]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/diagram.rs#L79
@@ -0,0 +1,157 @@
# Independent allocation-drag proposal review — 2026-09-11
Codex-956atf `/root/allocation_drag_critic`, bounded source/design critique of
[the proposal](release-activity-allocation-drag-design-20260911.md) for
[issue 3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/3).
Read local AGENTS/PROJECT_GOAL, remote `main` AGENTS, STATE, issue 3/comments and
[issue 4 coordination](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4#issuecomment-446).
Working branch read from `.git/HEAD`: `codex/behavior-authoring-20260910`, HEAD
`06ec9621297627a1f7d5c3bc65efb237d1c9923e`. Source anchors below refer to the
files inspected in that workspace. Only this new report was written. No build,
test, native execution, production edit, Git/index change or Gitea write occurred.
This is not implementation or acceptance evidence. Root owns synthesis and integration.
## Judgment
The diagnosis and principal choice are sound: ordinary drag in an Allocation
view should compose allocation and placement. Existing `MoveNode` only moves
placements/routes; projected parentage comes from `allocation_summary` and
semantic ancestry. Work-to-performer `Reparent`, relaxed leaf-only validation,
or turning off enclosure would damage the existing model. Direct allocation
retargeting should preserve relation identity and metadata. Preserve separate
decomposition, effective role responsibility and actual direct allocation.
The proposal needs the following corrections before it is precise enough to
implement. The first three can otherwise cause hidden semantic changes or a
broken ordinary gesture.
## Material corrections
1. **Resolve same-performer role semantics.** A leaf with `RoleAssignment R → x`
and `RoleRealization A → R` is displayed under A without direct Allocation.
The gesture text promises **Arrange within A**, but the no-direct-allocation
table says to create `A → x` on that drop. These are observably different:
direct allocation subsequently overrides role changes, and OA communication
allocation uses actual allocation rather than role responsibility. Specify
that dropping within the current unique effective performer is layout-only
for ordinary drag, including uniformly role-derived composites. Creating a
direct assignment to that same performer requires an explicit Assign action.
Dropping onto a different B may create direct allocation with the existing
advisory role warning. If removing direct A reveals role-derived B, the label
must say **Remove direct assignment; role places work in B**, not Unallocated.
A role-only outward drop should explain its retained role grouping and leave
accepted placement unchanged when it cannot fulfill the requested exit.
Sources: [effective performer](../crates/oa-core/src/validation.rs#L2850),
[role conflict is a warning](../crates/oa-core/src/validation.rs#L2553),
[actual OA communication allocation](../crates/oa-core/src/ports.rs#L1180).
2. **Compare surviving semantic records as well as deleted IDs.**
`deletion_impact` is not a complete semantic-change report. Counterexample:
same-layer Constraint C references Allocation a in `constrained`. Deleting a
clears C's reference while retaining C; the candidate can remain valid and
the removed-ID set can be exactly `{a}`. The proposed extra-deletion gate
would approve a silent constraint edit. Compare the prepared candidate's full
semantic diff to the intended allocation edits, and expose/refuse any other
semantic mutation as well as cascaded deletion. Presentation cleanup of the
deleted relation's own route is a distinct expected effect. Full validation
remains necessary for retargeting: state availability/actions and participant
scenarios also depend on effective performer. Preserve allowed warnings;
notably OA unallocation may leave a communication mapping incomplete with a
warning, whereas SA can cascade through port and exchange allocations.
Sources: [constraint validity](../crates/oa-core/src/validation.rs#L560),
[surviving-record cleanup](../crates/oa-core/src/references.rs#L403),
[deferred deletion](../crates/oa-core/src/references.rs#L372),
[state context](../crates/oa-core/src/validation.rs#L2920).
3. **Guard mouse-down selection, not just release.** Current node press arms
`state.drag` and publishes `SelectNode` without checking `editable`.
`SelectNode` can call `apply()` before a drop happens. Merely removing
`apply()` from the new release handler does not prevent unrelated dirty
properties from entering history, and may invalidate the captured revision
immediately. Give semantic drag a clean-owner admission/selection path at
press and recheck it at release. Dirty-source/property/behavior refusal must
not fall through to legacy movement that applies the draft. Preserve simple
selection behavior deliberately; do not silently commit a form to make drag
work. Sources: [press](../crates/oa-desktop/src/diagram.rs#L797),
[selection applies](../crates/oa-desktop/src/app.rs#L2129),
[legacy release dispatch](../crates/oa-desktop/src/app.rs#L2943).
4. **Specify two scopes and an actual placement rule for composites.** Define
the semantic same-kind work closure/leaf set separately from the presentation
placements that move. Counterexample: mixed composite P has x allocated to A
and y to B. x and y can be projected directly under those performers, outside
P's visual subtree. Changing both allocations to C while moving only P's
captured visual descendants leaves x/y at their old distant coordinates;
C then encloses all of them. Even translating all semantic placements by one
delta preserves that excessive spread. Specify a bounded packing rule for
this converging case, or retain a truthful refusal until it exists. Capture
hidden placements too; never invent placements for unplaced leaves, and edit
only the active diagram's geometry. An outgoing child can also make its
previously uniform composite move to a different projected group: include
affected ancestor grouping in the preview. Existing insertion adjustment only
pushes colliding siblings down; it does not solve arbitrary distributed
subtree packing. Sources: [parent derivation](../crates/oa-core/src/projection.rs#L81),
[descendant movement](../crates/oa-core/src/session.rs#L771),
[insertion adjustment](../crates/oa-desktop/src/diagram.rs#L79).
5. **Keep the target contract stable and the candidate projection fresh.**
Press-time rectangles are the right defense against enclosure chasing. Keep
their destination outline visible while candidate enclosures change, or the
visible target and accepted hit area can disagree. Define semantic ancestor
containment for nested performer precedence; geometric inclusion alone must
not turn overlapping peers into parent/child. Define the boundary/overlap and
work-card occlusion cases explicitly. Capture/invalidate bounds, camera and
view lifetime as proposed, preserving the ordered native pointer handling.
Semantic preview also requires a candidate `Project` and candidate `ViewIndex`:
the current preview clones only Diagram and reuses the accepted semantic
index. Extend scene-cache keys with semantic destination/plan identity, so an
unchanged coordinate cannot reuse a previous target's scene. Recompute model
work when destination/scope changes, rather than cloning and validating a
large project on every pointer pixel. Sources:
[existing preview/cache](../crates/oa-desktop/src/diagram.rs#L410),
[index lifetime contract](../crates/oa-core/src/projection.rs#L229).
## Smallest coherent implementation path
Keep one pure, bounded proposal composer plus stamped canvas gesture/App
admission. Use current commands and `Document::execute`; its source compilation
precedes its single commit and history stores exact source snapshots
([transaction](../crates/oa-desktop/src/document.rs#L301)). No metamodel change,
allocation inheritance or new generic draft framework is needed.
First establish ordinary incoming, between-performer, outgoing and same-performer
leaf gestures, then uniform composites with explicit descendant scope. Keep
unsupported role exits, collateral semantic edits and unreadable mixed layouts
as named local refusals with existing repair navigation. This is a staged scope,
not grounds to claim every composite workflow complete. A full mixed-impact
editor and automatic dependency repair are larger capabilities and should not
block proving the user's basic direct workflow. Mixed status alone is not a
semantic error or a reason for obligatory confirmation once a safe, readable
exact-set preview exists. Extra Apply steps should resolve a real scope/impact
decision. A visible **Arrange only** alternative should be temporary or clearly
indicated, not a sticky mode users must remember after every assignment.
## Acceptance additions before claiming the fix
- OA and SA leaf incoming/reassign/outgoing: exact relation identities and
metadata, work parents, active-diagram positions/routes, save/reopen, and one
exact Project/source Undo/Redo step. A snapped no-op creates no history.
- Role-only same-performer arrangement creates no direct allocation. Direct
removal that reveals another role performer has the same grouping and wording
in preview and result; warnings remain advisory.
- Constraint referencing an allocation, artifact/trace attached to an allocation,
SA port/exchange dependencies, OA communication mapping and participant-bound
scenario/state behavior: no unshown semantic deletion or surviving-record edit.
- Dirty form before press, dirty source, modal/save ownership, revision change,
peer first-child allocation/role-assignment transfer, camera/bounds change, Escape/unfocus and
outside release: no hidden apply or partial history. Include the actual mounted
press → SelectNode → move → release dispatch, not only planner calls.
- Uniform folded/unplaced children and mixed P/x/y fixture: exact leaf scope,
hidden-placement handling, bounded readable grouping or explicit refusal,
unchanged other-diagram geometry, and no target/enclosure chasing. Verify
competing/overlapping/nested targets and that the shown destination is the
committed destination.
Exercise the completed direct workflow in the actual Windows package at the
minimum client size. Planner tests and this source review cannot establish
target discoverability, cognitive load, readable layout or native event behavior.
@@ -0,0 +1,65 @@
# Shared behavior canvas — independent root union review
Codex-956atf `/root/canvas_union_review`, 2026-09-11. **No actionable correctness, input-ownership, architecture or source/history regression was found in this bounded source review of the integrated canvas and scenario-parent focus paths.** This is not a build, runtime, visual or release acceptance verdict.
## Scope and identity
Root delegated this read-only review against the production union on `codex/behavior-authoring-20260910`, first identified by root as commit `6969c825a8340980b6a419524ade3bb687a6051f`, then frozen at `06ec9621297627a1f7d5c3bc65efb237d1c9923e`. The latter adds two test-only compiler adaptations described below. Commit identities are root-provided; this reviewer did not run Git. The scenario test freeze independently matched SHA256 `950CF4347A3EDEA615B1AC71D9A681C32B446B5109FCFD0F03F3AE2B147360BF`.
Read the current AGENTS.md and PROJECT_GOAL.md, the [prior R1/R2/R3 review](release-behavior-canvas-r123-review-20260911.md), the isolated candidate's R1/R2/R3 source handoff, and the relevant actual root implementation and tests. The prior review's resolved counterexamples were checked for integration regressions rather than re-reported. Root owns Gitea coordination, compilation, native acceptance and publication. Only this report was written.
The following independently observed raw working-file hashes identify the important union boundaries. Paths are relative to `crates/oa-desktop/src/`.
| Path | SHA256 |
| --- | --- |
| `app.rs` | `AA2E4853B25BB7E185B1DE49EDA42028523C8EBF9599BFE6B7CB2A2B2A688090` |
| `behavior.rs` | `5E7CECB54BFCA64E4462F7881F460900ABD925BA2CFE9451CE2B0C78AF4BC850` |
| `behavior/scenario.rs` | `FB1DA03E8DF05A59253F7FDF34391E30ABD8AEA427B7D0A12B86B80FF3BF9ECA` |
| `behavior/scenario/focus.rs` | `73A9EA10AD7312086081B8688396A21AB4251C9FAECC2DF6D984B702F3963533` |
| `behavior/scenario/workspace.rs` | `284F5A7DA09BDC77D70C6788A091D57927150C796FFC10D786793CFF7DA4B7C6` |
| `behavior/scenario/workspace/tests.rs` | `950CF4347A3EDEA615B1AC71D9A681C32B446B5109FCFD0F03F3AE2B147360BF` |
| `behavior/canvas_workspace/focus.rs` | `CC58CB3838F546150D22FE4DD83A3FFD52504C279FEBAEB8D0B4A6A3B8A73950` |
| `behavior/canvas_workspace/tests.rs` | `D84D022574A6F1D69F203D9383502227FB9FC23605C6FB48779CA9F09BEE546D` |
| `behavior/topology/layout_tests.rs` | `A990947CB39B2FB15C21DFBC95EE16F92BF6BE749D3872C6904762B9BDE37089` |
| `behavior/form_session.rs` | `F5027FB3A5A5599A182E7D1A10DBA56DD66EB1F23A4BA4C4A1744C9A6A5B6E61` |
## Production integration
All source locations in this section are under `crates/oa-desktop/src/`.
`app.rs:1041` nests the scenario parent owner root outside the canvas owner root, with both outside the actual hosted/reference overlays. Their custom owner types are distinct. Each operation ignores the other's marker, receives its own current marker before visiting focusable children, and independently checks ownership in Collect and Apply. This preserves the parent form guard while allowing the canvas operation to cover the actual nested overlay traversal. A pending scenario form makes `Scope::input_owner` return None (`behavior/canvas_workspace.rs:84`); opening its source child removes `form_mount` (`behavior/scenario.rs:509`); Review removes the parent owner (`app.rs:1495`). No competing current canvas/parent focus owner was found in these paths.
`app.rs:1119` retains the exact current canvas Scope admission and narrow presentation whitelist. Only accepted presentation actions normalize the semantic epoch, and resulting control focus uses the post-action lifetime at `app.rs:1177`. The outer parent wrapper neither changes these epochs nor emits another focus operation. The resolved held-pan/toolbar and delayed canvas-task cases therefore retain their production guards after integration. Domain authoring still uses exact scenario context at `app.rs:1619`.
Scenario Name uses the session-and-mount-derived dynamic ID (`behavior/scenario/workspace.rs:283`), while the scroll reset wrapper remains keyed only to editing session (`:615`). The sensor uses the mount key and sends that mount in FormShown (`:565`). The handler accepts same-owner geometry epoch changes but checks document, owner, revision, current owner and pending mount (`app.rs:1527`). Back, Use and Escape rearm the parent through `remount_form` (`behavior/scenario.rs:324`, `:696`, `:745`). None of these presentation paths replaces the raw invocation or source draft. The zero-delta axis preservation in `behavior/scenario/focus.rs` remains present.
The Details pane now uses the production passive observer with `process = false` (`behavior/scenario/workspace.rs:595`). Its actual translation enters scenario Page scroll state, and a caller restore receives and consumes the matching token through `behavior.rs:114` and `behavior/scenario.rs:460`. `restore_caller` marks only Page pending (`behavior/scenario.rs:425`), consistent with the viewport using camera pan instead of a Scrollable. The reviewed source contains no new semantic command or source mutation in these observer/focus paths.
The boxed workflow convention is retained (`workflow.rs:22`, `:45`), including `review_event.as_ref()` in `app/reference_authoring.rs:218`. Reference chooser availability excludes pending behavior forms (`app/reference_authoring.rs:72`), so its separate canvas guard does not create an uncovered simultaneous parent-form/chooser owner in the reviewed production path.
## Mounted evaluator and nontrivial assertions
Independently inspected the installed Iced sources under `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/`:
- `iced_winit-0.14.0/src/lib.rs:1214` drains App updates, rebuilds the resulting UI, then executes actions; `:1300` collects immediately ready task actions for the whole message batch and retains pending streams; `:1742` executes chained widget passes synchronously.
- `iced_runtime-0.14.0/src/user_interface.rs:539` operates both the real root and nested overlays.
- `iced_widget-0.14.2/src/scrollable.rs:540` reports the scroll translation before traversing unshifted child layout, matching the parent's translated bounds observer and reveal calculation. `sensor.rs:198` rearms on key change and emits on the subsequent redraw, including unchanged bounds. `iced_core-0.14.0/src/widget/id.rs` supports the dynamic `Id::from(String)` used by the union.
The shared Mounted helper uses those actual UserInterface and Cache APIs, drains a complete input message batch before tasks, builds the final App view for ready actions, and executes Chain passes against that same UI (`behavior/canvas_workspace/tests.rs:140`, `:184`). Output messages form the next update cycle; pending streams are retained until completion. Unexpected native actions panic and unavailable renderer initialization fails. This is the relevant single-window widget/App ordering, not a replica of subscriptions, OS focus, native dialogs or arbitrary asynchronous scheduling.
The reconciled parent tests preserve useful falsifiers:
- `scenario/workspace/tests.rs:412` first focuses another real field, establishes actual parent overflow, performs the accepted task, and requires the exact Name to be the sole focus and fully visible. Mismatched mounts, revisions, owners and modal admission are separately rejected.
- `:794` obtains the real sensor task and first consumes initial focus before entering the actual child. Back, Use and Escape must retain session/raw identity, create a different mount, emit a new sensor task, and focus the parent again. A repeated FormShown must produce no task.
- `:864` retains an actual widget Action before replacement, child, Review and context transitions; it observes the resulting tree and requires the saved Collect to remain inert. `:935` similarly retains Apply across Review. This intentionally stronger retained-Apply invariant does not claim native Chain passes interleave.
- `:958` is explicitly a small synthetic missing-target test; it is not presented as a production-root modal test. The preceding production-root cases supply that coverage.
- `:1654` requests 700 pixels on the real Action form and requires the measured offset to exceed 64, preserves it while editing, then performs Cancel/Start with no intervening closed view. It checks zero offset before a redraw could rescue it through a new Name-focus task. Action and Observation share the same relevant field structure (`scenario/workspace.rs:280`, `:315`, `:476`), so replacement is not simply a switch to an empty form. Camera, raw invocation, recovery, source snapshot and history assertions remain.
- `:1773` tests same-bounds owner/document remeasurement with the same Mounted cache. `:1820` builds legitimate Details rows, requires the actual offset to be exactly 80, navigates through preselection and caller return, then requires the offset/camera restoration, consumed receipt, and subsequent pan movement. Source and revision comparisons surround navigation; existing rename history is acknowledged instead of asserting an empty history.
The shared chooser/Review case continues to use actual nested search and Review fields (`behavior/topology/layout_tests.rs:568`). The two compiler adaptations in `06ec962` are consistent with installed APIs: explicit `Headless::screenshot` selects the trait's physical-size/scale overload (`behavior/canvas_workspace/tests.rs:268`), and the Review fixture calls the existing boxed event constructor (`behavior/topology/layout_tests.rs:726`). Neither changes evaluator ordering or production behavior.
## Acceptance limits
No compiler, test, renderer, native GUI, Git/index, network, Linux/WSL or VPN action was performed by this reviewer. Root reported the initial two test API errors and the committed adaptations; the corrected compiler run was still underway when this report was prepared. There is no executed pass claim here, and earlier reports establish different bytes.
Root still needs the appropriate exact-union build/test and native acceptance evidence. In particular, operation geometry and prepared pixel assertions do not establish native focus/accessibility, clipping of transformed scene text, all modal geometry or minimum-window usability. The separately owned B4 renderer work and broader phase 1/issue 2/PROJECT_GOAL acceptance are not certified by this bounded integration review. No new blocking source finding is left for this reviewer to hand back.
@@ -0,0 +1,101 @@
# Shared behavior canvas: Windows union gate
Codex-956atf `/root`, 2026-09-11. The integrated camera, permanent palette and
editor overlay pass **729/729 desktop tests** at source
`ff0175488ac57a92d401b00a0e4542582a82d8e9`, with a warning-free compiler and
257.42 seconds of test execution. This is a closed test checkpoint, not native
or release acceptance. The complete OA/SA goal remains unfinished.
## Integration and review
The reviewed isolated candidate `fb1da912c9951f33feed2fad9ad689bc46cd20d0` was
applied from common ancestor `c0e66f94` to root `638d06c`. Integration `6969c82`
retains current parent focus/session recovery, reference chooser and boxed
workflow behavior. Both scenario-parent and canvas focus owners wrap the actual
App view. Existing parent regressions were reconciled with the real Iced
UserInterface/Cache harness rather than obsolete synthetic canvas scroll views.
The [independent union review](release-behavior-canvas-union-review-20260911.md)
found no actionable integration regression against `06ec962`. It independently
checked installed Iced message/action ordering and nested overlay operations;
its source-only verdict does not claim execution. Earlier
[R123 review](release-behavior-canvas-r123-review-20260911.md) and
[candidate source handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/fb1da912c9951f33feed2fad9ad689bc46cd20d0/harness/release-behavior-canvas-r123-source-20260911.md)
retain their respective scopes.
Candidate publication initially rejected because remote `113fb8b` contained the
user's instruction sync. Its only changes were AGENTS/PROJECT_GOAL, already
identical to root. Merging them in the isolated worktree produced `ca19ad2` with
unchanged candidate Rust sources. Normal push and exact remote readback passed;
neither history was force-pushed or overwritten.
## Executed gates and corrections
All commands used Rust/Cargo 1.98.1, Windows gnullvm, task-local LLVM-MinGW and
`CARGO_BUILD_JOBS=1`. Desktop is a **binary** target: use `--bin arcadia-studio`,
not `--lib`. Linux/WSL actions remain user-paused.
| Source | Command after `cargo test --locked -p oa-desktop --bin arcadia-studio` | Result |
| --- | --- | --- |
| `06ec962` | `canvas_workspace::tests -- --test-threads=1 --nocapture` | 2 passed |
| `06ec962` | `production_ -- --test-threads=1 --nocapture` | 22 passed: all 17 new mounted cases plus 5 existing cases |
| `06ec962` | `behavior::scenario::workspace::tests:: -- --test-threads=1 --nocapture` | 21 passed, including all five parent-focus gates |
| `ff01754` | `-- --test-threads=1` | 729 passed, 0 failed/ignored/filtered; 257.42 s |
| `82fd0ac` | `guide::tests -- --test-threads=1` | 3 passed after guide copy refresh |
These scopes overlap and must not be added together. The full test compile took
23.89 seconds. Source `06ec962` corrected two test API errors from the first
compile: explicit Headless screenshot trait selection and the existing boxed
semantic event constructor. `ff01754` narrowed the two concrete App-dependent
Scope methods to crate visibility, removing the resulting traceability visibility
warnings. All 518 full-gate inputs match Git bytes exactly.
The initial mistaken `--lib` invocation failed before compilation. Subsequent
strict all-target Clippy at `82fd0ac` reported one collapsible focus conditional;
`abffa4b` preserves its condition and behavior with a single if. Its next Clippy
run reached the production target and found four obsolete variants: First,
Canvas scroll area, and the two Tools shelves. Therefore **strict Clippy has not
passed on this contribution**, and this record does not certify the subsequent
cleanup. Current ownership and validation belong in
[issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
## Evidence and exact identity
[Archive manifest](experiments/canvas-union-windows-20260911/manifest.json)
identifies 45 exact payloads totaling 1,479,172 bytes, including successful and
failed command logs/results, input manifests and the original 18 changed Rust
inputs used by the `06ec962` compiler before LF normalization. Manifest SHA256:
`FF470BD8160C89B1ECFECE7AAF72AA6A7F4D3D722C69F191FCA07FAC5169695F`.
The `06ec962` raw Rust files differed from their Git blobs only in CRLF/LF;
their original bytes and both hashes are preserved. Before the full gate, those
owned files were normalized to the repository's existing LF policy. The later
518-input manifest is byte-identical to `ff01754`, SHA256
`966B125EC8862D0E48ED98692BC3DC380F0242007EB4C018621A9FDE844562EB`.
The immutable local full-gate test executable is 139,577,856 bytes, SHA256
`63DE2FF7FB4C5E49C1E59378EAE24D77471AF35FADADFAEC5871956E7B2E0D8D`.
Its metadata is archived; the executable itself is not distributed in this
evidence folder. The full log is 83,584 bytes, SHA256
`A6D2E0D39B45246D88F1F5B61117CDB0897B23A0D072238ECA7A5550495BD421`.
The task-local adjacent libunwind DLL is 190,464 bytes, SHA256
`AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A`.
This records copied artifact identity, not a newly captured loaded-module trace.
## Practical limits
The tests exercise actual App widgets, focus operations, drawn focus borders,
camera transforms, stale gestures, caller return and exact source/history. Native
dialogs, subscriptions, OS input/focus, complete accessibility and current GUI
usability remain separate. Forced palette-overflow tests use 1100x420; they do
not prove native overflow at the application's 1100x700 minimum. Picker tests
inject cancellation at the external dialog boundary.
Process free placement, saved scenario lane arrangement, wrapping/operand
transfer, full endpoint manipulation and the peer B4 renderer union remain
outside this camera milestone. The
[Capella comparison](release-capella-direct-manipulation-gap-20260911.md) records
those engineering distinctions. The separately adopted
[allocation drag contract](release-activity-allocation-drag-contract-v2-20260911.md)
and [review](release-activity-allocation-drag-review-20260911.md) address the user's
activity-movement issue; they are not implemented by this canvas gate.
@@ -0,0 +1,94 @@
# Capella direct-manipulation gap review — 2026-09-11
Codex-956atf `/root/capella_process_gap`. Advisory source review under [root's issue 2 scope](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-419); no design ratification or runtime acceptance.
Reviewed Studio commit **638d06c4e33a1f54203b234d0b49c3ecf551d27d**, its actual desktop/core sources, the [prior native Capella benchmark](release-capella-scenario-benchmark-20260910.md), [canvas design](release-unified-behavior-canvas-design-20260911.md), and the isolated [R123 source handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/fb1da912c9951f33feed2fad9ad689bc46cd20d0/harness/release-behavior-canvas-r123-source-20260911.md). Root is integrating changes while this report is written; Studio anchors below deliberately identify the published baseline. The R123 contribution changes presentation/input admission, not the persistent/domain contracts discussed here.
Actual Gitea default branch is `main`; its AGENTS was retrieved through MCP. Issues 1/2 confirm root's integration ownership and peers' B4/lifecycle/header and staged-source/navigation ownership. This review changed only this report, with no compiler, tests, native apps, Git/index changes, Wiki changes, Linux/WSL or VPN actions. Primary Capella source was read at **5537297b071fff809285bc63989f5067d675bc52**. It is a source snapshot, not a claim that the installed Capella 7.1 binary behaves identically.
## New evidence and distinctions
The prior benchmark established point-created activity lifelines and an empty dragged LOOP. It did not establish wrapping existing messages or transferring operands. Source inspection now adds these distinctions:
| Concept | Source-backed fact | Consequence for Studio |
| --- | --- | --- |
| Layout versus a domain edit | Capella exposes layout mode specifically to suppress operations that could change the semantic model, alongside automatic arrangement and pinning. [Diagram Editor][C1] | Preserve the engineer's ability to arrange a drawing confidently. A process node crossing another box or rank must not silently change precedence, decomposition, allocation or ownership. |
| Definition, use and responsibility | Capella's process/chain documentation distinguishes work involvements, involvement links and references to other chains. Allocation of activities to entities/actors and functions to system/actors/components is a separate relationship; the metamodel separately contains child functions. [Chains][C2], [Contextual allocations][C3], [FunctionalAnalysis.ecore][C4] | Four distinct operations exist: arrange a local use; move a definition under a same-kind definition; allocate work to a performer; move a local occurrence between behavioral owners. Nesting must advertise which operation it represents. A process use does not own the referenced activity/function definition. |
| Lane identity and order | Capella InstanceRole represents an instance. Its horizontal reorder handler moves entries in the scenario's `ownedInstanceRoles` list. It does not substitute represented identities. [Interaction.ecore][C5], [DoIRReorderExternalAction][C6] | Persisted view-owned x positions would be a deliberate Studio design choice. Do not call Capella's lane order wholly external to its model. In Studio, lane arrangement can remain behavior-neutral while being fully represented in source/REST. |
| Sequence order and operands | Capella's reorder service updates ordered interaction fragments and message ordering. Its scenario owns interaction fragments; CombinedFragment references operands and carries an operator, while covered lifelines are explicit references. [SequenceDiagramServices][C7], [Interaction.ecore][C5] | Studio's physically nested Fragment → Branch → Occurrence tree is a different representation. Implement Studio transfers against its own parent/list invariants; copying Capella's reorder calls would not be sufficient. A wider Studio frame cannot truthfully assert stored coverage that Studio does not have. |
| Function/activity edge endpoints | Capella involvement links point to specific function involvements, separately from the exchange they involve; sequence links have a different endpoint type. [FunctionalAnalysis.ecore][C4] | An exchange between work uses, a precedence arrow, a global work exchange, and a scenario message are distinct identities and edits. Repeated uses of one definition make this distinction observable. |
The inspected Capella source establishes model/handler behavior, not the usability of every drag path. In particular, no new native claim is made for cross-operand drag or range wrapping.
## Five representative engineering workflows
Run each applicable workflow in OA and SA. The engineering tasks below extend the existing design's falsifiers rather than reopening historical bugs.
| Workflow and intended result | Current observed capability | Remaining gap / boundary |
| --- | --- | --- |
| **1. Prepare a process/chain for a design review.** Place two work uses and a control node around a long alternate route; move one use across the computed rank; save, close, reopen and continue connecting it. | The graph is derived from sequence links/legacy order and fixed rank columns. Creation and guarded connect/reconnect exist. Drag reorder rejects a different column and produces `SetSteps`. [Graph][S1], [process gesture planners][S2] | Authored world placement is absent from this graph's input. Camera improvements do not preserve moved nodes. Movement must write presentation while leaving occurrence parents, ordered steps, guards and links unchanged. Existing layouts without stored positions still need a deterministic usable view. |
| **2. Explain who performs the work.** Show “Receive request” inside an operational entity or the system in an allocation view; separately show its subactivities/subfunctions; show two local uses in a process. | Core already distinguishes parent containment, allocation and represented Action use. Parent admission permits Activity → Activity and Function → Function, while scenario lifelines belong to scenarios. [parent rules][S3], [occurrence model][S4] | This is not evidence of missing allocation semantics. The direct-manipulation requirement is truthful nesting and a clear destination preview: “Arrange use”, “Allocate work” or “Move definition under …”. Dropping a process use over another use must not infer either allocation or decomposition. Full OA/SA acceptance still requires the architecture workflows; this report does not assess their current native usability. |
| **3. Put participants in the engineer's preferred order.** Place two scenario lifelines, name them for their roles, place two different lifelines representing the same work where admitted, and arrange the headers to minimize crossings. | Lifelines have separate IDs/represented references. Scene creation collects child lifelines and assigns uniformly spaced x positions; `Project::children` filters the element BTreeMap's ID order. [lane projection][S5], [children order][S6] | No authored lane order/spacing is consumed or persisted. Storage order should not dictate the only arrangement. Lane drag must retain each lifeline ID, its represented identity, every message endpoint and reuse binding. Changing x is not transferring an action to another lifeline. |
| **4. Refine a response sequence into alternatives.** Wrap a contiguous call/reply group in LOOP, add an alternate operand, move an entire nested fragment within its sequence, then deliberately move a selected sibling range to the other operand. | Clicked root/operand creation and same-parent reorder exist. The reorder planner explicitly rejects a different parent. Core already provides atomic `Batch`, `Reparent` and `SetSteps`, with branch ownership validation. [scenario planner][S7], [command primitives][S8], [branch validation][S9] | There is no range-wrap or explicit operand-transfer event/planner in this inspected canvas contract. This is an authoring gap over existing primitives, not proof that source/API cannot express it. Transfer must update parents and both ordered lists atomically, preserve moved UUIDs, and revalidate call/reply dominance, lifetime and reuse constraints. Copy/delete reconstruction would damage identity. |
| **5. Repair the exact communication use.** In a process, draw an existing OA interaction/SA functional exchange between two chosen work uses. In a scenario, reconnect a message to a compatible lifeline, keeping its values and call correlation. Open the underlying exchange only when changing its definition is intended. | Process direct reconnect edits only a SequenceLink. Exchange creation can produce an exchange contribution card. Crucially, `ContextualLinkPurpose::Exchange` already connects exact local or qualified Action addresses to a matching exchange, and its form creates/edits that relation. It is rendered as assembly incidence. [process planners][S2], [contextual planner][S10], [core endpoint validation][S11] | Do not report that work-to-work exchange edges are unrepresentable. The gap is a direct typed exchange/assembly gesture and endpoint manipulation using that existing contract. The scenario event enum has creation/reorder but no endpoint reconnect event. [scenario event contract][S12] Changing an occurrence endpoint must not silently edit the shared exchange or its correlated reply. |
For workflow 5, SA functional exchange definitions retain both work endpoints and functional port identities; OA interactions use activity endpoints. Participant lifelines resolve through compatible work allocation/roles rather than being interchangeable with function ports. Studio already encodes these distinctions in [the exchange body][S13] and [representation compatibility][S14]. A valid destination preview must use those validators, not merely node kind or screen proximity.
## Candidate approaches and smallest coherent next slice
Criteria follow the user's goal: predictable direct editing, low cognitive load, saved graphical/textual equivalence, exact identity/history, and maintainability.
| Candidate | Benefit | Material cost / rejection reason |
| --- | --- | --- |
| Keep rank/ID-derived arrangement; add more toolbar controls | Smallest implementation | Fails workflows 1 and 3: the chosen placement cannot be saved. Phase 1 is useful infrastructure, not this outcome. |
| Treat all dragging as semantic reorder/reparent | Reuses existing sequence commands | Wrong for a review layout; turns visual intention into behavioral edits. Also cannot express independent process x/y and lane spacing. Reject. |
| Desktop-only placement maps | Fast visible improvement | Loses source/REST parity and reliable reopen/history; two editing representations disagree. Reject. |
| **Owner-bound persisted behavior views** | Saves authored process placement and lane arrangement without changing behavioral order | Requires a reviewed core/source/API layout contract and integration with the in-flight schema work. Recommended. |
**Recommended next slice: make workflows 1 and 3 durable together.** Add an explicitly owner-bound process/chain view and scenario view, reuse the shared camera/admission shell, and provide whole-body process movement plus horizontal lifeline-header placement/movement. Persist only meaningful fields: process occurrence position/size, and scenario lane x/width or a defined lane-order/spacing representation. Do not introduce arbitrary scenario y coordinates in this slice.
The existing `Diagram`/`NodePlacement` machinery is a plausible home, but current [ProjectionKind][S15] lacks a process/scenario owner projection. Adding discriminated owner projections versus a specialized behavior-layout record requires core-owner review; this report does not choose a schema number or claim that every generic placement field has a valid scenario meaning. The contract must specify scope, identity, serialization, validation, delete/reidentify handling, and source/API construction. Multiple saved views must be able to arrange the same owner independently.
For an owner without an authored view, display a deterministic derived scene immediately. Merely opening, inspecting, panning or fitting stays clean. Materialize the view with the first accepted placement edit in one history entry. Automatic fallback handles newly added/unplaced uses; an explicit Auto arrange may replace hand placement as one undoable edit. Adding a semantic edge must not silently rearrange previously placed objects.
Repeated process references remain qualified by caller/reference path; start with moving the local reference block. A nested read-only preview must not become a placement keyed only by the shared definition UUID. Show the exact local occurrence identity in selection/details.
After that slice, workflow 4 is a bounded domain continuation using existing commands, and workflow 5 a typed endpoint continuation using existing contextual/communication validation. If the core layout contract cannot proceed immediately because of shared schema ownership, designing the atomic wrap/transfer planner is useful non-overlapping work; do not substitute a temporary persisted format. Additional Capella fragment operators/coverage semantics remain a separate capability assessment, not silently excluded from the full goal by this four-operator UI.
## Concrete acceptance and counterexamples
These are proposed gates, **not executed results**. Check whole accepted Project plus exact source/history, not only one position field. Exercise actual pointer/keyboard workflows at 1100×700 client and a larger window; readback assertions and screenshots alone do not establish editing usability.
| Case | Positive acceptance | Negative / invariant |
| --- | --- | --- |
| Process authored placement | Move a work-use body across at least two former rank columns, connect it, save/reopen, and observe the authored position. One release creates one layout history entry; Undo/Redo restores complete before/after states. | No step reorder, link/guard/parent/allocation change, implicit legacy-path conversion or shared-definition edit. A click below drag threshold adds no history. |
| View scope and identity | Arrange two saved views of the same owner differently; move two distinct references to the same process independently. Add a new contribution without displacing existing authored positions. | Wrong-owner/layer placements, duplicate placement identities, invalid dimensions/nonfinite coordinates, or bare nested-definition targets are rejected without partial writes. |
| Lane arrangement | Place a new lifeline at a previewed x; move its header past another lane; save/reopen. Same represented target on two lifelines retains two independently arranged lifeline identities where the scenario admits it. | Message senders/receivers, Action/State lifelines, branch steps, representations and ScenarioUse bindings remain identical. Alias/name edits must not reset authored arrangement. |
| Clean/invalid/stale operations | Open a legacy file and pan/Fit without source change. Cancel placement or encounter source-write failure while preserving the accepted model and usable retry state. | Release retained across owner/document/revision/view change, pending source draft or invalidated input lifetime commits nothing. Schema/source/API load/save preserve the same layout; invalid incoming layout is not silently discarded. |
| Wrapping/transfer continuation | Wrapping sibling IDs `[b,c]` within `[a,b,c,d]` yields `[a,fragment,d]`; its operand owns the original b/c IDs in order. Explicit transfer updates both lists/parents in one Batch, and moving a fragment carries its full subtree. | Refuse a partial child fragment, cross-parent range, ancestor into descendant, stale destination, or reply-before-call/branch/lifetime-invalid result. Ordinary drag crossing an operand does not silently transfer. All unrelated siblings, conditions, values and correlations remain identical. |
| Endpoint continuation | Drawing assembly from exact Action A to Action B offers matching exchanges; repeated definitions retain the chosen occurrence addresses. A compatible scenario endpoint edit preserves occurrence ID and all untouched payload fields. | Incompatible work/port/layer/represented participant, wrong message direction or broken call/reply correlation leaves the original intact with an actionable explanation. An assembly edit never creates a precedence relation, and occurrence repair never changes the global exchange implicitly. |
The release remains subject to the full [PROJECT_GOAL](../PROJECT_GOAL.md): OA/SA capability coverage, transitions, source/API parity, guidance, realistic failure recovery, platform verification and independent integrated acceptance. This report identifies the next useful slice and its semantic boundaries; it does not close issue 2 or ratify the whole product.
[C1]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/doc/plugins/org.polarsys.capella.ui.doc/html/04.%20User%20Interface/4.05.%20Diagram%20Editor.mediawiki#L84-L105
[C2]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/doc/plugins/org.polarsys.capella.ui.doc/html/05.%20Diagram%20Management/5.5.%20Creation%20of%20Functional%20Chains%20and%20Physical%20Paths.mediawiki
[C3]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/doc/plugins/org.polarsys.capella.ui.doc/html/07.%20Modelling%20Wizards%20and%20Accelerators/7.2.%20Contextual%20Allocations.mediawiki#L10-L34
[C4]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/m2/plugins/org.polarsys.capella.core.data.def/model/FunctionalAnalysis.ecore#L3381-L3469
[C5]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/m2/plugins/org.polarsys.capella.core.data.def/model/Interaction.ecore
[C6]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/core/plugins/org.polarsys.capella.core.sirius.analysis/src/org/polarsys/capella/core/sirius/analysis/actions/extensions/DoIRReorderExternalAction.java#L30-L54
[C7]: https://github.com/eclipse-capella/capella/blob/5537297b071fff809285bc63989f5067d675bc52/core/plugins/org.polarsys.capella.core.sirius.analysis/src/org/polarsys/capella/core/sirius/analysis/SequenceDiagramServices.java#L163-L291
[S1]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior/topology.rs#L988
[S2]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior/topology/direct.rs#L340
[S3]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/validation.rs#L92
[S4]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/model.rs#L550
[S5]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior.rs#L1304
[S6]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/model.rs#L1828
[S7]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior/scenario.rs#L1067
[S8]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/session.rs#L583
[S9]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/validation.rs#L2284
[S10]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior/topology/contextual.rs#L149
[S11]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/topology_context.rs#L269
[S12]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-desktop/src/behavior/scenario.rs#L98
[S13]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/model.rs#L1117
[S14]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/validation.rs#L1284
[S15]: http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/638d06c4e33a1f54203b234d0b49c3ecf551d27d/crates/oa-core/src/model.rs#L1431