Files
ARCADIA-Studio/crates/oa-desktop/src/behavior.rs
T

3742 lines
143 KiB
Rust

//! Readable behavioral projections. Selection edits the same typed records as text.
use crate::{
app::{App, Choice, Message},
style,
views::{action, heading, label, short},
};
use iced::{
Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse,
widget::{
Action, button,
canvas::{self, Frame, Path, Stroke, Text},
column, container, pick_list, row, scrollable, text,
},
};
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod canvas_workspace;
#[cfg(test)]
mod contextual_source_tests;
pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
#[cfg(test)]
mod scenario_hit_tests;
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
#[allow(dead_code)]
pub(crate) mod scenario_layout;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
pub(crate) mod state_edit;
pub(crate) mod state_menu;
pub(crate) mod state_view;
pub(crate) mod states;
pub(crate) mod topology;
// The standard on_scroll callback only reports active scrolling. This local
// wrapper reads the existing Scrollable's actual translation as well, including
// initial mount and passive size clamping. It forwards layout, input and drawing.
mod scenario_scroll {
use super::*;
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget};
use iced::{Length, Vector, overlay};
pub(super) fn observe<'a, R: renderer::Renderer + 'a>(
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Observer {
content: content.into(),
context,
area,
process,
restore,
})
}
struct Observer<'a, R = Renderer> {
content: Element<'a, Message, Theme, R>,
context: scenario::Context,
area: scenario::ScrollArea,
process: bool,
restore: Option<scenario::ScrollRestore>,
}
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: Identity,
report: Report,
restored: Option<Id>,
}
#[derive(Default)]
struct Translation(Option<Vector>);
impl widget::Operation for Translation {
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
self.0 = Some(translation);
}
}
struct RestoreTranslation {
offset: Vector,
applied: bool,
}
impl widget::Operation for RestoreTranslation {
// Restore only this wrapper's root Scrollable, never its nested canvas.
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
fn scrollable(
&mut self,
_: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn widget::operation::Scrollable,
) {
state.scroll_to(widget::operation::scrollable::AbsoluteOffset {
x: Some(self.offset.x),
y: Some(self.offset.y),
});
self.applied = true;
}
}
impl<R: renderer::Renderer> Observer<'_, R> {
fn report(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
shell: &mut Shell<'_, Message>,
) {
let restore = self.restore.filter(|request| {
!self.process
&& (
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| {
let mut operation = RestoreTranslation {
offset: request.offset,
applied: false,
};
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut operation,
);
operation.applied.then_some(request.token)
});
let mut offset = Translation::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut offset,
);
if let Some(offset) = offset.0 {
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);
state.report = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Restored(
self.context,
token,
self.area,
offset,
)));
return;
}
let previous = &mut state.report;
if *previous != next {
*previous = next;
shell.publish(if self.process {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
topology::Context {
document: self.context.document,
owner: self.context.scenario,
revision: self.context.revision,
epoch: self.context.epoch,
},
self.area,
offset,
))
} else {
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
))
});
}
}
}
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Observer<'_, R> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<ObservedScroll>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(ObservedScroll {
identity: identity(self.context),
report: None,
restored: None,
})
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
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();
tree.children = self.children();
} else {
tree.diff_children(std::slice::from_ref(&self.content));
}
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &iced::Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
// A passive clamp notification precedes any pointer result produced
// with the old context; App rejects that result after the epoch bump.
self.report(tree, layout, renderer, shell);
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
self.report(tree, layout, renderer, shell);
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
// Iced exposes its null renderer only in debug builds.
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> {
Observer {
context,
area: scenario::ScrollArea::Page,
process: false,
restore: None,
content: scrollable(iced::widget::Space::new().width(200.).height(height))
.id("observer-test")
.width(200.)
.height(200.)
.into(),
}
}
fn update(
observer: &mut Observer<'_, ()>,
tree: &mut widget::Tree,
event: iced::Event,
) -> (Vec<Message>, bool) {
let node = observer.layout(
tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
let mut messages = vec![];
let mut shell = Shell::new(&mut messages);
observer.update(
tree,
&event,
Layout::new(&node),
mouse::Cursor::Available(Point::new(50., 50.)),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(Size::new(800., 700.)),
);
let captured = shell.is_event_captured();
(messages, captured)
}
fn offsets(messages: &[Message]) -> Vec<Vector> {
messages
.iter()
.filter_map(|message| {
if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) =
message
{
Some(*offset)
} else {
None
}
})
.collect()
}
fn actual_offset(view: &mut Observer<'_, ()>, tree: &mut widget::Tree) -> Vector {
let node = view.layout(
tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
let mut operation = Translation::default();
view.operate(tree, Layout::new(&node), &(), &mut operation);
operation.0.unwrap()
}
#[test]
fn direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt() {
use scenario::{
CanvasEvent, ScrollArea,
direct_tests::{navigation_event, navigation_fixture},
};
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let area = ScrollArea::Page;
for returned_height in [600., 250.] {
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let source = app.document.bundle().clone();
let mut view = observer(600., app.scenario_context().unwrap());
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
)
.0
{
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
view = observer(600., app.scenario_context().unwrap());
view.area = area;
view.diff(&mut tree);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, use_id);
let context = app.scenario_context().unwrap();
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
view = observer(returned_height, context);
view.area = area;
view.restore = Some(request);
view.diff(&mut tree);
let expected = Vector::new(0., (returned_height - 200.).min(180.));
let messages = update(&mut view, &mut tree, redraw()).0;
assert!(
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
);
assert_eq!(actual_offset(&mut view, &mut tree), expected);
for message in messages {
let _ = app.update(message);
}
assert!(
app.behavior
.scenario
.scroll_restore(app.scenario_context().unwrap(), area)
.is_none()
);
// A delayed receipt leaves the same request in the view; later input must not snap back.
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
}),
)
.0
{
let _ = app.update(message);
}
let moved = expected - Vector::new(0., 20.);
assert_eq!(actual_offset(&mut view, &mut tree), moved);
view.context = app.scenario_context().unwrap();
view.diff(&mut tree);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
assert_eq!(actual_offset(&mut view, &mut tree), moved);
navigation_event(&mut app, |ctx| {
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
});
let late_context = app.scenario_context().unwrap();
let _ = app.update(Message::Page(crate::app::Page::Diagram));
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
late_context,
request.token,
area,
Vector::ZERO,
)));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
}
#[test]
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
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);
view.process = mismatch == 2;
view.restore = Some(request);
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let messages = update(
&mut view,
&mut tree,
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
)
.0;
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
assert!(!messages.iter().any(|m| matches!(
m,
Message::ScenarioCanvas(scenario::CanvasEvent::Restored(..))
)));
}
}
#[test]
fn independent_process_owner_change_resets_actual_scroll_offsets() {
let context = scenario::direct_tests::context(Id::new_v4());
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let process_offsets = |messages: Vec<Message>| {
messages
.into_iter()
.filter_map(|message| {
if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
_,
_,
offset,
)) = message
{
Some(offset)
} else {
None
}
})
.collect::<Vec<_>>()
};
let mut view = observer(600., context);
view.process = true;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
process_offsets(update(&mut view, &mut tree, redraw()).0),
vec![Vector::ZERO]
);
let (messages, captured) = update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
);
assert!(captured);
assert_eq!(process_offsets(messages), vec![Vector::new(0., 180.)]);
let mut other = context;
other.scenario = Id::new_v4();
let mut state = topology::State::default();
state.enter(context.document, context.scenario);
state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.));
state.enter(other.document, other.scenario);
assert_eq!(state.details_offset(), Vector::ZERO);
view = observer(600., other);
view.process = true;
view.diff(&mut tree);
let reported = process_offsets(update(&mut view, &mut tree, redraw()).0);
assert_eq!(
reported,
vec![Vector::ZERO],
"The new process immediately reacquires its predecessor's scroll offset"
);
}
#[test]
fn direct_scroll_mounted_identity_resets_navigation_but_preserves_edits_before_first_report()
{
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
let area = scenario::ScrollArea::Page;
for change in 0..6 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
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);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if !(2..4).contains(&change) {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
}
}
}
#[test]
fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() {
let context = scenario::direct_tests::context(Id::new_v4());
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let mut view = observer(600., context);
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::ZERO]
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
let (messages, captured) = update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
);
assert!(captured, "the observer forwards normal Scrollable capture");
assert_eq!(offsets(&messages), vec![Vector::new(0., 180.)]);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
view = observer(250., context);
view.diff(&mut tree);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::new(0., 50.)]
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
view = observer(600., context);
view.diff(&mut tree);
assert_eq!(
offsets(&update(&mut view, &mut tree, redraw()).0),
vec![Vector::new(0., 180.)]
);
let mut remounted = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
assert_eq!(
offsets(&update(&mut view, &mut remounted, redraw()).0),
vec![Vector::ZERO]
);
assert!(update(&mut view, &mut remounted, redraw()).0.is_empty());
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Creation(creation::Event),
Topology(topology::Event),
Scenario(scenario::Event),
States(states::Event),
Submit,
}
impl Event {
pub fn is_source_back(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit(
_,
source_draft::Edit::Back
)))
)
}
pub fn is_source(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Source(_))
| Self::Scenario(scenario::Event::Source(_))
| Self::Topology(topology::Event::Source(_))
)
}
pub fn starts_form(&self) -> bool {
matches!(
self,
Self::Creation(creation::Event::Open(..))
| Self::Topology(topology::Event::Start(..))
| Self::Scenario(scenario::Event::Start(..))
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
)
}
pub fn is_submit(&self) -> bool {
matches!(
self,
Self::Submit
| Self::Creation(creation::Event::Submit)
| Self::Topology(topology::Event::Submit)
| Self::Scenario(scenario::Event::Submit)
| Self::States(states::Event::Submit)
)
}
}
#[derive(Default)]
pub struct State {
pub(crate) creation: creation::State,
pub active_owner: Option<Id>,
pub diagram: state_view::State,
pub(crate) topology: topology::State,
pub(crate) scenario: scenario::State,
states: states::State,
}
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RecoveryDraft {
version: u32,
kind: RecoveryKind,
draft: serde_json::Value,
}
#[derive(serde::Serialize, serde::Deserialize)]
enum RecoveryKind {
Creation,
State,
Scenario,
Process,
}
impl State {
/// Capture only the pending authored form. Pointer gestures, cached choices,
/// viewport geometry and document/revision tokens never cross a restart.
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
let drafts = [
(RecoveryKind::Creation, self.creation.recovery_snapshot()?),
(RecoveryKind::State, self.states.recovery_snapshot()?),
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
];
let mut drafts = drafts.into_iter().filter_map(|(kind, draft)| {
draft.map(|draft| RecoveryDraft {
version: 1,
kind,
draft,
})
});
let first = drafts.next();
if drafts.next().is_some() {
return Err(
"Several behavior forms are pending; recovery cannot choose one to discard.".into(),
);
}
first
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
/// Restore raw fields without planning/executing them. Rejected values and
/// incomplete endpoints must remain available for repair after a crash.
pub fn restore_recovery(&mut self, value: serde_json::Value) -> Result<(), String> {
let saved: RecoveryDraft = serde_json::from_value(value).map_err(|e| e.to_string())?;
if saved.version != 1 {
return Err(format!(
"Unsupported behavior recovery version {}",
saved.version
));
}
let mut restored = Self::default();
match saved.kind {
RecoveryKind::Creation => {
restored.creation = creation::State::from_recovery(saved.draft)?
}
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
RecoveryKind::Scenario => {
restored.scenario = scenario::State::from_recovery(saved.draft)?
}
RecoveryKind::Process => {
restored.topology = topology::State::from_recovery(saved.draft)?
}
}
restored.active_owner = restored.pending_owner();
*self = restored;
Ok(())
}
pub fn has_pending(&self) -> bool {
self.creation.has_pending() || self.pending_owner().is_some()
}
pub fn pending_owner(&self) -> Option<Id> {
self.topology
.pending_owner()
.or_else(|| self.scenario.pending_owner())
.or_else(|| self.states.pending_owner())
}
/// Prepare the active form without discarding inputs. The caller must execute
/// this exact command and acknowledge only a successful document transaction.
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
if self.creation.has_pending() {
self.creation.prepare_pending(p).map(Some)
} else if self.topology.pending_owner().is_some() {
self.topology.prepare_pending(p).map(Some)
} else if self.scenario.pending_owner().is_some() {
self.scenario.prepare_pending(p).map(Some)
} else if self.states.pending_owner().is_some() {
self.states.prepare_pending(p).map(Some)
} else {
Ok(None)
}
}
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn source_active(&self) -> bool {
self.creation.source_active()
|| self.scenario.source_active()
|| self.topology.source_active()
}
pub fn source_open(&self) -> bool {
self.creation.source_open() || self.scenario.source_open() || self.topology.source_open()
}
pub fn source_back(&mut self) -> bool {
self.creation.source_back() || self.scenario.source_back() || self.topology.source_back()
}
pub fn acknowledge_commit(&mut self) {
self.creation.acknowledge_commit();
self.topology.acknowledge_commit();
self.scenario.acknowledge_commit();
self.states.acknowledge_commit();
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
if self.has_pending()
&& matches!(
&event,
Event::Creation(creation::Event::Open(..))
| Event::Topology(topology::Event::Start(..))
| Event::Scenario(scenario::Event::Start(..))
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
)
{
return Err(PENDING_DRAFT.into());
}
let cancels_pending = match &event {
Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(),
Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(),
Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(),
_ => false,
};
if cancels_pending {
self.acknowledge_commit();
return Ok(None);
}
match event {
Event::Creation(event) => self.creation.update(p, event),
Event::Topology(event) => self.topology.update(p, event),
Event::Scenario(event) => self.scenario.update(p, event),
Event::States(event) => self.states.update(p, event),
Event::Submit => self
.prepare_pending(p)?
.map(Some)
.ok_or_else(|| "Choose a behavior modeling tool.".into()),
}
}
}
pub fn view(app: &App) -> Element<'_, Message> {
let p = app.project();
if let Some(card) = app.behavior.creation.view(p) {
return card.map(|event| Message::Behavior(Event::Creation(event)));
}
if let Some(owner) = app.behavior.pending_owner() {
let kind = p.elements.get(&owner).map(oa_core::Element::kind);
let unavailable = p.layer_of(owner) != Some(app.layer)
|| (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario))
|| (app.behavior.topology.pending_owner().is_some()
&& !matches!(kind, Some(Kind::Process | Kind::FunctionalChain)));
if unavailable {
let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12);
if let Some(name) = app
.behavior
.scenario
.pending_name()
.or_else(|| app.behavior.topology.pending_name())
{
out =
out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14));
}
if let Some(child) = app.behavior.scenario.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e))));
} else if let Some(child) = app.behavior.topology.source_view(p) {
out = out.push(child.map(|e| Message::Behavior(Event::Topology(e))));
} else {
let cancel = if app.behavior.scenario.pending_owner().is_some() {
Event::Scenario(scenario::Event::Cancel)
} else if app.behavior.topology.pending_owner().is_some() {
Event::Topology(topology::Event::Cancel)
} else {
Event::States(states::Event::Cancel)
};
out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel)));
}
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()
.filter(|e| {
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
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())
.cloned();
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
let Some(selected) = selected else {
return out
.push(label(
"Start a named process, scenario or state machine, then model its behavior here.",
))
.push(action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer))),
))
.padding(24)
.into();
};
let e = &p.elements[&selected.id];
let compact_canvas = matches!(
e.kind(),
Kind::Process | Kind::FunctionalChain | Kind::Scenario
);
if e.kind() == Kind::StateMachine {
out = column![text("States & modes").size(20)].spacing(8);
} else if compact_canvas {
// The process/scenario canvas owns its compact remaining-height layout.
out = column![].spacing(6);
}
out = out.push(
row![
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(if compact_canvas { 6 } else { 10 }),
action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer)))
)
]
.spacing(8),
);
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return topology::view(app, e.id, out);
}
if e.kind() == Kind::Scenario {
return scenario::view(app, e.id, out);
}
state_view::view(app, e.id, out)
}
pub fn selected_owner(app: &App) -> Option<Id> {
let valid = |id: &Id| {
app.project().elements.get(id).is_some_and(|e| {
e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine)
})
};
app.behavior
.pending_owner()
.or(app.behavior.active_owner.filter(valid))
.or_else(|| selected_behavior(app.project(), app.selected).filter(valid))
.or_else(|| app.project().elements.keys().find(|id| valid(id)).copied())
}
/// Mirror the core's legacy-versus-explicit process interpretation. Existing
/// exchange-only lists still describe ordered paths until explicitly migrated.
fn uses_explicit_topology(p: &Project, owner: Id) -> bool {
let Some(e) = p.elements.get(&owner) else {
return false;
};
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return false;
}
p.relations.values().any(|r| {
r.kind() == RelationKind::SequenceLink
&& p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner)
&& p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner)
}) || e.steps().into_iter().flatten().any(|id| {
matches!(
p.elements.get(id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Action { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. },
..
})
)
})
}
pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
if let Some(Relation {
body: RelationBody::ContextualLink { owner, .. },
..
}) = p.relations.get(&selected)
{
return p
.elements
.get(owner)
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.map(|e| e.id);
}
let mut current = Some(
p.relations
.get(&selected)
.filter(|r| {
matches!(
r.kind(),
RelationKind::StateTransition
| RelationKind::SequenceLink
| RelationKind::StateAction
)
})
.map(|r| r.source())
.unwrap_or(selected),
);
while let Some(id) = current {
let e = p.elements.get(&id)?;
if e.kind().is_sequence() || e.kind() == Kind::StateMachine {
return Some(id);
}
current = e.parent;
}
None
}
fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
let mut current = Some(id);
while let Some(id) = current {
if id == machine {
return true;
}
current = p.elements.get(&id).and_then(|e| e.parent);
}
false
}
/// Identifies the drawn primitive that won selection, not an authoring permission.
/// In particular an activation and its message can share an occurrence UUID.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SceneHitRole {
LifelineHeader,
MessageBody,
MessageLabel,
Activation,
LifecycleMarker,
LifelineGuide,
Box,
OtherLine,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SceneHit {
id: Id,
role: SceneHitRole,
}
struct BoxItem {
id: Id,
role: SceneHitRole,
rect: Rectangle,
title: String,
detail: String,
marker: Option<Kind>,
}
fn box_text_clip(
pan: iced::Vector,
rect: Rectangle,
scale: f32,
viewport: Size,
) -> Option<Rectangle> {
if !pan.x.is_finite()
|| !pan.y.is_finite()
|| !scale.is_finite()
|| scale <= 0.
|| rect.width <= 20.
|| rect.height <= 16.
{
return None;
}
Rectangle::new(
Point::new(
pan.x + (rect.x + 10.) * scale,
pan.y + (rect.y + 8.) * scale,
),
Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
)
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
}
struct Line {
id: Id,
role: SceneHitRole,
points: Vec<Point>,
label: String,
label_at: Point,
dashed: bool,
}
impl Line {
fn label_bounds(&self) -> Rectangle {
Rectangle::new(
self.label_at,
Size::new(
(self.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
}
}
struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
layout_origin: Point,
selected: Id,
scale: f32,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
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.
render_lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
lifecycle: scenario_lifecycle::render::Rendering,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
}
struct SequenceSlot {
slot: scenario::Slot,
y: f32,
label: String,
}
#[derive(Default)]
struct SequenceInput {
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
stamp: scenario::spacing::Stamp,
token: Id,
},
Occurrence {
tool: scenario::Tool,
slot: scenario::Slot,
lifeline: Option<Id>,
},
Message {
source: Id,
slot: scenario::Slot,
start: Point,
first: bool,
moved: bool,
},
Reorder {
occurrence: Id,
parent: Id,
start: Point,
moved: bool,
},
}
impl SequenceInput {
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
let cancel = match self.press {
Some(SequencePress::Spacing { stamp, token }) => Some(
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
scenario::spacing::Event::Cancel { stamp, token },
)))
.and_capture(),
),
_ => None,
};
self.cancel();
cancel
}
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Scene {
fn removal_target(&self, owner: Id, id: Id) -> bool {
id != owner
&& (self.boxes.iter().any(|item| item.id == id)
|| self.lines.iter().any(|item| item.id == id))
}
// The integrated removal guard also needs the derived semantic hit set.
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
Self::sequence_geometry(p, owner, selected, None, None)
}
fn sequence_geometry(
p: &Project,
owner: Id,
selected: Id,
lane_override: Option<&BTreeMap<Id, f32>>,
plan: Option<&scenario_layout::Plan>,
) -> Self {
let mut scene = Self {
scale: 1.,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
layout_origin: Point::ORIGIN,
selected,
interaction: None,
spacing_stamp: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
render_lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
lifecycle: scenario_lifecycle::render::Rendering::default(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
};
let mut lanes = p
.children(owner)
.filter(|e| e.kind() == Kind::Lifeline)
.map(|e| {
(
e.id,
e.name.clone(),
match e.shared() {
Some(SharedBody::Lifeline {
represented: Some(id),
}) => p.named(*id).to_owned(),
_ => "Unassigned participant".into(),
},
)
})
.collect::<Vec<_>>();
let scenario = p.elements[&owner].kind() == Kind::Scenario;
if !scenario {
fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec<Id>) {
for id in steps {
if let Some(SharedBody::Occurrence { body, .. }) =
p.elements.get(id).and_then(|e| e.shared())
{
match body {
OccurrenceBody::Exchange {
exchange: Some(id), ..
} => {
if let Some(r) = p.relations.get(id) {
for endpoint in [r.source(), r.target()] {
if !ids.contains(&endpoint) {
ids.push(endpoint);
}
}
}
}
OccurrenceBody::Fragment { branches, .. } => {
endpoints(p, branches, ids)
}
OccurrenceBody::Branch { occurrences, .. } => {
endpoints(p, occurrences, ids)
}
_ => {}
}
}
}
}
let mut ids = vec![];
endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids);
lanes = ids
.into_iter()
.map(|id| {
(
id,
p.named(id).to_owned(),
p.elements
.get(&id)
.map(|e| e.kind().label())
.unwrap_or("Endpoint")
.into(),
)
})
.collect();
}
let mut lane_x = lanes
.iter()
.enumerate()
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
.collect::<BTreeMap<_, _>>();
if let Some(values) = lane_override {
for (id, x) in values {
if let Some(lane) = lane_x.get_mut(id) {
*lane = *x;
}
}
}
scene.render_lanes = lane_x.clone();
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
let layout_left = if lane_override.is_some() {
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
} else {
0.
};
scene.layout_origin.x = layout_left;
if lane_override.is_some() {
scene.size.width = lane_x
.values()
.fold(scene.size.width, |right, x| right.max(*x + 240.))
- layout_left;
}
if scenario {
scene.lanes = lane_x
.iter()
.filter(|(id, _)| {
matches!(
p.elements.get(id).and_then(|e| e.shared()),
Some(SharedBody::Lifeline {
represented: Some(_)
})
)
})
.map(|(id, x)| (*id, *x))
.collect();
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
if layout_left != 0. {
for item in &mut scene.boxes {
let body = scenario_layout::occurrence(p, item.id);
if matches!(
body,
Some(
OccurrenceBody::Branch { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. }
| OccurrenceBody::Exchange { .. }
)
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
{
item.rect.x += layout_left;
}
}
}
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
owner,
&lane_x,
&scene.rows,
&scene.sequence_bounds,
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
if let Some(plan) = plan {
plan.align_message_anchors(&mut scene.lines);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
let x = lane_x[&id];
if !scenario {
scene.lines.insert(
0,
Line {
id,
role: SceneHitRole::LifelineGuide,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
}
scene.boxes.push(BoxItem {
id,
role: if scenario {
SceneHitRole::LifelineHeader
} else {
SceneHitRole::Box
},
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
detail,
marker: None,
});
}
scene
}
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
fn occurrences(
&mut self,
p: &Project,
owner: Id,
scenario: bool,
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
plan: Option<&scenario_layout::Plan>,
) {
let steps = p
.elements
.get(&owner)
.and_then(|e| {
e.steps().map(Vec::as_slice).or_else(|| {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = e.shared()
{
Some(branches.as_slice())
} else {
None
}
})
})
.unwrap_or(&[]);
let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some();
let sequence_top = *y - 42.;
for (index, id) in steps.iter().enumerate() {
let Some(e) = p.elements.get(id) else {
continue;
};
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue;
};
if scenario
&& matches!(body, OccurrenceBody::Exchange {
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
{
// Record the lifecycle receive row only after making room for
// the self-message elbow and caption above its marker.
*y += 16.;
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if let Some(plan) = plan {
*y = plan.anchors[id].y;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
before: Some(*id),
};
self.slots.push(SequenceSlot {
slot,
y: *y - 42.,
label: scenario::slot_label(p, slot),
});
self.rows.insert(*id, (owner, *y));
}
match body {
OccurrenceBody::Fragment { operator, .. } => {
let top = *y;
*y += 55.;
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
let rect = Rectangle::new(
Point::new(18. + depth as f32 * 10., top),
Size::new(
self.size.width - 36. - depth as f32 * 20.,
(*y - top).max(95.),
),
);
let rect = plan.map_or(rect, |plan| plan.frames[id]);
self.boxes.insert(
0,
BoxItem {
id: *id,
role: SceneHitRole::Box,
rect,
title: format!("{operator:?} · {}", e.name),
detail: String::new(),
marker: Some(Kind::Occurrence),
},
);
*y += if scenario { 64. } else { 20. };
}
OccurrenceBody::Branch { guard, .. } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(36. + depth as f32 * 10., *y),
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
),
title: format!(
"[{}]",
if guard.is_empty() {
"guard not specified"
} else {
guard
}
),
detail: String::new(),
marker: Some(Kind::Occurrence),
});
*y += if scenario { 90. } else { 62. };
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
}
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
message_kind,
..
} => {
let r = exchange.and_then(|id| p.relations.get(&id));
let source = if scenario {
*sender
} else {
r.map(|r| r.source())
};
let target = if scenario {
if matches!(message_kind, MessageKind::Timer { .. }) {
*sender
} else {
*receiver
}
} else {
r.map(|r| r.target())
};
if let (Some(a), Some(b)) = (
source.and_then(|id| lanes.get(&id)),
target.and_then(|id| lanes.get(&id)),
) {
let points = if source == target {
vec![
Point::new(*a, *y),
Point::new(a + 75., *y),
Point::new(a + 75., *y + 24.),
Point::new(*a, *y + 24.),
]
} else {
vec![Point::new(*a, *y), Point::new(*b, *y)]
};
let kind = if !scenario {
""
} else {
match message_kind {
MessageKind::Asynchronous => "signal · ",
MessageKind::Call => "call · ",
MessageKind::Reply => "reply · ",
MessageKind::Create => "create · ",
MessageKind::Delete => "delete · ",
MessageKind::Timer { .. } => "timer · ",
}
};
let mut message_label = if !scenario {
r.map(|r| r.name.clone()).unwrap_or_else(|| e.name.clone())
} else if let MessageKind::Timer { expression } = message_kind {
format!("{} · {expression}", e.name)
} else if let Some(exchange) = r.filter(|r| r.name != e.name) {
format!("{} · {}", e.name, exchange.name)
} else {
e.name.clone()
};
if scenario
&& let Some(operation) = crate::data_values::operation_label(p, e)
{
message_label.push_str(" · ");
message_label.push_str(&operation);
}
self.lines.push(Line {
id: *id,
role: SceneHitRole::MessageBody,
points,
label: format!("{}. {kind}{}", index + 1, message_label),
label_at: Point::new(a.min(*b) + 12., *y - 26.),
dashed: matches!(message_kind, MessageKind::Reply),
});
} else {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 65.),
),
title: e.name.clone(),
detail: "Choose an exchange and its lifelines in Properties".into(),
marker: None,
});
}
*y += 92.;
}
OccurrenceBody::Action { action, lifeline }
| OccurrenceBody::State {
state: action,
lifeline,
} => {
let x = lifeline
.and_then(|id| lanes.get(&id))
.copied()
.unwrap_or(180.);
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
title: e.name.clone(),
detail: action
.map(|id| p.named(id).to_owned())
.unwrap_or("Choose observed behavior".into()),
marker: None,
});
*y += 95.;
}
OccurrenceBody::Control { kind } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
title: format!("{kind:?} · {}", e.name),
detail: "Contextual process control".into(),
marker: None,
});
*y += 95.;
}
OccurrenceBody::SequenceReference { sequence } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
title: e.name.clone(),
detail: sequence
.map(|id| format!("Use {}", p.named(id)))
.unwrap_or("Choose a reusable process or chain".into()),
marker: None,
});
*y += 95.;
}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 70.),
),
title: format!("ref · {}", e.name),
detail: scenario
.map(|id| {
format!(
"{} · {} explicit lifeline bindings",
p.named(id),
bindings.len()
)
})
.unwrap_or("Choose a scenario".into()),
marker: Some(Kind::Occurrence),
});
*y += 95.;
}
}
}
if ordered {
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
if let Some(plan) = plan {
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
*y = plan.scopes[&owner].1 + 42.;
}
let slot = scenario::Slot {
parent: owner,
before: None,
};
self.slots.push(SequenceSlot {
slot,
y: *y - 42.,
label: scenario::slot_label(p, slot),
});
}
}
fn slot_at(&self, point: Point, parent: Option<Id>) -> Option<&SequenceSlot> {
self.slots
.iter()
.min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs()))
.filter(|slot| parent.is_none_or(|id| id == slot.slot.parent))
}
fn lane_at(&self, point: Point) -> Option<Id> {
if let Some(header) = self
.boxes
.iter()
.find(|b| self.lanes.contains_key(&b.id) && b.rect.contains(point))
{
return Some(header.id);
}
self.lanes
.iter()
.filter(|(_, x)| (**x - point.x).abs() < 12. / self.scale)
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
.map(|(id, _)| *id)
}
fn reorder_handle(&self) -> Option<Rectangle> {
let (_, y) = self.rows.get(&self.selected)?;
Some(Rectangle::new(
Point::new(6., *y - 12.),
Size::new(22., 24.),
))
}
fn hit(&self, point: Point) -> Option<Id> {
self.hit_item(point).map(|hit| hit.id)
}
/// Preserve the semantic selection order and tolerance while retaining the
/// primitive's identity. Future spacing admission must also validate the
/// saved view, owner and core occurrence kind; a hit alone grants no edit.
fn hit_item(&self, point: Point) -> Option<SceneHit> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
});
}
self.boxes
.iter()
.rev()
.find(|b| {
!b.title.is_empty()
&& b.rect.contains(point)
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
})
.map(|b| SceneHit {
id: b.id,
role: b.role,
})
.or_else(|| {
self.lines
.iter()
.find(|line| {
!line.label.is_empty()
&& line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
.or_else(|| {
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point))
.map(|b| SceneHit {
id: b.call,
role: SceneHitRole::Activation,
})
})
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.map(|line| SceneHit {
id: line.id,
role: if line.role == SceneHitRole::MessageBody {
SceneHitRole::MessageLabel
} else {
SceneHitRole::OtherLine
},
})
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| self.near_sequence_item(point))
.or_else(|| {
self.lifecycle
.lane_at(point, self.scale)
.map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lane_at(point).map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lines
.iter()
.find(|line| {
line.label.is_empty()
&& line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1])
< 8. / self.scale
})
})
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
}
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
let messages = self
.lines
.iter()
.filter(|line| !line.label.is_empty())
.map(|line| {
let distance = line
.points
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.fold(f32::INFINITY, f32::min);
(
SceneHit {
id: line.id,
role: line.role,
},
distance,
)
});
// Compare broad message and lifecycle-marker tolerance together after
// exact shapes, so dense minimum-Fit rows choose the nearer item.
self.lifecycle
.nearest_marker(point)
.map(|(id, distance)| {
(
SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
},
distance,
)
})
.into_iter()
.chain(messages)
.filter(|(_, distance)| *distance <= 8. / self.scale)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
.map(|(hit, _)| hit)
}
}
impl canvas::Program<Message> for Scene {
type State = SequenceInput;
fn update(
&self,
state: &mut SequenceInput,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
use scenario::{CanvasEvent, CanvasTool};
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context || state.tool != tool {
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
// Scrolling either enclosing Scrollable changes the canvas origin. A
// captured insertion slot belongs to the previous view, even if the
// pointer itself did not move and the semantic context is unchanged.
if state.bounds.is_some_and(|previous| previous != bounds) {
let cancelled = state.cancel_spacing();
state.pointer = None;
state.bounds = Some(bounds);
if cancelled.is_some() {
return cancelled;
}
}
state.bounds = Some(bounds);
if (!editable || !self.coordinate_input)
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
}
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& matches!(
event,
canvas::Event::Mouse(
mouse::Event::CursorMoved { .. }
| mouse::Event::ButtonReleased(mouse::Button::Left)
)
)
{
return state.cancel_spacing();
}
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(press) = &mut state.press {
match press {
SequencePress::Message { start, moved, .. }
| SequencePress::Reorder { start, moved, .. } => {
*moved |= position.distance(*start) >= 3.
}
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
}
}
mouse::Cursor::Available(*position)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
| canvas::Event::Window(iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Window(iced::window::Event::Unfocused) => {
let cancelled = state.cancel_spacing();
state.pointer = None;
return cancelled.or_else(|| Some(Action::request_redraw()));
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position();
cursor
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
modifiers,
repeat: false,
..
}) if editable
&& tool == CanvasTool::Select
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
&& state.press.is_none()
&& state.source.is_none() =>
{
let context = context?;
if self.removal_target(context.scenario, self.selected) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
context,
self.selected,
)))
.and_capture(),
);
}
return None;
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
}
_ => cursor,
};
let world = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
(p.y - self.camera_pan.y) / self.scale,
)
});
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if !visible {
return None;
}
let point = world?;
if let Some(context) = context {
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(|| {
Some((self.lane_at(point)?, self.slot_at(point, None)?.slot))
})?;
state.source = Some((source, slot));
state.press = Some(SequencePress::Message {
source,
slot,
start: cursor.position()?,
first,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
if let CanvasTool::Add(tool) = tool {
if !tool.canvas_occurrence() && tool != scenario::Tool::Lifeline {
return None;
}
state.press = Some(SequencePress::Occurrence {
tool,
slot: self.slot_at(point, None)?.slot,
lifeline: tool.uses_lifeline().then(|| self.lane_at(point)).flatten(),
});
return Some(Action::request_redraw().and_capture());
}
if matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
) {
let hit = self.hit_item(point)?;
let target = match hit.role {
SceneHitRole::LifelineHeader => {
Some(scenario::spacing::Target::Lane(hit.id))
}
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
Some(scenario::spacing::Target::Message(hit.id))
}
_ => None,
};
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
&& stamp.context == context
{
let token = Id::new_v4();
state.press = Some(SequencePress::Spacing { stamp, token });
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Begin {
stamp,
token,
target,
press_screen: cursor.position()?,
canvas_bounds: bounds,
scale: self.scale,
pan: self.camera_pan,
},
)))
.and_capture(),
);
}
// Saved spacing never silently changes the shared Scenario order.
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, hit.id,
)))
.and_capture(),
);
}
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
.or_else(|| {
self.reorder_handle()
.filter(|r| r.contains(point))
.map(|_| self.selected)
})
{
state.press = Some(SequencePress::Reorder {
occurrence,
parent: self.rows[&occurrence].0,
start: cursor.position()?,
moved: false,
});
return Some(Action::request_redraw().and_capture());
}
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, id)))
.and_capture()
});
}
self.hit(point)
.map(|id| Action::publish(Message::Select(id)).and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
SequencePress::Spacing { stamp, token } => {
state.source = None;
let event = if world.is_some() {
scenario::spacing::Event::Finish {
stamp,
token,
screen: cursor.position()?,
}
} else {
scenario::spacing::Event::Cancel { stamp, token }
};
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
.and_capture(),
);
}
SequencePress::Occurrence {
tool: captured,
slot,
lifeline,
} if tool == CanvasTool::Add(captured) && world.is_some() => {
return Some(
Action::publish(Message::ScenarioCanvas(
CanvasEvent::BeginOccurrence {
context,
tool: captured,
slot,
lifeline,
},
))
.and_capture(),
);
}
SequencePress::Message { source, slot, .. }
if tool == CanvasTool::Message(scenario::MessageStyle::Timer)
&& world.is_some() =>
{
state.cancel();
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin {
context,
slot,
sender: source,
receiver: source,
style: scenario::MessageStyle::Timer,
}))
.and_capture(),
);
}
SequencePress::Message {
source,
slot,
first,
moved,
..
} if moved || !first => {
if let Some(receiver) = world.and_then(|p| self.lane_at(p)) {
let CanvasTool::Message(style) = tool else {
return None;
};
state.cancel();
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin {
context,
slot,
sender: source,
receiver,
style,
}))
.and_capture(),
);
}
}
SequencePress::Reorder {
occurrence,
moved: false,
..
} => {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, occurrence,
)))
.and_capture(),
);
}
SequencePress::Reorder {
occurrence,
parent,
moved: true,
..
} => {
if let Some(slot) = world.and_then(|p| self.slot_at(p, Some(parent))) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence,
slot: slot.slot,
}))
.and_capture(),
);
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Move {
stamp,
token,
screen: cursor.position()?,
},
)))
.and_capture(),
);
}
Some(Action::request_redraw())
}
_ => None,
}
}
fn draw(
&self,
state: &SequenceInput,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
// Guides belong behind execution fills and cards. In particular, a
// lifeline must not cut through the middle of its activation rectangle.
for guide in self.lines.iter().filter(|line| line.label.is_empty()) {
f.stroke(
&Path::new(|path| {
path.move_to(guide.points[0]);
for point in &guide.points[1..] {
path.line_to(*point);
}
}),
Stroke::default()
.with_color(if guide.id == self.selected {
style::TEAL
} else {
style::MUTED
})
.with_width(if guide.id == self.selected { 2.5 } else { 1.2 }),
);
}
// Label backplates clear guides, but must not erase execution borders
// or message geometry. Their text remains in the foreground below.
for line in self.lines.iter().filter(|line| !line.label.is_empty()) {
f.fill_rectangle(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
22.,
),
Color::WHITE,
);
}
for activation in &self.activations {
let path = Path::rectangle(activation.rect.position(), activation.rect.size());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
&path,
Stroke::default()
.with_color(if activation.call == self.selected {
style::TEAL
} else {
style::BOUNDARY
})
.with_width(if activation.call == self.selected {
2.5
} else {
1.
}),
);
}
for b in &self.boxes {
let outline = matches!(
b.marker,
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
);
let color = if b.id == self.selected {
style::TEAL
} else {
style::BOUNDARY
};
if outline {
f.stroke(
&Path::rectangle(b.rect.position(), b.rect.size()),
Stroke::default().with_color(color),
);
} else {
let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into());
f.fill(&path, Color::from_rgb8(243, 248, 248));
f.stroke(
&path,
Stroke::default()
.with_color(color)
.with_width(if b.id == self.selected { 2.5 } else { 1. }),
);
}
}
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
let color = if l.id == self.selected {
style::TEAL
} else {
style::MUTED
};
f.stroke(
&Path::new(|p| {
p.move_to(l.points[0]);
for point in &l.points[1..] {
p.line_to(*point);
}
}),
Stroke {
line_dash: canvas::LineDash {
segments: if l.dashed { &[6., 4.] } else { &[] },
offset: 0,
},
..Stroke::default()
.with_color(color)
.with_width(if l.id == self.selected { 2.5 } else { 1.2 })
},
);
if !l.label.is_empty() {
let end = l.points[l.points.len() - 1];
let prev = l.points[l.points.len() - 2];
let delta = end - prev;
let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.);
let d = delta / len;
f.stroke(
&Path::new(|p| {
p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.);
p.line_to(end);
p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.);
}),
Stroke::default().with_color(color),
);
f.fill_text(Text {
content: short(&l.label, 32),
position: l.label_at,
color,
size: 12.into(),
..Default::default()
});
}
}
for b in &self.boxes {
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
// WGPU child clips start with an identity transform, as in
// the lifecycle capsule label painter.
f.with_clip(clip, |label| {
label.translate(self.camera_pan);
label.scale(self.scale);
label.fill_text(Text {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
} else {
style::INK
},
..Default::default()
});
if !b.detail.is_empty() {
label.fill_text(Text {
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
});
}
}
for point in &self.open_calls {
f.fill_text(Text {
content: "…".into(),
position: *point,
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction {
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,
(p.y - self.camera_pan.y) / self.scale,
)
});
let source = active.then_some(state.source).flatten();
let reorder = active.then_some(state.press).flatten().and_then(|press| {
if let SequencePress::Reorder { parent, .. } = press {
Some(parent)
} else {
None
}
});
if editable
&& self.coordinate_input
&& tool == scenario::CanvasTool::Select
&& matches!(
context.representation,
scenario_layout::Representation::Derived { .. }
)
&& let Some(handle) = self.reorder_handle()
{
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
f.fill_text(Text {
content: "↕".into(),
position: handle.position() + iced::Vector::new(5., 2.),
size: 16.into(),
color: Color::WHITE,
..Default::default()
});
}
let placement = self.pending_placement.or_else(|| {
active.then_some(state.press).flatten().and_then(|press| {
if let SequencePress::Occurrence { slot, lifeline, .. } = press {
Some((slot, lifeline))
} else {
None
}
})
});
let slot = if let Some((slot, _)) = placement {
self.slots.iter().find(|s| s.slot == slot)
} else if let Some((_, slot)) = source {
self.slots.iter().find(|s| s.slot == slot)
} else if editable
&& (matches!(
tool,
scenario::CanvasTool::Message(_) | scenario::CanvasTool::Add(_)
) || reorder.is_some())
{
point.and_then(|p| self.slot_at(p, reorder))
} else {
None
};
if reorder.is_some()
&& slot.is_none()
&& let Some(point) = point
{
f.fill_text(Text {
content: "Other operand: move stays in the current sequence".into(),
position: Point::new(point.x + 8., point.y + 12.),
size: (11. / self.scale).into(),
color: Color::from_rgb(0.75, 0.2, 0.15),
..Default::default()
});
}
if let Some(slot) = slot {
f.stroke(
&Path::line(
Point::new(32., slot.y),
Point::new(self.size.width - 24., slot.y),
),
Stroke::default().with_color(style::TEAL).with_width(1.5),
);
f.fill_rectangle(
Point::new(34., slot.y - 20.),
Size::new((self.size.width - 70.).min(650.), 19.),
Color::WHITE,
);
f.fill_text(Text {
content: short(
&format!(
"{}: {}",
if self.pending_placement.is_some() {
"Pending"
} else {
"Insert"
},
slot.label
),
88,
),
position: Point::new(38., slot.y - 19.),
size: 11.into(),
color: style::TEAL,
..Default::default()
});
if let Some(x) = placement
.and_then(|(_, lifeline)| lifeline)
.and_then(|id| self.lanes.get(&id))
{
f.fill(&Path::circle(Point::new(*x, slot.y), 5.), style::TEAL);
}
if let Some((source, _)) = source
&& let Some(x) = self.lanes.get(&source)
&& let Some(point) = point
{
let to = self
.lane_at(point)
.and_then(|id| self.lanes.get(&id))
.copied()
.unwrap_or(point.x);
f.stroke(
&Path::line(Point::new(*x, slot.y), Point::new(to, slot.y)),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
f.fill(&Path::circle(Point::new(to, slot.y), 5.), style::TEAL);
}
}
}
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &SequenceInput,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.is_over(bounds) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
#[cfg(test)]
mod direct_canvas_tests {
use super::*;
use scenario::{CanvasEvent, CanvasTool, MessageStyle};
fn scene() -> (scenario::direct_tests::Fixture, Scene) {
let f = scenario::direct_tests::fixture();
let mut scene = Scene::sequence(&f.project, f.owner, f.call);
scene.interaction = Some((
scenario::direct_tests::context(f.owner),
CanvasTool::Message(MessageStyle::CallReply),
true,
));
(f, scene)
}
fn send(
scene: &Scene,
state: &mut SequenceInput,
event: mouse::Event,
final_cursor: Point,
bounds: Rectangle,
) -> Option<CanvasEvent> {
canvas::Program::update(
scene,
state,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(final_cursor),
)
.and_then(|action| action.into_inner().0)
.and_then(|message| {
if let Message::ScenarioCanvas(event) = message {
Some(event)
} else {
None
}
})
}
#[test]
fn reused_exchange_messages_display_their_distinct_occurrence_names() {
let mut f = scenario::direct_tests::fixture();
f.project.elements.get_mut(&f.call).unwrap().name = "Route request".into();
f.project.elements.get_mut(&f.reply).unwrap().name = "Route response".into();
let scene = Scene::sequence(&f.project, f.owner, f.call);
let call = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let reply = scene.lines.iter().find(|line| line.id == f.reply).unwrap();
assert!(call.label.starts_with("1. call · Route request"));
assert!(reply.label.starts_with("2. reply · Route response"));
assert!(call.label.ends_with(&f.project.relations[&f.exchange].name));
assert!(
reply
.label
.ends_with(&f.project.relations[&f.exchange].name)
);
}
#[test]
fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() {
for drag in [false, true] {
let (f, scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::new(30., 50.), scene.size);
let a = Point::new(
bounds.x + scene.lanes[&f.sender],
bounds.y + scene.slots[0].y,
);
let b = Point::new(bounds.x + scene.lanes[&f.receiver], a.y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: a },
b,
bounds,
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds
)
.is_none()
);
if !drag {
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
assert_eq!(state.source.unwrap().0, f.sender);
}
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
if !drag {
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds,
);
}
let result = send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds,
);
assert!(
matches!(result, Some(CanvasEvent::Begin { sender, receiver, slot, .. }) if sender == f.sender && receiver == f.receiver && slot.before == Some(f.call))
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
assert!(state.source.is_none() && state.press.is_none());
}
}
#[test]
fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() {
let (f, mut scene) = scene();
scene.camera_pan = iced::Vector::new(-35., -140.);
let bounds = Rectangle::new(Point::new(20., 230.), scene.size);
let slot = scene.slots.last().unwrap().slot;
let y = scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let mut state = SequenceInput::default();
// Batched pointer events use their own positions with the final cursor.
for event in [
mouse::Event::CursorMoved { position: a },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
] {
send(&scene, &mut state, event, b, bounds);
}
assert!(
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
);
}
#[test]
fn direct_scroll_resize_and_bounds_changes_cancel_captured_slots() {
for change in 0..5 {
let (f, scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::new(20., 70.), scene.size);
let a = Point::new(
bounds.x + scene.lanes[&f.sender],
bounds.y + scene.slots[0].y,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds,
);
assert!(state.source.is_some());
let changed = match change {
0 => Rectangle::new(
bounds.position() - iced::Vector::new(15., 32.),
bounds.size(),
),
1 => Rectangle::new(bounds.position(), bounds.size() + Size::new(20., 10.)),
_ => bounds,
};
let event = match change {
2 => canvas::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
}),
3 => canvas::Event::Window(iced::window::Event::Resized(Size::new(1100., 700.))),
_ => canvas::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
)),
};
let cursor = if change == 4 {
mouse::Cursor::Unavailable
} else {
mouse::Cursor::Available(a)
};
let event = if change == 4 {
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
} else {
event
};
canvas::Program::update(&scene, &mut state, &event, changed, cursor);
assert!(
state.source.is_none() && state.press.is_none(),
"change {change} must cancel the old slot"
);
let b = Point::new(
changed.x + scene.lanes[&f.receiver],
changed.y + scene.slots.last().unwrap().y,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
changed,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
changed,
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
changed
)
.is_none(),
"a click after change {change} must start a fresh gesture"
);
assert_eq!(state.source.unwrap().0, f.receiver);
assert_ne!(state.source.unwrap().1.before, Some(f.call));
}
}
#[test]
fn direct_bounds_change_during_held_drag_never_commits_old_slot() {
let (f, scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
let b = Point::new(scene.lanes[&f.receiver], a.y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
let shifted = Rectangle::new(Point::new(0., -40.), scene.size);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
shifted
)
.is_none()
);
assert!(state.source.is_none() && state.press.is_none());
}
#[test]
fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() {
for cancellation in 0..5 {
let (f, mut scene) = scene();
let mut state = SequenceInput::default();
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
let b = Point::new(scene.lanes[&f.receiver], a.y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
match cancellation {
0 => {
scene.interaction.as_mut().unwrap().0.revision += 1;
}
1 => {
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
}
2 => {
scene.interaction.as_mut().unwrap().2 = false;
}
3 => {
canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds,
mouse::Cursor::Unavailable,
);
}
_ => {
canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
bounds,
mouse::Cursor::Available(b),
);
}
}
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
assert!(state.press.is_none() && state.source.is_none());
}
}
#[test]
fn direct_clicked_occurrence_capture_cancels_on_view_and_context_changes() {
for tool in [
scenario::Tool::Action,
scenario::Tool::Observation,
scenario::Tool::Reuse,
scenario::Tool::Fragment,
] {
for cancellation in 0..9 {
let (f, mut scene) = scene();
scene.interaction.as_mut().unwrap().1 = CanvasTool::Add(tool);
let mut state = SequenceInput::default();
let mut bounds = Rectangle::new(Point::ORIGIN, scene.size);
let point = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: point },
point,
bounds,
);
assert!(
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds
)
.is_none()
);
assert!(matches!(
state.press,
Some(SequencePress::Occurrence { .. })
));
let cancellation_event = match cancellation {
0 => {
scene.interaction.as_mut().unwrap().0.epoch += 1;
None
}
1 => {
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
None
}
2 => {
scene.interaction.as_mut().unwrap().2 = false;
None
}
3 => Some(canvas::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
})),
4 => Some(canvas::Event::Window(iced::window::Event::Resized(
Size::new(1100., 700.),
))),
5 => {
bounds.y -= 30.;
None
}
6 => Some(canvas::Event::Window(iced::window::Event::Unfocused)),
7 => Some(canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
})),
_ => None,
};
if let Some(event) = cancellation_event {
let action = canvas::Program::update(
&scene,
&mut state,
&event,
bounds,
mouse::Cursor::Available(point),
);
assert!(action.and_then(|action| action.into_inner().0).is_none());
}
let cursor = if cancellation == 8 {
mouse::Cursor::Unavailable
} else {
mouse::Cursor::Available(point)
};
let released = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
cursor,
);
assert!(
released.and_then(|action| action.into_inner().0).is_none(),
"{tool:?} cancellation {cancellation}"
);
assert!(state.press.is_none());
}
}
}
#[test]
fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() {
let (f, mut scene) = scene();
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
let header = scene
.boxes
.iter()
.find(|b| b.id == f.sender)
.unwrap()
.rect
.center();
assert_eq!(scene.hit(header), Some(f.sender));
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.sender], scene.size.height - 28.)),
Some(f.sender)
);
let line = scene.lines.iter().find(|line| line.id == f.reply).unwrap();
assert_eq!(
scene.hit(line.label_at + iced::Vector::new(4., 4.)),
Some(f.reply)
);
let mut state = SequenceInput::default();
let start = scene.reorder_handle().unwrap().center();
let end = Point::new(start.x, scene.slots.last().unwrap().y);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: start },
start,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
start,
bounds,
);
send(
&scene,
&mut state,
mouse::Event::CursorMoved { position: end },
end,
bounds,
);
assert!(
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, bounds), Some(CanvasEvent::Reorder { occurrence, slot, .. }) if occurrence == f.call && slot.parent == f.owner && slot.before.is_none())
);
}
#[test]
fn direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand() {
use super::topology::occurrence;
let f = scenario::direct_tests::fixture();
let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
let fragment = occurrence(
&p,
f.owner,
"Alternatives",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let fragment_id = fragment.id;
let mut commands = vec![Command::CreateElement(fragment)];
let mut branches = vec![];
let mut replies = vec![];
let mut actions = vec![];
for index in 0..3 {
let mut branch = occurrence(
&p,
f.owner,
&format!("Operand {index}"),
OccurrenceBody::Branch {
guard: format!("path {index}"),
occurrences: vec![],
},
);
branch.parent = Some(fragment_id);
let branch_id = branch.id;
branches.push(branch_id);
commands.push(Command::CreateElement(branch));
let mut steps = vec![];
if index < 2 {
let mut reply = occurrence(
&p,
f.owner,
&format!("Reply {index}"),
OccurrenceBody::Exchange {
operation: None,
exchange: Some(f.exchange),
sender: Some(f.receiver),
receiver: Some(f.sender),
message_kind: MessageKind::Reply,
reply_to: Some(f.call),
values: vec![],
},
);
reply.parent = Some(branch_id);
replies.push(reply.id);
steps.push(reply.id);
commands.push(Command::CreateElement(reply));
}
let mut action = occurrence(
&p,
f.owner,
&format!("Action {index}"),
OccurrenceBody::Action {
action: Some(p.relations[&f.exchange].target()),
lifeline: Some(f.receiver),
},
);
action.parent = Some(branch_id);
actions.push(action.id);
steps.push(action.id);
commands.push(Command::CreateElement(action));
commands.push(Command::SetSteps(branch_id, steps));
}
if let Command::CreateElement(element) = &mut commands[0]
&& let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Fragment {
branches: stored, ..
},
..
}) = element.shared_mut()
{
*stored = branches.clone();
}
p = execute_batch(&p, &commands).unwrap();
let scene = Scene::sequence(&p, f.owner, f.call);
let bars: Vec<_> = scene
.activations
.iter()
.filter(|b| b.call == f.call)
.map(|b| b.rect)
.collect();
for reply in &replies {
let y = scene.rows[reply].1;
assert!(
bars.iter().any(|r| r.y <= y && r.y + r.height == y),
"every reply terminates its own operand segment"
);
}
for action in &actions[..2] {
let y = scene.rows[action].1;
assert!(
!bars.iter().any(|r| r.y <= y && r.y + r.height > y),
"a returned operand must not remain active through its following action"
);
}
let last_action = scene.rows[&actions[2]].1;
assert!(
scene.open_calls.iter().any(|p| p.y > last_action),
"the reply-free operand remains explicitly open"
);
let header = scene
.boxes
.iter()
.find(|b| b.id == branches[1] && !b.title.is_empty())
.unwrap()
.rect;
assert!(
!bars
.iter()
.any(|r| r.y < header.y + header.height && r.y + r.height > header.y),
"segments do not join mutually exclusive operand headers"
);
}
#[test]
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
let (f, scene) = scene();
let bar = scene.activations.iter().find(|b| b.call == f.call).unwrap();
assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1);
assert!(scene.open_calls.is_empty());
let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
let scene = Scene::sequence(&p, f.owner, f.call);
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
scene
.activations
.iter()
.find(|b| b.call == f.call)
.unwrap()
.rect
.height,
26.
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recovery_retains_each_incomplete_behavior_form_without_executing_it() {
let p = Project::demo();
let before = p.clone();
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
let machine = owner(Kind::StateMachine);
let scenario = owner(Kind::Scenario);
let process = owner(Kind::Process);
let cases = [
(
machine,
vec![
Event::States(states::Event::Start(
machine,
states::Tool::Transition,
machine,
)),
Event::States(states::Event::Name("Unfinished timeout".into())),
Event::States(states::Event::Timed(true)),
Event::States(states::Event::Expression("-".into())),
Event::States(states::Event::Guard("temperature >".into())),
],
),
(
scenario,
vec![
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
Event::Scenario(scenario::Event::Name("Unfinished request".into())),
Event::Scenario(scenario::Event::Expression("-".into())),
],
),
(
process,
vec![
Event::Topology(topology::Event::Start(
process,
topology::Tool::Connect,
None,
)),
Event::Topology(topology::Event::Name("Unfinished path".into())),
Event::Topology(topology::Event::Guard("available &&".into())),
],
),
];
for (owner, events) in cases {
let mut original = State::default();
for event in events {
original.update(&p, event).unwrap();
}
assert!(original.prepare_pending(&p).is_err());
let snapshot = original.recovery_snapshot().unwrap().unwrap();
let bytes = serde_json::to_vec(&snapshot).unwrap();
let mut recovered = State::default();
recovered
.restore_recovery(serde_json::from_slice(&bytes).unwrap())
.unwrap();
assert_eq!(recovered.pending_owner(), Some(owner));
assert_eq!(recovered.active_owner, Some(owner));
assert!(
recovered.prepare_pending(&p).is_err(),
"An incomplete recovered form is not applied or silently repaired"
);
assert_eq!(recovered.recovery_snapshot().unwrap(), Some(snapshot));
assert_eq!(p, before);
recovered.acknowledge_commit();
assert!(recovered.recovery_snapshot().unwrap().is_none());
}
}
#[test]
fn recovery_preserves_distinct_qualified_value_paths_and_rejects_invalid_payload_atomically() {
let p = Project::demo();
let scenario = p
.elements
.values()
.find(|e| e.kind() == Kind::Scenario)
.unwrap()
.id;
let field = Id::new_v4();
let paths = [Id::new_v4(), Id::new_v4()];
let mut state = State::default();
state
.update(
&p,
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Message)),
)
.unwrap();
for (path, value) in paths.into_iter().zip(["-", "3.4e"]) {
state
.update(
&p,
Event::Scenario(scenario::Event::Value(
crate::data_values::FieldPathKey {
path: vec![path],
field,
},
value.into(),
)),
)
.unwrap();
}
let original = state.recovery_snapshot().unwrap().unwrap();
let mut recovered = State::default();
recovered.restore_recovery(original.clone()).unwrap();
assert_eq!(
recovered.recovery_snapshot().unwrap(),
Some(original.clone())
);
let values = original["draft"]["values"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_ne!(values[0][0], values[1][0]);
assert_eq!(values[0][1], values[1][1]);
for bad in [
{
let mut value = original.clone();
value["version"] = 99.into();
value
},
{
let mut value = original.clone();
value["draft"]["unknown"] = true.into();
value
},
{
let mut value = original.clone();
value["draft"]["values"]
.as_array_mut()
.unwrap()
.push(values[0].clone());
value
},
] {
assert!(recovered.restore_recovery(bad).is_err());
assert_eq!(
recovered.recovery_snapshot().unwrap(),
Some(original.clone()),
"Malformed recovery cannot discard the currently retained form"
);
}
}
use crate::document::{Document, DocumentError};
use oa_core::source_project::{self, ProjectLimits};
#[test]
fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() {
let p = Project::demo();
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
let process = owner(Kind::Process);
let scenario = owner(Kind::Scenario);
let machine = owner(Kind::StateMachine);
let transition = p
.relations
.values()
.find(|r| r.kind() == RelationKind::StateTransition)
.unwrap()
.id;
let starts = [
Event::Topology(topology::Event::Start(
process,
topology::Tool::Control,
None,
)),
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)),
Event::States(states::Event::Start(machine, states::Tool::Region, machine)),
];
let names = [
Event::Topology(topology::Event::Name("Keep this draft".into())),
Event::Scenario(scenario::Event::Name("Keep this draft".into())),
Event::States(states::Event::Name("Keep this draft".into())),
];
let cancels = [
Event::Topology(topology::Event::Cancel),
Event::Scenario(scenario::Event::Cancel),
Event::States(states::Event::Cancel),
];
for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() {
let mut state = State::default();
assert!(!state.has_pending());
assert!(state.prepare_pending(&p).unwrap().is_none());
state.update(&p, starts[index].clone()).unwrap();
state.update(&p, names[index].clone()).unwrap();
if index == 1 {
state
.update(
&p,
Event::Scenario(scenario::Event::Expression("approved".into())),
)
.unwrap();
}
assert_eq!(state.pending_owner(), Some(expected_owner));
for replacement in starts
.iter()
.cloned()
.chain([Event::States(states::Event::Edit(transition))])
{
assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT);
assert_eq!(state.pending_owner(), Some(expected_owner));
}
state.update(&p, cancels[(index + 1) % 3].clone()).unwrap();
assert!(state.has_pending(), "Another editor's cancel is stale");
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.has_pending(), "Preparation must retain the form");
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
assert!(expected.elements.values().any(|e| {
!p.elements.contains_key(&e.id)
&& e.name == "Keep this draft"
&& e.parent == Some(expected_owner)
}));
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
state.acknowledge_commit();
assert!(!state.has_pending());
assert!(state.prepare_pending(document.project()).unwrap().is_none());
assert_eq!(document.revision(), 1);
assert!(document.undo());
assert_eq!(document.project(), &p);
assert!(!document.can_undo());
state.update(&p, starts[index].clone()).unwrap();
state.update(&p, cancels[index].clone()).unwrap();
assert!(!state.has_pending(), "Explicit cancel discards this form");
state.update(&p, starts[(index + 1) % 3].clone()).unwrap();
}
}
#[test]
fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
.unwrap();
let source = p
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
.unwrap();
let target = p
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
.unwrap();
let work = p
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
.unwrap();
let choice = |id| Choice {
id,
label: p.named(id).into(),
};
let mut state = State::default();
for event in [
states::Event::Start(machine, states::Tool::Transition, source),
states::Event::Name("Transition".repeat(1024)),
states::Event::Trigger(choice(work)),
states::Event::Effect(choice(work)),
states::Event::TriggerNotes("On a monitor result".into()),
states::Event::Guard("connection unavailable".into()),
states::Event::EffectNotes("Record the timeout".into()),
states::Event::Timed(true),
states::Event::TimeKind("At".into()),
states::Event::Expression("12:30 UTC".into()),
] {
state.update(&p, Event::States(event)).unwrap();
}
assert!(
state.prepare_pending(&p).is_err(),
"A target is still missing"
);
assert_eq!(state.pending_owner(), Some(machine));
state
.update(&p, Event::States(states::Event::Target(choice(target))))
.unwrap();
let command = state.prepare_pending(&p).unwrap().unwrap();
let bundle = source_project::format_bundle(&p);
let mut limits = ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 2000;
let mut document = Document::from_bundle(bundle.clone(), limits).unwrap();
assert!(matches!(
document.execute(command),
Err(DocumentError::Source(_))
));
assert_eq!(document.project(), &p);
assert_eq!(document.bundle(), &bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo());
assert!(state.has_pending());
state
.update(
&p,
Event::States(states::Event::Name("Wait for recovery".into())),
)
.unwrap();
let command = state.prepare_pending(&p).unwrap().unwrap();
let Command::Batch(commands) = &command else {
panic!("Expected one atomic transition batch")
};
assert_eq!(commands.len(), 2);
let Command::CreateElement(event) = &commands[0] else {
panic!("Expected a typed time event")
};
assert!(
matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC")
);
let Command::CreateRelation(relation) = &commands[1] else {
panic!("Expected the transition")
};
let RelationBody::StateTransition {
triggers,
effects,
trigger,
guard,
effect,
..
} = &relation.body
else {
panic!()
};
assert_eq!(triggers, &[work, event.id]);
assert_eq!(effects, &[work]);
assert_eq!(trigger, "On a monitor result");
assert_eq!(guard, "connection unavailable");
assert_eq!(effect, "Record the timeout");
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
document.execute(command).unwrap();
assert_eq!(
document.project(),
&expected,
"Commit the exact prepared identities"
);
state.acknowledge_commit();
assert!(!state.has_pending());
assert_eq!(document.revision(), 1);
assert!(document.undo());
assert_eq!(document.bundle(), &bundle);
assert_eq!(document.project(), &p);
assert!(!document.can_undo());
}
#[test]
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
let p = Project::demo();
let owners: Vec<_> = p
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
.collect();
assert!(owners.iter().any(|e| e.kind() == Kind::Process));
assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain));
for owner in owners {
assert!(!uses_explicit_topology(&p, owner.id));
let expected: Vec<_> = owner
.steps()
.unwrap()
.iter()
.enumerate()
.map(|(index, id)| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
},
..
}) = p.elements[id].shared()
else {
panic!("Demo legacy sequence should contain exchange occurrences");
};
format!("{}. {}", index + 1, p.relations[exchange].name)
})
.collect();
assert!(!expected.is_empty());
let scene = Scene::sequence(&p, owner.id, owner.id);
let actual: Vec<_> = scene
.lines
.iter()
.filter(|line| !line.label.is_empty())
.map(|line| line.label.clone())
.collect();
assert_eq!(
actual, expected,
"Legacy rendering must preserve the model's ordered exchange path"
);
}
}
#[test]
fn selecting_state_transition_keeps_its_behavior_context() {
let p = Project::demo();
for r in p
.relations
.values()
.filter(|r| r.kind() == RelationKind::StateTransition)
{
let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine");
assert_eq!(p.elements[&machine].kind(), Kind::StateMachine);
assert!(in_machine(&p, r.source(), machine));
assert!(in_machine(&p, r.target(), machine));
}
}
}