//! 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 scenario; 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>( context: scenario::Context, area: scenario::ScrollArea, content: impl Into>, ) -> Element<'a, Message> { Element::new(Observer { content: content.into(), context, area, process: false, }) } pub(super) fn observe_topology<'a>( context: topology::Context, area: scenario::ScrollArea, content: impl Into>, ) -> Element<'a, Message> { Element::new(Observer { content: content.into(), context: scenario::Context { document: context.document, scenario: context.owner, revision: context.revision, epoch: context.epoch, }, area, process: true, }) } struct Observer<'a, R = Renderer> { content: Element<'a, Message, Theme, R>, context: scenario::Context, area: scenario::ScrollArea, process: bool, } type Report = Option<(bool, Id, Id, Vector)>; struct ObservedScroll { identity: (Id, Id), report: Report, } #[derive(Default)] struct Translation(Option); 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); } } impl Observer<'_, R> { fn report( &mut self, tree: &mut widget::Tree, layout: Layout<'_>, renderer: &R, shell: &mut Shell<'_, Message>, ) { 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, self.context.document, self.context.scenario, offset, )); let previous = &mut tree.state.downcast_mut::().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 Widget for Observer<'_, R> { fn tag(&self) -> widget::tree::Tag { widget::tree::Tag::of::() } fn state(&self) -> widget::tree::State { widget::tree::State::new(ObservedScroll { identity: (self.context.document, self.context.scenario), report: None, }) } fn children(&self) -> Vec { vec![widget::Tree::new(self.content.as_widget())] } fn diff(&self, tree: &mut widget::Tree) { if tree.state.downcast_ref::().identity != (self.context.document, self.context.scenario) { // 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 { self.content.as_widget().size() } fn size_hint(&self) -> Size { 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> { 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::Canvas, process: false, 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, 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 { messages .iter() .filter_map(|message| { if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) = message { Some(*offset) } else { None } }) .collect() } #[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| { messages .into_iter() .filter_map(|message| { if let Message::TopologyCanvas(topology::CanvasEvent::Scrolled( _, _, offset, )) = message { Some(offset) } else { None } }) .collect::>() }; let mut view = observer(600., context); view.process = true; let mut tree = widget::Tree::new(&view as &dyn Widget); 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::Canvas, Vector::new(0., 180.)); state.enter(other.document, other.scenario); assert_eq!(state.scroll_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] { for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] { for change in 0..4 { 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); 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; } _ => {} } 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::>(); assert_eq!( actual, vec![if change < 2 { 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); 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); 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 { Topology(topology::Event), Scenario(scenario::Event), States(states::Event), Submit, } impl Event { pub fn starts_form(&self) -> bool { matches!( self, 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::Topology(topology::Event::Submit) | Self::Scenario(scenario::Event::Submit) | Self::States(states::Event::Submit) ) } } #[derive(Default)] pub struct State { pub active_owner: Option, 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 { 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, String> { let drafts = [ (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::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.pending_owner().is_some() } pub fn pending_owner(&self) -> Option { 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, String> { 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 acknowledge_commit(&mut self) { self.topology.acknowledge_commit(); self.scenario.acknowledge_commit(); self.states.acknowledge_commit(); } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { if self.has_pending() && matches!( &event, 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::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(); 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::>(); let owner = 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 with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into(); }; let e = &p.elements[&selected.id]; let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain) && (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty)); if e.kind() == Kind::StateMachine { out = column![text("States & modes").size(20)].spacing(8); } else if compact_process { // The process canvas owns its compact header and remaining-height layout. out = column![].spacing(6); } else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { out = column![text("Processes & functional chains").size(20)].spacing(8); } out = out.push( pick_list(choices, Some(selected), Message::BehaviorOwner) .width(Fill) .text_size(13) .padding(if compact_process { 6 } else { 10 }), ); 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 { 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 { 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 } struct BoxItem { id: Id, rect: Rectangle, title: String, detail: String, marker: Option, } struct Line { id: Id, points: Vec, label: String, label_at: Point, dashed: bool, } struct Scene { boxes: Vec, lines: Vec, size: Size, selected: Id, scale: f32, scroll_offset: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, lanes: BTreeMap, slots: Vec, rows: BTreeMap, open_calls: Vec, sequence_bounds: BTreeMap, } struct SequenceSlot { slot: scenario::Slot, y: f32, label: String, } #[derive(Default)] struct SequenceInput { context: Option, tool: scenario::CanvasTool, pointer: Option, bounds: Option, source: Option<(Id, scenario::Slot)>, press: Option, } #[derive(Clone, Copy)] enum SequencePress { 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(&mut self) { self.source = None; self.press = None; } } impl Scene { fn sequence(p: &Project, owner: Id, selected: Id) -> Self { let mut scene = Self { scale: 1., scroll_offset: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), selected, interaction: None, lanes: BTreeMap::new(), slots: vec![], rows: BTreeMap::new(), 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::>(); let scenario = p.elements[&owner].kind() == Kind::Scenario; if !scenario { fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec) { 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 lane_x = lanes .iter() .enumerate() .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); 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); if scenario { let calls: Vec<_> = scene .rows .keys() .filter_map(|&id| { if let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, receiver: Some(receiver), .. }, .. }) = p.elements[&id].shared() { Some((id, *lane_x.get(receiver)?)) } else { None } }) .collect(); for (call, x) in calls { scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None); } } scene.size.height = (y + 45.).max(210.); for (id, name, detail) in lanes { let x = lane_x[&id]; scene.lines.insert( 0, Line { id, 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, rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), title: name, detail, marker: None, }); } scene } fn occurrences( &mut self, p: &Project, owner: Id, scenario: bool, lanes: &BTreeMap, y: &mut f32, depth: usize, ) { 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 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); 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.), ), ); self.boxes.insert( 0, BoxItem { id: *id, 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, 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); } 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 a == b { 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, 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, 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, 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, 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, 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, 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.)); let slot = scenario::Slot { parent: owner, before: None, }; self.slots.push(SequenceSlot { slot, y: *y - 42., label: scenario::slot_label(p, slot), }); } } fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) { if bottom <= top { return; } self.boxes.push(BoxItem { id: call, rect: Rectangle::new(Point::new(x - 4., top), Size::new(8., bottom - top)), title: String::new(), detail: String::new(), marker: None, }); } /// Follow a single correlation through the ordered tree. Branches are drawn /// as independent segments, never as one chronological stretch through ALT /// operands. The result says whether a path can leave this sequence open. fn activation_sequence( &mut self, p: &Project, owner: Id, call: Id, x: f32, mut start: Option, ) -> bool { let entered_open = start.is_some(); let mut split = false; for id in p.elements[&owner].steps().into_iter().flatten() { let Some(&(_, y)) = self.rows.get(id) else { continue; }; if *id == call { start = Some(y); } let Some(top) = start else { continue; }; match p.elements[id].shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Reply, reply_to: Some(correlation), .. }, .. }) if *correlation == call => { self.activation_segment(call, x, top, y); self.boxes.push(BoxItem { id: call, rect: Rectangle::new(Point::new(x - 7., y - 1.), Size::new(14., 2.)), title: String::new(), detail: String::new(), marker: None, }); start = None; } Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { operator, branches }, .. }) => { split = true; self.activation_segment(call, x, top, y); let bottom = self .boxes .iter() .find(|b| b.id == *id && !b.title.is_empty()) .map(|b| b.rect.y + b.rect.height) .unwrap_or(y); let outcomes: Vec<_> = branches .iter() .filter_map(|branch| { let &(top, _) = self.sequence_bounds.get(branch)?; Some(self.activation_sequence(p, *branch, call, x, Some(top))) }) .collect(); let open = match operator { FragmentOperator::Alternative => { outcomes.is_empty() || outcomes.iter().any(|open| *open) } FragmentOperator::Parallel => outcomes.iter().all(|open| *open), // Optional/repeated bodies do not guarantee execution. FragmentOperator::Optional | FragmentOperator::Loop => true, }; start = open.then_some(bottom); } _ => {} } } if let Some(top) = start { let bottom = if entered_open || split { self.sequence_bounds .get(&owner) .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) } else { top + 26. }; self.activation_segment(call, x, top, bottom); self.open_calls.push(Point::new(x + 8., bottom - 7.)); } start.is_some() } fn slot_at(&self, point: Point, parent: Option) -> 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 { 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 { 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 { 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| b.id) .or_else(|| { self.lines .iter() .find(|line| { !line.label.is_empty() && (Rectangle::new( line.label_at, Size::new( (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25., ), ) .contains(point) || line.points.windows(2).any(|p| { crate::diagram::distance_to_segment(point, p[0], p[1]) < 8. / self.scale })) }) .map(|line| line.id) }) .or_else(|| { self.boxes .iter() .rev() .find(|b| b.rect.contains(point) && b.title.is_empty()) .map(|b| b.id) }) .or_else(|| self.lane_at(point)) .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| line.id) }) } } impl canvas::Program for Scene { type State = SequenceInput; fn update( &self, state: &mut SequenceInput, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { 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) { state.cancel(); state.pointer = None; } state.bounds = Some(bounds); if !editable { state.cancel(); } // Scrollable marks a pointer outside its visible viewport unavailable, // even when it lies inside the full (scrolled) canvas rectangle. let visible = cursor.position().is_some(); 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. } } } mouse::Cursor::Available(*position + self.scroll_offset) } 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) => { state.cancel(); state.pointer = None; return Some(Action::request_redraw()); } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), ) => state .pointer .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { state.pointer = cursor.position().map(|p| p - self.scroll_offset); cursor } 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() => { state.cancel(); return Some(Action::request_redraw().and_capture()); } _ => cursor, }; let world = cursor .position_in(bounds) .map(|p| Point::new(p.x / self.scale, p.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 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()? - self.scroll_offset, first, moved: false, }); return Some(Action::request_redraw().and_capture()); } if self.reorder_handle().is_some_and(|r| r.contains(point)) { state.press = Some(SequencePress::Reorder { occurrence: self.selected, parent: self.rows[&self.selected].0, start: cursor.position()? - self.scroll_offset, 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::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, 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() => { Some(Action::request_redraw()) } _ => None, } } fn draw( &self, state: &SequenceInput, renderer: &Renderer, _: &Theme, bounds: Rectangle, cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); f.scale(self.scale); 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::LINE }; 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 { 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_rectangle( l.label_at, Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), Color::WHITE, ); f.fill_text(Text { content: short(&l.label, 32), position: l.label_at, color, size: 12.into(), ..Default::default() }); } } for b in &self.boxes { f.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() { f.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() }); } if let Some((context, tool, true)) = self.interaction { let active = state.context == Some(context) && state.tool == tool; let point = cursor .position_in(bounds) .map(|p| Point::new(p.x / self.scale, p.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 tool == scenario::CanvasTool::Select && 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 slot = if let Some((_, slot)) = source { self.slots.iter().find(|s| s.slot == slot) } else if matches!(tool, scenario::CanvasTool::Message(_)) || reorder.is_some() { point.and_then(|p| self.slot_at(p, reorder)) } else { None }; 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!("Insert: {}", slot.label), 88), position: Point::new(38., slot.y - 19.), size: 11.into(), color: style::TEAL, ..Default::default() }); 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 { 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_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() { let (f, mut scene) = scene(); scene.scroll_offset = 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.scroll_offset; let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset; let mut state = SequenceInput::default(); // Iced Scrollable passes raw event coordinates and a translated 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 + scene.scroll_offset, bounds); } assert!( matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, 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_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 .boxes .iter() .filter(|b| b.id == f.call && b.rect.width == 8.) .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 .boxes .iter() .find(|b| b.id == f.call && b.rect.width == 8.) .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 .boxes .iter() .find(|b| b.id == f.call && b.rect.width == 8.) .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::() + 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)); } } }