Compose saved Scenario authoring with current Text and input authority
This commit is contained in:
+578
-31
@@ -19,6 +19,10 @@ mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
@@ -27,6 +31,7 @@ pub(crate) mod text_workspace;
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
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,
|
||||
@@ -546,6 +551,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,
|
||||
@@ -594,7 +600,7 @@ impl App {
|
||||
let active_diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
let draft = Draft::from_project(&project, selected);
|
||||
@@ -670,6 +676,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,
|
||||
@@ -684,6 +691,7 @@ impl App {
|
||||
workflow: None,
|
||||
};
|
||||
app.route_state_diagram_workspace();
|
||||
app.reconcile_scenario_workspace();
|
||||
app.last_workspace_page = app.page;
|
||||
app
|
||||
}
|
||||
@@ -819,6 +827,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();
|
||||
@@ -843,7 +862,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == self.layer)
|
||||
.filter(|d| d.layer == self.layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
}
|
||||
@@ -923,6 +942,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);
|
||||
}
|
||||
@@ -1097,7 +1136,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.layer == layer)
|
||||
.is_some_and(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
})
|
||||
.or(retained)
|
||||
.or_else(|| self.diagram_for(layer, target));
|
||||
@@ -1129,6 +1168,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
|
||||
@@ -1309,6 +1359,9 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Spacing(event)) = &message {
|
||||
return self.scenario_spacing(*event);
|
||||
}
|
||||
// Reject stale rename intents before camera/reveal reconciliation or
|
||||
// source interaction can disturb a newer navigation destination.
|
||||
if let Message::SourceRename(event) = &message
|
||||
@@ -1321,6 +1374,42 @@ impl App {
|
||||
if let Message::SourceRename(event @ source_rename::Event::PreviewScrolled(..)) = message {
|
||||
return self.source_rename_event(event);
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
// A retained Details button may belong to an earlier selection or
|
||||
// canvas epoch. Refuse it before retiring any newer reveal or draft.
|
||||
if let behavior::scenario::CanvasEvent::Properties(_, id) = event
|
||||
&& (self.scenario_context() != Some(captured)
|
||||
|| self.selected != *id
|
||||
|| !self.project().contains(*id)
|
||||
|| !behavior::scenario::properties_target(self.project(), captured, *id)
|
||||
|| self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.source_rename.open()
|
||||
|| !behavior::canvas_workspace::keyboard_enabled(self))
|
||||
{
|
||||
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
|
||||
@@ -1346,6 +1435,7 @@ impl App {
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let spacing_selected = self.selected;
|
||||
let task = self.update_inner(message);
|
||||
if self.allocation_boundary() != allocation_before {
|
||||
self.allocation_retire();
|
||||
@@ -1363,6 +1453,13 @@ impl App {
|
||||
{
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
if (self.camera_boundary() != before
|
||||
|| self.selected != spacing_selected
|
||||
|| !self.spacing_enabled())
|
||||
&& self.behavior.scenario.cancel_spacing()
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
@@ -1381,6 +1478,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)
|
||||
};
|
||||
@@ -1408,6 +1506,9 @@ impl App {
|
||||
_ => return Task::none(),
|
||||
}
|
||||
let task = self.update(message);
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
|
||||
// after the action, for Iced's final-view ordering.
|
||||
if self.camera_epoch == scope.epoch {
|
||||
@@ -1773,6 +1874,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;
|
||||
}
|
||||
@@ -1781,11 +1883,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
|
||||
@@ -1829,15 +1926,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
|
||||
@@ -1853,7 +1958,46 @@ impl App {
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn saved_scenario_diagram(&self, id: Id) -> bool {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.scenario().is_some())
|
||||
}
|
||||
|
||||
fn unsupported_saved_scenario_message(&self, message: &Message) -> bool {
|
||||
let active = self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id));
|
||||
match message {
|
||||
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
| Message::MoveNode(..)
|
||||
| Message::Route(_)
|
||||
| Message::Connection(_)
|
||||
| Message::PortAnchor(_)
|
||||
| Message::Export
|
||||
| Message::Fit
|
||||
| Message::Zoom(_)
|
||||
| Message::Pan(_) => active,
|
||||
Message::Add(_) | Message::CreateContextElement(..) => {
|
||||
active && self.page == Page::Diagram
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
// Refuse unsupported saved-view actions before they can Apply unrelated drafts.
|
||||
// Generic model/source edits and the semantic Scenario workspace remain available.
|
||||
if self.unsupported_saved_scenario_message(&message) {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
match message {
|
||||
Message::WorkDrag(event) => return self.allocation_event(event),
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
@@ -1876,6 +2020,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)
|
||||
@@ -1888,6 +2034,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)
|
||||
{
|
||||
@@ -1897,7 +2045,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
|
||||
@@ -1914,6 +2065,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)
|
||||
{
|
||||
@@ -1927,6 +2080,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
|
||||
})
|
||||
{
|
||||
@@ -1935,6 +2090,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,
|
||||
};
|
||||
@@ -1945,6 +2101,9 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
match input {
|
||||
Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
@@ -1956,7 +2115,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);
|
||||
}
|
||||
}
|
||||
@@ -1981,10 +2143,56 @@ impl App {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Representation(_, requested) => {
|
||||
use behavior::scenario_layout::{
|
||||
Representation, presentation::checked_owner,
|
||||
};
|
||||
if checked_owner(self.project(), requested) != Some(context.scenario) {
|
||||
return Task::none();
|
||||
}
|
||||
// An explicitly selected current view record follows
|
||||
// the arrangement picker, as with the Diagram picker.
|
||||
// Independent occurrence/lifeline selection stays put.
|
||||
let follow_view = matches!(context.representation,
|
||||
Representation::Saved { diagram } if self.selected == diagram);
|
||||
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,
|
||||
};
|
||||
if self.enter_scenario_representation(requested) && follow_view {
|
||||
self.selected = match requested {
|
||||
Representation::Saved { diagram } => diagram,
|
||||
// Automatic has no Diagram record; its Scenario
|
||||
// owner is the corresponding inspectable target.
|
||||
Representation::Derived { owner } => owner,
|
||||
};
|
||||
self.refresh_draft();
|
||||
}
|
||||
}
|
||||
CanvasEvent::Shelf(_, shelf) => {
|
||||
self.behavior.scenario.toggle_shelf(shelf);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Properties(_, _) => {
|
||||
// The checked action opens the current record only: no
|
||||
// select()/Apply side effect or representation change.
|
||||
self.behavior.scenario.show_properties();
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
@@ -2005,7 +2213,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);
|
||||
}
|
||||
@@ -2027,7 +2239,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() {
|
||||
@@ -2055,6 +2267,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 =
|
||||
@@ -2066,6 +2287,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 =
|
||||
@@ -2166,6 +2398,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);
|
||||
@@ -2514,7 +2753,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.state_machine().is_some())
|
||||
.is_some_and(|d| !d.projection.is_architecture())
|
||||
})
|
||||
{
|
||||
// Architecture remains reachable from a state workspace.
|
||||
@@ -2523,9 +2762,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.layer == self.layer && d.projection.state_machine().is_none()
|
||||
})
|
||||
.filter(|d| d.layer == self.layer && d.projection.is_architecture())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.fit();
|
||||
@@ -2536,6 +2773,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);
|
||||
@@ -2772,6 +3018,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 {
|
||||
@@ -2783,7 +3030,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.selected = self.project().id;
|
||||
@@ -2944,6 +3191,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();
|
||||
@@ -2956,6 +3206,10 @@ impl App {
|
||||
let Some(view) = self.project().diagrams.get(&diagram) else {
|
||||
return Task::none();
|
||||
};
|
||||
if view.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let layer = view.layer;
|
||||
let endpoints = self
|
||||
.project()
|
||||
@@ -3023,6 +3277,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Allocation,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -3176,6 +3431,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() {
|
||||
@@ -3195,7 +3460,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) => {
|
||||
@@ -3205,6 +3477,10 @@ impl App {
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.cloned()
|
||||
{
|
||||
if d.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
d.projection = projection;
|
||||
self.execute(Command::EditDiagram(d.id, d));
|
||||
self.fit();
|
||||
@@ -3789,6 +4065,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::Export => {
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
if !self.project().diagrams.contains_key(&diagram) {
|
||||
self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string();
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
return self.choose_file(FileOperation::Export);
|
||||
}
|
||||
@@ -3851,7 +4135,9 @@ impl App {
|
||||
};
|
||||
let id = e.id;
|
||||
let mut commands = vec![Command::CreateElement(e)];
|
||||
let mut diagram_target = self.active_diagram;
|
||||
let mut diagram_target = self
|
||||
.active_diagram
|
||||
.filter(|id| !self.saved_scenario_diagram(*id));
|
||||
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
|
||||
Some(ProjectionKind::Allocation)
|
||||
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
|
||||
@@ -3881,6 +4167,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -4180,11 +4467,24 @@ impl App {
|
||||
)
|
||||
}
|
||||
fn export_to(&mut self, path: PathBuf) -> Task<Message> {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let svg = crate::diagram::export_svg(self.project(), diagram);
|
||||
let svg = match crate::diagram::export_svg(self.project(), diagram) {
|
||||
Ok(svg) => svg,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -4423,6 +4723,9 @@ impl App {
|
||||
)
|
||||
}
|
||||
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
|
||||
if self.saved_scenario_diagram(self.active_diagram?) {
|
||||
return None;
|
||||
}
|
||||
Some(crate::route_edit::Context {
|
||||
document: self.canvas_document,
|
||||
diagram: self.active_diagram?,
|
||||
@@ -4647,6 +4950,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn fit(&mut self) {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.fit_to_view = true;
|
||||
let view = self
|
||||
.active_diagram
|
||||
@@ -4694,7 +5003,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
return p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
let view = project_view(p, d.id);
|
||||
(
|
||||
@@ -4708,7 +5017,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
}
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
(
|
||||
!p.elements
|
||||
@@ -5789,6 +6098,7 @@ mod port_view_tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -5955,3 +6265,240 @@ mod data_paging_lifecycle_tests {
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_scenario_dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let f = behavior::scenario::direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let architecture = project.ensure_diagram(Layer::Oa);
|
||||
let saved = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
saved,
|
||||
Diagram {
|
||||
id: saved,
|
||||
alias: "saved_scenario_dispatch".into(),
|
||||
name: "Saved Scenario".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.select(f.owner);
|
||||
(app, architecture, saved, f.owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep export draft".into()));
|
||||
assert!(app.draft_dirty());
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4()));
|
||||
std::fs::write(&path, b"existing export").unwrap();
|
||||
for target in [Some(saved), Some(Id::new_v4()), None] {
|
||||
for busy in [false, true] {
|
||||
app.active_diagram = target;
|
||||
app.busy = busy;
|
||||
let before = app.document.bundle().clone();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
);
|
||||
let view = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
);
|
||||
let task = app.export_to(path.clone());
|
||||
assert_eq!(task.units(), 0, "rejection must not schedule a write");
|
||||
assert_eq!(app.busy, busy);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo()
|
||||
),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan
|
||||
),
|
||||
view
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing export");
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() {
|
||||
for target in [None, Some(Id::new_v4())] {
|
||||
let (mut app, _, _, _) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied export draft".into(),
|
||||
));
|
||||
app.active_diagram = target;
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let task = app.handle(Message::Export);
|
||||
assert_eq!(task.units(), 0);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(
|
||||
if reveal { "/name" } else { "/id" }.into(),
|
||||
if reveal {
|
||||
"Unapplied scenario name"
|
||||
} else {
|
||||
"-"
|
||||
}
|
||||
.into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let selected = app.selected;
|
||||
let message = if reveal {
|
||||
Message::RevealInDiagram(saved, owner)
|
||||
} else {
|
||||
Message::Diagram(Choice {
|
||||
id: saved,
|
||||
label: "Saved Scenario".into(),
|
||||
})
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_cannot_use_architecture_controls_or_change_projection_by_toolbar() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep pending".into()));
|
||||
let before = app.document.bundle().clone();
|
||||
assert!(app.route_context().is_none());
|
||||
assert!(architecture_authoring::kinds(&app).is_empty());
|
||||
for message in [
|
||||
Message::Projection(ProjectionKind::Overview),
|
||||
Message::PlaceSelected,
|
||||
Message::Command(Command::Layout(saved)),
|
||||
Message::Export,
|
||||
] {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(
|
||||
app.project().diagrams[&saved]
|
||||
.projection
|
||||
.scenario()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_does_not_start_the_existing_architecture_palette() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Architecture(
|
||||
architecture_authoring::Event::Existing(None),
|
||||
));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.status.contains("architecture view"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_saved_usage_is_refused_without_selection_or_camera_changes() {
|
||||
let (mut app, architecture, saved, _) = fixture();
|
||||
let before = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch,
|
||||
);
|
||||
let source = app.document.bundle().clone();
|
||||
let usage = crate::diagram_usage::Usage {
|
||||
subject: saved,
|
||||
surface: crate::diagram_usage::Surface::Saved(saved),
|
||||
target: crate::diagram_usage::Target::Whole,
|
||||
role: "diagram".into(),
|
||||
bounds: iced::Rectangle::new(iced::Point::new(500., 600.), iced::Size::new(300., 200.)),
|
||||
};
|
||||
assert!(!app.reveal_diagram_usage(&usage));
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch
|
||||
),
|
||||
before
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_scenario_preview_remains_available_and_saved_views_are_not_default_architecture() {
|
||||
let (mut app, architecture, saved, owner) = fixture();
|
||||
assert_eq!(
|
||||
preferred_diagram(app.project(), Some(saved), Layer::Oa, owner),
|
||||
Some(architecture)
|
||||
);
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, owner);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut only_saved = app.project().clone();
|
||||
only_saved.diagrams.retain(|id, _| *id == saved);
|
||||
assert!(preferred_diagram(&only_saved, None, Layer::Oa, owner).is_none());
|
||||
assert!(App::with_project(only_saved).active_diagram.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user