diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index cd3ca660..aaa50d9e 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -10,13 +10,14 @@ mod hosted; mod reference_authoring; #[cfg(test)] mod review_navigation_tests; +mod scenario_diagram; pub(crate) mod source_rename; mod source_symbols; #[cfg(test)] mod transition_context_tests; #[cfg(test)] mod view_copy_tests; -pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "Saved Scenario layout rendering and canvas editing are not available yet. Select the Scenario in Behavior to use its existing semantic preview."; +pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "This action uses architecture diagram tools and is unavailable for a saved Scenario. Open the view in Behavior; saved positions are preserved."; use crate::forms::Draft; use crate::{ behavior, @@ -530,6 +531,7 @@ pub struct App { pub(crate) diagram_navigation_focus: Option, source_diagram_job: Option, diagram_lookup_in_flight: Option, + pending_scenario_reveal_visit: Option, pending_diagram_reveal: Option<( crate::diagram_usage::Context, crate::diagram_usage::Usage, @@ -651,6 +653,7 @@ impl App { source_diagram_job: None, diagram_lookup_in_flight: None, pending_diagram_reveal: None, + pending_scenario_reveal_visit: None, model_issues, migration: None, source_version: 0, @@ -665,6 +668,7 @@ impl App { workflow: None, }; app.route_state_diagram_workspace(); + app.reconcile_scenario_workspace(); app.last_workspace_page = app.page; app } @@ -775,6 +779,17 @@ impl App { self.sync_with_alias_editor(None); } fn sync_with_alias_editor(&mut self, hint: Option>) { + // A saved view may be retargeted across layers by one source transaction. + // Follow that explicit identity before generic active-view normalization. + if let Some(rep @ behavior::scenario_layout::Representation::Saved { diagram }) = + self.behavior.scenario.representation() + && self.active_diagram == Some(diagram) + && let Some(owner) = + behavior::scenario_layout::presentation::checked_owner(self.project(), rep) + { + self.layer = self.project().elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + } self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.model_issues = validate(self.project()); self.normalize_creation(); @@ -879,6 +894,26 @@ impl App { .map_err(|e| e.to_string()) }) { Ok(id) => { + if self.behavior.scenario.representation() + == Some(behavior::scenario_layout::Representation::Saved { diagram: original }) + && id != original + { + let representation = + behavior::scenario_layout::Representation::Saved { diagram: id }; + if let Some(owner) = behavior::scenario_layout::presentation::checked_owner( + self.project(), + representation, + ) { + self.behavior.scenario.enter_representation( + self.canvas_document, + owner, + representation, + ); + if self.page != Page::Behavior { + self.behavior.scenario.leave(); + } + } + } if self.active_diagram == Some(original) { self.active_diagram = Some(id); } @@ -1083,6 +1118,17 @@ impl App { && let Some(owner) = crate::behavior::selected_behavior(self.project(), id) { self.behavior.active_owner = Some(owner); + if self.behavior.scenario.representation().is_some_and(|rep| { + behavior::scenario_layout::presentation::checked_owner(self.project(), rep) + != Some(owner) + }) && self.project().elements[&owner].kind() == Kind::Scenario + { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner }, + ); + } else if self.project().elements[&owner].kind() != Kind::Scenario { + self.behavior.scenario.forget_view(); + } } if self.reset_state_view_after_navigation(previous_state_view) && self.page == Page::Behavior @@ -1232,6 +1278,38 @@ impl App { { return Task::none(); } + // Prior view visits are inert before they can retire a newer reveal, + // camera lifetime or focus operation. Passive events may relax epoch, + // but never the destination identity. + if let Message::ScenarioCanvas(event) = &message { + let captured = event.context(); + if self.scenario_context().is_none_or(|current| { + current.document != captured.document + || current.scenario != captured.scenario + || current.representation != captured.representation + || current.visit != captured.visit + || (!matches!(event, behavior::scenario::CanvasEvent::Restored(..)) + && current.revision != captured.revision) + }) { + return Task::none(); + } + if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event + && *lifetime != self.camera_epoch + { + return Task::none(); + } + } + if let Message::BehaviorControl(intent) = &message + && behavior::canvas_workspace::Scope::current(self).is_none_or(|scope| { + scope.document != intent.scope.document + || scope.owner != intent.scope.owner + || scope.scenario_view != intent.scope.scenario_view + || scope.revision != intent.scope.revision + || scope.epoch != intent.scope.epoch + }) + { + return Task::none(); + } let usage_viewport = self.diagram_reveal_viewport(&message); let window_resized = matches!(&message, Message::WindowResized(_)); // Reject stale measurements before legacy viewport handlers can change @@ -1284,6 +1362,7 @@ impl App { let valid_scenario = |c: behavior::scenario::Context| { c.document == scope.document && c.scenario == scope.owner + && scope.scenario_view == Some((c.representation, c.visit)) && c.revision == scope.revision && self.camera_toolbar_epoch(c.epoch) }; @@ -1667,6 +1746,7 @@ impl App { self.refresh_source_rename_context(); self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); + self.reconcile_scenario_workspace(); if self.page != Page::Home { self.last_workspace_page = self.page; } @@ -1675,11 +1755,6 @@ impl App { .topology .enter(context.document, context.owner); } - if let Some(context) = self.scenario_context() { - self.behavior - .scenario - .enter(context.document, context.scenario); - } if self.connection.as_ref().is_some_and(|session| { Some(session.context) != self.route_context() || self.page != Page::Diagram @@ -1723,15 +1798,23 @@ impl App { } } pub(crate) fn scenario_context(&self) -> Option { - let scenario = crate::behavior::selected_owner(self)?; - (self.page == Page::Behavior - && self.project().elements.get(&scenario)?.kind() == Kind::Scenario) - .then_some(crate::behavior::scenario::Context { - document: self.canvas_document, - scenario, - revision: self.document.revision(), - epoch: self.canvas_epoch, - }) + if self.page != Page::Behavior { + return None; + } + let scenario = if matches!( + self.behavior.scenario.representation(), + Some(behavior::scenario_layout::Representation::Saved { .. }) + ) { + self.behavior.scenario.owner()? + } else { + crate::behavior::selected_owner(self)? + }; + self.behavior.scenario.context( + self.canvas_document, + scenario, + self.document.revision(), + self.canvas_epoch, + ) } pub(crate) fn scenario_focus_owner(&self) -> Option { if self.busy @@ -1759,10 +1842,7 @@ impl App { .active_diagram .is_some_and(|id| self.saved_scenario_diagram(id)); match message { - Message::Diagram(choice) => self.saved_scenario_diagram(choice.id), - Message::RevealInDiagram(diagram, _) | Message::Command(Command::Layout(diagram)) => { - self.saved_scenario_diagram(*diagram) - } + Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram), Message::Projection(projection) => active || projection.scenario().is_some(), Message::SelectNode(_) | Message::PlaceSelected @@ -1811,6 +1891,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.scenario_focus_owner().is_some() && self.behavior.scenario.take_form_focus(mount) @@ -1823,6 +1905,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.behavior.scenario.viewport(size) { @@ -1832,7 +1916,10 @@ impl App { } if let CanvasEvent::Restored(_, token, area, offset) = event { if self.scenario_context().is_some_and(|current| { - current.document == context.document && current.scenario == context.scenario + current.document == context.document + && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit }) && self .behavior .scenario @@ -1849,6 +1936,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.behavior.scenario.scrolled(area, offset) { @@ -1862,6 +1951,8 @@ impl App { || !self.scenario_context().is_some_and(|c| { c.document == context.document && c.scenario == context.scenario + && c.representation == context.representation + && c.visit == context.visit && c.revision == context.revision }) { @@ -1870,6 +1961,7 @@ impl App { let scope = canvas_workspace::Scope { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: lifetime, }; @@ -1892,7 +1984,10 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1) } input => { - if self.behavior.scenario.camera(input) { + let mut state = std::mem::take(&mut self.behavior.scenario); + let changed = state.camera(self.project(), context.scenario, input); + self.behavior.scenario = state; + if changed { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } @@ -1903,6 +1998,32 @@ impl App { return Task::none(); } match event { + CanvasEvent::Representation(_, requested) => { + use behavior::scenario_layout::{ + Representation, presentation::checked_owner, + }; + if checked_owner(self.project(), requested) != Some(context.scenario) { + return Task::none(); + } + if !self.apply() { + return Task::none(); + } + let requested = match requested { + Representation::Saved { diagram } + if context.representation + == (Representation::Saved { diagram }) => + { + Representation::Saved { + diagram: self.active_diagram.unwrap_or(diagram), + } + } + Representation::Derived { owner } => Representation::Derived { + owner: self.behavior.active_owner.unwrap_or(owner), + }, + other => other, + }; + self.enter_scenario_representation(requested); + } CanvasEvent::Focus(_) => { self.behavior.scenario.toggle_focus(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); @@ -1931,7 +2052,11 @@ impl App { Ok(target) => { // A command can preselect this target while the // caller remains visible, bypassing select below. - self.behavior.active_owner = Some(target); + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { + owner: target, + }, + ); self.select(target); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } @@ -1953,7 +2078,7 @@ impl App { // A command may already have selected this UUID while // leaving the referenced definition open. select's // same-UUID fast path must not keep that old owner. - self.behavior.active_owner = Some(caller.owner); + self.enter_scenario_representation(caller.representation); self.select(caller.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); if let Some(context) = self.scenario_context() { @@ -1981,6 +2106,15 @@ impl App { } } CanvasEvent::Choose(_, tool) => { + if tool != scenario::CanvasTool::Select + && !self + .behavior + .scenario + .coordinate_input(self.project(), context) + { + self.status = "Canvas insertion and reordering are unavailable for this presentation.".into(); + return Task::none(); + } if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = @@ -1992,6 +2126,17 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } mutation => { + if !behavior::scenario_layout::presentation::present( + self.project(), + context.representation, + self.selected, + ) + .capabilities() + .coordinate_input + { + self.status = "Layout editing is unavailable for this presentation. Saved positions are preserved.".into(); + return Task::none(); + } if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = @@ -2092,6 +2237,13 @@ impl App { }) { self.behavior.active_owner = Some(choice.id); + if self.project().elements[&choice.id].kind() == Kind::Scenario { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner: choice.id }, + ); + } else { + self.behavior.scenario.forget_view(); + } self.behavior.diagram = Default::default(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.select(choice.id); @@ -2457,6 +2609,15 @@ impl App { crate::behavior::selected_behavior(self.project(), self.selected) { self.behavior.active_owner = Some(owner); + if self.project().elements[&owner].kind() == Kind::Scenario + && self.behavior.scenario.owner() != Some(owner) + { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner }, + ); + } else if self.project().elements[&owner].kind() != Kind::Scenario { + self.behavior.scenario.forget_view(); + } } self.behavior.active_owner = crate::behavior::selected_owner(self); self.reset_state_view_after_navigation(previous_state_view); @@ -2693,6 +2854,7 @@ impl App { } Message::Layer(layer) => { if self.apply() { + self.behavior.scenario.forget_view(); let previous_state_view = self.state_view_identity(); self.layer = layer; self.create_kind = if layer == Layer::Oa { @@ -2865,6 +3027,9 @@ impl App { } } Message::RevealInDiagram(diagram, selected) => { + if self.saved_scenario_diagram(diagram) { + return self.reveal_saved_scenario(diagram, selected); + } let original = self.selected; if !self.apply() { return Task::none(); @@ -3096,6 +3261,16 @@ impl App { self.status = "Diagram no longer exists.".into(); return Task::none(); } + if self.saved_scenario_diagram(choice.id) + && behavior::scenario_layout::presentation::checked_owner( + self.project(), + behavior::scenario_layout::Representation::Saved { diagram: choice.id }, + ) + .is_none() + { + self.status = "This Scenario view has an incompatible or missing owner.".into(); + return Task::none(); + } let previous_diagram = self.active_diagram; let follow_view = previous_diagram == Some(self.selected); if self.apply() { @@ -3115,7 +3290,14 @@ impl App { } self.selected_node = None; self.refresh_draft(); - self.fit(); + if self.saved_scenario_diagram(diagram) { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Saved { diagram }, + ); + } else { + self.page = Page::Diagram; + self.fit(); + } } } Message::Projection(projection) => { @@ -5748,12 +5930,17 @@ mod saved_scenario_dispatch_tests { } #[test] - fn unsupported_saved_picker_and_reveal_do_not_apply_pending_properties() { + fn saved_picker_with_invalid_properties_and_reveal_with_pending_properties_do_not_apply() { for reveal in [false, true] { let (mut app, architecture, saved, owner) = fixture(); let _ = app.update(Message::Field( - "/name".into(), - "Unapplied scenario name".into(), + if reveal { "/name" } else { "/id" }.into(), + if reveal { + "Unapplied scenario name" + } else { + "-" + } + .into(), )); assert!(app.draft_dirty()); let before = app.document.bundle().clone(); @@ -5773,7 +5960,6 @@ mod saved_scenario_dispatch_tests { assert_eq!(app.active_diagram, Some(architecture)); assert_eq!(app.selected, selected); assert!(app.draft_dirty()); - assert_eq!(app.status, SAVED_SCENARIO_UNAVAILABLE); } } diff --git a/crates/oa-desktop/src/app/diagram_reveal.rs b/crates/oa-desktop/src/app/diagram_reveal.rs index ab4dd765..a1e2516e 100644 --- a/crates/oa-desktop/src/app/diagram_reveal.rs +++ b/crates/oa-desktop/src/app/diagram_reveal.rs @@ -1,5 +1,6 @@ //! Complete a navigation camera after the destination reports its real viewport. use super::*; +use crate::behavior::scenario_layout::Representation; use crate::diagram_usage::{Context, Surface}; #[cfg(test)] mod tests; @@ -47,11 +48,23 @@ impl App { && c.epoch == *epoch, ), Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => ( - Surface::Scenario(c.scenario), + match c.representation { + Representation::Derived { owner } => Surface::Scenario(owner), + Representation::Saved { diagram } => Surface::Saved(diagram), + }, *size, c.document == context.document && c.revision == context.revision - && c.epoch == *epoch, + && c.epoch == *epoch + && self.pending_scenario_reveal_visit == Some(c.visit) + && self.page == Page::Behavior + && self.behavior.active_owner == Some(c.scenario) + && self.behavior.scenario.context( + self.canvas_document, + c.scenario, + self.document.revision(), + self.canvas_epoch, + ) == Some(*c), ), Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => ( Surface::Saved(c.diagram), @@ -91,6 +104,7 @@ impl App { | Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) ) { self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; if !Self::is_navigation_camera(message) { self.diagram_navigation_focus = None; } @@ -142,5 +156,6 @@ impl App { self.reveal_diagram_usage(&usage); } self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; } } diff --git a/crates/oa-desktop/src/app/diagram_reveal/tests.rs b/crates/oa-desktop/src/app/diagram_reveal/tests.rs index 9b78e668..be51ab84 100644 --- a/crates/oa-desktop/src/app/diagram_reveal/tests.rs +++ b/crates/oa-desktop/src/app/diagram_reveal/tests.rs @@ -144,6 +144,7 @@ fn measurement( epoch: u64, surface: Surface, size: Size, + visit: Id, ) -> Message { match canvas { Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size), @@ -160,6 +161,10 @@ fn measurement( behavior::scenario::Context { document: context.document, scenario: surface.id(), + representation: Representation::Derived { + owner: surface.id(), + }, + visit, revision: context.revision, epoch, }, @@ -177,12 +182,16 @@ fn measurement( } } -fn stamp(app: &App) -> (Context, u64) { +fn stamp(app: &App) -> (Context, u64, Id) { let (context, _, epoch) = app .pending_diagram_reveal .as_ref() .expect("reveal awaits measured destination"); - (*context, *epoch) + ( + *context, + *epoch, + app.pending_scenario_reveal_visit.unwrap_or(Id::nil()), + ) } fn assert_centered(app: &App, canvas: Canvas, target: &Usage) { @@ -220,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) { expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select); expected.viewport(ACTUAL); expected.zoom(Some(zoom)); - expected.camera(behavior::canvas_workspace::Event::Pan(pan)); + expected.camera( + app.project(), + target.surface.id(), + behavior::canvas_workspace::Event::Pan(pan), + ); assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary()); } } @@ -239,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author app.document.dirty(), ); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -261,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author let camera = app.camera_boundary(); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert!(app.camera_boundary() == camera); @@ -275,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { for invalid in 0..6 { let (mut app, target) = fixture(canvas); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); let camera = app.camera_boundary(); let mut stale = context; let mut wrong_epoch = epoch; @@ -290,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { } send( &mut app, - measurement(canvas, stale, wrong_epoch, target.surface, size), + measurement(canvas, stale, wrong_epoch, target.surface, size, visit), ); assert!( app.pending_diagram_reveal.is_some(), "{canvas:?} invalid case {invalid}" ); - assert_eq!(stamp(&app), (context, epoch)); + assert_eq!(stamp(&app), (context, epoch, visit)); assert_eq!(app.canvas_epoch, epoch); assert!( app.camera_boundary() == camera, @@ -304,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { ); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -318,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte let (mut app, target) = fixture(canvas); let accepted = app.document.snapshot(); assert!(app.reveal_diagram_usage(&target)); - let (context, old_epoch) = stamp(&app); + let (context, old_epoch, visit) = stamp(&app); send(&mut app, Message::WindowResized(Size::new(1100., 700.))); - let (current, epoch) = stamp(&app); + let (current, epoch, current_visit) = stamp(&app); + assert_eq!(current_visit, visit); assert_eq!(current, context); assert_eq!(epoch, app.canvas_epoch); if matches!(canvas, Canvas::Process | Canvas::Scenario) { @@ -328,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte let camera = app.camera_boundary(); send( &mut app, - measurement(canvas, context, old_epoch, target.surface, ACTUAL), + measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit), ); - assert_eq!(stamp(&app), (current, epoch)); + assert_eq!(stamp(&app), (current, epoch, visit)); assert!(app.camera_boundary() == camera); } send( &mut app, - measurement(canvas, current, epoch, target.surface, ACTUAL), + measurement(canvas, current, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -350,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() let (mut app, target) = fixture(canvas); let accepted = app.document.snapshot(); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); let event = match action { 0 => match canvas { Canvas::Architecture => Message::Pan(Vector::new(31., -17.)), @@ -387,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() // restore the old navigation camera after deliberate user input. send( &mut app, - measurement(canvas, context, epoch, target.surface, WIDE), + measurement(canvas, context, epoch, target.surface, WIDE, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert!( diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index bf4b5a18..70e5b87b 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -1,5 +1,6 @@ //! Navigation-only adapter: validates destinations before changing presentation. use super::*; +use crate::behavior::scenario_layout::{Representation, presentation}; use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage}; #[cfg(test)] mod tests; @@ -58,10 +59,6 @@ impl App { let process = presentation.into_query_state(); let scenario = behavior::scenario::State::default(); usage::collect(&project, context.subject, &process, &scenario) - .into_iter() - .filter(|row| !matches!(row.surface, Surface::Saved(id) - if project.diagrams.get(&id).is_some_and(|d| d.projection.scenario().is_some()))) - .collect() }) .await .map_err(|error| format!("Diagram lookup failed: {error}. Close and try again.")); @@ -168,6 +165,7 @@ impl App { // Only an accepted new chooser supersedes the previous reveal. // Rejected/stale panel envelopes leave its first measurement intact. self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; self.diagram_navigation_focus = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); return Task::batch([ @@ -290,14 +288,78 @@ impl App { Task::none() } + /// Explicit saved-Scenario reveal runs before generic Apply. An absent target + /// never selects the owner or another saved arrangement as a fallback. + pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task { + if self.diagram_usages.is_some() || !self.usage_navigation_available() { + self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into(); + return Task::none(); + } + if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none() + { + return Task::none(); + } + let Some(found) = self + .current_surface_usages(selected, Surface::Saved(diagram)) + .into_iter() + .find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_))) + else { + self.status = "This object has no visible target in that saved Scenario.".into(); + return Task::none(); + }; + self.reveal_diagram_usage(&found); + Task::none() + } + pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool { if !self.usage_navigation_available() { return false; } - if matches!(usage.surface, Surface::Saved(id) if self.saved_scenario_diagram(id)) { - return false; - } let previous_layer = self.layer; + let scenario_representation = match usage.surface { + Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => { + Some(Representation::Saved { diagram }) + } + Surface::Scenario(owner) => Some(Representation::Derived { owner }), + _ => None, + }; + if let Some(representation) = scenario_representation { + let Some(owner) = presentation::checked_owner(self.project(), representation) else { + return false; + }; + if !self + .behavior + .scenario + .representation_usages(self.project(), representation, usage.subject) + .iter() + .any(|row| row == usage) + { + return false; + } + // Finish target/camera admission before changing the App route. The + // checked immutable owner also makes the following entry infallible. + let revealed = match representation { + Representation::Derived { .. } => self.behavior.scenario.reveal_usage( + self.document.project(), + self.canvas_document, + owner, + &usage.target, + ), + Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage( + self.document.project(), + self.canvas_document, + owner, + representation, + &usage.target, + ), + }; + if !revealed { + return false; + } + if !self.enter_scenario_representation(representation) { + return false; + } + } let p = self.document.project(); let (selected, placement) = match &usage.target { Target::Whole => (usage.surface.id(), None), @@ -337,7 +399,10 @@ impl App { }; self.layer = diagram.layer; self.active_diagram = Some(id); - if let Some(machine) = diagram.projection.state_machine() { + if let Some(owner) = diagram.projection.scenario() { + self.behavior.active_owner = Some(owner); + self.page = Page::Behavior; + } else if let Some(machine) = diagram.projection.state_machine() { let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new( (self.window_size.width - 610.).max(280.), (self.window_size.height - 320.).max(240.), @@ -379,19 +444,14 @@ impl App { self.page = Page::Behavior; } Surface::Scenario(owner) => { - if !self.behavior.scenario.reveal_usage( - p, - self.canvas_document, - owner, - &usage.target, - ) { - return false; - } self.layer = p.elements[&owner].layer(); self.behavior.active_owner = Some(owner); self.page = Page::Behavior; } } + if scenario_representation.is_none() { + self.behavior.scenario.forget_view(); + } self.selected = selected; self.selected_node = placement; self.model_menu = None; @@ -418,6 +478,18 @@ impl App { usage: usage.clone(), selected, }); + self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| { + let owner = presentation::checked_owner(self.project(), representation)?; + self.behavior + .scenario + .context( + self.canvas_document, + owner, + self.document.revision(), + self.canvas_epoch, + ) + .map(|context| context.visit) + }); self.pending_diagram_reveal = Some(( Context { document: self.canvas_document, @@ -437,12 +509,21 @@ impl App { let current_surface = match surface { Surface::Saved(id) => { self.active_diagram == Some(id) - && !self.saved_scenario_diagram(id) && matches!(self.page, Page::Diagram | Page::Behavior) + && (!self.saved_scenario_diagram(id) + || (self.page == Page::Behavior + && self.behavior.scenario.representation() + == Some(Representation::Saved { diagram: id }))) } - Surface::Process(owner) | Surface::Scenario(owner) => { + Surface::Process(owner) => { self.page == Page::Behavior && self.behavior.active_owner == Some(owner) } + Surface::Scenario(owner) => { + self.page == Page::Behavior + && self.behavior.active_owner == Some(owner) + && self.behavior.scenario.representation() + == Some(Representation::Derived { owner }) + } }; (current_surface && focus.usage.surface == surface diff --git a/crates/oa-desktop/src/app/diagram_usages/tests.rs b/crates/oa-desktop/src/app/diagram_usages/tests.rs index 5f107d7b..5545c26e 100644 --- a/crates/oa-desktop/src/app/diagram_usages/tests.rs +++ b/crates/oa-desktop/src/app/diagram_usages/tests.rs @@ -1401,3 +1401,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() { assert_eq!(location(&app), prior); assert_eq!(authored(&app), before); } + +mod saved_scenario; diff --git a/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia new file mode 100644 index 00000000..70f8a4c4 --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia @@ -0,0 +1,108 @@ +arcadia 7; +project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" { + // Two operational activities, with one lifeline appearance per Scenario. + oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {} + oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {} + interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" { + source @report_incident; + target @assess_incident; + } + + oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" { + viewpoint OperationalActivity; + steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review]; + } + oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" { + parent @incident_response; represented @report_incident; + } + oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" { + parent @incident_response; represented @assess_incident; + } + // Both appearances reuse one definition. Navigation must retain the occurrence. + oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; }; + } + oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; }; + } + // A correlated self call/reply with deliberately unassigned exchange definitions. + oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" { + parent @incident_response; + body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; }; + } + oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" { + parent @incident_response; + body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; }; + } + // Intentional unfinished work: no receiver and therefore no fabricated arrow. + oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; }; + } + oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" { + parent @incident_response; + body { ScenarioUse { + bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }]; + scenario @reporting_review; + }; }; + } + + // This definition stays opaque in the caller. Open it, then Back to the saved view. + oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" { + viewpoint OperationalActivity; + steps [@review_delivery]; + } + oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" { + parent @reporting_review; represented @report_incident; + } + oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" { + parent @reporting_review; represented @assess_incident; + } + oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" { + parent @reporting_review; + body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; }; + } + + diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }]; + messages [ + { address { references []; occurrence @initial_report; }; send_y 160; }, + { address { references []; occurrence @updated_report; }; send_y 252; }, + { address { references []; occurrence @assess_call; }; send_y 344; }, + { address { references []; occurrence @assess_reply; }; send_y 436; } + ]; + }; }; + } + diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }]; + messages [ + { address { references []; occurrence @initial_report; }; send_y 160; }, + { address { references []; occurrence @updated_report; }; send_y 252; }, + { address { references []; occurrence @assess_call; }; send_y 344; }, + { address { references []; occurrence @assess_reply; }; send_y 436; } + ]; + }; }; + } + diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + // These valid authored lanes must remain saved during complete fallback. + lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }]; + messages [ + // REPAIR: change only this 300 to 424, then Undo and Redo the edit. + { address { references []; occurrence @assess_reply; }; send_y 300; }, + // This authored value stays latent while the receiver is missing. + { address { references []; occurrence @unfinished_notice; }; send_y 2000; } + ]; + }; }; + } +} diff --git a/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs new file mode 100644 index 00000000..a786136d --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs @@ -0,0 +1,356 @@ +//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history. +use super::*; +use crate::behavior::scenario::{CanvasEvent, State}; +use crate::behavior::scenario_layout::{ + Representation, + presentation::{self, PresentationStatus}, +}; +use iced::{Size, Vector}; + +fn fixture() -> App { + let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap(); + let errors: Vec<_> = oa_core::validation::validate(&p) + .into_iter() + .filter(|d| d.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + App::with_project(p) +} +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} +fn representation(view: Id) -> Representation { + Representation::Saved { diagram: view } +} +fn find(app: &App, subject: Id, view: Id) -> Usage { + usage::saved_usages_for(app.project(), subject, Some(view)) + .into_iter() + .find(|row| row.target == Target::ScenarioObject(subject)) + .expect("exact saved occurrence") +} +fn context(app: &App) -> behavior::scenario::Context { + let owner = app.behavior.active_owner.unwrap(); + app.behavior + .scenario + .context( + app.canvas_document, + owner, + app.document.revision(), + app.canvas_epoch, + ) + .unwrap() +} +fn navigate(app: &mut App, subject: Id, view: Id) -> Id { + let (token, rows) = open(app, subject); + let found = rows + .into_iter() + .find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject)) + .expect("saved chooser destination"); + choose(app, token, found); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.active_diagram, Some(view)); + assert_eq!( + app.behavior.scenario.representation(), + Some(representation(view)) + ); + assert_eq!(app.selected, subject); + token +} + +#[test] +fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque() +{ + let app = fixture(); + let owner = id(&app, "incident_response"); + let first = id(&app, "initial_report"); + let second = id(&app, "updated_report"); + let exchange = id(&app, "incident_report"); + let latent = id(&app, "unfinished_notice"); + let callee_message = id(&app, "review_delivery"); + let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a)); + let derived = app + .behavior + .scenario + .diagram_usages(app.project(), owner, first) + .pop() + .unwrap(); + let rows = views.map(|view| find(&app, first, view)); + assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.); + assert_ne!(rows[0].bounds, rows[1].bounds); + assert_eq!( + rows[2].bounds, derived.bounds, + "complete fallback cannot retain the authored lane override" + ); + for view in views { + let represented = usage::saved_usages_for(app.project(), exchange, Some(view)); + assert_eq!(represented.len(), 2); + assert!( + represented + .iter() + .any(|u| u.target == Target::ScenarioObject(first)) + ); + assert!( + represented + .iter() + .any(|u| u.target == Target::ScenarioObject(second)) + ); + assert!( + represented + .iter() + .all(|u| u.surface == Surface::Saved(view)) + ); + assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty()); + assert_eq!( + find(&app, latent, view).target, + Target::ScenarioObject(latent) + ); + let shown = presentation::present(app.project(), representation(view), owner); + let whole = usage::saved_usages_for(app.project(), view, Some(view)); + assert_eq!(whole.len(), 1); + assert_eq!(whole[0].target, Target::Whole); + assert_eq!(Some(whole[0].bounds), shown.bounds()); + } + let wide = presentation::present(app.project(), representation(views[1]), owner) + .bounds() + .unwrap(); + assert!(wide.x < -500. && wide.x + wide.width > 50000.); + let fallback = presentation::present(app.project(), representation(views[2]), owner); + assert!(matches!( + fallback.status(), + PresentationStatus::Fallback { .. } + )); + assert_eq!(fallback.latent_messages(), &[latent]); + assert!(!fallback.capabilities().coordinate_input); +} + +#[test] +fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() { + for alias in ["incident_readable", "incident_wide", "incident_repair"] { + let mut app = fixture(); + let view = id(&app, alias); + let first = id(&app, "initial_report"); + let owner = id(&app, "incident_response"); + let original = app.document.snapshot(); + app.document + .execute(Command::Rename(first, "Reviewed incident report".into())) + .unwrap(); + let edited = app.document.snapshot(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + navigate(&mut app, first, view); + assert_eq!(app.behavior.active_owner, Some(owner)); + assert_eq!(authored(&app), before); + send(&mut app, Message::RevealInDiagram(view, first)); + assert_eq!(app.active_diagram, Some(view)); + assert_eq!(authored(&app), before); + assert!(app.document.undo()); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(app.document.redo()); + assert_eq!(app.project(), &edited.decoded.project); + assert_eq!(app.document.bundle(), &edited.bundle); + } +} + +#[test] +fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() { + for chooser in [false, true] { + for block in 0..3 { + let mut app = fixture(); + let view = id(&app, "incident_repair"); + let first = id(&app, "initial_report"); + let choice = chooser.then(|| { + let (token, rows) = open(&mut app, first); + ( + token, + rows.into_iter() + .find(|u| u.surface == Surface::Saved(view)) + .unwrap(), + ) + }); + match block { + 0 => { + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit("? preserve unfinished source".into(), cursor); + } + 1 => { + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied inspector value".into()); + assert!(app.draft_dirty()); + } + _ => { + let p = app.project().clone(); + app.behavior + .creation + .update(&p, behavior::creation::Event::Open(Layer::Oa)) + .unwrap(); + assert!(app.behavior.has_pending()); + } + } + let before = authored(&app); + let prior = location(&app); + let draft = app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + if let Some((token, row)) = choice { + choose(&mut app, token, row); + } else { + send(&mut app, Message::RevealInDiagram(view, first)); + } + assert_eq!(authored(&app), before); + assert_eq!(location(&app), prior); + assert_eq!( + app.draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + } + } +} + +#[test] +fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() { + let mut app = fixture(); + let a = id(&app, "incident_readable"); + let b = id(&app, "incident_wide"); + let first = id(&app, "initial_report"); + let before = authored(&app); + let old_token = navigate(&mut app, first, a); + let old = context(&app); + navigate(&mut app, first, b); + navigate(&mut app, first, a); + let current = context(&app); + assert_ne!(old.visit, current.visit); + let held = behavior::scenario::Context { + epoch: current.epoch, + ..old + }; + let camera = app.behavior.scenario.camera_boundary(); + send( + &mut app, + Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))), + ); + send(&mut app, Message::ScenarioCanvas(CanvasEvent::Focus(held))); + send( + &mut app, + Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())), + ); + send(&mut app, Message::DiagramUsage(Event::Close(old_token))); + assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit)); + assert!(app.pending_diagram_reveal.is_some()); + assert!(app.behavior.scenario.camera_boundary() == camera); + let bounds = find(&app, first, a).bounds; + send( + &mut app, + Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(context(&app).visit, current.visit); + let mut expected = State::default(); + expected.enter_representation(app.canvas_document, current.scenario, representation(a)); + expected.viewport(Size::new(500., 300.)); + expected.zoom(Some(1.25)); + expected.camera( + app.project(), + current.scenario, + behavior::canvas_workspace::Event::Pan(Vector::new( + 250. - bounds.center_x() * 1.25, + 150. - bounds.center_y() * 1.25, + )), + ); + assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary()); + assert_eq!(authored(&app), before); +} + +#[test] +fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() { + let mut app = fixture(); + let first = id(&app, "initial_report"); + let view = id(&app, "incident_readable"); + let other = id(&app, "reporting_review"); + let (token, rows) = open(&mut app, first); + let old = rows + .into_iter() + .find(|u| u.surface == Surface::Saved(view)) + .unwrap(); + let mut replacement = app.project().diagrams[&view].clone(); + replacement.projection = ProjectionKind::Scenario { scenario: other }; + replacement.layout = None; + app.document + .execute(Command::EditDiagram(view, replacement)) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + let prior = location(&app); + choose(&mut app, token, old); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); + finish_lookup(&mut app); + let token = app.diagram_usages.as_ref().unwrap().token; + assert!( + app.diagram_usages + .as_ref() + .unwrap() + .usages + .iter() + .all(|u| u.surface != Surface::Saved(view)) + ); + send(&mut app, Message::DiagramUsage(Event::Close(token))); + send(&mut app, Message::RevealInDiagram(view, first)); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history() + { + let mut app = fixture(); + let view = id(&app, "incident_repair"); + let first = id(&app, "initial_report"); + let reply = id(&app, "assess_reply"); + let before = app.document.snapshot(); + let fallback = find(&app, first, view); + app.document + .execute(Command::SetScenarioMessagePosition { + diagram: view, + position: oa_core::ScenarioMessagePosition { + address: oa_core::OccurrenceEndpoint { + references: vec![], + occurrence: reply, + }, + send_y: 424., + }, + }) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let repaired = app.document.snapshot(); + let authored_row = find(&app, first, view); + assert_ne!(authored_row.bounds, fallback.bounds); + navigate(&mut app, first, view); + assert!(app.document.undo()); + app.sync_source(); + app.refresh_draft(); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(find(&app, first, view).bounds, fallback.bounds); + assert!(app.document.redo()); + app.sync_source(); + app.refresh_draft(); + assert_eq!(app.project(), &repaired.decoded.project); + assert_eq!(app.document.bundle(), &repaired.bundle); + assert_eq!(find(&app, first, view).bounds, authored_row.bounds); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram.rs b/crates/oa-desktop/src/app/scenario_diagram.rs new file mode 100644 index 00000000..5014830d --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram.rs @@ -0,0 +1,96 @@ +//! Saved Scenario navigation is presentation state, never a document edit. +use super::*; +use crate::behavior::scenario_layout::{Representation, presentation::checked_owner}; + +impl App { + pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool { + let Some(owner) = checked_owner(self.project(), representation) else { + self.status = + "This Scenario view is no longer available or has an incompatible owner.".into(); + return false; + }; + self.layer = self.project().elements[&owner].layer(); + self.page = Page::Behavior; + self.behavior.active_owner = Some(owner); + match representation { + Representation::Saved { diagram } => self.active_diagram = Some(diagram), + Representation::Derived { .. } => { + if self + .active_diagram + .is_some_and(|id| self.saved_scenario_diagram(id)) + { + self.active_diagram = None; + } + } + } + self.selected_node = None; + if self + .behavior + .scenario + .enter_representation(self.canvas_document, owner, representation) + { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + true + } + + /// Reconcile after navigation/adoption without selecting or applying a draft. + /// A retained invalid Saved identity remains unavailable; it must not silently + /// turn into a different automatic Scenario or another saved arrangement. + pub(super) fn reconcile_scenario_workspace(&mut self) { + if self.page == Page::Diagram + && !self.behavior.has_pending() + && let Some(diagram) = self + .active_diagram + .filter(|id| self.saved_scenario_diagram(*id)) + { + self.enter_scenario_representation(Representation::Saved { diagram }); + } + if self.page != Page::Behavior { + self.behavior.scenario.leave(); + return; + } + if let Some(rep @ Representation::Saved { diagram }) = + self.behavior.scenario.representation() + { + // The requested view is authoritative until explicit navigation + // retires it. This also recovers the same view when Undo restores it. + if let Some(owner) = checked_owner(self.project(), rep) { + self.active_diagram = Some(diagram); + self.layer = self.project().elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + self.behavior + .scenario + .enter_representation(self.canvas_document, owner, rep); + } else if let Some(owner) = self.behavior.scenario.owner() { + self.behavior + .scenario + .enter_representation(self.canvas_document, owner, rep); + } + return; + } + let Some(owner) = behavior::selected_owner(self) else { + self.behavior.scenario.leave(); + return; + }; + if self + .project() + .elements + .get(&owner) + .is_some_and(|e| e.kind() == Kind::Scenario) + { + self.behavior.scenario.enter(self.canvas_document, owner); + } else { + self.behavior.scenario.leave(); + } + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod adoption_tests; + +#[cfg(test)] +mod owner_navigation_tests; diff --git a/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs b/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs new file mode 100644 index 00000000..68b2044b --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs @@ -0,0 +1,266 @@ +//! Independent adoption oracles: expected Projects are edited directly in the +//! fixture, then admitted through the actual source editor/Document transaction. +//! Navigation is checked separately from the expected authored mutation. +use super::*; +use crate::behavior::canvas_workspace::tests::Mounted; +use iced::advanced::widget; +use iced::widget::text_editor; +use oa_core::language; +use std::sync::Arc; + +struct Fixture { + app: App, + owner: Id, + same_layer: Id, + other_layer: Id, + view: Id, + spare_view: Id, +} + +fn fixture() -> Fixture { + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, Layer::Oa, "Original owner", None) + .unwrap(); + let same_layer = project + .add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None) + .unwrap(); + let other_layer = project + .add_element(Kind::Scenario, Layer::Sa, "SA owner", None) + .unwrap(); + // A remaining topology makes silent fallback observably different from the + // required unavailable Saved view after its owner is deleted. + project + .add_element(Kind::Process, Layer::Oa, "Remaining process", None) + .unwrap(); + let mut make_view = |alias: &str, name: &str, owner| { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: alias.into(), + name: name.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + layout: None, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + }, + ); + id + }; + let view = make_view("retained_view", "Retained arrangement", owner); + let spare_view = make_view("spare_view", "Spare arrangement", same_layer); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(owner)); + choose(&mut app, view); + assert_saved(&app, view, owner, Layer::Oa); + assert!(!app.document.can_undo()); + Fixture { + app, + owner, + same_layer, + other_layer, + view, + spare_view, + } +} + +fn choose(app: &mut App, id: Id) { + let _ = app.update(Message::Diagram(Choice { + id, + label: "Previously captured picker item".into(), + })); +} + +fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) { + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.layer, layer); + assert_eq!(app.active_diagram, Some(view)); + let context = app + .scenario_context() + .expect("saved owner remains the visible context"); + assert_eq!(context.scenario, owner); + assert_eq!( + context.representation, + Representation::Saved { diagram: view } + ); + assert!(!context.visit.is_nil()); +} + +fn adopt_from_source(app: &mut App, expected: &Project) { + let text = language::format(expected); + // Parser equality is a fixture precondition, not the navigation oracle. + assert_eq!(language::parse(&text).unwrap(), *expected); + let _ = app.update(Message::Page(Page::Text)); + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(text)), + ))); + assert!(app.source_dirty()); + let _ = app.update(Message::ApplySource); + assert!(!app.source_dirty(), "{}", app.status); + assert_eq!( + app.project(), + expected, + "actual source transaction must adopt exactly the fixture change" + ); + let _ = app.update(Message::Page(Page::Behavior)); +} + +fn retarget(cross_layer: bool) { + let mut f = fixture(); + let before = f.app.document.snapshot(); + let old_context = f.app.scenario_context().unwrap(); + let revision = f.app.document.revision(); + let target = if cross_layer { + f.other_layer + } else { + f.same_layer + }; + let layer = if cross_layer { Layer::Sa } else { Layer::Oa }; + let mut expected = before.decoded.project.clone(); + let view = expected.diagrams.get_mut(&f.view).unwrap(); + view.projection = ProjectionKind::Scenario { scenario: target }; + view.layer = layer; + + adopt_from_source(&mut f.app, &expected); + assert_saved(&f.app, f.view, target, layer); + assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit); + assert_eq!( + f.app.document.revision(), + revision + 1, + "routing must not create a second transaction" + ); + assert_eq!( + f.app.project().diagrams[&f.spare_view], + before.decoded.project.diagrams[&f.spare_view] + ); + let adopted_bundle = f.app.document.bundle().clone(); + + let _ = f.app.update(Message::Undo); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!( + !f.app.document.can_undo(), + "exactly one authored adoption must be undone" + ); + let _ = f.app.update(Message::Redo); + assert_saved(&f.app, f.view, target, layer); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted_bundle); + assert!(!f.app.document.can_redo()); +} + +#[test] +fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() { + retarget(false); +} + +#[test] +fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() { + retarget(true); +} + +#[test] +fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() { + iced::futures::executor::block_on(async { + let mut f = fixture(); + let before = f.app.document.snapshot(); + let mut expected = before.decoded.project.clone(); + assert!(expected.elements.remove(&f.owner).is_some()); + assert!(expected.diagrams.remove(&f.view).is_some()); + assert!(expected.elements.contains_key(&f.same_layer)); + assert!( + expected + .elements + .values() + .any(|e| e.kind() == Kind::Process) + ); + adopt_from_source(&mut f.app, &expected); + assert_eq!( + f.app.behavior.scenario.representation(), + Some(Representation::Saved { diagram: f.view }) + ); + let adopted = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let mut mounted = Mounted::new(&mut f.app).await; + let geometry = mounted.geometry(&f.app); + assert!( + geometry + .containers + .contains_key(&widget::Id::from("scenario-viewport-unavailable")) + ); + assert!( + !geometry + .containers + .contains_key(&widget::Id::from("scenario-viewport")) + ); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted.bundle); + assert_eq!( + f.app.document.revision(), + revision, + "mounting unavailable must remain passive" + ); + + let _ = f.app.update(Message::Undo); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.document.bundle(), &before.bundle); + let _ = f.app.update(Message::Redo); + assert_eq!( + f.app.behavior.scenario.representation(), + Some(Representation::Saved { diagram: f.view }) + ); + mounted.settle(&mut f.app).await; + assert!( + mounted + .geometry(&f.app) + .containers + .contains_key(&widget::Id::from("scenario-viewport-unavailable")) + ); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted.bundle); + }); +} + +#[test] +fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() { + let mut f = fixture(); + let mut expected = f.app.project().clone(); + expected.diagrams.remove(&f.spare_view).unwrap(); + adopt_from_source(&mut f.app, &expected); + let _ = f.app.update(Message::Select(f.owner)); + let _ = f.app.update(Message::Field( + "/name".into(), + "Pending valid owner rename".into(), + )); + assert!(f.app.draft_dirty()); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let selected = f.app.selected; + for invalid in [Id::nil(), f.spare_view] { + choose(&mut f.app, invalid); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.selected, selected); + assert!(f.app.draft_dirty()); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.document.revision(), revision); + } + // Establish that the retained draft really was valid, not merely refused by + // the property validator: explicit Apply commits it in exactly one step. + assert!(f.app.apply(), "{}", f.app.status); + assert_eq!( + f.app.project().elements[&f.owner].name, + "Pending valid owner rename" + ); + assert_eq!(f.app.document.revision(), revision + 1); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs new file mode 100644 index 00000000..15a621d1 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs @@ -0,0 +1,113 @@ +//! Explicit owner navigation must retire a retained Saved Scenario, including +//! when selection was made on a different page. These are real App events. +use super::*; +use crate::behavior::scenario::CanvasEvent; + +fn setup(kind: Kind) -> (App, Id, Id) { + let mut project = Project::empty(); + let scenario = project + .add_element(Kind::Scenario, Layer::Oa, "Incident review", None) + .unwrap(); + let target = project + .add_element(kind, Layer::Oa, "Explicit destination", None) + .unwrap(); + let view = Id::new_v4(); + project.diagrams.insert( + view, + Diagram { + id: view, + alias: "incident_saved".into(), + name: "Incident saved arrangement".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario }, + layout: None, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + }, + ); + let errors: Vec<_> = oa_core::validation::validate(&project) + .into_iter() + .filter(|issue| issue.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(scenario)); + let _ = app.update(Message::Diagram(Choice { + id: view, + label: "Incident saved arrangement".into(), + })); + assert_eq!(app.page, Page::Behavior); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: view } + ); + assert_eq!(app.selected, scenario); + (app, scenario, target) +} + +fn assert_destination(app: &App, target: Id) { + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.selected, target); + assert_eq!(app.behavior.active_owner, Some(target)); + assert_eq!(behavior::selected_owner(app), Some(target)); + assert_eq!( + app.behavior.scenario.representation(), + None, + "explicit non-Scenario destination must not retain an overriding Saved request" + ); + assert!(app.scenario_context().is_none()); +} + +fn exercise(via_guide: bool) { + for kind in [Kind::Process, Kind::StateMachine] { + let (mut app, scenario, target) = setup(kind); + assert_ne!(scenario, target); + let held = app.scenario_context().unwrap(); + let accepted = app.document.snapshot(); + let drafts = app.source_workspace.bundle(); + let revision = app.document.revision(); + let dirty = (app.document.dirty(), app.dirty(), app.source_dirty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + if via_guide { + let _ = app.update(Message::Page(Page::Guide)); + assert_eq!(app.page, Page::Guide); + assert!(app.scenario_context().is_none()); + } + let _ = app.update(Message::Select(target)); + if via_guide { + assert_eq!( + app.page, + Page::Guide, + "explorer selection must not itself switch the page" + ); + let _ = app.update(Message::Page(Page::Behavior)); + } + assert_destination(&app, target); + let camera = app.camera_boundary(); + // The prior saved canvas cannot regain focus after either owner route. + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Focus(held))); + assert_destination(&app, target); + assert!(app.camera_boundary() == camera); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!(app.source_workspace.bundle(), drafts); + assert_eq!(app.document.revision(), revision); + assert_eq!( + (app.document.dirty(), app.dirty(), app.source_dirty()), + dirty + ); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() { + exercise(true); +} + +#[test] +fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() { + exercise(false); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram/tests.rs b/crates/oa-desktop/src/app/scenario_diagram/tests.rs new file mode 100644 index 00000000..e46d8a33 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/tests.rs @@ -0,0 +1,205 @@ +use super::*; +use crate::behavior::scenario::{CanvasEvent, ScrollArea}; + +fn saved(p: &mut Project, owner: Id, name: &str) -> Id { + let id = Id::new_v4(); + p.diagrams.insert( + id, + Diagram { + id, + alias: format!("view_{}", p.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 setup() -> (App, Id, Id, Id, Id) { + let (mut p, [owner, callee, _], [reference, _, _]) = + behavior::scenario::direct_tests::navigation_fixture(); + let a = saved(&mut p, owner, "Review A"); + let b = saved(&mut p, owner, "Review B"); + let mut app = App::with_project(p); + app.select(owner); + (app, a, b, reference, callee) +} +fn choose(app: &mut App, diagram: Id) { + let _ = app.update(Message::Diagram(Choice { + id: diagram, + label: "Captured view".into(), + })); +} +fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(f(context))); +} +#[test] +fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() { + let (mut app, a, b, _, _) = setup(); + let original = app.document.snapshot(); + let owner = app.selected; + assert!(app.execute(Command::Rename(owner, "Previously edited caller".into()))); + let edited = app.document.snapshot(); + let edited_revision = app.document.revision(); + choose(&mut app, a); + let first = app.scenario_context().unwrap(); + choose(&mut app, b); + let second = app.scenario_context().unwrap(); + choose(&mut app, a); + let third = app.scenario_context().unwrap(); + assert_eq!(first.scenario, owner); + assert_eq!(first.representation, Representation::Saved { diagram: a }); + assert_eq!(second.representation, Representation::Saved { diagram: b }); + assert_ne!(first.visit, second.visit); + assert_ne!(first.visit, third.visit); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.selected, owner); + assert_eq!(app.document.bundle(), &edited.bundle); + assert_eq!(app.project(), &edited.decoded.project); + assert_eq!(app.document.revision(), edited_revision); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &original.bundle); + assert_eq!(app.project(), &original.decoded.project); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &edited.bundle); + assert_eq!(app.project(), &edited.decoded.project); +} +#[test] +fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let old = app.scenario_context().unwrap(); + choose(&mut app, b); + choose(&mut app, a); + let current = app.scenario_context().unwrap(); + let camera = app.behavior.scenario.camera_boundary(); + let before = app.document.snapshot(); + for stale in [ + CanvasEvent::Viewport(old, iced::Size::new(432., 321.)), + CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)), + CanvasEvent::FormShown(old, Id::new_v4()), + CanvasEvent::Restored( + old, + Id::new_v4(), + ScrollArea::Page, + iced::Vector::new(0., 800.), + ), + CanvasEvent::Camera( + old, + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)), + ), + ] { + let _ = app.update(Message::ScenarioCanvas(stale)); + assert_eq!(app.scenario_context(), Some(current)); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.document.bundle(), &before.bundle); + } +} +#[test] +fn saved_reference_back_restores_exact_view_with_a_fresh_visit() { + let (mut app, a, _, reference, callee) = setup(); + choose(&mut app, a); + let caller = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + let target = app.scenario_context().unwrap(); + assert_eq!(target.scenario, callee); + assert_eq!( + target.representation, + Representation::Derived { owner: callee } + ); + assert_eq!(app.active_diagram, None); + event(&mut app, CanvasEvent::Back); + let returned = app.scenario_context().unwrap(); + assert_eq!(returned.representation, caller.representation); + assert_eq!(app.active_diagram, Some(a)); + assert_eq!(app.selected, reference); + assert_ne!(returned.visit, caller.visit); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert!(!app.document.can_undo()); +} +#[test] +fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let _ = app.update(Message::Select(a)); + let before = app.document.snapshot(); + let _ = app.update(Message::Field("/name".into(), "Renamed A".into())); + choose(&mut app, b); + assert_eq!(app.project().diagrams[&a].name, "Renamed A"); + assert_eq!(app.selected, b); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: b } + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + choose(&mut app, a); + let replacement = Id::new_v4(); + let _ = app.update(Message::Field("/id".into(), replacement.to_string())); + choose(&mut app, a); + assert!(!app.project().diagrams.contains_key(&a)); + assert_eq!(app.active_diagram, Some(replacement)); + assert_eq!(app.selected, replacement); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { + diagram: replacement + } + ); +} +#[test] +fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() { + let (mut app, a, _, _, _) = setup(); + choose(&mut app, a); + let old = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + let _ = app.update(Message::Page(Page::Guide)); + assert!(app.scenario_context().is_none()); + let _ = app.update(Message::Page(Page::Behavior)); + let fresh = app.scenario_context().unwrap(); + assert_eq!(fresh.representation, old.representation); + assert_ne!(fresh.visit, old.visit); + assert_eq!(app.document.bundle(), &before.bundle); +} + +#[test] +fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let stale = app.scenario_context().unwrap(); + choose(&mut app, b); + let current = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation( + stale, + Representation::Saved { diagram: a }, + ))); + assert_eq!(app.scenario_context(), Some(current)); + event(&mut app, |context| { + CanvasEvent::Representation( + context, + Representation::Derived { + owner: context.scenario, + }, + ) + }); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Derived { + owner: current.scenario + } + ); + assert_eq!(app.active_diagram, None); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index bf6cd7bc..a7adcdc7 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -24,6 +24,7 @@ impl App { if self.behavior.has_pending() { return; } + self.behavior.scenario.forget_view(); self.layer = layer; self.page = Page::Behavior; self.behavior.active_owner = Some(machine); diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 39ea21f5..060119d5 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -62,9 +62,18 @@ mod scenario_scroll { process: bool, restore: Option, } - type Report = Option<(bool, Id, Id, Vector)>; + type Identity = (Id, Id, scenario_layout::Representation, Id); + fn identity(context: scenario::Context) -> Identity { + ( + context.document, + context.scenario, + context.representation, + context.visit, + ) + } + type Report = Option<(bool, Identity, Vector)>; struct ObservedScroll { - identity: (Id, Id), + identity: Identity, report: Report, restored: Option, } @@ -115,8 +124,12 @@ mod scenario_scroll { ) { let restore = self.restore.filter(|request| { !self.process - && (request.document, request.owner) - == (self.context.document, self.context.scenario) + && ( + request.document, + request.owner, + request.representation, + request.visit, + ) == identity(self.context) && tree.state.downcast_ref::().restored != Some(request.token) }); let receipt = restore.and_then(|request| { @@ -140,12 +153,7 @@ mod scenario_scroll { &mut offset, ); if let Some(offset) = offset.0 { - let next = Some(( - self.process, - self.context.document, - self.context.scenario, - offset, - )); + let next = Some((self.process, identity(self.context), offset)); let state = tree.state.downcast_mut::(); if let Some(token) = receipt { state.restored = Some(token); @@ -189,7 +197,7 @@ mod scenario_scroll { } fn state(&self) -> widget::tree::State { widget::tree::State::new(ObservedScroll { - identity: (self.context.document, self.context.scenario), + identity: identity(self.context), report: None, restored: None, }) @@ -198,9 +206,7 @@ mod scenario_scroll { vec![widget::Tree::new(self.content.as_widget())] } fn diff(&self, tree: &mut widget::Tree) { - if tree.state.downcast_ref::().identity - != (self.context.document, self.context.scenario) - { + if tree.state.downcast_ref::().identity != identity(self.context) { // A new owner/document needs fresh real scroll state, not merely // cleared mirrored offsets. Edits and tool/form changes retain it. tree.state = self.state(); @@ -489,17 +495,25 @@ mod scenario_scroll { } #[test] fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() { - for mismatch in 0..3 { + for mismatch in 0..5 { let context = scenario::direct_tests::context(Id::new_v4()); let mut request = scenario::ScrollRestore { document: context.document, owner: context.scenario, + representation: context.representation, + visit: context.visit, token: Id::new_v4(), offset: Vector::new(0., 180.), }; match mismatch { 0 => request.document = Id::new_v4(), 1 => request.owner = Id::new_v4(), + 3 => { + request.representation = scenario_layout::Representation::Saved { + diagram: Id::new_v4(), + } + } + 4 => request.visit = Id::new_v4(), _ => {} } let mut view = observer(600., context); @@ -586,7 +600,7 @@ mod scenario_scroll { use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; for process in [false, true] { let area = scenario::ScrollArea::Page; - for change in 0..4 { + for change in 0..6 { let context = scenario::direct_tests::context(Id::new_v4()); let mut view = observer(600., context); view.process = process; @@ -615,6 +629,12 @@ mod scenario_scroll { next.revision += 1; next.epoch += 5; } + 4 => { + next.representation = scenario_layout::Representation::Saved { + diagram: Id::new_v4(), + } + } + 5 => next.visit = Id::new_v4(), _ => {} } view = observer(if change == 3 { 800. } else { 600. }, next); @@ -662,7 +682,7 @@ mod scenario_scroll { .collect::>(); assert_eq!( actual, - vec![if change < 2 { + vec![if change < 2 || change >= 4 { Vector::ZERO } else { Vector::new(0., 180.) @@ -976,6 +996,19 @@ pub fn view(app: &App) -> Element<'_, Message> { return container(out).padding(16).width(Fill).height(Fill).into(); } } + // A requested saved view remains the destination even after its model is removed. + // The shared display adapter reports unavailable instead of choosing another behavior. + if matches!( + app.behavior.scenario.representation(), + Some(scenario_layout::Representation::Saved { .. }) + ) && let Some(owner) = app.behavior.scenario.owner() + && !p + .elements + .get(&owner) + .is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer) + { + return scenario::view(app, owner, column![].spacing(6)); + } let choices = p .elements .values() @@ -987,7 +1020,14 @@ pub fn view(app: &App) -> Element<'_, Message> { label: format!("{} · {}", e.kind(), e.name), }) .collect::>(); - let owner = selected_owner(app); + let owner = if matches!( + app.behavior.scenario.representation(), + Some(scenario_layout::Representation::Saved { .. }) + ) { + app.behavior.scenario.owner() + } else { + selected_owner(app) + }; let selected = owner .and_then(|id| choices.iter().find(|c| c.id == id)) .or(choices.first()) @@ -1180,6 +1220,7 @@ struct Scene { scale: f32, camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, + coordinate_input: bool, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, // Exact render coordinates include unassigned lanes; do not derive from cards. @@ -1252,6 +1293,7 @@ impl Scene { layout_origin: Point::ORIGIN, selected, interaction: None, + coordinate_input: true, pending_placement: None, lanes: BTreeMap::new(), render_lanes: BTreeMap::new(), @@ -1857,7 +1899,7 @@ impl canvas::Program for Scene { state.pointer = None; } state.bounds = Some(bounds); - if !editable { + if !editable || !self.coordinate_input { state.cancel(); } // The viewport masks input outside its visible canvas bounds. @@ -1925,6 +1967,14 @@ impl canvas::Program for Scene { if !editable { return None; } + if !self.coordinate_input { + return self.hit(point).map(|id| { + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, id, + ))) + .and_capture() + }); + } if let CanvasTool::Message(_) = tool { let first = state.source.is_none(); let (source, slot) = state.source.or_else(|| { @@ -2252,7 +2302,10 @@ impl canvas::Program for Scene { self.lifecycle .draw_markers(&mut f, self.selected, self.scale, self.camera_pan); if let Some((context, tool, editable)) = self.interaction { - let active = editable && state.context == Some(context) && state.tool == tool; + let active = editable + && self.coordinate_input + && state.context == Some(context) + && state.tool == tool; let point = cursor.position_in(bounds).map(|p| { Point::new( (p.x - self.camera_pan.x) / self.scale, @@ -2268,6 +2321,7 @@ impl canvas::Program for Scene { } }); if editable + && self.coordinate_input && tool == scenario::CanvasTool::Select && let Some(handle) = self.reorder_handle() { diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs index 4d893a45..7d87e0d4 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -50,7 +50,7 @@ pub fn controls<'a>( tooltip::Position::Bottom), control("Fit", "Fit", Some(zoom(None))), control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), - text(format!("{:.0}%", scale * 100.)).size(12), + text(zoom_label(scale)).size(12), control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))), control("ActualSize", "100%", Some(zoom(Some(1.)))), ].spacing(4).align_y(iced::Center).into() @@ -58,6 +58,7 @@ pub fn controls<'a>( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Scope { + pub scenario_view: Option<(super::scenario_layout::Representation, Id)>, pub document: Id, pub owner: Id, pub revision: u64, @@ -69,6 +70,7 @@ impl Scope { Some(Self { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: app.camera_epoch, }) @@ -77,6 +79,7 @@ impl Scope { Some(Self { document: context.document, owner: context.owner, + scenario_view: None, revision: context.revision, epoch: app.camera_epoch, }) @@ -96,8 +99,8 @@ impl Scope { pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { iced::advanced::widget::Id::from(format!( - "behavior-control:{}:{}:{}:{name}", - self.document, self.owner, self.revision + "behavior-control:{}:{}:{}:{:?}:{name}", + self.document, self.owner, self.revision, self.scenario_view )) } } @@ -131,6 +134,20 @@ pub enum Event { pub use focus::{focus_task, root as focus_root}; /// Fold relative input in event order. The base scale is used only on leaving Fit. pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { + change_with_minimum(pan, zoom, fit, event, 0.05) +} + +/// Scenario extents can require scales below the topology canvas's 5% floor. +pub(super) fn change_with_minimum( + pan: &mut Vector, + zoom: &mut Option, + fit: &mut bool, + event: Event, + minimum: f32, +) -> bool { + if !minimum.is_finite() || minimum <= 0. || minimum > 3. { + return false; + } match event { Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { let next = *pan + delta; @@ -150,7 +167,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E && at.y.is_finite() => { let old = if *fit { base } else { zoom.unwrap_or(base) }; - let next = (old * factor).clamp(0.05, 3.); + if !old.is_finite() || old <= 0. { + return false; + } + let next = (old * factor).clamp(minimum, 3.); if old == next { return false; } @@ -168,6 +188,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E } } +fn zoom_label(scale: f32) -> String { + let percent = f64::from(scale) * 100.; + if !percent.is_finite() || percent <= 0. { + return "—".into(); + } + if percent >= 1. { + format!("{percent:.0}%") + } else if percent >= 0.01 { + format!("{percent:.3}%") + } else { + format!("{percent:.2e}%") + } +} + +#[cfg(test)] +mod camera_policy_tests { + use super::*; + + #[test] + fn topology_retains_five_percent_floor() { + let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false); + assert!(change( + &mut pan, + &mut zoom, + &mut fit, + Event::Zoom { + factor: 0.1, + at: Point::ORIGIN, + base: 0.06, + } + )); + assert_eq!(zoom, Some(0.05)); + } + + #[test] + fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() { + assert_eq!(zoom_label(1.), "100%"); + assert_eq!(zoom_label(0.05), "5%"); + assert_eq!(zoom_label(0.00034193), "0.034%"); + assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%"); + assert_eq!(zoom_label(1e-7), "1.00e-5%"); + assert_ne!(zoom_label(f32::from_bits(1)), "0%"); + } +} + /// Constant-width labelled palette. Arming a tool never reallocates the canvas. pub fn palette<'a>( scope: Scope, diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs index ba9f77cc..be4a65d6 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -368,6 +368,7 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() { use iced::widget::Space; let scope = Scope { + scenario_view: None, document: Id::new_v4(), owner: Id::new_v4(), revision: 2, @@ -427,6 +428,7 @@ pub(crate) fn scope(app: &App) -> Scope { Scope { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: app.camera_epoch, } @@ -435,6 +437,7 @@ pub(crate) fn scope(app: &App) -> Scope { Scope { document: context.document, owner: context.owner, + scenario_view: None, revision: context.revision, epoch: app.camera_epoch, } diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 8ba4abeb..64a986b6 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -4,7 +4,14 @@ use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; +mod camera; pub(crate) mod focus; +use super::scenario_layout::{ + Representation, + presentation::{self, ScenarioPresentation}, +}; +#[cfg(test)] +mod presentation_tests; mod usage; mod workspace; pub(crate) use workspace::NAME_INPUT; @@ -71,6 +78,8 @@ impl std::fmt::Display for MessageStyle { pub struct Context { pub document: Id, pub scenario: Id, + pub representation: Representation, + pub visit: Id, pub revision: u64, pub epoch: u64, } @@ -108,6 +117,7 @@ pub enum CanvasEvent { OpenUse(Context, Id), Back(Context), Choose(Context, CanvasTool), + Representation(Context, Representation), Select(Context, Id), Begin { context: Context, @@ -147,6 +157,7 @@ impl CanvasEvent { | Self::OpenUse(context, _) | Self::Back(context) | Self::Choose(context, _) + | Self::Representation(context, _) | Self::Select(context, _) | Self::Begin { context, .. } | Self::BeginOccurrence { context, .. } @@ -218,6 +229,7 @@ const CALLER_LIMIT: usize = 32; pub(crate) struct Caller { document: Id, pub owner: Id, + pub representation: Representation, pub occurrence: Id, target: Id, tool: CanvasTool, @@ -227,6 +239,8 @@ pub(crate) struct Caller { struct DetailsRestore { document: Id, owner: Id, + representation: Representation, + visit: Id, token: Id, offset: iced::Vector, } @@ -234,6 +248,8 @@ struct DetailsRestore { pub(super) struct ScrollRestore { pub document: Id, pub owner: Id, + pub representation: Representation, + pub visit: Id, pub token: Id, pub offset: iced::Vector, } @@ -327,42 +343,146 @@ impl State { } pub(crate) fn enter(&mut self, document: Id, owner: Id) { - if self.presentation.identity != Some((document, owner)) { + self.enter_representation(document, owner, Representation::Derived { owner }); + } + /// App validates requested identity with checked_owner before entering. + pub(crate) fn enter_representation( + &mut self, + document: Id, + owner: Id, + representation: Representation, + ) -> bool { + if matches!(representation, Representation::Derived { owner: requested } if requested != owner) + { + return false; + } + let changed = self.presentation.identity != Some((document, owner)) + || self.presentation.representation != Some(representation); + if changed { self.presentation = workspace::Presentation { identity: Some((document, owner)), + representation: Some(representation), focused: self.presentation.focused, + fit: matches!(representation, Representation::Saved { .. }), ..Default::default() }; self.details_offset = iced::Vector::ZERO; - self.restore = None; + self.canvas_tool = Some((owner, CanvasTool::Select)); } + if changed || self.presentation.visit.is_nil() { + self.presentation.visit = Id::new_v4(); + self.restore = None; + return true; + } + false + } + /// Suspend captured events while preserving this destination's camera state. + pub(crate) fn leave(&mut self) { + self.presentation.visit = Id::nil(); + self.restore = None; + } + pub(crate) fn forget_view(&mut self) { + self.presentation.identity = None; + self.presentation.representation = None; + self.presentation.visit = Id::nil(); + self.restore = None; + } + pub(crate) fn representation(&self) -> Option { + self.presentation.representation + } + pub(crate) fn owner(&self) -> Option { + self.presentation.identity.map(|(_, owner)| owner) + } + pub(crate) fn context( + &self, + document: Id, + owner: Id, + revision: u64, + epoch: u64, + ) -> Option { + if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil() + { + return None; + } + Some(Context { + document, + scenario: owner, + representation: self.representation()?, + visit: self.presentation.visit, + revision, + epoch, + }) + } + pub(crate) fn display( + &self, + p: &Project, + owner: Id, + selected: Id, + ) -> Option { + let (_, current_owner) = self.presentation.identity?; + let representation = self.representation()?; + (current_owner == owner).then(|| presentation::present(p, representation, selected)) + } + pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool { + self.context( + context.document, + context.scenario, + context.revision, + context.epoch, + ) == Some(context) + && self + .display(p, context.scenario, context.scenario) + .is_some_and(|shown| shown.capabilities().coordinate_input) } pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } - pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool { + pub(crate) fn camera( + &mut self, + project: &Project, + owner: Id, + event: super::canvas_workspace::Event, + ) -> bool { + let Some(bounds) = self + .display(project, owner, owner) + .and_then(|shown| shown.bounds()) + else { + return false; + }; let p = &mut self.presentation; - super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event) + camera::change( + &mut p.pan, + &mut p.zoom, + &mut p.fit, + p.viewport, + bounds, + event, + ) } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option) -> bool { - let size = Scene::sequence(p, owner, owner).size; - let old = self.presentation.scale(size); + let Some(bounds) = self + .display(p, owner, owner) + .and_then(|shown| shown.bounds()) + else { + return false; + }; if let Some(next) = zoom { + let current = self.presentation.camera(bounds); let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); - self.camera(super::canvas_workspace::Event::Zoom { - factor: next / old, - at: Point::new(viewport.width / 2., viewport.height / 2.), - base: old, - }) + self.camera( + p, + owner, + super::canvas_workspace::Event::Zoom { + factor: next / current.scale, + at: Point::new(viewport.width / 2., viewport.height / 2.), + base: current.scale, + }, + ) } else { - self.zoom(None); - let fitted = self.presentation.scale(size); - self.zoom(Some(fitted)); - self.presentation.pan = iced::Vector::ZERO; - true + self.zoom(None) } } pub(crate) fn focused(&self) -> bool { @@ -390,6 +510,7 @@ impl State { self.return_to.push(Caller { document: context.document, owner: context.scenario, + representation: context.representation, occurrence, target, tool: self.canvas_tool(context.scenario), @@ -404,6 +525,7 @@ impl State { fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool { frame.document == context.document && frame.target == context.scenario + && presentation::checked_owner(p, frame.representation) == Some(frame.owner) && referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target) } pub(super) fn can_back(&self, p: &Project, context: Context) -> bool { @@ -420,16 +542,23 @@ impl State { None } pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) { - if context.document != frame.document || context.scenario != frame.owner { + if context.document != frame.document + || context.scenario != frame.owner + || context.representation != frame.representation + { return; } self.canvas_tool = Some((frame.owner, frame.tool)); self.details_offset = frame.details_offset; self.presentation = frame.presentation; self.presentation.identity = Some((context.document, context.scenario)); + self.presentation.representation = Some(frame.representation); + self.presentation.visit = context.visit; self.restore = Some(DetailsRestore { document: context.document, owner: frame.owner, + representation: frame.representation, + visit: context.visit, token: Id::new_v4(), offset: frame.details_offset, }); @@ -439,13 +568,17 @@ impl State { context: Context, ScrollArea::Page: ScrollArea, ) -> Option { - let restore = self - .restore - .as_ref() - .filter(|r| r.document == context.document && r.owner == context.scenario)?; + let restore = self.restore.as_ref().filter(|r| { + r.document == context.document + && r.owner == context.scenario + && r.representation == context.representation + && r.visit == context.visit + })?; Some(ScrollRestore { document: restore.document, owner: restore.owner, + representation: restore.representation, + visit: restore.visit, token: restore.token, offset: restore.offset, }) @@ -458,7 +591,11 @@ impl State { offset: iced::Vector, ) -> bool { if !self.restore.as_ref().is_some_and(|r| { - r.document == context.document && r.owner == context.scenario && r.token == token + r.document == context.document + && r.owner == context.scenario + && r.representation == context.representation + && r.visit == context.visit + && r.token == token }) { return false; } @@ -1931,6 +2068,8 @@ pub(crate) mod direct_tests { Context { document: Id::new_v4(), scenario: owner, + representation: Representation::Derived { owner }, + visit: Id::new_v4(), revision: 0, epoch: 0, } diff --git a/crates/oa-desktop/src/behavior/scenario/camera.rs b/crates/oa-desktop/src/behavior/scenario/camera.rs new file mode 100644 index 00000000..28fff8e7 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/camera.rs @@ -0,0 +1,282 @@ +//! Scenario camera policy. Bounds are from the exact displayed presentation, +//! including its origin and automatic fallback; no model geometry is authored. +use crate::behavior::canvas_workspace::{self, Event}; +use iced::{Rectangle, Size, Vector}; + +pub(super) const MIN_SCALE: f32 = f32::from_bits(1); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Camera { + pub(super) scale: f32, + pub(super) pan: Vector, +} + +/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin +/// and size cannot overflow while finding the center. Unrepresentable cameras +/// are refused rather than silently clamped to an unrelated minimum scale. +pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option { + if [ + bounds.x, + bounds.y, + bounds.width, + bounds.height, + viewport.width, + viewport.height, + ] + .iter() + .any(|n| !n.is_finite()) + || bounds.width < 0. + || bounds.height < 0. + || viewport.width <= 0. + || viewport.height <= 0. + { + return None; + } + let width = f64::from(viewport.width); + let height = f64::from(viewport.height); + let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.)) + .min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.)) + .min(1.) as f32; + if !scale.is_finite() || scale <= 0. { + return None; + } + let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale); + let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale); + let pan = Vector::new(x as f32, y as f32); + (pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan }) +} + +pub(super) fn effective_camera( + pan: Vector, + zoom: Option, + fitting: bool, + viewport: Option, + bounds: Rectangle, +) -> Camera { + if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) { + return camera; + } + Camera { + scale: zoom.unwrap_or(1.), + pan, + } +} + +/// None preserves Fit as a mode, including before the first viewport arrives. +pub(super) fn set_zoom( + pan: &mut Vector, + zoom: &mut Option, + fitting: &mut bool, + scale: Option, +) -> bool { + if scale.is_some_and(|value| !value.is_finite() || value <= 0.) { + return false; + } + let next = scale.map(|value| value.min(3.)); + let next_pan = if scale.is_none() { Vector::ZERO } else { *pan }; + let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan; + *zoom = next; + *fitting = scale.is_none(); + *pan = next_pan; + changed +} + +/// Materialize the visible fitted camera only when a camera gesture succeeds. +/// A zero-motion or rejected event preserves the original mode and fields. +pub(super) fn change( + pan: &mut Vector, + zoom: &mut Option, + fitting: &mut bool, + viewport: Option, + bounds: Rectangle, + event: Event, +) -> bool { + let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds); + let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false); + let event = match event { + Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false, + Event::Zoom { factor, at, .. } => Event::Zoom { + factor, + at, + base: camera.scale, + }, + event => event, + }; + if !canvas_workspace::change_with_minimum( + &mut next_pan, + &mut next_zoom, + &mut next_fit, + event, + MIN_SCALE, + ) { + return false; + } + *pan = next_pan; + *zoom = next_zoom; + *fitting = next_fit; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use iced::Point; + + fn bounds() -> Rectangle { + Rectangle { + x: -1_000_160., + y: -200., + width: 2_000_400., + height: 1_000., + } + } + fn near(actual: f32, expected: f32, tolerance: f32) { + assert!( + (actual - expected).abs() <= tolerance, + "{actual} != {expected}" + ); + } + fn projected(camera: Camera, point: Point) -> Point { + Point::new( + point.x * camera.scale + camera.pan.x, + point.y * camera.scale + camera.pan.y, + ) + } + + #[test] + fn negative_origin_fit_contains_independently_known_extremes() { + let camera = fit(bounds(), Size::new(700., 500.)).unwrap(); + near(camera.scale, 0.0003419316, 1e-10); + near(camera.pan.x, 349.98633, 0.0001); + near( + projected(camera, Point::new(-1_000_160., -200.)).x, + 8., + 0.0001, + ); + near( + projected(camera, Point::new(1_000_240., 800.)).x, + 692., + 0.0001, + ); + near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001); + } + + #[test] + fn fit_remains_active_across_first_and_changed_viewport() { + let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false); + assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None)); + let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds()); + let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds()); + assert!(actual.scale < retained.scale); + near( + projected(actual, Point::new(-1_000_160., -200.)).x, + 8., + 0.0001, + ); + near( + projected(actual, Point::new(1_000_240., 800.)).x, + 492., + 0.0001, + ); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } + + #[test] + fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() { + let viewport = Some(Size::new(700., 500.)); + let start = fit(bounds(), viewport.unwrap()).unwrap(); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + let at = Point::new(210., 180.); + let world = Point::new( + (at.x - start.pan.x) / start.scale, + (at.y - start.pan.y) / start.scale, + ); + for factor in [1.25, 0.8] { + let prior_scale = zoom.unwrap_or(start.scale); + assert!(change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + Event::Zoom { + factor, + at, + base: start.scale, + } + )); + let camera = effective_camera(pan, zoom, fitting, viewport, bounds()); + near(camera.scale, prior_scale * factor, 1e-10); + assert!(camera.scale < 0.001); + near(projected(camera, world).x, at.x, 0.0001); + near(projected(camera, world).y, at.y, 0.0001); + } + near(zoom.unwrap(), start.scale, 1e-10); + near(pan.x, start.pan.x, 0.0001); + assert!(!fitting); + } + + #[test] + fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() { + let viewport = Some(Size::new(700., 500.)); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + for event in [ + Event::Pan(Vector::ZERO), + Event::Zoom { + factor: 1., + at: Point::ORIGIN, + base: 1., + }, + ] { + assert!(!change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + event + )); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } + let start = fit(bounds(), viewport.unwrap()).unwrap(); + assert!(change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + Event::Pan(Vector::new(20., -30.)) + )); + assert_eq!(pan, start.pan + Vector::new(20., -30.)); + assert_eq!(zoom, Some(start.scale)); + assert!(!fitting); + } + + #[test] + fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() { + assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none()); + let extreme = Rectangle { + x: f32::MAX, + y: 0., + width: f32::MAX, + height: 100., + }; + let camera = fit(extreme, Size::new(700., 500.)).unwrap(); + assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite()); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN))); + assert!(!change( + &mut pan, + &mut zoom, + &mut fitting, + Some(Size::new(700., 500.)), + bounds(), + Event::Zoom { + factor: 1.25, + at: Point::ORIGIN, + base: f32::NAN + } + )); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/focus.rs b/crates/oa-desktop/src/behavior/scenario/focus.rs index 9f6b0b73..4c10f429 100644 --- a/crates/oa-desktop/src/behavior/scenario/focus.rs +++ b/crates/oa-desktop/src/behavior/scenario/focus.rs @@ -11,6 +11,8 @@ use std::collections::BTreeMap; pub(crate) struct Stamp { document: ModelId, owner: ModelId, + representation: super::Representation, + visit: ModelId, revision: u64, session: ModelId, mount: ModelId, @@ -20,6 +22,8 @@ impl Stamp { Self { document: context.document, owner: context.scenario, + representation: context.representation, + visit: context.visit, revision: context.revision, session, mount, @@ -28,10 +32,12 @@ impl Stamp { } pub(crate) fn name_id(stamp: Stamp) -> Id { Id::from(format!( - "{}:{}:{}:{}:{}:{}", + "{}:{}:{}:{:?}:{}:{}:{}:{}", super::NAME_INPUT, stamp.document, stamp.owner, + stamp.representation, + stamp.visit, stamp.revision, stamp.session, stamp.mount diff --git a/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs b/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs new file mode 100644 index 00000000..d0754c26 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs @@ -0,0 +1,283 @@ +//! Controller identity and actual presentation input, independent of App routing. +use super::*; +use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout}; + +fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation { + let id = Id::new_v4(); + p.diagrams.insert( + id, + Diagram { + id, + alias: format!("saved_{}", p.diagrams.len()), + name: "Saved scenario".into(), + description: String::new(), + layer: p.elements[&owner].layer(), + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: lanes + .iter() + .map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x }) + .collect(), + messages: vec![], + })), + }, + ); + let errors: Vec<_> = oa_core::validation::validate(p) + .into_iter() + .filter(|issue| issue.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + Representation::Saved { diagram: id } +} + +#[test] +fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() { + let mut f = direct_tests::fixture(); + let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]); + let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]); + let original = f.project.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + assert!(state.enter_representation(document, f.owner, a)); + let first = state.context(document, f.owner, 7, 9).unwrap(); + assert!(!state.enter_representation(document, f.owner, a)); + assert_eq!(state.context(document, f.owner, 7, 9), Some(first)); + state.presentation.pan = iced::Vector::new(23., -17.); + state.leave(); + assert_eq!(state.representation(), Some(a)); + assert_eq!(state.owner(), Some(f.owner)); + assert!(state.context(document, f.owner, 7, 9).is_none()); + assert!(state.enter_representation(document, f.owner, a)); + let returned = state.context(document, f.owner, 7, 9).unwrap(); + assert_ne!(returned.visit, first.visit); + assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.)); + assert!(state.enter_representation(document, f.owner, b)); + let middle = state.context(document, f.owner, 7, 9).unwrap(); + assert!(state.enter_representation(document, f.owner, a)); + let last = state.context(document, f.owner, 7, 9).unwrap(); + assert_ne!(last.visit, returned.visit); + assert_ne!(last.visit, middle.visit); + assert!(!state.coordinate_input(&f.project, first)); + assert!(state.coordinate_input(&f.project, last)); + let session = Id::new_v4(); + let mount = Id::new_v4(); + assert_ne!( + focus::Stamp::new(first, session, mount), + focus::Stamp::new(last, session, mount) + ); + assert_eq!(f.project, original); + state.forget_view(); + assert_eq!(state.representation(), None); + assert_eq!(state.owner(), None); + assert!(state.context(document, f.owner, 7, 9).is_none()); +} + +#[test] +fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() { + let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture(); + let view = saved(&mut project, a, &[]); + let original = project.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + state.enter_representation(document, a, view); + let initial = state.context(document, a, 0, 0).unwrap(); + state.presentation.pan = iced::Vector::new(11., 29.); + state.details_offset = iced::Vector::new(0., 71.); + assert_eq!(state.open_use(&project, initial, reference).unwrap(), b); + state.enter(document, b); + let callee = state.context(document, b, 0, 0).unwrap(); + let frame = state.back(&project, callee).unwrap(); + assert_eq!(frame.representation, view); + state.enter_representation(document, a, frame.representation); + let returned = state.context(document, a, 0, 0).unwrap(); + state.restore_caller(returned, frame); + assert_ne!(returned.visit, initial.visit); + assert_eq!(state.representation(), Some(view)); + assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.)); + assert!(state.scroll_restore(initial, ScrollArea::Page).is_none()); + assert_eq!( + state + .scroll_restore(returned, ScrollArea::Page) + .unwrap() + .offset, + iced::Vector::new(0., 71.) + ); + assert_eq!(project, original); + state.open_use(&project, returned, reference).unwrap(); + state.enter(document, b); + let Representation::Saved { diagram } = view else { + unreachable!() + }; + project.diagrams.remove(&diagram); + let callee = state.context(document, b, 0, 0).unwrap(); + assert!(state.back(&project, callee).is_none()); +} + +#[test] +fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() { + let mut f = direct_tests::fixture(); + // Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call. + let rep = saved( + &mut f.project, + f.owner, + &[(f.sender, 900.), (f.receiver, 900.)], + ); + let original = f.project.clone(); + let mut state = State::default(); + state.enter_representation(Id::new_v4(), f.owner, rep); + let document = state.presentation.identity.unwrap().0; + let context = state.context(document, f.owner, 0, 0).unwrap(); + let shown = state.display(&f.project, f.owner, f.call).unwrap(); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Fallback { .. } + )); + assert!(!state.coordinate_input(&f.project, context)); + let automatic = presentation::present( + &f.project, + Representation::Derived { owner: f.owner }, + f.call, + ); + let automatic = automatic.scene().unwrap(); + assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes); + assert_eq!(shown.scene().unwrap().activations, automatic.activations); + assert!(!automatic.activations.is_empty()); + let mut scene = shown.into_scene().unwrap(); + scene.coordinate_input = false; + let line = scene.lines.iter().find(|line| line.id == f.call).unwrap(); + let point = Point::new( + (line.points[0].x + line.points.last().unwrap().x) / 2., + line.points[0].y, + ); + assert_eq!(scene.hit(point), Some(f.call)); + let bounds = Rectangle::with_size(Size::new(1600., 1000.)); + for tool in [ + CanvasTool::Select, + CanvasTool::Message(MessageStyle::CallReply), + CanvasTool::Add(Tool::Reuse), + ] { + scene.interaction = Some((context, tool, true)); + let mut input = SequenceInput::default(); + let mut messages = vec![]; + for event in [ + mouse::Event::CursorMoved { position: point }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { + position: Point::new(point.x, point.y + 100.), + }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + if let Some(message) = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(point), + ) + .and_then(|action| action.into_inner().0) + { + messages.push(message); + } + } + assert_eq!(messages.len(), 1); + assert!( + matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call) + ); + assert!(input.press.is_none() && input.source.is_none()); + } + // A gesture already captured in a Ready frame is cancelled when capability is revoked. + scene.coordinate_input = true; + scene.interaction = Some((context, CanvasTool::Select, true)); + let mut input = SequenceInput::default(); + let _ = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(point), + ); + assert!(input.press.is_some()); + scene.coordinate_input = false; + let result = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(point), + ); + assert!(result.and_then(|action| action.into_inner().0).is_none()); + assert!(input.press.is_none()); + assert_eq!(f.project, original); +} + +#[test] +fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() { + let mut f = direct_tests::fixture(); + let rep = saved( + &mut f.project, + f.owner, + &[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)], + ); + let original = f.project.clone(); + let mut state = State::default(); + state.enter_representation(Id::new_v4(), f.owner, rep); + state.viewport(Size::new(700., 500.)); + assert!( + state.presentation.fit, + "new saved views await their real viewport in Fit mode" + ); + assert!( + !state.camera_zoom(&f.project, f.owner, None), + "already-active Fit is idempotent" + ); + let shown = state.display(&f.project, f.owner, f.owner).unwrap(); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Ready + )); + let bounds = shown.bounds().unwrap(); + assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.); + let fitted = state.presentation.camera(bounds); + assert!(fitted.scale > 0. && fitted.scale < 0.001); + let left = bounds.x * fitted.scale + fitted.pan.x; + let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x; + assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001); + assert!(state.camera( + &f.project, + f.owner, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.)) + )); + let moved = state.presentation.camera(bounds); + assert_eq!(moved.scale, fitted.scale); + assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.)); + assert!(!state.presentation.fit); + assert_eq!(f.project, original); +} + +#[test] +fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() { + let mut f = direct_tests::fixture(); + let rep = saved(&mut f.project, f.owner, &[]); + let mut state = State::default(); + let document = Id::new_v4(); + state.enter_representation(document, f.owner, rep); + let Representation::Saved { diagram } = rep else { + unreachable!() + }; + f.project.diagrams.remove(&diagram); + let original = f.project.clone(); + let shown = state.display(&f.project, f.owner, f.owner).unwrap(); + assert_eq!(shown.requested(), rep); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Unavailable { .. } + )); + assert!(shown.scene().is_none() && shown.bounds().is_none()); + assert_eq!(state.owner(), Some(f.owner)); + assert!(state.context(document, f.owner, 0, 0).is_some()); + assert!(!state.camera_zoom(&f.project, f.owner, None)); + assert_eq!(f.project, original); +} diff --git a/crates/oa-desktop/src/behavior/scenario/usage.rs b/crates/oa-desktop/src/behavior/scenario/usage.rs index 824375b3..3ff5b88a 100644 --- a/crates/oa-desktop/src/behavior/scenario/usage.rs +++ b/crates/oa-desktop/src/behavior/scenario/usage.rs @@ -1,5 +1,6 @@ //! Navigation is derived from the same opaque-reference scene that is painted. use super::*; +use crate::behavior::scenario_layout::{Representation, presentation}; use crate::diagram_usage::{Surface, Target, Usage}; /// The renderer collapses whitespace before shortening. Require at least one @@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool { start < retained } -fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { +fn usages( + p: &Project, + owner: Id, + subject: Id, + surface: Surface, + bounds: Rectangle, + scene: &Scene, +) -> Vec { let mut result = Vec::new(); let mut add = |id, bounds, role: &str| { // Activation bars share the occurrence identity with a labelled message; @@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { { result.push(Usage { subject, - surface: Surface::Scenario(owner), + surface, target: Target::ScenarioObject(id), role: role.into(), bounds, @@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { } } } - if subject == owner { + if subject == owner || subject == surface.id() { result.insert( 0, Usage { subject, - surface: Surface::Scenario(owner), + surface, target: Target::Whole, role: "scenario".into(), - bounds: Rectangle::new(Point::ORIGIN, scene.size), + bounds, }, ); } @@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { } impl State { - pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { - if !p - .elements - .get(&owner) - .is_some_and(|e| e.kind() == Kind::Scenario) - { + /// Membership comes from the complete presentation chosen for this exact view, + /// including automatic fallback geometry while retaining its Saved identity. + pub(crate) fn representation_usages( + &self, + p: &Project, + representation: Representation, + subject: Id, + ) -> Vec { + let shown = presentation::present(p, representation, subject); + let (Some(owner), Some(bounds), Some(scene)) = + (shown.owner(), shown.bounds(), shown.scene()) + else { return vec![]; - } - usages(p, owner, subject, &Scene::sequence(p, owner, subject)) + }; + let surface = match representation { + Representation::Derived { owner } => Surface::Scenario(owner), + Representation::Saved { diagram } => Surface::Saved(diagram), + }; + usages(p, owner, subject, surface, bounds, scene) + } + + pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { + self.representation_usages(p, Representation::Derived { owner }, subject) } pub(crate) fn reveal_usage( @@ -171,12 +193,24 @@ impl State { owner: Id, target: &Target, ) -> bool { - if self.draft.is_some() - || !p - .elements - .get(&owner) - .is_some_and(|e| e.kind() == Kind::Scenario) - { + self.reveal_representation_usage( + p, + document, + owner, + Representation::Derived { owner }, + target, + ) + } + + pub(crate) fn reveal_representation_usage( + &mut self, + p: &Project, + document: Id, + owner: Id, + representation: Representation, + target: &Target, + ) -> bool { + if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) { return false; } let id = match target { @@ -185,7 +219,7 @@ impl State { _ => return false, }; let Some(usage) = self - .diagram_usages(p, owner, id) + .representation_usages(p, representation, id) .into_iter() .find(|u| &u.target == target) else { @@ -193,11 +227,29 @@ impl State { }; // Preserve an existing mounted viewport across enter's owner reset. let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); - self.enter(document, owner); + if !viewport.width.is_finite() + || !viewport.height.is_finite() + || viewport.width <= 0. + || viewport.height <= 0. + { + return false; + } + let (zoom, pan) = if matches!(target, Target::Whole) { + let Some(camera) = super::camera::fit(usage.bounds, viewport) else { + return false; + }; + (None, camera.pan) + } else { + let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); + if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() { + return false; + } + (Some(zoom), pan) + }; + self.enter_representation(document, owner, representation); self.presentation.viewport = Some(viewport); self.canvas_tool = Some((owner, CanvasTool::Select)); - let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); - self.presentation.zoom(Some(zoom)); + self.presentation.zoom(zoom); self.presentation.pan = pan; true } diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index d4d08d7b..956df0d8 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -13,6 +13,8 @@ pub enum Shelf { #[derive(Clone, Default)] pub(super) struct Presentation { pub(super) identity: Option<(Id, Id)>, + pub(super) representation: Option, + pub(super) visit: Id, pub(super) viewport: Option, pub(super) focused: bool, pub(super) shelf: Option, @@ -20,7 +22,6 @@ pub(super) struct Presentation { pub(super) zoom: Option, pub(super) fit: bool, } -const MIN_SCALE: f32 = 0.05; impl Presentation { pub(super) fn viewport(&mut self, size: Size) -> bool { if !size.width.is_finite() @@ -35,29 +36,26 @@ impl Presentation { true } pub(super) fn zoom(&mut self, scale: Option) -> bool { - if scale.is_some_and(|value| !value.is_finite() || value <= 0.) { - return false; - } - let next = scale.map(|value| value.clamp(MIN_SCALE, 3.)); - let changed = self.zoom != next || self.fit != scale.is_none(); - self.zoom = next; - self.fit = scale.is_none(); - changed + camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale) } - fn fit_scale(&self, scene: Size) -> Option { - let viewport = self.viewport?; - // Fit uses the actual mounted viewport with a small visual gutter. - Some( - ((viewport.width - 16.).max(1.) / scene.width.max(1.)) - .min((viewport.height - 16.).max(1.) / scene.height.max(1.)), - ) + pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera { + camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds) } - pub(super) fn scale(&self, scene: Size) -> f32 { - if self.fit { - self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.) - } else { - self.zoom.unwrap_or(1.) - } + // Legacy tests call size-only scale; actual display always passes full bounds. + #[cfg(test)] + pub(super) fn scale(&self, size: Size) -> f32 { + self.camera(Rectangle::new(Point::ORIGIN, size)).scale + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ArrangementChoice { + representation: Representation, + label: String, +} +impl std::fmt::Display for ArrangementChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) } } @@ -69,43 +67,71 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .draft .as_ref() .is_some_and(|draft| draft.owner == owner); - let context = app.scenario_context().expect("visible scenario context"); + let Some(context) = app.scenario_context() else { + return column![ + out, + text("Scenario view is unavailable."), + button("Open selected source").on_press(Message::SourceForSelection) + ] + .spacing(8) + .into(); + }; let tool = state.canvas_tool(owner); let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); - let mut scene = Scene::sequence(p, owner, app.selected); - let scale = presentation.scale(scene.size); - scene.scale = scale; - scene.camera_pan = presentation.pan; - scene.interaction = Some((context, tool, clean)); - scene.pending_placement = state.pending_placement(owner); + let shown = presentation::present(p, context.representation, app.selected); + let capabilities = shown.capabilities(); + let coordinate_input = clean && capabilities.coordinate_input; + let notice = display_notice(p, &shown); + let camera_state = shown.bounds().map(|bounds| presentation.camera(bounds)); + let scale = camera_state.map_or(1., |camera| camera.scale); let scope = super::super::canvas_workspace::Scope { document: context.document, owner, revision: context.revision, epoch: app.camera_epoch, + scenario_view: Some((context.representation, context.visit)), }; - let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app); + let keyboard = clean + && capabilities.geometry_navigation + && super::super::canvas_workspace::keyboard_enabled(app); let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e)); - let canvas = container( - sensor( - super::super::canvas_workspace::Viewport::new( - canvas::Canvas::new(scene).width(Fill).height(Fill), - scope, - scale, - keyboard, - camera, + let canvas: Element<'a, Message> = if let Some(mut scene) = shown.into_scene() { + let camera_state = camera_state.expect("displayed Scene has complete bounds"); + scene.scale = camera_state.scale; + scene.camera_pan = camera_state.pan; + scene.interaction = Some((context, tool, clean)); + scene.coordinate_input = capabilities.coordinate_input; + scene.pending_placement = coordinate_input + .then(|| state.pending_placement(owner)) + .flatten(); + container( + sensor( + super::super::canvas_workspace::Viewport::new( + canvas::Canvas::new(scene).width(Fill).height(Fill), + scope, + scale, + keyboard, + camera, + ) + .pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input), ) - .pan_background(tool == CanvasTool::Select), + .key(( + context.document, + format!("{:?}", context.representation), + context.visit, + )) + .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) + .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), ) - .key((context.document, owner)) - .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) - .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), - ) - .id("scenario-viewport") - .width(Fill) - .height(Fill) - .style(style::card) - .into(); + .id("scenario-viewport") + .width(Fill) + .height(Fill) + .style(style::card) + .into() + } else { + 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() + }; let mut navigation = row![ out.width(Fill), super::super::canvas_workspace::Control::new( @@ -141,9 +167,47 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))), )); } - let header = column![ - navigation, - super::super::canvas_workspace::controls( + let mut header = column![navigation, notice].spacing(4); + let saved_views = p + .diagrams + .values() + .filter(|diagram| { + presentation::checked_owner( + p, + Representation::Saved { + diagram: diagram.id, + }, + ) == Some(owner) + }) + .map(|diagram| ArrangementChoice { + representation: Representation::Saved { + diagram: diagram.id, + }, + label: short(&diagram.name, 64), + }) + .collect::>(); + if !saved_views.is_empty() { + let mut choices = vec![ArrangementChoice { + representation: Representation::Derived { owner }, + label: "Automatic arrangement".into(), + }]; + choices.extend(saved_views); + let selected = choices + .iter() + .find(|choice| choice.representation == context.representation) + .cloned(); + header = header.push( + pick_list(choices, selected, move |choice: ArrangementChoice| { + Message::ScenarioCanvas(CanvasEvent::Representation(context, choice.representation)) + }) + .placeholder("Choose arrangement") + .text_size(12) + .padding(5) + .width(Fill), + ); + } + if capabilities.geometry_navigation { + header = header.push(super::super::canvas_workspace::controls( scope, keyboard, scale, @@ -151,13 +215,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera( context, scope.epoch, - super::super::canvas_workspace::Event::Focus + super::super::canvas_workspace::Event::Focus, ))), move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)), - camera - ) - ] - .spacing(4); + camera, + )); + } let entries = [ ("S", "Select / reorder occurrences", CanvasTool::Select), ( @@ -226,9 +289,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a glyph, label, choice == tool, - clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose( - context, choice, - ))), + (clean && (choice == CanvasTool::Select || capabilities.coordinate_input)) + .then_some(Message::ScenarioCanvas(CanvasEvent::Choose( + context, choice, + ))), ) }), ); @@ -575,6 +639,100 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a super::super::canvas_workspace::host(header.into(), palette, canvas, editor) } +fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> { + use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus}; + let title = match shown.requested() { + Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else( + || "Unavailable saved Scenario view".into(), + |view| format!("Saved view · {}", view.name), + ), + Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)), + }; + let mut notice = column![text(title).size(14)].spacing(4); + match shown.status() { + PresentationStatus::Ready => { + for diagnostic in shown.diagnostics().iter().take(3) { + let explanation = match diagnostic { + Diagnostic::SourceOrderCrossing { before, after, .. } => format!( + "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.", + p.named(*frame), + p.named(*following) + ), + }; + notice = notice.push(text(explanation).size(12)); + } + if shown.diagnostics().len() > 3 { + notice = notice.push( + text(format!( + "{} additional spacing notices are retained.", + shown.diagnostics().len() - 3 + )) + .size(12), + ); + } + } + PresentationStatus::Fallback { requested_issues } => { + notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12)); + 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. 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)); + } + } + if !shown.latent_messages().is_empty() { + notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", shown.latent_messages().len())).size(12)); + } + notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection)); + container(notice) + .id("scenario-display-notice") + .padding(6) + .width(Fill) + .into() +} + +fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String { + use super::super::scenario_layout::GeometryIssue; + match issue { + GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!( + "The position of '{}' conflicts with the execution of '{}'.", + p.named(*event), + p.named(*call) + ), + GeometryIssue::CoincidentDistinctEndpoints(message) => format!( + "The distinct endpoints of '{}' have the same horizontal position.", + p.named(*message) + ), + GeometryIssue::TypedActivationOutsideLifetime { call, .. } + | GeometryIssue::TypedActivationOutsideScope { call, .. } => format!( + "The position of '{}' falls outside its supported execution interval.", + p.named(*call) + ), + GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!( + "The saved spacing would move the fixed header of '{}'.", + p.named(*frame) + ), + GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!( + "The spacing of '{}' extends outside its enclosing frame.", + p.named(*operand) + ), + GeometryIssue::LifecycleGeometryNotSupported { .. } + | GeometryIssue::LifecycleAnchorOutsideRenderScope { .. } + | GeometryIssue::LifetimeInversionNotSupported { .. } => { + "The saved spacing cannot preserve the participant lifetime geometry.".into() + } + _ => "These saved positions are outside the geometry currently supported by the canvas." + .into(), + } +} + fn sequence_action<'a>( title: &str, message: Message, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index 311c93cb..bb6e6493 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -1760,14 +1760,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp assert!(!presentation.viewport(Size::new(484., 204.))); let scale = presentation.scale(graph); assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); - assert!(scale > MIN_SCALE); + assert!(scale > camera::MIN_SCALE); for bad in [f32::NAN, f32::INFINITY, 0., -1.] { assert!(!presentation.zoom(Some(bad))); assert!(!presentation.viewport(Size::new(bad, 100.))); assert!(!presentation.viewport(Size::new(100., bad))); assert_eq!(presentation.scale(graph), scale); } - assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE); + let tall = Rectangle::with_size(Size::new(900., 10000.)); + let camera = presentation.camera(tall); + assert!(camera.scale < 0.05 && camera.scale > 0.); + assert!((camera.scale - 188. / 10000.).abs() < 1e-8); + assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4); + assert!((camera.pan.y - 8.).abs() < 1e-4); assert!(presentation.zoom(Some(1.25))); presentation.viewport(Size::new(200., 100.)); assert_eq!( diff --git a/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs b/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs index 8730f79f..e2ad0ff0 100644 --- a/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs +++ b/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs @@ -160,7 +160,7 @@ pub(crate) fn present( } } -fn checked_owner(project: &Project, requested: Representation) -> Option { +pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option { let owner = match requested { Representation::Derived { owner } => owner, Representation::Saved { diagram } => { diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 86cb8ec9..cf548175 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -219,6 +219,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele graph.interaction = Some((context, state.tool, clean)); let scale = graph.scale; let scope = super::super::canvas_workspace::Scope { + scenario_view: None, document: context.document, owner, revision: context.revision, diff --git a/crates/oa-desktop/src/diagram_usage.rs b/crates/oa-desktop/src/diagram_usage.rs index 6c0562c4..08a11a06 100644 --- a/crates/oa-desktop/src/diagram_usage.rs +++ b/crates/oa-desktop/src/diagram_usage.rs @@ -170,6 +170,13 @@ impl Surface { { "State views" } + Self::Saved(id) + if p.diagrams + .get(&id) + .is_some_and(|d| d.projection.scenario().is_some()) => + { + "Scenarios" + } Self::Saved(_) => "Architecture", Self::Process(_) => "Processes and chains", Self::Scenario(_) => "Scenarios", @@ -276,6 +283,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option