//! 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, content: impl Into>, ) -> 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, } 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, } #[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); } } 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 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::().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::(); 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 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: identity(self.context), report: None, restored: 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 != 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 { 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::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, 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() } 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); 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); 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| { 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::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); 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::>(); assert_eq!( actual, vec![if change < 2 || change >= 4 { 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 { 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, 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, 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 { 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.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, 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::>(); 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 { 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 } /// 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, } fn box_text_clip( pan: iced::Vector, rect: Rectangle, scale: f32, viewport: Size, ) -> Option { 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, 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, lines: Vec, size: Size, layout_origin: Point, selected: Id, scale: f32, camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, spacing_stamp: Option, coordinate_input: bool, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, // Exact render coordinates include unassigned lanes; do not derive from cards. render_lanes: BTreeMap, slots: Vec, rows: BTreeMap, activations: Vec, lifecycle: scenario_lifecycle::render::Rendering, 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 { Spacing { stamp: scenario::spacing::Stamp, token: Id, }, Occurrence { tool: scenario::Tool, slot: scenario::Slot, lifeline: Option, }, 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> { 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 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>, 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::>(); 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 mut lane_x = lanes .iter() .enumerate() .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); 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: if scenario { format!("Lane · {name}") } else { 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, 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) -> 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.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 { 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 { 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 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) { 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::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 { 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::LINE }) .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::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.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 { 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::() + 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)); } } }