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;
#[cfg(test)]
mod review_navigation_tests;
mod scenario_diagram;
pub(crate) mod source_rename;
mod source_symbols;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
mod view_copy_tests;
pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "Saved Scenario layout rendering and canvas editing are not available yet. Select the Scenario in Behavior to use its existing semantic preview.";
pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "This action uses architecture diagram tools and is unavailable for a saved Scenario. Open the view in Behavior; saved positions are preserved.";
use crate::forms::Draft;
use crate::{
behavior,
@@ -530,6 +531,7 @@ pub struct App {
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>,
pending_scenario_reveal_visit: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
@@ -651,6 +653,7 @@ impl App {
source_diagram_job: None,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
pending_scenario_reveal_visit: None,
model_issues,
migration: None,
source_version: 0,
@@ -665,6 +668,7 @@ impl App {
workflow: None,
};
app.route_state_diagram_workspace();
app.reconcile_scenario_workspace();
app.last_workspace_page = app.page;
app
}
@@ -775,6 +779,17 @@ impl App {
self.sync_with_alias_editor(None);
}
fn sync_with_alias_editor(&mut self, hint: Option<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.model_issues = validate(self.project());
self.normalize_creation();
@@ -879,6 +894,26 @@ impl App {
.map_err(|e| e.to_string())
}) {
Ok(id) => {
if self.behavior.scenario.representation()
== Some(behavior::scenario_layout::Representation::Saved { diagram: original })
&& id != original
{
let representation =
behavior::scenario_layout::Representation::Saved { diagram: id };
if let Some(owner) = behavior::scenario_layout::presentation::checked_owner(
self.project(),
representation,
) {
self.behavior.scenario.enter_representation(
self.canvas_document,
owner,
representation,
);
if self.page != Page::Behavior {
self.behavior.scenario.leave();
}
}
}
if self.active_diagram == Some(original) {
self.active_diagram = Some(id);
}
@@ -1083,6 +1118,17 @@ impl App {
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
{
self.behavior.active_owner = Some(owner);
if self.behavior.scenario.representation().is_some_and(|rep| {
behavior::scenario_layout::presentation::checked_owner(self.project(), rep)
!= Some(owner)
}) && self.project().elements[&owner].kind() == Kind::Scenario
{
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner },
);
} else if self.project().elements[&owner].kind() != Kind::Scenario {
self.behavior.scenario.forget_view();
}
}
if self.reset_state_view_after_navigation(previous_state_view)
&& self.page == Page::Behavior
@@ -1232,6 +1278,38 @@ impl App {
{
return Task::none();
}
// Prior view visits are inert before they can retire a newer reveal,
// camera lifetime or focus operation. Passive events may relax epoch,
// but never the destination identity.
if let Message::ScenarioCanvas(event) = &message {
let captured = event.context();
if self.scenario_context().is_none_or(|current| {
current.document != captured.document
|| current.scenario != captured.scenario
|| current.representation != captured.representation
|| current.visit != captured.visit
|| (!matches!(event, behavior::scenario::CanvasEvent::Restored(..))
&& current.revision != captured.revision)
}) {
return Task::none();
}
if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event
&& *lifetime != self.camera_epoch
{
return Task::none();
}
}
if let Message::BehaviorControl(intent) = &message
&& behavior::canvas_workspace::Scope::current(self).is_none_or(|scope| {
scope.document != intent.scope.document
|| scope.owner != intent.scope.owner
|| scope.scenario_view != intent.scope.scenario_view
|| scope.revision != intent.scope.revision
|| scope.epoch != intent.scope.epoch
})
{
return Task::none();
}
let usage_viewport = self.diagram_reveal_viewport(&message);
let window_resized = matches!(&message, Message::WindowResized(_));
// Reject stale measurements before legacy viewport handlers can change
@@ -1284,6 +1362,7 @@ impl App {
let valid_scenario = |c: behavior::scenario::Context| {
c.document == scope.document
&& c.scenario == scope.owner
&& scope.scenario_view == Some((c.representation, c.visit))
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
@@ -1667,6 +1746,7 @@ impl App {
self.refresh_source_rename_context();
self.reconcile_reference_chooser();
self.route_state_diagram_workspace();
self.reconcile_scenario_workspace();
if self.page != Page::Home {
self.last_workspace_page = self.page;
}
@@ -1675,11 +1755,6 @@ impl App {
.topology
.enter(context.document, context.owner);
}
if let Some(context) = self.scenario_context() {
self.behavior
.scenario
.enter(context.document, context.scenario);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1723,15 +1798,23 @@ impl App {
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
if self.page != Page::Behavior {
return None;
}
let scenario = if matches!(
self.behavior.scenario.representation(),
Some(behavior::scenario_layout::Representation::Saved { .. })
) {
self.behavior.scenario.owner()?
} else {
crate::behavior::selected_owner(self)?
};
self.behavior.scenario.context(
self.canvas_document,
scenario,
self.document.revision(),
self.canvas_epoch,
)
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
@@ -1759,10 +1842,7 @@ impl App {
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id));
match message {
Message::Diagram(choice) => self.saved_scenario_diagram(choice.id),
Message::RevealInDiagram(diagram, _) | Message::Command(Command::Layout(diagram)) => {
self.saved_scenario_diagram(*diagram)
}
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
Message::Projection(projection) => active || projection.scenario().is_some(),
Message::SelectNode(_)
| Message::PlaceSelected
@@ -1811,6 +1891,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.scenario_focus_owner().is_some()
&& self.behavior.scenario.take_form_focus(mount)
@@ -1823,6 +1905,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.behavior.scenario.viewport(size)
{
@@ -1832,7 +1916,10 @@ impl App {
}
if let CanvasEvent::Restored(_, token, area, offset) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document && current.scenario == context.scenario
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
}) && self
.behavior
.scenario
@@ -1849,6 +1936,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset)
{
@@ -1862,6 +1951,8 @@ impl App {
|| !self.scenario_context().is_some_and(|c| {
c.document == context.document
&& c.scenario == context.scenario
&& c.representation == context.representation
&& c.visit == context.visit
&& c.revision == context.revision
})
{
@@ -1870,6 +1961,7 @@ impl App {
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: lifetime,
};
@@ -1892,7 +1984,10 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
}
input => {
if self.behavior.scenario.camera(input) {
let mut state = std::mem::take(&mut self.behavior.scenario);
let changed = state.camera(self.project(), context.scenario, input);
self.behavior.scenario = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
@@ -1903,6 +1998,32 @@ impl App {
return Task::none();
}
match event {
CanvasEvent::Representation(_, requested) => {
use behavior::scenario_layout::{
Representation, presentation::checked_owner,
};
if checked_owner(self.project(), requested) != Some(context.scenario) {
return Task::none();
}
if !self.apply() {
return Task::none();
}
let requested = match requested {
Representation::Saved { diagram }
if context.representation
== (Representation::Saved { diagram }) =>
{
Representation::Saved {
diagram: self.active_diagram.unwrap_or(diagram),
}
}
Representation::Derived { owner } => Representation::Derived {
owner: self.behavior.active_owner.unwrap_or(owner),
},
other => other,
};
self.enter_scenario_representation(requested);
}
CanvasEvent::Focus(_) => {
self.behavior.scenario.toggle_focus();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -1931,7 +2052,11 @@ impl App {
Ok(target) => {
// A command can preselect this target while the
// caller remains visible, bypassing select below.
self.behavior.active_owner = Some(target);
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived {
owner: target,
},
);
self.select(target);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
@@ -1953,7 +2078,7 @@ impl App {
// A command may already have selected this UUID while
// leaving the referenced definition open. select's
// same-UUID fast path must not keep that old owner.
self.behavior.active_owner = Some(caller.owner);
self.enter_scenario_representation(caller.representation);
self.select(caller.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(context) = self.scenario_context() {
@@ -1981,6 +2106,15 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if tool != scenario::CanvasTool::Select
&& !self
.behavior
.scenario
.coordinate_input(self.project(), context)
{
self.status = "Canvas insertion and reordering are unavailable for this presentation.".into();
return Task::none();
}
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
@@ -1992,6 +2126,17 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if !behavior::scenario_layout::presentation::present(
self.project(),
context.representation,
self.selected,
)
.capabilities()
.coordinate_input
{
self.status = "Layout editing is unavailable for this presentation. Saved positions are preserved.".into();
return Task::none();
}
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
@@ -2092,6 +2237,13 @@ impl App {
})
{
self.behavior.active_owner = Some(choice.id);
if self.project().elements[&choice.id].kind() == Kind::Scenario {
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner: choice.id },
);
} else {
self.behavior.scenario.forget_view();
}
self.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(choice.id);
@@ -2457,6 +2609,15 @@ impl App {
crate::behavior::selected_behavior(self.project(), self.selected)
{
self.behavior.active_owner = Some(owner);
if self.project().elements[&owner].kind() == Kind::Scenario
&& self.behavior.scenario.owner() != Some(owner)
{
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner },
);
} else if self.project().elements[&owner].kind() != Kind::Scenario {
self.behavior.scenario.forget_view();
}
}
self.behavior.active_owner = crate::behavior::selected_owner(self);
self.reset_state_view_after_navigation(previous_state_view);
@@ -2693,6 +2854,7 @@ impl App {
}
Message::Layer(layer) => {
if self.apply() {
self.behavior.scenario.forget_view();
let previous_state_view = self.state_view_identity();
self.layer = layer;
self.create_kind = if layer == Layer::Oa {
@@ -2865,6 +3027,9 @@ impl App {
}
}
Message::RevealInDiagram(diagram, selected) => {
if self.saved_scenario_diagram(diagram) {
return self.reveal_saved_scenario(diagram, selected);
}
let original = self.selected;
if !self.apply() {
return Task::none();
@@ -3096,6 +3261,16 @@ impl App {
self.status = "Diagram no longer exists.".into();
return Task::none();
}
if self.saved_scenario_diagram(choice.id)
&& behavior::scenario_layout::presentation::checked_owner(
self.project(),
behavior::scenario_layout::Representation::Saved { diagram: choice.id },
)
.is_none()
{
self.status = "This Scenario view has an incompatible or missing owner.".into();
return Task::none();
}
let previous_diagram = self.active_diagram;
let follow_view = previous_diagram == Some(self.selected);
if self.apply() {
@@ -3115,7 +3290,14 @@ impl App {
}
self.selected_node = None;
self.refresh_draft();
self.fit();
if self.saved_scenario_diagram(diagram) {
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Saved { diagram },
);
} else {
self.page = Page::Diagram;
self.fit();
}
}
}
Message::Projection(projection) => {
@@ -5748,12 +5930,17 @@ mod saved_scenario_dispatch_tests {
}
#[test]
fn unsupported_saved_picker_and_reveal_do_not_apply_pending_properties() {
fn saved_picker_with_invalid_properties_and_reveal_with_pending_properties_do_not_apply() {
for reveal in [false, true] {
let (mut app, architecture, saved, owner) = fixture();
let _ = app.update(Message::Field(
"/name".into(),
"Unapplied scenario name".into(),
if reveal { "/name" } else { "/id" }.into(),
if reveal {
"Unapplied scenario name"
} else {
"-"
}
.into(),
));
assert!(app.draft_dirty());
let before = app.document.bundle().clone();
@@ -5773,7 +5960,6 @@ mod saved_scenario_dispatch_tests {
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected, selected);
assert!(app.draft_dirty());
assert_eq!(app.status, SAVED_SCENARIO_UNAVAILABLE);
}
}