Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a7ccecb6 | ||
|
|
fa694664a2 | ||
|
|
cb5d50de03 | ||
|
|
83e840e37f | ||
|
|
2c9cd2b3e6 | ||
|
|
46e106c04e | ||
|
|
aa62f42b81 | ||
|
|
7f192f5dc4 |
@@ -35,26 +35,26 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
|
||||
Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
|
||||
Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
|
||||
+214
-28
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -47,11 +48,23 @@ impl App {
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Scenario(c.scenario),
|
||||
match c.representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
&& c.epoch == *epoch
|
||||
&& self.pending_scenario_reveal_visit == Some(c.visit)
|
||||
&& self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(c.scenario)
|
||||
&& self.behavior.scenario.context(
|
||||
self.canvas_document,
|
||||
c.scenario,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
) == Some(*c),
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
@@ -91,6 +104,7 @@ impl App {
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
) {
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
if !Self::is_navigation_camera(message) {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
@@ -142,5 +156,6 @@ impl App {
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ fn measurement(
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
visit: Id,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
@@ -160,6 +161,10 @@ fn measurement(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
representation: Representation::Derived {
|
||||
owner: surface.id(),
|
||||
},
|
||||
visit,
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
@@ -177,12 +182,16 @@ fn measurement(
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
fn stamp(app: &App) -> (Context, u64, Id) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(*context, *epoch)
|
||||
(
|
||||
*context,
|
||||
*epoch,
|
||||
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
@@ -220,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
target.surface.id(),
|
||||
behavior::canvas_workspace::Event::Pan(pan),
|
||||
);
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
@@ -239,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -261,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
@@ -275,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
@@ -290,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(stamp(&app), (context, epoch, visit));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
@@ -304,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -318,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
let (context, old_epoch, visit) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch) = stamp(&app);
|
||||
let (current, epoch, current_visit) = stamp(&app);
|
||||
assert_eq!(current_visit, visit);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
@@ -328,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert_eq!(stamp(&app), (current, epoch, visit));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -350,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
@@ -387,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
measurement(canvas, context, epoch, target.surface, WIDE, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -58,10 +59,6 @@ impl App {
|
||||
let process = presentation.into_query_state();
|
||||
let scenario = behavior::scenario::State::default();
|
||||
usage::collect(&project, context.subject, &process, &scenario)
|
||||
.into_iter()
|
||||
.filter(|row| !matches!(row.surface, Surface::Saved(id)
|
||||
if project.diagrams.get(&id).is_some_and(|d| d.projection.scenario().is_some())))
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
|
||||
@@ -168,6 +165,7 @@ impl App {
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
@@ -290,14 +288,78 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
|
||||
/// never selects the owner or another saved arrangement as a fallback.
|
||||
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<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 {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
if matches!(usage.surface, Surface::Saved(id) if self.saved_scenario_diagram(id)) {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let scenario_representation = match usage.surface {
|
||||
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
|
||||
Some(Representation::Saved { diagram })
|
||||
}
|
||||
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(representation) = scenario_representation {
|
||||
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
|
||||
return false;
|
||||
};
|
||||
if !self
|
||||
.behavior
|
||||
.scenario
|
||||
.representation_usages(self.project(), representation, usage.subject)
|
||||
.iter()
|
||||
.any(|row| row == usage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Finish target/camera admission before changing the App route. The
|
||||
// checked immutable owner also makes the following entry infallible.
|
||||
let revealed = match representation {
|
||||
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
),
|
||||
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
representation,
|
||||
&usage.target,
|
||||
),
|
||||
};
|
||||
if !revealed {
|
||||
return false;
|
||||
}
|
||||
if !self.enter_scenario_representation(representation) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
@@ -337,7 +399,10 @@ impl App {
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
if let Some(owner) = diagram.projection.scenario() {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
} else if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
@@ -379,19 +444,14 @@ impl App {
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
if scenario_representation.is_none() {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
@@ -418,6 +478,18 @@ impl App {
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
|
||||
let owner = presentation::checked_owner(self.project(), representation)?;
|
||||
self.behavior
|
||||
.scenario
|
||||
.context(
|
||||
self.canvas_document,
|
||||
owner,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
)
|
||||
.map(|context| context.visit)
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
@@ -437,12 +509,21 @@ impl App {
|
||||
let current_surface = match surface {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& !self.saved_scenario_diagram(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
&& (!self.saved_scenario_diagram(id)
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Saved { diagram: id })))
|
||||
}
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
Surface::Process(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(owner)
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Derived { owner })
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
|
||||
@@ -1401,3 +1401,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
mod saved_scenario;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ impl App {
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
scenario_view: None,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|
||||
@@ -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,264 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
self.behavior.scenario.forget_view();
|
||||
self.layer = layer;
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
|
||||
@@ -49,11 +49,28 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
|
||||
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
|
||||
p.place(view, reused, Position { x: 873., y: 421. });
|
||||
let mut app = App::with_project(p);
|
||||
app.layer = layer;
|
||||
app.page = Page::Behavior;
|
||||
app.selected = root;
|
||||
app.behavior.active_owner = Some(root);
|
||||
app.refresh_draft();
|
||||
let accepted = app.document.snapshot();
|
||||
// Enter through the actual navigation path: assigning Page/owner alone does
|
||||
// not create the representation and live visit required by canvas events.
|
||||
send(&mut app, Message::Select(root));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, root);
|
||||
assert_eq!(app.behavior.active_owner, Some(root));
|
||||
if kind == Kind::Scenario {
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.document, app.canvas_document);
|
||||
assert_eq!(context.scenario, root);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner: root }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
(app, root, work, reused)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) mod scenario;
|
||||
mod scenario_activation;
|
||||
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
|
||||
#[allow(dead_code)]
|
||||
mod scenario_layout;
|
||||
pub(crate) mod scenario_layout;
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
@@ -62,9 +62,18 @@ mod scenario_scroll {
|
||||
process: bool,
|
||||
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 {
|
||||
identity: (Id, Id),
|
||||
identity: Identity,
|
||||
report: Report,
|
||||
restored: Option<Id>,
|
||||
}
|
||||
@@ -115,8 +124,12 @@ mod scenario_scroll {
|
||||
) {
|
||||
let restore = self.restore.filter(|request| {
|
||||
!self.process
|
||||
&& (request.document, request.owner)
|
||||
== (self.context.document, self.context.scenario)
|
||||
&& (
|
||||
request.document,
|
||||
request.owner,
|
||||
request.representation,
|
||||
request.visit,
|
||||
) == identity(self.context)
|
||||
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
|
||||
});
|
||||
let receipt = restore.and_then(|request| {
|
||||
@@ -140,12 +153,7 @@ mod scenario_scroll {
|
||||
&mut offset,
|
||||
);
|
||||
if let Some(offset) = offset.0 {
|
||||
let next = Some((
|
||||
self.process,
|
||||
self.context.document,
|
||||
self.context.scenario,
|
||||
offset,
|
||||
));
|
||||
let next = Some((self.process, identity(self.context), offset));
|
||||
let state = tree.state.downcast_mut::<ObservedScroll>();
|
||||
if let Some(token) = receipt {
|
||||
state.restored = Some(token);
|
||||
@@ -189,7 +197,7 @@ mod scenario_scroll {
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(ObservedScroll {
|
||||
identity: (self.context.document, self.context.scenario),
|
||||
identity: identity(self.context),
|
||||
report: None,
|
||||
restored: None,
|
||||
})
|
||||
@@ -198,9 +206,7 @@ mod scenario_scroll {
|
||||
vec![widget::Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity
|
||||
!= (self.context.document, self.context.scenario)
|
||||
{
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
|
||||
// A new owner/document needs fresh real scroll state, not merely
|
||||
// cleared mirrored offsets. Edits and tool/form changes retain it.
|
||||
tree.state = self.state();
|
||||
@@ -489,17 +495,25 @@ mod scenario_scroll {
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
|
||||
for mismatch in 0..3 {
|
||||
for mismatch in 0..5 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut request = scenario::ScrollRestore {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: Vector::new(0., 180.),
|
||||
};
|
||||
match mismatch {
|
||||
0 => request.document = Id::new_v4(),
|
||||
1 => request.owner = Id::new_v4(),
|
||||
3 => {
|
||||
request.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
4 => request.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
let mut view = observer(600., context);
|
||||
@@ -586,7 +600,7 @@ mod scenario_scroll {
|
||||
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
|
||||
for process in [false, true] {
|
||||
let area = scenario::ScrollArea::Page;
|
||||
for change in 0..4 {
|
||||
for change in 0..6 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut view = observer(600., context);
|
||||
view.process = process;
|
||||
@@ -615,6 +629,12 @@ mod scenario_scroll {
|
||||
next.revision += 1;
|
||||
next.epoch += 5;
|
||||
}
|
||||
4 => {
|
||||
next.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
5 => next.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
view = observer(if change == 3 { 800. } else { 600. }, next);
|
||||
@@ -662,7 +682,7 @@ mod scenario_scroll {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![if change < 2 {
|
||||
vec![if change < 2 || change >= 4 {
|
||||
Vector::ZERO
|
||||
} else {
|
||||
Vector::new(0., 180.)
|
||||
@@ -976,6 +996,19 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
// A requested saved view remains the destination even after its model is removed.
|
||||
// The shared display adapter reports unavailable instead of choosing another behavior.
|
||||
if matches!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(scenario_layout::Representation::Saved { .. })
|
||||
) && let Some(owner) = app.behavior.scenario.owner()
|
||||
&& !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer)
|
||||
{
|
||||
return scenario::view(app, owner, column![].spacing(6));
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -987,7 +1020,14 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
label: format!("{} · {}", e.kind(), e.name),
|
||||
})
|
||||
.collect::<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
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.or(choices.first())
|
||||
@@ -1180,6 +1220,7 @@ struct Scene {
|
||||
scale: f32,
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
// Exact render coordinates include unassigned lanes; do not derive from cards.
|
||||
@@ -1252,6 +1293,7 @@ impl Scene {
|
||||
layout_origin: Point::ORIGIN,
|
||||
selected,
|
||||
interaction: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
render_lanes: BTreeMap::new(),
|
||||
@@ -1857,7 +1899,7 @@ impl canvas::Program<Message> for Scene {
|
||||
state.pointer = None;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
if !editable || !self.coordinate_input {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
@@ -1925,6 +1967,14 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, id,
|
||||
)))
|
||||
.and_capture()
|
||||
});
|
||||
}
|
||||
if let CanvasTool::Message(_) = tool {
|
||||
let first = state.source.is_none();
|
||||
let (source, slot) = state.source.or_else(|| {
|
||||
@@ -2252,7 +2302,10 @@ impl canvas::Program<Message> for Scene {
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
&& state.context == Some(context)
|
||||
&& state.tool == tool;
|
||||
let point = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
@@ -2268,6 +2321,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
});
|
||||
if editable
|
||||
&& self.coordinate_input
|
||||
&& tool == scenario::CanvasTool::Select
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn controls<'a>(
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
text(zoom_label(scale)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
@@ -58,6 +58,7 @@ pub fn controls<'a>(
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
@@ -69,6 +70,7 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -77,6 +79,7 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -96,8 +99,8 @@ impl Scope {
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
"behavior-control:{}:{}:{}:{:?}:{name}",
|
||||
self.document, self.owner, self.revision, self.scenario_view
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -131,6 +134,20 @@ pub enum Event {
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<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 {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
@@ -150,7 +167,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if !old.is_finite() || old <= 0. {
|
||||
return false;
|
||||
}
|
||||
let next = (old * factor).clamp(minimum, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
@@ -168,6 +188,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<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.
|
||||
pub fn palette<'a>(
|
||||
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() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
scenario_view: None,
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
@@ -427,6 +428,7 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
@@ -435,6 +437,7 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
|
||||
@@ -893,11 +893,14 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
// Capture the context created by actual navigation, including its visit.
|
||||
// Merely assigning active_owner/selected does not mount a Scenario anymore.
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.scenario, a);
|
||||
assert!(!context.visit.is_nil());
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
|
||||
context,
|
||||
@@ -908,23 +911,40 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
},
|
||||
lifeline: None,
|
||||
};
|
||||
app.behavior.active_owner = Some(b);
|
||||
app.selected = b;
|
||||
let _ = app.update(Message::Select(b));
|
||||
let second = app.scenario_context().unwrap();
|
||||
assert_eq!(second.scenario, b);
|
||||
assert_ne!(second.visit, context.visit);
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project(), &p);
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
app.layer = Layer::Sa;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before_layer_change = app.scenario_context().unwrap();
|
||||
assert_eq!(before_layer_change.scenario, a);
|
||||
assert_ne!(before_layer_change.visit, context.visit);
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
app.layer = Layer::Oa;
|
||||
|
||||
let _ = app.update(Message::Layer(Layer::Oa));
|
||||
let _ = app.update(Message::Select(a));
|
||||
let current = app.scenario_context().unwrap();
|
||||
assert_eq!(current.scenario, a);
|
||||
assert_ne!(current.visit, before_layer_change.visit);
|
||||
// Returning to the same owner/layer must not revive either retired packet.
|
||||
for retired in [context, before_layer_change] {
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(current)));
|
||||
assert_eq!(app.behavior.pending_owner(), Some(a));
|
||||
assert_eq!(app.project(), &p);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,7 +4,14 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, ScenarioPresentation},
|
||||
};
|
||||
#[cfg(test)]
|
||||
mod presentation_tests;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
@@ -71,6 +78,8 @@ impl std::fmt::Display for MessageStyle {
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub scenario: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
@@ -108,6 +117,7 @@ pub enum CanvasEvent {
|
||||
OpenUse(Context, Id),
|
||||
Back(Context),
|
||||
Choose(Context, CanvasTool),
|
||||
Representation(Context, Representation),
|
||||
Select(Context, Id),
|
||||
Begin {
|
||||
context: Context,
|
||||
@@ -147,6 +157,7 @@ impl CanvasEvent {
|
||||
| Self::OpenUse(context, _)
|
||||
| Self::Back(context)
|
||||
| Self::Choose(context, _)
|
||||
| Self::Representation(context, _)
|
||||
| Self::Select(context, _)
|
||||
| Self::Begin { context, .. }
|
||||
| Self::BeginOccurrence { context, .. }
|
||||
@@ -218,6 +229,7 @@ const CALLER_LIMIT: usize = 32;
|
||||
pub(crate) struct Caller {
|
||||
document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
@@ -227,6 +239,8 @@ pub(crate) struct Caller {
|
||||
struct DetailsRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
visit: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
}
|
||||
@@ -234,6 +248,8 @@ struct DetailsRestore {
|
||||
pub(super) struct ScrollRestore {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub token: Id,
|
||||
pub offset: iced::Vector,
|
||||
}
|
||||
@@ -327,42 +343,146 @@ impl State {
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.enter_representation(document, owner, Representation::Derived { owner });
|
||||
}
|
||||
/// App validates requested identity with checked_owner before entering.
|
||||
pub(crate) fn enter_representation(
|
||||
&mut self,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
) -> bool {
|
||||
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let changed = self.presentation.identity != Some((document, owner))
|
||||
|| self.presentation.representation != Some(representation);
|
||||
if changed {
|
||||
self.presentation = workspace::Presentation {
|
||||
identity: Some((document, owner)),
|
||||
representation: Some(representation),
|
||||
focused: self.presentation.focused,
|
||||
fit: matches!(representation, Representation::Saved { .. }),
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.restore = None;
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
}
|
||||
if changed || self.presentation.visit.is_nil() {
|
||||
self.presentation.visit = Id::new_v4();
|
||||
self.restore = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Suspend captured events while preserving this destination's camera state.
|
||||
pub(crate) fn leave(&mut self) {
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn forget_view(&mut self) {
|
||||
self.presentation.identity = None;
|
||||
self.presentation.representation = None;
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn representation(&self) -> Option<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 {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
pub(crate) fn camera(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
event: super::canvas_workspace::Event,
|
||||
) -> bool {
|
||||
let Some(bounds) = self
|
||||
.display(project, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let p = &mut self.presentation;
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
camera::change(
|
||||
&mut p.pan,
|
||||
&mut p.zoom,
|
||||
&mut p.fit,
|
||||
p.viewport,
|
||||
bounds,
|
||||
event,
|
||||
)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
let Some(bounds) = self
|
||||
.display(p, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(next) = zoom {
|
||||
let current = self.presentation.camera(bounds);
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
self.camera(
|
||||
p,
|
||||
owner,
|
||||
super::canvas_workspace::Event::Zoom {
|
||||
factor: next / current.scale,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: current.scale,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
self.zoom(None)
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
@@ -390,6 +510,7 @@ impl State {
|
||||
self.return_to.push(Caller {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
@@ -404,6 +525,7 @@ impl State {
|
||||
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
|
||||
frame.document == context.document
|
||||
&& frame.target == context.scenario
|
||||
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
|
||||
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
|
||||
}
|
||||
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
|
||||
@@ -420,16 +542,23 @@ impl State {
|
||||
None
|
||||
}
|
||||
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
|
||||
if context.document != frame.document || context.scenario != frame.owner {
|
||||
if context.document != frame.document
|
||||
|| context.scenario != frame.owner
|
||||
|| context.representation != frame.representation
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.presentation.representation = Some(frame.representation);
|
||||
self.presentation.visit = context.visit;
|
||||
self.restore = Some(DetailsRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
representation: frame.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
});
|
||||
@@ -439,13 +568,17 @@ impl State {
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
})?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
representation: restore.representation,
|
||||
visit: restore.visit,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
})
|
||||
@@ -458,7 +591,11 @@ impl State {
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
&& r.token == token
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
@@ -1931,6 +2068,8 @@ pub(crate) mod direct_tests {
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
scenario: owner,
|
||||
representation: Representation::Derived { owner },
|
||||
visit: Id::new_v4(),
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
representation: super::Representation,
|
||||
visit: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
@@ -20,6 +22,8 @@ impl Stamp {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
@@ -28,10 +32,12 @@ impl Stamp {
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
"{}:{}:{}:{:?}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.representation,
|
||||
stamp.visit,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
|
||||
@@ -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.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The renderer collapses whitespace before shortening. Require at least one
|
||||
@@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
|
||||
start < retained
|
||||
}
|
||||
|
||||
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
fn usages(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
subject: Id,
|
||||
surface: Surface,
|
||||
bounds: Rectangle,
|
||||
scene: &Scene,
|
||||
) -> Vec<Usage> {
|
||||
let mut result = Vec::new();
|
||||
let mut add = |id, bounds, role: &str| {
|
||||
// Activation bars share the occurrence identity with a labelled message;
|
||||
@@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
{
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
surface,
|
||||
target: Target::ScenarioObject(id),
|
||||
role: role.into(),
|
||||
bounds,
|
||||
@@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject == owner {
|
||||
if subject == owner || subject == surface.id() {
|
||||
result.insert(
|
||||
0,
|
||||
Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
surface,
|
||||
target: Target::Whole,
|
||||
role: "scenario".into(),
|
||||
bounds: Rectangle::new(Point::ORIGIN, scene.size),
|
||||
bounds,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
if !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
/// Membership comes from the complete presentation chosen for this exact view,
|
||||
/// including automatic fallback geometry while retaining its Saved identity.
|
||||
pub(crate) fn representation_usages(
|
||||
&self,
|
||||
p: &Project,
|
||||
representation: Representation,
|
||||
subject: Id,
|
||||
) -> Vec<Usage> {
|
||||
let shown = presentation::present(p, representation, subject);
|
||||
let (Some(owner), Some(bounds), Some(scene)) =
|
||||
(shown.owner(), shown.bounds(), shown.scene())
|
||||
else {
|
||||
return vec![];
|
||||
}
|
||||
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
|
||||
};
|
||||
let surface = match representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
};
|
||||
usages(p, owner, subject, surface, bounds, scene)
|
||||
}
|
||||
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
self.representation_usages(p, Representation::Derived { owner }, subject)
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_usage(
|
||||
@@ -171,12 +193,24 @@ impl State {
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
self.reveal_representation_usage(
|
||||
p,
|
||||
document,
|
||||
owner,
|
||||
Representation::Derived { owner },
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_representation_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
|
||||
return false;
|
||||
}
|
||||
let id = match target {
|
||||
@@ -185,7 +219,7 @@ impl State {
|
||||
_ => return false,
|
||||
};
|
||||
let Some(usage) = self
|
||||
.diagram_usages(p, owner, id)
|
||||
.representation_usages(p, representation, id)
|
||||
.into_iter()
|
||||
.find(|u| &u.target == target)
|
||||
else {
|
||||
@@ -193,11 +227,29 @@ impl State {
|
||||
};
|
||||
// Preserve an existing mounted viewport across enter's owner reset.
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.enter(document, owner);
|
||||
if !viewport.width.is_finite()
|
||||
|| !viewport.height.is_finite()
|
||||
|| viewport.width <= 0.
|
||||
|| viewport.height <= 0.
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (zoom, pan) = if matches!(target, Target::Whole) {
|
||||
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
|
||||
return false;
|
||||
};
|
||||
(None, camera.pan)
|
||||
} else {
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
(Some(zoom), pan)
|
||||
};
|
||||
self.enter_representation(document, owner, representation);
|
||||
self.presentation.viewport = Some(viewport);
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
self.presentation.zoom(Some(zoom));
|
||||
self.presentation.zoom(zoom);
|
||||
self.presentation.pan = pan;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum Shelf {
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) representation: Option<Representation>,
|
||||
pub(super) visit: Id,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
@@ -20,7 +22,6 @@ pub(super) struct Presentation {
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
@@ -35,29 +36,26 @@ impl Presentation {
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// Fit uses the actual mounted viewport with a small visual gutter.
|
||||
Some(
|
||||
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
||||
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
||||
)
|
||||
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
|
||||
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
|
||||
}
|
||||
pub(super) fn scale(&self, scene: Size) -> f32 {
|
||||
if self.fit {
|
||||
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
|
||||
} else {
|
||||
self.zoom.unwrap_or(1.)
|
||||
}
|
||||
// Legacy tests call size-only scale; actual display always passes full bounds.
|
||||
#[cfg(test)]
|
||||
pub(super) fn scale(&self, size: Size) -> f32 {
|
||||
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArrangementChoice {
|
||||
representation: Representation,
|
||||
label: String,
|
||||
}
|
||||
impl std::fmt::Display for ArrangementChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,43 +67,71 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.owner == owner);
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let Some(context) = app.scenario_context() else {
|
||||
return column![
|
||||
out,
|
||||
text("Scenario view is unavailable."),
|
||||
button("Open selected source").on_press(Message::SourceForSelection)
|
||||
]
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||||
let scale = presentation.scale(scene.size);
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = presentation.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.pending_placement = state.pending_placement(owner);
|
||||
let shown = presentation::present(p, context.representation, app.selected);
|
||||
let capabilities = shown.capabilities();
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = display_notice(p, &shown);
|
||||
let camera_state = shown.bounds().map(|bounds| presentation.camera(bounds));
|
||||
let scale = camera_state.map_or(1., |camera| camera.scale);
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let keyboard = clean
|
||||
&& capabilities.geometry_navigation
|
||||
&& super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas = container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
let canvas: Element<'a, Message> = if let Some(mut scene) = shown.into_scene() {
|
||||
let camera_state = camera_state.expect("displayed Scene has complete bounds");
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select),
|
||||
.key((
|
||||
context.document,
|
||||
format!("{:?}", context.representation),
|
||||
context.visit,
|
||||
))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
||||
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
||||
};
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
@@ -141,9 +167,47 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
let header = column![
|
||||
navigation,
|
||||
super::super::canvas_workspace::controls(
|
||||
let mut header = column![navigation, notice].spacing(4);
|
||||
let saved_views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|diagram| {
|
||||
presentation::checked_owner(
|
||||
p,
|
||||
Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
) == Some(owner)
|
||||
})
|
||||
.map(|diagram| ArrangementChoice {
|
||||
representation: Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
label: short(&diagram.name, 64),
|
||||
})
|
||||
.collect::<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,
|
||||
keyboard,
|
||||
scale,
|
||||
@@ -151,13 +215,12 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus
|
||||
super::super::canvas_workspace::Event::Focus,
|
||||
))),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
camera,
|
||||
));
|
||||
}
|
||||
let entries = [
|
||||
("S", "Select / reorder occurrences", CanvasTool::Select),
|
||||
(
|
||||
@@ -226,9 +289,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
glyph,
|
||||
label,
|
||||
choice == tool,
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
|
||||
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
@@ -575,6 +639,100 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
|
||||
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
|
||||
let title = match shown.requested() {
|
||||
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|
||||
|| "Unavailable saved Scenario view".into(),
|
||||
|view| format!("Saved view · {}", view.name),
|
||||
),
|
||||
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
|
||||
};
|
||||
let mut notice = column![text(title).size(14)].spacing(4);
|
||||
match shown.status() {
|
||||
PresentationStatus::Ready => {
|
||||
for diagnostic in shown.diagnostics().iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
p.named(*frame),
|
||||
p.named(*following)
|
||||
),
|
||||
};
|
||||
notice = notice.push(text(explanation).size(12));
|
||||
}
|
||||
if shown.diagnostics().len() > 3 {
|
||||
notice = notice.push(
|
||||
text(format!(
|
||||
"{} additional spacing notices are retained.",
|
||||
shown.diagnostics().len() - 3
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
PresentationStatus::Fallback { requested_issues } => {
|
||||
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||
}
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
||||
}
|
||||
PresentationStatus::Unavailable { .. } => {
|
||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||
}
|
||||
}
|
||||
if !shown.latent_messages().is_empty() {
|
||||
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", shown.latent_messages().len())).size(12));
|
||||
}
|
||||
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
|
||||
container(notice)
|
||||
.id("scenario-display-notice")
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
|
||||
use super::super::scenario_layout::GeometryIssue;
|
||||
match issue {
|
||||
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
|
||||
"The position of '{}' conflicts with the execution of '{}'.",
|
||||
p.named(*event),
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
|
||||
"The distinct endpoints of '{}' have the same horizontal position.",
|
||||
p.named(*message)
|
||||
),
|
||||
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
|
||||
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
|
||||
"The position of '{}' falls outside its supported execution interval.",
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
|
||||
"The saved spacing would move the fixed header of '{}'.",
|
||||
p.named(*frame)
|
||||
),
|
||||
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
|
||||
"The spacing of '{}' extends outside its enclosing frame.",
|
||||
p.named(*operand)
|
||||
),
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
|
||||
"The saved spacing cannot preserve the participant lifetime geometry.".into()
|
||||
}
|
||||
_ => "These saved positions are outside the geometry currently supported by the canvas."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
title: &str,
|
||||
message: Message,
|
||||
|
||||
@@ -48,8 +48,28 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
mounted.pan(&mut app, start, start + delta).await;
|
||||
let diff = app.behavior.scenario.presentation.pan - next_pan;
|
||||
assert!(diff.x.hypot(diff.y) < 0.01);
|
||||
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
|
||||
mounted.click(&mut app, tool).await;
|
||||
// Iced operations report palette children in content coordinates.
|
||||
// The preceding Action focus may have scrolled this retained palette.
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::Scope::current(&app).unwrap();
|
||||
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
|
||||
let tool = geometry.bounds("behavior-tool:A").center() - offset;
|
||||
assert!(
|
||||
palette.contains(tool),
|
||||
"visible Action at {tool:?}: {palette:?}"
|
||||
);
|
||||
let chosen = mounted.click(&mut app, tool).await;
|
||||
assert!(
|
||||
chosen.iter().any(|message| matches!(
|
||||
canvas_workspace::tests::intent_action(message),
|
||||
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
|
||||
)),
|
||||
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(f.owner),
|
||||
CanvasTool::Add(Tool::Action)
|
||||
);
|
||||
let messages = mounted.click(&mut app, bounds.position() + desired).await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
@@ -57,7 +77,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
tool: Tool::Action, slot, lifeline: Some(line), ..
|
||||
}) if *slot == expected && *line == f.sender)
|
||||
),
|
||||
"scale {scale}: {messages:?}"
|
||||
"scale {scale}, expected slot {expected:?}: {messages:?}"
|
||||
);
|
||||
let session = app.behavior.scenario.form_session().unwrap();
|
||||
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
|
||||
@@ -1337,7 +1357,18 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + iced::Vector::new(95., 90.);
|
||||
let lifetime = app.camera_epoch;
|
||||
let pan = app.behavior.scenario.presentation.pan;
|
||||
// Fit stores no pan offset. A real drag starts from the displayed fitted
|
||||
// camera, then materializes it before applying its ordered screen deltas.
|
||||
let shown = app
|
||||
.behavior
|
||||
.scenario
|
||||
.display(app.project(), f.owner, app.selected)
|
||||
.unwrap();
|
||||
let before_camera = app
|
||||
.behavior
|
||||
.scenario
|
||||
.presentation
|
||||
.camera(shown.bounds().unwrap());
|
||||
let first = point + iced::Vector::new(13., 9.);
|
||||
let last = first + iced::Vector::new(17., -3.);
|
||||
let emitted = mounted
|
||||
@@ -1352,7 +1383,21 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
mouse::Cursor::Available(last),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
|
||||
let expected_pan = before_camera.pan + (last - point);
|
||||
let actual_pan = app.behavior.scenario.presentation.pan;
|
||||
let difference = actual_pan - expected_pan;
|
||||
assert!(
|
||||
difference.x.hypot(difference.y) < 0.001,
|
||||
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.zoom,
|
||||
Some(before_camera.scale)
|
||||
);
|
||||
assert!(
|
||||
!app.behavior.scenario.presentation.fit,
|
||||
"drag materializes the visible Fit camera"
|
||||
);
|
||||
assert_eq!(
|
||||
app.camera_epoch, lifetime,
|
||||
"own ordered deltas retain their lifetime"
|
||||
@@ -1760,14 +1805,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
|
||||
assert!(!presentation.viewport(Size::new(484., 204.)));
|
||||
let scale = presentation.scale(graph);
|
||||
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
|
||||
assert!(scale > MIN_SCALE);
|
||||
assert!(scale > camera::MIN_SCALE);
|
||||
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
|
||||
assert!(!presentation.zoom(Some(bad)));
|
||||
assert!(!presentation.viewport(Size::new(bad, 100.)));
|
||||
assert!(!presentation.viewport(Size::new(100., bad)));
|
||||
assert_eq!(presentation.scale(graph), scale);
|
||||
}
|
||||
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
|
||||
let tall = Rectangle::with_size(Size::new(900., 10000.));
|
||||
let camera = presentation.camera(tall);
|
||||
assert!(camera.scale < 0.05 && camera.scale > 0.);
|
||||
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
|
||||
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
|
||||
assert!((camera.pan.y - 8.).abs() < 1e-4);
|
||||
assert!(presentation.zoom(Some(1.25)));
|
||||
presentation.viewport(Size::new(200., 100.));
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,6 +6,8 @@ use oa_core::{DiagramLayout, ScenarioLayout};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) mod presentation;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -30,13 +32,13 @@ struct MessageAnchors {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Diagnostic {
|
||||
pub(crate) enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) enum GeometryIssue {
|
||||
pub(crate) enum GeometryIssue {
|
||||
UnknownAnchor(Id),
|
||||
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
|
||||
InvalidRepresentation(Id),
|
||||
@@ -312,17 +314,14 @@ impl Plan {
|
||||
..
|
||||
}),
|
||||
) = (messages.get(id), occurrence(p, *id))
|
||||
{
|
||||
if let Some((previous, previous_y)) =
|
||||
&& let Some((previous, previous_y)) =
|
||||
last.insert(*lane, (*id, message.receive_y))
|
||||
{
|
||||
if previous_y > message.receive_y {
|
||||
return Err(GeometryIssue::LifetimeInversionNotSupported {
|
||||
create: previous,
|
||||
delete: *id,
|
||||
});
|
||||
}
|
||||
}
|
||||
&& previous_y > message.receive_y
|
||||
{
|
||||
return Err(GeometryIssue::LifetimeInversionNotSupported {
|
||||
create: previous,
|
||||
delete: *id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,12 +347,14 @@ impl Plan {
|
||||
if covered.y < rect.y {
|
||||
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
|
||||
}
|
||||
if covered.y + covered.height > rect.y + rect.height {
|
||||
let old_covered = old_coverage[&id];
|
||||
let padding =
|
||||
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
|
||||
rect.height = covered.y + covered.height + padding - rect.y;
|
||||
}
|
||||
let old_covered = old_coverage[&id];
|
||||
let padding =
|
||||
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
|
||||
// Preserve padding before the footprint itself reaches the old
|
||||
// frame edge: operand scopes already retain their own padding.
|
||||
rect.height = rect
|
||||
.height
|
||||
.max(covered.y + covered.height + padding - rect.y);
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
@@ -449,13 +450,13 @@ impl Plan {
|
||||
after: pair[1],
|
||||
});
|
||||
}
|
||||
if let Some(frame) = frames.get(&pair[0]) {
|
||||
if frame.y + frame.height > b.y {
|
||||
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: pair[0],
|
||||
following: pair[1],
|
||||
});
|
||||
}
|
||||
if let Some(frame) = frames.get(&pair[0])
|
||||
&& frame.y + frame.height > b.y
|
||||
{
|
||||
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: pair[0],
|
||||
following: pair[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,7 +475,7 @@ impl Plan {
|
||||
|
||||
/// An appearance identity, never inferred from selection or from the owner alone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Representation {
|
||||
pub(crate) enum Representation {
|
||||
Derived { owner: Id },
|
||||
Saved { diagram: Id },
|
||||
}
|
||||
@@ -671,15 +672,14 @@ pub(super) fn resolve(
|
||||
reply_to: Some(call),
|
||||
..
|
||||
}) => {
|
||||
if let Some(start) = plan.messages.get(call) {
|
||||
if (desired.contains_key(&id) || desired.contains_key(call))
|
||||
&& message.send_y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: *call,
|
||||
event: id,
|
||||
});
|
||||
}
|
||||
if let Some(start) = plan.messages.get(call)
|
||||
&& (desired.contains_key(&id) || desired.contains_key(call))
|
||||
&& message.send_y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: *call,
|
||||
event: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -704,15 +704,14 @@ pub(super) fn resolve(
|
||||
| scenario_activation::Finish::Terminated(event) => Some(event),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event {
|
||||
if (desired.contains_key(&event) || desired.contains_key(&segment.call))
|
||||
&& plan.anchors[&event].y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: segment.call,
|
||||
event,
|
||||
});
|
||||
}
|
||||
if let Some(event) = event
|
||||
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
|
||||
&& plan.anchors[&event].y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: segment.call,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Display policy for one requested Scenario representation, without authoring.
|
||||
//! The resolver remains the sole renderer. Freshness and editor admission belong
|
||||
//! to the caller; a capability here never bypasses those guards.
|
||||
use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve};
|
||||
use crate::behavior::Scene;
|
||||
use iced::Rectangle;
|
||||
use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PresentationStatus {
|
||||
Ready,
|
||||
Fallback {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
Unavailable {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
automatic_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PresentationCapabilities {
|
||||
/// Actual geometry exists for inspection, Fit and geometry-based navigation.
|
||||
pub(crate) geometry_navigation: bool,
|
||||
/// Still subject to document, gesture, editor and view-visit admission.
|
||||
pub(crate) coordinate_input: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ScenarioPresentation {
|
||||
requested: Representation,
|
||||
owner: Option<Id>,
|
||||
status: PresentationStatus,
|
||||
geometry: Option<ResolvedScenarioPlan>,
|
||||
// Saved metadata must survive a complete automatic fallback.
|
||||
latent_messages: Vec<Id>,
|
||||
}
|
||||
|
||||
impl ScenarioPresentation {
|
||||
pub(crate) fn requested(&self) -> Representation {
|
||||
self.requested
|
||||
}
|
||||
pub(crate) fn owner(&self) -> Option<Id> {
|
||||
self.owner
|
||||
}
|
||||
pub(crate) fn status(&self) -> &PresentationStatus {
|
||||
&self.status
|
||||
}
|
||||
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map(|geometry| geometry.representation)
|
||||
}
|
||||
pub(crate) fn bounds(&self) -> Option<Rectangle> {
|
||||
self.geometry.as_ref().map(|geometry| geometry.bounds)
|
||||
}
|
||||
/// Diagnostics describe the actual displayed geometry. Requested hard failures
|
||||
/// are retained separately in status(), never presented as automatic warnings.
|
||||
pub(crate) fn diagnostics(&self) -> &[Diagnostic] {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map_or(&[], |geometry| &geometry.plan.diagnostics)
|
||||
}
|
||||
pub(crate) fn latent_messages(&self) -> &[Id] {
|
||||
&self.latent_messages
|
||||
}
|
||||
pub(crate) fn capabilities(&self) -> PresentationCapabilities {
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: self.geometry.is_some(),
|
||||
coordinate_input: matches!(self.status, PresentationStatus::Ready),
|
||||
}
|
||||
}
|
||||
pub(in crate::behavior) fn scene(&self) -> Option<&Scene> {
|
||||
self.geometry.as_ref().map(|geometry| &geometry.scene)
|
||||
}
|
||||
pub(in crate::behavior) fn into_scene(self) -> Option<Scene> {
|
||||
self.geometry.map(|geometry| geometry.scene)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a structurally accepted Project for passive display. This is not a
|
||||
/// second model validator: identity and resolver-reported structural failures
|
||||
/// cannot be converted into a more permissive automatic representation.
|
||||
pub(crate) fn present(
|
||||
project: &Project,
|
||||
requested: Representation,
|
||||
selected: Id,
|
||||
) -> ScenarioPresentation {
|
||||
let owner = checked_owner(project, requested);
|
||||
let Some(owner_id) = owner else {
|
||||
let id = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => diagram,
|
||||
};
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner: None,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues: vec![GeometryIssue::InvalidRepresentation(id)],
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
};
|
||||
match resolve(project, requested, selected) {
|
||||
Ok(geometry) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Ready,
|
||||
latent_messages: geometry.latent_messages.clone(),
|
||||
geometry: Some(geometry),
|
||||
},
|
||||
Err(requested_issues) => {
|
||||
if matches!(requested, Representation::Derived { .. })
|
||||
|| requested_issues.iter().any(structural_failure)
|
||||
{
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
}
|
||||
match resolve(
|
||||
project,
|
||||
Representation::Derived { owner: owner_id },
|
||||
selected,
|
||||
) {
|
||||
Ok(geometry) => {
|
||||
let latent_messages =
|
||||
saved_latent_messages(project, requested, &geometry.scene);
|
||||
ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Fallback { requested_issues },
|
||||
geometry: Some(geometry),
|
||||
latent_messages,
|
||||
}
|
||||
}
|
||||
Err(automatic_issues) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
|
||||
let owner = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => {
|
||||
let view = project.diagrams.get(&diagram)?;
|
||||
let owner = view.projection.scenario()?;
|
||||
if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer {
|
||||
return None;
|
||||
}
|
||||
owner
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario)
|
||||
.then_some(owner)
|
||||
}
|
||||
|
||||
fn structural_failure(issue: &GeometryIssue) -> bool {
|
||||
// These violate the existing identity/address/finite-coordinate admission
|
||||
// contract. Geometry support failures below do not invalidate stored intent.
|
||||
match issue {
|
||||
GeometryIssue::InvalidRepresentation(_)
|
||||
| GeometryIssue::UnknownAnchor(_)
|
||||
| GeometryIssue::InvalidCoordinate(_)
|
||||
| GeometryIssue::DuplicateCoordinate(_)
|
||||
| GeometryIssue::ExpandedAddressNotSupported(_)
|
||||
| GeometryIssue::NonFinite(_) => true,
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::CoincidentDistinctEndpoints(_)
|
||||
| GeometryIssue::TypedActivationOutsideScope { .. }
|
||||
| GeometryIssue::TypedActivationOutsideLifetime { .. }
|
||||
| GeometryIssue::TypedActivationCrossingNotSupported { .. }
|
||||
| GeometryIssue::CompoundMoveNotImplemented(_)
|
||||
| GeometryIssue::FixedFrameHeaderWouldMove { .. }
|
||||
| GeometryIssue::OperandScopeOutsideEnclosingInterval { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec<Id> {
|
||||
let Representation::Saved { diagram } = requested else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect();
|
||||
layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let id = message.address.occurrence;
|
||||
(message.address.references.is_empty()
|
||||
&& scene.rows.contains_key(&id)
|
||||
&& !lines.contains(&id)
|
||||
&& matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&id)
|
||||
.and_then(|element| element.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout,
|
||||
ScenarioMessagePosition, language, validation,
|
||||
};
|
||||
|
||||
fn fixture() -> (Project, Id) {
|
||||
let project = language::parse(FIXTURE).unwrap();
|
||||
assert_admitted(&project);
|
||||
let owner = named(&project, "conversation");
|
||||
(project, owner)
|
||||
}
|
||||
fn named(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn save(project: &mut Project, owner: Id, layout: Option<ScenarioLayout>) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", project.diagrams.len()),
|
||||
name: "Saved conversation".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout {
|
||||
ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
|
||||
.collect(),
|
||||
messages: messages
|
||||
.iter()
|
||||
.map(|&(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn same_geometry(a: &Scene, b: &Scene) {
|
||||
assert!(
|
||||
!a.activations.is_empty(),
|
||||
"fallback must include real typed executions"
|
||||
);
|
||||
assert_eq!(a.activations, b.activations);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.render_lanes, b.render_lanes);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin));
|
||||
let boxes = |scene: &Scene| {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|item| {
|
||||
(
|
||||
item.id,
|
||||
item.rect,
|
||||
item.title.clone(),
|
||||
item.detail.clone(),
|
||||
item.marker,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let lines = |scene: &Scene| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
(
|
||||
line.id,
|
||||
line.points.clone(),
|
||||
line.label.clone(),
|
||||
line.label_at,
|
||||
line.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let slots = |scene: &Scene| {
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
(
|
||||
slot.slot.parent,
|
||||
slot.slot.before,
|
||||
slot.y,
|
||||
slot.label.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(slots(a), slots(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_saved_layouts_remain_saved_ready_representations() {
|
||||
let (mut project, owner) = fixture();
|
||||
let absent = save(&mut project, owner, None);
|
||||
let empty = save(&mut project, owner, Some(ScenarioLayout::default()));
|
||||
assert_admitted(&project);
|
||||
let automatic = present(&project, Representation::Derived { owner }, owner);
|
||||
assert_eq!(automatic.status(), &PresentationStatus::Ready);
|
||||
for diagram in [absent, empty] {
|
||||
let requested = Representation::Saved { diagram };
|
||||
let displayed = present(&project, requested, owner);
|
||||
assert_eq!(displayed.requested(), requested);
|
||||
assert_eq!(displayed.geometry_representation(), Some(requested));
|
||||
assert_eq!(displayed.owner(), Some(owner));
|
||||
assert_eq!(displayed.status(), &PresentationStatus::Ready);
|
||||
assert!(displayed.capabilities().coordinate_input);
|
||||
assert_eq!(displayed.bounds(), automatic.bounds());
|
||||
same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let second = named(&project, "second");
|
||||
let left = named(&project, "left");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let diagram = save(
|
||||
&mut project,
|
||||
owner,
|
||||
Some(layout(
|
||||
&[(first, 1000.), (unfinished, 2000.)],
|
||||
&[(left, 900.)],
|
||||
)),
|
||||
);
|
||||
assert_admitted(&project);
|
||||
let shown = present(&project, Representation::Saved { diagram }, first);
|
||||
assert_eq!(shown.status(), &PresentationStatus::Ready);
|
||||
assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.);
|
||||
assert_eq!(shown.scene().unwrap().rows[&second].1, 240.);
|
||||
assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.);
|
||||
assert!(
|
||||
shown
|
||||
.diagnostics()
|
||||
.contains(&Diagnostic::SourceOrderCrossing {
|
||||
scope: owner,
|
||||
before: first,
|
||||
after: second
|
||||
})
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice()
|
||||
{
|
||||
let (mut project, owner) = fixture();
|
||||
let call = named(&project, "call");
|
||||
let reply = named(&project, "reply");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let left = named(&project, "left");
|
||||
let right = named(&project, "right");
|
||||
let authored = layout(
|
||||
&[(reply, 300.), (unfinished, 2000.)],
|
||||
&[(left, 900.), (right, -500.)],
|
||||
);
|
||||
let a = save(&mut project, owner, Some(authored.clone()));
|
||||
let b = save(&mut project, owner, Some(authored));
|
||||
assert_admitted(&project);
|
||||
let before = project.clone();
|
||||
let automatic = present(&project, Representation::Derived { owner }, call);
|
||||
let control = automatic.scene().unwrap();
|
||||
assert_eq!(control.render_lanes[&left], 160.);
|
||||
assert_eq!(control.render_lanes[&right], 400.);
|
||||
assert_eq!(control.rows[&reply].1, 424.);
|
||||
assert_eq!(control.activations.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
control.activations[0].rect.y,
|
||||
control.activations[0].rect.height
|
||||
),
|
||||
(356., 68.)
|
||||
);
|
||||
for diagram in [a, b] {
|
||||
let request = Representation::Saved { diagram };
|
||||
let shown = present(&project, request, call);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(
|
||||
shown.geometry_representation(),
|
||||
Some(Representation::Derived { owner })
|
||||
);
|
||||
assert_eq!(
|
||||
shown.status(),
|
||||
&PresentationStatus::Fallback {
|
||||
requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call,
|
||||
event: reply
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: true,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert!(shown.diagnostics().is_empty());
|
||||
assert_eq!(shown.bounds(), automatic.bounds());
|
||||
same_geometry(shown.scene().unwrap(), control);
|
||||
}
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() {
|
||||
let (mut project, owner) = fixture();
|
||||
let left = named(&project, "left");
|
||||
let diagram = save(&mut project, owner, None);
|
||||
let missing = Id::new_v4();
|
||||
for request in [
|
||||
Representation::Saved { diagram: missing },
|
||||
Representation::Derived { owner: left },
|
||||
] {
|
||||
let shown = present(&project, request, owner);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(shown.owner(), None);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none() && shown.bounds().is_none());
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: false,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: missing };
|
||||
assert!(matches!(
|
||||
present(&project, Representation::Saved { diagram }, owner).status(),
|
||||
PresentationStatus::Unavailable { .. }
|
||||
));
|
||||
// Deliberately invalid-model control, not a warning-admitted geometry failure.
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: owner };
|
||||
project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout(
|
||||
&[],
|
||||
&[(left, oa_core::MAX_COORDINATE + 1.)],
|
||||
)));
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error)
|
||||
);
|
||||
let before = project.clone();
|
||||
let shown = present(&project, Representation::Saved { diagram }, owner);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues }
|
||||
if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let reply = named(&project, "reply");
|
||||
let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[])));
|
||||
assert_admitted(&project);
|
||||
let mut document = Document::from_project(project, Default::default()).unwrap();
|
||||
let initial_project = document.project().clone();
|
||||
let initial_source = document.bundle().clone();
|
||||
document
|
||||
.execute(Command::Rename(first, "An intentional prior edit".into()))
|
||||
.unwrap();
|
||||
let accepted_project = document.project().clone();
|
||||
let accepted_source = document.bundle().clone();
|
||||
let revision = document.revision();
|
||||
{
|
||||
let shown = present(
|
||||
document.project(),
|
||||
Representation::Saved { diagram: view },
|
||||
first,
|
||||
);
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert!(shown.scene().is_some());
|
||||
}
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &initial_project);
|
||||
assert_eq!(document.bundle(), &initial_source);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
|
||||
oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
|
||||
oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" id "70000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
|
||||
oa_occurrence unfinished "Unfinished" id "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -1050,6 +1050,19 @@ fn post_fragment_death_cannot_disappear_and_feasible_operand_spacing_retains_its
|
||||
let (document, owner, _, second, delete, right) = alternatives_with_death();
|
||||
let mut p = document.project().clone();
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let operand = p.elements[&second].parent.unwrap();
|
||||
let fragment = p.elements[&operand].parent.unwrap();
|
||||
let baseline_frame = baseline
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == fragment)
|
||||
.unwrap()
|
||||
.rect;
|
||||
assert_eq!(
|
||||
(baseline_frame.y, baseline_frame.y + baseline_frame.height),
|
||||
(148., 567.)
|
||||
);
|
||||
assert_eq!(baseline.sequence_bounds[&operand], (433., 525.));
|
||||
let x = baseline.render_lanes[&right];
|
||||
assert_eq!(baseline.rows[&delete].1, 631.);
|
||||
assert_eq!(
|
||||
@@ -1064,6 +1077,23 @@ fn post_fragment_death_cannot_disappear_and_feasible_operand_spacing_retains_its
|
||||
);
|
||||
let view = saved(&mut p, owner, Some(positions(&[(second, 520.)], &[])));
|
||||
let resolved = result(&p, view);
|
||||
// The message still lies above the old frame bottom, but its padded
|
||||
// operand would end at 570. Retain the automatic 92px frame padding.
|
||||
assert_eq!(resolved.scene.rows[&second].1, 520.);
|
||||
assert_eq!(resolved.plan.scopes[&operand], (433., 570.));
|
||||
let frame = resolved.plan.frames[&fragment];
|
||||
assert_eq!((frame.y, frame.y + frame.height), (148., 612.));
|
||||
assert_eq!(resolved.scene.sequence_bounds[&operand], (433., 570.));
|
||||
assert_eq!(
|
||||
resolved
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == fragment)
|
||||
.unwrap()
|
||||
.rect,
|
||||
frame
|
||||
);
|
||||
assert!(resolved.scene.lifecycle.missing_geometry().is_empty());
|
||||
assert_eq!(
|
||||
resolved.scene.lifecycle.marker_at(Point::new(x, 631.)),
|
||||
|
||||
@@ -219,6 +219,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
graph.interaction = Some((context, state.tool, clean));
|
||||
let scale = graph.scale;
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
scenario_view: None,
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
|
||||
@@ -170,6 +170,13 @@ impl Surface {
|
||||
{
|
||||
"State views"
|
||||
}
|
||||
Self::Saved(id)
|
||||
if p.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.scenario().is_some()) =>
|
||||
{
|
||||
"Scenarios"
|
||||
}
|
||||
Self::Saved(_) => "Architecture",
|
||||
Self::Process(_) => "Processes and chains",
|
||||
Self::Scenario(_) => "Scenarios",
|
||||
@@ -276,6 +283,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
|
||||
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
|
||||
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 mut usages = Vec::new();
|
||||
for diagram in p
|
||||
@@ -283,6 +302,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
|
||||
.values()
|
||||
.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 surface = Surface::Saved(diagram.id);
|
||||
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
|
||||
|
||||
Reference in New Issue
Block a user