Wire saved Scenario arrangements through workspace navigation

This commit is contained in:
2026-09-12 23:00:52 -07:00
parent aa62f42b81
commit 46e106c04e
25 changed files with 2719 additions and 197 deletions
+214 -28
View File
@@ -10,13 +10,14 @@ mod hosted;
mod reference_authoring; mod reference_authoring;
#[cfg(test)] #[cfg(test)]
mod review_navigation_tests; mod review_navigation_tests;
mod scenario_diagram;
pub(crate) mod source_rename; pub(crate) mod source_rename;
mod source_symbols; mod source_symbols;
#[cfg(test)] #[cfg(test)]
mod transition_context_tests; mod transition_context_tests;
#[cfg(test)] #[cfg(test)]
mod view_copy_tests; 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::forms::Draft;
use crate::{ use crate::{
behavior, behavior,
@@ -530,6 +531,7 @@ pub struct App {
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>, pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>, source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>, diagram_lookup_in_flight: Option<Id>,
pending_scenario_reveal_visit: Option<Id>,
pending_diagram_reveal: Option<( pending_diagram_reveal: Option<(
crate::diagram_usage::Context, crate::diagram_usage::Context,
crate::diagram_usage::Usage, crate::diagram_usage::Usage,
@@ -651,6 +653,7 @@ impl App {
source_diagram_job: None, source_diagram_job: None,
diagram_lookup_in_flight: None, diagram_lookup_in_flight: None,
pending_diagram_reveal: None, pending_diagram_reveal: None,
pending_scenario_reveal_visit: None,
model_issues, model_issues,
migration: None, migration: None,
source_version: 0, source_version: 0,
@@ -665,6 +668,7 @@ impl App {
workflow: None, workflow: None,
}; };
app.route_state_diagram_workspace(); app.route_state_diagram_workspace();
app.reconcile_scenario_workspace();
app.last_workspace_page = app.page; app.last_workspace_page = app.page;
app app
} }
@@ -775,6 +779,17 @@ impl App {
self.sync_with_alias_editor(None); self.sync_with_alias_editor(None);
} }
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) { fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
// 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.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.model_issues = validate(self.project()); self.model_issues = validate(self.project());
self.normalize_creation(); self.normalize_creation();
@@ -879,6 +894,26 @@ impl App {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
}) { }) {
Ok(id) => { 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) { if self.active_diagram == Some(original) {
self.active_diagram = Some(id); self.active_diagram = Some(id);
} }
@@ -1083,6 +1118,17 @@ impl App {
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id) && let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
{ {
self.behavior.active_owner = Some(owner); 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) if self.reset_state_view_after_navigation(previous_state_view)
&& self.page == Page::Behavior && self.page == Page::Behavior
@@ -1232,6 +1278,38 @@ impl App {
{ {
return Task::none(); 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 usage_viewport = self.diagram_reveal_viewport(&message);
let window_resized = matches!(&message, Message::WindowResized(_)); let window_resized = matches!(&message, Message::WindowResized(_));
// Reject stale measurements before legacy viewport handlers can change // Reject stale measurements before legacy viewport handlers can change
@@ -1284,6 +1362,7 @@ impl App {
let valid_scenario = |c: behavior::scenario::Context| { let valid_scenario = |c: behavior::scenario::Context| {
c.document == scope.document c.document == scope.document
&& c.scenario == scope.owner && c.scenario == scope.owner
&& scope.scenario_view == Some((c.representation, c.visit))
&& c.revision == scope.revision && c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch) && self.camera_toolbar_epoch(c.epoch)
}; };
@@ -1667,6 +1746,7 @@ impl App {
self.refresh_source_rename_context(); self.refresh_source_rename_context();
self.reconcile_reference_chooser(); self.reconcile_reference_chooser();
self.route_state_diagram_workspace(); self.route_state_diagram_workspace();
self.reconcile_scenario_workspace();
if self.page != Page::Home { if self.page != Page::Home {
self.last_workspace_page = self.page; self.last_workspace_page = self.page;
} }
@@ -1675,11 +1755,6 @@ impl App {
.topology .topology
.enter(context.document, context.owner); .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| { if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context() Some(session.context) != self.route_context()
|| self.page != Page::Diagram || self.page != Page::Diagram
@@ -1723,15 +1798,23 @@ impl App {
} }
} }
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> { pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?; if self.page != Page::Behavior {
(self.page == Page::Behavior return None;
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario) }
.then_some(crate::behavior::scenario::Context { let scenario = if matches!(
document: self.canvas_document, self.behavior.scenario.representation(),
scenario, Some(behavior::scenario_layout::Representation::Saved { .. })
revision: self.document.revision(), ) {
epoch: self.canvas_epoch, 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<crate::behavior::scenario::focus::Stamp> { pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy if self.busy
@@ -1759,10 +1842,7 @@ impl App {
.active_diagram .active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id)); .is_some_and(|id| self.saved_scenario_diagram(id));
match message { match message {
Message::Diagram(choice) => self.saved_scenario_diagram(choice.id), Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
Message::RevealInDiagram(diagram, _) | Message::Command(Command::Layout(diagram)) => {
self.saved_scenario_diagram(*diagram)
}
Message::Projection(projection) => active || projection.scenario().is_some(), Message::Projection(projection) => active || projection.scenario().is_some(),
Message::SelectNode(_) Message::SelectNode(_)
| Message::PlaceSelected | Message::PlaceSelected
@@ -1811,6 +1891,8 @@ impl App {
if self.scenario_context().is_some_and(|current| { if self.scenario_context().is_some_and(|current| {
current.document == context.document current.document == context.document
&& current.scenario == context.scenario && current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision && current.revision == context.revision
}) && self.scenario_focus_owner().is_some() }) && self.scenario_focus_owner().is_some()
&& self.behavior.scenario.take_form_focus(mount) && self.behavior.scenario.take_form_focus(mount)
@@ -1823,6 +1905,8 @@ impl App {
if self.scenario_context().is_some_and(|current| { if self.scenario_context().is_some_and(|current| {
current.document == context.document current.document == context.document
&& current.scenario == context.scenario && current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision && current.revision == context.revision
}) && self.behavior.scenario.viewport(size) }) && self.behavior.scenario.viewport(size)
{ {
@@ -1832,7 +1916,10 @@ impl App {
} }
if let CanvasEvent::Restored(_, token, area, offset) = event { if let CanvasEvent::Restored(_, token, area, offset) = event {
if self.scenario_context().is_some_and(|current| { 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 }) && self
.behavior .behavior
.scenario .scenario
@@ -1849,6 +1936,8 @@ impl App {
if self.scenario_context().is_some_and(|current| { if self.scenario_context().is_some_and(|current| {
current.document == context.document current.document == context.document
&& current.scenario == context.scenario && current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision && current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset) }) && self.behavior.scenario.scrolled(area, offset)
{ {
@@ -1862,6 +1951,8 @@ impl App {
|| !self.scenario_context().is_some_and(|c| { || !self.scenario_context().is_some_and(|c| {
c.document == context.document c.document == context.document
&& c.scenario == context.scenario && c.scenario == context.scenario
&& c.representation == context.representation
&& c.visit == context.visit
&& c.revision == context.revision && c.revision == context.revision
}) })
{ {
@@ -1870,6 +1961,7 @@ impl App {
let scope = canvas_workspace::Scope { let scope = canvas_workspace::Scope {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision, revision: context.revision,
epoch: lifetime, epoch: lifetime,
}; };
@@ -1892,7 +1984,10 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1) self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
} }
input => { 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); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
} }
} }
@@ -1903,6 +1998,32 @@ impl App {
return Task::none(); return Task::none();
} }
match event { 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(_) => { CanvasEvent::Focus(_) => {
self.behavior.scenario.toggle_focus(); self.behavior.scenario.toggle_focus();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -1931,7 +2052,11 @@ impl App {
Ok(target) => { Ok(target) => {
// A command can preselect this target while the // A command can preselect this target while the
// caller remains visible, bypassing select below. // 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.select(target);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
} }
@@ -1953,7 +2078,7 @@ impl App {
// A command may already have selected this UUID while // A command may already have selected this UUID while
// leaving the referenced definition open. select's // leaving the referenced definition open. select's
// same-UUID fast path must not keep that old owner. // 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.select(caller.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(context) = self.scenario_context() { if let Some(context) = self.scenario_context() {
@@ -1981,6 +2106,15 @@ impl App {
} }
} }
CanvasEvent::Choose(_, tool) => { 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() if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{ {
self.status = self.status =
@@ -1992,6 +2126,17 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
} }
mutation => { 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() if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{ {
self.status = self.status =
@@ -2092,6 +2237,13 @@ impl App {
}) })
{ {
self.behavior.active_owner = Some(choice.id); 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.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(choice.id); self.select(choice.id);
@@ -2457,6 +2609,15 @@ impl App {
crate::behavior::selected_behavior(self.project(), self.selected) crate::behavior::selected_behavior(self.project(), self.selected)
{ {
self.behavior.active_owner = Some(owner); 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.behavior.active_owner = crate::behavior::selected_owner(self);
self.reset_state_view_after_navigation(previous_state_view); self.reset_state_view_after_navigation(previous_state_view);
@@ -2693,6 +2854,7 @@ impl App {
} }
Message::Layer(layer) => { Message::Layer(layer) => {
if self.apply() { if self.apply() {
self.behavior.scenario.forget_view();
let previous_state_view = self.state_view_identity(); let previous_state_view = self.state_view_identity();
self.layer = layer; self.layer = layer;
self.create_kind = if layer == Layer::Oa { self.create_kind = if layer == Layer::Oa {
@@ -2865,6 +3027,9 @@ impl App {
} }
} }
Message::RevealInDiagram(diagram, selected) => { Message::RevealInDiagram(diagram, selected) => {
if self.saved_scenario_diagram(diagram) {
return self.reveal_saved_scenario(diagram, selected);
}
let original = self.selected; let original = self.selected;
if !self.apply() { if !self.apply() {
return Task::none(); return Task::none();
@@ -3096,6 +3261,16 @@ impl App {
self.status = "Diagram no longer exists.".into(); self.status = "Diagram no longer exists.".into();
return Task::none(); 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 previous_diagram = self.active_diagram;
let follow_view = previous_diagram == Some(self.selected); let follow_view = previous_diagram == Some(self.selected);
if self.apply() { if self.apply() {
@@ -3115,7 +3290,14 @@ impl App {
} }
self.selected_node = None; self.selected_node = None;
self.refresh_draft(); 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) => { Message::Projection(projection) => {
@@ -5748,12 +5930,17 @@ mod saved_scenario_dispatch_tests {
} }
#[test] #[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] { for reveal in [false, true] {
let (mut app, architecture, saved, owner) = fixture(); let (mut app, architecture, saved, owner) = fixture();
let _ = app.update(Message::Field( let _ = app.update(Message::Field(
"/name".into(), if reveal { "/name" } else { "/id" }.into(),
"Unapplied scenario name".into(), if reveal {
"Unapplied scenario name"
} else {
"-"
}
.into(),
)); ));
assert!(app.draft_dirty()); assert!(app.draft_dirty());
let before = app.document.bundle().clone(); 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.active_diagram, Some(architecture));
assert_eq!(app.selected, selected); assert_eq!(app.selected, selected);
assert!(app.draft_dirty()); assert!(app.draft_dirty());
assert_eq!(app.status, SAVED_SCENARIO_UNAVAILABLE);
} }
} }
+17 -2
View File
@@ -1,5 +1,6 @@
//! Complete a navigation camera after the destination reports its real viewport. //! Complete a navigation camera after the destination reports its real viewport.
use super::*; use super::*;
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::{Context, Surface}; use crate::diagram_usage::{Context, Surface};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -47,11 +48,23 @@ impl App {
&& c.epoch == *epoch, && c.epoch == *epoch,
), ),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => ( 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, *size,
c.document == context.document c.document == context.document
&& c.revision == context.revision && 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)) => ( Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram), Surface::Saved(c.diagram),
@@ -91,6 +104,7 @@ impl App {
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) | Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) { ) {
self.pending_diagram_reveal = None; self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
if !Self::is_navigation_camera(message) { if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None; self.diagram_navigation_focus = None;
} }
@@ -142,5 +156,6 @@ impl App {
self.reveal_diagram_usage(&usage); self.reveal_diagram_usage(&usage);
} }
self.pending_diagram_reveal = None; self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
} }
} }
@@ -144,6 +144,7 @@ fn measurement(
epoch: u64, epoch: u64,
surface: Surface, surface: Surface,
size: Size, size: Size,
visit: Id,
) -> Message { ) -> Message {
match canvas { match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size), Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
@@ -160,6 +161,10 @@ fn measurement(
behavior::scenario::Context { behavior::scenario::Context {
document: context.document, document: context.document,
scenario: surface.id(), scenario: surface.id(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
revision: context.revision, revision: context.revision,
epoch, epoch,
}, },
@@ -177,12 +182,16 @@ fn measurement(
} }
} }
fn stamp(app: &App) -> (Context, u64) { fn stamp(app: &App) -> (Context, u64, Id) {
let (context, _, epoch) = app let (context, _, epoch) = app
.pending_diagram_reveal .pending_diagram_reveal
.as_ref() .as_ref()
.expect("reveal awaits measured destination"); .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) { 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.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL); expected.viewport(ACTUAL);
expected.zoom(Some(zoom)); 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()); 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(), app.document.dirty(),
); );
assert!(app.reveal_diagram_usage(&target)); assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app); let (context, epoch, visit) = stamp(&app);
send( send(
&mut app, &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.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target); 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(); let camera = app.camera_boundary();
send( send(
&mut app, &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.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera); 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 { for invalid in 0..6 {
let (mut app, target) = fixture(canvas); let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target)); assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app); let (context, epoch, visit) = stamp(&app);
let camera = app.camera_boundary(); let camera = app.camera_boundary();
let mut stale = context; let mut stale = context;
let mut wrong_epoch = epoch; let mut wrong_epoch = epoch;
@@ -290,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
} }
send( send(
&mut app, &mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size), measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
); );
assert!( assert!(
app.pending_diagram_reveal.is_some(), app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}" "{canvas:?} invalid case {invalid}"
); );
assert_eq!(stamp(&app), (context, epoch)); assert_eq!(stamp(&app), (context, epoch, visit));
assert_eq!(app.canvas_epoch, epoch); assert_eq!(app.canvas_epoch, epoch);
assert!( assert!(
app.camera_boundary() == camera, app.camera_boundary() == camera,
@@ -304,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
); );
send( send(
&mut app, &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.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target); 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 (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot(); let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target)); 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.))); 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!(current, context);
assert_eq!(epoch, app.canvas_epoch); assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) { 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(); let camera = app.camera_boundary();
send( send(
&mut app, &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); assert!(app.camera_boundary() == camera);
} }
send( send(
&mut app, &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!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target); 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 (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot(); let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target)); assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app); let (context, epoch, visit) = stamp(&app);
let event = match action { let event = match action {
0 => match canvas { 0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)), 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. // restore the old navigation camera after deliberate user input.
send( send(
&mut app, &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!(app.pending_diagram_reveal.is_none());
assert!( assert!(
+99 -18
View File
@@ -1,5 +1,6 @@
//! Navigation-only adapter: validates destinations before changing presentation. //! Navigation-only adapter: validates destinations before changing presentation.
use super::*; use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage}; use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
@@ -58,10 +59,6 @@ impl App {
let process = presentation.into_query_state(); let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default(); let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario) 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 .await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again.")); .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. // Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact. // Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None; self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
self.diagram_navigation_focus = None; self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([ return Task::batch([
@@ -290,14 +288,78 @@ impl App {
Task::none() 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<Message> {
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 { pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() { if !self.usage_navigation_available() {
return false; return false;
} }
if matches!(usage.surface, Surface::Saved(id) if self.saved_scenario_diagram(id)) {
return false;
}
let previous_layer = self.layer; 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 p = self.document.project();
let (selected, placement) = match &usage.target { let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None), Target::Whole => (usage.surface.id(), None),
@@ -337,7 +399,10 @@ impl App {
}; };
self.layer = diagram.layer; self.layer = diagram.layer;
self.active_diagram = Some(id); 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( let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.), (self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.), (self.window_size.height - 320.).max(240.),
@@ -379,19 +444,14 @@ impl App {
self.page = Page::Behavior; self.page = Page::Behavior;
} }
Surface::Scenario(owner) => { 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.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner); self.behavior.active_owner = Some(owner);
self.page = Page::Behavior; self.page = Page::Behavior;
} }
} }
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
self.selected = selected; self.selected = selected;
self.selected_node = placement; self.selected_node = placement;
self.model_menu = None; self.model_menu = None;
@@ -418,6 +478,18 @@ impl App {
usage: usage.clone(), usage: usage.clone(),
selected, 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(( self.pending_diagram_reveal = Some((
Context { Context {
document: self.canvas_document, document: self.canvas_document,
@@ -437,12 +509,21 @@ impl App {
let current_surface = match surface { let current_surface = match surface {
Surface::Saved(id) => { Surface::Saved(id) => {
self.active_diagram == Some(id) self.active_diagram == Some(id)
&& !self.saved_scenario_diagram(id)
&& matches!(self.page, Page::Diagram | Page::Behavior) && 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) 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 (current_surface
&& focus.usage.surface == surface && focus.usage.surface == surface
@@ -1401,3 +1401,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
assert_eq!(location(&app), prior); assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before); assert_eq!(authored(&app), before);
} }
mod saved_scenario;
@@ -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; }
];
}; };
}
}
@@ -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);
}
@@ -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;
@@ -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);
}
@@ -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);
}
@@ -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());
}
@@ -24,6 +24,7 @@ impl App {
if self.behavior.has_pending() { if self.behavior.has_pending() {
return; return;
} }
self.behavior.scenario.forget_view();
self.layer = layer; self.layer = layer;
self.page = Page::Behavior; self.page = Page::Behavior;
self.behavior.active_owner = Some(machine); self.behavior.active_owner = Some(machine);
+74 -20
View File
@@ -62,9 +62,18 @@ mod scenario_scroll {
process: bool, process: bool,
restore: Option<scenario::ScrollRestore>, restore: Option<scenario::ScrollRestore>,
} }
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 { struct ObservedScroll {
identity: (Id, Id), identity: Identity,
report: Report, report: Report,
restored: Option<Id>, restored: Option<Id>,
} }
@@ -115,8 +124,12 @@ mod scenario_scroll {
) { ) {
let restore = self.restore.filter(|request| { let restore = self.restore.filter(|request| {
!self.process !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::<ObservedScroll>().restored != Some(request.token) && tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
}); });
let receipt = restore.and_then(|request| { let receipt = restore.and_then(|request| {
@@ -140,12 +153,7 @@ mod scenario_scroll {
&mut offset, &mut offset,
); );
if let Some(offset) = offset.0 { if let Some(offset) = offset.0 {
let next = Some(( let next = Some((self.process, identity(self.context), offset));
self.process,
self.context.document,
self.context.scenario,
offset,
));
let state = tree.state.downcast_mut::<ObservedScroll>(); let state = tree.state.downcast_mut::<ObservedScroll>();
if let Some(token) = receipt { if let Some(token) = receipt {
state.restored = Some(token); state.restored = Some(token);
@@ -189,7 +197,7 @@ mod scenario_scroll {
} }
fn state(&self) -> widget::tree::State { fn state(&self) -> widget::tree::State {
widget::tree::State::new(ObservedScroll { widget::tree::State::new(ObservedScroll {
identity: (self.context.document, self.context.scenario), identity: identity(self.context),
report: None, report: None,
restored: None, restored: None,
}) })
@@ -198,9 +206,7 @@ mod scenario_scroll {
vec![widget::Tree::new(self.content.as_widget())] vec![widget::Tree::new(self.content.as_widget())]
} }
fn diff(&self, tree: &mut widget::Tree) { fn diff(&self, tree: &mut widget::Tree) {
if tree.state.downcast_ref::<ObservedScroll>().identity if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
!= (self.context.document, self.context.scenario)
{
// A new owner/document needs fresh real scroll state, not merely // A new owner/document needs fresh real scroll state, not merely
// cleared mirrored offsets. Edits and tool/form changes retain it. // cleared mirrored offsets. Edits and tool/form changes retain it.
tree.state = self.state(); tree.state = self.state();
@@ -489,17 +495,25 @@ mod scenario_scroll {
} }
#[test] #[test]
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() { 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 context = scenario::direct_tests::context(Id::new_v4());
let mut request = scenario::ScrollRestore { let mut request = scenario::ScrollRestore {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
representation: context.representation,
visit: context.visit,
token: Id::new_v4(), token: Id::new_v4(),
offset: Vector::new(0., 180.), offset: Vector::new(0., 180.),
}; };
match mismatch { match mismatch {
0 => request.document = Id::new_v4(), 0 => request.document = Id::new_v4(),
1 => request.owner = 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); let mut view = observer(600., context);
@@ -586,7 +600,7 @@ mod scenario_scroll {
use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] { for process in [false, true] {
let area = scenario::ScrollArea::Page; 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 context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context); let mut view = observer(600., context);
view.process = process; view.process = process;
@@ -615,6 +629,12 @@ mod scenario_scroll {
next.revision += 1; next.revision += 1;
next.epoch += 5; 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); view = observer(if change == 3 { 800. } else { 600. }, next);
@@ -662,7 +682,7 @@ mod scenario_scroll {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!( assert_eq!(
actual, actual,
vec![if change < 2 { vec![if change < 2 || change >= 4 {
Vector::ZERO Vector::ZERO
} else { } else {
Vector::new(0., 180.) 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(); 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 let choices = p
.elements .elements
.values() .values()
@@ -987,7 +1020,14 @@ pub fn view(app: &App) -> Element<'_, Message> {
label: format!("{} · {}", e.kind(), e.name), label: format!("{} · {}", e.kind(), e.name),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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 let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id)) .and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first()) .or(choices.first())
@@ -1180,6 +1220,7 @@ struct Scene {
scale: f32, scale: f32,
camera_pan: iced::Vector, camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>, pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>, lanes: BTreeMap<Id, f32>,
// Exact render coordinates include unassigned lanes; do not derive from cards. // Exact render coordinates include unassigned lanes; do not derive from cards.
@@ -1252,6 +1293,7 @@ impl Scene {
layout_origin: Point::ORIGIN, layout_origin: Point::ORIGIN,
selected, selected,
interaction: None, interaction: None,
coordinate_input: true,
pending_placement: None, pending_placement: None,
lanes: BTreeMap::new(), lanes: BTreeMap::new(),
render_lanes: BTreeMap::new(), render_lanes: BTreeMap::new(),
@@ -1857,7 +1899,7 @@ impl canvas::Program<Message> for Scene {
state.pointer = None; state.pointer = None;
} }
state.bounds = Some(bounds); state.bounds = Some(bounds);
if !editable { if !editable || !self.coordinate_input {
state.cancel(); state.cancel();
} }
// The viewport masks input outside its visible canvas bounds. // The viewport masks input outside its visible canvas bounds.
@@ -1925,6 +1967,14 @@ impl canvas::Program<Message> for Scene {
if !editable { if !editable {
return None; 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 { if let CanvasTool::Message(_) = tool {
let first = state.source.is_none(); let first = state.source.is_none();
let (source, slot) = state.source.or_else(|| { let (source, slot) = state.source.or_else(|| {
@@ -2252,7 +2302,10 @@ impl canvas::Program<Message> for Scene {
self.lifecycle self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan); .draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction { 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| { let point = cursor.position_in(bounds).map(|p| {
Point::new( Point::new(
(p.x - self.camera_pan.x) / self.scale, (p.x - self.camera_pan.x) / self.scale,
@@ -2268,6 +2321,7 @@ impl canvas::Program<Message> for Scene {
} }
}); });
if editable if editable
&& self.coordinate_input
&& tool == scenario::CanvasTool::Select && tool == scenario::CanvasTool::Select
&& let Some(handle) = self.reorder_handle() && let Some(handle) = self.reorder_handle()
{ {
@@ -50,7 +50,7 @@ pub fn controls<'a>(
tooltip::Position::Bottom), tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))), control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), 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("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))), control("ActualSize", "100%", Some(zoom(Some(1.)))),
].spacing(4).align_y(iced::Center).into() ].spacing(4).align_y(iced::Center).into()
@@ -58,6 +58,7 @@ pub fn controls<'a>(
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope { pub struct Scope {
pub scenario_view: Option<(super::scenario_layout::Representation, Id)>,
pub document: Id, pub document: Id,
pub owner: Id, pub owner: Id,
pub revision: u64, pub revision: u64,
@@ -69,6 +70,7 @@ impl Scope {
Some(Self { Some(Self {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision, revision: context.revision,
epoch: app.camera_epoch, epoch: app.camera_epoch,
}) })
@@ -77,6 +79,7 @@ impl Scope {
Some(Self { Some(Self {
document: context.document, document: context.document,
owner: context.owner, owner: context.owner,
scenario_view: None,
revision: context.revision, revision: context.revision,
epoch: app.camera_epoch, epoch: app.camera_epoch,
}) })
@@ -96,8 +99,8 @@ impl Scope {
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!( iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}", "behavior-control:{}:{}:{}:{:?}:{name}",
self.document, self.owner, self.revision 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}; pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit. /// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool { pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, 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<f32>,
fit: &mut bool,
event: Event,
minimum: f32,
) -> bool {
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
return false;
}
match event { match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta; let next = *pan + delta;
@@ -150,7 +167,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
&& at.y.is_finite() => && at.y.is_finite() =>
{ {
let old = if *fit { base } else { zoom.unwrap_or(base) }; 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 { if old == next {
return false; return false;
} }
@@ -168,6 +188,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, 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. /// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>( pub fn palette<'a>(
scope: Scope, scope: Scope,
@@ -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() { fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space; use iced::widget::Space;
let scope = Scope { let scope = Scope {
scenario_view: None,
document: Id::new_v4(), document: Id::new_v4(),
owner: Id::new_v4(), owner: Id::new_v4(),
revision: 2, revision: 2,
@@ -427,6 +428,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope { Scope {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision, revision: context.revision,
epoch: app.camera_epoch, epoch: app.camera_epoch,
} }
@@ -435,6 +437,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope { Scope {
document: context.document, document: context.document,
owner: context.owner, owner: context.owner,
scenario_view: None,
revision: context.revision, revision: context.revision,
epoch: app.camera_epoch, epoch: app.camera_epoch,
} }
+161 -22
View File
@@ -4,7 +4,14 @@ use super::topology::{occurrence, unique_batch};
use super::*; use super::*;
use iced::widget::{Column, column, text_input}; use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet}; use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus; pub(crate) mod focus;
use super::scenario_layout::{
Representation,
presentation::{self, ScenarioPresentation},
};
#[cfg(test)]
mod presentation_tests;
mod usage; mod usage;
mod workspace; mod workspace;
pub(crate) use workspace::NAME_INPUT; pub(crate) use workspace::NAME_INPUT;
@@ -71,6 +78,8 @@ impl std::fmt::Display for MessageStyle {
pub struct Context { pub struct Context {
pub document: Id, pub document: Id,
pub scenario: Id, pub scenario: Id,
pub representation: Representation,
pub visit: Id,
pub revision: u64, pub revision: u64,
pub epoch: u64, pub epoch: u64,
} }
@@ -108,6 +117,7 @@ pub enum CanvasEvent {
OpenUse(Context, Id), OpenUse(Context, Id),
Back(Context), Back(Context),
Choose(Context, CanvasTool), Choose(Context, CanvasTool),
Representation(Context, Representation),
Select(Context, Id), Select(Context, Id),
Begin { Begin {
context: Context, context: Context,
@@ -147,6 +157,7 @@ impl CanvasEvent {
| Self::OpenUse(context, _) | Self::OpenUse(context, _)
| Self::Back(context) | Self::Back(context)
| Self::Choose(context, _) | Self::Choose(context, _)
| Self::Representation(context, _)
| Self::Select(context, _) | Self::Select(context, _)
| Self::Begin { context, .. } | Self::Begin { context, .. }
| Self::BeginOccurrence { context, .. } | Self::BeginOccurrence { context, .. }
@@ -218,6 +229,7 @@ const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller { pub(crate) struct Caller {
document: Id, document: Id,
pub owner: Id, pub owner: Id,
pub representation: Representation,
pub occurrence: Id, pub occurrence: Id,
target: Id, target: Id,
tool: CanvasTool, tool: CanvasTool,
@@ -227,6 +239,8 @@ pub(crate) struct Caller {
struct DetailsRestore { struct DetailsRestore {
document: Id, document: Id,
owner: Id, owner: Id,
representation: Representation,
visit: Id,
token: Id, token: Id,
offset: iced::Vector, offset: iced::Vector,
} }
@@ -234,6 +248,8 @@ struct DetailsRestore {
pub(super) struct ScrollRestore { pub(super) struct ScrollRestore {
pub document: Id, pub document: Id,
pub owner: Id, pub owner: Id,
pub representation: Representation,
pub visit: Id,
pub token: Id, pub token: Id,
pub offset: iced::Vector, pub offset: iced::Vector,
} }
@@ -327,42 +343,146 @@ impl State {
} }
pub(crate) fn enter(&mut self, document: Id, owner: Id) { 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 { self.presentation = workspace::Presentation {
identity: Some((document, owner)), identity: Some((document, owner)),
representation: Some(representation),
focused: self.presentation.focused, focused: self.presentation.focused,
fit: matches!(representation, Representation::Saved { .. }),
..Default::default() ..Default::default()
}; };
self.details_offset = iced::Vector::ZERO; 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<Representation> {
self.presentation.representation
}
pub(crate) fn owner(&self) -> Option<Id> {
self.presentation.identity.map(|(_, owner)| owner)
}
pub(crate) fn context(
&self,
document: Id,
owner: Id,
revision: u64,
epoch: u64,
) -> Option<Context> {
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<ScenarioPresentation> {
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 { pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size) 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; 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<f32>) -> bool { pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale) self.presentation.zoom(scale)
} }
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool { pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let size = Scene::sequence(p, owner, owner).size; let Some(bounds) = self
let old = self.presentation.scale(size); .display(p, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
if let Some(next) = zoom { if let Some(next) = zoom {
let current = self.presentation.camera(bounds);
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::canvas_workspace::Event::Zoom { self.camera(
factor: next / old, p,
at: Point::new(viewport.width / 2., viewport.height / 2.), owner,
base: old, super::canvas_workspace::Event::Zoom {
}) factor: next / current.scale,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: current.scale,
},
)
} else { } else {
self.zoom(None); self.zoom(None)
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
} }
} }
pub(crate) fn focused(&self) -> bool { pub(crate) fn focused(&self) -> bool {
@@ -390,6 +510,7 @@ impl State {
self.return_to.push(Caller { self.return_to.push(Caller {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
representation: context.representation,
occurrence, occurrence,
target, target,
tool: self.canvas_tool(context.scenario), tool: self.canvas_tool(context.scenario),
@@ -404,6 +525,7 @@ impl State {
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool { fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
frame.document == context.document frame.document == context.document
&& frame.target == context.scenario && frame.target == context.scenario
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target) && referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
} }
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool { pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
@@ -420,16 +542,23 @@ impl State {
None None
} }
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) { 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; return;
} }
self.canvas_tool = Some((frame.owner, frame.tool)); self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset; self.details_offset = frame.details_offset;
self.presentation = frame.presentation; self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario)); self.presentation.identity = Some((context.document, context.scenario));
self.presentation.representation = Some(frame.representation);
self.presentation.visit = context.visit;
self.restore = Some(DetailsRestore { self.restore = Some(DetailsRestore {
document: context.document, document: context.document,
owner: frame.owner, owner: frame.owner,
representation: frame.representation,
visit: context.visit,
token: Id::new_v4(), token: Id::new_v4(),
offset: frame.details_offset, offset: frame.details_offset,
}); });
@@ -439,13 +568,17 @@ impl State {
context: Context, context: Context,
ScrollArea::Page: ScrollArea, ScrollArea::Page: ScrollArea,
) -> Option<ScrollRestore> { ) -> Option<ScrollRestore> {
let restore = self let restore = self.restore.as_ref().filter(|r| {
.restore r.document == context.document
.as_ref() && r.owner == context.scenario
.filter(|r| r.document == context.document && r.owner == context.scenario)?; && r.representation == context.representation
&& r.visit == context.visit
})?;
Some(ScrollRestore { Some(ScrollRestore {
document: restore.document, document: restore.document,
owner: restore.owner, owner: restore.owner,
representation: restore.representation,
visit: restore.visit,
token: restore.token, token: restore.token,
offset: restore.offset, offset: restore.offset,
}) })
@@ -458,7 +591,11 @@ impl State {
offset: iced::Vector, offset: iced::Vector,
) -> bool { ) -> bool {
if !self.restore.as_ref().is_some_and(|r| { 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; return false;
} }
@@ -1931,6 +2068,8 @@ pub(crate) mod direct_tests {
Context { Context {
document: Id::new_v4(), document: Id::new_v4(),
scenario: owner, scenario: owner,
representation: Representation::Derived { owner },
visit: Id::new_v4(),
revision: 0, revision: 0,
epoch: 0, epoch: 0,
} }
@@ -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<Camera> {
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<f32>,
fitting: bool,
viewport: Option<Size>,
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<f32>,
fitting: &mut bool,
scale: Option<f32>,
) -> 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<f32>,
fitting: &mut bool,
viewport: Option<Size>,
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));
}
}
@@ -11,6 +11,8 @@ use std::collections::BTreeMap;
pub(crate) struct Stamp { pub(crate) struct Stamp {
document: ModelId, document: ModelId,
owner: ModelId, owner: ModelId,
representation: super::Representation,
visit: ModelId,
revision: u64, revision: u64,
session: ModelId, session: ModelId,
mount: ModelId, mount: ModelId,
@@ -20,6 +22,8 @@ impl Stamp {
Self { Self {
document: context.document, document: context.document,
owner: context.scenario, owner: context.scenario,
representation: context.representation,
visit: context.visit,
revision: context.revision, revision: context.revision,
session, session,
mount, mount,
@@ -28,10 +32,12 @@ impl Stamp {
} }
pub(crate) fn name_id(stamp: Stamp) -> Id { pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!( Id::from(format!(
"{}:{}:{}:{}:{}:{}", "{}:{}:{}:{:?}:{}:{}:{}:{}",
super::NAME_INPUT, super::NAME_INPUT,
stamp.document, stamp.document,
stamp.owner, stamp.owner,
stamp.representation,
stamp.visit,
stamp.revision, stamp.revision,
stamp.session, stamp.session,
stamp.mount stamp.mount
@@ -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);
}
@@ -1,5 +1,6 @@
//! Navigation is derived from the same opaque-reference scene that is painted. //! Navigation is derived from the same opaque-reference scene that is painted.
use super::*; use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{Surface, Target, Usage}; use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one /// 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 start < retained
} }
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> { fn usages(
p: &Project,
owner: Id,
subject: Id,
surface: Surface,
bounds: Rectangle,
scene: &Scene,
) -> Vec<Usage> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut add = |id, bounds, role: &str| { let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message; // 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<Usage> {
{ {
result.push(Usage { result.push(Usage {
subject, subject,
surface: Surface::Scenario(owner), surface,
target: Target::ScenarioObject(id), target: Target::ScenarioObject(id),
role: role.into(), role: role.into(),
bounds, bounds,
@@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
} }
} }
} }
if subject == owner { if subject == owner || subject == surface.id() {
result.insert( result.insert(
0, 0,
Usage { Usage {
subject, subject,
surface: Surface::Scenario(owner), surface,
target: Target::Whole, target: Target::Whole,
role: "scenario".into(), 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<Usage> {
} }
impl State { impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> { /// Membership comes from the complete presentation chosen for this exact view,
if !p /// including automatic fallback geometry while retaining its Saved identity.
.elements pub(crate) fn representation_usages(
.get(&owner) &self,
.is_some_and(|e| e.kind() == Kind::Scenario) p: &Project,
{ representation: Representation,
subject: Id,
) -> Vec<Usage> {
let shown = presentation::present(p, representation, subject);
let (Some(owner), Some(bounds), Some(scene)) =
(shown.owner(), shown.bounds(), shown.scene())
else {
return vec![]; 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<Usage> {
self.representation_usages(p, Representation::Derived { owner }, subject)
} }
pub(crate) fn reveal_usage( pub(crate) fn reveal_usage(
@@ -171,12 +193,24 @@ impl State {
owner: Id, owner: Id,
target: &Target, target: &Target,
) -> bool { ) -> bool {
if self.draft.is_some() self.reveal_representation_usage(
|| !p p,
.elements document,
.get(&owner) owner,
.is_some_and(|e| e.kind() == Kind::Scenario) 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; return false;
} }
let id = match target { let id = match target {
@@ -185,7 +219,7 @@ impl State {
_ => return false, _ => return false,
}; };
let Some(usage) = self let Some(usage) = self
.diagram_usages(p, owner, id) .representation_usages(p, representation, id)
.into_iter() .into_iter()
.find(|u| &u.target == target) .find(|u| &u.target == target)
else { else {
@@ -193,11 +227,29 @@ impl State {
}; };
// Preserve an existing mounted viewport across enter's owner reset. // Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); 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.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select)); self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); self.presentation.zoom(zoom);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan; self.presentation.pan = pan;
true true
} }
@@ -13,6 +13,8 @@ pub enum Shelf {
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub(super) struct Presentation { pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>, pub(super) identity: Option<(Id, Id)>,
pub(super) representation: Option<Representation>,
pub(super) visit: Id,
pub(super) viewport: Option<Size>, pub(super) viewport: Option<Size>,
pub(super) focused: bool, pub(super) focused: bool,
pub(super) shelf: Option<Shelf>, pub(super) shelf: Option<Shelf>,
@@ -20,7 +22,6 @@ pub(super) struct Presentation {
pub(super) zoom: Option<f32>, pub(super) zoom: Option<f32>,
pub(super) fit: bool, pub(super) fit: bool,
} }
const MIN_SCALE: f32 = 0.05;
impl Presentation { impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool { pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite() if !size.width.is_finite()
@@ -35,29 +36,26 @@ impl Presentation {
true true
} }
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool { pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) { camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
} }
fn fit_scale(&self, scene: Size) -> Option<f32> { pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
let viewport = self.viewport?; camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
} }
pub(super) fn scale(&self, scene: Size) -> f32 { // Legacy tests call size-only scale; actual display always passes full bounds.
if self.fit { #[cfg(test)]
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.) pub(super) fn scale(&self, size: Size) -> f32 {
} else { self.camera(Rectangle::new(Point::ORIGIN, size)).scale
self.zoom.unwrap_or(1.) }
} }
#[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 .draft
.as_ref() .as_ref()
.is_some_and(|draft| draft.owner == owner); .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 tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected); let shown = presentation::present(p, context.representation, app.selected);
let scale = presentation.scale(scene.size); let capabilities = shown.capabilities();
scene.scale = scale; let coordinate_input = clean && capabilities.coordinate_input;
scene.camera_pan = presentation.pan; let notice = display_notice(p, &shown);
scene.interaction = Some((context, tool, clean)); let camera_state = shown.bounds().map(|bounds| presentation.camera(bounds));
scene.pending_placement = state.pending_placement(owner); let scale = camera_state.map_or(1., |camera| camera.scale);
let scope = super::super::canvas_workspace::Scope { let scope = super::super::canvas_workspace::Scope {
document: context.document, document: context.document,
owner, owner,
revision: context.revision, revision: context.revision,
epoch: app.camera_epoch, 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 camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container( let canvas: Element<'a, Message> = if let Some(mut scene) = shown.into_scene() {
sensor( let camera_state = camera_state.expect("displayed Scene has complete bounds");
super::super::canvas_workspace::Viewport::new( scene.scale = camera_state.scale;
canvas::Canvas::new(scene).width(Fill).height(Fill), scene.camera_pan = camera_state.pan;
scope, scene.interaction = Some((context, tool, clean));
scale, scene.coordinate_input = capabilities.coordinate_input;
keyboard, scene.pending_placement = coordinate_input
camera, .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)) .id("scenario-viewport")
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) .width(Fill)
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), .height(Fill)
) .style(style::card)
.id("scenario-viewport") .into()
.width(Fill) } else {
.height(Fill) container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.style(style::card) .id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
.into(); };
let mut navigation = row![ let mut navigation = row![
out.width(Fill), out.width(Fill),
super::super::canvas_workspace::Control::new( 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))), clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
)); ));
} }
let header = column![ let mut header = column![navigation, notice].spacing(4);
navigation, let saved_views = p
super::super::canvas_workspace::controls( .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::<Vec<_>>();
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, scope,
keyboard, keyboard,
scale, 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( clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
context, context,
scope.epoch, scope.epoch,
super::super::canvas_workspace::Event::Focus super::super::canvas_workspace::Event::Focus,
))), ))),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)), move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera camera,
) ));
] }
.spacing(4);
let entries = [ let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select), ("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, glyph,
label, label,
choice == tool, choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose( (clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
context, choice, .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) 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>( fn sequence_action<'a>(
title: &str, title: &str,
message: Message, message: Message,
@@ -1760,14 +1760,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
assert!(!presentation.viewport(Size::new(484., 204.))); assert!(!presentation.viewport(Size::new(484., 204.)));
let scale = presentation.scale(graph); let scale = presentation.scale(graph);
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); 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.] { for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
assert!(!presentation.zoom(Some(bad))); assert!(!presentation.zoom(Some(bad)));
assert!(!presentation.viewport(Size::new(bad, 100.))); assert!(!presentation.viewport(Size::new(bad, 100.)));
assert!(!presentation.viewport(Size::new(100., bad))); assert!(!presentation.viewport(Size::new(100., bad)));
assert_eq!(presentation.scale(graph), scale); 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))); assert!(presentation.zoom(Some(1.25)));
presentation.viewport(Size::new(200., 100.)); presentation.viewport(Size::new(200., 100.));
assert_eq!( assert_eq!(
@@ -160,7 +160,7 @@ pub(crate) fn present(
} }
} }
fn checked_owner(project: &Project, requested: Representation) -> Option<Id> { pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
let owner = match requested { let owner = match requested {
Representation::Derived { owner } => owner, Representation::Derived { owner } => owner,
Representation::Saved { diagram } => { Representation::Saved { diagram } => {
@@ -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)); graph.interaction = Some((context, state.tool, clean));
let scale = graph.scale; let scale = graph.scale;
let scope = super::super::canvas_workspace::Scope { let scope = super::super::canvas_workspace::Scope {
scenario_view: None,
document: context.document, document: context.document,
owner, owner,
revision: context.revision, revision: context.revision,
+31
View File
@@ -170,6 +170,13 @@ impl Surface {
{ {
"State views" "State views"
} }
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.scenario().is_some()) =>
{
"Scenarios"
}
Self::Saved(_) => "Architecture", Self::Saved(_) => "Architecture",
Self::Process(_) => "Processes and chains", Self::Process(_) => "Processes and chains",
Self::Scenario(_) => "Scenarios", Self::Scenario(_) => "Scenarios",
@@ -276,6 +283,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) { if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
return Vec::new(); return Vec::new();
} }
if let Some(diagram) = only_diagram
.and_then(|id| p.diagrams.get(&id))
.filter(|d| d.projection.scenario().is_some())
{
return crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
);
}
let index = ViewIndex::new(p); let index = ViewIndex::new(p);
let mut usages = Vec::new(); let mut usages = Vec::new();
for diagram in p for diagram in p
@@ -283,6 +302,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
.values() .values()
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id)) .filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
{ {
if diagram.projection.scenario().is_some() {
usages.extend(
crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
),
);
continue;
}
let view = projection::project_diagram_with_index(p, diagram, &index); let view = projection::project_diagram_with_index(p, diagram, &index);
let surface = Surface::Saved(diagram.id); let surface = Surface::Saved(diagram.id);
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) { if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {