Compare commits

..
400 changed files with 45296 additions and 18226 deletions
-3
View File
@@ -12,9 +12,6 @@
*.png binary
*.jpg binary
*.zip binary
harness/scenario-selection-evidence-20260924/** -text whitespace=cr-at-eol
harness/scenario-group-spacing-evidence-20260924/** -text whitespace=cr-at-eol
harness/scenario-reorder-interaction-evidence-20260924/** -text whitespace=cr-at-eol
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
+96 -79
View File
@@ -6,6 +6,8 @@ mod architecture_authoring_tests;
mod behavior_removal_tests;
mod checkpoints;
#[cfg(test)]
mod context_help_tests;
#[cfg(test)]
mod diagram_focus_tests;
pub(crate) mod diagram_removal;
#[cfg(test)]
@@ -25,8 +27,6 @@ mod review_navigation_tests;
#[cfg(test)]
mod scenario_details_tests;
mod scenario_diagram;
mod scenario_multiselect;
pub(crate) mod scenario_reorder;
mod scenario_spacing;
#[cfg(test)]
mod source_indentation_tests;
@@ -217,14 +217,11 @@ mod direct_scenario_tests {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.visit, context.visit);
assert_eq!(fresh.representation, context.representation);
assert_ne!(
fresh.epoch, context.epoch,
"selection retires older gestures"
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
);
let context = fresh;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
@@ -381,6 +378,10 @@ pub enum Message {
ExactStrings(bool),
Page(Page),
Guide(crate::guide::Event),
ContextHelp(crate::guide::Topic),
CloseContextHelp,
ContextHelpScroll(crate::guide::ContextScroll),
Help,
Traceability(crate::views::traceability::Event),
ReviewLayerTraces,
Behavior(crate::behavior::Event),
@@ -442,7 +443,6 @@ pub enum Message {
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
ScenarioReorder(Box<scenario_reorder::Event>),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
@@ -527,9 +527,9 @@ pub struct App {
/// Home resumes this page; explicit Architecture navigation remains separate.
last_workspace_page: Page,
pub guide: crate::guide::State,
pub context_help: bool,
pub traceability: crate::views::traceability::State,
pub behavior: crate::behavior::State,
pub(crate) scenario_reorder: scenario_reorder::State,
pub data_state: crate::data_view::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
@@ -650,9 +650,9 @@ impl App {
page: Page::Diagram,
last_workspace_page: Page::Diagram,
guide: crate::guide::State::default(),
context_help: false,
traceability: Default::default(),
behavior: Default::default(),
scenario_reorder: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
@@ -1302,6 +1302,11 @@ impl App {
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
if self.context_help {
iced::event::listen_with(crate::guide::context_scroll_key)
} else {
Subscription::none()
},
iced::event::listen_with(|event, _, _| {
matches!(
event,
@@ -1328,7 +1333,7 @@ impl App {
return Some(Message::Escape);
}
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
return Some(Message::Page(Page::Guide));
return Some(Message::Help);
}
if key == keyboard::Key::Named(keyboard::key::Named::F3) {
return Some(Message::SourceNavigation(
@@ -1356,45 +1361,57 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Captured reorder packets must be admitted before shared navigation,
// camera or source boundaries can disturb a newer operation.
if let Message::ScenarioReorder(event) = message {
return self.scenario_reorder_event(*event);
if let Message::ContextHelpScroll(scroll) = message {
return if self.context_help {
crate::guide::context_scroll_task(scroll)
} else {
Task::none()
};
}
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
{
return self.scenario_selection_event(*event);
}
// A delayed Scenario menu cannot retire the current reveal/gesture even
// when it uses the same semantic owner after a representation round trip.
if let Message::Menu(crate::model_menu::Event::Open(request)) = &message
&& request.scenario.is_some()
&& crate::model_menu::Opened::new(self, request.clone(), self.menu_sequence).is_none()
{
return Task::none();
}
if let Message::Menu(crate::model_menu::Event::Invoke(token, _)) = &message
&& let Some(menu) = &self.model_menu
&& menu.request.scenario.is_some()
&& (menu.token != *token || !menu.valid(self))
{
return Task::none();
}
if self.scenario_group_selected()
&& matches!(
&message,
Message::Command(Command::MoveStep(..)) | Message::Delete
// Help owns user input before any canvas/reference fast path can act.
// Passive results may finish; they retain their normal lifetime checks.
if self.context_help
&& !matches!(
message,
Message::Guide(_)
| Message::CloseContextHelp
| Message::Escape
| Message::Request(Pending::Close(_))
| Message::CheckpointWritten(..)
| Message::CheckpointTick
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(..)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(..)
| Message::SavePath(_)
| Message::SourceRecovered(_)
| Message::Exported(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::DiagramUsagesReady(_)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Scrolled(..)
| behavior::scenario::CanvasEvent::Restored(..)
| behavior::scenario::CanvasEvent::Viewport(..)
)
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Scrolled(..)
| behavior::topology::CanvasEvent::Viewport(..)
)
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
{
self.status = "Group reordering and removal need a whole-selection operation. Select one occurrence to edit it individually.".into();
return Task::none();
}
let preview_read_only = self.process_preview_primary().is_some();
let selection_boundary = self.multiselect_boundary();
let scenario_selection_before = self.scenario_context().and_then(|context| {
self.multiselect_snapshot()
.map(|selection| (context, selection))
});
// Reject delayed gestures before they can retire newer input ownership.
if let Message::Connection(
crate::connection_edit::Event::Endpoint(captured, _)
@@ -1563,16 +1580,6 @@ impl App {
}) {
return Task::none();
}
if self.scenario_group_selected()
&& matches!(
event,
behavior::scenario::CanvasEvent::Select(..)
| behavior::scenario::CanvasEvent::Remove(..)
| behavior::scenario::CanvasEvent::Reorder { .. }
)
{
return Task::none();
}
// Passive layout/form reports retain their existing epoch rules;
// camera input uses its separate lifetime below. All other actions
// must match the exact context before shared reveal reconciliation.
@@ -1660,20 +1667,9 @@ impl App {
{
self.reconcile_multiselect();
}
if let Some((context, selection)) = scenario_selection_before
&& self.scenario_context() == Some(context)
&& self.multiselect_snapshot().as_ref() != Some(&selection)
{
// Legacy singleton selection retires old spacing packets. If the
// action already advanced its context, its new navigation/reveal
// owns that context and must not be retired by reconciliation.
self.behavior.scenario.cancel_spacing();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
self.refresh_draft();
}
self.scenario_reorder_reconcile();
task
}
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
@@ -1966,6 +1962,10 @@ impl App {
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.context_help {
self.context_help = false;
return Task::none();
}
if self.hosted.visible {
self.hosted.visible = false;
return Task::none();
@@ -2157,6 +2157,7 @@ impl App {
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
|| self.context_help
|| self.hosted.visible
|| self.workflow.is_some()
|| self.architecture.is_some()
@@ -2224,7 +2225,6 @@ impl App {
}
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
Message::Hosted(event) => return self.hosted_event(event),
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
@@ -2733,9 +2733,6 @@ impl App {
if request.process.is_some() {
request.process = self.topology_context().map(Box::new);
}
if request.scenario.is_some() {
request.scenario = self.scenario_context().map(Box::new);
}
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
}
Message::Menu(crate::model_menu::Event::Close) => {
@@ -2768,11 +2765,6 @@ impl App {
if matches!(*action, Message::DiagramSelection(_))
|| (menu.request.process.is_some()
&& matches!(*action, Message::TopologyCanvas(_)))
|| (menu.request.scenario.is_some()
&& matches!(
*action,
Message::ScenarioCanvas(_) | Message::ScenarioReorder(_)
))
{
return self.update(*action);
}
@@ -2815,10 +2807,6 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.scenario_reorder.session.is_some() {
self.scenario_reorder_cancel();
return Task::none();
}
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
@@ -2866,6 +2854,35 @@ impl App {
);
}
}
Message::ContextHelp(topic) => {
self.guide.update(crate::guide::Event::Topic(topic));
self.context_help = true;
return iced::widget::operation::snap_to(
crate::guide::ARTICLE_SCROLL_ID,
iced::widget::scrollable::RelativeOffset::START,
);
}
Message::CloseContextHelp => {
self.context_help = false;
}
Message::ContextHelpScroll(_) => {}
Message::Help => {
let topic = match self.page {
Page::Diagram => Some(crate::guide::Topic::Nesting),
Page::Behavior => self.behavior.active_owner.and_then(|id| {
self.project()
.elements
.get(&id)
.map(|element| match element.kind() {
Kind::StateMachine => crate::guide::Topic::States,
Kind::Scenario => crate::guide::Topic::Scenarios,
_ => crate::guide::Topic::Chains,
})
}),
_ => None,
};
return self.update(topic.map_or(Message::Page(Page::Guide), Message::ContextHelp));
}
Message::ExactStrings(enabled) => {
if let Some(d) = &mut self.draft {
match d.set_exact(enabled) {
+8 -2
View File
@@ -16,6 +16,7 @@ use std::{
pub const FINISH_EDIT: &str =
"Finish or cancel the current edit before selecting or moving elements.";
const ARRANGE_ARMED: &str = "Arrange next drag armed · presentation only · Esc cancels";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
@@ -635,6 +636,7 @@ impl App {
}
pub(crate) fn allocation_input_owner(&self) -> bool {
self.page == Page::Diagram
&& !self.context_help
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
@@ -699,7 +701,12 @@ impl App {
| Message::DiagramUsagesReady(_)
| Message::DiagramRevealViewport(..)
) {
let clear_armed_status =
self.allocation_drag.arrange_next && self.status == ARRANGE_ARMED;
self.allocation_drag.retire();
if clear_armed_status {
self.status = "Arrange cancelled".into();
}
}
}
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
@@ -841,8 +848,7 @@ impl App {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status =
"Arrange next drag armed · presentation only · Esc cancels".into();
self.status = ARRANGE_ARMED.into();
} else {
self.status = FINISH_EDIT.into();
}
@@ -190,6 +190,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty
)
.await;
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.status, "Arrange cancelled");
mounted
.batch(
&mut f.app,
@@ -179,7 +179,7 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
let token = delete(&mut mounted, &mut app, true).await;
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
assert!(app.delete_pending, "old token does not cancel new dialog");
// Keep selection and epoch unchanged while moving to another Scenario owner.
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
let captured = app.scenario_context().unwrap();
assert_eq!(captured.scenario, owners[0]);
assert_eq!(
@@ -197,28 +197,9 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
assert_eq!(other.epoch, captured.epoch);
assert_eq!(app.selected, uses[0]);
assert!(
!app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the real captured snapshot rejects the changed Scenario destination"
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the independent owner guard must reject an otherwise current token"
);
// Isolate the independent behavior-owner predicate: a current, unqualified
// object request has no old Scenario envelope or destination to reject it.
// This test-only replacement does not refresh the captured behavior owner.
let otherwise_current = crate::model_menu::Opened::new(
&app,
crate::model_menu::Request::object(uses[0], Point::ORIGIN),
token,
)
.unwrap();
assert!(otherwise_current.valid(&app));
let target = app.delete_target.as_ref().unwrap();
assert_eq!(target.selection, app.selected);
assert_eq!(target.placement, None);
assert_eq!(app.selected_node, None);
assert_eq!(target.behavior_owner, Some(owners[0]));
assert_eq!(diagram_removal::behavior_owner(&app), Some(owners[1]));
assert!(app.delete_pending);
assert!(!app.behavior.has_pending() && !app.source_dirty() && !app.draft_dirty());
app.delete_target.as_mut().unwrap().snapshot = otherwise_current;
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
assert_eq!(app.document.bundle(), &original);
assert!(!app.delete_pending && app.delete_target.is_none());
@@ -0,0 +1,723 @@
//! Context help is a reading overlay, not a workspace navigation or edit.
use super::*;
use crate::diagram_usage::Target;
use crate::guide::Topic;
fn send(app: &mut App, message: Message) {
// Do not poll I/O tasks: exercise the same admission boundary as the UI.
let _ = app.update(message);
}
fn architecture() -> (App, Id, Id, Id) {
let mut project = Project::empty();
let a = project
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Sa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
let a = project.place(diagram, a, Position { x: 20., y: 20. });
let b = project.place(diagram, b, Position { x: 400., y: 20. });
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
(app, diagram, a, b)
}
fn choose(app: &mut App, placement: Id, choice: multiselect::Choice) {
let context = app.multiselect_context().unwrap();
send(
app,
Message::DiagramSelection(multiselect::Event::Choose(
context,
Target::Placement(placement),
choice,
)),
);
}
#[test]
fn context_help_preserves_group_property_draft_camera_and_model() {
let (mut app, diagram, a, b) = architecture();
choose(&mut app, a, multiselect::Choice::Replace);
choose(&mut app, b, multiselect::Choice::Toggle);
send(
&mut app,
Message::Field("/name".into(), "Uncommitted name 雨".into()),
);
assert!(app.draft_dirty());
app.zoom = 0.73;
app.pan = iced::Vector::new(31., -57.);
let selected = app.selected;
let selection = app.multiselect_snapshot().unwrap().targets().to_vec();
assert_eq!(selection.len(), 2);
let draft = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let before = app.document.snapshot();
let camera = (app.zoom, app.pan);
send(&mut app, Message::ContextHelp(Topic::Nesting));
assert!(app.context_help);
for message in [
Message::Page(Page::Catalog),
Message::Select(diagram),
Message::Apply,
Message::RevertProperties,
Message::Undo,
Message::Delete,
Message::Field("/name".into(), "Background mutation".into()),
] {
send(&mut app, message);
}
assert!(app.context_help);
send(
&mut app,
Message::Guide(crate::guide::Event::Topic(Topic::Exchanges)),
);
assert_eq!(app.guide.topic, Topic::Exchanges);
send(&mut app, Message::CloseContextHelp);
assert!(!app.context_help);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.selected, selected);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.as_slice()
);
assert_eq!((app.zoom, app.pan), camera);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn context_help_escape_preserves_pending_behavior_form_and_blocks_submit() {
use crate::behavior::states;
let mut project = Project::empty();
let system = project
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(state));
send(&mut app, Message::Page(Page::Behavior));
send(
&mut app,
Message::Behavior(behavior::Event::States(states::Event::Start(
machine,
states::Tool::Transition,
state,
))),
);
send(
&mut app,
Message::Behavior(behavior::Event::States(states::Event::Name(
"Pending transition 雨".into(),
))),
);
assert!(app.behavior.has_pending());
let draft = app.behavior.recovery_snapshot().unwrap();
let before = app.document.snapshot();
let selected = app.selected;
send(&mut app, Message::Help);
assert!(app.context_help);
assert_eq!(app.guide.topic, Topic::States);
for event in [
states::Event::Cancel,
states::Event::Submit,
states::Event::Name("Lost".into()),
] {
send(&mut app, Message::Behavior(behavior::Event::States(event)));
}
send(&mut app, Message::Page(Page::Catalog));
send(&mut app, Message::Escape);
assert!(!app.context_help);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, selected);
assert_eq!(app.behavior.active_owner, Some(machine));
assert!(app.behavior.has_pending());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn context_help_retains_source_draft_and_rejects_background_source_input() {
let (mut app, diagram, _, _) = architecture();
send(&mut app, Message::Page(Page::Text));
send(
&mut app,
Message::SourceEdit(text_editor::Action::SelectAll),
);
send(
&mut app,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new("invalid unfinished source 雨".into()),
))),
);
assert!(app.source_dirty());
send(&mut app, Message::Page(Page::Diagram));
let source = app.source_editor.text();
let version = app.source_version;
let before = app.document.snapshot();
send(&mut app, Message::Help);
assert!(app.context_help);
assert_eq!(app.guide.topic, Topic::Nesting);
for message in [
Message::ApplySource,
Message::RevertSource,
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
Arc::new("overwrite".into()),
))),
] {
send(&mut app, message);
}
send(&mut app, Message::Escape);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(diagram));
assert!(app.source_dirty());
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.source_version, version);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn context_help_f1_selects_scenario_topic_without_leaving_the_workspace() {
let fixture = behavior::scenario::direct_tests::fixture();
let mut app = App::with_project(fixture.project);
send(&mut app, Message::Select(fixture.owner));
send(&mut app, Message::Page(Page::Behavior));
let before = app.document.snapshot();
send(&mut app, Message::Help);
assert!(app.context_help);
assert_eq!(app.guide.topic, Topic::Scenarios);
assert_eq!(app.page, Page::Behavior);
assert!(app.scenario_focus_owner().is_none());
assert!(!behavior::canvas_workspace::keyboard_owner_enabled(&app));
send(&mut app, Message::Escape);
assert!(!app.context_help);
assert_eq!(app.selected, fixture.owner);
assert_eq!(app.behavior.active_owner, Some(fixture.owner));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn context_help_disables_allocation_input_including_early_work_drag_dispatch() {
let mut project = Project::empty();
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let mut app = App::with_project(project);
assert!(app.allocation_input_owner());
send(
&mut app,
Message::WorkDrag(allocation_drag::Event::ArrangeNext),
);
assert!(app.allocation_drag.arrange_next);
send(&mut app, Message::Help);
assert!(app.context_help);
assert!(!app.allocation_input_owner());
assert!(!app.allocation_drag.arrange_next);
assert_eq!(app.status, "Arrange cancelled");
let before = app.document.snapshot();
send(
&mut app,
Message::WorkDrag(allocation_drag::Event::ArrangeNext),
);
assert!(!app.allocation_drag.arrange_next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
send(&mut app, Message::Escape);
assert!(app.allocation_input_owner());
}
#[test]
fn context_help_rejects_a_reference_picker_open_queued_from_the_inspector() {
let mut project = Project::empty();
let owner = project
.add_element(Kind::DataType, Layer::Oa, "Record", None)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
let mut app = App::with_project(project);
send(&mut app, Message::Select(owner));
let request = app.draft.as_ref().unwrap().reference_open(
app.project(),
"/body/Oa/Shared/DataType/definition/Record/supertype",
iced::Rectangle {
x: 790.,
y: 400.,
width: 278.,
height: 32.,
},
);
let before = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
send(&mut app, Message::ContextHelp(Topic::Nesting));
assert!(app.context_help);
send(&mut app, Message::FieldReferenceOpen(request));
assert!(!app.reference_input_active());
send(&mut app, Message::Escape);
assert!(!app.context_help);
assert_eq!(
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
before
);
}
#[test]
fn context_help_accepts_live_process_viewport_and_rejects_stale_measurements() {
use crate::behavior::topology::CanvasEvent;
let mut project = Project::empty();
let owner = project
.add_element(Kind::Process, Layer::Oa, "Response process", None)
.unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::Select(owner));
send(&mut app, Message::Page(Page::Behavior));
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(CanvasEvent::Viewport(context, iced::Size::new(640., 480.))),
);
let initial = app.behavior.topology.camera_boundary();
let before = app.document.snapshot();
send(&mut app, Message::Help);
assert!(app.context_help);
assert_eq!(app.guide.topic, Topic::Chains);
let resized = iced::Size::new(420., 330.);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(CanvasEvent::Viewport(context, resized)),
);
let measured = app.behavior.topology.camera_boundary();
assert!(
measured != initial,
"Help must admit passive viewport measurements"
);
assert!(app.context_help);
let epoch = app.canvas_epoch;
for field in 0..3 {
let mut stale = app.topology_context().unwrap();
match field {
0 => stale.document = Id::new_v4(),
1 => stale.owner = Id::new_v4(),
_ => stale.revision += 1,
}
send(
&mut app,
Message::TopologyCanvas(CanvasEvent::Viewport(stale, iced::Size::new(999., 777.))),
);
assert!(app.behavior.topology.camera_boundary() == measured);
assert_eq!(app.canvas_epoch, epoch);
}
send(&mut app, Message::CloseContextHelp);
assert!(!app.context_help);
assert!(app.behavior.topology.camera_boundary() == measured);
let context = app.topology_context().unwrap();
let epoch = app.canvas_epoch;
send(
&mut app,
Message::TopologyCanvas(CanvasEvent::Viewport(context, resized)),
);
assert_eq!(
app.canvas_epoch, epoch,
"The size observed behind Help is already current"
);
let context = app.topology_context().unwrap();
send(
&mut app,
Message::TopologyCanvas(CanvasEvent::Viewport(context, iced::Size::new(700., 500.))),
);
assert!(app.behavior.topology.camera_boundary() != measured);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, owner);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
async fn help_key(
mounted: &mut crate::behavior::canvas_workspace::tests::Mounted,
app: &mut App,
event: iced::Event,
) -> Vec<Message> {
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&event),
iced::mouse::Cursor::Unavailable,
);
super::escape::tests::subscriptions(app, event, statuses[0], &mut messages);
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
messages
}
#[tokio::test]
async fn mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret() {
use crate::behavior::canvas_workspace::tests::{Mounted, character, key};
use iced::keyboard::key::Named;
let (mut app, diagram, a, _) = architecture();
let element = app.project().diagrams[&diagram].nodes[&a].element;
send(&mut app, Message::Select(element));
let before = app.document.snapshot();
let mut mounted = Mounted::new(&mut app).await;
let field = mounted
.geometry(&app)
.text_inputs
.into_iter()
.filter(|bounds| bounds.x > app.window_size.width - 320.)
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible Inspector Name input");
mounted.click(&mut app, field.center()).await;
help_key(&mut mounted, &mut app, key(Named::Home)).await;
for character_value in "DRAFT ".chars() {
help_key(
&mut mounted,
&mut app,
character(&character_value.to_string()),
)
.await;
}
help_key(&mut mounted, &mut app, key(Named::ArrowLeft)).await;
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"DRAFT Send"
);
let camera = (app.zoom, app.pan);
help_key(&mut mounted, &mut app, key(Named::F1)).await;
assert!(app.context_help);
let article = iced::advanced::widget::Id::new(crate::guide::ARTICLE_SCROLL_ID);
let geometry = mounted.geometry(&app);
let background_scrolls = geometry
.scrolls
.iter()
.filter(|(id, _)| **id != article)
.map(|(id, (_, offset))| (id.clone(), *offset))
.collect::<Vec<_>>();
assert_eq!(geometry.scrolls[&article].1.y, 0.);
help_key(&mut mounted, &mut app, key(Named::PageDown)).await;
let down = mounted.geometry(&app).scrolls[&article].1.y;
assert!(down > 0., "PageDown scrolls the mounted Help article");
help_key(&mut mounted, &mut app, key(Named::End)).await;
let end = mounted.geometry(&app).scrolls[&article].1.y;
assert!(end > down);
help_key(&mut mounted, &mut app, key(Named::PageDown)).await;
assert_eq!(mounted.geometry(&app).scrolls[&article].1.y, end);
help_key(&mut mounted, &mut app, key(Named::PageUp)).await;
assert!(mounted.geometry(&app).scrolls[&article].1.y < end);
help_key(&mut mounted, &mut app, key(Named::Home)).await;
assert_eq!(mounted.geometry(&app).scrolls[&article].1.y, 0.);
help_key(&mut mounted, &mut app, key(Named::ArrowDown)).await;
assert_eq!(mounted.geometry(&app).scrolls[&article].1.y, 40.);
help_key(&mut mounted, &mut app, key(Named::ArrowUp)).await;
help_key(&mut mounted, &mut app, key(Named::PageUp)).await;
let geometry = mounted.geometry(&app);
assert_eq!(geometry.scrolls[&article].1.y, 0.);
for (id, offset) in background_scrolls {
assert_eq!(
geometry.scrolls[&id].1, offset,
"Help only scrolls its own article"
);
}
for character_value in "IGNORED".chars() {
help_key(
&mut mounted,
&mut app,
character(&character_value.to_string()),
)
.await;
}
let messages = mounted
.batch(
&mut app,
&[
iced::Event::InputMethod(iced::advanced::input_method::Event::Preedit(
"雨".into(),
None,
)),
iced::Event::InputMethod(iced::advanced::input_method::Event::Commit("雨".into())),
],
iced::mouse::Cursor::Unavailable,
)
.await;
assert!(!messages.iter().any(|m| matches!(m, Message::Field(..))));
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"DRAFT Send"
);
help_key(&mut mounted, &mut app, key(Named::Escape)).await;
assert!(!app.context_help);
help_key(&mut mounted, &mut app, character("X")).await;
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"DRAFTX Send",
"Return keeps the actual mid-string caret without refocusing a guessed field"
);
assert_eq!((app.zoom, app.pan), camera);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let messages = help_key(&mut mounted, &mut app, key(Named::PageDown)).await;
assert!(
!messages
.iter()
.any(|m| matches!(m, Message::ContextHelpScroll(_)))
);
assert!(
iced_runtime::task::into_stream(
app.update(Message::ContextHelpScroll(crate::guide::ContextScroll::End,))
)
.is_none(),
"A late Help scroll cannot operate after closing"
);
}
#[tokio::test]
async fn mounted_context_help_observes_modifier_release_before_plain_canvas_click() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
keyboard::{Event, Modifiers, key::Named},
mouse,
};
let (mut app, diagram, a, b) = architecture();
choose(&mut app, a, multiselect::Choice::Replace);
app.window_size = iced::Size::new(2000., 1400.);
let mut mounted = Mounted::new(&mut app).await;
app.zoom = 1.;
app.pan = iced::Vector::ZERO;
app.fit_to_view = false;
mounted.settle(&mut app).await;
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(Event::ModifiersChanged(
Modifiers::SHIFT,
))],
mouse::Cursor::Unavailable,
)
.await;
help_key(&mut mounted, &mut app, key(Named::F1)).await;
assert!(app.context_help);
mounted
.batch(
&mut app,
&[iced::Event::Keyboard(Event::ModifiersChanged(
Modifiers::empty(),
))],
mouse::Cursor::Unavailable,
)
.await;
help_key(&mut mounted, &mut app, key(Named::Escape)).await;
let bounds = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let node = projection::project_view(app.project(), diagram)
.nodes
.into_iter()
.find(|node| node.id == b)
.unwrap();
let at = iced::Point::new(
bounds.x + node.position.x + 40.,
bounds.y + node.position.y + 35.,
);
mounted.click(&mut app, at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::Placement(b)],
"Shift released during Help must not add the next card to the previous selection"
);
}
#[tokio::test]
async fn mounted_context_help_retires_held_pan_without_replaying_its_release() {
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{keyboard::key::Named, mouse};
let (mut app, _, _, _) = architecture();
let mut mounted = Mounted::new(&mut app).await;
let canvas = mounted
.geometry(&app)
.bounds(crate::allocation_input::CANVAS_ID);
let start = canvas.center();
let moved = start + iced::Vector::new(20., 10.);
mounted
.batch(
&mut 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: moved }),
],
mouse::Cursor::Available(moved),
)
.await;
let camera = (app.zoom, app.pan);
let before = app.document.snapshot();
help_key(&mut mounted, &mut app, key(Named::F1)).await;
assert!(app.context_help);
mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Middle,
))],
mouse::Cursor::Unavailable,
)
.await;
help_key(&mut mounted, &mut app, key(Named::Escape)).await;
let next = moved + iced::Vector::new(90., 50.);
let messages = mounted
.batch(
&mut app,
&[iced::Event::Mouse(mouse::Event::CursorMoved {
position: next,
})],
mouse::Cursor::Available(next),
)
.await;
assert!(!messages.iter().any(|m| matches!(m, Message::Pan(_))));
assert_eq!(
(app.zoom, app.pan),
camera,
"Moving without a held button cannot resume the pre-Help pan"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[tokio::test]
async fn context_help_barrier_retires_text_selection_drag_without_changing_the_caret() {
use iced::advanced::{Layout, layout, widget};
use iced::{Element, Event, Rectangle, mouse};
type InputState = iced::widget::text_input::State<
<iced::Renderer as iced::advanced::text::Renderer>::Paragraph,
>;
fn input(open: bool) -> Element<'static, Message> {
crate::guide::protect_workspace(
open,
iced::widget::text_input("Name", "A long retained property name")
.on_input(|value| Message::Field("/name".into(), value))
.width(400)
.padding(8)
.into(),
)
}
fn dispatch(
root: &mut Element<'_, Message>,
tree: &mut widget::Tree,
node: &layout::Node,
renderer: &iced::Renderer,
event: Event,
cursor: mouse::Cursor,
) -> Vec<Message> {
let mut messages = Vec::new();
root.as_widget_mut().update(
tree,
&event,
Layout::new(node),
cursor,
renderer,
&mut iced::advanced::clipboard::Null,
&mut iced::advanced::Shell::new(&mut messages),
&Rectangle::new(iced::Point::ORIGIN, iced::Size::new(400., 60.)),
);
messages
}
let mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await;
let renderer = &mounted.renderer;
let mut root = input(false);
let mut tree = widget::Tree::new(root.as_widget());
let node = root.as_widget_mut().layout(
&mut tree,
renderer,
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(400., 60.)),
);
let start = iced::Point::new(20., 18.);
let end = iced::Point::new(110., 18.);
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: end },
] {
let cursor = if matches!(event, mouse::Event::CursorMoved { position } if position == end) {
mouse::Cursor::Available(end)
} else {
mouse::Cursor::Available(start)
};
dispatch(
&mut root,
&mut tree,
&node,
renderer,
Event::Mouse(event),
cursor,
);
}
let caret = tree.children[0].state.downcast_ref::<InputState>().cursor();
assert!(
tree.children[0]
.state
.downcast_ref::<InputState>()
.is_focused()
);
root = input(true);
tree.diff(root.as_widget());
let messages = dispatch(
&mut root,
&mut tree,
&node,
renderer,
Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
mouse::Cursor::Unavailable,
);
assert!(
messages.is_empty(),
"Gesture retirement discards release actions"
);
dispatch(
&mut root,
&mut tree,
&node,
renderer,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
mouse::Cursor::Unavailable,
);
root = input(false);
tree.diff(root.as_widget());
let later = iced::Point::new(300., 18.);
dispatch(
&mut root,
&mut tree,
&node,
renderer,
Event::Mouse(mouse::Event::CursorMoved { position: later }),
mouse::Cursor::Available(later),
);
let state = tree.children[0].state.downcast_ref::<InputState>();
assert!(state.is_focused());
assert_eq!(
state.cursor(),
caret,
"An unheld pointer no longer extends the pre-Help text selection"
);
}
+4 -58
View File
@@ -1,4 +1,4 @@
//! Keep overlay ownership ahead of cancellation and captured reorder Apply.
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
//!
//! Iced skips root.update when an overlay captures an event. Keeping this
//! wrapper present even when inactive preserves the child's widget state. A
@@ -14,7 +14,8 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
content: Element<'a, Message, Theme, R>,
) -> Element<'a, Message, Theme, R> {
let modal = app.text_workspace.menu().is_none()
&& (app.behavior.creation.has_pending()
&& (app.context_help
|| app.behavior.creation.has_pending()
|| app.diagram_usages.is_some()
|| app.behavior.source_active()
|| (app.page == Page::Text
@@ -24,18 +25,9 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
&& app.architecture.is_none()));
Element::new(Bridge {
content,
reorder_active: app.scenario_reorder_enabled() && app.scenario_reorder.session.is_some(),
reorder_apply: crate::behavior::scenario::reorder_view::apply_message(app),
messages: [
architecture_authoring::captured_escape_message(app),
modal.then_some(Message::Escape).or_else(|| {
app.scenario_reorder.session.as_ref().map(|session| {
crate::behavior::scenario::reorder_view::message(
session,
super::scenario_reorder::Intent::Cancel,
)
})
}),
modal.then_some(Message::Escape),
],
})
}
@@ -43,8 +35,6 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
struct Bridge<'a, R> {
content: Element<'a, Message, Theme, R>,
messages: [Option<Message>; 2],
reorder_active: bool,
reorder_apply: Option<Message>,
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
@@ -98,43 +88,6 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if self.reorder_active
&& matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Enter), modifiers, .. })
if modifiers.control() && !modifiers.alt() && !modifiers.shift() && !modifiers.logo())
{
// A picker may have opened earlier in this same input batch, before
// App state can invalidate the captured reorder action.
if let Some(element) = self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
Vector::ZERO,
) {
let mut current = overlay::Nested::new(element);
let node = current.layout(renderer, viewport.size());
current.update(
event,
Layout::new(&node),
cursor,
renderer,
clipboard,
shell,
);
shell.capture_event();
return;
}
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { repeat: false, .. })
) && let Some(message) = &self.reorder_apply
{
shell.publish(message.clone());
}
shell.capture_event();
return;
}
let escape = matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
@@ -168,13 +121,6 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
if shell.is_event_captured() {
return;
}
if self.reorder_active
&& let Some(cancel) = &self.messages[1]
{
shell.publish(cancel.clone());
shell.capture_event();
return;
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
-24
View File
@@ -100,7 +100,6 @@ impl App {
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
self.topology_context(),
self.behavior.topology.selection_address(),
self.scenario_context(),
)
}
pub(crate) fn multiselect_context(&self) -> Option<Context> {
@@ -110,9 +109,6 @@ impl App {
if let Some(current) = self.topology_context() {
return Some(process_context(current));
}
if let Some(current) = self.scenario_context() {
return Some(crate::behavior::scenario::selection::context(current));
}
let current = self.state_context()?;
crate::behavior::state_edit::validate_context(self.project(), current, current)
.ok()?;
@@ -123,14 +119,6 @@ impl App {
}
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
if let Some(context) = self.scenario_context() {
return crate::behavior::scenario::selection::visible(
self.project(),
context,
self.selected,
)
.contains(target);
}
if let Some(context) = self.topology_context() {
return self
.behavior
@@ -164,10 +152,6 @@ impl App {
}
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
// Scenario input additionally needs its representation and visit.
if self.scenario_context().is_some() {
return false;
}
let Some(current) = self.multiselect_context() else {
return false;
};
@@ -235,10 +219,6 @@ impl App {
}
pub(crate) fn reconcile_multiselect(&mut self) {
if self.scenario_context().is_some() {
self.reconcile_scenario_multiselect();
return;
}
if self.topology_context().is_some() {
self.reconcile_process_multiselect();
return;
@@ -304,10 +284,6 @@ impl App {
}
pub(super) fn inspect_multiselect_primary(&mut self) {
if self.scenario_context().is_some() {
self.inspect_scenario_multiselect_primary();
return;
}
if self.topology_context().is_some() {
self.inspect_process_multiselect_primary();
return;
@@ -1,135 +0,0 @@
//! Exact rendered Scenario selection, independent of semantic movement permission.
use super::*;
use crate::behavior::scenario::{self, selection};
use crate::diagram_selection::Selection;
use crate::diagram_usage::Target;
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn scenario_selection_envelope(&self, event: &selection::Event) -> bool {
self.scenario_context() == Some(event.context)
&& self.diagram_selection.as_ref().is_some_and(|selected| {
selected.matches(&event.snapshot, selection::context(event.context))
})
}
pub(super) fn reconcile_scenario_multiselect(&mut self) {
let Some(context) = self.scenario_context() else {
return;
};
let scope = selection::context(context).scope;
let visible = selection::visible(self.project(), context, self.selected);
let old = self
.diagram_selection
.as_ref()
.filter(|s| s.scope() == scope)
.and_then(Selection::primary)
.cloned();
let legacy = Target::ScenarioObject(self.selected);
let held = old.as_ref() == Some(&legacy);
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
let selected = self
.diagram_selection
.get_or_insert_with(|| Selection::new(scope));
selected.reconcile(scope, |target| visible.contains(target));
if held || lost {
if selected.primary() != old.as_ref() {
self.inspect_scenario_multiselect_primary();
}
} else if selected.primary() != Some(&legacy) {
if visible.contains(&legacy) {
selected.replace(legacy, |target| visible.contains(target));
} else {
selected.clear();
}
}
}
pub(super) fn inspect_scenario_multiselect_primary(&mut self) {
let Some(context) = self.scenario_context() else {
return;
};
self.selected = match self.diagram_selection.as_ref().and_then(Selection::primary) {
Some(Target::ScenarioObject(id)) => *id,
_ => context.scenario,
};
self.selected_node = None;
self.selected_bend = None;
self.selected_attachment = None;
self.behavior.diagram.placement = None;
self.diagram_navigation_focus = None;
self.refresh_draft();
}
/// This is dispatched before shared navigation/gesture retirement. Invalid
/// captures and unchanged selection must not disturb a newer input owner.
pub(super) fn scenario_selection_event(&mut self, event: selection::Event) -> Task<Message> {
use multiselect::Choice;
use selection::Intent;
if !self.scenario_selection_envelope(&event) {
return Task::none();
}
let visible = selection::visible(self.project(), event.context, self.selected);
if !event.snapshot.targets().iter().all(|t| visible.contains(t)) {
return Task::none();
}
if !diagram_removal::enabled(self)
|| self.source_rename.open()
|| self.behavior.scenario.canvas_tool(event.context.scenario)
!= scenario::CanvasTool::Select
{
self.status =
"Finish or cancel the active edit before changing the Scenario selection.".into();
return Task::none();
}
let selected = self.diagram_selection.as_mut().expect("admitted selection");
let valid = |target: &Target| visible.contains(target);
let changed = match &event.intent {
Intent::Choose(target, choice) => {
if !valid(target) {
return Task::none();
}
match choice {
Choice::Replace => selected.replace(target.clone(), valid),
Choice::Add => selected.add(target.clone(), valid),
Choice::Remove => selected.remove(target),
Choice::Toggle => selected.toggle(target.clone(), valid),
Choice::Inspect => selected.set_primary(target),
}
}
Intent::Collapse(target) => {
if !selected.contains(target) || !valid(target) {
return Task::none();
}
selected.replace(target.clone(), valid)
}
Intent::Clear => selected.clear(),
Intent::Refuse(reason) => {
self.status = reason.clone();
return Task::none();
}
};
if changed {
self.scenario_reorder_cancel();
self.source_interaction();
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
scenario::CanvasEvent::Selection(Box::new(event)),
));
self.behavior.scenario.cancel_spacing();
// Every generation change, including a secondary membership ABA,
// retires old singleton spacing packets as well as group captures.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.inspect_scenario_multiselect_primary();
}
Task::none()
}
pub(super) fn scenario_group_selected(&self) -> bool {
self.scenario_context().is_some()
&& self
.multiselect_snapshot()
.is_some_and(|s| s.targets().len() > 1)
}
}
@@ -1,379 +0,0 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, selection::Intent, spacing};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Surface;
use iced::{Size, Vector};
fn setup(saved: bool) -> (App, Id, Id, Id, Id) {
let (document, owner, diagram, lane, message) = spacing::controller::tests::fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
if saved {
let _ = app.update(Message::Diagram(super::super::Choice {
id: diagram,
label: "Saved".into(),
}));
} else {
let _ = app.update(Message::Page(Page::Behavior));
}
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(900., 600.),
)));
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
(app, owner, diagram, lane, message)
}
fn capture(app: &App, intent: Intent) -> selection::Event {
selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent,
}
}
fn send(app: &mut App, event: selection::Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
send(
app,
capture(app, Intent::Choose(Target::ScenarioObject(id), choice)),
);
}
fn inert(app: &mut App, messages: Vec<Message>, preserve_status: bool) {
let model = app.project().clone();
let bundle = app.document.bundle().clone();
let source = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (app.document.can_undo(), app.document.can_redo());
let selected = app.multiselect_snapshot();
let primary = app.selected;
let context = app.scenario_context();
let camera = app.camera_boundary();
let status = app.status.clone();
let spacing = (
app.behavior.scenario.spacing_stamp(),
app.behavior.scenario.spacing_token(),
);
for message in messages {
let _ = app.update(message);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(app.document.revision(), revision);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(app.multiselect_snapshot(), selected);
assert_eq!(app.selected, primary);
assert_eq!(app.scenario_context(), context);
assert!(app.camera_boundary() == camera);
assert_eq!(
(
app.behavior.scenario.spacing_stamp(),
app.behavior.scenario.spacing_token()
),
spacing
);
if preserve_status {
assert_eq!(app.status, status);
}
}
}
fn packet(event: selection::Event) -> Message {
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event)))
}
#[test]
fn exact_scenario_membership_primary_and_noops_preserve_model_source_and_history() {
for saved in [false, true] {
let (mut app, owner, diagram, lane, message) = setup(saved);
let model = app.project().clone();
let bundle = app.document.bundle().clone();
let revision = app.document.revision();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
choose(&mut app, message, multiselect::Choice::Inspect);
let snapshot = app.multiselect_snapshot().unwrap();
assert_eq!(
snapshot.targets(),
&[
Target::ScenarioObject(message),
Target::ScenarioObject(lane)
]
);
assert_eq!(snapshot.primary(), Some(&Target::ScenarioObject(message)));
assert_eq!(
snapshot.context().scope.surface,
if saved {
Surface::Saved(diagram)
} else {
Surface::Scenario(owner)
}
);
assert_eq!(app.selected, message);
let repeated = capture(
&app,
Intent::Choose(Target::ScenarioObject(message), multiselect::Choice::Add),
);
inert(&mut app, vec![packet(repeated)], true);
let stale_collapse = capture(&app, Intent::Collapse(Target::ScenarioObject(lane)));
choose(&mut app, lane, multiselect::Choice::Remove);
choose(&mut app, lane, multiselect::Choice::Add);
choose(&mut app, message, multiselect::Choice::Inspect);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
snapshot.primary()
);
inert(&mut app, vec![packet(stale_collapse)], true);
assert_eq!(app.project(), &model);
assert_eq!(app.document.bundle(), &bundle);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn same_owner_representation_round_trip_clears_group_and_rejects_old_packets() {
let (mut app, owner, diagram, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let old = capture(&app, Intent::Clear);
let snapshot = app.multiselect_snapshot().unwrap();
let mut second = app.project().diagrams[&diagram].clone();
let other = Id::new_v4();
second.id = other;
second.alias = "other_saved".into();
second.name = "Other saved".into();
// Navigation itself is tested against a model with both saved identities.
let mut project = app.project().clone();
project.diagrams.insert(other, second);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(super::super::Choice {
id: diagram,
label: "A".into(),
}));
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let first = capture(&app, Intent::Clear);
let first_visit = first.context.visit;
for representation in [
Representation::Saved { diagram: other },
Representation::Derived { owner },
Representation::Saved { diagram },
] {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
context,
representation,
)));
assert!(app.multiselect_snapshot().unwrap().targets().len() <= 1);
}
assert_ne!(app.scenario_context().unwrap().visit, first_visit);
choose(&mut app, message, multiselect::Choice::Replace);
choose(&mut app, lane, multiselect::Choice::Add);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
snapshot.targets()
);
inert(&mut app, vec![packet(first), packet(old)], true);
}
#[test]
fn unsupported_targets_and_bare_generic_packets_are_inert_before_side_effects() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let current = app.multiselect_context().unwrap();
let events = [
Target::Whole,
Target::Placement(message),
Target::ScenarioObject(Id::new_v4()),
]
.into_iter()
.map(|target| {
packet(capture(
&app,
Intent::Choose(target, multiselect::Choice::Replace),
))
})
.chain([
Message::DiagramSelection(multiselect::Event::Clear(current)),
Message::DiagramSelection(multiselect::Event::Choose(
current,
Target::ScenarioObject(message),
multiselect::Choice::Replace,
)),
])
.collect();
inert(&mut app, events, true);
}
#[test]
fn group_rejects_legacy_singleton_remove_reorder_and_index_arrows() {
let (mut app, owner, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let context = app.scenario_context().unwrap();
inert(
&mut app,
vec![
Message::ScenarioCanvas(CanvasEvent::Select(context, message)),
Message::ScenarioCanvas(CanvasEvent::Remove(context, lane)),
Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: message,
slot: scenario::Slot {
parent: owner,
before: None,
},
}),
Message::Command(Command::MoveStep(owner, 0, 1)),
Message::Delete,
],
false,
);
assert!(!app.delete_pending);
}
#[test]
fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let old = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
choose(&mut app, lane, multiselect::Choice::Add);
let group_begin = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(group_begin)));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
assert_eq!(
app.canvas_epoch, epoch,
"pending click retains its admitted lifetime"
);
assert_eq!(
app.behavior.scenario.spacing_token(),
Some(group_begin.token())
);
choose(&mut app, lane, multiselect::Choice::Remove);
assert!(app.behavior.scenario.spacing_token().is_none());
inert(
&mut app,
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
true,
);
let current = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let spacing::Event::Begin { press_screen, .. } = current else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
inert(
&mut app,
vec![
Message::ScenarioCanvas(CanvasEvent::Spacing(old)),
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Cancel {
stamp: old.stamp(),
token: old.token(),
})),
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish {
stamp: old.stamp(),
token: old.token(),
screen: press_screen + Vector::new(0., 25.),
})),
],
true,
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Finish {
stamp: current.stamp(),
token: current.token(),
screen: press_screen + Vector::new(0., 25.),
},
)));
assert!(app.document.can_undo());
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(message)]
);
}
#[test]
fn legacy_singleton_selection_aba_retires_old_spacing_begin() {
for canvas in [false, true] {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let old = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
for id in [lane, message] {
let event = if canvas {
Message::ScenarioCanvas(CanvasEvent::Select(app.scenario_context().unwrap(), id))
} else {
Message::Select(id)
};
let _ = app.update(event);
assert_eq!(app.selected, id);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(id)]
);
}
assert_ne!(app.scenario_context().unwrap(), old.stamp().context);
inert(
&mut app,
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
true,
);
let current = super::super::scenario_spacing::tests::packet_for_app(
&app,
spacing::Target::Message(message),
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
}
}
#[test]
fn pending_primary_edit_is_not_applied_by_selection_input() {
let (mut app, _, _, lane, message) = setup(true);
choose(&mut app, message, multiselect::Choice::Add);
let _ = app.update(Message::Field(
"/name".into(),
"Pending primary name".into(),
));
assert!(app.draft_dirty());
let event = capture(
&app,
Intent::Choose(Target::ScenarioObject(lane), multiselect::Choice::Add),
);
inert(&mut app, vec![packet(event)], false);
assert!(app.draft_dirty());
assert_ne!(app.project().named(message), "Pending primary name");
}
@@ -1,339 +0,0 @@
//! Exact-input adapter for an explicit semantic reorder operation.
use super::*;
use crate::behavior::scenario_layout::presentation::PresentationStatus;
use crate::{
behavior::scenario::{
self,
reorder_session::{Outcome, Session},
selection,
},
diagram_selection::Snapshot,
};
#[derive(Default)]
pub(crate) struct State {
pub(crate) session: Option<Session>,
pub(crate) query: String,
pub(crate) page: usize,
pub(crate) summary: Option<(scenario::Context, String)>,
summary_selection: Option<Snapshot>,
}
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub(crate) context: scenario::Context,
pub(crate) snapshot: Snapshot,
pub(crate) token: Id,
pub(crate) intent: Intent,
}
#[derive(Debug, Clone)]
pub(crate) enum Intent {
Begin,
Choose(scenario::Slot),
Query(String),
Page(usize),
Finish(scenario::Slot),
Cancel,
}
fn short_label(value: &str) -> String {
crate::views::short(value, 24)
}
/// Actual committed block order, independent of capture order or saved Y.
fn result_neighborhood(project: &oa_core::Project, parent: Id, snapshot: &Snapshot) -> String {
let Some(order) = project
.elements
.get(&parent)
.and_then(|element| element.steps())
else {
return String::new();
};
let selected: Vec<_> = order
.iter()
.enumerate()
.filter(|(_, id)| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(**id))
})
.collect();
let Some((first, _)) = selected.first() else {
return String::new();
};
let last = selected.last().expect("nonempty block").0;
let label = |index: usize| format!("#{} @{}", index + 1, project.elements[&order[index]].alias);
let left = first
.checked_sub(1)
.map_or_else(|| "Start".to_owned(), label);
let right = if last + 1 < order.len() {
label(last + 1)
} else {
"End".to_owned()
};
let mut members = selected
.iter()
.take(3)
.map(|(index, _)| label(*index))
.collect::<Vec<_>>()
.join(", ");
if selected.len() > 3 {
members.push_str(&format!(", +{} more", selected.len() - 3));
}
format!("{left} → [{members}] → {right}")
}
impl App {
pub(crate) fn scenario_reorder_enabled(&self) -> bool {
self.spacing_enabled()
&& diagram_removal::enabled(self)
&& self.behavior.scenario.spacing_token().is_none()
&& self.scenario_context().is_some_and(|context| {
self.behavior.scenario.canvas_tool(context.scenario) == scenario::CanvasTool::Select
})
}
pub(crate) fn scenario_reorder_cancel(&mut self) {
if self.scenario_reorder.session.take().is_some() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
}
pub(crate) fn scenario_reorder_reconcile(&mut self) {
if self
.scenario_reorder
.summary
.as_ref()
.is_some_and(|(context, _)| {
// A committed result is presentation, not a live authoring packet.
// Resizing diagnostics or moving the camera retires input epochs
// without changing the result's document, view or selection.
let same_result = self.scenario_context().is_some_and(|mut current| {
current.epoch = context.epoch;
current == *context
});
let same_selection = self
.scenario_reorder
.summary_selection
.as_ref()
.zip(self.diagram_selection.as_ref())
.is_some_and(|(snapshot, selected)| {
selected.matches(snapshot, snapshot.context())
});
!same_result || !same_selection
})
{
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
}
let Some(session) = self.scenario_reorder.session.as_ref() else {
return;
};
let valid = self.scenario_reorder_enabled()
&& self
.scenario_context()
.zip(self.multiselect_snapshot())
.is_some_and(|(context, snapshot)| {
session.matches(self.project(), context, &snapshot, session.token())
});
if !valid {
self.scenario_reorder_cancel();
}
}
pub(crate) fn scenario_reorder_event(&mut self, event: Event) -> Task<Message> {
// Reject stale envelopes before touching any newer operation or UI state.
if event.token.is_nil()
|| self.scenario_context() != Some(event.context)
|| !self
.diagram_selection
.as_ref()
.is_some_and(|selection_state| {
selection_state.matches(&event.snapshot, selection::context(event.context))
})
{
return Task::none();
}
if matches!(&event.intent, Intent::Begin) {
if self.scenario_reorder.session.is_some() || !self.scenario_reorder_enabled() {
return Task::none();
}
// Retire the captured Begin before construction, including failed starts.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
let context = self.scenario_context().expect("admitted scenario");
let snapshot = self.multiselect_snapshot().expect("admitted selection");
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
self.scenario_reorder.summary = None;
self.scenario_reorder.summary_selection = None;
match Session::new(
self.document.snapshot().decoded.project.clone().into(),
context,
snapshot,
event.token,
) {
Ok(session) => {
self.scenario_reorder.session = Some(session);
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
return scenario::reorder_view::focus_query(event.token);
}
Err(reason) => self.status = reason,
}
return Task::none();
}
let Some(session) = self.scenario_reorder.session.as_ref() else {
return Task::none();
};
if !session.matches(self.project(), event.context, &event.snapshot, event.token) {
return Task::none();
}
if let Intent::Finish(slot) = &event.intent
&& session
.current_preview()
.is_none_or(|preview| preview.slot != *slot)
{
return Task::none();
}
if matches!(&event.intent, Intent::Cancel) {
self.scenario_reorder_cancel();
self.status = "Reorder cancelled. Sequence unchanged.".into();
return Task::none();
}
if !self.scenario_reorder_enabled() {
self.scenario_reorder_cancel();
return Task::none();
}
match event.intent {
Intent::Choose(slot) => {
let preview = self
.scenario_reorder
.session
.as_mut()
.expect("admitted session")
.preview(slot);
self.status = match &preview.outcome {
Outcome::Refused(reason) => reason.clone(),
Outcome::Noop => "This destination leaves the semantic order unchanged.".into(),
Outcome::Change => match preview.presentation.as_ref().map(|shown| shown.status()) {
Some(PresentationStatus::Ready) => "Review the semantic order, then apply reorder. Saved positions are preserved.".into(),
Some(PresentationStatus::Fallback { .. }) => "The new semantic order requires automatic display fallback. Apply keeps the requested saved view and its authored positions.".into(),
_ => "The candidate cannot be displayed; reorder cannot be applied.".into(),
},
};
}
Intent::Query(query) => {
self.scenario_reorder.query = query;
self.scenario_reorder.page = 0;
}
Intent::Page(page) => self.scenario_reorder.page = page,
Intent::Finish(slot) => {
let label = |id| {
self.project().elements.get(&id).map_or_else(
|| id.to_string(),
|element| {
format!(
"{} (@{})",
short_label(&element.name),
short_label(&element.alias)
)
},
)
};
let parent_name = label(slot.parent);
let destination = slot.before.map_or_else(
|| "at the end".to_owned(),
|anchor| format!("before {}", label(anchor)),
);
let session = self
.scenario_reorder
.session
.take()
.expect("admitted session");
let selection_visible = session
.current_preview()
.is_some_and(|preview| preview.selection_visible);
let result =
session.finish(self.project(), event.context, &event.snapshot, event.token);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.scenario_reorder.query.clear();
self.scenario_reorder.page = 0;
match result {
Err(reason) => self.status = reason,
Ok(completion) => {
debug_assert_eq!(completion.representation, event.context.representation);
if !selection_visible {
self.status = "The candidate would hide a selected occurrence; reorder was not applied.".into();
} else if matches!(
completion.presentation_status,
PresentationStatus::Unavailable { .. }
) {
self.status =
"The candidate cannot be displayed; reorder was not applied."
.into();
} else if let Some(command) = completion.command {
match self.document.execute(*command) {
Ok(_) => {
if let Some(crate::diagram_usage::Target::ScenarioObject(
primary,
)) = completion.snapshot.primary()
{
self.selected = *primary;
}
self.sync();
self.reconcile_multiselect();
self.inspect_scenario_multiselect_primary();
let mut summary = format!(
"Moved {} selected occurrences {} in {}. {}. Saved positions are preserved.",
completion.snapshot.targets().len(),
destination,
parent_name,
result_neighborhood(
self.project(),
slot.parent,
&completion.snapshot
)
);
if matches!(
completion.presentation_status,
PresentationStatus::Fallback { .. }
) {
summary.push_str(" Automatic display fallback is active.");
}
self.scenario_reorder.summary =
self.scenario_context().map(|context| (context, summary));
self.scenario_reorder.summary_selection =
self.multiselect_snapshot();
self.status = if matches!(
completion.presentation_status,
PresentationStatus::Fallback { .. }
) {
"Semantic order updated. Automatic display fallback is active; the requested saved view and authored positions are preserved.".into()
} else {
"Semantic order updated. Saved positions are preserved."
.into()
};
}
Err(error) => {
self.status = format!("Reorder was not applied: {error}")
}
}
} else {
self.status = "Semantic order unchanged.".into();
}
}
}
}
Intent::Begin | Intent::Cancel => unreachable!(),
}
Task::none()
}
}
#[cfg(test)]
mod summary_tests;
#[cfg(test)]
mod tests;
@@ -1,231 +0,0 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, Slot, direct_tests};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Target;
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
fn committed() -> (App, Id, Id, oa_core::source_project::SourceBundle) {
let f = direct_tests::fixture();
let mut project = f.project;
let mut extra = project
.make_element(Kind::Occurrence, Layer::Oa, "Tail", Some(f.owner))
.unwrap();
if let Some(oa_core::SharedBody::Occurrence { body, .. }) = extra.shared_mut() {
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
}
let extra_id = extra.id;
project.elements.insert(extra_id, extra);
project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(extra_id);
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "summary_view".into(),
name: "Summary view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
},
);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Summary view".into(),
}));
for (id, choice) in [
(f.call, multiselect::Choice::Replace),
(f.reply, multiselect::Choice::Add),
] {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
viewport(&mut app, 700.);
let baseline = app.document.bundle().clone();
dispatch(&mut app, Intent::Begin);
let slot = Slot {
parent: f.owner,
before: None,
};
dispatch(&mut app, Intent::Choose(slot));
dispatch(&mut app, Intent::Finish(slot));
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[extra_id, f.call, f.reply]
);
assert!(app.scenario_reorder.summary.is_some(), "{}", app.status);
(app, f.owner, f.call, baseline)
}
fn dispatch(app: &mut App, intent: Intent) {
let event = Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
token: app
.scenario_reorder
.session
.as_ref()
.map_or_else(Id::new_v4, Session::token),
intent,
};
let _ = app.update(Message::ScenarioReorder(Box::new(event)));
}
fn viewport(app: &mut App, height: f32) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(900., height),
)));
}
#[test]
fn scenario_reorder_summary_survives_diagnostics_resize_and_camera_then_undo_clears() {
let (mut app, _, _, baseline) = committed();
let summary = app.scenario_reorder.summary.clone();
let changed = app.document.bundle().clone();
let epoch = app.canvas_epoch;
viewport(&mut app, 660.);
assert_ne!(
app.canvas_epoch, epoch,
"real diagnostics-height viewport change must retire input"
);
assert_eq!(app.scenario_reorder.summary, summary);
let context = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
crate::behavior::canvas_workspace::Event::Pan(iced::Vector::new(24., 12.)),
)));
assert!(app.behavior.scenario.camera_boundary() != camera);
assert_eq!(app.scenario_reorder.summary, summary);
assert_eq!(app.document.bundle(), &changed);
let _ = app.update(Message::Undo);
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
assert_eq!(app.document.bundle(), &baseline);
}
#[test]
fn scenario_reorder_summary_retires_on_view_selection_and_new_operation() {
for action in 0..3 {
let (mut app, owner, call, _) = committed();
let source = app.document.bundle().clone();
match action {
0 => {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
context,
Representation::Derived { owner },
)));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived { owner }
);
}
1 => {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Collapse(Target::ScenarioObject(call)),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
_ => {
dispatch(&mut app, Intent::Begin);
assert!(app.scenario_reorder.session.is_some());
dispatch(&mut app, Intent::Cancel);
assert!(app.scenario_reorder.session.is_none());
assert_eq!(app.status, "Reorder cancelled. Sequence unchanged.");
}
}
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
assert_eq!(app.document.bundle(), &source);
}
}
#[test]
fn scenario_reorder_summary_selection_generation_aba_is_not_a_camera_change() {
let (mut app, _, call, _) = committed();
let captured = app.multiselect_snapshot().unwrap();
let selected = app.diagram_selection.as_mut().unwrap();
assert!(selected.set_primary(&Target::ScenarioObject(call)));
assert!(selected.set_primary(captured.primary().unwrap()));
assert_eq!(selected.targets(), captured.targets());
assert_eq!(selected.primary(), captured.primary());
app.scenario_reorder_reconcile();
assert!(app.scenario_reorder.summary.is_none());
}
#[test]
fn scenario_reorder_summary_clears_after_real_spacing_commit_without_another_event() {
use crate::behavior::scenario::spacing::{Event as Spacing, Target as SpacingTarget};
let (mut app, owner, call, _) = committed();
let reordered = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let begin =
crate::app::scenario_spacing::tests::packet_for_app(&app, SpacingTarget::Message(call));
let Spacing::Begin { press_screen, .. } = begin else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
assert!(app.scenario_reorder.summary.is_some());
let screen = press_screen + iced::Vector::new(0., 24.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
)));
assert_eq!(app.document.bundle(), &reordered.bundle);
let finish = Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(finish)));
assert_ne!(
app.document.bundle(),
&reordered.bundle,
"spacing must actually commit: {}",
app.status
);
assert_eq!(
app.project().elements[&owner].steps(),
reordered.decoded.project.elements[&owner].steps()
);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert!(app.scenario_reorder.summary.is_none());
assert!(app.scenario_reorder.summary_selection.is_none());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &reordered.bundle);
}
File diff suppressed because it is too large Load Diff
+27 -113
View File
@@ -1,33 +1,9 @@
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
use super::*;
use crate::diagram_usage::Target;
use behavior::scenario::{
selection,
spacing::{Event, Refusal, controller::Finish},
};
use behavior::scenario::spacing::{Event, controller::Finish};
#[cfg(test)]
mod group_tests;
#[cfg(test)]
pub(super) mod tests;
fn refusal_reason(refusal: &Refusal) -> String {
match refusal {
Refusal::InvalidSelection(reason) => (*reason).into(),
Refusal::TargetNotRendered(_) => {
"Every selected object must be a rendered local message or lifeline in this saved view."
.into()
}
Refusal::Geometry(_) => {
"These positions cannot be displayed together. The saved arrangement is unchanged."
.into()
}
Refusal::Command(reason) => reason.clone(),
_ => {
"The drag could not be applied in this view. The saved arrangement is unchanged.".into()
}
}
}
mod tests;
impl App {
pub(super) fn spacing_enabled(&self) -> bool {
@@ -40,54 +16,22 @@ impl App {
}
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
if self.scenario_reorder.session.is_some() {
return Task::none();
}
let stamp = event.stamp();
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
{
return Task::none();
}
let Some(snapshot) = self.multiselect_snapshot() else {
return Task::none();
};
if snapshot.generation() != stamp.selection_generation
|| snapshot.context() != selection::context(stamp.context)
{
return Task::none();
}
let state = &self.behavior.scenario;
let matches =
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
if match event {
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
_ => {
!matches
|| !state.spacing_snapshot().is_some_and(|captured| {
self.diagram_selection.as_ref().is_some_and(|selected| {
selected.matches(captured, selection::context(stamp.context))
})
})
}
_ => !matches,
} {
return Task::none();
}
if matches!(event, Event::Begin { .. }) {
let visible = selection::visible(self.project(), stamp.context, self.selected);
if !snapshot
.targets()
.iter()
.all(|target| visible.contains(target))
{
// This otherwise current Begin cannot be replayed after repair.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status =
"Every selected object must still be visible before moving the group.".into();
return Task::none();
}
}
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
if self.behavior.scenario.cancel_spacing() || matches!(event, Event::Begin { .. }) {
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
@@ -95,38 +39,18 @@ impl App {
match event {
Event::Begin { .. } => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_spacing_with_snapshot(self.project(), event, snapshot);
let result = state.begin_spacing(self.project(), event);
self.behavior.scenario = state;
if let Err(error) = result {
// Consume admitted failed starts too; a repeated Begin is stale.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = refusal_reason(&error);
if result.is_err() {
self.status = "The drag could not start in this view. Try again.".into();
}
}
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
Event::Finish { screen, .. } => {
// Full captured selection matched above, before taking the session.
let captured = self
.behavior
.scenario
.spacing_snapshot()
.cloned()
.expect("admitted session snapshot");
let group = captured.targets().len() > 1;
let finished = self.behavior.scenario.finish_spacing(screen);
// Retire queued packets even for zero motion or a refused finish.
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
match finished {
Ok(Finish::Click(target)) if group => {
let target = Target::ScenarioObject(target);
// The controller only captures a pressed member of this set.
if captured.targets().contains(&target) {
self.diagram_selection
.as_mut()
.expect("admitted selection")
.replace(target, |_| true);
self.inspect_scenario_multiselect_primary();
}
}
Ok(Finish::Click(target)) => self.select(target),
Ok(Finish::Noop) => {}
Ok(Finish::Commit {
@@ -134,38 +58,28 @@ impl App {
selected,
scale,
pan,
}) => match self.document.execute(*command) {
Ok(_) => {
self.selected = if group {
match captured.primary() {
Some(Target::ScenarioObject(id)) => *id,
_ => selected,
}
} else {
selected
};
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
if group {
self.inspect_scenario_multiselect_primary();
}) => {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
self.diagram_navigation_focus = None;
self.behavior.scenario.accept_spacing_camera(scale, pan);
self.sync();
self.status = "Scenario spacing updated.".into();
}
self.status = if group {
format!(
"Spacing updated for {} selected objects. Scenario order is unchanged.",
captured.targets().len()
)
} else {
"Scenario spacing updated.".into()
};
Err(error) => self.status = format!("Spacing was not applied: {error}"),
}
Err(error) => self.status = format!("Spacing was not applied: {error}"),
},
Err(error) => self.status = refusal_reason(&error),
}
Err(_) => {
self.status =
"That position cannot be applied. The saved arrangement is unchanged."
.into()
}
}
self.reconcile_multiselect();
self.scenario_reorder_reconcile();
}
Event::Cancel { .. } => unreachable!(),
}
@@ -1,374 +0,0 @@
use super::tests::{begin, finish, move_to, packet_for_app, send, setup};
use super::*;
use crate::behavior::scenario::{CanvasEvent, selection, spacing::Target};
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::Target as SelectedTarget;
use iced::Vector;
use oa_core::{
DiagramLayout, OccurrenceEndpoint, ScenarioLanePosition, ScenarioLayout,
ScenarioMessagePosition,
};
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(SelectedTarget::ScenarioObject(id), choice),
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
event,
))));
}
fn group(messages: bool, reverse: bool) -> (App, [Id; 2]) {
let (mut app, lane, message) = setup();
let ids = if messages {
[message, id(&app, "second")]
} else {
[lane, id(&app, "operator")]
};
let order = if reverse { [ids[1], ids[0]] } else { ids };
for member in order {
choose(&mut app, member, multiselect::Choice::Add);
}
(app, ids)
}
fn target(messages: bool, id: Id) -> Target {
if messages {
Target::Message(id)
} else {
Target::Lane(id)
}
}
fn delta(messages: bool, pixels: f32) -> Vector {
if messages {
Vector::new(0., pixels)
} else {
Vector::new(pixels, 0.)
}
}
#[test]
fn either_pressed_member_moves_complete_group_and_preserves_primary_source_and_one_history_step() {
for messages in [false, true] {
for pressed_index in 0..2 {
let (mut app, ids) = group(messages, true);
// Automatic lane order depends on generated identities. Give this
// literal geometry oracle explicit authored starting coordinates.
let c = app.scenario_context().unwrap();
let Representation::Saved { diagram } = c.representation else {
unreachable!()
};
let mut initial = app.project().clone();
let mut members = ids.to_vec();
members.sort();
let mut layout = ScenarioLayout::default();
for member in members {
if messages {
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: member,
},
send_y: if member == ids[0] { 148. } else { 240. },
});
} else {
layout.lanes.push(ScenarioLanePosition {
lifeline: member,
x: if member == ids[0] { 160. } else { 400. },
});
}
}
initial.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(layout));
app.document =
Document::from_project(initial, oa_core::source_project::ProjectLimits::default())
.unwrap();
app.sync();
let original = app.document.snapshot();
let original_selection = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let c = app.scenario_context().unwrap();
let Representation::Saved { diagram } = c.representation else {
unreachable!()
};
let (event, start, scale) = begin(&mut app, target(messages, ids[pressed_index]));
let world_delta = 24. / scale;
move_to(&mut app, event, start + delta(messages, 24.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.selected, primary);
assert_eq!(
app.behavior.scenario.spacing_snapshot(),
Some(&original_selection)
);
let mut expected = original.decoded.project.clone();
let mut members = ids.to_vec();
members.sort();
let mut layout = ScenarioLayout::default();
for member in members {
if messages {
let y: f32 = if member == ids[0] { 148. } else { 240. };
layout.messages.push(ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: member,
},
send_y: (f64::from(y) + f64::from(world_delta)) as f32,
});
} else {
let x: f32 = if member == ids[0] { 160. } else { 400. };
layout.lanes.push(ScenarioLanePosition {
lifeline: member,
x: (f64::from(x) + f64::from(world_delta)) as f32,
});
}
}
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(layout));
finish(&mut app, event, start + delta(messages, 24.));
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, primary);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
original_selection.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
original_selection.primary()
);
let saved = app.document.bundle().clone();
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &saved);
let reopened =
Document::from_bundle(saved, oa_core::source_project::ProjectLimits::default())
.unwrap();
assert_eq!(reopened.project(), &expected);
}
}
}
#[test]
fn group_stationary_click_collapses_but_offaxis_outback_cancel_preserve_group_and_redo() {
for messages in [false, true] {
for mode in 0..4 {
let (mut app, ids) = group(messages, true);
let (seed, start, _) = begin(&mut app, target(messages, ids[0]));
finish(&mut app, seed, start + delta(messages, 20.));
let _ = app.update(Message::ShortcutUndo(false));
assert!(app.document.can_redo());
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, target(messages, ids[1]));
match mode {
0 => finish(&mut app, event, start),
1 => finish(&mut app, event, start + delta(!messages, 50.)),
2 => {
move_to(&mut app, event, start + delta(messages, 30.));
finish(&mut app, event, start);
}
_ => {
move_to(&mut app, event, start + delta(messages, 30.));
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
}
}
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.can_redo() && !app.document.can_undo());
assert!(camera == app.behavior.scenario.camera_boundary());
if mode == 0 {
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[SelectedTarget::ScenarioObject(ids[1])]
);
assert_eq!(app.selected, ids[1]);
} else {
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(app.selected, primary);
}
}
}
}
#[test]
fn stale_group_packets_and_generation_forgery_do_not_disturb_a_newer_live_gesture() {
let (mut app, ids) = group(true, true);
let (old, start, _) = begin(&mut app, Target::Message(ids[0]));
send(
&mut app,
Event::Cancel {
stamp: old.stamp(),
token: old.token(),
},
);
let (current, current_start, _) = begin(&mut app, Target::Message(ids[1]));
let original = app.document.snapshot();
let selection = app.multiselect_snapshot();
let epoch = app.canvas_epoch;
let status = app.status.clone();
let mut forged = current;
if let Event::Begin { stamp, .. } = &mut forged {
stamp.selection_generation = stamp.selection_generation.wrapping_add(1);
}
for event in [
old,
forged,
Event::Move {
stamp: old.stamp(),
token: old.token(),
screen: start + Vector::new(0., 90.),
},
Event::Finish {
stamp: old.stamp(),
token: old.token(),
screen: start + Vector::new(0., 90.),
},
Event::Cancel {
stamp: old.stamp(),
token: old.token(),
},
] {
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.status, status);
assert_eq!(app.multiselect_snapshot(), selection);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
}
finish(&mut app, current, current_start + Vector::new(0., 24.));
assert!(app.document.can_undo());
}
#[test]
fn mixed_group_drag_refuses_whole_set_without_primary_only_change_or_replay() {
let (mut app, lane, message) = setup();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let event = packet_for_app(&app, Target::Message(message));
let original = app.document.snapshot();
let selected = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
move_to(&mut app, event, press_screen + Vector::new(0., 30.));
assert_eq!(app.project(), &original.decoded.project);
finish(&mut app, event, press_screen + Vector::new(0., 30.));
assert_ne!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selected.targets()
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
selected.primary()
);
let status = app.status.clone();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_eq!(app.status, status);
assert_eq!(app.canvas_epoch, epoch);
}
#[test]
fn mixed_group_stationary_click_on_either_role_still_selects_only_that_member() {
for lane_hit in [false, true] {
let (mut app, lane, message) = setup();
choose(&mut app, message, multiselect::Choice::Add);
choose(&mut app, lane, multiselect::Choice::Add);
let original = app.document.snapshot();
let member = if lane_hit { lane } else { message };
let (event, start, _) = begin(&mut app, target(!lane_hit, member));
finish(&mut app, event, start);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[SelectedTarget::ScenarioObject(member)]
);
assert_eq!(app.selected, member);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
}
#[test]
fn malformed_current_begin_consumes_its_lifetime_without_editing_or_replaying() {
let (mut app, ids) = group(true, true);
let mut event = packet_for_app(&app, Target::Message(ids[0]));
if let Event::Begin { canvas_bounds, .. } = &mut event {
canvas_bounds.width = 0.;
}
let original = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let epoch = app.canvas_epoch;
send(&mut app, event);
assert_ne!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
let epoch = app.canvas_epoch;
let status = app.status.clone();
send(&mut app, event);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.status, status);
}
#[test]
fn group_source_write_failure_is_atomic_and_preserves_selection_and_camera() {
let (mut app, ids) = group(true, true);
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();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selection = app.multiselect_snapshot().unwrap();
let primary = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(ids[1]));
finish(&mut app, event, start + Vector::new(0., 24.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
selection.targets()
);
assert_eq!(app.selected, primary);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
@@ -8,7 +8,7 @@ use behavior::scenario::{
};
use iced::{Point, Size, Vector};
pub(super) fn setup() -> (App, Id, Id) {
fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
@@ -88,21 +88,14 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture()
"the current gesture remains usable"
);
}
pub(in crate::app) fn packet_for_app(app: &App, target: Target) -> Event {
let mut event = packet(
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
if let Event::Begin { stamp, .. } = &mut event {
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
}
event
}
pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet_for_app(app, target);
let Event::Begin {
press_screen,
scale,
@@ -115,10 +108,10 @@ pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
pub(super) fn send(app: &mut App, event: Event) {
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
@@ -128,7 +121,7 @@ pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
},
);
}
pub(super) fn finish(app: &mut App, begin: Event, screen: Point) {
fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
@@ -10,6 +10,11 @@ async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
assert_eq!(
statuses,
[iced::event::Status::Captured],
"the mounted picker must capture Escape before App subscriptions"
);
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
for message in messages.iter().cloned() {
let task = app.update(message);
@@ -41,17 +46,11 @@ async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reope
send(&mut app, Message::BehaviorDiagram(choice));
let mut mounted = Mounted::new(&mut app).await;
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, canvas, _) = mounted
.geometry(&app)
.focused
.into_iter()
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
.unwrap();
// The existing picker is immediately above the measured canvas, separated
// by 8px. Its 12px text and 8px padding give a 31.6px row.
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
let option = iced::Point::new(picker.x, canvas.y + 8.);
// Locate the stable picker slot itself: selection controls may sit between
// it and the canvas. The single menu option opens below this slot.
let bounds = mounted.geometry(&app).bounds("state-missing-picker");
let picker = bounds.center();
let option = iced::Point::new(picker.x, bounds.y + bounds.height * 1.5);
let before = app.document.snapshot();
let original = app.project().clone();
let source = app.document.bundle().clone();
+6 -209
View File
@@ -1243,7 +1243,6 @@ struct Scene {
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -1263,8 +1262,6 @@ struct SequenceSlot {
}
#[derive(Default)]
struct SequenceInput {
modifiers: iced::keyboard::Modifiers,
group_press: Option<ScenarioGroupPress>,
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
@@ -1272,13 +1269,6 @@ struct SequenceInput {
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
struct ScenarioGroupPress {
context: scenario::Context,
snapshot: crate::diagram_selection::Snapshot,
target: crate::diagram_usage::Target,
start: Point,
moved: bool,
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
@@ -1319,48 +1309,11 @@ impl SequenceInput {
cancel
}
fn cancel(&mut self) {
self.group_press = None;
self.source = None;
self.press = None;
}
}
impl Scene {
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
let mut ids = std::collections::BTreeSet::new();
ids.extend(
self.boxes
.iter()
.filter(|b| !b.title.is_empty())
.map(|b| b.id),
);
ids.extend(self.lines.iter().map(|line| line.id));
ids.extend(self.activations.iter().map(|segment| segment.call));
ids.extend(self.render_lanes.keys().copied());
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
ids.into_iter()
.map(crate::diagram_usage::Target::ScenarioObject)
.collect()
}
fn is_selected(&self, id: Id) -> bool {
self.selection
.as_ref()
.map_or(id == self.selected, |(_, snapshot)| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
})
}
fn is_group(&self) -> bool {
self.selection
.as_ref()
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
}
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
let (context, snapshot) = self.selection.as_ref()?;
Some(
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
)
}
fn removal_target(&self, owner: Id, id: Id) -> bool {
id != owner
&& (self.boxes.iter().any(|item| item.id == id)
@@ -1387,7 +1340,6 @@ impl Scene {
selected,
interaction: None,
spacing_stamp: None,
selection: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
@@ -2030,24 +1982,7 @@ impl canvas::Program<Message> for Scene {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use crate::{app::multiselect::Choice, diagram_usage::Target};
use scenario::selection::Intent;
use scenario::{CanvasEvent, CanvasTool};
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
return None;
}
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
state.modifiers = iced::keyboard::Modifiers::default();
}
if state.group_press.as_ref().is_some_and(|press| {
self.selection.as_ref().is_none_or(|(context, snapshot)| {
*context != press.context || *snapshot != press.snapshot
})
}) {
state.cancel();
}
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
@@ -2071,11 +2006,7 @@ impl canvas::Program<Message> for Scene {
}
}
state.bounds = Some(bounds);
if !editable {
state.group_press = None;
}
if (!editable || !self.coordinate_input)
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
@@ -2106,9 +2037,6 @@ impl canvas::Program<Message> for Scene {
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(press) = &mut state.group_press {
press.moved |= position.distance(press.start) >= 3.;
}
if let Some(press) = &mut state.press {
match press {
SequencePress::Message { start, moved, .. }
@@ -2147,9 +2075,6 @@ impl canvas::Program<Message> for Scene {
&& state.source.is_none() =>
{
let context = context?;
if self.is_group() {
return self.selection_action(Intent::Refuse("Removing multiple Scenario objects is being implemented; select one occurrence to remove it".into()));
}
if self.removal_target(context.scenario, self.selected) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
@@ -2164,10 +2089,7 @@ impl canvas::Program<Message> for Scene {
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some()
|| state.press.is_some()
|| state.group_press.is_some() =>
{
}) if state.source.is_some() || state.press.is_some() => {
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
@@ -2181,18 +2103,6 @@ impl canvas::Program<Message> for Scene {
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
let (context, _) = self.selection.as_ref()?;
let hit = self.hit_item(world?)?;
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
request.scenario = Some(Box::new(*context));
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
state.cancel();
Some(
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
.and_capture(),
)
}
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
return None;
@@ -2202,83 +2112,6 @@ impl canvas::Program<Message> for Scene {
if !editable {
return None;
}
if let Some((selection_context, snapshot)) = &self.selection {
let hit = self.hit_item(point).or_else(|| {
(!self.is_group()
&& self.is_selected(self.selected)
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
))
.then(|| {
self.reorder_handle()
.filter(|handle| handle.contains(point))
})
.flatten()
.map(|_| SceneHit {
id: self.selected,
role: SceneHitRole::Box,
})
});
if tool != CanvasTool::Select && self.is_group() {
return self.selection_action(Intent::Refuse(
"Select one occurrence before using an authoring tool".into(),
));
}
if tool == CanvasTool::Select {
if let Some(hit) = hit {
let target = Target::ScenarioObject(hit.id);
if state.modifiers.shift() {
state.cancel();
return self
.selection_action(Intent::Choose(target, Choice::Toggle));
}
let group_spacing_hit = self.coordinate_input
&& self.spacing_stamp.is_some()
&& matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
)
&& matches!(
hit.role,
SceneHitRole::LifelineHeader
| SceneHitRole::MessageBody
| SceneHitRole::MessageLabel
);
if self.is_group() && self.is_selected(hit.id) && !group_spacing_hit
{
state.group_press = Some(ScenarioGroupPress {
context: *selection_context,
snapshot: snapshot.clone(),
target,
start: cursor.position()?,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
// Preserve the existing first-press singleton
// spacing/reorder gesture. Replacing a displayed
// group is an explicit selection change first.
if (self.is_group() && !self.is_selected(hit.id))
|| !self.coordinate_input
{
return self
.selection_action(Intent::Choose(target, Choice::Replace));
}
} else {
state.cancel();
if state.modifiers.shift() {
return Some(Action::capture());
}
// Background clearing shares this press with camera panning.
return Some(Action::publish(scenario::selection::message(
*selection_context,
snapshot,
Intent::Clear,
)));
}
}
}
if !self.coordinate_input {
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
@@ -2381,27 +2214,6 @@ impl canvas::Program<Message> for Scene {
.map(|id| Action::publish(Message::Select(id)).and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if let Some(press) = state.group_press.take() {
if world.is_none() {
return Some(Action::request_redraw());
}
let intent = if press.moved {
Intent::Refuse(
"This selection cannot be moved here; select one occurrence for its individual actions"
.into(),
)
} else {
Intent::Collapse(press.target)
};
return Some(
Action::publish(scenario::selection::message(
press.context,
&press.snapshot,
intent,
))
.and_capture(),
);
}
let press = state.press.take()?;
let context = context?;
match press {
@@ -2553,7 +2365,7 @@ impl canvas::Program<Message> for Scene {
}
}),
Stroke::default()
.with_color(if self.is_selected(guide.id) {
.with_color(if guide.id == self.selected {
style::TEAL
} else {
style::MUTED
@@ -2579,7 +2391,7 @@ impl canvas::Program<Message> for Scene {
f.stroke(
&path,
Stroke::default()
.with_color(if self.is_selected(activation.call) {
.with_color(if activation.call == self.selected {
style::TEAL
} else {
style::BOUNDARY
@@ -2596,7 +2408,7 @@ impl canvas::Program<Message> for Scene {
b.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
);
let color = if self.is_selected(b.id) {
let color = if b.id == self.selected {
style::TEAL
} else {
style::BOUNDARY
@@ -2618,7 +2430,7 @@ impl canvas::Program<Message> for Scene {
}
}
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if self.is_selected(l.id) {
let color = if l.id == self.selected {
style::TEAL
} else {
style::MUTED
@@ -2674,7 +2486,7 @@ impl canvas::Program<Message> for Scene {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if self.is_selected(b.id) {
color: if b.id == self.selected {
style::TEAL
} else {
style::INK
@@ -2704,19 +2516,6 @@ impl canvas::Program<Message> for Scene {
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if self.selection.is_some() {
// Lifecycle glyphs use their exact painted hit identity too.
for (id, point) in self.lifecycle.selection_markers() {
if self.is_selected(id) {
f.stroke(
&Path::circle(point, 12.),
Stroke::default()
.with_color(style::TEAL)
.with_width(if id == self.selected { 3. } else { 1.5 }),
);
}
}
}
if let Some((context, tool, editable)) = self.interaction {
let active = editable
&& self.coordinate_input
@@ -2743,8 +2542,6 @@ impl canvas::Program<Message> for Scene {
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& !self.is_group()
&& self.is_selected(self.selected)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
@@ -16,6 +16,7 @@ pub fn keyboard_enabled(app: &crate::app::App) -> bool {
/// Passive I/O suspends commands without changing the logical input owner.
pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
!app.hosted.visible
&& !app.context_help
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
+19 -22
View File
@@ -6,12 +6,6 @@ use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus;
pub(crate) mod group_reorder;
pub(crate) mod reorder_session;
#[cfg(test)]
mod reorder_slot_experiments;
pub(crate) mod reorder_view;
pub(crate) mod selection;
pub(crate) mod spacing;
use super::scenario_layout::{
Representation,
@@ -149,7 +143,6 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Selection(Box<selection::Event>),
Spacing(spacing::Event),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
@@ -193,7 +186,6 @@ impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Spacing(event) => event.stamp().context,
Self::Selection(event) => event.context,
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
@@ -1261,21 +1253,26 @@ pub(crate) fn reorder(
occurrence: Id,
slot: Slot,
) -> Result<Command, String> {
let prepared = group_reorder::Prepared::new(
std::sync::Arc::new(p.clone()),
owner,
&[crate::diagram_usage::Target::ScenarioObject(occurrence)],
)?;
match prepared.plan(slot)? {
group_reorder::Plan::Change(command) => Ok(*command),
group_reorder::Plan::Noop => Ok(Command::SetSteps(
slot.parent,
p.elements[&slot.parent]
.steps()
.expect("admitted sequence")
.clone(),
)),
let to = slot_index(p, owner, slot)?;
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
return Err(
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
.into(),
);
}
let mut steps = p.elements[&slot.parent]
.steps()
.expect("slot validated")
.clone();
let from = steps
.iter()
.position(|id| *id == occurrence)
.ok_or("This occurrence is not in the sequence.")?;
steps.remove(from);
steps.insert(to - usize::from(from < to), occurrence);
let command = Command::SetSteps(slot.parent, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
Ok(command)
}
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
represented(p, referenced)
@@ -1,179 +0,0 @@
//! Frozen, all-or-nothing reorder of one immediate Scenario sequence.
use super::Slot;
use crate::diagram_usage::Target;
use oa_core::{Command, Id, Kind, OccurrenceBody, Project, SharedBody, execute_batch};
use std::{collections::BTreeSet, sync::Arc};
#[derive(Debug)]
pub(crate) enum Plan {
Noop,
Change(Box<Command>),
}
pub(crate) struct Prepared {
project: Arc<Project>,
parent: Id,
original: Vec<Id>,
selected: BTreeSet<Id>,
}
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
match project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. }
if matches!(
body,
OccurrenceBody::Exchange { .. }
| OccurrenceBody::Action { .. }
| OccurrenceBody::State { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Fragment { .. }
) =>
{
Some(body)
}
_ => None,
}
}
// Follow the same structural edges that make rows visible; parent pointers alone
// would admit children of an operand omitted from its fragment's branch list.
fn reachable_parent(project: &Project, owner: Id, wanted: Id) -> bool {
let mut pending = vec![owner];
let mut visited = BTreeSet::new();
while let Some(parent) = pending.pop() {
if !visited.insert(parent) {
continue;
}
if parent == wanted {
return true;
}
let Some(steps) = project.elements.get(&parent).and_then(|e| e.steps()) else {
continue;
};
for id in steps {
if project.elements.get(id).and_then(|e| e.parent) != Some(parent) {
continue;
}
if let Some(OccurrenceBody::Fragment { branches, .. }) = step_body(project, *id) {
pending.extend(branches.iter().copied().filter(|branch| {
project.elements.get(branch).is_some_and(|element| {
element.parent == Some(*id)
&& matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
}));
}
}
}
false
}
impl Prepared {
pub(crate) fn new(
project: Arc<Project>,
owner: Id,
targets: &[Target],
) -> Result<Self, String> {
if project.elements.get(&owner).map(|e| e.kind()) != Some(Kind::Scenario) {
return Err("This scenario no longer exists.".into());
}
let mut selected = BTreeSet::new();
let mut parent = None;
for target in targets {
let Target::ScenarioObject(id) = target else {
return Err("Select only ordered occurrences in this scenario.".into());
};
if !selected.insert(*id) || step_body(&project, *id).is_none() {
return Err(
"Every selected subject must be a unique ordered scenario occurrence.".into(),
);
}
let actual = project.elements[id]
.parent
.ok_or("The occurrence has no sequence.")?;
if parent.is_some_and(|parent| parent != actual) {
return Err(
"Reorder inside one immediate sequence; dragging never changes operands."
.into(),
);
}
parent = Some(actual);
}
let parent = parent.ok_or("Select at least one occurrence.")?;
if !reachable_parent(&project, owner, parent) {
return Err("The selected sequence is not reachable in this scenario.".into());
}
let original = project.elements[&parent]
.steps()
.ok_or("The parent is not a sequence.")?
.clone();
let mut listed = BTreeSet::new();
for id in &original {
if !listed.insert(*id)
|| step_body(&project, *id).is_none()
|| project.elements[id].parent != Some(parent)
{
return Err("The complete sequence must contain unique, existing occurrences of this parent.".into());
}
}
if !selected.is_subset(&listed) {
return Err("A selected occurrence is not in this sequence.".into());
}
// Cache a validated immutable baseline, including descendants and other
// sequences. Advisory authoring diagnostics deliberately remain allowed.
oa_core::session::validate_candidate(&project).map_err(|e| e.to_string())?;
Ok(Self {
project,
parent,
original,
selected,
})
}
pub(crate) fn plan(&self, slot: Slot) -> Result<Plan, String> {
if slot.parent != self.parent {
return Err("Reorder inside the occurrence's current sequence; dragging never changes its operand.".into());
}
if let Some(anchor) = slot.before {
if !self.original.contains(&anchor) {
return Err("The insertion slot changed. Choose its position again.".into());
}
if self.selected.contains(&anchor) {
return Ok(Plan::Noop);
}
}
let mut steps: Vec<_> = self
.original
.iter()
.copied()
.filter(|id| !self.selected.contains(id))
.collect();
let at = slot.before.map_or(steps.len(), |anchor| {
steps
.iter()
.position(|id| *id == anchor)
.expect("admitted anchor")
});
steps.splice(
at..at,
self.original
.iter()
.copied()
.filter(|id| self.selected.contains(id)),
);
if steps == self.original {
return Ok(Plan::Noop);
}
let command = Command::SetSteps(self.parent, steps);
execute_batch(&self.project, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(Plan::Change(Box::new(command)))
}
}
#[cfg(test)]
mod tests;
@@ -1,720 +0,0 @@
use super::*;
use crate::{behavior::topology::occurrence, document::Document};
use oa_core::{FragmentOperator, Layer, LifelineBinding, MessageKind, language};
fn add(p: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
let e = occurrence(p, parent, name, body);
let id = e.id;
p.elements.insert(id, e);
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn row(p: &mut Project, parent: Id, name: &str) -> Id {
add(
p,
parent,
name,
OccurrenceBody::Action {
action: None,
lifeline: None,
},
)
}
fn operand(p: &mut Project, parent: Id, operator: FragmentOperator) -> (Id, Id) {
let fragment = add(
p,
parent,
"Fragment",
OccurrenceBody::Fragment {
operator,
branches: vec![],
},
);
let branch = occurrence(
p,
fragment,
"Operand",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
let id = branch.id;
p.elements.insert(id, branch);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(id);
}
(fragment, id)
}
fn prepare(p: &Project, owner: Id, ids: &[Id]) -> Result<Prepared, String> {
Prepared::new(
Arc::new(p.clone()),
owner,
&ids.iter()
.copied()
.map(Target::ScenarioObject)
.collect::<Vec<_>>(),
)
}
fn slot(parent: Id, before: Option<Id>) -> Slot {
Slot { parent, before }
}
fn ordered(p: &Project, parent: Id, ids: &[Id]) -> Project {
let mut expected = p.clone();
*expected
.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap() = ids.to_vec();
expected
}
fn exact_transaction(p: &Project, plan: Plan, expected: Project) {
let Plan::Change(command) = plan else {
panic!("expected a change")
};
assert_eq!(
execute_batch(p, std::slice::from_ref(&command)).unwrap(),
expected
);
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let original_source = doc.bundle().clone();
doc.execute(*command).unwrap();
assert_eq!(doc.project(), &expected);
assert_eq!(
language::parse(&language::format(doc.project())).unwrap(),
expected
);
let changed_source = doc.bundle().clone();
assert_eq!(
Document::from_bundle(changed_source.clone(), Default::default())
.unwrap()
.project(),
&expected
);
assert!(doc.undo());
assert_eq!(doc.project(), p);
assert_eq!(doc.bundle(), &original_source);
assert!(!doc.can_undo());
assert!(doc.redo());
assert_eq!(doc.project(), &expected);
assert_eq!(doc.bundle(), &changed_source);
assert!(!doc.can_redo());
}
fn four(nested: bool) -> (Project, Id, Id, [Id; 4]) {
four_in_layer(nested, Layer::Oa)
}
fn four_in_layer(nested: bool, layer: Layer) -> (Project, Id, Id, [Id; 4]) {
let mut p = Project::empty();
let owner = p
.add_element(oa_core::Kind::Scenario, layer, "Scenario", None)
.unwrap();
let parent = if nested {
operand(&mut p, owner, FragmentOperator::Alternative).1
} else {
owner
};
let ids = ["A", "B", "C", "D"].map(|name| row(&mut p, parent, name));
oa_core::session::validate_candidate(&p).unwrap();
(p, owner, parent, ids)
}
#[test]
fn root_and_operand_use_semantic_block_order_with_exact_history() {
for (nested, layer) in [
(false, Layer::Oa),
(true, Layer::Oa),
(false, Layer::Sa),
(true, Layer::Sa),
] {
let (p, owner, parent, [a, b, c, d]) = four_in_layer(nested, layer);
let prepared = prepare(&p, owner, &[c, a]).unwrap();
exact_transaction(
&p,
prepared.plan(slot(parent, Some(d))).unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
exact_transaction(
&p,
prepared.plan(slot(parent, None)).unwrap(),
ordered(&p, parent, &[b, d, a, c]),
);
}
}
#[test]
fn singleton_wrapper_noop_preserves_existing_redo_and_source() {
for nested in [false, true] {
let (p, owner, parent, [a, b, c, d]) = four(nested);
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let change = super::super::reorder(doc.project(), owner, a, slot(parent, None)).unwrap();
doc.execute(change).unwrap();
assert_eq!(doc.project(), &ordered(&p, parent, &[b, c, d, a]));
let changed = doc.bundle().clone();
assert!(doc.undo());
let source = doc.bundle().clone();
let revision = doc.revision();
for before in [Some(a), Some(b)] {
let noop =
super::super::reorder(doc.project(), owner, a, slot(parent, before)).unwrap();
doc.execute(noop).unwrap();
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), &source);
assert_eq!(doc.revision(), revision);
assert!(!doc.can_undo());
assert!(doc.can_redo());
}
assert!(doc.redo());
assert_eq!(doc.bundle(), &changed);
}
}
#[test]
fn selected_anchor_and_exact_noop_preserve_redo_and_frozen_snapshot() {
let (p, owner, parent, [a, b, c, d]) = four(false);
let prepared = prepare(&p, owner, &[c, a]).unwrap();
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
let Plan::Change(command) = prepared.plan(slot(parent, Some(d))).unwrap() else {
panic!()
};
doc.execute(*command).unwrap();
let changed = doc.bundle().clone();
assert!(doc.undo());
let source = doc.bundle().clone();
let revision = doc.revision();
for plan in [
prepared.plan(slot(parent, Some(c))),
prepare(&p, owner, &[a])
.unwrap()
.plan(slot(parent, Some(b))),
prepare(&p, owner, &[c, d])
.unwrap()
.plan(slot(parent, None)),
] {
assert!(matches!(plan.unwrap(), Plan::Noop));
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), &source);
assert_eq!(doc.revision(), revision);
assert!(doc.can_redo());
}
assert!(doc.redo());
assert_eq!(doc.bundle(), &changed);
// The earlier prepared value still plans from its original owned snapshot.
exact_transaction(
&p,
prepared.plan(slot(parent, Some(d))).unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
}
#[test]
fn admission_rejects_every_invalid_member_list_and_destination_before_noop() {
let (p, owner, parent, [a, b, c, d]) = four(false);
for ids in [vec![], vec![a, a], vec![a, Id::new_v4()]] {
assert!(prepare(&p, owner, &ids).is_err());
}
for target in [Target::Whole, Target::Placement(a)] {
assert!(
Prepared::new(
Arc::new(p.clone()),
owner,
&[Target::ScenarioObject(a), target]
)
.is_err()
);
}
let mut with_line = p.clone();
let line = with_line
.add_element(Kind::Lifeline, Layer::Oa, "Line", Some(owner))
.unwrap();
assert!(prepare(&with_line, owner, &[a, line]).is_err());
let mut nested = p.clone();
let (fragment, branch) = operand(&mut nested, owner, FragmentOperator::Alternative);
let child = row(&mut nested, branch, "Child");
assert!(prepare(&nested, owner, &[a, child]).is_err());
assert!(prepare(&nested, owner, &[fragment, child]).is_err());
let prepared = prepare(&p, owner, &[a, c]).unwrap();
assert!(prepared.plan(slot(branch, Some(a))).is_err());
assert!(prepared.plan(slot(parent, Some(Id::new_v4()))).is_err());
for list in [vec![a, b, c, b], vec![a, Id::new_v4(), c, d]] {
assert!(prepare(&ordered(&p, parent, &list), owner, &[a, c]).is_err());
}
let mut wrong_parent = p.clone();
wrong_parent.elements.get_mut(&b).unwrap().parent = Some(branch);
assert!(prepare(&wrong_parent, owner, &[a, c]).is_err());
for body in [
OccurrenceBody::Control {
kind: oa_core::ControlKind::Choice,
},
OccurrenceBody::SequenceReference { sequence: None },
] {
let mut wrong_type = p.clone();
if let Some(SharedBody::Occurrence { body: actual, .. }) =
wrong_type.elements.get_mut(&b).unwrap().shared_mut()
{
*actual = body;
}
assert!(prepare(&wrong_type, owner, &[a, c]).is_err());
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = nested.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.clear();
}
assert!(
prepare(&nested, owner, &[child]).is_err(),
"parent ancestry cannot admit omitted operand"
);
}
#[test]
fn unlisted_warning_child_is_preserved_but_cannot_be_selected() {
let (mut p, owner, parent, [a, b, c, d]) = four(false);
let hidden = row(&mut p, parent, "Hidden draft");
p.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.pop();
assert!(
oa_core::validation::validate(&p)
.iter()
.any(|i| i.code == "unordered_occurrence")
);
assert!(prepare(&p, owner, &[hidden]).is_err());
exact_transaction(
&p,
prepare(&p, owner, &[c, a])
.unwrap()
.plan(slot(parent, Some(d)))
.unwrap(),
ordered(&p, parent, &[b, a, c, d]),
);
}
#[test]
fn call_reply_moves_as_explicit_stable_pair_and_invalid_baseline_refuses_noop() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
let prepared = prepare(&p, f.owner, &[f.reply, f.call]).unwrap();
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
);
assert!(
prepare(&p, f.owner, &[f.reply])
.unwrap()
.plan(slot(f.owner, Some(f.call)))
.unwrap_err()
.contains("reply_order")
);
let invalid = ordered(&p, f.owner, &[f.reply, x, f.call, y]);
assert!(prepare(&invalid, f.owner, &[x]).is_err());
}
#[test]
fn nested_fragment_descendant_reply_is_validated_and_subtree_preserved() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
p.elements.get_mut(&f.reply).unwrap().parent = Some(inner);
p.elements
.get_mut(&inner)
.unwrap()
.steps_mut()
.unwrap()
.push(f.reply);
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, fragment, y]);
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(f.call)))
.unwrap_err()
.contains("reply_order")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[f.call, y, x, fragment]),
);
}
#[test]
fn nested_lifetime_and_opaque_local_bindings_are_checked_without_expansion() {
let f = super::super::direct_tests::fixture();
let mut p = execute_batch(
&f.project,
&[Command::Delete(f.reply), Command::Delete(f.call)],
)
.unwrap();
let create = add(
&mut p,
f.owner,
"Create",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(f.sender),
receiver: Some(f.receiver),
message_kind: MessageKind::Create,
reply_to: None,
values: vec![],
operation: None,
},
);
let x = row(&mut p, f.owner, "X");
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
add(
&mut p,
inner,
"Use local",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let y = row(&mut p, f.owner, "Y");
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(create)))
.unwrap_err()
.contains("lifeline_lifetime")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, None)).unwrap(),
ordered(&p, f.owner, &[create, y, x, fragment]),
);
let definition = p
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
.unwrap();
let represented = match p.elements[&f.receiver].shared().unwrap() {
SharedBody::Lifeline { represented } => *represented,
_ => panic!(),
};
let remote = p
.add_element(Kind::Lifeline, Layer::Oa, "Formal", Some(definition))
.unwrap();
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
SharedBody::Lifeline { represented };
let uses = ["First use", "Second use"].map(|name| {
add(
&mut p,
f.owner,
name,
OccurrenceBody::ScenarioUse {
scenario: Some(definition),
bindings: vec![LifelineBinding {
referenced: remote,
local: f.receiver,
}],
},
)
});
let prepared = prepare(&p, f.owner, &[uses[1], uses[0]]).unwrap();
assert!(
prepared
.plan(slot(f.owner, Some(create)))
.unwrap_err()
.contains("lifeline_lifetime")
);
exact_transaction(
&p,
prepared.plan(slot(f.owner, Some(x))).unwrap(),
ordered(&p, f.owner, &[create, uses[0], uses[1], x, fragment, y]),
);
}
fn delete_line(p: &mut Project, parent: Id, sender: Id, receiver: Id) {
add(
p,
parent,
"Delete participant",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(sender),
receiver: Some(receiver),
message_kind: MessageKind::Delete,
reply_to: None,
values: vec![],
operation: None,
},
);
}
fn second_operand(p: &mut Project, fragment: Id) -> Id {
let branch = occurrence(
p,
fragment,
"Other operand",
OccurrenceBody::Branch {
guard: "otherwise".into(),
occurrences: vec![],
},
);
let id = branch.id;
p.elements.insert(id, branch);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
else {
panic!()
};
branches.push(id);
id
}
#[test]
fn conditional_delete_rejects_moving_an_earlier_use_after_the_fragment() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let use_line = add(
&mut p,
f.owner,
"Use participant",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let x = row(&mut p, f.owner, "X");
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Alternative);
delete_line(&mut p, branch, f.sender, f.receiver);
row(&mut p, f.owner, "Y");
oa_core::session::validate_candidate(&p).unwrap();
let prepared = prepare(&p, f.owner, &[x, use_line]).unwrap();
assert!(
prepared
.plan(slot(f.owner, None))
.unwrap_err()
.contains("lifeline_lifetime")
);
}
#[test]
fn exclusive_operand_move_is_legal_but_parallel_baseline_is_invalid() {
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let (fragment, left) = operand(&mut p, f.owner, operator);
let right = second_operand(&mut p, fragment);
delete_line(&mut p, left, f.sender, f.receiver);
let use_line = add(
&mut p,
right,
"Use participant",
OccurrenceBody::Action {
action: None,
lifeline: Some(f.receiver),
},
);
let x = row(&mut p, right, "X");
let y = row(&mut p, right, "Y");
if operator == FragmentOperator::Alternative {
oa_core::session::validate_candidate(&p).unwrap();
exact_transaction(
&p,
prepare(&p, f.owner, &[x, use_line])
.unwrap()
.plan(slot(right, None))
.unwrap(),
ordered(&p, right, &[y, use_line, x]),
);
} else {
// This is an invalid input control, not a valid model broken by reorder.
assert!(
oa_core::session::validate_candidate(&p)
.unwrap_err()
.to_string()
.contains("lifeline_lifetime")
);
assert!(prepare(&p, f.owner, &[x, use_line]).is_err());
}
}
}
#[test]
fn repeated_reply_warning_does_not_block_independent_sibling_reorder() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Loop);
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
p.elements.get_mut(&f.reply).unwrap().parent = Some(branch);
p.elements
.get_mut(&branch)
.unwrap()
.steps_mut()
.unwrap()
.push(f.reply);
let x = row(&mut p, branch, "X");
let y = row(&mut p, branch, "Y");
let z = row(&mut p, branch, "Z");
let warning = |project: &Project| {
oa_core::validation::validate(project).iter().any(|issue| {
issue.element == Some(f.reply)
&& issue.code == "reply_repetition"
&& issue.severity == oa_core::validation::Severity::Warning
})
};
assert!(warning(&p));
let expected = ordered(&p, branch, &[f.reply, y, x, z]);
assert!(warning(&expected));
exact_transaction(
&p,
prepare(&p, f.owner, &[z, x])
.unwrap()
.plan(slot(branch, None))
.unwrap(),
expected,
);
}
#[test]
fn semantic_reorder_preserves_affected_and_unrelated_authored_layouts() {
use oa_core::{
Diagram, DiagramLayout, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition,
ScenarioLayout, ScenarioMessagePosition,
};
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let x = row(&mut p, f.owner, "X");
let y = row(&mut p, f.owner, "Y");
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
for (alias, offset) in [("group_saved_a", 0.), ("group_saved_b", 1500.)] {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: "Preserve my layout".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: f.sender,
x: -300. + offset,
},
ScenarioLanePosition {
lifeline: f.receiver,
x: 700. + offset,
},
],
messages: vec![
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: f.call,
},
send_y: 123. + offset,
},
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: f.reply,
},
send_y: 567. + offset,
},
],
})),
},
);
}
let unrelated = p
.add_element(Kind::Scenario, Layer::Oa, "Unrelated scenario", None)
.unwrap();
let line = p
.add_element(
Kind::Lifeline,
Layer::Oa,
"Unrelated clock",
Some(unrelated),
)
.unwrap();
let timer = add(
&mut p,
unrelated,
"Wait",
OccurrenceBody::Exchange {
exchange: None,
sender: Some(line),
receiver: None,
message_kind: MessageKind::Timer {
expression: "5 s".into(),
},
reply_to: None,
values: vec![],
operation: None,
},
);
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "group_unrelated_saved".into(),
name: "Unrelated arrangement".into(),
description: "Do not touch this view".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario {
scenario: unrelated,
},
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: line,
x: -123.,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: timer,
},
send_y: 987.,
}],
})),
},
);
oa_core::session::validate_candidate(&p).unwrap();
exact_transaction(
&p,
prepare(&p, f.owner, &[f.reply, f.call])
.unwrap()
.plan(slot(f.owner, None))
.unwrap(),
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
);
}
@@ -218,7 +218,6 @@ fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestu
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
selection_generation: 0,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
@@ -1,241 +0,0 @@
//! Immutable semantic reorder operation, independent of its destination UI.
use super::{
Context, Slot,
group_reorder::{Plan, Prepared},
presentation, selection,
};
use crate::{diagram_selection::Snapshot, diagram_usage::Target};
use oa_core::{Command, Id, Project, execute_batch};
use std::sync::Arc;
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Outcome {
Change,
Noop,
Refused(String),
}
pub(crate) struct Preview {
pub(crate) slot: Slot,
pub(crate) before: Vec<Id>,
pub(crate) after: Vec<Id>,
pub(crate) outcome: Outcome,
/// Geometry status is independent of semantic validity; requested Saved
/// representation is retained even when presentation reports fallback.
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
pub(crate) selection_visible: bool,
command: Option<Box<Command>>,
}
pub(crate) struct Completion {
pub(crate) command: Option<Box<Command>>,
pub(crate) snapshot: Snapshot,
pub(crate) representation: super::Representation,
pub(crate) presentation_status: presentation::PresentationStatus,
}
pub(crate) struct Session {
project: Arc<Project>,
context: Context,
snapshot: Snapshot,
token: Id,
primary: Id,
parent: Id,
before: Vec<Id>,
prepared: Prepared,
preview: Option<Preview>,
#[cfg(test)]
proposal_builds: usize,
}
impl Session {
pub(crate) fn new(
project: Arc<Project>,
context: Context,
snapshot: Snapshot,
token: Id,
) -> Result<Self, String> {
if token.is_nil()
|| context.document.is_nil()
|| context.visit.is_nil()
|| snapshot.context() != selection::context(context)
|| presentation::checked_owner(&project, context.representation)
!= Some(context.scenario)
{
return Err("The reorder input no longer matches this scenario view.".into());
}
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
return Err("Select ordered scenario occurrences.".into());
};
let primary = *primary;
let visible = selection::visible(&project, context, primary);
if !snapshot
.targets()
.contains(&Target::ScenarioObject(primary))
|| snapshot
.targets()
.iter()
.any(|target| !visible.contains(target))
{
return Err("Every selected occurrence must be visible in this scenario view.".into());
}
let prepared = Prepared::new(project.clone(), context.scenario, snapshot.targets())?;
let parent = project.elements[&primary]
.parent
.expect("prepared occurrence parent");
let before = project.elements[&parent]
.steps()
.expect("prepared sequence")
.clone();
Ok(Self {
project,
context,
snapshot,
token,
primary,
parent,
before,
prepared,
preview: None,
#[cfg(test)]
proposal_builds: 0,
})
}
pub(crate) fn context(&self) -> Context {
self.context
}
pub(crate) fn token(&self) -> Id {
self.token
}
pub(crate) fn snapshot(&self) -> &Snapshot {
&self.snapshot
}
pub(crate) fn parent(&self) -> Id {
self.parent
}
pub(crate) fn before(&self) -> &[Id] {
&self.before
}
pub(crate) fn current_preview(&self) -> Option<&Preview> {
self.preview.as_ref()
}
#[cfg(test)]
pub(crate) fn current_preview_mut(&mut self) -> Option<&mut Preview> {
self.preview.as_mut()
}
pub(crate) fn matches(
&self,
current: &Project,
context: Context,
snapshot: &Snapshot,
token: Id,
) -> bool {
current == self.project.as_ref()
&& context == self.context
&& snapshot == &self.snapshot
&& token == self.token
}
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
if self
.preview
.as_ref()
.is_none_or(|preview| preview.slot != slot)
{
#[cfg(test)]
{
self.proposal_builds += 1;
}
let result = self.prepared.plan(slot).and_then(|plan| match plan {
Plan::Noop => Ok((None, self.project.clone())),
Plan::Change(command) => {
let candidate =
execute_batch(&self.project, std::slice::from_ref(command.as_ref()))
.map_err(|error| error.to_string())?;
Ok((Some(command), Arc::new(candidate)))
}
});
let preview = match result {
Ok((command, candidate)) => {
let shown = presentation::present(
&candidate,
self.context.representation,
self.primary,
);
let visible = shown
.scene()
.map_or_else(Vec::new, |scene| scene.selection_targets());
let selection_visible = self
.snapshot
.targets()
.iter()
.all(|target| visible.contains(target));
Preview {
slot,
before: self.before.clone(),
after: candidate.elements[&self.parent]
.steps()
.expect("validated sequence")
.clone(),
outcome: if command.is_some() {
Outcome::Change
} else {
Outcome::Noop
},
presentation: Some(shown),
selection_visible,
command,
}
}
Err(reason) => Preview {
slot,
before: self.before.clone(),
after: self.before.clone(),
outcome: Outcome::Refused(reason),
presentation: None,
selection_visible: false,
command: None,
},
};
self.preview = Some(preview);
}
self.preview.as_ref().expect("explicit destination preview")
}
/// Consume once; never execute a Document transaction or alter selection.
pub(crate) fn finish(
self,
current: &Project,
context: Context,
snapshot: &Snapshot,
token: Id,
) -> Result<Completion, String> {
if !self.matches(current, context, snapshot, token) {
return Err("The reorder input changed; choose the operation again.".into());
}
let preview = self
.preview
.ok_or("Choose an explicit destination before applying reorder.")?;
if let Outcome::Refused(reason) = preview.outcome {
return Err(reason);
}
let presentation_status = preview
.presentation
.expect("valid plan presentation")
.status()
.clone();
Ok(Completion {
command: preview.command,
snapshot: self.snapshot,
representation: self.context.representation,
presentation_status,
})
}
}
#[cfg(test)]
mod tests;
@@ -1,354 +0,0 @@
use super::*;
use crate::{
behavior::{
scenario::{Representation, direct_tests},
topology::occurrence,
},
diagram_selection::Selection,
};
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
fn row(project: &mut Project, parent: Id, name: &str) -> Id {
let element = occurrence(
project,
parent,
name,
OccurrenceBody::Action {
action: None,
lifeline: None,
},
);
let id = element.id;
project.elements.insert(id, element);
project
.elements
.get_mut(&parent)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn snapshot(context: Context, ids: &[Id]) -> Snapshot {
let mut selection = Selection::new(selection::context(context).scope);
for id in ids {
selection.add(Target::ScenarioObject(*id), |_| true);
}
selection.snapshot(selection::context(context)).unwrap()
}
fn session(project: &Project, context: Context, ids: &[Id]) -> Session {
Session::new(
Arc::new(project.clone()),
context,
snapshot(context, ids),
Id::new_v4(),
)
.unwrap()
}
#[test]
fn explicit_destination_cache_and_exact_whole_model_saved_presentation() {
for mode in 0..3 {
let f = direct_tests::fixture();
let mut project = f.project;
let x = row(&mut project, f.owner, "X");
let y = row(&mut project, f.owner, "Y");
let mut context = direct_tests::context(f.owner);
for name in ["A", "B"] {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: name.into(),
name: name.into(),
description: "Keep this view".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![oa_core::ScenarioLanePosition {
lifeline: f.sender,
x: if name == "A" { 180. } else { 220. },
}],
messages: [(f.call, 600.), (f.reply, 800.)]
.into_iter()
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
})
.collect(),
})),
},
);
if mode != 0 && name == "A" {
context.representation = Representation::Saved { diagram: id };
if mode == 2 {
project.diagrams.get_mut(&id).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: [(f.call, 600.), (f.reply, 200.)]
.into_iter()
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
})
.collect(),
}));
}
}
}
let mut operation = session(&project, context, &[f.reply, f.call]);
assert_eq!(operation.parent(), f.owner);
assert_eq!(operation.before(), &[f.call, f.reply, x, y]);
assert_eq!(operation.context(), context);
let original_snapshot = operation.snapshot().clone();
let token = operation.token();
let slot = Slot {
parent: f.owner,
before: None,
};
let preview = operation.preview(slot);
assert_eq!(preview.before, [f.call, f.reply, x, y]);
assert_eq!(preview.after, [x, y, f.call, f.reply]);
assert_eq!(preview.outcome, Outcome::Change);
assert_eq!(
preview.presentation.as_ref().unwrap().requested(),
context.representation
);
assert_eq!(operation.proposal_builds, 1);
operation.preview(slot);
assert_eq!(operation.proposal_builds, 1);
operation.preview(Slot {
parent: f.owner,
before: Some(f.call),
});
assert_eq!(operation.proposal_builds, 2);
operation.preview(slot);
assert_eq!(operation.proposal_builds, 3);
assert_eq!(operation.preview.as_ref().unwrap().slot, slot);
let completion = operation
.finish(&project, context, &original_snapshot, token)
.unwrap();
assert_eq!(completion.snapshot, original_snapshot);
assert_eq!(completion.representation, context.representation);
if mode == 2 {
assert!(matches!(
completion.presentation_status,
presentation::PresentationStatus::Fallback { .. }
));
} else {
assert!(matches!(
completion.presentation_status,
presentation::PresentationStatus::Ready
));
}
let mut expected = project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![x, y, f.call, f.reply];
assert_eq!(
execute_batch(&project, &[*completion.command.unwrap()]).unwrap(),
expected
);
assert_eq!(expected.diagrams, project.diagrams);
}
}
#[test]
fn no_destination_is_not_noop_and_selected_anchor_is_explicit_noop() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut operation = session(&f.project, context, &[f.call, f.reply]);
let captured = operation.snapshot().clone();
let token = operation.token();
assert!(
operation
.finish(&f.project, context, &captured, token)
.is_err()
);
operation = session(&f.project, context, &[f.call, f.reply]);
let token = operation.token();
assert_eq!(
operation
.preview(Slot {
parent: f.owner,
before: Some(f.reply)
})
.outcome,
Outcome::Noop
);
assert!(
operation
.finish(&f.project, context, &captured, token)
.unwrap()
.command
.is_none()
);
}
#[test]
fn temporal_candidate_refuses_without_changing_baseline_or_selection() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut project = f.project;
let later = row(&mut project, f.owner, "Later action");
let mut operation = session(&project, context, &[f.reply]);
let captured = operation.snapshot().clone();
let token = operation.token();
// A previously valid proposal must not survive a new refused destination.
let valid = operation.preview(Slot {
parent: f.owner,
before: None,
});
assert_eq!(valid.outcome, Outcome::Change);
assert_eq!(valid.after, [f.call, later, f.reply]);
let preview = operation.preview(Slot {
parent: f.owner,
before: Some(f.call),
});
assert!(matches!(&preview.outcome, Outcome::Refused(reason) if reason.contains("reply_order")));
assert!(preview.presentation.is_none());
assert_eq!(preview.before, preview.after);
assert!(
operation
.finish(&project, context, &captured, token)
.is_err()
);
}
#[test]
fn mixed_cross_parent_and_unrendered_sets_never_reduce_to_primary() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
let mut project = f.project;
let other = project
.add_element(oa_core::Kind::Scenario, Layer::Oa, "Other", None)
.unwrap();
let foreign = row(&mut project, other, "Foreign");
let fragment = occurrence(
&project,
f.owner,
"Fragment",
OccurrenceBody::Fragment {
operator: oa_core::FragmentOperator::Alternative,
branches: vec![],
},
);
let fragment_id = fragment.id;
project.elements.insert(fragment_id, fragment);
project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(fragment_id);
let branch = occurrence(
&project,
fragment_id,
"Branch",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
let branch_id = branch.id;
project.elements.insert(branch_id, branch);
if let Some(oa_core::SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = project.elements.get_mut(&fragment_id).unwrap().shared_mut()
{
branches.push(branch_id);
}
let nested = row(&mut project, branch_id, "Nested");
assert!(
selection::visible(&project, context, f.call).contains(&Target::ScenarioObject(nested))
);
for ids in [
vec![f.sender, f.call],
vec![nested, f.call],
vec![foreign, f.call],
vec![Id::new_v4(), f.call],
] {
assert!(
Session::new(
Arc::new(project.clone()),
context,
snapshot(context, &ids),
Id::new_v4()
)
.is_err()
);
}
}
#[test]
fn stale_finish_checks_every_immutable_input_and_constructor_rejects_nil_lifetimes() {
let f = direct_tests::fixture();
let context = direct_tests::context(f.owner);
for altered in 0..8 {
let mut operation = session(&f.project, context, &[f.call, f.reply]);
let mut token = operation.token();
let mut captured = operation.snapshot().clone();
let mut current = f.project.clone();
let mut live = context;
operation.preview(Slot {
parent: f.owner,
before: None,
});
match altered {
0 => {
current.elements.get_mut(&f.call).unwrap().name.push('!');
}
1 => live.epoch += 1,
2 => captured = snapshot(context, &[f.reply, f.call]),
3 => token = Id::new_v4(),
4 => live.visit = Id::new_v4(),
5 => live.document = Id::new_v4(),
6 => live.revision += 1,
_ => {
let mut selection = Selection::new(selection::context(context).scope);
selection.add(Target::ScenarioObject(f.call), |_| true);
selection.add(Target::ScenarioObject(f.reply), |_| true);
selection.set_primary(&Target::ScenarioObject(f.call));
selection.set_primary(&Target::ScenarioObject(f.reply));
captured = selection.snapshot(selection::context(context)).unwrap();
assert_eq!(captured.targets(), operation.snapshot().targets());
assert_eq!(captured.primary(), operation.snapshot().primary());
assert_ne!(captured.generation(), operation.snapshot().generation());
}
}
assert!(operation.finish(&current, live, &captured, token).is_err());
}
for altered in 0..3 {
let mut live = context;
let mut token = Id::new_v4();
match altered {
0 => live.document = Id::nil(),
1 => live.visit = Id::nil(),
_ => token = Id::nil(),
}
assert!(
Session::new(
Arc::new(f.project.clone()),
live,
snapshot(live, &[f.call]),
token
)
.is_err()
);
}
}
@@ -1,234 +0,0 @@
//! Candidate-input experiment, not an accepted new interaction policy.
//! Exercise production presentation, nearest-Y picking, and exact semantic slots.
use super::{Representation, Slot, group_reorder, presentation};
use crate::{behavior::Scene, diagram_usage::Target};
use iced::Point;
use oa_core::{
Diagram, DiagramLayout, Id, OccurrenceEndpoint, Project, ProjectionKind, ScenarioLayout,
ScenarioMessagePosition, execute_batch, language, validation,
};
use std::sync::Arc;
struct Fixture {
project: Project,
owner: Id,
diagram: Id,
ids: [Id; 4],
}
fn fixture(ys: [f32; 4]) -> Fixture {
let mut project = language::parse(SOURCE).unwrap();
let named = |name: &str| {
project
.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let owner = named("conversation");
let ids = [named("a"), named("b"), named("c"), named("d")];
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Authored coordinates".into(),
description: String::new(),
layer: project.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![],
messages: ids
.into_iter()
.zip(ys)
.map(|(occurrence, send_y)| ScenarioMessagePosition {
address: OccurrenceEndpoint {
occurrence,
references: vec![],
},
send_y,
})
.collect(),
})),
},
);
assert!(
validation::validate(&project)
.iter()
.all(|issue| issue.severity != validation::Severity::Error)
);
Fixture {
project,
owner,
diagram,
ids,
}
}
fn shown(f: &Fixture) -> presentation::ScenarioPresentation {
let shown = presentation::present(
&f.project,
Representation::Saved { diagram: f.diagram },
f.ids[0],
);
assert_eq!(shown.status(), &presentation::PresentationStatus::Ready);
assert_eq!(shown.requested(), shown.geometry_representation().unwrap());
shown
}
fn before(f: &Fixture, id: Id) -> Slot {
Slot {
parent: f.owner,
before: Some(id),
}
}
fn y(scene: &Scene, slot: Slot) -> f32 {
scene.slots.iter().find(|item| item.slot == slot).unwrap().y
}
fn picked(scene: &Scene, f: &Fixture, world_y: f32) -> Slot {
scene
.slot_at(Point::new(100., world_y), Some(f.owner))
.unwrap()
.slot
}
fn exact_change(f: &Fixture, selected: &[Id], slot: Slot, order: &[Id]) {
let prepared = group_reorder::Prepared::new(
Arc::new(f.project.clone()),
f.owner,
&selected
.iter()
.copied()
.map(Target::ScenarioObject)
.collect::<Vec<_>>(),
)
.unwrap();
let group_reorder::Plan::Change(command) = prepared.plan(slot).unwrap() else {
panic!("expected an actual semantic change")
};
let actual = execute_batch(&f.project, std::slice::from_ref(command.as_ref())).unwrap();
let mut expected = f.project.clone();
*expected
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = order.to_vec();
// Literal full-model oracle: every authored position and other field survives.
assert_eq!(actual, expected);
assert_eq!(
language::parse(&language::format(&actual)).unwrap(),
expected
);
assert_eq!(
presentation::present(
&actual,
Representation::Saved { diagram: f.diagram },
selected[0]
)
.status(),
&presentation::PresentationStatus::Ready
);
}
#[test]
fn crossed_saved_slots_are_reachable_but_screen_order_is_not_semantic_order() {
let f = fixture([200., 500., 300., 400.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(
f.ids.map(|id| y(scene, before(&f, id))),
[158., 458., 258., 358.]
);
let mut spatial = f.ids.to_vec();
spatial.sort_by(|a, b| y(scene, before(&f, *a)).total_cmp(&y(scene, before(&f, *b))));
assert_eq!(spatial, [a, c, d, b]);
for id in f.ids {
assert_eq!(picked(scene, &f, y(scene, before(&f, id))), before(&f, id));
}
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
}
#[test]
fn equal_saved_slots_hide_one_destination_while_exact_chooser_can_select_it() {
let f = fixture([200., 300., 300., 500.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, b)), 258.);
assert_eq!(y(scene, before(&f, c)), 258.);
assert!(
scene
.slots
.iter()
.position(|item| item.slot == before(&f, b))
< scene
.slots
.iter()
.position(|item| item.slot == before(&f, c))
);
// The two distances are identical for every finite Y; min_by keeps the first
// tie. The sample exercises that production behavior, not an exhaustive proof.
for quarter_pixel in -4000..=12000 {
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, c));
}
assert_eq!(picked(scene, &f, 258.), before(&f, b));
exact_change(&f, &[d, a], before(&f, c), &[b, a, d, c]);
exact_change(&f, &[d, a], picked(scene, &f, 258.), &[a, d, b, c]);
}
#[test]
fn close_saved_slots_have_subpixel_hit_regions_without_becoming_unreachable_continuously() {
let f = fixture([200., 300., 300.125, 300.25]);
let [_, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, b)), 258.);
assert_eq!(y(scene, before(&f, c)), 258.125);
assert_eq!(y(scene, before(&f, d)), 258.25);
assert_eq!(picked(scene, &f, 258.125), before(&f, c));
// Fixed scale=1/pan=0: C's open Voronoi interval is only
// (258.0625, 258.1875). Integer-pixel samples miss it. This does not assert
// that all platforms quantize pointer input to integer coordinates.
for screen_y in 0..=600 {
assert_ne!(picked(scene, &f, screen_y as f32), before(&f, c));
}
}
#[test]
fn offscreen_saved_slot_needs_camera_change_or_nonspatial_destination_input() {
let f = fixture([200., 300., 400., 2000.]);
let [a, b, c, d] = f.ids;
let shown = shown(&f);
let scene = shown.scene().unwrap();
assert_eq!(y(scene, before(&f, d)), 1958.);
// Fixed scale=1/pan=0 and a 600px-high canvas. The C/D midpoint is
// 1158, beyond the whole viewport, so D cannot win within that viewport.
for quarter_pixel in 0..=2400 {
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, d));
}
assert_eq!(picked(scene, &f, 1958.), before(&f, d));
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
}
const SOURCE: &str = r#"arcadia 7;
project slot_probe "Slot probe" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000004" { viewpoint OperationalActivity; steps [@a, @b, @c, @d]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000005" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @target; }
oa_occurrence a "A" id "71000000-0000-4000-8000-000000000007" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence b "B" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence c "C" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence d "D" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
}
"#;
@@ -1,323 +0,0 @@
//! Identity-based destination choice, independent of saved screen coordinates.
use super::*;
use crate::app::scenario_reorder::{Event, Intent};
use crate::behavior::scenario_layout::presentation::PresentationStatus;
use iced::widget::{column, opaque, scrollable};
const PAGE_SIZE: usize = 5;
pub(crate) fn query_id(token: Id) -> iced::widget::Id {
iced::widget::Id::from(format!("scenario-reorder-query:{token}"))
}
pub(crate) fn focus_query(token: Id) -> iced::Task<Message> {
use iced::advanced::widget::{Operation, operation};
struct Find {
target: iced::widget::Id,
present: bool,
}
impl Operation for Find {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&iced::widget::Id>,
_: iced::Rectangle,
_: &mut dyn operation::Focusable,
) {
self.present |= id == Some(&self.target);
}
fn finish(&self) -> operation::Outcome<()> {
if self.present {
operation::Outcome::Chain(Box::new(operation::focusable::focus(
self.target.clone(),
)))
} else {
operation::Outcome::None
}
}
}
// Generic focus would blur every current widget if the old token vanished.
// Discover the exact target without changing focus before chaining it.
iced::advanced::widget::operate(Find {
target: query_id(token),
present: false,
})
.discard()
}
pub(crate) fn apply_message(app: &App) -> Option<Message> {
if !app.scenario_reorder_enabled() {
return None;
}
let session = app.scenario_reorder.session.as_ref()?;
let preview = session.current_preview()?;
(preview.selection_visible
&& !matches!(preview.outcome, reorder_session::Outcome::Refused(_))
&& matches!(
preview.presentation.as_ref()?.status(),
PresentationStatus::Ready | PresentationStatus::Fallback { .. }
))
.then(|| message(session, Intent::Finish(preview.slot)))
}
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
Message::ScenarioReorder(Box::new(Event {
context: session.context(),
snapshot: session.snapshot().clone(),
token: session.token(),
intent,
}))
}
pub(crate) fn begin(app: &App) -> Option<Message> {
(app.scenario_reorder_enabled() && app.scenario_reorder.session.is_none()).then_some(())?;
let snapshot = app.multiselect_snapshot()?;
if snapshot.targets().is_empty() {
return None;
}
Some(Message::ScenarioReorder(Box::new(Event {
context: app.scenario_context()?,
snapshot,
token: Id::new_v4(),
intent: Intent::Begin,
})))
}
fn label(p: &Project, id: Id) -> String {
p.elements.get(&id).map_or_else(
|| id.to_string(),
|e| format!("{} (@{})", short(&e.name, 38), e.alias),
)
}
fn destination(p: &Project, before: &[Id], slot: Slot) -> String {
slot.before.map_or_else(
|| "End of sequence".into(),
|id| {
let position = before
.iter()
.position(|item| *item == id)
.map_or(0, |i| i + 1);
format!("Before {position}. {}", label(p, id))
},
)
}
fn order(p: &Project, ids: &[Id], snapshot: &crate::diagram_selection::Snapshot) -> String {
// A bounded neighborhood keeps the moved block and its immediate neighbors
// visible. Ellipses explicitly mark omitted, unchanged sequence members.
let selected = |id: &Id| {
snapshot
.targets()
.contains(&crate::diagram_usage::Target::ScenarioObject(*id))
};
let mut parts = Vec::new();
let mut omitted = false;
for (index, id) in ids.iter().enumerate() {
let near = selected(id)
|| index.checked_sub(1).is_some_and(|i| selected(&ids[i]))
|| ids.get(index + 1).is_some_and(selected);
if !near {
omitted = true;
continue;
}
if omitted {
parts.push("…".into());
omitted = false;
}
parts.push(format!(
"{}{}. {}",
if selected(id) { "• " } else { "" },
index + 1,
label(p, *id)
));
}
if omitted {
parts.push("…".into());
}
parts.join("\n")
}
pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Message>> {
let session = app.scenario_reorder.session.as_ref()?;
let p = app.project();
let state = &app.scenario_reorder;
let query = state.query.to_lowercase();
let slots = session
.before()
.iter()
.copied()
.map(Some)
.chain(std::iter::once(None))
.enumerate()
.map(|(index, before)| {
let slot = Slot {
parent: session.parent(),
before,
};
let title = before.map_or_else(
|| "End of sequence".into(),
|id| format!("Before {}. {}", index + 1, label(p, id)),
);
(slot, title)
})
.filter(|(slot, title)| {
title.to_lowercase().contains(&query)
|| slot
.before
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| {
e.name.to_lowercase().contains(&query)
|| e.alias.to_lowercase().contains(&query)
})
})
.collect::<Vec<_>>();
let pages = slots.len().div_ceil(PAGE_SIZE).max(1);
let page = state.page.min(pages - 1);
let mut choices = column![].spacing(4).width(Fill);
for (slot, title) in slots.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
choices = choices.push(
button(
text(title.clone())
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
)
.width(Fill)
.on_press(message(session, Intent::Choose(*slot))),
);
}
if slots.is_empty() {
choices = choices
.push(text("No matching destination. Try its name, alias or position.").size(12));
}
let mut body = column![
text(format!(
"Reorder {} selected",
session.snapshot().targets().len()
))
.size(17),
text(format!("Sequence: {}", label(p, session.parent())))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
text("Changes execution order; saved positions stay as authored.").size(12),
text_input("Find destination by name, alias or position", &state.query)
.id(query_id(session.token()))
.on_input(move |query| message(session, Intent::Query(query)))
.on_submit_maybe(
(slots.len() == 1).then(|| message(session, Intent::Choose(slots[0].0)))
)
.size(13),
choices,
row![
button("Previous")
.on_press_maybe((page > 0).then(|| message(session, Intent::Page(page - 1)))),
text(format!("{} / {pages}", page + 1)).size(12),
button("Next").on_press_maybe(
(page + 1 < pages).then(|| message(session, Intent::Page(page + 1)))
)
]
.spacing(8),
]
.spacing(8)
.width(Fill);
let mut apply = None;
let mut footer =
column![text("Enter previews a unique match. Ctrl+Enter applies; Esc cancels.").size(12)]
.spacing(6);
if let Some(preview) = session.current_preview() {
footer = footer.push(
text(format!(
"Destination: {}",
destination(p, session.before(), preview.slot)
))
.size(13)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
);
let comparison = row![
container(
column![
text("Before").size(13),
text(order(p, &preview.before, session.snapshot()))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
]
.width(Fill)
)
.width(Fill)
.id("scenario-reorder-before"),
container(
column![
text("After • selected").size(13),
text(order(p, &preview.after, session.snapshot()))
.size(12)
.width(Fill)
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
]
.width(Fill)
)
.width(Fill)
.id("scenario-reorder-after")
]
.spacing(10)
.width(Fill);
body = body.push(comparison);
let feedback = match &preview.outcome {
reorder_session::Outcome::Refused(reason) => reason.clone(),
_ if !preview.selection_visible => {
"This destination would hide a selected occurrence; choose another destination."
.into()
}
_ => match preview.presentation.as_ref().map(|shown| shown.status()) {
Some(PresentationStatus::Unavailable { .. }) | None => {
"This order cannot be displayed; choose another destination.".into()
}
Some(status) => {
apply = Some(message(session, Intent::Finish(preview.slot)));
if matches!(status, PresentationStatus::Fallback { .. }) {
"Apply will display an automatic arrangement. Your requested saved view and authored positions are retained; they are not repaired.".into()
} else if matches!(preview.outcome, reorder_session::Outcome::Noop) {
"This destination keeps the current order; Apply will close without an edit.".into()
} else {
"The selected occurrences move together in their existing order. One Undo restores the sequence.".into()
}
}
},
};
footer = footer.push(text(feedback).size(12));
} else {
body = body.push(text("Choose a destination to preview the new sequence.").size(12));
}
let height = viewport.map_or(500., |size| (size.height - 32.).max(100.));
let card = column![
scrollable(body)
.height(Fill)
.width(Fill)
.spacing(8)
.id("scenario-reorder-body"),
footer,
row![
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
button("Apply order (Ctrl+Enter)").on_press_maybe(apply)
]
.spacing(8)
]
.spacing(10);
Some(
container(opaque(
container(card)
.padding(12)
.width(480)
.height(height.min(630.))
.style(style::card),
))
.align_right(Fill)
.padding(8)
.into(),
)
}
@@ -1,54 +0,0 @@
//! Captured exact Scenario selection input; membership grants no movement permission.
use super::{Context, presentation};
use crate::{
app::multiselect::Choice,
diagram_selection,
diagram_usage::{Surface, Target},
};
use oa_core::{Id, Project};
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub context: Context,
pub snapshot: diagram_selection::Snapshot,
pub intent: Intent,
}
#[derive(Debug, Clone)]
pub(crate) enum Intent {
Choose(Target, Choice),
Clear,
Collapse(Target),
Refuse(String),
}
pub(crate) fn context(context: Context) -> diagram_selection::Context {
diagram_selection::Context {
scope: diagram_selection::Scope {
document: context.document,
surface: match context.representation {
super::Representation::Derived { owner } => Surface::Scenario(owner),
super::Representation::Saved { diagram } => Surface::Saved(diagram),
},
},
revision: context.revision,
epoch: context.epoch,
}
}
pub(crate) fn visible(project: &Project, context: Context, selected: Id) -> Vec<Target> {
let shown = presentation::present(project, context.representation, selected);
shown
.scene()
.map_or_else(Vec::new, |scene| scene.selection_targets())
}
pub(crate) fn message(
context: Context,
snapshot: &diagram_selection::Snapshot,
intent: Intent,
) -> crate::app::Message {
crate::app::Message::ScenarioCanvas(super::CanvasEvent::Selection(Box::new(Event {
context,
snapshot: snapshot.clone(),
intent,
})))
}
#[cfg(test)]
mod tests;
@@ -1,268 +0,0 @@
use super::*;
use crate::behavior::{
Scene, SequenceInput,
scenario::{self, CanvasEvent, CanvasTool},
};
use crate::diagram_selection::Selection;
use iced::{
Point, Rectangle, Vector, mouse,
widget::canvas::{self, Program},
};
fn fixture(group: bool) -> (scenario::direct_tests::Fixture, Scene, Point) {
let f = scenario::direct_tests::fixture();
let context = scenario::direct_tests::context(f.owner);
let mut scene = Scene::sequence(&f.project, f.owner, f.reply);
scene.interaction = Some((context, CanvasTool::Select, true));
let mut selection = Selection::new(super::context(context).scope);
selection.add(Target::ScenarioObject(f.call), |_| true);
if group {
selection.add(Target::ScenarioObject(f.reply), |_| true);
}
scene.selection = Some((
context,
selection.snapshot(super::context(context)).unwrap(),
));
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = line.label_at + Vector::new(8., 8.);
assert_eq!(scene.hit(point), Some(f.call));
(f, scene, point)
}
fn send(
scene: &Scene,
state: &mut SequenceInput,
event: canvas::Event,
point: Option<Point>,
) -> Option<CanvasEvent> {
scene
.update(
state,
&event,
Rectangle::new(Point::ORIGIN, scene.size),
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
)
.and_then(|action| action.into_inner().0)
.and_then(|message| match message {
crate::app::Message::ScenarioCanvas(event) => Some(event),
_ => None,
})
}
fn mouse_event(event: mouse::Event) -> canvas::Event {
canvas::Event::Mouse(event)
}
#[test]
fn saved_group_body_press_emits_one_spacing_lifetime_without_collapsing_selection() {
use crate::behavior::scenario::spacing::{self, Event};
use crate::behavior::scenario_layout::{Representation, resolve};
let (doc, owner, diagram, _, first) = spacing::controller::tests::fixture();
let second = doc
.project()
.identities()
.into_iter()
.find(|(_, a)| *a == "second")
.unwrap()
.0;
let mut context = scenario::direct_tests::context(owner);
context.representation = Representation::Saved { diagram };
let mut scene = resolve(doc.project(), context.representation, second)
.unwrap()
.scene;
let mut selection = Selection::new(super::context(context).scope);
selection.add(Target::ScenarioObject(first), |_| true);
selection.add(Target::ScenarioObject(second), |_| true);
let snapshot = selection.snapshot(super::context(context)).unwrap();
let stamp = spacing::Stamp {
context,
camera_epoch: 9,
selection_generation: snapshot.generation(),
};
scene.selection = Some((context, snapshot.clone()));
scene.selected = second;
scene.interaction = Some((context, CanvasTool::Select, true));
scene.coordinate_input = true;
scene.spacing_stamp = Some(stamp);
let line = scene.lines.iter().find(|line| line.id == first).unwrap();
let point = Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y);
assert_eq!(scene.hit(point), Some(first));
let mut input = SequenceInput::default();
let Some(CanvasEvent::Spacing(Event::Begin {
stamp: got,
token,
target,
..
})) = send(
&scene,
&mut input,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
)
else {
panic!("group spacing begins on first press")
};
assert_eq!(got, stamp);
assert_eq!(target, spacing::Target::Message(first));
assert!(input.group_press.is_none());
let moved = point + Vector::new(0., 24.);
assert!(
matches!(send(&scene,&mut input,mouse_event(mouse::Event::CursorMoved{position:moved}),Some(moved)),Some(CanvasEvent::Spacing(Event::Move{stamp:s,token:t,..})) if s==stamp && t==token)
);
assert!(
matches!(send(&scene,&mut input,mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),Some(moved)),Some(CanvasEvent::Spacing(Event::Finish{stamp:s,token:t,..})) if s==stamp && t==token)
);
assert_eq!(scene.selection, Some((context, snapshot)));
assert_eq!(scene.selected, second);
assert!(input.press.is_none() && input.group_press.is_none());
}
#[test]
fn group_press_retains_snapshot_and_collapses_only_stationary_release_once() {
let (f, scene, point) = fixture(true);
let mut state = SequenceInput::default();
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point)
)
.is_none()
);
assert!(state.press.is_none());
let captured = state.group_press.as_ref().unwrap().snapshot.clone();
assert_eq!(captured.primary(), Some(&Target::ScenarioObject(f.reply)));
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
) else {
panic!("captured collapse")
};
assert_eq!(event.snapshot, captured);
assert!(matches!(event.intent, Intent::Collapse(Target::ScenarioObject(id)) if id == f.call));
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)
.is_none()
);
}
#[test]
fn crossed_threshold_return_to_origin_refuses_whole_set_and_outside_release_cancels() {
for outside in [false, true] {
let (_, scene, point) = fixture(true);
let mut state = SequenceInput::default();
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
let moved = point + Vector::new(12., 0.);
send(
&scene,
&mut state,
mouse_event(mouse::Event::CursorMoved { position: moved }),
Some(moved),
);
send(
&scene,
&mut state,
mouse_event(mouse::Event::CursorMoved { position: point }),
Some(point),
);
let event = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
(!outside).then_some(point),
);
if outside {
assert!(event.is_none());
} else {
let Some(CanvasEvent::Selection(event)) = event else {
panic!("whole-set refusal")
};
assert!(matches!(event.intent, Intent::Refuse(_)));
assert_eq!(event.snapshot.targets().len(), 2);
}
assert!(state.group_press.is_none() && state.press.is_none());
}
}
#[test]
fn passive_shift_survives_context_refresh_and_window_loss_clears_it() {
let (f, mut scene, point) = fixture(false);
let mut state = SequenceInput::default();
assert!(
send(
&scene,
&mut state,
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
iced::keyboard::Modifiers::SHIFT
)),
Some(point)
)
.is_none()
);
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
) else {
panic!("toggle")
};
assert!(
matches!(event.intent, Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if id == f.call)
);
assert!(state.press.is_none());
scene.interaction.as_mut().unwrap().0.epoch += 1;
send(
&scene,
&mut state,
canvas::Event::Window(iced::window::Event::Unfocused),
None,
);
assert!(state.modifiers.is_empty());
}
#[test]
fn stale_selection_and_fallback_release_do_not_move_primary() {
let (_, mut scene, point) = fixture(true);
scene.coordinate_input = false;
let mut state = SequenceInput::default();
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
let Some(CanvasEvent::Selection(event)) = send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
) else {
panic!("fallback stationary selection remains usable")
};
assert!(matches!(event.intent, Intent::Collapse(_)));
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
);
scene.selection.as_mut().unwrap().0.visit = Id::new_v4();
assert!(
send(
&scene,
&mut state,
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point)
)
.is_none()
);
assert!(state.group_press.is_none());
}
@@ -5,17 +5,13 @@
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use crate::diagram_usage::Target as SelectionTarget;
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
use std::collections::BTreeSet;
pub(crate) mod controller;
#[cfg(test)]
mod group_tests;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
@@ -34,7 +30,6 @@ impl Target {
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSelection(&'static str),
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
@@ -50,7 +45,6 @@ pub(super) struct Drag {
diagram: Id,
target: Target,
origin: f32,
other_origins: Vec<(Target, f32)>,
original_geometry: ResolvedScenarioPlan,
}
@@ -64,135 +58,17 @@ pub(super) struct Preview {
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
capture_targets(project, diagram, target, &[target])
}
/// The exact selected set is one operation, never an eligible subset. Selection
/// order and the pressed member do not determine persistence order.
pub(super) fn capture_group(
project: &Project,
diagram: Id,
pressed: Target,
targets: &[SelectionTarget],
) -> Result<Drag, Refusal> {
if targets.is_empty() {
return Err(Refusal::InvalidSelection(
"Select at least one occurrence to move.",
));
}
let mut seen = BTreeSet::new();
let mut members = Vec::with_capacity(targets.len());
for selected in targets {
let SelectionTarget::ScenarioObject(id) = selected else {
return Err(Refusal::InvalidSelection(
"Move only local Scenario lifelines or messages.",
));
};
if !seen.insert(*id) {
return Err(Refusal::InvalidSelection(
"The selection contains a duplicate occurrence.",
));
}
let target = match pressed {
Target::Lane(_) => Target::Lane(*id),
Target::Message(_) => Target::Message(*id),
};
let Some(element) = project.elements.get(id) else {
return Err(Refusal::TargetNotRendered(target));
};
let same_role = match target {
Target::Lane(_) => element.kind() == Kind::Lifeline,
Target::Message(_) => matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
),
};
if !same_role {
return Err(Refusal::InvalidSelection(
"Move a group of lifelines or a group of messages; mixed and other occurrence types are not supported.",
));
}
members.push(target);
}
if !members.contains(&pressed) {
return Err(Refusal::InvalidSelection(
"The pressed occurrence is not in the captured selection.",
));
}
members.sort_by_key(|target| target.id());
capture_targets(project, diagram, pressed, &members)
}
// Require the actual local sequence/operand path, not just an ancestor pointer.
// Cross-operand groups are allowed; no member changes its existing parent.
fn local_message(project: &Project, owner: Id, id: Id) -> bool {
let mut current = id;
let mut visited = BTreeSet::new();
while current != owner && visited.insert(current) {
let Some(element) = project.elements.get(&current) else {
return false;
};
let Some(parent) = element.parent.and_then(|id| project.elements.get(&id)) else {
return false;
};
if element.layer() != project.elements[&owner].layer() {
return false;
}
let listed = match parent.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
) && branches.iter().filter(|&&id| id == current).count() == 1
}
_ => parent
.steps()
.is_some_and(|steps| steps.iter().filter(|&&id| id == current).count() == 1),
};
if !listed {
return false;
}
current = parent.id;
}
current == owner
}
fn captured_origin(
project: &Project,
owner: Id,
geometry: &ResolvedScenarioPlan,
target: Target,
) -> Result<f32, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| {
e.kind() == Kind::Lifeline
&& e.parent == Some(owner)
&& e.layer() == project.elements[&owner].layer()
}) =>
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
{
// Unassigned headers are still rendered local targets.
let headers = geometry
.scene
.boxes
.iter()
.filter(|item| {
item.id == id && item.role == crate::behavior::SceneHitRole::LifelineHeader
})
.count();
(headers == 1)
.then(|| geometry.scene.render_lanes.get(&id).copied())
.flatten()
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
}
Target::Message(id)
if element.is_some_and(|e| {
@@ -203,16 +79,17 @@ fn captured_origin(
..
})
)
}) && local_message(project, owner, id) =>
}) =>
{
// A lifecycle receive row is not the stored send coordinate. Reject
// ambiguous repeated drawing IDs rather than choosing the first.
let mut lines = geometry.scene.lines.iter().filter(|line| line.id == id);
let first = lines
.next()
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.and_then(|line| line.points.first())
.map(|point| point.y);
if lines.next().is_some() { None } else { first }
.map(|point| point.y)
}
_ => None,
}
@@ -220,38 +97,11 @@ fn captured_origin(
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(origin)
}
fn capture_targets(
project: &Project,
diagram: Id,
target: Target,
targets: &[Target],
) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let origins = targets
.iter()
.map(|member| {
captured_origin(project, owner, &geometry, *member).map(|origin| (*member, origin))
})
.collect::<Result<Vec<_>, _>>()?;
let origin = origins
.iter()
.find(|(member, _)| *member == target)
.expect("admitted pressed member")
.1;
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
other_origins: origins
.into_iter()
.filter(|(member, _)| *member != target)
.collect(),
original_geometry: geometry,
})
}
@@ -267,50 +117,28 @@ impl Drag {
if original_delta == 0. {
return Ok(None);
}
let mut commands = Vec::new();
for (target, origin) in
std::iter::once((self.target, self.origin)).chain(self.other_origins.iter().copied())
{
let position = (f64::from(origin) + f64::from(original_delta)) as f32;
if !position.is_finite() || position.abs() > oa_core::MAX_COORDINATE {
return Err(Refusal::Command(
"Scenario positions must be finite and within the shared coordinate bounds."
.into(),
));
}
if position == origin {
continue;
}
let command = match target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
commands.push((target.id(), command));
}
if commands.is_empty() {
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
return Ok(None);
}
commands.sort_by_key(|(id, _)| *id);
let mut commands: Vec<_> = commands.into_iter().map(|(_, command)| command).collect();
let command = if self.other_origins.is_empty() {
commands.pop().expect("changed singleton")
} else {
Command::Batch(commands)
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
@@ -5,7 +5,6 @@ use crate::behavior::{
scenario::{CanvasTool, Context, State},
scenario_layout::Diagnostic,
};
use crate::{diagram_selection::Snapshot, diagram_usage::Target as SelectionTarget};
use iced::{Point, Rectangle, Vector};
#[cfg(test)]
@@ -17,7 +16,6 @@ mod timing_tests;
pub struct Stamp {
pub(crate) context: Context,
pub(crate) camera_epoch: u64,
pub(crate) selection_generation: u64,
}
#[derive(Clone, Copy, Debug)]
@@ -74,14 +72,11 @@ pub(crate) struct Session {
drag: Drag,
preview: Option<Preview>,
refusal: Option<Refusal>,
group_refusal: Option<Refusal>,
ever_crossed: bool,
snapshot: Option<Snapshot>,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
pub(in crate::behavior) selected_count: usize,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
@@ -103,21 +98,7 @@ pub(crate) enum Finish {
impl Session {
fn configure(&self, scene: &mut Scene) {
scene.selected = self
.snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.and_then(|s| match s.primary() {
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
_ => None,
})
.unwrap_or(self.drag.target.id());
scene.selection = self
.snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.cloned()
.map(|s| (self.stamp.context, s));
scene.selected = self.drag.target.id();
scene.scale = self.scale;
scene.camera_pan = self.pan;
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
@@ -136,18 +117,8 @@ impl Session {
Target::Lane(_) => screen.x - self.press_screen.x,
Target::Message(_) => screen.y - self.press_screen.y,
};
self.ever_crossed |= if self
.snapshot
.as_ref()
.is_some_and(|s| s.targets().len() > 1)
{
screen.distance(self.press_screen) >= 3.
} else {
distance.abs() >= 3.
};
if !self.ever_crossed || self.group_refusal.is_some() {
// A rejected whole set may retain a pressed singleton only as its
// original display witness. It must never prepare a partial move.
self.ever_crossed |= distance.abs() >= 3.;
if !self.ever_crossed {
return;
}
match self.drag.prepare(distance / self.scale) {
@@ -168,9 +139,6 @@ impl State {
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
self.spacing.as_ref().map(|s| s.stamp)
}
pub(crate) fn spacing_snapshot(&self) -> Option<&Snapshot> {
self.spacing.as_ref()?.snapshot.as_ref()
}
pub(crate) fn cancel_spacing(&mut self) -> bool {
self.spacing.take().is_some()
}
@@ -181,55 +149,28 @@ impl State {
camera_epoch: u64,
) -> Option<Paint<'_>> {
let session = self.spacing.as_ref()?;
if session.stamp.context != context || session.stamp.camera_epoch != camera_epoch {
if session.stamp
!= (Stamp {
context,
camera_epoch,
})
{
return None;
}
let original = &session.drag.original_geometry;
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
Some(Paint {
scene: &shown.scene,
selected_count: session
.snapshot
.as_ref()
.map_or(1, |s| s.targets().len().max(1)),
#[cfg(test)]
bounds: shown.bounds,
refusal: session.refusal.as_ref().or_else(|| {
session
.ever_crossed
.then_some(session.group_refusal.as_ref())
.flatten()
}),
refusal: session.refusal.as_ref(),
original_diagnostics: &original.plan.diagnostics,
original_latent: &original.latent_messages,
diagnostics: &shown.plan.diagnostics,
})
}
#[cfg(test)]
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
self.begin_spacing_capture(project, event, None)
}
pub(crate) fn begin_spacing_with_snapshot(
&mut self,
project: &Project,
event: Event,
snapshot: Snapshot,
) -> Result<(), Refusal> {
if snapshot.generation() != event.stamp().selection_generation
|| snapshot.context()
!= crate::behavior::scenario::selection::context(event.stamp().context)
{
return Err(Refusal::InvalidGesture);
}
self.begin_spacing_capture(project, event, Some(snapshot))
}
fn begin_spacing_capture(
&mut self,
project: &Project,
event: Event,
snapshot: Option<Snapshot>,
) -> Result<(), Refusal> {
let Event::Begin {
stamp,
token,
@@ -275,34 +216,7 @@ impl State {
let Representation::Saved { diagram } = context.representation else {
return Err(Refusal::InvalidGesture);
};
let mut group_refusal = None;
let mut drag = if let Some(selected) = snapshot.as_ref().filter(|s| s.targets().len() > 1) {
if !selected
.targets()
.contains(&SelectionTarget::ScenarioObject(target.id()))
{
return Err(Refusal::TargetNotHit(target));
}
match capture_group(project, diagram, target, selected.targets()) {
Ok(drag) => drag,
Err(error)
if matches!(
&error,
Refusal::InvalidSelection(_) | Refusal::TargetNotRendered(_)
) =>
{
// Selection clicks do not require movement eligibility.
// This capture supplies original paint and the exact hit
// only; group_refusal forbids calling its prepare method.
let original = capture(project, diagram, target)?;
group_refusal = Some(error);
original
}
Err(error) => return Err(error),
}
} else {
capture(project, diagram, target)?
};
let mut drag = capture(project, diagram, target)?;
let camera = self.presentation.camera(drag.original_geometry.bounds);
if self.presentation.viewport != Some(canvas_bounds.size())
|| camera.scale != scale
@@ -332,19 +246,7 @@ impl State {
}
// Configure baseline without changing the persistent camera or selection.
let scene = &mut drag.original_geometry.scene;
scene.selected = snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.and_then(|s| match s.primary() {
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
_ => None,
})
.unwrap_or(target.id());
scene.selection = snapshot
.as_ref()
.filter(|s| s.targets().len() > 1)
.cloned()
.map(|s| (context, s));
scene.selected = target.id();
scene.scale = scale;
scene.camera_pan = pan;
scene.interaction = Some((context, CanvasTool::Select, true));
@@ -360,9 +262,7 @@ impl State {
drag,
preview: None,
refusal: None,
group_refusal,
ever_crossed: false,
snapshot,
});
Ok(())
}
@@ -382,9 +282,6 @@ impl State {
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
if let Some(error) = session.group_refusal {
return Err(error);
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
@@ -95,7 +95,6 @@ pub(crate) fn packet(
stamp: Stamp {
context,
camera_epoch,
selection_generation: 0,
},
token: Id::new_v4(),
target,
@@ -136,55 +135,6 @@ fn press(event: Event) -> Point {
press_screen
}
#[test]
fn group_preview_keeps_captured_members_and_nonpressed_primary() {
use crate::diagram_selection::Selection;
use crate::diagram_usage::Target as Selected;
let (doc, mut state, mut event) = setup(true);
let context = event.stamp().context;
let first = match event {
Event::Begin { target, .. } => target.id(),
_ => unreachable!(),
};
let second = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "second")
.unwrap()
.0;
let selection_context = crate::behavior::scenario::selection::context(context);
let mut selection = Selection::new(selection_context.scope);
selection.add(Selected::ScenarioObject(first), |_| true);
selection.add(Selected::ScenarioObject(second), |_| true);
let snapshot = selection.snapshot(selection_context).unwrap();
if let Event::Begin { stamp, .. } = &mut event {
stamp.selection_generation = snapshot.generation();
}
let start = press(event);
state
.begin_spacing_with_snapshot(doc.project(), event, snapshot.clone())
.unwrap();
for delta in [Vector::new(0., 0.), Vector::new(0., 24.)] {
state.move_spacing(start + delta);
let paint = state
.spacing_paint(context, event.stamp().camera_epoch)
.unwrap();
assert_eq!(paint.scene.selected, second);
assert_eq!(
paint.scene.selection.as_ref().unwrap(),
&(context, snapshot.clone())
);
assert_eq!(paint.selected_count, 2);
}
state.move_spacing(start + Vector::new(50., 0.));
assert!(matches!(
state.finish_spacing(start + Vector::new(50., 0.)).unwrap(),
Finish::Noop
));
assert!(!doc.can_undo());
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
@@ -1,675 +0,0 @@
use super::*;
use crate::document::Document;
use oa_core::{Diagram, DiagramLayout, Layer, ProjectionKind, ScenarioLayout, language};
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let right = id(&project, "right");
let mut views = Vec::new();
for (alias, layout) in [
("active_view", layout),
(
"untouched_view",
Some(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: right,
x: 700.,
}],
messages: vec![],
}),
),
] {
let diagram = Id::new_v4();
project.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: alias.into(),
name: alias.into(),
description: "Preserve authored view metadata".into(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: layout.map(DiagramLayout::Scenario),
},
);
views.push(diagram);
}
(
Document::from_project(project, Default::default()).unwrap(),
views[0],
views[1],
)
}
fn targets(ids: &[Id]) -> Vec<SelectionTarget> {
ids.iter()
.copied()
.map(SelectionTarget::ScenarioObject)
.collect()
}
fn message(occurrence: Id, send_y: f32) -> ScenarioMessagePosition {
ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y,
}
}
// Literal expected authored payload, not another execution of planner commands.
fn expected(
project: &Project,
diagram: Id,
lanes: Vec<ScenarioLanePosition>,
messages: Vec<ScenarioMessagePosition>,
) -> Project {
let mut result = project.clone();
result.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout { lanes, messages }));
result
}
fn exact_transaction(mut document: Document, preview: Preview, expected: Project) {
let original = document.snapshot();
let revision = document.revision();
assert_eq!(preview.project, expected);
assert!(matches!(&preview.command, Command::Batch(_)));
document.execute(preview.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.revision(), revision + 1);
let changed = document.bundle().clone();
assert_ne!(changed, original.bundle);
assert_eq!(
language::parse(&language::format(document.project())).unwrap(),
expected
);
assert!(document.undo());
assert_eq!(document.project(), &original.decoded.project);
assert_eq!(document.bundle(), &original.bundle);
assert!(!document.can_undo());
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &changed);
assert!(!document.can_redo());
let reopened = Document::from_bundle(changed, Default::default()).unwrap();
assert_eq!(reopened.project(), &expected);
assert!(!reopened.dirty() && !reopened.can_undo());
}
#[test]
fn group_messages_move_from_each_original_with_press_and_selection_order_independent() {
let (document, diagram, other) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let wanted = expected(
before,
diagram,
vec![],
vec![message(first, 188.), message(second, 280.)],
);
for members in [[first, second], [second, first]] {
for pressed in [first, second] {
let drag = capture_group(
before,
diagram,
Target::Message(pressed),
&targets(&members),
)
.unwrap();
let preview = drag.prepare(40.).unwrap().unwrap();
assert_eq!(preview.project, wanted);
assert_eq!(preview.project.diagrams[&other], before.diagrams[&other]);
assert_eq!(drag.original, *before);
}
}
let preview = capture_group(
before,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(40.)
.unwrap()
.unwrap();
exact_transaction(document, preview, wanted);
}
#[test]
fn group_lanes_vacate_each_others_space_in_one_candidate_and_one_history_entry() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let left = id(before, "left");
let right = id(before, "right");
// Moving only Left into Right's old x cannot render a distinct-endpoint arrow.
assert!(matches!(
capture(before, diagram, Target::Lane(left))
.unwrap()
.prepare(240.),
Err(Refusal::Geometry(_))
));
let wanted = expected(
before,
diagram,
vec![
ScenarioLanePosition {
lifeline: left,
x: 400.,
},
ScenarioLanePosition {
lifeline: right,
x: 640.,
},
],
vec![],
);
for pressed in [left, right] {
let preview = capture_group(
before,
diagram,
Target::Lane(pressed),
&targets(&[right, left]),
)
.unwrap()
.prepare(240.)
.unwrap()
.unwrap();
assert_eq!(preview.project, wanted);
assert_eq!(preview.geometry.scene.render_lanes[&left], 400.);
assert_eq!(preview.geometry.scene.render_lanes[&right], 640.);
}
let preview = capture_group(
before,
diagram,
Target::Lane(right),
&targets(&[left, right]),
)
.unwrap()
.prepare(240.)
.unwrap()
.unwrap();
exact_transaction(document, preview, wanted);
}
#[test]
fn self_call_and_reply_are_validated_together_but_partial_candidate_is_refused() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let call = id(before, "call");
let reply = id(before, "reply");
let first = id(before, "first");
assert!(matches!(
capture(before, diagram, Target::Message(call))
.unwrap()
.prepare(118.),
Err(Refusal::Geometry(_))
));
let drag = capture_group(
before,
diagram,
Target::Message(reply),
&targets(&[reply, call]),
)
.unwrap();
let preview = drag.prepare(118.).unwrap().unwrap();
assert_eq!(
preview.project,
expected(
before,
diagram,
vec![],
vec![message(call, 450.), message(reply, 542.)]
)
);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == call)
.unwrap();
assert_eq!(line.points.first().unwrap().y, 450.);
assert_eq!(line.points.last().unwrap().y, 474.);
let partial = capture_group(
before,
diagram,
Target::Message(first),
&targets(&[first, call]),
)
.unwrap();
assert!(matches!(partial.prepare(118.), Err(Refusal::Geometry(_))));
assert_eq!(partial.original, *before);
assert!(!document.can_undo());
}
#[test]
fn exact_set_admission_rejects_empty_duplicates_mixed_unsupported_missing_and_unpressed() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let left = id(before, "left");
let source = id(before, "source");
let unfinished = id(before, "unfinished");
for selection in [
vec![],
targets(&[first, first]),
targets(&[first, left]),
targets(&[first, source]),
targets(&[first, Id::new_v4()]),
targets(&[second]),
targets(&[first, unfinished]),
vec![
SelectionTarget::ScenarioObject(first),
SelectionTarget::Whole,
],
vec![
SelectionTarget::ScenarioObject(first),
SelectionTarget::Placement(second),
],
] {
assert!(
capture_group(before, diagram, Target::Message(first), &selection).is_err(),
"{selection:?}"
);
}
assert!(
capture_group(
before,
Id::nil(),
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
assert!(
capture_group(
before,
diagram,
Target::Lane(first),
&targets(&[first, second])
)
.is_err()
);
assert!(!document.dirty() && !document.can_undo());
}
#[test]
fn foreign_unlisted_and_ambiguous_local_messages_cannot_enter_a_group() {
let (document, diagram, _) = fixture(None);
let before = document.project();
let first = id(before, "first");
let second = id(before, "second");
let owner = id(before, "conversation");
for case in 0..3 {
let mut project = before.clone();
match case {
0 => {
let foreign = project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
project.elements.get_mut(&second).unwrap().parent = Some(foreign);
}
1 => project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != second),
_ => project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(second),
}
assert!(
capture_group(
&project,
diagram,
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
}
}
#[test]
fn fallback_baseline_is_refused_before_any_zero_motion_can_be_planned() {
let (document, diagram, _) = fixture(None);
let call = id(document.project(), "call");
let reply = id(document.project(), "reply");
let invalid = expected(
document.project(),
diagram,
vec![],
vec![message(call, 450.)],
);
let shown = crate::behavior::scenario_layout::presentation::present(
&invalid,
Representation::Saved { diagram },
call,
);
assert!(shown.capabilities().geometry_navigation);
assert!(!shown.capabilities().coordinate_input);
assert!(matches!(
capture_group(
&invalid,
diagram,
Target::Message(reply),
&targets(&[reply, call])
),
Err(Refusal::Geometry(_))
));
}
#[test]
fn group_noops_preserve_sparse_overrides_complete_source_and_existing_redo() {
for sparse in [true, false] {
let (mut document, diagram, _) = fixture((!sparse).then(ScenarioLayout::default));
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let owner = id(document.project(), "conversation");
document
.execute(Command::Rename(owner, "Redo sentinel".into()))
.unwrap();
let redo = document.snapshot();
assert!(document.undo());
let original = document.snapshot();
let revision = document.revision();
let drag = capture_group(
document.project(),
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap();
assert!(drag.prepare(30.).unwrap().is_some());
for delta in [0., -0., f32::from_bits(1)] {
assert!(drag.prepare(delta).unwrap().is_none());
}
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
assert_eq!(document.bundle(), &original.bundle);
assert_eq!(document.revision(), revision);
assert_eq!(drag.original, original.decoded.project);
assert!(document.redo());
assert_eq!(document.project(), &redo.decoded.project);
assert_eq!(document.bundle(), &redo.bundle);
}
}
#[test]
fn subprecision_unchanged_member_does_not_materialize_an_absent_override() {
let (document, diagram, _) = fixture(None);
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let project = expected(
document.project(),
diagram,
vec![],
vec![message(first, 0.)],
);
let delta = 0.000001_f32;
assert_eq!(240_f32 + delta, 240.);
let preview = capture_group(
&project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(&project, diagram, vec![], vec![message(first, delta)])
);
assert!(matches!(preview.command, Command::Batch(ref commands) if commands.len() == 1));
}
#[test]
fn invalid_delta_or_one_out_of_bounds_member_refuses_the_whole_group() {
let (document, diagram, _) = fixture(None);
let left = id(document.project(), "left");
let right = id(document.project(), "right");
let project = expected(
document.project(),
diagram,
vec![ScenarioLanePosition {
lifeline: right,
x: 999_990.,
}],
vec![],
);
let drag = capture_group(
&project,
diagram,
Target::Lane(left),
&targets(&[left, right]),
)
.unwrap();
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
}
for delta in [20., f32::MAX, -f32::MAX] {
assert!(matches!(drag.prepare(delta), Err(Refusal::Command(_))));
}
assert_eq!(drag.original, project);
assert!(drag.prepare(0.).unwrap().is_none());
}
#[test]
fn singleton_capture_group_retains_existing_singleton_command_and_result() {
let (document, diagram, _) = fixture(None);
for target in [
Target::Lane(id(document.project(), "right")),
Target::Message(id(document.project(), "second")),
] {
let single = capture(document.project(), diagram, target)
.unwrap()
.prepare(25.)
.unwrap()
.unwrap();
let group = capture_group(
document.project(),
diagram,
target,
&targets(&[target.id()]),
)
.unwrap()
.prepare(25.)
.unwrap()
.unwrap();
assert_eq!(group.project, single.project);
assert!(!matches!(group.command, Command::Batch(_)));
}
}
#[test]
fn existing_layout_order_and_unselected_values_are_preserved_exactly() {
let (document, diagram, _) = fixture(None);
let first = id(document.project(), "first");
let second = id(document.project(), "second");
let reply = id(document.project(), "reply");
let right = id(document.project(), "right");
let lanes = vec![ScenarioLanePosition {
lifeline: right,
x: 400.,
}];
let project = expected(
document.project(),
diagram,
lanes.clone(),
vec![message(reply, 424.), message(first, 148.)],
);
let preview = capture_group(
&project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(40.)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(
&project,
diagram,
lanes,
vec![
message(reply, 424.),
message(first, 188.),
message(second, 280.)
]
)
);
}
#[test]
fn local_messages_in_different_operands_move_without_reparenting_or_reordering() {
use crate::behavior::topology::occurrence;
let (document, diagram, _) = fixture(None);
let mut project = document.project().clone();
let owner = id(&project, "conversation");
let first = id(&project, "first");
let second = id(&project, "second");
let fragment = occurrence(
&project,
owner,
"Alternatives",
OccurrenceBody::Fragment {
operator: oa_core::FragmentOperator::Alternative,
branches: vec![],
},
);
let frame = fragment.id;
project.elements.insert(frame, fragment);
let mut branches = Vec::new();
for member in [first, second] {
let branch = occurrence(
&project,
frame,
"Operand",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![member],
},
);
let branch_id = branch.id;
project.elements.insert(branch_id, branch);
project.elements.get_mut(&member).unwrap().parent = Some(branch_id);
branches.push(branch_id);
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
branches: items, ..
},
..
}) = project.elements.get_mut(&frame).unwrap().shared_mut()
{
*items = branches.clone();
} else {
unreachable!();
}
let steps = project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap();
steps.retain(|id| ![first, second].contains(id));
steps.insert(0, frame);
let document = Document::from_project(project, Default::default()).unwrap();
let project = document.project();
let baseline = resolve(project, Representation::Saved { diagram }, first).unwrap();
let original_send = |id| {
baseline
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
};
let preview = capture_group(
project,
diagram,
Target::Message(second),
&targets(&[second, first]),
)
.unwrap()
.prepare(-5.)
.unwrap()
.unwrap();
assert_eq!(
preview.project,
expected(
project,
diagram,
vec![],
vec![
message(first, original_send(first) - 5.),
message(second, original_send(second) - 5.)
]
)
);
assert_eq!(preview.project.elements, project.elements);
let mut omitted = project.clone();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
branches: items, ..
},
..
}) = omitted.elements.get_mut(&frame).unwrap().shared_mut()
{
items.retain(|id| *id != branches[1]);
}
assert!(
capture_group(
&omitted,
diagram,
Target::Message(first),
&targets(&[first, second])
)
.is_err()
);
}
const FIXTURE: &str = r#"arcadia 7;
project spacing_group "Saved group spacing" id "71000000-0000-4000-8000-000000000001" {
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
}
"#;
@@ -75,7 +75,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.spacing(8)
.into();
};
let snapshot = app.multiselect_snapshot();
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
// Resolve once per accepted view. An active spacing session owns its exact
@@ -91,8 +90,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
},
ScenarioPresentation::capabilities,
);
let coordinate_input =
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
let coordinate_input = clean && capabilities.coordinate_input;
let notice = if let Some(paint) = &preview {
display_notice_parts(
p,
@@ -125,7 +123,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
scenario_view: Some((context.representation, context.visit)),
};
let keyboard = clean
&& app.scenario_reorder.session.is_none()
&& capabilities.geometry_navigation
&& super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
@@ -141,21 +138,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
scene.scale = camera_state.scale;
scene.camera_pan = camera_state.pan;
scene.interaction = Some((context, tool, clean));
scene.selection = snapshot.clone().map(|snapshot| (context, snapshot));
scene.spacing_stamp = (coordinate_input
&& !app.source_rename.open()
&& keyboard
&& matches!(context.representation, Representation::Saved { .. }))
.then(|| {
snapshot.as_ref().map(|snapshot| spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
selection_generation: snapshot.generation(),
})
})
.flatten();
scene.coordinate_input =
capabilities.coordinate_input && app.scenario_reorder.session.is_none();
.then_some(spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
});
scene.coordinate_input = capabilities.coordinate_input;
scene.pending_placement = coordinate_input
.then(|| state.pending_placement(owner))
.flatten();
@@ -167,27 +158,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
// Keep this Stack in both branches: replacing the Canvas with a wrapper
// on Begin would discard its pointer capture before Move/Finish.
let mut layers = iced::widget::stack![raw_canvas];
if let Some((_, summary)) = &app.scenario_reorder.summary {
layers = layers.push(
container(iced::widget::opaque(
container(
text(format!("{summary} Use Undo to restore the previous order.")).size(12),
)
.padding(8)
.style(style::card),
))
.align_bottom(Fill)
.width(Fill)
.padding(8),
);
}
if let Some(paint) = &preview {
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
let feedback = if paint.selected_count > 1 {
format!("{} selected - {feedback}", paint.selected_count)
} else {
feedback
};
layers = layers.push(
container(
container(text(feedback).size(12))
@@ -224,30 +196,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
};
// The chooser must sit outside Viewport: that camera wrapper deliberately
// consumes Space, arrows and wheel before its child receives input.
let mut canvas_layers = iced::widget::stack![canvas];
if let Some(panel) = reorder_view::panel(app, presentation.viewport) {
canvas_layers = canvas_layers.push(panel);
}
let canvas = canvas_layers.into();
let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len());
let mut navigation = row![
out.width(Fill),
text(format!("{selected_count} selected")).size(12)
]
.spacing(6);
let reorder = reorder_view::begin(app);
navigation = navigation.push(super::super::canvas_workspace::Control::new(
button(text("Reorder…").size(12))
.padding(6)
.on_press_maybe(reorder.clone()),
scope,
"Reorder",
keyboard,
reorder,
camera,
));
let mut navigation = row![out.width(Fill)].spacing(6);
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
@@ -313,12 +262,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let entries = [
(
"S",
if selected_count > 1 && matches!(context.representation, Representation::Saved { .. })
{
"Drag messages vertically or lifelines horizontally; Shift-click to change selection"
} else if selected_count > 1 {
"Shift-click to change selection; click a member to select only it"
} else if matches!(context.representation, Representation::Saved { .. }) {
if matches!(context.representation, Representation::Saved { .. }) {
"Select / move messages and lifelines"
} else {
"Select / reorder occurrences"
@@ -399,15 +343,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
}),
);
let mut details = column![
text(if selected_count > 1 {
format!(
"{selected_count} selected; properties: {}",
p.named(app.selected)
)
} else {
format!("Selected: {}", p.named(app.selected))
})
.size(12),
text(format!("Selected: {}", p.named(app.selected))).size(12),
container(button(text("Properties").size(12)).on_press_maybe(
(clean && properties_target(p, context, app.selected)).then_some(
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
@@ -431,7 +367,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
sequence_rows(p, context, owner, details, clean, snapshot.as_ref()).into(),
sequence_rows(p, context, owner, details, clean).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
@@ -749,9 +685,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.into();
}
}
let editor = if app.scenario_reorder.session.is_some() {
None
} else if pending {
let editor = if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
@@ -790,13 +724,9 @@ fn display_notice_parts<'a>(
for diagnostic in diagnostics.iter().take(3) {
let explanation = match diagnostic {
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
"Saved positions place @{} below @{}, contrary to execution order.",
p.elements
.get(before)
.map_or("?", |element| element.alias.as_str()),
p.elements
.get(after)
.map_or("?", |element| element.alias.as_str())
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
p.named(*before),
p.named(*after)
),
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
@@ -821,7 +751,7 @@ fn display_notice_parts<'a>(
if let Some(issue) = requested_issues.first() {
notice = notice.push(text(geometry_problem(p, issue)).size(12));
}
notice = notice.push(text("Selection, navigation and Fit remain available. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").size(12));
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
}
PresentationStatus::Unavailable { .. } => {
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
@@ -978,24 +908,7 @@ fn sequence_rows<'a>(
owner: Id,
mut out: Column<'a, Message>,
enabled: bool,
snapshot: Option<&crate::diagram_selection::Snapshot>,
) -> Column<'a, Message> {
let choose = |id| {
snapshot.map_or_else(
|| Message::ScenarioCanvas(CanvasEvent::Select(context, id)),
|snapshot| {
selection::message(
context,
snapshot,
selection::Intent::Choose(
crate::diagram_usage::Target::ScenarioObject(id),
crate::app::multiselect::Choice::Replace,
),
)
},
)
};
let order_enabled = enabled && snapshot.is_none_or(|snapshot| snapshot.targets().len() <= 1);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
@@ -1004,10 +917,10 @@ fn sequence_rows<'a>(
for branch in branches {
out = out.push(sequence_action(
&format!("Operand: {}", p.named(*branch)),
choose(*branch),
Message::Select(*branch),
enabled,
));
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
out = sequence_rows(p, context, *branch, out, enabled);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
@@ -1016,17 +929,17 @@ fn sequence_rows<'a>(
row![
sequence_action(
&format!("{}. {}", i + 1, p.named(*id)),
choose(*id),
Message::Select(*id),
enabled
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((order_enabled && i > 0).then_some(Message::Command(
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(order_enabled && i + 1 < steps.len())
(enabled && i + 1 < steps.len())
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
)
]
@@ -1047,7 +960,7 @@ fn sequence_rows<'a>(
enabled,
));
}
out = sequence_rows(p, context, *id, out, enabled, snapshot);
out = sequence_rows(p, context, *id, out, enabled);
}
}
out
@@ -388,16 +388,13 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_details(&mut app);
let mut begin = packet(
let begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
if let Spacing::Begin { stamp, .. } = &mut begin {
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
}
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
@@ -446,180 +443,3 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_selection_shift_survives_properties_focus_busy_rebuild_and_clears_on_window_loss() {
use crate::app::multiselect::Choice;
use crate::behavior::canvas_workspace::tests::focused;
use crate::diagram_usage::Target;
fn at(mounted: &mut Mounted, app: &App, target: Id) -> Point {
let context = app.scenario_context().unwrap();
let geometry = resolve(app.project(), context.representation, app.selected).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == target)
.unwrap();
let world = line.label_at + Vector::new(8., 8.);
assert_eq!(geometry.scene.hit_item(world).unwrap().id, target);
let bounds = mounted.geometry(app).bounds("scenario-viewport");
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
assert!(bounds.contains(point));
point
}
async fn focus_name(mounted: &mut Mounted, app: &mut App) -> Point {
let shown = mounted.geometry(app);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible Name input");
mounted.click(app, input).await;
assert!(!focused(mounted, app, "viewport"));
assert!(
mounted
.geometry(app)
.focused
.iter()
.any(|(_, bounds, yes)| *yes && bounds.contains(input + offset))
);
assert!(!app.draft_dirty());
input
}
let shift = |held| {
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if held {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
}))
};
let (mut app, diagram, first) = setup();
let second = id(&app, "updated_report");
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
let (details, offset) = shown.scrolls[&widget::Id::new("scenario-details")];
let properties = shown.bounds("scenario-properties").center() - offset;
assert!(details.contains(properties));
mounted.click(&mut app, properties).await;
mounted.settle(&mut app).await;
let input = focus_name(&mut mounted, &mut app).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let context = app.scenario_context().unwrap();
let selected = app.multiselect_snapshot().unwrap();
assert_eq!(selected.targets(), &[Target::ScenarioObject(first)]);
let passive = mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
.await;
assert!(
!passive
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
);
assert!(!focused(&mut mounted, &app, "viewport"));
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
let second_at = at(&mut mounted, &app, second);
let messages = mounted.click(&mut app, second_at).await;
assert_eq!(messages.iter().filter(|message| matches!(message,
Message::ScenarioCanvas(CanvasEvent::Selection(event))
if matches!(&event.intent, selection::Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if *id == second))).count(), 1);
assert!(!messages.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(_) | CanvasEvent::Reorder { .. })
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(second))
);
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(
&mut app,
&[shift(false)],
mouse::Cursor::Available(second_at),
)
.await;
app.busy = false;
mounted.settle(&mut app).await;
let first_at = at(&mut mounted, &app, first);
let messages = mounted.click(&mut app, first_at).await;
// Saved group clicks now complete the captured spacing lifetime; its
// stationary outcome must retain the same selection-only collapse.
assert!(messages.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(
super::super::spacing::Event::Finish { .. }
))
)));
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
let input = focus_name(&mut mounted, &mut app).await;
app.busy = true;
mounted.settle(&mut app).await;
mounted
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
.await;
app.busy = false;
mounted.settle(&mut app).await;
let second_at = at(&mut mounted, &app, second);
mounted.click(&mut app, second_at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(second))
);
mounted
.batch(
&mut app,
&[iced::Event::Window(iced::window::Event::Unfocused)],
mouse::Cursor::Unavailable,
)
.await;
let first_at = at(&mut mounted, &app, first);
mounted.click(&mut app, first_at).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(first))
);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
@@ -436,204 +436,3 @@ async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_c
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
#[tokio::test]
async fn saved_first_press_spaces_unselected_message_with_existing_singleton_selection() {
use crate::diagram_usage::Target;
let (mut app, mut mounted, diagram) = setup(false).await;
let initial = id(&app, "initial_report");
let target = id(&app, "updated_report");
let context = app.scenario_context().unwrap();
let snapshot = app.multiselect_snapshot().unwrap();
let task = app.update(selection::message(
context,
&snapshot,
selection::Intent::Choose(
Target::ScenarioObject(initial),
crate::app::multiselect::Choice::Replace,
),
));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(initial)]
);
let before = app.document.snapshot();
let revision = app.document.revision();
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
let start = Point::new(projected.x.round(), projected.y.round());
let geometry = resolved(&app);
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(effective.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - effective.pan.x) / effective.scale,
(start.y - bounds.y - effective.pan.y) / effective.scale,
);
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let messages = press(&mut mounted, &mut app, start).await;
assert_eq!(
messages
.iter()
.filter(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert!(
!messages
.iter()
.any(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(_))))
);
let token = app
.behavior
.scenario
.spacing_token()
.expect("first press starts saved spacing despite different selected singleton");
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
let end = start + Vector::new(0., 10.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
let messages = release(&mut mounted, &mut app, end).await;
assert_eq!(
messages
.iter()
.filter(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(
app.multiselect_snapshot().unwrap().targets(),
&[Target::ScenarioObject(target)]
);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::ScenarioObject(target))
);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let revision = app.document.revision();
let duplicate = release(&mut mounted, &mut app, end).await;
assert!(!duplicate.iter().any(|message| matches!(
message,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
)));
assert_eq!(app.document.revision(), revision);
assert!(Arc::ptr_eq(&after, &app.document.snapshot()));
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_blank_left_drag_clears_selection_and_pans_on_that_same_press() {
use crate::diagram_usage::Target;
for group in [false, true] {
for same_batch in [false, true] {
let (mut app, mut mounted, _) = setup(false).await;
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
for target in [Some(first), group.then_some(second)].into_iter().flatten() {
let context = app.scenario_context().unwrap();
let snapshot = app.multiselect_snapshot().unwrap();
let task = app.update(selection::message(
context,
&snapshot,
selection::Intent::Choose(
Target::ScenarioObject(target),
crate::app::multiselect::Choice::Add,
),
));
mounted.perform(&mut app, task).await;
}
mounted.settle(&mut app).await;
assert_eq!(
app.multiselect_snapshot().unwrap().targets().len(),
if group { 2 } else { 1 }
);
let before = app.document.snapshot();
let context = app.scenario_context().unwrap();
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let start = bounds.position() + Vector::new(bounds.width - 25., bounds.height - 25.);
let delta = Vector::new(-25., -20.);
let end = start + delta;
let geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
assert!(
geometry.scene.hit_item(world).is_none(),
"test must start on actual blank geometry"
);
let stored = stored_camera(&app);
let messages = if same_batch {
mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await
} else {
let mut messages = press(&mut mounted, &mut app, start).await;
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
mounted.settle(&mut app).await;
messages.extend(move_to(&mut mounted, &mut app, end).await);
messages.extend(release(&mut mounted, &mut app, end).await);
messages
};
assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(event)) if matches!(&event.intent, selection::Intent::Clear))).count(), 1);
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
let actual = stored_camera(&app);
let difference = actual.0 - (stored.0 + delta);
assert!(
difference.x.hypot(difference.y) < 0.01,
"same-press pan lost after Clear: group={group}, same_batch={same_batch}, {stored:?} -> {actual:?}"
);
assert_eq!(actual.1, stored.1);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
@@ -234,13 +234,6 @@ impl Rendering {
.collect()
}
/// Exact painted marker subjects and centers for shared selection adapters.
pub(in crate::behavior) fn selection_markers(&self) -> impl Iterator<Item = (Id, Point)> + '_ {
self.markers
.iter()
.map(|marker| (marker.event.occurrence, marker.center))
}
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
self.markers
.iter()
@@ -1497,7 +1497,9 @@ impl StateCanvas<'_> {
format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))
}
}).unwrap_or_default(),
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
// General gestures are available in Help; keep this stable slot
// for the active operation or the item under the pointer.
_ => String::new(),
},
}
}
+10 -10
View File
@@ -187,7 +187,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
.into();
out = out.push(
column![
missing_controls,
container(missing_controls).id("state-missing-picker"),
selection_controls(app),
crate::diagram::focus::wrap(
canvas.map(event),
@@ -225,13 +225,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
}
controls = controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
))
.push(
label(format!(
"Properties edit only {}",
crate::views::short(app.project().named(app.selected), 18)
"Properties: {} only",
crate::views::short(app.project().named(app.selected), 10)
))
.wrapping(iced::widget::text::Wrapping::None),
);
@@ -241,12 +241,12 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
} else {
"Use Select / move to select multiple elements"
};
column![
controls,
label(hint).wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
controls
.push(crate::views::diagram_help(
crate::guide::Topic::States,
hint,
))
.into()
}
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
@@ -585,15 +585,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
.wrapping(iced::widget::text::Wrapping::None),
);
}
// These two rows exist even with an empty set so selection never moves the
// canvas viewport or changes the camera's interpretation of the pointer.
column![
controls,
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
.wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
// Keep the row even with an empty set so selection never moves the canvas.
controls
.push(crate::views::diagram_help(
crate::guide::Topic::Chains,
"Shift-click toggles selection. Drag selected local cards to reorder; reused previews are read-only.",
))
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
+27
View File
@@ -37,6 +37,8 @@ mod port_hit_tests;
#[path = "diagram/view_options_tests.rs"]
mod view_options_tests;
#[path = "diagram/label_cues.rs"]
mod label_cues;
#[path = "diagram/port_labels.rs"]
mod port_labels;
pub use port_labels::PortLabels;
@@ -91,6 +93,7 @@ struct Scene {
work: Option<Arc<crate::app::allocation_drag::Preview>>,
view: ProjectedView,
routed: Arc<RoutedView>,
label_cues: BTreeMap<Id, label_cues::Cue>,
}
struct CachedScene {
context: Context,
@@ -529,6 +532,11 @@ impl DiagramCanvas<'_> {
work: Some(preview.clone()),
view: preview.placement.view.clone(),
routed: preview.routed.clone(),
label_cues: label_cues::derive(
preview.project(),
&preview.placement.view,
&preview.routed,
),
});
*state.work_cache.borrow_mut() = Some(scene.clone());
return scene;
@@ -582,6 +590,7 @@ impl DiagramCanvas<'_> {
);
let scene = Arc::new(Scene {
work: None,
label_cues: label_cues::derive(self.project, &view, &routed),
view,
routed,
});
@@ -1350,6 +1359,20 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
if !routed.label.text.is_empty() {
let label = &routed.label;
if let Some(cue) = scene.label_cues.get(&edge.id) {
f.stroke(
&Path::line(point(cue.boundary), point(cue.anchor)),
Stroke {
line_dash: canvas::LineDash {
segments: label_cues::DASH,
offset: 0,
},
..Stroke::default()
.with_color(style::MUTED)
.with_width(label_cues::WIDTH)
},
);
}
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
@@ -1953,6 +1976,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
}
let view = project_view(p, diagram);
let routed = route_diagram(p, d, &view);
let label_cues = label_cues::derive(p, &view, &routed);
let left = routed.bounds.position.x - 30.;
let top = routed.bounds.position.y - 30.;
let right = routed.bounds.position.x + routed.bounds.size.width + 30.;
@@ -2030,6 +2054,9 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
svg.push_str(&format!("<path data-edge=\"{}\" d=\"{path}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",edge.id,escape(p.named(edge.id))));
let label = &route.label;
if !label.text.is_empty() {
if let Some(cue) = label_cues.get(&edge.id) {
svg.push_str(&label_cues::svg(edge.id, *cue));
}
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
@@ -423,6 +423,7 @@ fn coincident_attachment_handles_honor_explicit_end_choice() {
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
label_cues: BTreeMap::new(),
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
+403
View File
@@ -0,0 +1,403 @@
//! Experimental, presentation-only label association. Never used for picking.
use oa_core::{
Id, Position, Project,
projection::ProjectedView,
routing::{self, RoutedView, RoutingBounds},
};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Cue {
pub boundary: Position,
pub anchor: Position,
}
const MIN_GAP: f32 = 12.;
const LANDING_CLEARANCE: f32 = 7.;
pub(super) const DASH: &[f32] = &[2., 3.];
pub(super) const WIDTH: f32 = 1.;
pub(super) fn derive(
project: &Project,
view: &ProjectedView,
routed: &RoutedView,
) -> BTreeMap<Id, Cue> {
let association_labels: Vec<_> = routed
.edges
.values()
.flat_map(|edge| {
project
.relations
.get(&edge.id)
.into_iter()
.flat_map(move |relation| {
routing::association_end_geometry(relation, edge)
.into_iter()
.map(|end| end.label.bounds)
})
})
.collect();
routed
.edges
.values()
.filter_map(|edge| {
if edge.label.text.is_empty() {
return None;
}
let bounds = edge.label.bounds;
let anchor = edge.label.anchor;
// Nearest rectangle boundary, not center displacement (which inflates
// apparent separation for long labels).
let boundary = Position {
x: anchor
.x
.clamp(bounds.position.x, bounds.position.x + bounds.size.width),
y: anchor
.y
.clamp(bounds.position.y, bounds.position.y + bounds.size.height),
};
if (anchor.x - boundary.x).hypot(anchor.y - boundary.y) <= MIN_GAP {
return None;
}
let cue = Cue { boundary, anchor };
for other in routed.edges.values().filter(|other| other.id != edge.id) {
if other.points.windows(2).any(|pair| {
point_distance(anchor, pair[0], pair[1]) <= LANDING_CLEARANCE
|| intersects(cue.boundary, cue.anchor, pair[0], pair[1])
}) {
return None;
}
if !other.label.text.is_empty() && crosses_box(cue, other.label.bounds) {
return None;
}
}
if view.nodes.iter().any(|node| {
crosses_box(
cue,
RoutingBounds {
position: node.position,
size: node.size,
},
)
}) || association_labels
.iter()
.any(|bounds| crosses_box(cue, *bounds))
{
return None;
}
Some((edge.id, cue))
})
.collect()
}
fn point_distance(p: Position, a: Position, b: Position) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let length = dx * dx + dy * dy;
let t = if length == 0. {
0.
} else {
(((p.x - a.x) * dx + (p.y - a.y) * dy) / length).clamp(0., 1.)
};
(p.x - a.x - t * dx).hypot(p.y - a.y - t * dy)
}
fn cross(a: Position, b: Position, c: Position) -> f32 {
(b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
}
fn intersects(a: Position, b: Position, c: Position, d: Position) -> bool {
// Closed segments: touching an unrelated path is also ambiguous.
if a.x.max(b.x) < c.x.min(d.x)
|| c.x.max(d.x) < a.x.min(b.x)
|| a.y.max(b.y) < c.y.min(d.y)
|| c.y.max(d.y) < a.y.min(b.y)
{
return false;
}
cross(a, b, c) * cross(a, b, d) <= 0. && cross(c, d, a) * cross(c, d, b) <= 0.
}
fn crosses_box(cue: Cue, bounds: RoutingBounds) -> bool {
let x = bounds.position.x;
let y = bounds.position.y;
let right = x + bounds.size.width;
let bottom = y + bounds.size.height;
let inside = |p: Position| p.x >= x && p.x <= right && p.y >= y && p.y <= bottom;
let corners = [
Position { x, y },
Position { x: right, y },
Position {
x: right,
y: bottom,
},
Position { x, y: bottom },
];
inside(cue.boundary)
|| inside(cue.anchor)
|| (0..4).any(|i| intersects(cue.boundary, cue.anchor, corners[i], corners[(i + 1) % 4]))
}
pub(super) fn svg(edge: Id, cue: Cue) -> String {
format!(
"<path data-label-cue=\"{edge}\" d=\"M {} {} L {} {}\" fill=\"none\" stroke=\"#4b5968\" stroke-width=\"1\" stroke-dasharray=\"2 3\" pointer-events=\"none\"/>",
cue.boundary.x, cue.boundary.y, cue.anchor.x, cue.anchor.y
)
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{Diagram, Size};
use serde_json::json;
fn representative() -> (Project, Id, Id) {
let mut project = oa_core::persistence::decode(include_bytes!(
"../../../../examples/release1/model.json"
))
.unwrap();
let id: Id = "00000000-0038-4000-8000-000000000001".parse().unwrap();
let mut nodes = serde_json::Map::new();
for (i, (alias, x, y, width, height)) in [
("oa_assessment_procedure", 314, 40, 243, 101),
("sa_technician", 1180, 18, 265, 143),
("sa_assess", 54, 245, 265, 143),
("sa_control", 736, 245, 265, 143),
("sa_repair", 1083, 343, 266, 143),
("sa_reset", 227, 516, 266, 119),
("oa_triage", 1317, 581, 243, 103),
("sa_confirm", 227, 689, 266, 143),
]
.into_iter()
.enumerate()
{
let element = project
.elements
.values()
.find(|e| e.alias == alias)
.unwrap()
.id;
let node = format!("00000000-0038-4000-8000-{:012}", 101 + i);
nodes.insert(
node.clone(),
json!({"id":node,"alias":format!("issue38_{alias}"),"element":element,
"position":{"x":x,"y":y},"size":{"width":width,"height":height},"collapsed":false}),
);
}
let diagram: Diagram =
serde_json::from_value(json!({"id":id,"alias":"issue38_representative_mixed",
"name":"Representative eight-card view","description":"Not recovered user source",
"layer":"Sa","projection":"Traceability","nodes":nodes,"routes":[]}))
.unwrap();
project.diagrams.insert(id, diagram);
assert!(
!oa_core::validation::validate(&project)
.iter()
.any(|issue| issue.severity == oa_core::validation::Severity::Error)
);
(
project,
id,
"63009b5f-539e-5914-b630-ec6b7fefb4d5".parse().unwrap(),
)
}
#[test]
fn representative_cue_and_svg_preserve_complete_project_and_geometry() {
let (project, diagram, edge) = representative();
let before = project.clone();
let view = oa_core::projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let geometry = routed.clone();
assert_eq!(view.edges.len(), 12);
let cues = derive(&project, &view, &routed);
assert_eq!(
cues[&edge],
Cue {
boundary: Position { x: 1180., y: 274.5 },
anchor: Position { x: 1162., y: 274.5 }
}
);
assert_eq!(project, before);
assert_eq!(routed, geometry);
let output = super::super::export_svg(&project, diagram).unwrap();
let cue = output
.split('<')
.find(|part| part.contains(&format!("data-label-cue=\"{edge}\"")))
.unwrap();
assert!(cue.contains("stroke-dasharray=\"2 3\""));
assert!(!cue.contains("marker") && !cue.contains("data-edge="));
assert_eq!(project, before);
assert_eq!(
routing::route_diagram(&project, &project.diagrams[&diagram], &view),
geometry
);
}
#[test]
fn default_gap_and_anchor_inside_label_do_not_add_cues() {
let (project, diagram, edge) = representative();
let view = oa_core::projection::project_view(&project, diagram);
let mut routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
// Geometry controls: center 18 above anchor with a 24-high label leaves
// only a six-unit boundary gap. Large text width must not trigger a cue.
let label = &mut routed.edges.get_mut(&edge).unwrap().label;
label.bounds = RoutingBounds {
position: Position {
x: label.anchor.x - 116.5,
y: label.anchor.y - 30.,
},
size: Size {
width: 233.,
height: 24.,
},
};
assert!(!derive(&project, &view, &routed).contains_key(&edge));
// Same clear horizontal corridor as the representative cue: only the
// boundary gap changes, so these controls isolate the strict threshold.
for (gap, admitted) in [(12., false), (12.25, true)] {
let label = &mut routed.edges.get_mut(&edge).unwrap().label;
label.bounds = RoutingBounds {
position: Position {
x: label.anchor.x + gap,
y: label.anchor.y - 12.,
},
size: Size {
width: 233.,
height: 24.,
},
};
assert_eq!(
derive(&project, &view, &routed).contains_key(&edge),
admitted
);
}
let mut authored = project.clone();
authored
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(oa_core::EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(oa_core::EdgeLabel {
fraction: 0.5,
offset: Position { x: 0., y: 0. },
}),
});
let authored_before = authored.clone();
let authored_routed =
routing::route_diagram(&authored, &authored.diagrams[&diagram], &view);
assert!(!derive(&authored, &view, &authored_routed).contains_key(&edge));
assert_eq!(authored, authored_before);
}
#[test]
fn shared_or_near_landing_and_route_card_label_obstructions_suppress_cue() {
let (project, diagram, edge) = representative();
let view = oa_core::projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let other = *routed.edges.keys().find(|id| **id != edge).unwrap();
for x in [1162., 1168., 1171.] {
let mut changed = routed.clone();
changed.edges.get_mut(&other).unwrap().points =
vec![Position { x, y: 260. }, Position { x, y: 290. }];
// x1162 shares the landing, x1168 is too near, x1171 crosses the cue.
assert!(!derive(&project, &view, &changed).contains_key(&edge));
}
// These segments lie LEFT of the cue and cannot intersect it. They
// independently exercise landing clearance, including its boundary.
for (x, admitted) in [(1156., false), (1155., false), (1154., true)] {
let mut changed = routed.clone();
changed.edges.get_mut(&other).unwrap().points =
vec![Position { x, y: 260. }, Position { x, y: 290. }];
assert_eq!(
derive(&project, &view, &changed).contains_key(&edge),
admitted
);
}
let mut cards = view.clone();
cards.nodes[0].position = Position { x: 1169., y: 270. };
cards.nodes[0].size = Size {
width: 4.,
height: 10.,
};
assert!(!derive(&project, &cards, &routed).contains_key(&edge));
let mut labels = routed.clone();
let label = &mut labels.edges.get_mut(&other).unwrap().label;
label.text = "Obstruction".into();
label.bounds = RoutingBounds {
position: Position { x: 1169., y: 270. },
size: Size {
width: 4.,
height: 10.,
},
};
assert!(!derive(&project, &view, &labels).contains_key(&edge));
}
#[test]
fn selection_hover_and_camera_reuse_cached_scene_cues() {
let (project, diagram, edge) = representative();
let mut canvas = super::super::DiagramCanvas {
selection: None,
allocation: None,
project: &project,
view_index: None,
diagram,
selected: Id::nil(),
navigation_focus: None,
zoom: 1.,
pan: iced::Vector::ZERO,
context: crate::route_edit::Context {
document: project.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
};
let mut state = super::super::State::default();
let before = canvas.scene(&state);
assert!(before.label_cues.contains_key(&edge));
canvas.selected = edge;
canvas.zoom = 0.52;
canvas.pan = iced::Vector::new(25., 30.);
state.pointer = Some(iced::Point::new(60., 70.));
let after = canvas.scene(&state);
assert!(std::sync::Arc::ptr_eq(&before, &after));
assert_eq!(before.label_cues, after.label_cues);
let mut changed = project.clone();
changed
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(oa_core::EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(oa_core::EdgeLabel {
fraction: 0.5,
offset: Position { x: 0., y: 0. },
}),
});
canvas.project = &changed;
canvas.context.revision += 1;
let rebuilt = canvas.scene(&state);
assert!(!std::sync::Arc::ptr_eq(&after, &rebuilt));
assert!(!rebuilt.label_cues.contains_key(&edge));
assert_eq!(
rebuilt.routed.edges[&edge].label.center,
rebuilt.routed.edges[&edge].label.anchor
);
}
}
@@ -184,10 +184,6 @@ impl Selection {
}
impl Snapshot {
pub(crate) fn generation(&self) -> u64 {
self.generation
}
pub fn context(&self) -> Context {
self.context
}
+25 -4
View File
@@ -48,6 +48,11 @@ pub enum Event {
pub(crate) const ARTICLE_SCROLL_ID: &str = "engineering-guide-article";
mod context_help;
pub(crate) use context_help::{
ContextScroll, context_scroll_key, context_scroll_task, protect_workspace,
};
impl State {
/// Returns whether navigation selected a different article.
pub fn update(&mut self, event: Event) -> bool {
@@ -310,7 +315,7 @@ fn article(topic: Topic) -> Article {
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.",
"Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.",
],
),
@@ -334,7 +339,7 @@ fn article(topic: Topic) -> Article {
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.",
"Use Shift-click to add or remove cards from the selection, or right-click a card and choose Add to selection. Drag any selected work card to assign the whole work selection to one performer. Independent work can start on different performers. The inspector keeps its primary element during the drag; right-click a selected card and choose Inspect this element to change that primary without losing the group.",
"The move preserves work ownership and identities. A composite work card moves its leaves together when they have one coherent responsibility; if its own leaves are distributed across performers, choose specific leaves instead. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"To move work and performers together without changing responsibility, select their cards and choose Arrange next drag or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
"To move work and performers together without changing responsibility, select their cards and choose Arrange or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
],
),
@@ -463,6 +468,10 @@ fn article(topic: Topic) -> Article {
summary: "An operational Process or system Functional chain connects contributions of existing work and exchanges, including alternatives and reusable procedures. It explains how work contributes to an outcome.",
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps caller contextual qualified assembly common work focus reuse",
sections: &[
explain(
"Select a group",
"Use Select, then Shift-click to add or remove contributions from the selection. Drag selected local cards to reorder them in the same column. Reused previews are read-only; open their referenced definition to edit. Properties affect only the primary selected item. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
),
walkthrough(
"Describe a path through exchanges",
&[
@@ -569,6 +578,10 @@ fn article(topic: Topic) -> Article {
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
sections: &[
explain(
"Select a group",
"Choose Select / move, then Shift-click to add or remove elements from the selection. Properties affect only the primary selected item; Hide selected removes placements from this view. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
),
walkthrough(
"Build a state machine",
&[
@@ -1156,6 +1169,14 @@ fn topic_button(topic: Topic, selected: bool) -> iced::widget::Button<'static, M
}
fn article_view(topic: Topic) -> Element<'static, Message> {
article_content(topic, true)
}
pub(crate) fn context_view(state: &State) -> Element<'static, Message> {
article_content(state.topic, false)
}
fn article_content(topic: Topic, navigation: bool) -> Element<'static, Message> {
let article = article(topic);
let mut content = column![
text(topic.category()).size(11).color(style::MUTED),
@@ -1194,14 +1215,14 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
}
content = content.push(container(body).padding(20).width(Fill).style(style::card));
}
if let Some((page, label)) = article.destination {
if navigation && let Some((page, label)) = article.destination {
content = content.push(
button(text(label).size(13))
.padding([11, 15])
.on_press(Message::Page(page)),
);
}
if topic == Topic::FirstProject {
if navigation && topic == Topic::FirstProject {
content = content.push(text("For a worked model, open the water-pressure walkthrough. Copy the whole examples/release1 folder and open the copied project.arcadia before editing; read its README.md. If you use Save as, also copy evidence/ beside the new entry: Save as copies model source, not external artifact targets.").size(13));
content = content.push(
button(text("Open water-pressure walkthrough").size(13))
+282
View File
@@ -0,0 +1,282 @@
//! Modal reading input and preservation of the mounted editing workspace.
use super::ARTICLE_SCROLL_ID;
use crate::app::Message;
use iced::Element;
#[derive(Debug, Clone, Copy)]
pub(crate) enum ContextScroll {
LineUp,
LineDown,
PageUp,
PageDown,
Home,
End,
}
pub(crate) fn context_scroll_key(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
use iced::keyboard::{Event, Key, key::Named};
let iced::Event::Keyboard(Event::KeyPressed { key, modifiers, .. }) = event else {
return None;
};
if status == iced::event::Status::Captured || !modifiers.is_empty() {
return None;
}
let scroll = match key {
Key::Named(Named::ArrowUp) => ContextScroll::LineUp,
Key::Named(Named::ArrowDown) => ContextScroll::LineDown,
Key::Named(Named::PageUp) => ContextScroll::PageUp,
Key::Named(Named::PageDown) => ContextScroll::PageDown,
Key::Named(Named::Home) => ContextScroll::Home,
Key::Named(Named::End) => ContextScroll::End,
_ => return None,
};
Some(Message::ContextHelpScroll(scroll))
}
pub(crate) fn context_scroll_task(scroll: ContextScroll) -> iced::Task<Message> {
struct Scroll(ContextScroll);
impl<T> iced::advanced::widget::Operation<T> for Scroll {
fn traverse(
&mut self,
visit: &mut dyn FnMut(&mut dyn iced::advanced::widget::Operation<T>),
) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&iced::advanced::widget::Id>,
bounds: iced::Rectangle,
content_bounds: iced::Rectangle,
_: iced::Vector,
state: &mut dyn iced::advanced::widget::operation::Scrollable,
) {
use iced::advanced::widget::operation::scrollable::{AbsoluteOffset, RelativeOffset};
if id != Some(&iced::advanced::widget::Id::new(ARTICLE_SCROLL_ID)) {
return;
}
match self.0 {
ContextScroll::Home | ContextScroll::End => state.snap_to(RelativeOffset {
x: None,
y: Some(if matches!(self.0, ContextScroll::End) {
1.
} else {
0.
}),
}),
scroll => {
let y = match scroll {
ContextScroll::LineUp => -40.,
ContextScroll::LineDown => 40.,
ContextScroll::PageUp => -bounds.height * 0.9,
ContextScroll::PageDown => bounds.height * 0.9,
_ => unreachable!(),
};
// Iced clamps this movement to the actual content bounds.
state.scroll_by(AbsoluteOffset { x: 0., y }, bounds, content_bounds);
}
}
}
}
iced::advanced::widget::operate(Scroll(scroll))
}
/// Keep the mounted editor and its caret, but prevent modal input from reaching
/// widget-internal state before the App message guard can reject edits.
pub(crate) fn protect_workspace<'a, R: iced::advanced::renderer::Renderer + 'a>(
help_open: bool,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
Element::new(WorkspaceInput { help_open, content })
}
struct WorkspaceInput<'a, R> {
help_open: bool,
content: Element<'a, Message, iced::Theme, R>,
}
#[derive(Default)]
struct WorkspaceInputState {
help_open: bool,
}
impl<R: iced::advanced::renderer::Renderer> iced::advanced::Widget<Message, iced::Theme, R>
for WorkspaceInput<'_, R>
{
fn tag(&self) -> iced::advanced::widget::tree::Tag {
iced::advanced::widget::tree::Tag::of::<WorkspaceInputState>()
}
fn state(&self) -> iced::advanced::widget::tree::State {
iced::advanced::widget::tree::State::new(WorkspaceInputState::default())
}
fn children(&self) -> Vec<iced::advanced::widget::Tree> {
vec![iced::advanced::widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut iced::advanced::widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> iced::Size<iced::Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> iced::Size<iced::Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut iced::advanced::widget::Tree,
renderer: &R,
limits: &iced::advanced::layout::Limits,
) -> iced::advanced::layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
renderer: &R,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut iced::advanced::widget::Tree,
event: &iced::Event,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
renderer: &R,
clipboard: &mut dyn iced::advanced::Clipboard,
shell: &mut iced::advanced::Shell<'_, Message>,
viewport: &iced::Rectangle,
) {
let entering = {
let state = tree.state.downcast_mut::<WorkspaceInputState>();
let entering = self.help_open && !state.help_open;
state.help_open = self.help_open;
entering
};
if entering {
// Retire held gestures without executing their release actions.
// Text controls keep their real caret/focus; drawing tools remain
// selected, but an old drag cannot resume when Help is dismissed.
for event in [
iced::mouse::Event::CursorLeft,
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Middle),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Right),
] {
let mut discarded = Vec::new();
let mut cancellation = iced::advanced::Shell::new(&mut discarded);
self.content.as_widget_mut().update(
&mut tree.children[0],
&iced::Event::Mouse(event),
layout,
iced::mouse::Cursor::Unavailable,
renderer,
clipboard,
&mut cancellation,
viewport,
);
shell.request_redraw_at(cancellation.redraw_request());
if cancellation.is_layout_invalid() {
shell.invalidate_layout();
}
if cancellation.are_widgets_invalid() {
shell.invalidate_widgets();
}
}
}
if self.help_open
&& matches!(
event,
iced::Event::Keyboard(
iced::keyboard::Event::KeyPressed { .. }
| iced::keyboard::Event::KeyReleased { .. }
) | iced::Event::Mouse(_)
| iced::Event::Touch(_)
| iced::Event::InputMethod(_)
)
{
return;
}
// ModifiersChanged and passive window/redraw events still reach the
// editor, so releasing Shift during Help cannot stick on return.
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &iced::advanced::widget::Tree,
renderer: &mut R,
theme: &iced::Theme,
style: &iced::advanced::renderer::Style,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
renderer: &R,
) -> iced::mouse::Interaction {
if self.help_open {
iced::mouse::Interaction::None
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'b>,
renderer: &R,
viewport: &iced::Rectangle,
translation: iced::Vector,
) -> Option<iced::advanced::overlay::Element<'b, Message, iced::Theme, R>> {
if self.help_open {
None
} else {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
}
+39 -165
View File
@@ -19,7 +19,6 @@ pub struct Request {
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub process: Option<Box<crate::behavior::topology::Context>>,
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
pub source_file: Option<String>,
}
impl Request {
@@ -35,7 +34,6 @@ impl Request {
creation_point: None,
canvas: None,
process: None,
scenario: None,
source_file: None,
}
}
@@ -57,7 +55,6 @@ pub struct Opened {
page: Page,
layer: Layer,
diagram: Option<Id>,
scenario_selection: Option<crate::diagram_selection::Snapshot>,
}
impl Opened {
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
@@ -70,18 +67,9 @@ impl Opened {
.process
.as_deref()
.is_some_and(|c| Some(*c) != app.topology_context())
|| request
.scenario
.as_deref()
.is_some_and(|c| Some(*c) != app.scenario_context())
{
return None;
}
let scenario_selection = if request.scenario.is_some() {
Some(app.multiselect_snapshot()?)
} else {
None
};
Some(Self {
token,
request,
@@ -91,7 +79,6 @@ impl Opened {
page: app.page,
layer: app.layer,
diagram: current_diagram(app),
scenario_selection,
})
}
pub fn valid(&self, app: &App) -> bool {
@@ -112,15 +99,6 @@ impl Opened {
.process
.as_deref()
.is_none_or(|c| Some(*c) == app.topology_context())
&& self
.request
.scenario
.as_deref()
.is_none_or(|c| Some(*c) == app.scenario_context())
&& self
.scenario_selection
.as_ref()
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
&& self
.request
.source_file
@@ -131,19 +109,9 @@ impl Opened {
fn target_matches(app: &App, request: &Request) -> bool {
use crate::diagram_usage::Target;
if request.scenario.is_some()
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
{
return false;
}
let Some(target) = request.selection_target.as_deref() else {
return true;
};
if let Some(context) = request.scenario.as_deref() {
return Some(*context) == app.scenario_context()
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
&& app.multiselect_target_visible(target);
}
if (request.canvas.is_none() && request.process.is_none())
|| !app.multiselect_target_visible(target)
{
@@ -191,17 +159,6 @@ fn current_diagram(app: &App) -> Option<Id> {
if app.page == Page::Behavior {
app.topology_context()
.map(|context| context.owner)
.or_else(|| {
app.scenario_context()
.map(|context| match context.representation {
crate::behavior::scenario_layout::Representation::Derived { owner } => {
owner
}
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
diagram
}
})
})
.or_else(|| app.state_context().map(|context| context.diagram))
} else {
app.active_diagram
@@ -215,56 +172,49 @@ pub fn open(object: Id, anchor: Point) -> Message {
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
crate::context_menu::Area::left(
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
move |anchor| {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
Message::Menu(Event::Open(request))
},
)
.into()
}
fn actions_request(app: &App, anchor: Point) -> Request {
let mut request = Request::object(app.selected, anchor);
if app.page == Page::Diagram {
request.placement = app.selected_node;
request.bend = app
.selected_bend
.filter(|(id, _)| *id == app.selected)
.map(|(_, i)| i);
request.canvas = app.route_context();
} else if app.page == Page::Behavior {
request.canvas = app.state_context();
request.process = app.topology_context().map(Box::new);
request.scenario = app.scenario_context().map(Box::new);
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
request.selection_target = Some(Box::new(target.clone()));
request.placement = match target {
crate::diagram_usage::Target::Placement(id) => {
if let Some(node) = request
.canvas
.and_then(|c| app.project().diagrams.get(&c.diagram))
.and_then(|d| d.nodes.get(id))
{
request.object = node.element;
}
Some(*id)
}
crate::diagram_usage::Target::ScenarioObject(id) => {
request.object = *id;
None
}
_ => None,
};
} else if let Some(context) = request.canvas
&& let Some(node) = app.project().diagrams[&context.diagram]
.nodes
.values()
.find(|node| node.element == app.selected || node.id == app.selected)
{
request.object = node.element;
request.placement = Some(node.id);
}
}
request
}
pub fn popup(app: &App) -> Option<Menu<Message>> {
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
let request = &opened.request;
@@ -311,78 +261,6 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
});
}
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
if let Some(context) = request.scenario.as_deref().copied() {
use crate::app::multiselect::Choice;
use crate::behavior::scenario::{CanvasEvent, selection};
let snapshot = opened.scenario_selection.as_ref()?;
let selection_message = |intent| {
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
context,
snapshot: snapshot.clone(),
intent,
})))
};
if let Some(target) = request.selection_target.as_deref() {
let selected = snapshot.targets().contains(target);
entries.push(item(
if selected {
"Remove from selection"
} else {
"Add to selection"
},
selection_message(selection::Intent::Choose(
target.clone(),
if selected {
Choice::Remove
} else {
Choice::Add
},
)),
true,
));
if snapshot.targets().len() > 1 {
if selected {
entries.push(item(
"Inspect this element",
selection_message(selection::Intent::Choose(
target.clone(),
Choice::Inspect,
)),
true,
));
}
entries.push(item(
"Select only this element",
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
true,
));
}
entries.push(Entry::separator());
}
// A menu on an unselected subject must not expose singleton model actions
// while the canvas still depicts a group. Selection changes are explicit.
if snapshot.targets().len() > 1 {
entries.push(Entry::title(format!(
"{} selected",
snapshot.targets().len()
)));
entries.push(Entry::title(
"Use Reorder above the canvas to change sequence order",
));
entries.push(Entry::disabled(
"Remove selected occurrences (not yet available)",
));
entries.push(Entry::title(
"Select one occurrence for its individual actions",
));
return Some(Menu {
token: opened.token,
anchor: request.anchor,
entries,
on_close: Message::Menu(Event::Close),
});
}
}
if (request.canvas.is_some() || request.process.is_some())
&& let Some(context) = app.multiselect_context()
{
@@ -920,10 +798,6 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
}
}
#[cfg(test)]
#[path = "model_menu_scenario_tests.rs"]
mod scenario_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1,303 +0,0 @@
use super::*;
use crate::{
app::multiselect::Choice,
behavior::scenario::{CanvasEvent, Context, selection},
behavior::scenario_layout::Representation,
diagram_usage::Target,
};
fn saved(project: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: format!("scenario_menu_view_{}", project.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn fixture() -> (App, [Id; 3], Id, [Id; 2]) {
let (mut project, [owner, _, _], [first, second, hidden]) =
crate::behavior::scenario::direct_tests::navigation_fixture();
let mut third = project.elements[&first].clone();
third.id = Id::new_v4();
third.alias = "third_menu_reference".into();
third.name = "Third reference".into();
let third_id = third.id;
project = execute_batch(&project, &[Command::CreateElement(third)]).unwrap();
let views = [
saved(&mut project, owner, "A"),
saved(&mut project, owner, "B"),
];
let mut app = App::with_project(project);
send(&mut app, Message::Select(owner));
send(&mut app, Message::Page(Page::Behavior));
(app, [first, second, third_id], hidden, views)
}
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn choose(app: &mut App, id: Id, choice: Choice) {
let event = selection::Event {
context: app.scenario_context().unwrap(),
snapshot: app.multiselect_snapshot().unwrap(),
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
};
send(
app,
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event))),
);
}
fn request(app: &App, id: Id) -> Request {
let mut request = Request::object(id, Point::new(120., 80.));
request.scenario = app.scenario_context().map(Box::new);
request.selection_target = Some(Box::new(Target::ScenarioObject(id)));
request
}
fn action(app: &App, caption: &str) -> Message {
popup(app)
.unwrap()
.entries
.into_iter()
.find_map(|entry| match entry {
Entry::Action { label, message, .. } if label == caption => message,
_ => None,
})
.unwrap_or_else(|| panic!("Expected enabled Scenario menu action {caption}"))
}
#[test]
fn scenario_requests_require_exact_visible_target_and_complete_exclusive_context() {
let (mut app, [first, second, _], hidden, [view, _]) = fixture();
choose(&mut app, first, Choice::Replace);
let valid = request(&app, first);
assert!(Opened::new(&app, valid.clone(), 1).is_some());
let current = app.scenario_context().unwrap();
for captured in [
Context {
document: Id::new_v4(),
..current
},
Context {
scenario: Id::new_v4(),
..current
},
Context {
representation: Representation::Saved { diagram: view },
..current
},
Context {
visit: Id::new_v4(),
..current
},
Context {
revision: current.revision + 1,
..current
},
Context {
epoch: current.epoch + 1,
..current
},
] {
let mut stale = valid.clone();
stale.scenario = Some(Box::new(captured));
assert!(Opened::new(&app, stale, 2).is_none());
}
let mut wrong = valid.clone();
wrong.object = second;
assert!(Opened::new(&app, wrong, 2).is_none());
assert!(app.project().contains(hidden));
assert!(!app.multiselect_target_visible(&Target::ScenarioObject(hidden)));
assert!(Opened::new(&app, request(&app, hidden), 2).is_none());
let mut wrong = valid.clone();
wrong.placement = Some(Id::new_v4());
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.canvas = Some(route_edit::Context {
document: current.document,
diagram: view,
revision: current.revision,
epoch: current.epoch,
});
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.process = Some(Box::new(crate::behavior::topology::Context {
document: current.document,
owner: current.scenario,
revision: current.revision,
epoch: current.epoch,
}));
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid.clone();
wrong.selection_target = Some(Box::new(Target::Edge(first)));
assert!(Opened::new(&app, wrong, 2).is_none());
let mut wrong = valid;
wrong.scenario = None;
assert!(Opened::new(&app, wrong, 2).is_none());
}
#[test]
fn scenario_menu_rejects_saved_visit_roundtrip_even_with_equal_generic_snapshot() {
let (mut app, [first, second, _], _, [a, b]) = fixture();
let switch = |app: &mut App, diagram| {
let context = app.scenario_context().unwrap();
send(
app,
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
Representation::Saved { diagram },
)),
);
};
switch(&mut app, a);
choose(&mut app, first, Choice::Replace);
choose(&mut app, second, Choice::Add);
let opened = Opened::new(&app, request(&app, first), 1).unwrap();
let old_context = app.scenario_context().unwrap();
let before = app.document.snapshot();
let selected = app.diagram_selection.take().unwrap();
switch(&mut app, b);
assert!(!opened.valid(&app));
switch(&mut app, a);
assert_ne!(app.scenario_context().unwrap().visit, old_context.visit);
// Restore the same shared selection and epoch deliberately. Only the full
// Scenario visit may distinguish the old request from this returned view.
app.canvas_epoch = old_context.epoch;
app.diagram_selection = Some(selected);
app.selected = second;
assert_eq!(app.multiselect_snapshot(), opened.scenario_selection);
assert!(!opened.valid(&app));
assert!(Opened::new(&app, opened.request.clone(), 2).is_none());
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn scenario_group_menus_capture_set_and_never_expose_partial_model_actions() {
let (mut app, [first, second, third], _, _) = fixture();
choose(&mut app, first, Choice::Replace);
choose(&mut app, second, Choice::Add);
let before = app.document.snapshot();
let capture = actions_request(&app, Point::ORIGIN);
assert_eq!(capture.object, second);
assert_eq!(
capture.selection_target.as_deref(),
Some(&Target::ScenarioObject(second))
);
assert_eq!(capture.scenario.as_deref().copied(), app.scenario_context());
assert!(capture.canvas.is_none() && capture.process.is_none() && capture.placement.is_none());
for target in [first, third] {
let captured_request = request(&app, target);
send(&mut app, Message::Menu(Event::Open(captured_request)));
let snapshot = app.multiselect_snapshot().unwrap();
let menu = popup(&app).unwrap();
assert!(
menu.entries
.iter()
.any(|e| matches!(e, Entry::Title(label) if label == "2 selected"))
);
let mut enabled = 0;
for entry in menu.entries {
match entry {
Entry::Action {
label,
message: Some(Message::Menu(Event::Invoke(_, message))),
..
} => {
let Message::ScenarioCanvas(CanvasEvent::Selection(event)) = *message else {
panic!("Scenario group menu exposed a singleton model action");
};
assert_eq!(event.context, app.scenario_context().unwrap());
assert_eq!(event.snapshot, snapshot);
let selection::Intent::Choose(actual_target, choice) = event.intent else {
panic!("Unexpected group selection intent");
};
assert_eq!(actual_target, Target::ScenarioObject(target));
assert!(matches!(
(label.as_str(), choice),
("Add to selection", Choice::Add)
| ("Remove from selection", Choice::Remove)
| ("Inspect this element", Choice::Inspect)
| ("Select only this element", Choice::Replace)
));
enabled += 1;
}
Entry::Action {
label,
message: None,
..
} => {
assert!(!label.contains("Hide"));
}
Entry::Action {
message: Some(_), ..
} => panic!("Uncaptured menu action"),
_ => {}
}
}
assert_eq!(enabled, if target == first { 3 } else { 2 });
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
assert_eq!(app.selected, second);
}
let selected_request = request(&app, first);
send(&mut app, Message::Menu(Event::Open(selected_request)));
let inspect = action(&app, "Inspect this element");
send(&mut app, inspect);
assert_eq!(app.selected, first);
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[
Target::ScenarioObject(first),
Target::ScenarioObject(second)
]
);
let remove_request = request(&app, second);
send(&mut app, Message::Menu(Event::Open(remove_request)));
let remove = action(&app, "Remove from selection");
send(&mut app, remove);
assert_eq!(
app.diagram_selection.as_ref().unwrap().targets(),
&[Target::ScenarioObject(first)]
);
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), before.bundle);
}
#[test]
fn scenario_menu_snapshot_rejects_selection_aba_and_singleton_actions_stay_available() {
let (mut app, [first, second, _], _, _) = fixture();
choose(&mut app, first, Choice::Replace);
let captured = request(&app, first);
send(&mut app, Message::Menu(Event::Open(captured)));
let _ = action(&app, "Properties");
let _ = action(&app, "Delete from model…");
let opened = app.model_menu.take().unwrap();
let context = app.scenario_context();
// Isolate generation admission from the adapter's additional epoch defense.
let selected = app.diagram_selection.as_mut().unwrap();
selected.add(Target::ScenarioObject(second), |_| true);
selected.remove(&Target::ScenarioObject(second));
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, first);
assert!(!opened.valid(&app));
}
+151 -110
View File
@@ -25,6 +25,23 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
.on_press(m)
.style(style::nav)
}
/// Optional instructions belong beside the work, without taking a canvas row.
/// The button also opens keyboard-accessible help; the tooltip is only a preview.
pub(crate) fn diagram_help<'a>(
topic: crate::guide::Topic,
hint: impl Into<String>,
) -> View<'a, Message> {
tooltip(
action("Help · F1", Message::ContextHelp(topic)),
container(text(hint.into()).size(12))
.padding(10)
.max_width(360)
.style(style::card),
tooltip::Position::Bottom,
)
.into()
}
pub fn short(s: &str, limit: usize) -> String {
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.graphemes(true).count() > limit {
@@ -240,11 +257,23 @@ pub fn view(app: &App) -> View<'_, Message> {
if !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
body = body.push(inspector(app));
}
let diagram_feedback = diagram_feedback(app);
// Keep operation outcomes visible even while a drawing hint is active.
let (status, status_detail) = if app.busy {
("Working…".into(), "Working…".into())
} else if let Some(feedback) = diagram_feedback.filter(|feedback| *feedback != app.status) {
(
format!("{} · {}", short(&app.status, 60), short(&feedback, 80)),
format!("{}\n{}", app.status, feedback),
)
} else {
(short(&app.status, 145), app.status.clone())
};
let footer = container(
row![
container(tooltip(
text(short(if app.busy { "Working…" } else { &app.status }, 145)).size(11),
container(text(if app.busy { "Working…" } else { &app.status }).size(12))
text(status).size(11).wrapping(widget::text::Wrapping::None),
container(text(status_detail).size(12))
.padding(10)
.max_width(600)
.style(style::card),
@@ -267,6 +296,25 @@ pub fn view(app: &App) -> View<'_, Message> {
.padding([4, 16])
.style(style::panel);
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
let root = crate::guide::protect_workspace(app.context_help, root);
if app.context_help {
return modal_width(
root,
column![
row![
text("Diagram help").size(18),
Space::new().width(Fill),
action("Close · Esc", Message::CloseContextHelp),
]
.align_y(iced::Center),
crate::guide::context_view(&app.guide),
]
.spacing(12)
.height((app.window_size.height - 140.).max(300.))
.into(),
(app.window_size.width - 80.).min(900.),
);
}
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
return modal_width(
root,
@@ -362,7 +410,8 @@ pub fn view(app: &App) -> View<'_, Message> {
(app.window_size.width - 32.).clamp(120., 760.),
);
}
root
// Match modal_width's first child so help does not recreate the editor tree.
widget::stack![root].into()
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
modal_width(base, content, 800.)
@@ -447,7 +496,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.map(|d| d.projection);
let modes = row![
let mut modes = row![
label("View content"),
pick_list(
[
@@ -462,7 +511,15 @@ fn diagram(app: &App) -> View<'_, Message> {
Message::Projection
)
.padding(8)
.text_size(12)
.text_size(12),
diagram_help(
crate::guide::Topic::Nesting,
if projection == Some(ProjectionKind::Traceability) {
"Shows OA and SA elements with correspondence lines. Place both ends of a trace to show it; port traces use their owner cards. Open Help for trace visibility and editing controls."
} else {
"Shift-click to add or remove selection. Right-click for actions; drag to arrange; scroll to zoom. Drag a connection line, bend or label to move it; Insert adds a bend and Escape cancels. Open Help for full editing controls."
},
)
]
.spacing(10)
.align_y(iced::Center);
@@ -555,6 +612,26 @@ fn diagram(app: &App) -> View<'_, Message> {
.center(Fill)
.into()
};
if projection == Some(ProjectionKind::Allocation) {
modes = modes.push(tooltip(
action(
if app.allocation_drag.arrange_next {
"Arrange ✓"
} else {
"Arrange"
},
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext),
),
container(text(format!(
"Arrange next drag · {}. Move work within its current allocation; Escape cancels.",
crate::allocation_input::ARRANGE_SHORTCUT
)))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Bottom,
));
}
let mut tools = column![toolbar].spacing(8);
if app.active_diagram.is_some() {
tools = tools.push(modes);
@@ -568,82 +645,50 @@ fn diagram(app: &App) -> View<'_, Message> {
.feedback
.as_ref()
.map(|feedback| feedback.destination_identity())
.unwrap_or_else(|| "None · drag work to choose".into());
.unwrap_or_else(|| "—".into());
let inspection = app
.allocation_drag
.feedback
.as_ref()
.map(|feedback| feedback.inspection_identity())
.unwrap_or_else(|| destination.clone());
tools = tools.push(
container(tooltip(
text(format!("Destination: {}", short(&destination, 28)))
.size(13)
.wrapping(widget::text::Wrapping::None),
container(text(inspection).size(13))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Top,
))
.id("allocation-destination")
.height(26)
.width(Fill)
.clip(true),
);
tools = tools.push(
row![
action(
&format!(
"{} · {}",
if app.allocation_drag.arrange_next {
"Arrange next drag: armed"
} else {
"Arrange next drag"
},
crate::allocation_input::ARRANGE_SHORTCUT
),
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
),
label("Drag work into a performer to assign · out to remove assignment")
]
.spacing(8)
.align_y(iced::Center),
);
let destination_control = container(tooltip(
text(format!("Destination: {}", short(&destination, 28)))
.size(13)
.wrapping(widget::text::Wrapping::None),
container(text(inspection).size(13))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Top,
))
.id("allocation-destination")
.height(26)
.width(Fill)
.clip(true);
tools = tools.push(destination_control);
}
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
if projection == Some(ProjectionKind::Traceability) {
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
tools = tools.push(
column![
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
row![
label(format!("{correspondence_lines} correspondence lines in this view")),
action(
"Add existing elements…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
),
action("Review layer traces", Message::ReviewLayerTraces),
action("View visibility…", Message::Select(diagram)),
]
.spacing(7)
.align_y(iced::Center)
.wrap(),
row![
label(format!("{correspondence_lines} trace lines")),
action(
"Add existing elements…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
None
))
),
action("Review layer traces", Message::ReviewLayerTraces),
action("View visibility…", Message::Select(diagram)),
]
.spacing(4),
.spacing(7)
.align_y(iced::Center)
.wrap(),
);
}
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
let promoted = edge.is_some_and(|edge| {
oa_core::projection::edge_endpoint_placements(
app.project(),
&app.project().diagrams[&diagram],
edge.id,
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let selection = app.multiselect_snapshot();
let count = selection.as_ref().map_or(0, |s| s.targets().len());
let mut selection_controls =
@@ -662,66 +707,62 @@ fn diagram(app: &App) -> View<'_, Message> {
}
selection_controls = selection_controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
snapshot.context(),
)),
))
.push(
label(format!(
"Properties edit only {}",
short(app.project().named(app.selected), 36)
"Properties: {} only",
short(app.project().named(app.selected), 14)
))
.wrapping(widget::text::Wrapping::None),
);
}
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
feedback.text.as_str()
} else if let Some(session) = &app.connection {
if session.source.is_some() {
if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
} else {
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
}
} else {
session.tool.hint()
}
} else if count > 1 {
if selection
.as_ref()
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
&& projection != Some(ProjectionKind::Allocation)
{
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
} else {
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
}
} else if promoted {
"Expand endpoint containers to edit this connection route"
} else if edge.is_some() {
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
} else {
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
};
// Selection changes must not resize the measured canvas or refit its
// camera. Reserve both rows even when no group actions are available.
// Selection never moves the canvas. General controls live in Help;
// active gesture feedback uses the persistent application status bar.
tools = tools.push(
container(
column![
selection_controls,
label(hint).wrapping(widget::text::Wrapping::None)
]
.spacing(3),
)
.height(56)
.width(Fill)
.clip(true),
container(selection_controls)
.height(32)
.width(Fill)
.clip(true),
);
}
column![container(tools).padding(14), canvas].into()
}
fn diagram_feedback(app: &App) -> Option<String> {
if app.page != Page::Diagram {
return None;
}
if let Some(feedback) = &app.allocation_drag.feedback {
return Some(feedback.text.clone());
}
if let Some(session) = &app.connection {
return Some(
if session.source.is_none() {
session.tool.hint()
} else if session.candidates.is_empty() {
"No compatible targets visible. Expand containers or show an endpoint · Esc cancels"
} else {
"Choose a highlighted target · Esc cancels"
}
.into(),
);
}
let diagram = app.active_diagram?;
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected)?;
let (source, target) = oa_core::projection::edge_endpoint_placements(
app.project(),
&app.project().diagrams[&diagram],
edge.id,
)?;
(source != edge.source || target != edge.target)
.then(|| "Expand endpoint containers to edit this connection route".into())
}
fn inspector(app: &App) -> View<'_, Message> {
let p = app.project();
if let Some(target) = app.process_preview_primary() {
+2
View File
@@ -2,6 +2,8 @@
The normative contract is [language.rst](language.rst); [scope.md](scope.md) explains deliberate differences from the complete Arcadia/Capella ecosystem. V2 uses one typed model and one validated mutation boundary across the desktop, text editor and REST service.
Desktop changes follow the [editing workspace UX review criteria](ui-review.md) for concise persistent content, contextual help, visible actionable feedback and native interaction review.
## Crate boundaries
| Crate | Responsibility | Does not depend on |
+27
View File
@@ -0,0 +1,27 @@
# Editing workspace UX review
These development criteria implement the [project goal](../PROJECT_GOAL.md): reduce system engineers' cognitive load, keep graphical editing intuitive, and provide concise, navigable built-in help. They also address the user's request tracked in Gitea issue #39 to prevent persistent instructional text from consuming editing space. They are review criteria, not a claim that any particular build has passed them.
## Persistent content and progressive disclosure
Populated editing workspaces prioritize model content, controls, current state and actionable feedback. General explanations, gesture tutorials and repeated shortcut lists belong in contextual Help, tooltips or explicitly opened details. Do not add permanent instructional paragraphs above a populated canvas merely to explain a feature.
Keep essential control labels and relevant status visible. Offer concise next-step guidance in empty states and while a user is performing a multi-step action. Put longer explanations behind a clearly labeled disclosure near the relevant control or context. Essential instructions must be accessible through an explicit keyboard-reachable route; a hover-only tooltip is insufficient. Reuse the built-in guide for broader modeling explanations.
Distinguish instruction from information needed to act safely. Current errors, pending edits, failed operations, fallback rendering, unavailable actions and recovery steps must remain apparent. Preserve consequential distinctions such as editing a shared definition, changing semantic order rather than saved positions, or applying properties to only one member of a selection. Long diagnostic detail may be disclosed on demand, but its visible summary must identify the problem and a usable next step.
## Stable editing context
Selection changes and gesture feedback must not unexpectedly move or resize the canvas, reset its camera, or change the interpretation of the pointer. Compact existing stable layout slots instead of conditionally removing them when their content is absent. Opening and closing help must preserve the model, selection and pending drafts, and return the user to a usable editing context. Explicitly requested panel changes may resize the workspace; verify their effect on focus and navigation.
## Native review
Exercise the changed workspace in the actual built application at its configured minimum window size and a representative normal size. Include an independent reviewer for material interaction or layout changes, following [the working agreement](../AGENTS.md). Review representative populated models as well as relevant empty, pending, failure and recovery states.
- Inspect whether instructional prose or unused space displaces useful model content. Compare before/after screenshots at the same window size, display scaling, model and panel state when assessing space savings.
- Perform the affected workflow: select, edit, navigate, cancel and recover as applicable. Change between empty, single and multiple selections; exercise affected gestures and inspect camera and pointer stability.
- Find and use the disclosed guidance by keyboard as well as pointer. Check focus, dismissal, return to editing, and preservation of drafts.
- Trigger relevant disabled or rejected operations. Confirm their reason and recovery action remain visible, accurate and reachable.
- Check text, controls, panels and warnings for clipping, overlap, excessive scrolling and lost actions at the minimum size. Do not trade recovered space for unreadable text or unexplained symbols.
Record the tested code version, runtime environment, workflows, observations and representative screenshots in the change's review evidence. Screenshots support layout findings; they do not establish working interaction, accessibility or state preservation. Fix material findings and recheck affected behavior before treating the change as verified.
+43 -28
View File
@@ -1,36 +1,51 @@
# Integrated prerelease source checkpoint — 2026-09-24 UTC
# Isolated nonproduction label cue trial — 2026-10-01 PDT
This branch assembles peer contributions for the Windows prerelease in issue37.
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative. The complete OA/SA
application is unfinished; published previews and finite prototypes are intermediate.
This branch adds renderer experiment B at `3e5e094b` to the accepted foundation
below. [Actual trial and preserved evidence](experiments/label-cue-20261001/README.md)
pass code/conservation gates but fail reliable first-reading association. Do not
promote its cue or treat this branch as an accepted integrated release. Published
preview binaries/tags remain unchanged. Live next work stays in Gitea issue38.
**The current prerelease is Windows-only. Linux/WSL builds and checks remain
excluded by the user's explicit request, including after networking recovers.**
## Accepted foundation checkpoint
## Assembled source and acceptance boundary
This branch combines the released integrated foundation
`715036e132677dd2bc2fb37ed316d93dc9987aec` with compact diagram controls and
contextual Help production source `6e7c4ddaf0775198384a8c4882809ea8e14b2976`.
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative; complete OA/SA
acceptance is unfinished.
Combined code through **4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8**, plus the
test-only import correction from **7c2b06b63f8a0fe14b3f001996bdf965b36a4e85**, adds the reviewed
Connection/Route early-admission correction to the assembled Scenario/Text,
ordinary/State/Process/Allocation selection, direct drawing, Traceability and
picker changes. The [source handoff](prerelease-integrated-source-20260924.md)
identifies the incorporated commits, exact new test paths and required gates.
The nine event regressions and existing completed-Text-Apply cases are retained.
Direct formatting and whitespace checks pass; this source checkpoint does not
claim combined Cargo, native, packaged or release acceptance. The independent
[event-fix review](reviews/prerelease37-event-admission-20260924.md) found no
further material source defect in its stated scope. A subsequent peer compile
found a missing `projection::` qualification in the new Route test helper;
the current code includes that one-line correction. The original compile failed
before running tests. The first combined compile at `897d9f04` subsequently
found a missing `text_editor` import in the Allocation Text Apply regression;
the current source includes that one-line correction too. Both failures remain
recorded in the source handoff. Corrected combined execution remains pending.
**Linux/WSL builds remain excluded by the user's explicit request.**
Live build, final-source and publication results belong in
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
Human workflows are described in [the preview checklist](../docs/preview-human-checks.md).
Private mixed-parser source is excluded from this application candidate.
## Verified capabilities and evidence
The [frozen integrated Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.integrated.20260924)
provides the accepted 715 foundation and its combined test/package evidence.
It remains unchanged. The older pending-build description previously at the top
of this file is superseded by that published result.
The [compact Help contribution](compact-help-validation-20261001.md) removes
permanent canvas tutorial prose, keeps stable concise selection rows and full
allocation destinations, and supports contextual Help/F1 with keyboard reading,
exact caret/draft preservation and safe gesture retirement. Final focused gates
pass 125 tests; the earlier full-suite result retains its separate source scope.
[Fresh package validation](release-compact-help-validation-20261001.md) verifies
actual extracted desktop/server behavior, Save/Undo/Redo/reopen and complete
source/model oracles. This is a scoped development preview, not complete release
or acceptance of the separately owned follow-up union.
The [scoped compact Help Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.compact-help.20261001)
is published from evidence checkpoint `f1fdca371394aec1148e67f33f4d8c6b53a0c350`.
Its ZIP, checksum and validation attachment were downloaded and matched after
publication; [publication receipts](experiments/compact-help-20261001/publication.md)
retain the exact asset hashes and provenance.
Dense diagrams, remaining Scenario group adapters, quantities/calculations/
constraints, maintained libraries, broad accessibility/DPI and full OA/SA
acceptance remain unfinished. The independent projection/navigation, outgoing-drag,
icon, grid/performance and State-label contributions are outside this code.
A protected Save As destination collision has misleading external-change wording;
[issue 46](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/46)
records it. Files remain protected and normal/fresh-destination recovery is
verified. Live ownership, integration and release coordination remain in Gitea.
## Contribution-specific verification
+34
View File
@@ -0,0 +1,34 @@
# Compact diagram controls and contextual help
Branch-specific work for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39), based on published preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`.
This record does not accept the complete first release or the other diagram defects.
## Problem and choice
The user's screenshot showed two permanent Traceability explanation paragraphs and a separate generic gesture-hint row. Direct Windows inspection of the released water-pressure example reproduced these. At a 1100×700 client size (1102×732 captured window), the Traceability canvas began around window y=470 and ended around y=692, leaving about 220 pixels for modeling. At the normal 1440×900 client size its top was around y=417. The release was opened from the existing downloaded package; only an in-memory projection switch was made and Undo restored the clean model before closure.
The candidate moves general instructions into contextual Help, reusing the offline guide. Architecture, State and Process selection bars use a stable compact row. Allocation retains its full-width destination slot; a concise Arrange control sits beside the existing view-content and Help controls. Traceability retains its displayed-line count and existing actions. Active drawing/assignment feedback appears beside the operation outcome in the persistent status bar, with full text on hover. Operation errors must never be replaced by generic guidance.
Help opens as an overlay and closes with Close or Escape. F1 opens the relevant diagram topic. The workspace, selected group, pending source/property/behavior drafts and camera remain owned by their existing state. Background user commands are rejected before specialized canvas/reference dispatch. Passive results retain their established validity checks. The editor remains the first child of a stable Stack while Help opens and closes.
Rejected alternatives: ordinary navigation to the Guide page cleared selection and could reject Help while a behavior draft was pending; placing feedback beside the entire selection toolbar exhausted narrow-window width; unconditional status substitution hid operation failures. Independent source review identified these before acceptance. Hover-only help was rejected because the complete instructions need a keyboard-accessible route.
[Editing workspace UX review](../docs/ui-review.md) is the development/review contract requested by the user. It covers persistent prose, actionable feedback, context preservation and independent native interaction checks.
## Verification status
Clean source `f83068757af885572f6b3f932d07233dd1b94b7e` passed `cargo build -p oa-desktop --locked` on Windows gnullvm. Its candidate executable SHA-256 is `c1a9b196ec03e86f049fa2aadb2b86bfcaa3f0fbae582a80ee39ee8adf0758e3e`. The [independent source review](compact-context-help-source-review-20260925.md) records the corrected source findings, including passive viewport updates while Help is open.
Independent native review of that executable recovered approximately 61 pixels in normal Traceability, 114 pixels in minimum Traceability and 58 pixels in minimum Allocation. Help resizes, pointer-scrolls, selects the State topic, and closes through Close/Escape. The review also reproduced two acceptance failures: PageDown cannot scroll Help, and F1/Escape preserves a property or Process-creation draft but loses typing focus/caret continuation. These findings require correction and another native check; model-state unit tests alone do not cover internal widget focus. The idle State-canvas gesture sentence is also removed in the successor; active/pending and hovered-item feedback retain their stable slot.
The initial focused test run passed nine and failed one: the allocation test fixture used the default Overview projection before asserting allocation ownership. A separate ten-case mounted geometry run passed nine and failed the destination's existing 400-pixel minimum-width check. Sharing its row with the verbose Arrange action reduced that slot to 328 pixels at minimum size. The successor fixes the fixture explicitly and restores the full destination row without weakening its width check. The original failed logs are preserved in the [native/test evidence](experiments/compact-help-20260925/README.md). These diagnostic runs began before the successor edits and are not frozen-source acceptance of those edits.
Frozen successor `5657a996ed2cefb1fa84ef967b655eaf8f9f6817` passed formatting, strict all-target desktop Clippy and the Windows GUI build. The full desktop run passed 1,538 tests, ignored seven and failed one new mounted Help test before Help opened: its synthetic multi-character KeyPressed event inserted only the first character, as the actual Iced text input specifies. The final correction sends individual character events and retains the exact-caret, scrolling and IME assertions. The existing allocation width and ownership checks and new modifier/pointer cancellation checks passed. See the [preserved successor evidence](experiments/compact-help-20260925/candidate-5657/review.md) and logs; the failed run is not reported as a passing suite.
Independent native review of that exact executable confirmed keyboard Help scrolling, precise Inspector and Process-draft caret continuation after Help, resize, retained State tool state and complete allocation-error tooltips. Minimum Allocation recovers approximately 64 pixels with the full destination slot; minimum Traceability recovers 114 pixels. The reviewer found no new blocker within that scope.
Final source `6e7c4ddaf0775198384a8c4882809ea8e14b2976` passes formatting, strict desktop/all-target Clippy, GUI build and 125 focused Help/allocation/guide tests (zero failures/ignores). The corrected mounted caret test now executes its complete assertions. Unchanged evidence from the earlier full run is retained at its original source; the remaining 1,421 desktop cases were not rerun at the final source. The initial final GUI link failed when the disk filled; after the user expanded the disk, the same-source rebuild passed. The failed log remains preserved.
The [final native report](experiments/compact-help-20260925/candidate-6e7c/review.md) directly verifies cancellation status, guide wording, native group selection through context menus, primary Inspector changes, long-name controls at minimum size, exact caret return after both Help dismissal routes, and Apply/Undo/Redo/Undo recovery. Independent source review found no blocker in the final small delta. The watcher records exit 0 and empty stdout/stderr; all 37 copied model files still match the original after closure. An early transient OSK/modifier obstruction occurred before model loading and is recorded separately from app behavior. No blocking defect was found within this contribution's review scope. Shared integration and release admission remain in issue 39; full product completion is not claimed.
Required native checks: minimum and normal windows; Traceability and Allocation space comparison; multi-selection with long names; Help/F1/Close/Escape with property and behavior drafts; editor focus and canvas scroll/camera restoration; active feedback plus operation failure; State and Process controls; subsequent edit/Undo/Redo and source preservation. Linux remains deferred under the recorded user instruction.
@@ -0,0 +1,44 @@
# Compact contextual help: independent source review
Review date: 2026-09-25. Scope: Gitea issue #39 compact editing chrome and contextual Help, reviewed against base `715036e1` and the issue screenshot. Reviewed source includes `e0812b3932fbb36100516f0981dc9c646eeda56d` plus the subsequent local passive-geometry admission patch and regression test in this change. This is source-review evidence, not native acceptance or executed test evidence.
Criteria: [editing workspace UX review](../docs/ui-review.md), especially preserving pending edits and selection, stable canvas geometry, accessible disclosure, and retaining actionable feedback.
## Findings addressed in source
- The initial Help action used ordinary page navigation, clearing multiselection and selected bends, including before a pending behavior form rejected navigation. Contextual Help now overlays the existing workspace. Close/Escape changes reading state without navigating or applying drafts.
- App-level modal admission originally ran after allocation and reference-picker fast paths. Help now gates user messages at the beginning of `App::update`; allocation and behavior keyboard ownership are also disabled while Help is open.
- Combining live gesture feedback with multiselection controls could leave no width for feedback at the configured minimum window size. Selection controls now keep a compact, fixed-height row; current drawing/allocation feedback uses the existing application footer.
- Footer feedback initially replaced operation status, concealing errors while a promoted edge or drawing tool remained selected. The footer now retains operation status alongside feedback and exposes complete text in its tooltip.
- Conditional modal wrapping could recreate the editor widget tree. Both closed and open states now use the same outer Stack with the workspace as its first child.
- State/Process selection guidance removed from persistent chrome was missing from its target Help articles. The States and Chains articles now include group selection, primary-properties semantics and relevant tool/read-only guidance.
- The new early modal gate initially rejected passive behavior viewport/scroll notifications. It now admits Process/Scenario geometry and State viewport notifications to their existing context-validated handlers, preventing resize observations from being lost while Help is open.
## Regression coverage added
[`context_help_tests.rs`](../crates/oa-desktop/src/app/context_help_tests.rs) covers property drafts and multiselection, source drafts, pending state-transition forms, blocked background actions, contextual Help topic dispatch, Close/Escape, allocation input ownership, queued inspector reference-picker admission, and valid/stale Process viewport measurements during Help and after closing. Assertions include accepted-model snapshot identity and absence of history changes where applicable. Passive geometry intentionally validates document/owner/revision; stale pointer epochs alone do not invalidate measurements.
The reviewer did not compile or execute these tests. Suggested focused command: `cargo test -p oa-desktop context_help_tests`. The integration owner records actual execution and native results separately.
## Follow-up from native review
Native review of the subsequent candidate found that contextual Help did not respond to PageDown and that Escape reached a focused underlying text input, dropping its typing focus. The follow-up patch adds a Help-only keyboard subscription and article-ID-targeted scrolling (40-pixel arrows, 90% of the actual viewport for PageUp/Down, Home/End), using Iced's bounds-clamped scroll state. A stable workspace input barrier preserves the actual editor caret while suppressing key, pointer, touch and IME input behind Help; modifier changes and passive geometry continue. Opening Help retires held pointer gestures through cancellation releases with all authored actions discarded, while forwarding redraw/layout invalidation. This also prevents a held canvas pan or text selection from resuming after release behind Help.
New mounted regressions exercise the production App tree and subscriptions for keyboard scrolling, unchanged background scroll positions, precise Inspector caret return, IME exclusion, Shift release, and held-pan retirement. A real text-input widget regression checks selection-drag retirement while preserving its caret. The allocation ownership fixture was corrected to explicitly request Allocation, because `ensure_diagram` creates Overview. These follow-up changes were formatted and source-reviewed here; compilation, execution and native acceptance remain the integration owner's checks.
A second, read-only critic reviewed the follow-up independently of its implementation. It found and prompted corrections for stale modifiers, IME leakage and interrupted pointer gestures. Its final read found no further blocking source defect in the input barrier, cancellation, passive-event handling or relocated Arrange control. The separate native reviewer supplied the original keyboard/focus failures. Neither source review establishes executed or native acceptance of the successor.
## Scope and remaining verification
A final independent read-only source review covered the small post-`5657a996`
delta. It confirmed that the mounted typing fixture must send one KeyPressed per
character because Iced consumes the first scalar, with the original caret/IME
assertions retained. Arrange cancellation now replaces only its own obsolete
armed status, preserving unrelated errors; input epochs and model commands are
unchanged. The guide names the actual Arrange control. No blocking finding was
identified in that delta. Actual executed and native evidence remains separately
identified by source in the design record and evidence index.
Production changes reviewed: desktop App messages/admission, allocation input ownership, Escape bridging, behavior keyboard ownership, architecture/State/Process toolbar layout, guide article disclosure and footer feedback; supporting architecture/UI-review documentation. Regression tests and this record are review artifacts. No model schema, persistence format or authored-model transformation is introduced.
No further concrete source blocker was identified after the fixes above. Native checks remain necessary at 1100×700 and a representative normal size: Help discovery and keyboard reading/dismissal, focus and caret return, preservation of canvas camera/selection and form drafts, resize while Help is open, multiselection control visibility, allocation refusal/destination feedback, current operation failures, and stable canvas bounds through selection/gesture changes. Source inspection and App tests do not establish layout quality, keyboard scrolling, pointer behavior or usability.
@@ -0,0 +1,41 @@
# Compact diagram Help: Windows validation
The source contribution for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39)
is based on released `715036e132677dd2bc2fb37ed316d93dc9987aec`; final production/test
source is `6e7c4ddaf0775198384a8c4882809ea8e14b2976`. It removes permanent canvas
instructions, preserves compact stable controls and offers contextual Help/F1.
Model schema, core, server, dependencies and persistence code are unchanged.
[Decision and acceptance boundary](compact-context-help-20260925.md),
[independent source review](compact-context-help-source-review-20260925.md),
[native reports, screenshots and gate logs](experiments/compact-help-20260925/README.md)
and [development UX criteria](../docs/ui-review.md) are authoritative for this change.
Windows gnullvm / Rust 1.98.1: formatting, strict desktop/all-target Clippy and the
GUI build pass. Final focused run: **125 passed, zero failed/ignored**. Earlier
full desktop execution at `5657a996` passed 1,538, failed one new test's typing
setup and ignored seven; the corrected test executes fully in the final passing
run. This is not a claim of a full-suite run at the final commit. The initial
final link failed with a full disk and is retained; the recovered build succeeds.
Final Studio SHA256:
`d55ecad4b0f75c0c9c26625e5441bef9c9360890b65bc13236c6ebfaf03d631e`.
Matching runtime SHA256:
`f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4`.
Independent native checks pass group selection/primary properties, Help dismissal
and exact caret continuation, long names at 1100×700, Arrange cancellation,
guide consistency and Apply/Undo/Redo/Undo. Earlier versioned native checks cover
Process drafts, State tools, Help resize and actual operation refusals. Minimum
Allocation recovers about 64 pixels; minimum Traceability about 114 pixels.
Final process exit is 0, both logs are empty, and all 37 copied example files
remain unchanged. Native counts do not establish whole-Project equality; mounted
regressions provide the separately scoped model/history checks.
The application remains an unfinished development preview. The separate
projection/navigation, outgoing-drag, icon, grid/performance and State-label
contributions are outside this source. Dense routes/labels, remaining Scenario
group adapters, quantities/calculations/constraints, maintained libraries,
complete OA/SA acceptance and broad accessibility/DPI coverage remain unfinished.
Linux/WSL checks remain excluded by the user's recorded instruction. This change
does not accept the complete first release.
@@ -0,0 +1,62 @@
# Compact Help native evidence
Branch-specific evidence for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39).
The [design record](../../compact-context-help-20260925.md) owns the decision and current acceptance boundary.
`native/` contains the Windows baseline captures from published source
`715036e132677dd2bc2fb37ed316d93dc9987aec`.
`candidate-f830/` preserves the independent review of source
`f83068757af885572f6b3f932d07233dd1b94b7e`, including its two failed Help workflows,
original screenshots, exact executable/runtime hashes and successful build log.
These observations must not be relabeled as acceptance of a successor executable.
`candidate-5657/` preserves the next frozen source's build, strict all-target
Clippy, full desktop test log and independent native review. Formatting, Clippy
and build passed. Tests: 1,538 passed, one failed, seven ignored. The sole failure
was the new caret test's synthetic typing setup before Help opened; `help-failure.log`
records its exact rerun. The next correction uses one character per key event.
The native report records corrected Help keyboard/caret behavior, minimum-window
layout and real operation-refusal feedback, with explicit scope limitations.
Copied text logs have normalized line endings and trailing blank lines removed;
original local logs are retained. Image and fixture hashes are unchanged.
`candidate-6e7c/` preserves final source
`6e7c4ddaf0775198384a8c4882809ea8e14b2976`: formatting and strict desktop/all-target
Clippy pass, the recovered GUI build passes, and 125 focused Help/allocation/guide
tests pass with zero failures/ignores. `build-diskfull.log` preserves the first
failed link; no code change was needed for the recovered build. Its native report
and 15 screenshots cover cancellation, guide consistency, group/primary selection,
minimum-window long names, exact caret return and Undo/Redo/Undo. The runtime
receipt confirms exit 0 and empty logs. Post-close baseline comparison confirms
all 37 copied fixture files unchanged. The transient OSK observation happened
before loading the model and is explicitly retained. Prior source-specific
workflows remain attributed to their actual binaries.
Reproduce final code checks on the documented Windows gnullvm/Rust 1.98.1 toolchain:
```powershell
cargo fmt --all -- --check
cargo clippy -p oa-desktop --all-targets --locked -- -D warnings
cargo build -p oa-desktop --locked
cargo test -p oa-desktop --locked -- context_help_tests allocation_drag guide::tests --test-threads=1
```
The initial test compilation began at that commit; successor work was edited
while it ran. `tests.log` (nine passed, one failed fixture precondition) and
`geometry-tests.log` (nine passed, one failed destination-width invariant) are
diagnostic evidence from that cycle, not frozen-source acceptance. The geometry
run reused its test executable without recompiling. The first command's
`allocation_drag_full_tree_tests` filter matched no tests; the second used the
actual `full_tree` filter plus the affected mounted State/Process checks.
The review used a copy of `examples/release1/project.arcadia` from the published
Windows package. All 37 fixture files matched the baseline before the trial and
again after closing without saving. `baseline-proof.json` records those bytes.
The executable is a local development build, not a published release artifact.
Reproduce the build using the documented Windows gnullvm toolchain and
`cargo build -p oa-desktop --locked`. Place its matching `libunwind.dll` beside the
executable, launch it, and open an isolated copy of the release1 example. Perform
the workflows and use the window sizes in [the native report](candidate-f830/review.md).
The baseline captures establish layout comparison; the report records actual
interaction results and explicit unverified workflows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -0,0 +1,49 @@
{
"baseline_binary": "2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399",
"baseline_source": "715036e132677dd2bc2fb37ed316d93dc9987aec",
"model_files": {
"evidence/repair-check.md": "f4db17bdca4ecbb1bf0caef01b2da5f1f7f798ab3152b648ce6dd8696a6a68e5",
"expected-diagnostics.json": "3700393d76b940cfc076bf52861bd7b1f43370f8eabce3594531bd0bf1fc851e",
"model.json": "ef0ef4006c3e09d5db6e1b621256b3f7d6642f3a7b09cc34607cfbcca830c727",
"model.rs": "ddc2d92b0d7699a4cadfbb35ae379f0a68d17f4f5d7df82b4b1afcf8d5425fbe",
"oa/activities/activity_oa_restore_work.arcadia": "21cbd7176af8fad68453306f2371ff03e127a994b8796cdf044567332de9686f",
"oa/behavior/process_oa_assessment_procedure.arcadia": "23cabc213c264a16c8595414db6acb09248c1cef9cd699f1b0316e5bb82c062a",
"oa/behavior/process_oa_field_procedure.arcadia": "9c52086759b7344b4809ceb8d372f65647b16fc24153a03b887faa378c37fe96",
"oa/behavior/process_oa_response.arcadia": "675798bd19e6b40d03e7076b0e59b110cdaaab7b6d16c47c3ac80fa960072e84",
"oa/behavior/scenario_oa_conversation.arcadia": "e217e5394a0771dc16206ed9437a231da27905d2f19be64b3f48281c0a32fb6b",
"oa/behavior/scenario_oa_full_scenario.arcadia": "c5a285f2d7c61bd2a136107f267fbf9cddb1cba3f63ff36a6f53f7ab98f33df5",
"oa/behavior/state_machine_oa_monitor.arcadia": "e7349e1e5336282eed3f5a103129db33d3ae4ed500788ee7129e8fc193ac57f6",
"oa/capabilities.arcadia": "26a3d9b615a3251ff010e0b47127f2d16bc2dcd45ed95967208b0076d725843a",
"oa/model.arcadia": "069a5eaaf525089935cc9d73ae7b916866ed78c6534705d75739650ecdea7ada",
"oa/participants.arcadia": "591a75a9ecad280302be4d4ae0171ad616377ed11ead5e233848ca2dc0a71819",
"oa/types.arcadia": "f02c391d584674c879f662a418abba3a220f1fee88471ca49322ac9873de628d",
"project.arcadia": "b2674e6bf77cfe0f6f0ba79c81d2e96d92f39c02ca2c6266d80e91723bfb9fda",
"README.md": "113cfb1cf38a98b51bff60442d86a35d636a3d80ef206bece7d53d0aae67f2d5",
"sa/behavior/process_sa_assessment_procedure.arcadia": "5c414fbdaabf9f9e402b0441fcacf1cacf4dbbc6c822162f028de35abf59be2d",
"sa/behavior/process_sa_field_procedure.arcadia": "ece846fdf93857e71a9ab0efcbdf64a5c41c71180f62afa3f79077fffb53b8f2",
"sa/behavior/process_sa_response.arcadia": "af7fdda280a917cf626f6e077162d481f9aaad280f8e81330c07d7fe384b1c52",
"sa/behavior/scenario_sa_conversation.arcadia": "01339699ec3ee409418d9bb232542f2bcd9419f1e67b183e77700c56bb9f71d2",
"sa/behavior/scenario_sa_full_scenario.arcadia": "5dce6b00a3a441162e8dee3e23d9ca5ab7bed4a24de669b1fba03664a3086dda",
"sa/behavior/state_machine_sa_monitor.arcadia": "17dbcfa5bbfd10fc8e23a325fc950ce853c219b4aa89c74f96278cbb3f34acc8",
"sa/capabilities.arcadia": "81706a0ca9d7db2aa0350888b9c00d853885185db2e6b90557d3d51a11c3d645",
"sa/functions/function_sa_audit_thresholds.arcadia": "27f798f78f36e72b38edab3725af2feb0eaa010fd52a60b5c6b6bef57ad3b686",
"sa/functions/function_sa_restore_work.arcadia": "357c6acb80f86b4e8d8f5c98f9f79d140f85fbe09f3406b41b7ac6eba464ec8c",
"sa/model.arcadia": "805b90262e27090ae7da81a51bcefa49c97fb0b3d3220b4a72323b4c4351c395",
"sa/participants.arcadia": "ca144d6a8f45fe720b5d18c7b59c7671444baa8c37e5ce9117bcba34b3a6aa84",
"sa/types.arcadia": "540ddbc2bcd0ccf45298e3ea5660637341aff63ff65fd87ac2f26469d1adc064",
"traceability.arcadia": "474bd28d955ff7af7722f6e9a23220d816879e3f46202e79a233544b9c593116",
"transition-before.json": "2253e6434f741687e22327c37d520ed15c043bc36025498650bc2366bb6a874d",
"transition-request.json": "43b9a22e5f95ad3bc0ebd2478ead0e14406854abc989ae94ded9367b013d812a",
"views/diagram_oa_allocation_view.arcadia": "c45d7467839219d894804c1ef2d22ff4d4a5a84a6a273c526bafd4c1facde624",
"views/diagram_sa_allocation_view.arcadia": "352a3ca745517592b5c9b1239b5770f62f0466a329a91fd03457ef2b53c06ed6",
"views/diagram_sa_capability_view.arcadia": "8f18fcccbcddcf3c1ea0ffe727b5b23b77fc148c5da4fde953abe681e4c20cd2",
"views/diagram_sa_service_view.arcadia": "4c72cb7593d9c4be8d2600b0656a7a3a6ce84c45bb2f3e58a4ee5ae3bf91913c",
"views/diagram_sa_state_view.arcadia": "dd1bea81ac7dc549a53f9b190be6f13052e00ede345bd180444cc31edb892230"
},
"matching_files": 37,
"baseline_captures": {
"baseline-allocation-compact.png": "d92a0cca62a038dcf0894d92b37a64028342c2365e3fb8880ed597222123d96f",
"baseline-trace-compact.png": "b608a3d75612d8c06aacc094a7aebd12a0798ce30f2126c00bc3ad61a9103217",
"baseline-trace-normal.png": "6dfb6c595af4247aab3600da0e0a25ba4b11151436e9fc4a406f1036053f71d0"
}
}
@@ -0,0 +1,6 @@
{
"buildExit": 0,
"runtime": "F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4",
"source": "5657a996ed2cefb1fa84ef967b655eaf8f9f6817",
"binary": "83CCEFF8B713491AFEE92FFCAFE62B02F5EEA6A3D899F6F334848FDC7BB0D88D"
}
@@ -0,0 +1,2 @@
Compiling oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Finished `dev` profile [unoptimized] target(s) in 2m 12s
@@ -0,0 +1,6 @@
Compiling oa-core v0.2.0 (C:\ar-compact-01a0d717\crates\oa-core)
Checking iced_widget v0.14.2 (C:\ar-compact-01a0d717\crates\vendor\iced_widget)
Compiling oa-server v0.2.0 (C:\ar-compact-01a0d717\crates\oa-server)
Compiling oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Checking iced v0.14.0
Finished `dev` profile [unoptimized] target(s) in 4m 09s
@@ -0,0 +1,16 @@
running 1 test
thread 'app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret' (7368) panicked at crates\oa-desktop\src\app\context_help_tests.rs:389:5:
assertion `left == right` failed
left: "DSend"
right: "DRAFT Send"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret ... FAILED
failures:
failures:
app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1545 filtered out; finished in 1.56s
@@ -0,0 +1,32 @@
# Independent native successor review
Reviewed source `5657a996ed2cefb1fa84ef967b655eaf8f9f6817`; executable SHA-256 `83CCEFF8B713491AFEE92FFCAFE62B02F5EEA6A3D899F6F334848FDC7BB0D88D` independently rechecked after the run; runtime SHA-256 `F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4` from build.json. Used Windows native Computer Use, the isolated `model/project.arcadia`, normal 1440x900 and minimum 1100x700 client sizes (1442x932 and 1102x732 captures). The prior candidate report remains historical under `../candidate-f830/review.md`.
## Result
Both prior blocking native findings are corrected in the exercised workflows. No new blocking finding was observed in this bounded retest. This is not complete product, accessibility, multiselection or first-release acceptance.
### Help keyboard reading and caret preservation
1. In normal Allocation, focused inspector Name, selected its text, typed `LEFT RIGHT`, and pressed Ctrl+Left to put the caret before `RIGHT`. Opened Help with F1. PageDown visibly scrolled to lower sections; PageUp returned to the top; End reached the bottom; Home returned to the top; Down moved one increment and Up reversed it. Evidence: `02-help-pagedown.png`, `03-help-end.png`.
2. Pressed Escape, then typed `X` without refocusing. Name became exactly `LEFT XRIGHT`. Reopened with F1, clicked visible Close, then typed `Y` without refocusing. Name became exactly `LEFT XYRIGHT`. Thus keyboard reading did not move the background caret, and both dismissal paths preserved input continuation. Evidence: `04-caret-after-escape.png`, `05-caret-after-close.png`. Revert restored the accepted property.
3. Opened Help again and resized the native window to its minimum width and height through the native system menu. Help reflowed with Close reachable; End still reached the bottom and Escape returned to the same diagram. Evidence: `06-help-minimum-resized.png`.
4. At minimum size, opened System functional chain `Handle a pressure alarm`, chose W, clicked blank canvas, and entered `LEFT RIGHT` in the pending contribution Name. Ctrl+Left, F1, End, Escape, then `X` produced exactly `LEFT XRIGHT` without refocusing. Create remained disabled with its missing-source explanation and Cancel reachable. Cancel preserved the accepted model. Evidence: `15-process-caret-restored.png`.
### Compact controls and actionable feedback
- Normal and minimum Allocation show projection, Help and Arrange together, with a separate full-width Destination slot. At minimum size the canvas begins near y=350 versus baseline y=414, approximately 64 pixels recovered. Changing from no selection to a selected work card and exercising refused drags did not move the canvas boundary. Evidence: `01-allocation-normal.png`, `07-allocation-minimum.png`.
- Clicking Arrange displays `Arrange ✓`; the tooltip explains one-drag scope, Alt+A and Escape. Escape removes the check; Alt+A arms it again. Evidence: `08-arrange-tooltip-armed.png`.
- With Arrange armed, dragged `Assess pressure alarm` from Control room toward Field technician. The operation was refused with the visible footer message `Make room or adjust the view, then drag again. Zoom in to read the work and destination.` The accepted model remained saved and the work stayed allocated to Control room. Evidence: `09-arrange-refusal.png`.
- Repeated the drag without Arrange. Core validation refused the reassignment with `exchange_allocation_performer: Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.` The shortened footer remained visible, and pointing at it exposed the complete readable explanation. The selected activity and accepted model remained unchanged. Evidence: `10-assignment-refusal.png`, `11-refusal-tooltip.png`.
- Changed View content to Traceability. Count, Add existing elements, Review layer traces, View visibility and Help all remain reachable at minimum width. Canvas starts near y=356 versus baseline y=470, approximately 114 pixels recovered. Undid this temporary projection change. Evidence: `12-trace-minimum.png`.
- Opened saved State view `5 · SA pressure supervision`: the generic idle canvas instruction is removed. Choosing +Region still shows its active creation prompt. F1 opens the States topic; Escape returns with +Region still selected, without creating an object. Evidence: `13-state-idle.png`, `14-state-active-prompt.png`.
## Limitations and minor observations
- The existing State active prompt still truncates at minimum width (`Create Region ... machine or composite s...`). This retest confirms it was retained, not that every pre-existing State prompt has complete narrow-window readability.
- The compact control says `Arrange`, while the guide still says `Arrange next drag`; its tooltip explains that wording. Minor consistency cleanup was reported to the integration owner; no user workflow failed because of it.
- Modifier-click/native multiselection and long-name group toolbar layout were not independently operated because the supported Computer Use API has no modifier-click/key-hold operation. Do not infer them from the single-selection screenshots. The parent owns mounted-widget verification separately. Source-editor caret behavior and full Scenario workflows were not repeated in this bounded successor retest.
- The approved native launch API does not expose stdout/stderr or process exit status. After Alt+F4, refreshed native window enumeration confirmed both successor application and console windows disappeared. No crash dialog was observed; this is not an asserted exit code or log inspection.
No save was performed. A post-run hash comparison against baseline-proof.json found no changed files among all 37 copied fixture files. The UI returned to the saved baseline with 94 objects, 62 links and one model check before closure. Screenshots are supporting evidence; the results above describe actual input and observed responses.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,49 @@
{
"baseline_binary": "2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399",
"baseline_source": "715036e132677dd2bc2fb37ed316d93dc9987aec",
"model_files": {
"evidence/repair-check.md": "f4db17bdca4ecbb1bf0caef01b2da5f1f7f798ab3152b648ce6dd8696a6a68e5",
"expected-diagnostics.json": "3700393d76b940cfc076bf52861bd7b1f43370f8eabce3594531bd0bf1fc851e",
"model.json": "ef0ef4006c3e09d5db6e1b621256b3f7d6642f3a7b09cc34607cfbcca830c727",
"model.rs": "ddc2d92b0d7699a4cadfbb35ae379f0a68d17f4f5d7df82b4b1afcf8d5425fbe",
"oa/activities/activity_oa_restore_work.arcadia": "21cbd7176af8fad68453306f2371ff03e127a994b8796cdf044567332de9686f",
"oa/behavior/process_oa_assessment_procedure.arcadia": "23cabc213c264a16c8595414db6acb09248c1cef9cd699f1b0316e5bb82c062a",
"oa/behavior/process_oa_field_procedure.arcadia": "9c52086759b7344b4809ceb8d372f65647b16fc24153a03b887faa378c37fe96",
"oa/behavior/process_oa_response.arcadia": "675798bd19e6b40d03e7076b0e59b110cdaaab7b6d16c47c3ac80fa960072e84",
"oa/behavior/scenario_oa_conversation.arcadia": "e217e5394a0771dc16206ed9437a231da27905d2f19be64b3f48281c0a32fb6b",
"oa/behavior/scenario_oa_full_scenario.arcadia": "c5a285f2d7c61bd2a136107f267fbf9cddb1cba3f63ff36a6f53f7ab98f33df5",
"oa/behavior/state_machine_oa_monitor.arcadia": "e7349e1e5336282eed3f5a103129db33d3ae4ed500788ee7129e8fc193ac57f6",
"oa/capabilities.arcadia": "26a3d9b615a3251ff010e0b47127f2d16bc2dcd45ed95967208b0076d725843a",
"oa/model.arcadia": "069a5eaaf525089935cc9d73ae7b916866ed78c6534705d75739650ecdea7ada",
"oa/participants.arcadia": "591a75a9ecad280302be4d4ae0171ad616377ed11ead5e233848ca2dc0a71819",
"oa/types.arcadia": "f02c391d584674c879f662a418abba3a220f1fee88471ca49322ac9873de628d",
"project.arcadia": "b2674e6bf77cfe0f6f0ba79c81d2e96d92f39c02ca2c6266d80e91723bfb9fda",
"README.md": "113cfb1cf38a98b51bff60442d86a35d636a3d80ef206bece7d53d0aae67f2d5",
"sa/behavior/process_sa_assessment_procedure.arcadia": "5c414fbdaabf9f9e402b0441fcacf1cacf4dbbc6c822162f028de35abf59be2d",
"sa/behavior/process_sa_field_procedure.arcadia": "ece846fdf93857e71a9ab0efcbdf64a5c41c71180f62afa3f79077fffb53b8f2",
"sa/behavior/process_sa_response.arcadia": "af7fdda280a917cf626f6e077162d481f9aaad280f8e81330c07d7fe384b1c52",
"sa/behavior/scenario_sa_conversation.arcadia": "01339699ec3ee409418d9bb232542f2bcd9419f1e67b183e77700c56bb9f71d2",
"sa/behavior/scenario_sa_full_scenario.arcadia": "5dce6b00a3a441162e8dee3e23d9ca5ab7bed4a24de669b1fba03664a3086dda",
"sa/behavior/state_machine_sa_monitor.arcadia": "17dbcfa5bbfd10fc8e23a325fc950ce853c219b4aa89c74f96278cbb3f34acc8",
"sa/capabilities.arcadia": "81706a0ca9d7db2aa0350888b9c00d853885185db2e6b90557d3d51a11c3d645",
"sa/functions/function_sa_audit_thresholds.arcadia": "27f798f78f36e72b38edab3725af2feb0eaa010fd52a60b5c6b6bef57ad3b686",
"sa/functions/function_sa_restore_work.arcadia": "357c6acb80f86b4e8d8f5c98f9f79d140f85fbe09f3406b41b7ac6eba464ec8c",
"sa/model.arcadia": "805b90262e27090ae7da81a51bcefa49c97fb0b3d3220b4a72323b4c4351c395",
"sa/participants.arcadia": "ca144d6a8f45fe720b5d18c7b59c7671444baa8c37e5ce9117bcba34b3a6aa84",
"sa/types.arcadia": "540ddbc2bcd0ccf45298e3ea5660637341aff63ff65fd87ac2f26469d1adc064",
"traceability.arcadia": "474bd28d955ff7af7722f6e9a23220d816879e3f46202e79a233544b9c593116",
"transition-before.json": "2253e6434f741687e22327c37d520ed15c043bc36025498650bc2366bb6a874d",
"transition-request.json": "43b9a22e5f95ad3bc0ebd2478ead0e14406854abc989ae94ded9367b013d812a",
"views/diagram_oa_allocation_view.arcadia": "c45d7467839219d894804c1ef2d22ff4d4a5a84a6a273c526bafd4c1facde624",
"views/diagram_sa_allocation_view.arcadia": "352a3ca745517592b5c9b1239b5770f62f0466a329a91fd03457ef2b53c06ed6",
"views/diagram_sa_capability_view.arcadia": "8f18fcccbcddcf3c1ea0ffe727b5b23b77fc148c5da4fde953abe681e4c20cd2",
"views/diagram_sa_service_view.arcadia": "4c72cb7593d9c4be8d2600b0656a7a3a6ce84c45bb2f3e58a4ee5ae3bf91913c",
"views/diagram_sa_state_view.arcadia": "dd1bea81ac7dc549a53f9b190be6f13052e00ede345bd180444cc31edb892230"
},
"matching_files": 37,
"baseline_captures": {
"baseline-allocation-compact.png": "d92a0cca62a038dcf0894d92b37a64028342c2365e3fb8880ed597222123d96f",
"baseline-trace-compact.png": "b608a3d75612d8c06aacc094a7aebd12a0798ce30f2126c00bc3ad61a9103217",
"baseline-trace-normal.png": "6dfb6c595af4247aab3600da0e0a25ba4b11151436e9fc4a406f1036053f71d0"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
{
"source": "6e7c4ddaf0775198384a8c4882809ea8e14b2976",
"binary": "D55ECAD4B0F75C0C9C26625E5441BEF9C9360890B65BC13236C6EBFAF03D631E",
"buildExit": 0,
"runtime": "F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4"
}
@@ -0,0 +1,2 @@
Compiling oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Finished `dev` profile [unoptimized] target(s) in 2m 21s
@@ -0,0 +1,2 @@
Checking oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Finished `dev` profile [unoptimized] target(s) in 5m 24s
@@ -0,0 +1,49 @@
# Final native UX retest
Independent review by `/root/ux_critique`, using Windows Computer Use (`@oai/sky`). Final preparation and launch began on 2026-09-29; the resumed input session completed on 2026-10-01 America/Los_Angeles (completion clock: 2026-10-02 00:42:55 UTC).
## Identity and scope
- Source: `6e7c4ddaf0775198384a8c4882809ea8e14b2976`.
- Executable: `.local/native-final/arcadia-studio.exe`, SHA-256 `D55ECAD4B0F75C0C9C26625E5441BEF9C9360890B65BC13236C6EBFAF03D631E`.
- Runtime: `libunwind.dll`, SHA-256 `F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4`.
- Read build identity from `build.json`. Parent launched this executable with a stdout/stderr/exit watcher; reviewer selected its unique returned native window and did not launch another executable.
- Model: only `.local/native-final/model/project.arcadia`, copied from the released water-pressure fixture. `baseline-proof.json` records its 37 original file hashes and baseline source/binary identity. No Save was performed.
- Normal window: 1440x900 client, captured 1442x932. Minimum: 1100x700 client, captured 1102x732. Same native environment and baseline fixture as prior review.
This is a bounded final-delta retest plus previously missing native group-selection/long-name coverage. The earlier `../candidate-5657/review.md` remains historical evidence for Process draft, State, Traceability, refused allocation, and resize-with-Help workflows on source `5657a996...`; those are not silently relabeled as this binary's results.
## Result
No blocking UX defect found in the workflows below. The two final minor corrections behave as intended, and the newly exercised native group workflow passed.
### Arrange cancellation and disclosed guidance
1. In the populated Allocation view, clicked Arrange. The control displayed its check and footer showed the armed state.
2. Pressed Escape. The check cleared and footer became `Arrange cancelled`, replacing the obsolete armed outcome. Repeated at minimum size with the same result. Evidence: `02-arrange-escape-cancelled.png`, `14-minimum-arrange-cancelled.png`.
3. Armed Arrange again, then opened contextual Help with the pointer. Help opened and footer became `Arrange cancelled`. Visible Close returned to the same model and canvas. Evidence: `03-help-cancels-arrange.png`.
4. Used PageDown to reach the relevant guide paragraphs. They now say `Choose Arrange` / `choose Arrange or press Alt+A`, matching the visible control, and explain right-click Add to selection / Inspect this element. Evidence: `04-guide-arrange-and-context-selection.png`.
### Native group selection and primary inspector
1. Clicked `Assess pressure alarm`; right-clicked `Repair valve in the field`; chose `Add to selection` from the ordinary context menu. Both cards gained selected outlines and the row displayed `2 selected`, `Hide selected`, `Clear`, and `Properties: Repair valve ... only`. Inspector showed the Repair activity. Evidence: `05-native-group-selection.png`.
2. Right-clicked the selected Assess card and chose `Inspect this element`. Both cards stayed selected; Inspector switched to Assess and the row's `Properties: ... only` summary followed it. Evidence: `06-group-primary-inspector.png`.
3. Opened F1 Help and dismissed with Escape. The same pair, primary inspector, canvas position and zoom remained. Evidence: `07-group-help-preserved.png`.
4. Resized to the configured minimum through the native window system menu. The group controls remained readable and reachable, with the full-width Destination slot above them. The intentional resize refitted the view; subsequent selection/draft/help transitions did not move its canvas boundary. Evidence: `08-group-minimum.png`.
### Long name, exact caret, Apply, Undo and Redo
1. With both cards still selected and Assess as primary, focused its Name field and replaced the draft with `Pressure alarm assessment with a deliberately long operational activity name LEFT RIGHT`.
2. Ctrl+Left placed the caret before RIGHT. Opened F1, pressed End to scroll Help, dismissed with Escape, and typed X without clicking the field. Reopened F1, used visible Close, and typed Y without refocusing. Pressing End in the restored field exposed the ending `LEFT XYRIGHT`. Thus both dismissal paths preserved the exact draft caret, not merely the draft contents. The pair and primary inspector also remained unchanged. Evidence: `09-group-long-draft-caret.png`.
3. Clicked Apply changes. Only the primary Assess activity acquired the long name; Repair retained its original label. At minimum size, the selection row remained one stable row: count, Hide selected, Clear, and a shortened `Properties: Pressure alar... only` summary. The truncation did not hide the `only` distinction or an action. The Inspector Name field remained editable/scrollable for the complete name. Evidence: `10-long-name-group-minimum.png`.
4. Clicked the non-editable summary row to leave the text input, then Ctrl+Z restored `Assess pressure alarm` and the UI's saved state. Ctrl+Y restored the long name and unsaved state. Ctrl+Z restored the original name and saved state again. The group remained selected through all three operations. Evidence: `11-long-name-undo.png`, `12-long-name-redo.png`.
5. Clicked Clear. The group cleared, Inspector returned to view properties, and the canvas boundary and card positions remained stable. Evidence: `13-clear-selection-stable-canvas.png`.
## Environment observation and limits
- Initial Ctrl+O unexpectedly opened Windows On-Screen Keyboard, obscuring the target. Its screenshot showed a highlighted Windows key; another observation showed a different highlighted key without another keyboard action from this reviewer. Input was stopped and the parent notified. The keyboard was already absent on the next fresh window enumeration; reviewer did not close it or press any Windows-key control. Used pointer Open and a literal copied-fixture path to continue. Subsequent Escape, PageDown, F1, Ctrl+A, Ctrl+Left, Alt+Space, Undo/Redo and typing produced the expected target-app responses. This transient environment interference is preserved in `00-environment-input-interference.png`; it is not attributed to an application defect.
- Native modifier-click remains untested; native group selection was genuinely exercised through the supported context menu instead. No multi-item drag/assignment was attempted in this final retest. Prior successor refused-drag evidence is separate.
- Process/State/Scenario, source-editor caret, all Help navigation keys and resize while Help is open were not all repeated on this final binary. The final run directly exercised property draft/caret, group preservation, PageDown and End, F1/Escape/Close, minimum sizing and edit recovery. Full acceptance must combine the accurately versioned evidence rather than infer missing workflows from screenshots.
- Saved-state labels, counts and visible Undo results do not prove whole-Project equality. This review made no Save; post-close byte-hash and watcher-log checks belong to the parent and are recorded separately.
Before closure, UI showed `All changes saved`, original activity names, 94 objects, 62 links and one model check. Closed normally with Alt+F4. Fresh native window enumeration confirmed the final application's window was absent. Parent was notified to inspect watcher stdout/stderr/exit and fixture hashes. No crash dialog was observed; native window disappearance alone is not an asserted exit code.

Some files were not shown because too many files have changed in this diff Show More