diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index dbb68d1c..69ad549d 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -14,6 +14,9 @@ use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; #[cfg(test)] mod behavior_lifecycle_tests; +mod state_diagram; +#[cfg(test)] +mod state_diagram_tests; #[derive(Debug, Clone)] pub enum LoadedSnapshot { @@ -138,6 +141,9 @@ pub enum Message { Redo, Diagram(Choice), Projection(ProjectionKind), + BehaviorOwner(Choice), + BehaviorDiagram(Choice), + StateDiagram(crate::behavior::state_edit::Event), PlaceSelected, HideSelected, ToggleCollapse, @@ -382,16 +388,29 @@ impl App { }) .flatten(); } - fn refresh_draft(&mut self) { - self.selected_node = self - .active_diagram + fn selected_architecture_placement(&self) -> Option { + self.active_diagram .and_then(|id| self.project().diagrams.get(&id)) .and_then(|d| { d.nodes .values() .find(|n| n.id == self.selected || n.element == self.selected) }) - .map(|n| n.id); + .map(|n| n.id) + } + fn refresh_draft(&mut self) { + self.selected_node = if self.page == Page::Behavior { + None + } else { + self.selected_architecture_placement() + }; + if let Some(context) = self.state_context() { + self.behavior.diagram.placement = self.project().diagrams[&context.diagram] + .nodes + .values() + .find(|n| n.id == self.selected || n.element == self.selected) + .map(|n| n.id); + } self.draft = Draft::from_project(self.project(), self.selected); if self.exact_strings && let Some(d) = &mut self.draft @@ -507,6 +526,12 @@ impl App { if self.active_diagram == Some(original) { self.active_diagram = Some(id); } + if self.behavior.active_owner == Some(original) { + self.behavior.active_owner = Some(id); + } + if self.behavior.diagram.diagram == Some(original) { + self.behavior.diagram.diagram = Some(id); + } if self.selected_node == Some(original) { self.selected_node = Some(id); } @@ -525,8 +550,17 @@ impl App { .map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id))) { self.layer = layer; - self.active_diagram = Some(diagram); - self.selected_node = node; + if self.page == Page::Behavior + && let Some(machine) = + self.project().diagrams[&diagram].projection.state_machine() + { + self.behavior.active_owner = Some(machine); + self.behavior.diagram.diagram = Some(diagram); + self.behavior.diagram.placement = node; + } else { + self.active_diagram = Some(diagram); + self.selected_node = node; + } } self.sync(); self.status = "Properties applied".into(); @@ -608,6 +642,7 @@ impl App { if !self.project().contains(id) { return; } + let previous_owner = crate::behavior::selected_owner(self); self.selected = id; self.selected_node = None; if let Some(layer) = self.project().layer_of(id) @@ -617,6 +652,15 @@ impl App { self.active_diagram = self.diagram_for(layer, id); self.fit(); } + if self.page == Page::Behavior + && let Some(owner) = crate::behavior::selected_behavior(self.project(), id) + { + self.behavior.active_owner = Some(owner); + if previous_owner != Some(owner) { + self.behavior.diagram = Default::default(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } self.refresh_draft(); } fn reveal_explorer_selection(&mut self) { @@ -885,6 +929,37 @@ impl App { } fn handle(&mut self, message: Message) -> Task { match message { + Message::StateDiagram(event) => return self.edit_state_diagram(event), + Message::BehaviorOwner(choice) => { + if self.apply() + && self.project().elements.get(&choice.id).is_some_and(|e| { + e.layer() == self.layer + && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) + }) + { + self.behavior.active_owner = Some(choice.id); + self.behavior.diagram = Default::default(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.select(choice.id); + self.page = Page::Behavior; + } + } + Message::BehaviorDiagram(choice) => { + if self.apply() + && let Some(owner) = crate::behavior::selected_owner(self) + && self + .project() + .diagrams + .get(&choice.id) + .is_some_and(|d| d.projection.state_machine() == Some(owner)) + { + self.behavior.diagram.diagram = Some(choice.id); + self.behavior.diagram.tool = crate::behavior::state_edit::Tool::Select; + self.behavior.diagram.fit = true; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.fit_state_diagram(); + } + } Message::PortAnchor(event) => { use crate::port_edit::Event; let (captured, node, port, command) = match event { @@ -970,7 +1045,11 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); let mut request = opened.request; if request.canvas.is_some() { - request.canvas = self.route_context(); + request.canvas = if self.page == Page::Behavior { + self.state_context() + } else { + self.route_context() + }; } self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence); } @@ -1120,7 +1199,21 @@ impl App { } Message::Page(page) => { if page != Page::Text || !self.draft_dirty() || self.apply() { + if page == Page::Behavior && self.page != Page::Behavior { + if let Some(owner) = + crate::behavior::selected_behavior(self.project(), self.selected) + { + self.behavior.active_owner = Some(owner); + } + self.behavior.active_owner = crate::behavior::selected_owner(self); + self.behavior.diagram.fit = true; + } self.page = page; + self.selected_node = if page == Page::Behavior { + None + } else { + self.selected_architecture_placement() + }; if page == Page::Text { return self.request_lint(); } @@ -1499,6 +1592,14 @@ impl App { } } Message::Command(command) => { + let semantic_selection = match &command { + Command::EditNode { diagram, node, .. } + if self.state_context().is_some_and(|c| c.diagram == *diagram) => + { + Some(node.element) + } + _ => None, + }; let original = self.selected; if self.apply() { let command = match command { @@ -1508,7 +1609,12 @@ impl App { } command => command, }; - self.execute(command); + if self.execute(command) + && let Some(element) = semantic_selection + { + self.selected = element; + self.refresh_draft(); + } } } Message::Delete => { diff --git a/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs b/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs index c4cba873..bf691589 100644 --- a/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs +++ b/crates/oa-desktop/src/app/behavior_lifecycle_tests.rs @@ -323,9 +323,6 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() { ); assert!(app.draft_dirty()); for event in [ - behavior::Event::States(states::Event::Viewport(iced::Size::new(900., 600.))), - behavior::Event::States(states::Event::Zoom(0.7)), - behavior::Event::States(states::Event::Fit), behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new( 900., 600., ))), diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs new file mode 100644 index 00000000..ca34bf4e --- /dev/null +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -0,0 +1,177 @@ +//! Adapter from captured state-canvas events to the shared document transaction. +use super::*; +use crate::behavior::{ + self, + state_edit::{self, Context, Event, Tool}, +}; + +impl App { + pub fn state_context(&self) -> Option { + if self.page != Page::Behavior { + return None; + } + let owner = behavior::selected_owner(self)?; + let diagram = behavior::state_view::diagram(self, owner)?; + Some(Context { + document: self.canvas_document, + diagram, + revision: self.document.revision(), + epoch: self.canvas_epoch, + }) + } + + pub(super) fn fit_state_diagram(&mut self) { + let Some(context) = self.state_context() else { + return; + }; + let diagram = &self.project().diagrams[&context.diagram]; + let view = projection::project_diagram(self.project(), diagram); + let bounds = routing::route_diagram(self.project(), diagram, &view).bounds; + let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new( + (self.window_size.width - 610.).max(280.), + (self.window_size.height - 320.).max(240.), + )); + let width = (viewport.width - 48.).max(1.); + let height = (viewport.height - 80.).max(1.); + let zoom = (width / bounds.size.width.max(1.)) + .min(height / bounds.size.height.max(1.)) + .clamp(0.15, 1.25); + self.behavior.diagram.zoom = zoom; + self.behavior.diagram.pan = iced::Vector::new( + 24. + (width - bounds.size.width * zoom) / 2. - bounds.position.x * zoom, + 56. + (height - bounds.size.height * zoom) / 2. - bounds.position.y * zoom, + ); + } + + pub(super) fn edit_state_diagram(&mut self, event: Event) -> Task { + if self.page != Page::Behavior { + return Task::none(); + } + if let Some(captured) = event.context() { + let Some(current) = self.state_context() else { + return Task::none(); + }; + if state_edit::validate_context(self.project(), captured, current).is_err() { + return Task::none(); + } + } + match event { + Event::Viewport(_, size) => { + if size.width.is_finite() + && size.height.is_finite() + && size.width > 0. + && size.height > 0. + { + let resized = self.behavior.diagram.viewport != Some(size); + self.behavior.diagram.viewport = Some(size); + if resized && self.behavior.diagram.fit { + self.fit_state_diagram(); + } + } + } + Event::Fit(_) => { + self.behavior.diagram.fit = true; + self.fit_state_diagram(); + } + Event::Zoom(_, zoom) if zoom.is_finite() => { + let state = &mut self.behavior.diagram; + let new = zoom.clamp(0.15, 3.); + let center = state.viewport.map_or(iced::Vector::new(300., 220.), |s| { + iced::Vector::new(s.width / 2., s.height / 2.) + }); + state.pan = center - (center - state.pan) * (new / state.zoom); + state.zoom = new; + state.fit = false; + } + Event::Pan(_, delta) if delta.x.is_finite() && delta.y.is_finite() => { + self.behavior.diagram.pan += delta; + self.behavior.diagram.fit = false; + } + Event::Select(_, id) => { + if !self.behavior.has_pending() { + self.select(id); + self.selected_node = None; + } + } + Event::ContextMenu { + context, + object, + placement, + anchor, + } => { + let mut request = crate::model_menu::Request::object(object, anchor); + request.canvas = Some(context); + request.placement = placement; + return self.handle(Message::Menu(crate::model_menu::Event::Open(request))); + } + Event::EditTransition(_, id) => { + if self.behavior.has_pending() || !self.apply() { + return Task::none(); + } + return self.handle(Message::Behavior(behavior::Event::States( + behavior::states::Event::Edit(id), + ))); + } + Event::Choose(tool) => { + if !self.apply() { + return Task::none(); + } + self.behavior.diagram.tool = tool; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + Event::CreateView(machine) => { + if behavior::selected_owner(self) != Some(machine) || !self.apply() { + return Task::none(); + } + match state_edit::create_view(self.project(), machine) { + Ok(plan) => { + if self.execute(plan.command) { + self.behavior.active_owner = Some(machine); + self.behavior.diagram.diagram = Some(plan.diagram); + self.behavior.diagram.placement = plan.placement; + self.behavior.diagram.tool = Tool::Select; + self.behavior.diagram.fit = true; + self.selected = plan.selected; + self.selected_node = None; + self.refresh_draft(); + self.fit_state_diagram(); + self.status = "State diagram created · existing model elements are placed in this view".into(); + } + } + Err(error) => self.status = error.to_string(), + } + } + Event::Zoom(..) | Event::Pan(..) => {} + mutation => { + if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { + self.status = + "Apply or cancel pending edits before changing the state diagram.".into(); + return Task::none(); + } + let Some(context) = self.state_context() else { + return Task::none(); + }; + match state_edit::plan(self.project(), context, &mutation) { + Ok(plan) => { + if self.execute(plan.command) { + self.behavior.diagram.diagram = Some(plan.diagram); + self.behavior.diagram.placement = plan.placement; + self.selected = plan.selected; + self.selected_node = None; + self.refresh_draft(); + if !matches!(mutation, Event::Move { .. }) { + self.behavior.diagram.tool = Tool::Select; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + self.status = + "State diagram updated · Undo restores the complete operation" + .into(); + } + } + Err(error) => self.status = error.to_string(), + } + } + } + Task::none() + } +} diff --git a/crates/oa-desktop/src/app/state_diagram_tests.rs b/crates/oa-desktop/src/app/state_diagram_tests.rs new file mode 100644 index 00000000..67b864f9 --- /dev/null +++ b/crates/oa-desktop/src/app/state_diagram_tests.rs @@ -0,0 +1,753 @@ +//! App transaction and draft boundaries for captured state-canvas messages. +use super::*; +use crate::behavior::{ + self, + state_edit::{self, Context, Endpoint, Event, Tool}, + states, +}; + +struct Fixture { + app: App, + machine: Id, + region: Id, + a: Id, + b: Id, + leaf: Id, + members: BTreeSet, +} + +fn send(app: &mut App, message: Message) { + // Exercise the real App boundary without polling asynchronous I/O tasks. + let _ = app.update(message); +} + +fn event(app: &mut App, event: Event) { + send(app, Message::StateDiagram(event)); +} + +fn form(app: &mut App, event: states::Event) { + send(app, Message::Behavior(behavior::Event::States(event))); +} + +fn fixture() -> Fixture { + let mut project = Project::empty(); + let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap(); + let owner = add(Kind::System, "System", None); + let machine = add(Kind::StateMachine, "Operating modes", Some(owner)); + let region = add(Kind::Region, "Main", Some(machine)); + let initial = add(Kind::Initial, "Start", Some(region)); + let a = add(Kind::State, "Operating", Some(region)); + let b = add(Kind::State, "Standby", Some(region)); + let nested = add(Kind::Region, "Work", Some(a)); + let leaf = add(Kind::State, "Working", Some(nested)); + let other_machine = add(Kind::StateMachine, "Other machine", None); + let other_region = add(Kind::Region, "Other region", Some(other_machine)); + add(Kind::State, "Foreign state", Some(other_region)); + let mut app = App::with_project(project); + send(&mut app, Message::Select(a)); + send(&mut app, Message::Page(Page::Behavior)); + assert_eq!(behavior::selected_owner(&app), Some(machine)); + Fixture { + app, + machine, + region, + a, + b, + leaf, + members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]), + } +} + +fn create_view(f: &mut Fixture) -> Context { + event(&mut f.app, Event::CreateView(f.machine)); + f.app + .state_context() + .expect("CreateView must activate the new persisted view") +} + +fn node(app: &App, diagram: Id, element: Id) -> Id { + app.project().diagrams[&diagram] + .nodes + .values() + .find(|n| n.element == element) + .unwrap() + .id +} + +fn assert_source_model_agree(app: &App) { + assert_eq!( + source_project::compile_bundle(app.document.bundle(), LIMITS) + .unwrap() + .decoded + .project, + *app.project() + ); + assert_eq!( + source_workspace::reachable(&app.source_workspace.bundle()).unwrap(), + *app.document.bundle() + ); + assert!(!app.source_dirty()); +} + +#[test] +fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() { + let mut f = fixture(); + let before = f.app.document.snapshot(); + let context = create_view(&mut f); + let app = &mut f.app; + let diagram = &app.project().diagrams[&context.diagram]; + assert_eq!( + diagram.projection, + ProjectionKind::StateMachine { machine: f.machine } + ); + assert_eq!( + diagram + .nodes + .values() + .map(|n| n.element) + .collect::>(), + f.members + ); + assert_eq!(app.project().elements, before.decoded.project.elements); + assert_eq!(app.project().relations, before.decoded.project.relations); + assert_eq!(app.selected, f.machine); + assert!(app.selected_node.is_none()); + assert_eq!(app.document.revision(), 1); + assert_source_model_agree(app); + let accepted = app.document.snapshot(); + send(app, Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); + assert!(!app.dirty()); + assert_source_model_agree(app); + send(app, Message::Redo); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_source_model_agree(app); +} + +#[test] +fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() { + let mut f = fixture(); + let context = create_view(&mut f); + let app = &mut f.app; + let before_create = app.document.snapshot(); + event( + app, + Event::CreateAt { + context, + owner: f.region, + kind: Kind::State, + position: Position { x: 900., y: 700. }, + }, + ); + let created = app.selected; + assert_eq!(app.project().elements[&created].parent, Some(f.region)); + assert_eq!(app.project().elements[&created].kind(), Kind::State); + assert_eq!( + app.project().elements.len(), + before_create.decoded.project.elements.len() + 1 + ); + let created_node = node(app, context.diagram, created); + assert_ne!(created_node, created); + assert_eq!( + app.project().diagrams[&context.diagram].nodes[&created_node].position, + Position { x: 900., y: 700. } + ); + assert_source_model_agree(app); + let before_connect = app.document.snapshot(); + let context = app.state_context().unwrap(); + event( + app, + Event::Connect { + context, + source: f.a, + target: created, + }, + ); + let relation = app.selected; + assert!(matches!(app.project().relations[&relation].body, + RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. } + if source == f.a && target == created)); + assert!(app.selected_node.is_none()); + assert_eq!(behavior::selected_owner(app), Some(f.machine)); + assert_source_model_agree(app); + + let context = app.state_context().unwrap(); + let before_select = app.document.snapshot(); + event(app, Event::Select(context, f.a)); + assert_eq!(app.selected, f.a); + assert_eq!( + app.state_context(), + Some(context), + "The press selection must not invalidate its following drag release" + ); + assert!(Arc::ptr_eq(&before_select, &app.document.snapshot())); + let placement = node(app, context.diagram, f.a); + let leaf = node(app, context.diagram, f.leaf); + let original = app.project().diagrams[&context.diagram].nodes[&placement].position; + let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position; + event( + app, + Event::Move { + context, + node: placement, + position: Position { + x: original.x + 31., + y: original.y + 43., + }, + }, + ); + assert_eq!(app.selected, f.a); + assert!(app.selected_node.is_none()); + assert_eq!( + app.project().diagrams[&context.diagram].nodes[&leaf].position, + Position { + x: leaf_original.x + 31., + y: leaf_original.y + 43. + } + ); + assert_eq!( + app.document.revision(), + 4, + "View, vertex, edge, and drag each commit once" + ); + assert_source_model_agree(app); + for expected in [before_select, before_connect, before_create] { + send(app, Message::Undo); + assert_eq!(app.project(), &expected.decoded.project); + assert_eq!(app.document.bundle(), &expected.bundle); + assert_source_model_agree(app); + } + assert!(app.project().diagrams.contains_key(&context.diagram)); + assert!(!app.project().contains(created)); + assert!(!app.project().contains(relation)); +} + +#[test] +fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() { + let mut f = fixture(); + let context = create_view(&mut f); + let app = &mut f.app; + let before = app.document.snapshot(); + let selected = app.selected; + let pan = app.behavior.diagram.pan; + let zoom = app.behavior.diagram.zoom; + let viewport = app.behavior.diagram.viewport; + let placement = node(app, context.diagram, f.a); + for changed in 0..4 { + let mut captured = context; + match changed { + 0 => captured.document = Id::new_v4(), + 1 => captured.diagram = Id::new_v4(), + 2 => captured.revision += 1, + _ => captured.epoch += 1, + } + for stale in [ + Event::Select(captured, f.b), + Event::Viewport(captured, iced::Size::new(999., 777.)), + Event::Pan(captured, iced::Vector::new(55., 66.)), + Event::Zoom(captured, 2.7), + Event::Move { + context: captured, + node: placement, + position: Position { x: 88., y: 99. }, + }, + Event::Connect { + context: captured, + source: f.a, + target: f.b, + }, + Event::CreateAt { + context: captured, + owner: f.region, + kind: Kind::State, + position: Position { x: 800., y: 800. }, + }, + ] { + event(app, stale); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.selected, selected); + assert_eq!(app.behavior.diagram.pan, pan); + assert_eq!(app.behavior.diagram.zoom, zoom); + assert_eq!(app.behavior.diagram.viewport, viewport); + assert_eq!(app.state_context(), Some(context)); + assert!(!app.behavior.has_pending()); + } + } + assert_source_model_agree(app); +} + +#[test] +fn real_view_tool_and_history_changes_invalidate_old_release_events() { + let mut f = fixture(); + let first = create_view(&mut f); + let second = create_view(&mut f); + let app = &mut f.app; + assert_ne!(first.diagram, second.diagram); + let first_choice = Choice { + id: first.diagram, + label: app.project().named(first.diagram).into(), + }; + send(app, Message::BehaviorDiagram(first_choice)); + let restored_view = app.state_context().unwrap(); + assert_eq!(restored_view.diagram, first.diagram); + let before = app.document.snapshot(); + for captured in [first, second] { + event( + app, + Event::Connect { + context: captured, + source: f.a, + target: f.b, + }, + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + } + event(app, Event::Choose(Tool::Transition)); + let armed = app.state_context().unwrap(); + assert_ne!(restored_view.epoch, armed.epoch); + event( + app, + Event::Connect { + context: restored_view, + source: f.a, + target: f.b, + }, + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + event( + app, + Event::Connect { + context: armed, + source: f.a, + target: f.b, + }, + ); + assert_eq!( + app.project().relations.len(), + before.decoded.project.relations.len() + 1 + ); + let committed = app.state_context().unwrap(); + send(app, Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + let undone = app.document.snapshot(); + event( + app, + Event::Connect { + context: armed, + source: f.a, + target: f.b, + }, + ); + event( + app, + Event::Connect { + context: committed, + source: f.a, + target: f.b, + }, + ); + assert!(Arc::ptr_eq(&undone, &app.document.snapshot())); + send(app, Message::Page(Page::Diagram)); + assert!(app.state_context().is_none()); + let diagram_count = app.project().diagrams.len(); + event(app, Event::CreateView(f.machine)); + assert_eq!(app.project().diagrams.len(), diagram_count); + assert!(Arc::ptr_eq(&undone, &app.document.snapshot())); +} + +#[test] +fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() { + let mut f = fixture(); + let context = create_view(&mut f); + let app = &mut f.app; + let before = app.document.snapshot(); + let selected = app.selected; + event( + app, + Event::CreateAt { + context, + owner: f.a, + kind: Kind::State, + position: Position { x: 600., y: 700. }, + }, + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.selected, selected); + assert_eq!(app.state_context(), Some(context)); + event( + app, + Event::CreateAt { + context, + owner: f.region, + kind: Kind::State, + position: Position { x: 600., y: 700. }, + }, + ); + assert_eq!(app.project().elements[&app.selected].parent, Some(f.region)); + assert_eq!(app.document.revision(), context.revision + 1); + assert_source_model_agree(app); + send(app, Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); +} + +#[test] +fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() { + for pending in ["properties", "source", "behavior"] { + let mut f = fixture(); + let context = create_view(&mut f); + event( + &mut f.app, + Event::Connect { + context, + source: f.a, + target: f.b, + }, + ); + let relation = f.app.selected; + // A valid Place request must have a genuinely absent placement. + let missing = f + .app + .project() + .make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region)) + .unwrap(); + let missing_id = missing.id; + send( + &mut f.app, + Message::Command(Command::CreateElement(missing)), + ); + send(&mut f.app, Message::Select(f.a)); + let app = &mut f.app; + let context = app.state_context().unwrap(); + let placement = node(app, context.diagram, f.a); + let at = app.project().diagrams[&context.diagram].nodes[&placement].position; + let mutations = [ + Event::CreateAt { + context, + owner: f.region, + kind: Kind::State, + position: Position { x: 700., y: 800. }, + }, + Event::Place { + context, + element: missing_id, + position: Position { x: 800., y: 900. }, + }, + Event::Move { + context, + node: placement, + position: Position { + x: at.x + 50., + y: at.y + 70., + }, + }, + Event::Connect { + context, + source: f.b, + target: f.a, + }, + Event::Reconnect { + context, + relation, + endpoint: Endpoint::Target, + vertex: f.leaf, + }, + ]; + for mutation in &mutations { + let plan = state_edit::plan(app.project(), context, mutation).unwrap(); + assert_ne!( + &prepare_command(app.project(), &plan.command) + .unwrap() + .project, + app.project(), + "Each blocked probe must otherwise be a valid, substantive model change" + ); + } + match pending { + "properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())), + "source" => { + let draft = format!( + "{}\n// Unapplied source note.\n", + app.source_workspace.text() + ); + send(app, Message::SourceEdit(text_editor::Action::SelectAll)); + send( + app, + Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste( + Arc::new(draft), + ))), + ); + } + _ => { + form(app, states::Event::Edit(relation)); + form(app, states::Event::Name("Retained form name".into())); + form(app, states::Event::Guard("temperature nominal".into())); + form(app, states::Event::TriggerNotes("Retained trigger".into())); + } + } + assert_eq!(app.draft_dirty(), pending == "properties"); + assert_eq!(app.source_dirty(), pending == "source"); + assert_eq!(app.behavior.has_pending(), pending == "behavior"); + let before = app.document.snapshot(); + let source = app.source_workspace.bundle(); + let properties = app.draft.as_ref().unwrap().materialize().unwrap(); + let pending_candidate = app + .behavior + .prepare_pending(app.project()) + .unwrap() + .map(|command| prepare_command(app.project(), &command).unwrap().project); + let selected = app.selected; + for mutation in mutations { + event(app, mutation); + assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}"); + assert_eq!(app.selected, selected); + assert_eq!(app.source_workspace.bundle(), source); + assert_eq!( + app.draft.as_ref().unwrap().materialize().unwrap(), + properties + ); + } + for passive in [ + Event::Viewport(context, iced::Size::new(960., 640.)), + Event::Pan(context, iced::Vector::new(12., 18.)), + Event::Zoom(context, 0.7), + Event::Fit(context), + ] { + event(app, passive); + assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}"); + assert_eq!(app.source_workspace.bundle(), source); + assert_eq!( + app.draft.as_ref().unwrap().materialize().unwrap(), + properties + ); + } + assert_eq!( + app.behavior + .prepare_pending(app.project()) + .unwrap() + .map(|command| prepare_command(app.project(), &command).unwrap().project), + pending_candidate + ); + match pending { + "properties" => send(app, Message::RevertProperties), + "source" => send(app, Message::RevertSource), + _ => form(app, states::Event::Cancel), + } + assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()); + let context = app.state_context().unwrap(); + event( + app, + Event::Move { + context, + node: placement, + position: Position { + x: at.x + 50., + y: at.y + 70., + }, + }, + ); + assert_eq!(app.selected, f.a); + assert_ne!(app.document.bundle(), &before.bundle); + assert_source_model_agree(app); + send(app, Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + } +} + +#[test] +fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() { + let f = fixture(); + let mut project = f.app.project().clone(); + // One eligible machine makes the fallback deterministic. Start from a loaded + // project with no selected behavior element and no explicitly chosen owner. + project + .elements + .retain(|id, element| f.members.contains(id) || element.kind() == Kind::System); + let plan = state_edit::create_view(&project, f.machine).unwrap(); + let project = prepare_command(&project, &plan.command).unwrap().project; + let mut app = App::with_project(project); + assert_eq!(app.selected, app.project().id); + assert!(app.behavior.active_owner.is_none()); + send(&mut app, Message::Page(Page::Behavior)); + let context = app.state_context().unwrap(); + assert_eq!(context.diagram, plan.diagram); + assert_eq!(behavior::selected_owner(&app), Some(f.machine)); + let before = app.document.snapshot(); + let placement = node(&app, context.diagram, f.a); + let original = app.project().diagrams[&context.diagram].nodes[&placement].position; + event(&mut app, Event::Select(context, f.a)); + assert_eq!(app.selected, f.a); + assert_eq!( + app.state_context(), + Some(context), + "Pinning the already-visible fallback owner must not advance the gesture epoch" + ); + assert_eq!(app.behavior.diagram.placement, Some(placement)); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + let destination = Position { + x: original.x + 37., + y: original.y + 29., + }; + event( + &mut app, + Event::Move { + context, + node: placement, + position: destination, + }, + ); + assert_eq!( + app.project().diagrams[&context.diagram].nodes[&placement].position, + destination + ); + assert_eq!(app.selected, f.a); + assert_eq!( + app.document.revision(), + 1, + "The first release must commit without another press" + ); + assert_source_model_agree(&app); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); +} + +#[test] +fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() { + let mut f = fixture(); + let context = create_view(&mut f); + let app = &mut f.app; + let size = iced::Size::new(960., 640.); + event(app, Event::Viewport(context, size)); + assert!(app.behavior.diagram.fit); + let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom); + let placement = node(app, context.diagram, f.a); + // Move far enough to change projected bounds. Refitting now would visibly + // jump the camera even though the canvas has not changed size. + event( + app, + Event::Move { + context, + node: placement, + position: Position { x: 4000., y: 3000. }, + }, + ); + let changed = app.state_context().unwrap(); + assert_ne!(changed.revision, context.revision); + assert_eq!( + (app.behavior.diagram.pan, app.behavior.diagram.zoom), + camera + ); + assert!(app.behavior.diagram.fit); + let after_move = app.document.snapshot(); + event(app, Event::Viewport(changed, size)); + assert_eq!( + (app.behavior.diagram.pan, app.behavior.diagram.zoom), + camera, + "Context initialization is not a canvas resize" + ); + event(app, Event::Choose(Tool::Transition)); + let changed_tool = app.state_context().unwrap(); + assert_ne!(changed_tool.epoch, changed.epoch); + event(app, Event::Viewport(changed_tool, size)); + assert_eq!( + (app.behavior.diagram.pan, app.behavior.diagram.zoom), + camera + ); + assert!(Arc::ptr_eq(&after_move, &app.document.snapshot())); + // A real resize still uses automatic fitting while fit mode is active. + event( + app, + Event::Viewport(changed_tool, iced::Size::new(1200., 720.)), + ); + assert_ne!( + (app.behavior.diagram.pan, app.behavior.diagram.zoom), + camera + ); + assert!(Arc::ptr_eq(&after_move, &app.document.snapshot())); + assert_source_model_agree(app); +} + +#[test] +fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() { + let f = fixture(); + let mut project = f.app.project().clone(); + let architecture = project.ensure_diagram(Layer::Sa); + let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. }); + let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. }); + project.place(architecture, f.leaf, Position { x: 190., y: 310. }); + let state_view = state_edit::create_view(&project, f.machine).unwrap(); + let project = prepare_command(&project, &state_view.command) + .unwrap() + .project; + let architecture_before = project.diagrams[&architecture].clone(); + let mut app = App::with_project(project); + let architecture_choice = Choice { + id: architecture, + label: app.project().named(architecture).into(), + }; + send(&mut app, Message::Diagram(architecture_choice)); + send(&mut app, Message::Select(f.b)); + assert_eq!(app.active_diagram, Some(architecture)); + assert_eq!(app.selected_node, Some(arch_b)); + send(&mut app, Message::Page(Page::Behavior)); + let context = app.state_context().unwrap(); + assert_eq!(context.diagram, state_view.diagram); + let state_a = node(&app, context.diagram, f.a); + assert_ne!(state_a, arch_a); + event(&mut app, Event::Select(context, f.a)); + assert_eq!(app.selected, f.a); + assert!( + app.selected_node.is_none(), + "Behavior must not borrow the architecture placement" + ); + assert_eq!(app.behavior.diagram.placement, Some(state_a)); + assert_eq!(app.active_diagram, Some(architecture)); + let before_move = app.document.snapshot(); + let original = app.project().diagrams[&context.diagram].nodes[&state_a].position; + let destination = Position { + x: original.x + 41., + y: original.y + 63., + }; + event( + &mut app, + Event::Move { + context, + node: state_a, + position: destination, + }, + ); + assert_eq!( + app.project().diagrams[&context.diagram].nodes[&state_a].position, + destination + ); + assert_eq!(app.project().diagrams[&architecture], architecture_before); + assert_eq!(app.selected, f.a); + assert!(app.selected_node.is_none()); + assert_eq!(app.behavior.diagram.placement, Some(state_a)); + assert_source_model_agree(&app); + let moved = app.document.snapshot(); + send(&mut app, Message::Page(Page::Diagram)); + assert_eq!(app.active_diagram, Some(architecture)); + assert_eq!(app.selected, f.a); + assert_eq!( + app.selected_node, + Some(arch_a), + "Returning to architecture resolves that view's placement of the same state" + ); + assert!(Arc::ptr_eq(&moved, &app.document.snapshot())); + send(&mut app, Message::Page(Page::Behavior)); + assert!(app.selected_node.is_none()); + assert_eq!(app.behavior.diagram.placement, Some(state_a)); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &before_move.decoded.project); + assert_eq!(app.document.bundle(), &before_move.bundle); + assert_eq!(app.project().diagrams[&architecture], architecture_before); + assert_eq!(app.behavior.diagram.placement, Some(state_a)); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 91d67d0b..874cbc0a 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -15,7 +15,10 @@ use iced::{ use oa_core::*; use std::collections::BTreeMap; pub(crate) mod scenario; -mod state_scene; +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; #[derive(Debug, Clone)] @@ -27,7 +30,8 @@ pub enum Event { } impl Event { pub fn starts_form(&self) -> bool { - matches!(self, + matches!( + self, Self::Topology(topology::Event::Start(..)) | Self::Scenario(scenario::Event::Start(..)) | Self::States(states::Event::Start(..) | states::Event::Edit(..)) @@ -45,6 +49,8 @@ impl Event { } #[derive(Default)] pub struct State { + pub active_owner: Option, + pub diagram: state_view::State, topology: topology::State, scenario: scenario::State, states: states::State, @@ -130,7 +136,7 @@ pub fn view(app: &App) -> Element<'_, Message> { label: format!("{} · {}", e.kind(), e.name), }) .collect::>(); - let owner = app.behavior.pending_owner().or_else(|| selected_behavior(p, app.selected)); + let owner = selected_owner(app); let selected = owner .and_then(|id| choices.iter().find(|c| c.id == id)) .or(choices.first()) @@ -140,8 +146,11 @@ pub fn view(app: &App) -> Element<'_, Message> { 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]; + if e.kind() == Kind::StateMachine { + out = column![text("States & modes").size(20)].spacing(8); + } out = out.push( - pick_list(choices, Some(selected), |c| Message::Select(c.id)) + pick_list(choices, Some(selected), Message::BehaviorOwner) .width(Fill) .text_size(13) .padding(10), @@ -152,7 +161,20 @@ pub fn view(app: &App) -> Element<'_, Message> { if e.kind() == Kind::Scenario { return scenario::view(app, e.id, out); } - states::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. @@ -179,7 +201,7 @@ fn uses_explicit_topology(p: &Project, owner: Id) -> bool { ) }) } -fn selected_behavior(p: &Project, selected: Id) -> Option { +pub fn selected_behavior(p: &Project, selected: Id) -> Option { let mut current = Some( p.relations .get(&selected) @@ -570,12 +592,9 @@ impl canvas::Program for Scene { ) && let Some(p) = cursor.position_in(bounds) { let p = Point::new(p.x / self.scale, p.y / self.scale); - if let Some(b) = self - .boxes - .iter() - .rev() - .find(|b| b.rect.contains(p) && (!self.is_container(b) || p.y < b.rect.y + 38.)) - { + if let Some(b) = self.boxes.iter().rev().find(|b| { + b.rect.contains(p) && (b.marker != Some(Kind::Occurrence) || p.y < b.rect.y + 38.) + }) { return Some(Action::publish(Message::Select(b.id)).and_capture()); } if let Some(l) = self.lines.iter().find(|l| { @@ -620,10 +639,7 @@ impl canvas::Program for Scene { &Path::rectangle(b.rect.position(), b.rect.size()), Stroke::default().with_color(color), ); - } else if !b - .marker - .is_some_and(|k| k.is_state() && !k.is_stable_state()) - { + } 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( @@ -686,45 +702,9 @@ impl canvas::Program for Scene { } } for b in &self.boxes { - let pseudo = b - .marker - .is_some_and(|k| k.is_state() && !k.is_stable_state()); - if pseudo { - state_scene::draw_symbol( - &mut f, - b, - if b.id == self.selected { - style::TEAL - } else { - style::INK - }, - ); - } - let prefix = match b.marker { - Some(Kind::Initial) => "INITIAL · ", - Some(Kind::Final) => "FINAL · ", - Some(Kind::Mode) => "MODE · ", - Some(Kind::State) => "STATE · ", - Some(Kind::Choice) => "CHOICE · ", - Some(Kind::Junction) => "JUNCTION · ", - Some(Kind::Fork) => "FORK · ", - Some(Kind::Join) => "JOIN · ", - Some(Kind::ShallowHistory) => "H · ", - Some(Kind::DeepHistory) => "H* · ", - Some(Kind::EntryPoint) => "ENTRY · ", - Some(Kind::ExitPoint) => "EXIT · ", - Some(Kind::Terminate) => "TERMINATE · ", - _ => "", - }; f.fill_text(Text { - content: format!( - "{prefix}{}", - short(&b.title, ((b.rect.width - 25.) / 7.) as usize) - ), - position: Point::new( - b.rect.x + if pseudo { 38. } else { 12. }, - b.rect.y + if pseudo { 18. } else { 12. }, - ), + 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 diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs new file mode 100644 index 00000000..04c231f3 --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -0,0 +1,2314 @@ +//! Persisted state-diagram geometry with context-bound, ordered pointer gestures. +use super::state_edit::{self, Context, Endpoint, Event, Tool}; +use crate::style; +use iced::{ + Color, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse, + widget::{ + Action, + canvas::{self, Frame, Path, Stroke, Text}, + }, +}; +use oa_core::{ + projection::{self, ProjectedNode, ProjectedView}, + routing::{self, RoutedView}, + *, +}; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +/// The scene is clipped below this strip, keeping guidance legible at every pan. +pub const GUIDANCE_HEIGHT: f32 = 42.; + +pub struct StateCanvas<'a> { + pub project: &'a Project, + pub diagram: Id, + pub context: Context, + pub selected: Id, + pub tool: Tool, + pub zoom: f32, + pub pan: Vector, + pub editable: bool, +} + +#[derive(Default)] +pub struct State { + context: Option, + tool: Option, + pointer: Option, + focused: bool, + press: Option, + source: Option, + preview: Option<(Id, Position)>, + pan_from: Option<(mouse::Button, Point)>, + viewport: Option, + scene: RefCell>, + geometry: canvas::Cache, + paint_key: RefCell>, +} + +#[derive(Clone, Copy)] +enum Press { + // Starts are window coordinates: contextual controls can move the canvas + // between input events without moving the physical pointer. + Move { + node: Id, + start: Point, + original: Position, + }, + Create { + owner: Id, + kind: Kind, + start: Point, + }, + Connect { + source: Id, + first: bool, + start: Point, + dragged: bool, + }, + Reconnect { + relation: Id, + endpoint: Endpoint, + /// Handles require a drag; an explicitly chosen reconnect tool accepts a click. + start: Option, + }, +} + +struct Scene { + view: ProjectedView, + routed: RoutedView, + symbol_labels: BTreeMap, +} +struct CachedScene { + context: Context, + preview: Option<(Id, Position)>, + scene: Arc, +} +#[derive(PartialEq)] +struct PaintKey { + context: Context, + preview: Option<(Id, Position)>, + selected: Id, + zoom: f32, + pan: Vector, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Hit { + Node { placement: Id, element: Id }, + Transition(Id), +} + +impl State { + fn cancel_gesture(&mut self) { + self.press = None; + self.preview = None; + self.pan_from = None; + } + fn reset(&mut self) { + self.cancel_gesture(); + self.source = None; + self.pointer = None; + } +} + +fn point(p: Position) -> Point { + Point::new(p.x, p.y) +} +fn position(p: Point) -> Position { + Position { x: p.x, y: p.y } +} +fn rectangle(n: &ProjectedNode) -> Rectangle { + Rectangle::new(point(n.position), Size::new(n.size.width, n.size.height)) +} +fn expanded(r: Rectangle, amount: f32) -> Rectangle { + Rectangle::new( + Point::new(r.x - amount, r.y - amount), + Size::new(r.width + 2. * amount, r.height + 2. * amount), + ) +} + +fn overlap_area(a: Rectangle, b: Rectangle) -> f32 { + ((a.x + a.width).min(b.x + b.width) - a.x.max(b.x)).max(0.) + * ((a.y + a.height).min(b.y + b.height) - a.y.max(b.y)).max(0.) +} + +fn segment_crosses(rect: Rectangle, a: Position, b: Position) -> bool { + let mut enter: f32 = 0.; + let mut exit: f32 = 1.; + for (start, delta, low, high) in [ + (a.x, b.x - a.x, rect.x, rect.x + rect.width), + (a.y, b.y - a.y, rect.y, rect.y + rect.height), + ] { + if delta.abs() < f32::EPSILON { + if start < low || start > high { + return false; + } + } else { + let first = (low - start) / delta; + let last = (high - start) / delta; + enter = enter.max(first.min(last)); + exit = exit.min(first.max(last)); + if enter > exit { + return false; + } + } + } + true +} + +/// Twelve nearby candidates, settled in stable placement-ID order. This is +/// derived scene geometry: names follow previews without changing shared routes. +fn symbol_labels( + project: &Project, + view: &ProjectedView, + routed: &RoutedView, +) -> BTreeMap { + let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect(); + let headers: BTreeMap<_, _> = view + .nodes + .iter() + .map(|n| { + ( + n.id, + Rectangle::new( + point(n.position), + Size::new( + n.size.width, + projection::state_header_height(project, n.element).min(n.size.height), + ), + ), + ) + }) + .collect(); + let segments: Vec<_> = routed + .edges + .values() + .flat_map(|edge| edge.points.windows(2).map(|pair| (pair[0], pair[1]))) + .collect(); + let edge_labels: Vec<_> = routed + .edges + .values() + .filter(|edge| !edge.label.text.is_empty()) + .map(|edge| { + Rectangle::new( + point(edge.label.bounds.position), + Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), + ) + }) + .collect(); + let mut placed: BTreeMap = BTreeMap::new(); + for node in nodes + .values() + .copied() + .filter(|n| projection::state_symbol_size(project.elements[&n.element].kind()).is_some()) + { + let r = rectangle(node); + let name = short(&project.elements[&node.element].name, 220., 11.); + let size = Size::new((name.chars().count() as f32 * 6.6).ceil().max(7.), 18.); + let mut ancestors = BTreeSet::new(); + let mut parent = node.visual_parent; + while let Some(id) = parent { + if !ancestors.insert(id) { + break; + } + parent = nodes.get(&id).and_then(|node| node.visual_parent); + } + let obstacles: Vec<_> = nodes + .values() + .map(|n| { + if ancestors.contains(&n.id) { + headers[&n.id] + } else { + rectangle(n) + } + }) + .collect(); + let center = r.center(); + let sides = |gap| { + [ + Point::new(r.x + r.width + gap, center.y - size.height / 2.), + Point::new(center.x - size.width / 2., r.y + r.height + gap), + Point::new(r.x - gap - size.width, center.y - size.height / 2.), + Point::new(center.x - size.width / 2., r.y - gap - size.height), + ] + }; + let candidates = sides(7.) + .into_iter() + .chain([ + Point::new(r.x + r.width + 7., r.y - size.height - 7.), + Point::new(r.x + r.width + 7., r.y + r.height + 7.), + Point::new(r.x - size.width - 7., r.y - size.height - 7.), + Point::new(r.x - size.width - 7., r.y + r.height + 7.), + ]) + .chain(sides(25.)); + let score = |label: Rectangle| { + let clear = expanded(label, 3.); + let mut collisions = segments + .iter() + .filter(|(a, b)| segment_crosses(clear, *a, *b)) + .count(); + let mut area = 0.; + for obstacle in obstacles + .iter() + .chain(edge_labels.iter()) + .chain(placed.values()) + { + let overlap = overlap_area(clear, *obstacle); + collisions += usize::from(overlap > 0.); + area += overlap; + } + for ancestor in &ancestors { + let outside = (label.width * label.height + - overlap_area(label, rectangle(nodes[ancestor]))) + .max(0.); + collisions += usize::from(outside > 0.1); + area += outside; + } + (collisions, area) + }; + let chosen = candidates + .map(|at| Rectangle::new(at, size)) + .enumerate() + .map(|(index, label)| (score(label), index, label)) + .min_by(|a, b| { + a.0.0 + .cmp(&b.0.0) + .then(a.0.1.total_cmp(&b.0.1)) + .then(a.1.cmp(&b.1)) + }) + .expect("the fixed candidate list is nonempty") + .2; + placed.insert(node.id, chosen); + } + placed +} +fn label_text(frame: &mut Frame, value: impl Into, at: Point, size: f32, color: Color) { + frame.fill_text(Text { + content: value.into(), + position: at, + size: size.into(), + color, + ..Default::default() + }); +} +fn short(value: &str, width: f32, font: f32) -> String { + let count = (width / (font * 0.6)).max(1.) as usize; + if value.chars().count() <= count { + value.into() + } else { + value + .chars() + .take(count.saturating_sub(1)) + .chain(['…']) + .collect() + } +} +fn line(a: Point, b: Point) -> Path { + Path::new(|p| { + p.move_to(a); + p.line_to(b); + }) +} +fn polyline(points: &[Position]) -> Path { + Path::new(|path| { + if let Some(first) = points.first() { + path.move_to(point(*first)); + for next in &points[1..] { + path.line_to(point(*next)); + } + } + }) +} +fn internal_rows<'a>(project: &'a Project, node: &ProjectedNode) -> Vec<(&'a Relation, Rectangle)> { + if node.collapsed + || !project + .elements + .get(&node.element) + .is_some_and(|e| e.kind().is_stable_state()) + { + return vec![]; + } + let relations: Vec<_> = project + .relations + .values() + .filter(|r| { + matches!(&r.body, + RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. } + if *source == node.element) + }) + .collect(); + let top = node.position.y + projection::state_header_height(project, node.element) + - relations.len() as f32 * 56.; + relations + .into_iter() + .enumerate() + .map(|(i, relation)| { + ( + relation, + Rectangle::new( + Point::new(node.position.x + 8., top + i as f32 * 56.), + Size::new((node.size.width - 16.).max(1.), 52.), + ), + ) + }) + .collect() +} + +impl StateCanvas<'_> { + fn scale(&self) -> f32 { + if self.zoom.is_finite() { + self.zoom.clamp(0.0001, 3.) + } else { + 1. + } + } + fn offset(&self) -> Vector { + self.pan + } + fn world(&self, local: Point) -> Point { + let translated = local - self.offset(); + Point::new(translated.x / self.scale(), translated.y / self.scale()) + } + fn content(bounds: Rectangle) -> Rectangle { + Rectangle::new( + Point::new(0., GUIDANCE_HEIGHT), + Size::new(bounds.width, (bounds.height - GUIDANCE_HEIGHT).max(0.)), + ) + } + fn valid(&self) -> bool { + self.diagram == self.context.diagram + && state_edit::validate_context(self.project, self.context, self.context).is_ok() + } + fn scene(&self, state: &State) -> Option> { + if !self.valid() { + return None; + } + let preview = self + .gesture_active(state) + .then_some(state.preview) + .flatten(); + if let Some(cached) = state.scene.borrow().as_ref() + && cached.context == self.context + && cached.preview == preview + { + return Some(cached.scene.clone()); + } + let mut diagram = self.project.diagrams.get(&self.diagram)?.clone(); + if let Some((id, at)) = preview + && let Some(node) = diagram.nodes.get(&id) + { + let delta = Position { + x: at.x - node.position.x, + y: at.y - node.position.y, + }; + let mut moved = projection::descendant_nodes(self.project, self.diagram, id); + moved.push(id); + let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect(); + let routes = projection::translated_routes(self.project, &diagram, &deltas); + for id in moved { + if let Some(node) = diagram.nodes.get_mut(&id) { + node.position.x += delta.x; + node.position.y += delta.y; + } + } + for route in routes { + if let Some(stored) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) { + *stored = route; + } + } + } + let view = projection::project_diagram(self.project, &diagram); + let routed = routing::route_diagram(self.project, &diagram, &view); + let symbol_labels = symbol_labels(self.project, &view, &routed); + let scene = Arc::new(Scene { + view, + routed, + symbol_labels, + }); + *state.scene.borrow_mut() = Some(CachedScene { + context: self.context, + preview, + scene: scene.clone(), + }); + Some(scene) + } + fn symbol_hit(&self, node: &ProjectedNode, at: Point) -> bool { + let Some(element) = self.project.elements.get(&node.element) else { + return false; + }; + let rect = rectangle(node); + if projection::state_symbol_size(element.kind()).is_none() { + return rect.contains(at); + } + let slop = 5. / self.scale(); + let center = rect.center(); + let delta = at - center; + match element.kind() { + Kind::Fork | Kind::Join => expanded(rect, slop).contains(at), + Kind::Choice => { + delta.x.abs() / (rect.width / 2. + slop) + delta.y.abs() / (rect.height / 2. + slop) + <= 1. + } + Kind::Terminate => { + expanded(rect, slop).contains(at) + && ((delta.x - delta.y).abs().min((delta.x + delta.y).abs()) <= slop + 2.) + } + _ => delta.x.hypot(delta.y) <= rect.width / 2. + slop, + } + } + fn node_at<'a>(&self, scene: &'a Scene, world: Point) -> Option<&'a ProjectedNode> { + scene + .view + .nodes + .iter() + .filter(|n| { + self.symbol_hit(n, world) + || scene + .symbol_labels + .get(&n.id) + .is_some_and(|label| label.contains(world)) + }) + .max_by_key(|n| (n.depth, n.id)) + } + fn vertex_at(&self, scene: &Scene, world: Point) -> Option { + self.node_at(scene, world) + .filter(|n| { + self.project + .elements + .get(&n.element) + .is_some_and(|e| e.kind().is_state()) + }) + .map(|n| n.element) + } + fn hit(&self, scene: &Scene, world: Point) -> Option { + let node = self.node_at(scene, world); + if let Some(node) = node { + if let Some((relation, _)) = internal_rows(self.project, node) + .into_iter() + .find(|(_, r)| r.contains(world)) + { + return Some(Hit::Transition(relation.id)); + } + let container = self + .project + .elements + .get(&node.element) + .is_some_and(|e| matches!(e.kind(), Kind::Region | Kind::StateMachine)) + || scene + .view + .nodes + .iter() + .any(|n| n.visual_parent == Some(node.id)); + let rect = rectangle(node); + let border = 5. / self.scale(); + if !container + || world.y <= rect.y + projection::state_header_height(self.project, node.element) + || world.x < rect.x + border + || world.x > rect.x + rect.width - border + || world.y > rect.y + rect.height - border + { + return Some(Hit::Node { + placement: node.id, + element: node.element, + }); + } + } + let edge = scene + .routed + .edges + .values() + .filter_map(|edge| { + let label = Rectangle::new( + point(edge.label.bounds.position), + Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), + ); + let distance = if label.contains(world) { + 0. + } else { + routing::project_onto_polyline(&edge.points, position(world))?.distance + }; + (distance <= 6. / self.scale()).then_some((edge.id, distance)) + }) + .min_by(|a, b| { + a.1.total_cmp(&b.1) + .then_with(|| (a.0 != self.selected).cmp(&(b.0 != self.selected))) + .then(a.0.cmp(&b.0)) + }); + edge.map(|(id, _)| Hit::Transition(id)).or_else(|| { + node.map(|n| Hit::Node { + placement: n.id, + element: n.element, + }) + }) + } + fn endpoint_at(&self, scene: &Scene, world: Point) -> Option<(Id, Endpoint)> { + let route = scene.routed.edges.get(&self.selected)?; + [ + (Endpoint::Source, route.points.first()), + (Endpoint::Target, route.points.last()), + ] + .into_iter() + .filter_map(|(endpoint, p)| p.map(|p| (endpoint, world.distance(point(*p))))) + .filter(|(_, distance)| *distance <= 8. / self.scale()) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(endpoint, _)| (route.id, endpoint)) + } + fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option { + self.node_at(scene, world) + .filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind)) + .map(|n| n.element) + } + fn move_pointer(&self, state: &mut State, window: Point) { + match state.press.as_mut() { + Some(Press::Move { + node, + start, + original, + }) if window.distance(*start) >= 3. || state.preview.is_some() => { + let delta = (window - *start) / self.scale(); + state.preview = Some(( + *node, + Position { + x: original.x + delta.x, + y: original.y + delta.y, + }, + )); + } + Some(Press::Connect { start, dragged, .. }) => { + *dragged |= window.distance(*start) >= 3. + } + _ => {} + } + } + fn gesture_active(&self, state: &State) -> bool { + self.editable && state.context == Some(self.context) && state.tool == Some(self.tool) + } + fn guidance(&self, state: &State, scene: &Scene, world: Option) -> String { + if !self.editable { + return "Pending edits · apply or cancel them before editing the diagram".into(); + } + let hovered = world.and_then(|p| self.vertex_at(scene, p)); + let press = self.gesture_active(state).then_some(state.press).flatten(); + let source = self.gesture_active(state).then_some(state.source).flatten(); + let reconnect = match press { + Some(Press::Reconnect { + relation, endpoint, .. + }) => Some((relation, endpoint)), + _ => { + if let Tool::Reconnect { relation, endpoint } = self.tool { + Some((relation, endpoint)) + } else { + None + } + } + }; + if let Some((relation, endpoint)) = reconnect { + return match hovered { + Some(vertex) + if state_edit::can_reconnect( + self.project, + self.diagram, + relation, + endpoint, + vertex, + ) => + { + format!( + "Reconnect {endpoint:?} of {} to {} · release to apply", + self.project.named(relation), + self.project.named(vertex) + ) + } + _ => format!( + "Reconnect {endpoint:?} of {} · choose a compatible vertex · Esc cancels", + self.project.named(relation) + ), + }; + } + match self.tool { + Tool::Vertex(_) | Tool::Region => { + let kind = kind_for_tool(self.tool); + world.and_then(|p| self.owner_at(scene, p, kind)).map_or_else( + || format!("Create {} · click its explicit owning {} · Esc cancels", kind.label(), if kind == Kind::Region { "machine or composite state" } else { "region" }), + |owner| format!("Create {} inside {} · click to place", kind.label(), self.project.named(owner))) + } + Tool::Transition => match source { + Some(source) => match hovered { + Some(target) if state_edit::can_connect(self.project, self.diagram, source, target) => + format!("{} → {} · compatible target · click or release to connect", self.project.named(source), self.project.named(target)), + _ => format!("Source: {} · choose a compatible target · Esc cancels", self.project.named(source)), + }, + None => "Draw transition · click a source, then a compatible target, or drag between vertices".into(), + }, + _ => if let Some(Hit::Transition(id)) = world.and_then(|p| self.hit(scene, p)) { + self.project.relations.get(&id).map(|r| format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))).unwrap_or_default() + } else { "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into() }, + } + } +} + +fn kind_for_tool(tool: Tool) -> Kind { + if let Tool::Vertex(kind) = tool { + kind + } else { + Kind::Region + } +} + +impl canvas::Program for StateCanvas<'_> { + type State = State; + fn update( + &self, + state: &mut State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + if state.context != Some(self.context) || state.tool != Some(self.tool) { + if state.context != Some(self.context) { + state.viewport = None; + } + state.reset(); + state.context = Some(self.context); + state.tool = Some(self.tool); + } + if !self.editable { + state.press = None; + state.preview = None; + state.source = None; + } + // Winit positions are ordered events. The cursor supplied by Iced is the + // final cursor for an entire batch and cannot reinterpret earlier clicks. + let cursor = match event { + canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { + state.pointer = Some(*position); + mouse::Cursor::Available(*position) + } + canvas::Event::Mouse(mouse::Event::CursorLeft) => { + state.pointer = None; + cursor + } + 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(); + let size = bounds.size(); + if state.viewport != Some(size) { + state.viewport = Some(size); + return Some(Action::publish(Event::Viewport(self.context, size))); + } + cursor + } + canvas::Event::Window(iced::window::Event::Unfocused) => { + state.reset(); + state.focused = false; + return None; + } + _ => cursor, + }; + let window = cursor.position(); + let local = window.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); + let inside = local.is_some_and(|p| Self::content(bounds).contains(p)); + if let canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event + && state.focused + { + use keyboard::{Key, key::Named}; + if *key == Key::Named(Named::Escape) { + state.reset(); + return Some(Action::publish(Event::Choose(Tool::Select)).and_capture()); + } + if modifiers.is_empty() { + let pan = match key.as_ref() { + Key::Named(Named::ArrowLeft) => Some(Vector::new(60., 0.)), + Key::Named(Named::ArrowRight) => Some(Vector::new(-60., 0.)), + Key::Named(Named::ArrowUp) => Some(Vector::new(0., 60.)), + Key::Named(Named::ArrowDown) => Some(Vector::new(0., -60.)), + _ => None, + }; + if let Some(delta) = pan { + state.cancel_gesture(); + return Some(Action::publish(Event::Pan(self.context, delta)).and_capture()); + } + } + } + let scene = self.scene(state)?; + match event { + canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) => { + state.focused = inside; + if !inside { + return None; + } + let local = local?; + let window = window?; + let world = self.world(local); + if *button == mouse::Button::Middle { + state.cancel_gesture(); + state.pan_from = Some((mouse::Button::Middle, window)); + return Some(Action::capture()); + } + if *button == mouse::Button::Right { + state.reset(); + let (object, placement) = match self.hit(&scene, world) { + Some(Hit::Node { element, placement }) => (element, Some(placement)), + Some(Hit::Transition(id)) => (id, None), + None => (self.diagram, None), + }; + return Some( + Action::publish(Event::ContextMenu { + context: self.context, + object, + placement, + anchor: window, + }) + .and_capture(), + ); + } + if *button != mouse::Button::Left || !self.editable { + return None; + } + match self.tool { + Tool::Select => { + if let Some((relation, endpoint)) = self.endpoint_at(&scene, world) { + state.press = Some(Press::Reconnect { + relation, + endpoint, + start: Some(window), + }); + return Some(Action::capture()); + } + match self.hit(&scene, world) { + Some(Hit::Node { placement, element }) => { + let original = self + .project + .diagrams + .get(&self.diagram)? + .nodes + .get(&placement)? + .position; + state.press = Some(Press::Move { + node: placement, + start: window, + original, + }); + Some( + Action::publish(Event::Select(self.context, element)) + .and_capture(), + ) + } + Some(Hit::Transition(id)) => { + Some(Action::publish(Event::Select(self.context, id)).and_capture()) + } + None => { + state.pan_from = Some((mouse::Button::Left, window)); + Some(Action::capture()) + } + } + } + Tool::Vertex(_) | Tool::Region => { + let kind = kind_for_tool(self.tool); + let owner = self.owner_at(&scene, world, kind)?; + state.press = Some(Press::Create { + owner, + kind, + start: window, + }); + Some(Action::capture()) + } + Tool::Transition => { + let first = state.source.is_none(); + let source = state.source.or_else(|| { + self.vertex_at(&scene, world) + .filter(|id| state_edit::can_start(self.project, self.diagram, *id)) + })?; + state.source = Some(source); + state.press = Some(Press::Connect { + source, + first, + start: window, + dragged: false, + }); + Some(Action::capture()) + } + Tool::Reconnect { relation, endpoint } => { + state.press = Some(Press::Reconnect { + relation, + endpoint, + start: None, + }); + Some(Action::capture()) + } + } + } + canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { + let window = window?; + if let Some((button, previous)) = state.pan_from { + state.pan_from = Some((button, window)); + if window != previous { + return Some( + Action::publish(Event::Pan(self.context, window - previous)) + .and_capture(), + ); + } + } + self.move_pointer(state, window); + Some(Action::request_redraw()) + } + canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => { + if let Some((held, previous)) = state.pan_from + && *button == held + { + state.pan_from = None; + return Some(match window { + Some(window) if window != previous => { + Action::publish(Event::Pan(self.context, window - previous)) + .and_capture() + } + _ => Action::capture(), + }); + } + if *button != mouse::Button::Left { + return None; + } + if let Some(window) = window { + self.move_pointer(state, window); + } + let press = state.press.take()?; + let preview = state.preview.take(); + let world = local.map(|p| self.world(p)); + let event = match press { + Press::Move { node, .. } => preview.map(|(_, position)| Event::Move { + context: self.context, + node, + position, + }), + Press::Create { owner, kind, start } + if inside + && window?.distance(start) < 3. + && self.owner_at(&scene, world?, kind) == Some(owner) => + { + Some(Event::CreateAt { + context: self.context, + owner, + kind, + position: position(world?), + }) + } + Press::Connect { + source, + first, + dragged, + .. + } if inside && (!first || dragged) => self + .vertex_at(&scene, world?) + .filter(|target| { + state_edit::can_connect(self.project, self.diagram, source, *target) + }) + .map(|target| { + state.source = None; + Event::Connect { + context: self.context, + source, + target, + } + }), + Press::Reconnect { + relation, + endpoint, + start, + } if inside + && start.is_none_or(|start| { + window.is_some_and(|p| p.distance(start) >= 3.) + }) => + { + self.vertex_at(&scene, world?) + .filter(|vertex| { + state_edit::can_reconnect( + self.project, + self.diagram, + relation, + endpoint, + *vertex, + ) + }) + .map(|vertex| Event::Reconnect { + context: self.context, + relation, + endpoint, + vertex, + }) + } + _ => None, + }; + Some(event.map_or_else(Action::capture, |event| { + Action::publish(event).and_capture() + })) + } + canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside => { + state.cancel_gesture(); + let amount = match delta { + mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => *y / 50., + }; + Some( + Action::publish(Event::Zoom( + self.context, + self.scale() * 1.12_f32.powf(amount.clamp(-8., 8.)), + )) + .and_capture(), + ) + } + _ => None, + } + } + fn draw( + &self, + state: &State, + renderer: &Renderer, + _: &Theme, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Vec { + let Some(scene) = self.scene(state) else { + return vec![]; + }; + let key = PaintKey { + context: self.context, + preview: self + .gesture_active(state) + .then_some(state.preview) + .flatten(), + selected: self.selected, + zoom: self.scale(), + pan: self.pan, + }; + if state.paint_key.borrow().as_ref() != Some(&key) { + state.geometry.clear(); + *state.paint_key.borrow_mut() = Some(key); + } + let content = Self::content(bounds); + let geometry = state.geometry.draw(renderer, bounds.size(), |frame| { + // WGPU appends parent meshes after clipped child meshes. Keep the + // parent background outside the content clip so it cannot cover nodes. + frame.fill_rectangle( + Point::ORIGIN, + Size::new(bounds.width, GUIDANCE_HEIGHT), + style::BG, + ); + frame.with_clip(content, |frame| { + frame.fill_rectangle(content.position(), content.size(), style::BG); + frame.translate(self.offset()); + frame.scale(self.scale()); + for node in &scene.view.nodes { + paint_node( + frame, + self.project, + node, + self.selected, + scene.symbol_labels.get(&node.id), + ); + } + // Containers are already painted. Route bodies have shared + // clearance from state headers and internal-transition rows. + for edge in scene.routed.edges.values() { + let active = edge.id == self.selected; + let color = if active { style::TEAL } else { style::INK }; + frame.stroke( + &polyline(&edge.points), + Stroke::default().with_color(color).with_width(if active { + 2.4 + } else { + 1.4 + }), + ); + if let [.., previous, end] = edge.points.as_slice() { + let direction = point(*end) - point(*previous); + let length = direction.x.hypot(direction.y).max(0.001); + let direction = direction / length; + let side = Vector::new(-direction.y, direction.x) * 4.; + frame.stroke( + &Path::new(|p| { + p.move_to(point(*end) - direction * 10. + side); + p.line_to(point(*end)); + p.line_to(point(*end) - direction * 10. - side); + }), + Stroke::default().with_color(color).with_width(1.5), + ); + } + let label = &edge.label; + frame.fill_rectangle( + point(label.bounds.position), + Size::new(label.bounds.size.width, label.bounds.size.height), + Color::WHITE, + ); + let width = label.bounds.size.width - 10.; + label_text( + frame, + short(self.project.named(edge.id), width, 10.), + point(label.bounds.position) + Vector::new(5., 0.), + 10., + color, + ); + label_text( + frame, + short(&label.text, width, 10.), + point(label.bounds.position) + Vector::new(5., 12.), + 10., + style::MUTED, + ); + if active { + for end in [edge.points.first(), edge.points.last()] + .into_iter() + .flatten() + { + frame.fill(&Path::circle(point(*end), 5. / self.scale()), Color::WHITE); + frame.stroke( + &Path::circle(point(*end), 5. / self.scale()), + Stroke::default() + .with_color(style::TEAL) + .with_width(1.6 / self.scale()), + ); + } + } + } + }); + }); + let mut overlay = Frame::new(renderer, bounds.size()); + let local = cursor.position_in(bounds).filter(|p| content.contains(*p)); + let world = local.map(|p| self.world(p)); + overlay.with_clip(content, |frame| { + frame.translate(self.offset()); + frame.scale(self.scale()); + if let Some(at) = world + && self.editable + { + let owner = match self.tool { + Tool::Vertex(_) | Tool::Region => { + self.owner_at(&scene, at, kind_for_tool(self.tool)) + } + _ => None, + }; + if let Some(owner) = owner + && let Some(node) = scene.view.nodes.iter().find(|n| n.element == owner) + { + let r = rectangle(node); + frame.stroke( + &Path::rectangle(r.position(), r.size()), + Stroke::default() + .with_color(style::TEAL) + .with_width(2. / self.scale()), + ); + } + let press = self.gesture_active(state).then_some(state.press).flatten(); + let source = self.gesture_active(state).then_some(state.source).flatten(); + let from = if let Some(Press::Reconnect { + relation, endpoint, .. + }) = press + { + scene + .routed + .edges + .get(&relation) + .and_then(|r| { + if endpoint == Endpoint::Source { + r.points.last() + } else { + r.points.first() + } + }) + .copied() + } else if let Tool::Reconnect { relation, endpoint } = self.tool { + scene + .routed + .edges + .get(&relation) + .and_then(|r| { + if endpoint == Endpoint::Source { + r.points.last() + } else { + r.points.first() + } + }) + .copied() + } else { + source + .and_then(|id| scene.view.nodes.iter().find(|n| n.element == id)) + .map(|n| position(rectangle(n).center())) + }; + if let Some(from) = from { + let mut stroke = Stroke::default() + .with_color(style::TEAL) + .with_width(1.8 / self.scale()); + stroke.line_dash = canvas::LineDash { + segments: &[6., 4.], + offset: 0, + }; + frame.stroke(&line(point(from), at), stroke); + frame.stroke( + &Path::circle(at, 5. / self.scale()), + Stroke::default().with_color(style::TEAL), + ); + } + } + }); + label_text( + &mut overlay, + short( + &self.guidance(state, &scene, world), + bounds.width - 24., + 12., + ), + Point::new(12., 12.), + 12., + style::INK, + ); + overlay.stroke( + &line( + Point::new(0., GUIDANCE_HEIGHT - 1.), + Point::new(bounds.width, GUIDANCE_HEIGHT - 1.), + ), + Stroke::default().with_color(style::LINE), + ); + vec![geometry, overlay.into_geometry()] + } + fn mouse_interaction( + &self, + state: &State, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> mouse::Interaction { + if state.pan_from.is_some() { + return mouse::Interaction::Grabbing; + } + if state.press.is_some() { + return mouse::Interaction::Grabbing; + } + if !cursor + .position_in(bounds) + .is_some_and(|p| Self::content(bounds).contains(p)) + { + return mouse::Interaction::default(); + } + if self.editable && self.tool != Tool::Select { + mouse::Interaction::Crosshair + } else { + mouse::Interaction::Pointer + } + } +} + +fn paint_node( + frame: &mut Frame, + project: &Project, + node: &ProjectedNode, + selected: Id, + symbol_label: Option<&Rectangle>, +) { + let Some(element) = project.elements.get(&node.element) else { + return; + }; + let r = rectangle(node); + let color = if element.id == selected { + style::TEAL + } else { + style::INK + }; + let stroke = Stroke::default() + .with_color(color) + .with_width(if element.id == selected { 2.4 } else { 1.3 }); + if projection::state_symbol_size(element.kind()).is_some() { + let center = r.center(); + let radius = r.width / 2.; + let circle = Path::circle(center, radius); + match element.kind() { + Kind::Initial | Kind::Junction => frame.fill(&circle, color), + Kind::Final => { + frame.fill(&circle, Color::WHITE); + frame.stroke(&circle, stroke); + frame.fill(&Path::circle(center, radius - 4.), color); + } + Kind::Fork | Kind::Join => frame.fill_rectangle(r.position(), r.size(), color), + Kind::Choice => { + let path = Path::new(|p| { + p.move_to(Point::new(center.x, r.y)); + p.line_to(Point::new(r.x + r.width, center.y)); + p.line_to(Point::new(center.x, r.y + r.height)); + p.line_to(Point::new(r.x, center.y)); + p.close(); + }); + frame.fill(&path, Color::WHITE); + frame.stroke(&path, stroke); + } + Kind::Terminate => { + frame.stroke( + &line(r.position(), Point::new(r.x + r.width, r.y + r.height)), + stroke, + ); + frame.stroke( + &line( + Point::new(r.x + r.width, r.y), + Point::new(r.x, r.y + r.height), + ), + stroke, + ); + } + _ => { + frame.fill(&circle, Color::WHITE); + frame.stroke(&circle, stroke); + match element.kind() { + Kind::ShallowHistory | Kind::DeepHistory => label_text( + frame, + if element.kind() == Kind::DeepHistory { + "H*" + } else { + "H" + }, + center + - Vector::new( + if element.kind() == Kind::DeepHistory { + 9. + } else { + 5. + }, + 9., + ), + 14., + color, + ), + Kind::ExitPoint => { + let d = radius * 0.6; + frame.stroke( + &line(center - Vector::new(d, d), center + Vector::new(d, d)), + stroke, + ); + frame.stroke( + &line(center + Vector::new(d, -d), center + Vector::new(-d, d)), + stroke, + ); + } + _ => {} + } + } + } + if let Some(label) = symbol_label { + label_text( + frame, + short(&element.name, 220., 11.), + label.position(), + 11., + color, + ); + } + return; + } + let path = if element.kind() == Kind::Region { + Path::rectangle(r.position(), r.size()) + } else { + Path::rounded_rectangle(r.position(), r.size(), 10.into()) + }; + frame.fill( + &path, + if element.kind() == Kind::Region { + Color::from_rgb8(247, 250, 253) + } else if element.kind() == Kind::StateMachine { + Color::from_rgb8(234, 241, 248) + } else { + Color::WHITE + }, + ); + let mut border = stroke; + if element.kind() == Kind::Region { + border.line_dash = canvas::LineDash { + segments: &[7., 4.], + offset: 0, + }; + } + frame.stroke(&path, border); + label_text( + frame, + short(&element.name, r.width - 24., 14.), + r.position() + Vector::new(12., 10.), + 14., + color, + ); + label_text( + frame, + if node.collapsed { + format!("{} · collapsed", element.kind().label()) + } else { + element.kind().label().into() + }, + r.position() + Vector::new(12., 33.), + 10., + style::MUTED, + ); + frame.stroke( + &line( + Point::new(r.x + 1., r.y + 55.), + Point::new(r.x + r.width - 1., r.y + 55.), + ), + Stroke::default().with_color(style::LINE), + ); + if node.collapsed { + return; + } + if let Some(SharedBody::State { + entry, + exit, + do_activity, + .. + }) = element.shared() + { + let actions = [("entry", entry), ("do", do_activity), ("exit", exit)] + .into_iter() + .filter(|(_, text)| !text.is_empty()) + .map(|(kind, text)| format!("{kind} / {text}")) + .collect::>() + .join(" · "); + if !actions.is_empty() { + label_text( + frame, + short(&actions, r.width - 24., 10.), + r.position() + Vector::new(12., 65.), + 10., + style::MUTED, + ); + } + } + for (relation, row) in internal_rows(project, node) { + if relation.id == selected { + frame.fill_rectangle(row.position(), row.size(), Color::from_rgb8(224, 247, 242)); + } + frame.stroke( + &line(row.position(), Point::new(row.x + row.width, row.y)), + Stroke::default().with_color(style::LINE), + ); + label_text( + frame, + short( + &format!("INTERNAL · {}", relation.name), + row.width - 12., + 11., + ), + row.position() + Vector::new(6., 6.), + 11., + if relation.id == selected { + style::TEAL + } else { + color + }, + ); + label_text( + frame, + short( + &routing::state_transition_summary(project, relation), + row.width - 12., + 10., + ), + row.position() + Vector::new(6., 27.), + 10., + style::MUTED, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + + struct Fixture { + project: Project, + diagram: Id, + machine: Id, + region: Id, + initial: Id, + a: Id, + b: Id, + c: Id, + leaf: Id, + } + + fn fixture() -> Fixture { + let mut project = Project::empty(); + let mut add = + |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap(); + let machine = add(Kind::StateMachine, "Operating modes", None); + let region = add(Kind::Region, "Main region", Some(machine)); + let initial = add(Kind::Initial, "Start", Some(region)); + let a = add(Kind::State, "Operating", Some(region)); + let b = add(Kind::State, "Standby", Some(region)); + let c = add(Kind::State, "Stopped", Some(region)); + let inner = add(Kind::Region, "Nested region", Some(a)); + let leaf = add(Kind::State, "Working", Some(inner)); + let diagram = Id::new_v4(); + project.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: "operating_view".into(), + name: "Operating view".into(), + description: String::new(), + layer: Layer::Sa, + projection: ProjectionKind::StateMachine { machine }, + nodes: BTreeMap::new(), + routes: vec![], + }, + ); + for (element, x, y, width, height) in [ + (machine, 0., 0., 1100., 900.), + (region, 24., 64., 1000., 800.), + (initial, 60., 160., 224., 94.), + (a, 180., 180., 440., 500.), + (b, 760., 180., 224., 94.), + (c, 760., 430., 224., 94.), + (inner, 204., 350., 380., 300.), + (leaf, 310., 450., 224., 94.), + ] { + let node = project.place(diagram, element, Position { x, y }); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .size = oa_core::Size { width, height }; + } + validate_candidate(&project).unwrap(); + Fixture { + project, + diagram, + machine, + region, + initial, + a, + b, + c, + leaf, + } + } + + fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> { + StateCanvas { + project: &f.project, + diagram: f.diagram, + context: Context { + document: Id::new_v4(), + diagram: f.diagram, + revision: 7, + epoch: 3, + }, + selected: f.machine, + tool, + zoom, + pan: Vector::new(20., 30.), + editable: true, + } + } + + fn bounds() -> Rectangle { + Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.)) + } + + fn screen(canvas: &StateCanvas<'_>, world: Point) -> Point { + Point::new(world.x * canvas.scale(), world.y * canvas.scale()) + + canvas.offset() + + Vector::new(bounds().x, bounds().y) + } + + fn header(canvas: &StateCanvas<'_>, element: Id) -> Point { + let scene = canvas.scene(&State::default()).unwrap(); + let node = scene + .view + .nodes + .iter() + .find(|n| n.element == element) + .unwrap(); + point(node.position) + Vector::new(20., 20.) + } + + fn placement(f: &Fixture, element: Id) -> Id { + f.project.diagrams[&f.diagram] + .nodes + .values() + .find(|n| n.element == element) + .unwrap() + .id + } + + fn input( + canvas: &StateCanvas<'_>, + state: &mut State, + event: mouse::Event, + batch_cursor: Point, + ) -> Option { + input_at_bounds(canvas, state, event, batch_cursor, bounds()) + } + + fn input_at_bounds( + canvas: &StateCanvas<'_>, + state: &mut State, + event: mouse::Event, + batch_cursor: Point, + bounds: Rectangle, + ) -> Option { + canvas::Program::update( + canvas, + state, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(batch_cursor), + ) + .and_then(|action| action.into_inner().0) + } + + #[test] + fn selection_relayout_does_not_move_nodes_or_change_drag_distance() { + for zoom in [0.5, 1., 2.] { + for delta in [Vector::ZERO, Vector::new(36., 24.)] { + let mut f = fixture(); + let transition = f.project.relate( + RelationKind::StateTransition, + "Selected transition", + f.a, + f.c, + ); + let mut canvas = canvas(&f, Tool::Select, zoom); + canvas.selected = transition; + let mut state = State::default(); + let start = screen(&canvas, header(&canvas, f.b)); + motion(&canvas, &mut state, start, start); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), start), + Some(Event::Select(_, id)) if id == f.b)); + // App selection removes contextual controls above the canvas. + // Context stays valid, but subsequent events have new bounds. + canvas.selected = f.b; + let shifted = + Rectangle::new(bounds().position() - Vector::new(22., 38.), bounds().size()); + let release = start + delta * zoom; + input_at_bounds( + &canvas, + &mut state, + mouse::Event::CursorMoved { position: release }, + release, + shifted, + ); + let event = input_at_bounds( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + release, + shifted, + ); + if delta == Vector::ZERO { + assert!( + event.is_none(), + "A stationary selection must not edit the model" + ); + } else { + let node = placement(&f, f.b); + let original = f.project.diagrams[&f.diagram].nodes[&node].position; + assert!( + matches!(event, Some(Event::Move { node: moved, position, .. }) + if moved == node && position == Position { + x: original.x + delta.x, y: original.y + delta.y, + }) + ); + } + assert!(state.press.is_none() && state.preview.is_none()); + } + } + } + + #[test] + fn relayout_does_not_turn_a_transition_click_into_a_drag_or_pan() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Transition, 1.); + let mut state = State::default(); + let start = screen(&canvas, header(&canvas, f.b)); + let shifted = Rectangle::new(bounds().position() - Vector::new(0., 24.), bounds().size()); + motion(&canvas, &mut state, start, start); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + ); + assert!( + input_at_bounds( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + start, + shifted + ) + .is_none() + ); + assert_eq!(state.source, Some(f.b)); + + canvas.tool = Tool::Select; + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Middle), + start, + ); + assert!( + input_at_bounds( + &canvas, + &mut state, + mouse::Event::CursorMoved { position: start }, + start, + shifted + ) + .is_none() + ); + assert!( + input_at_bounds( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Middle), + start, + shifted + ) + .is_none() + ); + assert!(state.pan_from.is_none()); + } + + #[test] + fn context_menu_keeps_window_anchor_and_explicit_placement_identity() { + let f = fixture(); + let canvas = canvas(&f, Tool::Select, 1.); + let mut state = State::default(); + let at = screen(&canvas, header(&canvas, f.b)); + motion(&canvas, &mut state, at, at); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Right), at), + Some(Event::ContextMenu { context, object, placement: Some(node), anchor }) + if context == canvas.context && object == f.b + && node == placement(&f, f.b) && anchor == at)); + let background = Point::new( + bounds().x + bounds().width - 10., + bounds().y + bounds().height - 10., + ); + motion(&canvas, &mut state, background, background); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Right), background), + Some(Event::ContextMenu { context, object, placement: None, anchor }) + if context == canvas.context && object == f.diagram && anchor == background)); + } + + fn motion( + canvas: &StateCanvas<'_>, + state: &mut State, + actual: Point, + batch_cursor: Point, + ) -> Option { + input( + canvas, + state, + mouse::Event::CursorMoved { position: actual }, + batch_cursor, + ) + } + + fn click(canvas: &StateCanvas<'_>, state: &mut State, at: Point) -> Vec { + let at = screen(canvas, at); + motion(canvas, state, at, at) + .into_iter() + .chain(input( + canvas, + state, + mouse::Event::ButtonPressed(mouse::Button::Left), + at, + )) + .chain(input( + canvas, + state, + mouse::Event::ButtonReleased(mouse::Button::Left), + at, + )) + .collect() + } + + #[test] + fn ordered_stationary_click_does_not_move_on_later_motion_in_the_same_batch() { + let f = fixture(); + let canvas = canvas(&f, Tool::Select, 1.); + let mut state = State::default(); + let press = screen(&canvas, header(&canvas, f.a)); + let later = screen(&canvas, header(&canvas, f.b)); + assert!(motion(&canvas, &mut state, press, later).is_none()); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), later), + Some(Event::Select(context, id)) if context == canvas.context && id == f.a)); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + later + ) + .is_none() + ); + assert!(motion(&canvas, &mut state, later, later).is_none()); + assert!(state.press.is_none() && state.preview.is_none() && state.pan_from.is_none()); + } + + #[test] + fn drag_uses_ordered_release_at_each_zoom_and_moves_descendants_in_one_undo() { + for zoom in [0.5, 1., 2.] { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Select, zoom); + let mut state = State::default(); + let press = screen(&canvas, header(&canvas, f.a)); + let release = press + Vector::new(36., 24.) * zoom; + let later = release + Vector::new(150., 90.); + motion(&canvas, &mut state, press, later); + assert!(matches!(input(&canvas, &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), later), + Some(Event::Select(_, id)) if id == f.a)); + // App selection changes are not a document/diagram change. + canvas.selected = f.a; + assert!(motion(&canvas, &mut state, release, later).is_none()); + let event = input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + later, + ) + .unwrap(); + let node = placement(&f, f.a); + let original = f.project.diagrams[&f.diagram].nodes[&node].position; + assert!( + matches!(&event, Event::Move { context, node: moved, position } + if *context == canvas.context && *moved == node + && *position == Position { x: original.x + 36., y: original.y + 24. }) + ); + assert!(motion(&canvas, &mut state, later, later).is_none()); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + later + ) + .is_none() + ); + let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); + assert_eq!( + plan.selected, f.a, + "Selection remains semantic, not the placement UUID" + ); + let mut document = + Document::from_project(f.project.clone(), Default::default()).unwrap(); + let source = document.bundle().clone(); + document.execute(plan.command).unwrap(); + let leaf = placement(&f, f.leaf); + let before = f.project.diagrams[&f.diagram].nodes[&leaf].position; + assert_eq!( + document.project().diagrams[&f.diagram].nodes[&leaf].position, + Position { + x: before.x + 36., + y: before.y + 24. + } + ); + assert!(document.undo()); + assert_eq!(document.project(), &f.project); + assert_eq!(document.bundle(), &source); + assert!( + !document.undo(), + "A drag produces exactly one committed history entry" + ); + } + } + + #[test] + fn context_tool_and_pending_form_changes_cancel_mutating_gestures() { + let f = fixture(); + for reset in 0..5 { + let mut canvas = canvas(&f, Tool::Select, 1.); + let mut state = State::default(); + let press = screen(&canvas, header(&canvas, f.a)); + let release = press + Vector::new(40., 30.); + motion(&canvas, &mut state, press, release); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + release, + ); + motion(&canvas, &mut state, release, release); + assert!(state.preview.is_some()); + match reset { + 0 => canvas.context.document = Id::new_v4(), + 1 => canvas.context.revision += 1, + 2 => canvas.context.epoch += 1, + 3 => canvas.tool = Tool::Transition, + _ => canvas.editable = false, + } + // A draw before the next input must not expose the old preview. + assert!(!canvas.gesture_active(&state)); + let scene = canvas.scene(&state).unwrap(); + let original = projection::project_view(&f.project, f.diagram); + assert_eq!(scene.view.nodes, original.nodes); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + release + ) + .is_none() + ); + assert!(state.press.is_none() && state.preview.is_none() && state.source.is_none()); + } + } + + #[test] + fn transition_supports_click_click_and_drag_without_coalesced_accidental_edges() { + let f = fixture(); + for drag in [false, true] { + let canvas = canvas(&f, Tool::Transition, 1.); + let mut state = State::default(); + let source = screen(&canvas, header(&canvas, f.a)); + let target = screen(&canvas, header(&canvas, f.c)); + motion(&canvas, &mut state, source, target); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + target + ) + .is_none() + ); + if !drag { + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + target + ) + .is_none() + ); + assert_eq!(state.source, Some(f.a)); + } + assert!(motion(&canvas, &mut state, target, target).is_none()); + if !drag { + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + target, + ); + } + let event = input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + target, + ) + .unwrap(); + assert!(matches!(event, Event::Connect { context, source, target } + if context == canvas.context && source == f.a && target == f.c)); + assert!(state.source.is_none() && state.press.is_none()); + } + let canvas = canvas(&f, Tool::Transition, 1.); + let mut state = State::default(); + assert!(click(&canvas, &mut state, header(&canvas, f.b)).is_empty()); + let events = click(&canvas, &mut state, header(&canvas, f.b)); + assert!( + matches!(events.as_slice(), [Event::Connect { source, target, .. }] + if *source == f.b && *target == f.b), + "A deliberate second click can create a self-loop" + ); + } + + #[test] + fn creation_uses_the_explicit_deepest_owner_and_never_a_fallback_ancestor() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Vertex(Kind::State), 1.); + let mut state = State::default(); + let owner = header(&canvas, f.region); + assert!(matches!(click(&canvas, &mut state, owner).as_slice(), + [Event::CreateAt { context, owner: id, kind: Kind::State, position: at }] + if *context == canvas.context && *id == f.region && *at == position(owner))); + assert!( + click(&canvas, &mut state, header(&canvas, f.a)).is_empty(), + "Clicking a state must not silently create a sibling in its ancestor region" + ); + assert!(click(&canvas, &mut state, Point::new(1400., 1000.)).is_empty()); + canvas.tool = Tool::Region; + assert!( + matches!(click(&canvas, &mut state, header(&canvas, f.a)).as_slice(), + [Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.a) + ); + assert!( + matches!(click(&canvas, &mut state, header(&canvas, f.machine)).as_slice(), + [Event::CreateAt { owner, kind: Kind::Region, .. }] if *owner == f.machine) + ); + } + + #[test] + fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() { + let mut f = fixture(); + let relation = f + .project + .relate(RelationKind::StateTransition, "Go standby", f.a, f.b); + let mut canvas = canvas(&f, Tool::Select, 1.); + canvas.selected = relation; + let mut state = State::default(); + let scene = canvas.scene(&state).unwrap(); + let end = point(*scene.routed.edges[&relation].points.last().unwrap()); + let handle = screen(&canvas, end); + motion(&canvas, &mut state, handle, handle); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + handle, + ); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + handle + ) + .is_none() + ); + let target = screen(&canvas, header(&canvas, f.c)); + motion(&canvas, &mut state, handle, target); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + target, + ); + motion(&canvas, &mut state, target, target); + let event = input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + target, + ) + .unwrap(); + assert!( + matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex } + if *context == canvas.context && *id == relation && *vertex == f.c) + ); + let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); + let accepted = prepare_command(&f.project, &plan.command).unwrap().project; + assert_eq!(plan.selected, relation); + assert_eq!(accepted.relations[&relation].source(), f.a); + assert_eq!(accepted.relations[&relation].target(), f.c); + } + + #[test] + fn internal_rows_and_pseudostate_hits_use_shared_persisted_geometry() { + let mut f = fixture(); + for name in ["Check pressure", "Update status"] { + let id = f + .project + .relate(RelationKind::StateTransition, name, f.a, f.a); + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut f.project.relations.get_mut(&id).unwrap().body + { + *transition_kind = TransitionKind::Internal; + } + } + let edge = f + .project + .relate(RelationKind::StateTransition, "Remain standby", f.b, f.b); + let canvas = canvas(&f, Tool::Select, 3.); + let scene = canvas.scene(&State::default()).unwrap(); + let a = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap(); + let rows = internal_rows(&f.project, a); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].1.y - rows[0].1.y, 56.); + assert_eq!( + rows[1].1.y + 56., + a.position.y + projection::state_header_height(&f.project, f.a) + ); + for (relation, rect) in rows { + assert_eq!( + canvas.hit(&scene, rect.center()), + Some(Hit::Transition(relation.id)) + ); + } + let initial = scene + .view + .nodes + .iter() + .find(|n| n.element == f.initial) + .unwrap(); + assert_eq!( + initial.size, + projection::state_symbol_size(Kind::Initial).unwrap() + ); + assert!(canvas.symbol_hit(initial, rectangle(initial).center())); + assert!( + !canvas.symbol_hit(initial, point(initial.position)), + "The invisible square corner is not part of the circular initial symbol" + ); + let loop_route = &scene.routed.edges[&edge]; + assert!(loop_route.points.len() >= 4); + assert!( + loop_route + .points + .windows(2) + .all(|pair| pair[0].x == pair[1].x || pair[0].y == pair[1].y) + ); + assert!(!scene.routed.edges.values().any(|r| matches!( + f.project.relations[&r.id].body, + RelationBody::StateTransition { + transition_kind: TransitionKind::Internal, + .. + } + ))); + } + + #[test] + fn pseudostate_names_avoid_rightward_and_downward_routes_and_share_hit_bounds() { + for rightward in [true, false] { + let mut f = fixture(); + let initial = placement(&f, f.initial); + let target = placement(&f, f.b); + let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap(); + diagram + .nodes + .retain(|_, n| [f.machine, f.region, f.initial, f.b].contains(&n.element)); + diagram.nodes.get_mut(&initial).unwrap().position = Position { x: 250., y: 220. }; + diagram.nodes.get_mut(&target).unwrap().position = if rightward { + Position { x: 500., y: 181. } + } else { + Position { x: 146., y: 450. } + }; + let transition = + f.project + .relate(RelationKind::StateTransition, "Begin", f.initial, f.b); + let canvas = canvas(&f, Tool::Select, 1.); + let scene = canvas.scene(&State::default()).unwrap(); + let symbol = scene.view.nodes.iter().find(|n| n.id == initial).unwrap(); + let legacy = Rectangle::new( + Point::new( + symbol.position.x + symbol.size.width + 7., + symbol.position.y, + ), + Size::new(33., 18.), + ); + let route = &scene.routed.edges[&transition]; + if rightward { + assert!( + route + .points + .windows(2) + .any(|p| segment_crosses(legacy, p[0], p[1])), + "The regression fixture must cross the former fixed right-hand name" + ); + } else { + assert!( + route.points[1].y > route.points[0].y, + "The second case leaves the Initial downward" + ); + } + let label = scene.symbol_labels[&initial]; + assert!(scene.routed.edges.values().all(|edge| { + edge.points + .windows(2) + .all(|p| !segment_crosses(expanded(label, 3.), p[0], p[1])) + })); + assert_eq!(canvas.vertex_at(&scene, label.center()), Some(f.initial)); + assert_eq!( + canvas.hit(&scene, label.center()), + Some(Hit::Node { + placement: initial, + element: f.initial + }) + ); + assert_eq!( + symbol.size, + projection::state_symbol_size(Kind::Initial).unwrap() + ); + assert_eq!( + f.project.diagrams[&f.diagram].nodes[&initial].position, + Position { x: 250., y: 220. } + ); + } + } + + #[test] + fn stacked_symbol_names_avoid_each_other_and_are_independent_of_input_order() { + let mut f = fixture(); + let initial = placement(&f, f.initial); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .retain(|_, n| [f.machine, f.region, f.initial].contains(&n.element)); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .nodes + .get_mut(&initial) + .unwrap() + .position = Position { x: 250., y: 220. }; + let junction = f + .project + .add_element(Kind::Junction, Layer::Sa, "Second marker", Some(f.region)) + .unwrap(); + f.project + .place(f.diagram, junction, Position { x: 250., y: 240. }); + let canvas = canvas(&f, Tool::Select, 1.); + let scene = canvas.scene(&State::default()).unwrap(); + let labels: Vec<_> = scene.symbol_labels.values().copied().collect(); + assert_eq!(labels.len(), 2); + assert_eq!(overlap_area(expanded(labels[0], 3.), labels[1]), 0.); + for node in scene + .view + .nodes + .iter() + .filter(|n| scene.symbol_labels.contains_key(&n.id)) + { + let label = scene.symbol_labels[&node.id]; + assert_eq!(canvas.vertex_at(&scene, label.center()), Some(node.element)); + for other in scene.view.nodes.iter().filter(|other| { + projection::state_symbol_size(f.project.elements[&other.element].kind()).is_some() + }) { + assert_eq!(overlap_area(expanded(label, 3.), rectangle(other)), 0.); + } + } + let mut reversed = scene.view.clone(); + reversed.nodes.reverse(); + assert_eq!( + symbol_labels(&f.project, &reversed, &scene.routed), + scene.symbol_labels + ); + } + + #[test] + fn pan_is_button_scoped_read_only_and_hover_does_not_reproject_the_scene() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Select, 1.); + canvas.editable = false; + let mut state = State::default(); + let start = screen(&canvas, header(&canvas, f.a)); + let end = start + Vector::new(32., 21.); + assert!(motion(&canvas, &mut state, start, start).is_none()); + let scene = canvas.scene(&state).unwrap(); + assert!(Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap())); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Middle), + end, + ); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + end + ) + .is_none() + ); + assert!(matches!(motion(&canvas, &mut state, end, end), + Some(Event::Pan(context, delta)) if context == canvas.context && delta == Vector::new(32., 21.))); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Middle), + end + ) + .is_none() + ); + assert!(state.pan_from.is_none()); + canvas.pan += Vector::new(32., 21.); + canvas.zoom = 0.5; + assert!( + Arc::ptr_eq(&scene, &canvas.scene(&state).unwrap()), + "Pan, zoom, selection, and hover reuse the persisted projection and routing" + ); + assert!(motion(&canvas, &mut state, start, start).is_none()); + assert!(state.pan_from.is_none()); + } + + #[test] + fn escape_and_view_switch_discard_the_armed_transition_source() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Transition, 1.); + let mut state = State::default(); + click(&canvas, &mut state, header(&canvas, f.a)); + assert_eq!(state.source, Some(f.a)); + let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Escape), + modified_key: keyboard::Key::Named(keyboard::key::Named::Escape), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: None, + repeat: false, + }); + let result = canvas::Program::update( + &canvas, + &mut state, + &escape, + bounds(), + mouse::Cursor::Unavailable, + ) + .and_then(|action| action.into_inner().0); + assert!(matches!(result, Some(Event::Choose(Tool::Select)))); + assert!(state.source.is_none() && state.press.is_none()); + click(&canvas, &mut state, header(&canvas, f.a)); + assert_eq!(state.source, Some(f.a)); + let at = screen(&canvas, header(&canvas, f.b)); + canvas.diagram = Id::new_v4(); + canvas.context.diagram = canvas.diagram; + assert!(motion(&canvas, &mut state, at, at).is_none()); + assert!(state.source.is_none()); + assert!(canvas.scene(&state).is_none()); + } + + #[test] + fn viewport_uses_full_canvas_coordinates_and_republishes_for_a_new_context() { + let f = fixture(); + let mut canvas = canvas(&f, Tool::Select, 1.); + let mut state = State::default(); + let redraw = canvas::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )); + let mut redraw_message = |canvas: &StateCanvas<'_>| { + canvas::Program::update( + canvas, + &mut state, + &redraw, + bounds(), + mouse::Cursor::Unavailable, + ) + .and_then(|action| action.into_inner().0) + }; + let expected = bounds().size(); + assert!( + matches!(redraw_message(&canvas), Some(Event::Viewport(context, size)) + if context == canvas.context && size == expected) + ); + assert!(redraw_message(&canvas).is_none()); + canvas.context.document = Id::new_v4(); + assert!( + matches!(redraw_message(&canvas), Some(Event::Viewport(context, size)) + if context == canvas.context && size == expected) + ); + } +} diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index df7919b4..173c53aa 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -32,6 +32,12 @@ pub enum Event { Viewport(Context, iced::Size), Fit(Context), EditTransition(Context, Id), + ContextMenu { + context: Context, + object: Id, + placement: Option, + anchor: iced::Point, + }, CreateAt { context: Context, owner: Id, @@ -71,6 +77,7 @@ impl Event { | Self::Viewport(c, _) | Self::Fit(c) | Self::EditTransition(c, _) + | Self::ContextMenu { context: c, .. } | Self::CreateAt { context: c, .. } | Self::Place { context: c, .. } | Self::Move { context: c, .. } diff --git a/crates/oa-desktop/src/behavior/state_menu.rs b/crates/oa-desktop/src/behavior/state_menu.rs new file mode 100644 index 00000000..d3f4522c --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_menu.rs @@ -0,0 +1,195 @@ +//! State actions capture their own diagram; architecture selection stays separate. +use super::{ + state_edit::{self, Endpoint, Event, Tool}, + states, +}; +use crate::{ + app::{App, Message}, + context_menu::{Entry, Menu}, + model_menu::{self, Opened}, +}; +use oa_core::*; + +pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option> { + let project = app.project(); + let machine = super::selected_owner(app) + .filter(|id| project.elements[id].kind() == Kind::StateMachine)?; + let context = app.state_context(); + let request = &opened.request; + let id = request.object; + let item = |caption: &str, message: Message, enabled: bool| { + if clean && enabled { + Entry::action( + caption, + Message::Menu(model_menu::Event::Invoke(opened.token, Box::new(message))), + ) + } else { + Entry::disabled(caption) + } + }; + let diagram_event = Message::StateDiagram; + let form = |tool| { + Message::Behavior(super::Event::States(states::Event::Start( + machine, tool, id, + ))) + }; + let mut entries = vec![Entry::title(crate::views::short(project.named(id), 38))]; + if !clean { + entries.push(Entry::title("Apply or cancel pending edits first")); + } + + if let Some(context) = context.filter(|context| context.diagram == id) { + entries.extend([ + item("View properties", Message::EditProperties(id), true), + item("Fit diagram", diagram_event(Event::Fit(context)), true), + item( + "Create another state view", + diagram_event(Event::CreateView(machine)), + true, + ), + Entry::separator(), + item( + "Create region…", + diagram_event(Event::Choose(Tool::Region)), + true, + ), + item( + "New transition with details…", + form(states::Tool::Transition), + true, + ), + ]); + } else { + entries.extend([ + item("Properties", Message::EditProperties(id), true), + item("Open source", Message::SourceForSelection, true), + item( + "Connections & references", + Message::InspectorTab(crate::navigation::InspectorTab::Connections), + true, + ), + ]); + if let Some(element) = project.elements.get(&id) + && projection::state_machine_contains(project, machine, id) + { + entries.push(Entry::separator()); + let creates = if element.kind() == Kind::Region { + Some(( + "Create state…", + Kind::State, + states::Tool::Vertex, + Tool::Vertex(Kind::State), + )) + } else if element.kind() == Kind::StateMachine || element.kind().is_stable_state() { + Some(( + "Create region…", + Kind::Region, + states::Tool::Region, + Tool::Region, + )) + } else { + None + }; + if let Some((caption, kind, typed, tool)) = creates { + entries.push(if let Some(context) = context { + item( + caption, + diagram_event(Event::Choose(tool)), + state_edit::eligible_owner(project, context.diagram, id, kind), + ) + } else { + item(caption, form(typed), true) + }); + } + entries.push(item( + "New transition with details…", + form(states::Tool::Transition), + true, + )); + if let Some(context) = context + && let Some(node) = request + .placement + .and_then(|placement| project.diagrams[&context.diagram].nodes.get(&placement)) + .filter(|node| node.element == id) + { + entries.push(Entry::separator()); + entries.push(item( + "Placement properties", + Message::EditProperties(node.id), + true, + )); + if element.kind() == Kind::StateMachine + || element.kind() == Kind::Region + || element.kind().is_stable_state() + { + let mut changed = node.clone(); + changed.collapsed = !changed.collapsed; + entries.push(item( + if node.collapsed { + "Unfold contents" + } else { + "Fold contents" + }, + Message::Command(Command::EditNode { + diagram: context.diagram, + original: node.id, + node: changed, + }), + true, + )); + } + entries.push(item( + "Hide from this diagram", + Message::Command(Command::Delete(node.id)), + true, + )); + } + } + if let Some(relation) = project.relations.get(&id) + && relation.kind() == RelationKind::StateTransition + && projection::state_machine_contains(project, machine, relation.source()) + && projection::state_machine_contains(project, machine, relation.target()) + { + entries.push(Entry::separator()); + let edit = context.map_or_else( + || Message::Behavior(super::Event::States(states::Event::Edit(id))), + |context| diagram_event(Event::EditTransition(context, id)), + ); + entries.push(item("Edit transition", edit, true)); + if let Some(context) = context { + let visible = projection::project_view(project, context.diagram) + .edges + .iter() + .any(|edge| edge.id == id); + for (caption, endpoint) in [ + ("Reconnect source", Endpoint::Source), + ("Reconnect target", Endpoint::Target), + ] { + entries.push(item( + caption, + diagram_event(Event::Choose(Tool::Reconnect { + relation: id, + endpoint, + })), + visible, + )); + } + if !visible { + entries.push(Entry::title( + "Use transition details for hidden or internal endpoints", + )); + } + } + } + if id != project.id { + entries.push(Entry::separator()); + entries.push(item("Delete from model…", Message::Delete, true)); + } + } + Some(Menu { + token: opened.token, + anchor: request.anchor, + entries, + on_close: Message::Menu(model_menu::Event::Close), + }) +} diff --git a/crates/oa-desktop/src/behavior/state_scene.rs b/crates/oa-desktop/src/behavior/state_scene.rs deleted file mode 100644 index 38875fc3..00000000 --- a/crates/oa-desktop/src/behavior/state_scene.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! State geometry follows semantic containment; presentation never reparents records. -use super::*; - -fn internal_transitions(p: &Project, id: Id) -> impl Iterator { - p.relations.values().filter(move |r| { - matches!(&r.body, RelationBody::StateTransition { - source, transition_kind: TransitionKind::Internal, .. - } if *source == id) - }) -} - -fn content_width(p: &Project, owner: Id) -> f32 { - p.children(owner) - .map(|e| match e.kind() { - Kind::Region => content_width(p, e.id) + 120., - k if k.is_stable_state() => content_width(p, e.id) + 32., - _ => 300., - }) - .fold(300., f32::max) -} - -impl Scene { - pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self { - let mut scene = Self { - scale: 1., - boxes: vec![], - lines: vec![], - size: Size::new(400., 180.), - selected, - }; - if !p.children(owner).any(|e| e.kind() == Kind::Region) { - return scene; - } - let width = content_width(p, owner) + 40.; - scene.boxes.push(BoxItem { - id: owner, - rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)), - title: p.named(owner).into(), - detail: String::new(), - marker: Some(Kind::StateMachine), - }); - let mut y = 64.; - for region in p.children(owner).filter(|e| e.kind() == Kind::Region) { - y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.; - } - scene.boxes[0].rect.height = y - 20.; - - // Entry/exit symbols are region-owned but attach to the containing boundary. - let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect(); - for b in &mut scene.boxes { - if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) { - continue; - } - let boundary = p - .elements - .get(&b.id) - .and_then(|e| e.parent) - .and_then(|id| p.elements.get(&id)) - .and_then(|e| e.parent); - if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) { - // The symbol is centered 16 px into its 32 px hit target. - b.rect.x = if b.marker == Some(Kind::EntryPoint) { - rect.x - 16. - } else { - rect.x + rect.width - 16. - }; - } - } - let vertices: BTreeMap<_, _> = scene - .boxes - .iter() - .filter(|b| b.marker.is_some_and(|k| k.is_state())) - .map(|b| (b.id, (b.rect, b.marker.unwrap()))) - .collect(); - for (index, r) in p - .relations - .values() - .filter(|r| { - r.kind() == RelationKind::StateTransition - && vertices.contains_key(&r.source()) - && vertices.contains_key(&r.target()) - }) - .enumerate() - { - if matches!( - &r.body, - RelationBody::StateTransition { - transition_kind: TransitionKind::Internal, - .. - } - ) { - continue; - } - let (a, ak) = vertices[&r.source()]; - let (b, bk) = vertices[&r.target()]; - let ay = a.y + if ak.is_stable_state() { 25. } else { 28. }; - let by = if r.source() == r.target() { - ay + 28. - } else { - b.y + if bk.is_stable_state() { 25. } else { 28. } - }; - let ax = if ak.is_stable_state() { - a.x + a.width - } else { - a.x + 32. - }; - let bx = if bk.is_stable_state() { - b.x + b.width - } else { - b.x + 32. - }; - let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.); - let activation = states::transition_summary(p, r); - scene.lines.push(Line { - id: r.id, - points: vec![ - Point::new(ax, ay), - Point::new(lane, ay), - Point::new(lane, by), - Point::new(bx, by), - ], - label: format!("{}. {}", index + 1, short(&activation, 24)), - label_at: Point::new(lane + 5., (ay + by) / 2.), - dashed: matches!( - &r.body, - RelationBody::StateTransition { - transition_kind: TransitionKind::Local, - .. - } - ), - }); - scene.size.width = scene.size.width.max(lane + 220.); - } - scene.size.width = scene.size.width.max(width + 180.); - scene.size.height = (y + 24.).max(180.); - scene - } - - fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 { - let index = self.boxes.len(); - self.boxes.push(BoxItem { - id, - rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)), - title: format!("REGION · {}", p.named(id)), - detail: String::new(), - marker: Some(Kind::Region), - }); - let mut y = top + 42.; - for state in p.children(id).filter(|e| e.kind().is_state()) { - let stable = state.kind().is_stable_state(); - let inner_width = if stable { width - 100. } else { 300. }; - let index = self.boxes.len(); - let detail = match state.shared() { - Some(SharedBody::State { - entry, - exit, - do_activity, - .. - }) => [("entry", entry), ("do", do_activity), ("exit", exit)] - .into_iter() - .filter(|(_, value)| !value.is_empty()) - .map(|(label, value)| format!("{label} / {value}")) - .collect::>() - .join(" · "), - _ => String::new(), - }; - self.boxes.push(BoxItem { - id: state.id, - rect: Rectangle::new( - Point::new(x + 18., y), - Size::new(inner_width, if stable { 76. } else { 56. }), - ), - title: state.name.clone(), - detail, - marker: Some(state.kind()), - }); - let start = y; - y += if stable { 76. } else { 56. }; - for r in internal_transitions(p, state.id) { - self.boxes.push(BoxItem { - id: r.id, - rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)), - title: format!("INTERNAL · {}", r.name), - detail: states::transition_summary(p, r), - marker: None, - }); - y += 61.; - } - for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) { - y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.; - } - self.boxes[index].rect.height = y - start; - y += 18.; - } - self.boxes[index].rect.height = (y - top).max(90.); - top + self.boxes[index].rect.height - } - - pub(super) fn is_container(&self, item: &BoxItem) -> bool { - matches!( - item.marker, - Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) - ) || (item.marker.is_some_and(|kind| kind.is_stable_state()) - && self.boxes.iter().any(|other| { - other.id != item.id - && item.rect.contains(other.rect.position()) - && item.rect.contains(Point::new( - other.rect.x + other.rect.width, - other.rect.y + other.rect.height, - )) - })) - } -} - -/// Pseudostates use actual vector shapes, with a full row as their hit target. -pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) { - let center = Point::new(b.rect.x + 16., b.rect.y + 28.); - let stroke = Stroke::default().with_color(color).with_width(1.8); - let circle = Path::circle(center, 11.); - match b.marker.unwrap() { - Kind::Initial | Kind::Junction => f.fill( - &Path::circle( - center, - if b.marker == Some(Kind::Initial) { - 8. - } else { - 5. - }, - ), - color, - ), - Kind::Final => { - f.stroke(&circle, stroke); - f.fill(&Path::circle(center, 7.), color); - } - Kind::Choice => f.stroke( - &Path::new(|p| { - p.move_to(Point::new(center.x, center.y - 12.)); - p.line_to(Point::new(center.x + 12., center.y)); - p.line_to(Point::new(center.x, center.y + 12.)); - p.line_to(Point::new(center.x - 12., center.y)); - p.close(); - }), - stroke, - ), - Kind::Fork | Kind::Join => f.fill_rectangle( - Point::new(center.x - 3., center.y - 18.), - Size::new(6., 36.), - color, - ), - Kind::ShallowHistory | Kind::DeepHistory => { - f.stroke(&circle, stroke); - f.fill_text(Text { - content: if b.marker == Some(Kind::DeepHistory) { - "H*" - } else { - "H" - } - .into(), - position: Point::new(center.x - 8., center.y - 9.), - color, - size: 13.into(), - ..Default::default() - }); - } - Kind::EntryPoint => { - f.fill(&circle, Color::WHITE); - f.stroke(&circle, stroke); - } - Kind::ExitPoint | Kind::Terminate => { - if b.marker == Some(Kind::ExitPoint) { - f.fill(&circle, Color::WHITE); - f.stroke(&circle, stroke); - } - f.stroke( - &Path::new(|p| { - p.move_to(Point::new(center.x - 7., center.y - 7.)); - p.line_to(Point::new(center.x + 7., center.y + 7.)); - p.move_to(Point::new(center.x - 7., center.y + 7.)); - p.line_to(Point::new(center.x + 7., center.y - 7.)); - }), - stroke, - ); - } - _ => {} - } -} diff --git a/crates/oa-desktop/src/behavior/state_view.rs b/crates/oa-desktop/src/behavior/state_view.rs new file mode 100644 index 00000000..73527ddc --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_view.rs @@ -0,0 +1,221 @@ +//! State workspace controls. Persistent semantics and layout live in the model. +use super::state_edit::{Event, Tool}; +use super::*; +use iced::widget::{Column, column, opaque, stack}; + +pub struct State { + pub diagram: Option, + pub placement: Option, + pub tool: Tool, + pub viewport: Option, + pub zoom: f32, + pub pan: iced::Vector, + pub fit: bool, +} +impl Default for State { + fn default() -> Self { + Self { + diagram: None, + placement: None, + tool: Tool::Select, + viewport: None, + zoom: 0.8, + pan: iced::Vector::new(24., 48.), + fit: true, + } + } +} + +pub fn diagram(app: &App, machine: Id) -> Option { + let valid = |id: &Id| { + app.project() + .diagrams + .get(id) + .is_some_and(|d| d.projection.state_machine() == Some(machine)) + }; + app.behavior + .diagram + .diagram + .filter(valid) + .or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied()) +} + +fn event(e: Event) -> Message { + Message::StateDiagram(e) +} + +pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { + let Some(id) = diagram(app, machine) else { + let base = out.push(container(column![ + text("Create a state diagram").size(20), + label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."), + button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))), + row![ + action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))), + action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))), + transition_details(machine, app.selected), + ].spacing(5), + ].spacing(14)).padding(24).style(style::card)).padding(18).into(); + return with_form(app, machine, base); + }; + let context = app + .state_context() + .expect("a visible state diagram has a context"); + let p = app.project(); + let state = &app.behavior.diagram; + let views = p + .diagrams + .values() + .filter(|d| d.projection.state_machine() == Some(machine)) + .map(|d| Choice { + id: d.id, + label: d.name.clone(), + }) + .collect::>(); + let current = views.iter().find(|v| v.id == id).cloned(); + out = out.push( + row![ + pick_list(views, current, Message::BehaviorDiagram) + .padding(8) + .text_size(12) + .width(Fill), + action("+ View", event(Event::CreateView(machine))), + action("Fit", event(Event::Fit(context))), + action("−", event(Event::Zoom(context, state.zoom / 1.2))), + label(format!("{:.0}%", state.zoom * 100.)), + action("+", event(Event::Zoom(context, state.zoom * 1.2))), + ] + .spacing(5) + .align_y(iced::Center), + ); + + let tool = |name: &'static str, value: Tool| { + button(text(name).size(12)) + .padding([8, 10]) + .style(if state.tool == value { + button::primary + } else { + style::nav + }) + .on_press(event(Event::Choose(value))) + }; + let kinds = [ + Kind::State, + Kind::Mode, + Kind::Initial, + Kind::Final, + Kind::Choice, + Kind::Junction, + Kind::Fork, + Kind::Join, + Kind::ShallowHistory, + Kind::DeepHistory, + Kind::EntryPoint, + Kind::ExitPoint, + Kind::Terminate, + ]; + let selected_kind = if let Tool::Vertex(kind) = state.tool { + Some(kind) + } else { + None + }; + out = out.push( + row![ + tool("Select / move", Tool::Select), + pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose( + Tool::Vertex(k) + ))) + .placeholder("+ State / symbol") + .padding(8) + .text_size(12), + tool("+ Region", Tool::Region), + tool("Transition", Tool::Transition), + ] + .spacing(4), + ); + + let missing = state_edit::unplaced(p, id) + .into_iter() + .map(|element| Choice { + id: element, + label: format!("{} · {}", p.named(element), p.elements[&element].kind()), + }) + .collect::>(); + if !missing.is_empty() { + let position = Position { + x: (48. - state.pan.x) / state.zoom, + y: (90. - state.pan.y) / state.zoom, + }; + out = out.push( + pick_list(missing, None::, move |c| { + event(Event::Place { + context, + element: c.id, + position, + }) + }) + .placeholder("Show an element not yet in this view…") + .padding(8) + .text_size(12) + .width(Fill), + ); + } + let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas { + project: p, + diagram: id, + context, + selected: app.selected, + tool: state.tool, + zoom: state.zoom, + pan: state.pan, + editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(), + }) + .width(Fill) + .height(Fill) + .into(); + out = out.push(canvas.map(event)); + let base: Element<'_, Message> = container(out.spacing(8)) + .padding(14) + .width(Fill) + .height(Fill) + .into(); + with_form(app, machine, base) +} + +fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> { + action( + "New transition with details…", + Message::Behavior(super::Event::States(states::Event::Start( + machine, + states::Tool::Transition, + selected, + ))), + ) + .into() +} + +fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> { + if let Some(form) = states::form(app, machine) { + stack![ + base, + opaque( + container( + container( + scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.)) + ) + .padding(18) + .width(600) + .style(style::card) + ) + .center(Fill) + .style(|_| container::Style { + background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), + ..Default::default() + }) + ) + ] + .into() + } else { + base + } +} diff --git a/crates/oa-desktop/src/behavior/states.rs b/crates/oa-desktop/src/behavior/states.rs index 675c9a43..df218ea7 100644 --- a/crates/oa-desktop/src/behavior/states.rs +++ b/crates/oa-desktop/src/behavior/states.rs @@ -1,6 +1,6 @@ //! State authoring through the same validated commands as source and REST. use super::*; -use iced::widget::{Column, checkbox, column, text_input}; +use iced::widget::{checkbox, column, text_input}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { @@ -41,15 +41,10 @@ pub enum Event { Timed(bool), TimeKind(String), Expression(String), - Viewport(Size), - Zoom(f32), - Fit, } #[derive(Default)] pub struct State { draft: Option, - viewport: Option, - zoom: Option, } struct Draft { machine: Id, @@ -272,9 +267,6 @@ impl State { } Event::Cancel => self.acknowledge_commit(), Event::Submit => return self.prepare_pending(p).map(Some), - Event::Viewport(size) => self.viewport = Some(size), - Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)), - Event::Fit => self.zoom = None, event => { if let Some(d) = &mut self.draft { match event { @@ -359,359 +351,218 @@ fn picker<'a>( .width(Fill) .into() } -pub(super) fn transition_summary(p: &Project, r: &Relation) -> String { - let RelationBody::StateTransition { - transition_kind, - triggers, - trigger, - guard, - effects, - effect, - .. - } = &r.body - else { - return r.name.clone(); - }; - let activation = if !triggers.is_empty() { - triggers - .iter() - .map(|id| p.named(*id)) - .collect::>() - .join(" OR ") - } else if !trigger.trim().is_empty() { - format!("Unresolved trigger: {trigger}") - } else if p - .elements - .get(&r.source()) - .is_some_and(|e| e.kind().is_stable_state()) - { - "Completion".into() - } else { - "Transient routing".into() - }; - let mut result = format!("{transition_kind:?} · {activation}"); - if !triggers.is_empty() && !trigger.is_empty() { - result.push_str(&format!(" · {trigger}")); - } - if !guard.is_empty() { - result.push_str(&format!(" [{guard}]")); - } - if !effects.is_empty() { - result.push_str(&format!( - " / {}", - effects - .iter() - .map(|id| p.named(*id)) - .collect::>() - .join(", ") - )); - } - if !effect.is_empty() { - result.push_str(&format!(" / {effect}")); - } - result -} -pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { +pub(super) fn form(app: &App, machine: Id) -> Option> { let p = app.project(); - let state = &app.behavior.states; - out = out.push( - row![ - action( - "+ Vertex", - event(Event::Start(machine, Tool::Vertex, app.selected)) - ), - action( - "+ Region", - event(Event::Start(machine, Tool::Region, app.selected)) - ), - action( - "+ Transition", - event(Event::Start(machine, Tool::Transition, app.selected)) - ), - action("Properties", Message::EditProperties(machine)), - ] - .spacing(5), - ); - if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) { - let mut form = column![ - text(if d.original.is_some() { - "Edit transition" - } else { - match d.tool { - Tool::Vertex => "Add state or pseudostate", - Tool::Region => "Add region", - Tool::Transition => "Connect states", - } - }) - .size(17), - text_input("Engineering name (optional)", &d.name) - .on_input(|s| event(Event::Name(s))) - .padding(9) - .size(12), - ] - .spacing(9); - if d.tool != Tool::Transition { - if d.tool == Tool::Vertex { - form = form.push( - pick_list( - Kind::ALL - .into_iter() - .filter(|k| k.is_state()) - .collect::>(), - Some(d.vertex), - |k| event(Event::Vertex(k)), - ) - .padding(9) - .text_size(12) - .width(Fill), - ); - } - let parents = p - .elements - .values() - .filter(|e| { - in_machine(p, e.id, machine) - && if d.tool == Tool::Vertex { - e.kind() == Kind::Region - } else { - e.kind() == Kind::StateMachine || e.kind().is_stable_state() - } - }) - .map(|e| choice(p, e.id)) - .collect(); - form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent)); - form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." })); + let d = app + .behavior + .states + .draft + .as_ref() + .filter(|d| d.machine == machine)?; + let mut form = column![ + text(if d.original.is_some() { + "Edit transition" } else { - let mut context = p.clone(); - if let Some(id) = d.original { - context.relations.remove(&id); + match d.tool { + Tool::Vertex => "Add state or pseudostate", + Tool::Region => "Add region", + Tool::Transition => "Connect states", } - let vertices: Vec<_> = p - .elements - .values() - .filter(|e| e.kind().is_state() && in_machine(p, e.id, machine)) - .map(|e| choice(p, e.id)) - .collect(); - let targets = vertices - .iter() - .filter(|c| { - d.source.is_some_and(|source| { - if d.kind == TransitionKind::Internal { - return c.id == source && p.elements[&source].kind().is_stable_state(); - } - if d.kind == TransitionKind::Local - && p.elements[&source].kind().is_stable_state() - { - return c.id != source && in_machine(p, c.id, source); - } - validation::can_connect( - &context, - RelationKind::StateTransition, - source, - c.id, - ) - }) - }) - .cloned() - .collect(); - form = form - .push(picker(vertices, d.source, "From state…", Event::Source)) - .push(picker(targets, d.target, "To state…", Event::Target)) - .push( - pick_list( - [ - TransitionChoice(TransitionKind::External), - TransitionChoice(TransitionKind::Local), - TransitionChoice(TransitionKind::Internal), - ], - Some(TransitionChoice(d.kind)), - |k| event(Event::Kind(k)), - ) - .padding(9) - .text_size(12) - .width(Fill), - ); - let layer = p.elements[&machine].layer(); - let triggers = p - .elements - .values() - .filter(|e| { - e.layer() == layer - && (e.kind() == Kind::Event || e.kind().is_work()) - && !d.triggers.contains(&e.id) - }) - .map(|e| choice(p, e.id)) - .collect(); - form = form.push(label("Trigger events / work (alternatives)")); - for id in &d.triggers { - form = form.push( - row![ - text(p.named(*id)).size(12).width(Fill), - action("Remove", event(Event::RemoveTrigger(*id))) - ] - .spacing(5), - ); - } - form = form - .push(picker( - triggers, - None, - "Add existing trigger…", - Event::Trigger, - )) - .push( - checkbox(d.timed) - .label("Add a time event with this transition") - .on_toggle(|b| event(Event::Timed(b))), - ); - if d.timed { - form = form.push( - row![ - pick_list( - ["After".to_string(), "At".to_string()], - Some(format!("{:?}", d.time_kind)), - |s| event(Event::TimeKind(s)) - ) - .padding(9) - .text_size(12), - text_input("Time expression, e.g. 30 s", &d.expression) - .on_input(|s| event(Event::Expression(s))) - .padding(9) - .size(12), - ] - .spacing(6), - ); - } - form = form - .push( - text_input("Trigger notes (optional)", &d.trigger) - .on_input(|s| event(Event::TriggerNotes(s))) - .padding(9) - .size(12), + }) + .size(17), + text_input("Engineering name (optional)", &d.name) + .on_input(|s| event(Event::Name(s))) + .padding(9) + .size(12), + ] + .spacing(9); + if d.tool != Tool::Transition { + if d.tool == Tool::Vertex { + form = form.push( + pick_list( + Kind::ALL + .into_iter() + .filter(|k| k.is_state()) + .collect::>(), + Some(d.vertex), + |k| event(Event::Vertex(k)), ) - .push( - text_input("Guard condition (optional)", &d.guard) - .on_input(|s| event(Event::Guard(s))) - .padding(9) - .size(12), - ); - let effects = p - .elements - .values() - .filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id)) - .map(|e| choice(p, e.id)) - .collect(); - for id in &d.effects { - form = form.push( - row![ - text(p.named(*id)).size(12).width(Fill), - action("Remove", event(Event::RemoveEffect(*id))) - ] - .spacing(5), - ); - } - form = form - .push(picker(effects, None, "Add effect work…", Event::Effect)) - .push( - text_input("Effect notes (optional)", &d.effect) - .on_input(|s| event(Event::EffectNotes(s))) - .padding(9) - .size(12), - ); - if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() { - form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate.")); - } - } - let candidate = d.command(p).and_then(|command| { - execute_batch(p, &[command]) - .map(|_| ()) - .map_err(|e| e.to_string()) - }); - if let Err(error) = &candidate { - form = form.push(text(error.clone()).size(12).color(style::MUTED)); - } - form = form.push( - row![ - button("Apply") - .padding(9) - .on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))), - action("Cancel", event(Event::Cancel)) - ] - .spacing(7), - ); - out = out.push(container(form).padding(14).style(style::card)); - } - let mut scene = Scene::states(p, machine, app.selected); - if scene.boxes.is_empty() { - out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card)); - } else { - let width = state - .viewport - .map_or((app.window_size.width - 600.).max(200.), |s| s.width); - let scale = state.zoom.unwrap_or_else(|| { - ((width - 24.).max(1.) / scene.size.width) - .min(440. / scene.size.height) - .clamp(0.05, 1.) - }); - scene.scale = scale; - let size = Size::new(scene.size.width * scale, scene.size.height * scale); - out = out - .push( - row![ - action("Fit", event(Event::Fit)), - action("−", event(Event::Zoom(scale / 1.25))), - label(format!("{:.0}%", scale * 100.)), - action("+", event(Event::Zoom(scale * 1.25))), - action("100%", event(Event::Zoom(1.))) - ] - .spacing(8) - .align_y(iced::Center), - ) - .push( - iced::widget::sensor( - container( - scrollable( - canvas::Canvas::new(scene) - .width(size.width) - .height(size.height), - ) - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .height(size.height.clamp(180., 460.)), - ) - .padding(8) - .width(Fill) - .style(style::card), - ) - .on_show(|s| event(Event::Viewport(s))) - .on_resize(|s| event(Event::Viewport(s))), + .padding(9) + .text_size(12) + .width(Fill), ); - } - for r in p - .relations - .values() - .filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine)) - { - out = out.push( - container( - column![ - action( - &format!("{} → {}", p.named(r.source()), p.named(r.target())), - event(Event::Edit(r.id)) - ) - .width(Fill), - text(transition_summary(p, r)).size(12), - action("Properties", Message::EditProperties(r.id)) + } + let parents = p + .elements + .values() + .filter(|e| { + in_machine(p, e.id, machine) + && if d.tool == Tool::Vertex { + e.kind() == Kind::Region + } else { + e.kind() == Kind::StateMachine || e.kind().is_stable_state() + } + }) + .map(|e| choice(p, e.id)) + .collect(); + form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent)); + form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." })); + } else { + let mut context = p.clone(); + if let Some(id) = d.original { + context.relations.remove(&id); + } + let vertices: Vec<_> = p + .elements + .values() + .filter(|e| e.kind().is_state() && in_machine(p, e.id, machine)) + .map(|e| choice(p, e.id)) + .collect(); + let targets = vertices + .iter() + .filter(|c| { + d.source.is_some_and(|source| { + if d.kind == TransitionKind::Internal { + return c.id == source && p.elements[&source].kind().is_stable_state(); + } + if d.kind == TransitionKind::Local + && p.elements[&source].kind().is_stable_state() + { + return c.id != source && in_machine(p, c.id, source); + } + validation::can_connect(&context, RelationKind::StateTransition, source, c.id) + }) + }) + .cloned() + .collect(); + form = form + .push(picker(vertices, d.source, "From state…", Event::Source)) + .push(picker(targets, d.target, "To state…", Event::Target)) + .push( + pick_list( + [ + TransitionChoice(TransitionKind::External), + TransitionChoice(TransitionKind::Local), + TransitionChoice(TransitionKind::Internal), + ], + Some(TransitionChoice(d.kind)), + |k| event(Event::Kind(k)), + ) + .padding(9) + .text_size(12) + .width(Fill), + ); + let layer = p.elements[&machine].layer(); + let triggers = p + .elements + .values() + .filter(|e| { + e.layer() == layer + && (e.kind() == Kind::Event || e.kind().is_work()) + && !d.triggers.contains(&e.id) + }) + .map(|e| choice(p, e.id)) + .collect(); + form = form.push(label("Trigger events / work (alternatives)")); + for id in &d.triggers { + form = form.push( + row![ + text(p.named(*id)).size(12).width(Fill), + action("Remove", event(Event::RemoveTrigger(*id))) ] .spacing(5), + ); + } + form = form + .push(picker( + triggers, + None, + "Add existing trigger…", + Event::Trigger, + )) + .push( + checkbox(d.timed) + .label("Add a time event with this transition") + .on_toggle(|b| event(Event::Timed(b))), + ); + if d.timed { + form = form.push( + row![ + pick_list( + ["After".to_string(), "At".to_string()], + Some(format!("{:?}", d.time_kind)), + |s| event(Event::TimeKind(s)) + ) + .padding(9) + .text_size(12), + text_input("Time expression, e.g. 30 s", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ] + .spacing(6), + ); + } + form = form + .push( + text_input("Trigger notes (optional)", &d.trigger) + .on_input(|s| event(Event::TriggerNotes(s))) + .padding(9) + .size(12), ) - .padding(12) - .style(style::card), - ); + .push( + text_input("Guard condition (optional)", &d.guard) + .on_input(|s| event(Event::Guard(s))) + .padding(9) + .size(12), + ); + let effects = p + .elements + .values() + .filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id)) + .map(|e| choice(p, e.id)) + .collect(); + for id in &d.effects { + form = form.push( + row![ + text(p.named(*id)).size(12).width(Fill), + action("Remove", event(Event::RemoveEffect(*id))) + ] + .spacing(5), + ); + } + form = form + .push(picker(effects, None, "Add effect work…", Event::Effect)) + .push( + text_input("Effect notes (optional)", &d.effect) + .on_input(|s| event(Event::EffectNotes(s))) + .padding(9) + .size(12), + ); + if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() { + form = form.push(label( + "No event trigger: completion from a state, transient routing from a pseudostate.", + )); + } } - scrollable(out.padding(24)).into() + let candidate = d.command(p).and_then(|command| { + execute_batch(p, &[command]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }); + if let Err(error) = &candidate { + form = form.push(text(error.clone()).size(12).color(style::MUTED)); + } + form = form.push( + row![ + button("Apply") + .padding(9) + .on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))), + action("Cancel", event(Event::Cancel)) + ] + .spacing(7), + ); + Some(container(form).padding(4).into()) } #[cfg(test)] @@ -783,7 +634,7 @@ mod tests { assert_eq!(guard, "link unavailable"); assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s"))); assert_eq!(&language::parse(&language::format(result)).unwrap(), result); - assert!(transition_summary(result, r).contains(" OR ")); + assert!(routing::state_transition_summary(result, r).contains(" OR ")); session.undo(); assert_eq!(session.project(), &p); } @@ -841,14 +692,7 @@ mod tests { result.relations[&id].source(), result.relations[&id].target() ); - let scene = Scene::states(&result, machine, id); - assert!( - scene - .boxes - .iter() - .any(|b| b.id == id && b.title.starts_with("INTERNAL")) - ); - assert!(!scene.lines.iter().any(|l| l.id == id)); + assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty()); } #[test] fn selected_composite_owns_new_region_and_history_uses_selected_region() { @@ -881,29 +725,6 @@ mod tests { .children(region) .any(|e| e.kind() == Kind::DeepHistory) ); - let mut scene = Scene::states(&result, machine, ready); - let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect; - let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect; - assert!(container.contains(child.position())); - assert!(container.contains(Point::new(child.x + child.width, child.y + child.height))); - let history = scene - .boxes - .iter() - .find(|b| b.marker == Some(Kind::DeepHistory)) - .unwrap(); - let id = history.id; - let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5); - scene.scale = 0.5; - let action = canvas::Program::update( - &scene, - &mut (), - &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), - Rectangle::new(Point::ORIGIN, scene.size), - mouse::Cursor::Available(point), - ) - .unwrap() - .into_inner() - .0; - assert!(matches!(action, Some(Message::Select(selected)) if selected == id)); + assert_eq!(language::parse(&language::format(&result)).unwrap(), result); } } diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 7aae7432..87ccd5f0 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -53,7 +53,7 @@ impl Opened { if !app.project().contains(request.object) || request .canvas - .is_some_and(|c| Some(c) != app.route_context()) + .is_some_and(|c| Some(c) != current_canvas(app)) { return None; } @@ -65,7 +65,7 @@ impl Opened { epoch: app.canvas_epoch, page: app.page, layer: app.layer, - diagram: app.active_diagram, + diagram: current_diagram(app), }) } pub fn valid(&self, app: &App) -> bool { @@ -74,8 +74,12 @@ impl Opened { && self.epoch == app.canvas_epoch && self.page == app.page && self.layer == app.layer - && self.diagram == app.active_diagram + && self.diagram == current_diagram(app) && app.project().contains(self.request.object) + && self + .request + .canvas + .is_none_or(|c| Some(c) == current_canvas(app)) && self .request .source_file @@ -84,6 +88,22 @@ impl Opened { } } +fn current_canvas(app: &App) -> Option { + if app.page == Page::Behavior { + app.state_context() + } else { + app.route_context() + } +} + +fn current_diagram(app: &App) -> Option { + if app.page == Page::Behavior { + app.state_context().map(|context| context.diagram) + } else { + app.active_diagram + } +} + pub fn open(object: Id, anchor: Point) -> Message { Message::Menu(Event::Open(Request::object(object, anchor))) } @@ -100,6 +120,17 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> { .filter(|(id, _)| *id == app.selected) .map(|(_, i)| i); request.canvas = app.route_context(); + } else if app.page == Page::Behavior { + request.canvas = app.state_context(); + if let Some(context) = request.canvas + && let Some(node) = app.project().diagrams[&context.diagram] + .nodes + .values() + .find(|node| node.element == app.selected || node.id == app.selected) + { + request.object = node.element; + request.placement = Some(node.id); + } } Message::Menu(Event::Open(request)) }, @@ -112,8 +143,9 @@ pub fn popup(app: &App) -> Option> { let request = &opened.request; let p = app.project(); let id = request.object; - let clean = - !app.busy && (request.source_file.is_some() || (!app.draft_dirty() && !app.source_dirty())); + let clean = !app.busy + && (request.source_file.is_some() + || (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending())); let item = |caption: &str, message: Message, enabled: bool| { if clean && enabled { Entry::action( @@ -151,6 +183,11 @@ pub fn popup(app: &App) -> Option> { on_close: Message::Menu(Event::Close), }); } + if app.page == Page::Behavior + && let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean) + { + return Some(menu); + } let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))]; if !clean { entries.push(Entry::title("Apply or revert pending edits first")); @@ -453,6 +490,193 @@ mod tests { }) .unwrap_or_else(|| panic!("Expected enabled menu action {caption}")) } + #[test] + fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() { + use crate::behavior::state_edit; + let mut project = Project::empty(); + let machine = project + .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Oa, "Region", Some(machine)) + .unwrap(); + let state = project + .add_element(Kind::State, Layer::Oa, "Ready", Some(region)) + .unwrap(); + let architecture = project.ensure_diagram(Layer::Oa); + project.place(architecture, state, Position { x: 80., y: 90. }); + let first = state_edit::create_view(&project, machine).unwrap(); + let first_id = first.diagram; + project = execute_batch(&project, &[first.command]).unwrap(); + let second = state_edit::create_view(&project, machine).unwrap(); + let second_id = second.diagram; + project = execute_batch(&project, &[second.command]).unwrap(); + let placement = project.diagrams[&first_id] + .nodes + .values() + .find(|node| node.element == state) + .unwrap() + .id; + let mut app = App::with_project(project.clone()); + send(&mut app, Message::Select(machine)); + send(&mut app, Message::Page(Page::Behavior)); + app.behavior.diagram.diagram = Some(first_id); + app.active_diagram = Some(architecture); + let open_state = |app: &mut App| { + let mut request = Request::object(state, Point::ORIGIN); + request.canvas = app.state_context(); + request.placement = Some(placement); + send(app, Message::Menu(Event::Open(request))); + }; + + // A canvas menu from Architecture is stale on the state page even when + // the architecture diagram itself remains the active architecture view. + let mut wrong = Request::object(state, Point::ORIGIN); + wrong.canvas = app.route_context(); + assert!(Opened::new(&app, wrong, 1).is_none()); + open_state(&mut app); + let fold = choose(&app, "Fold contents"); + send(&mut app, fold); + assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed); + assert_eq!(app.selected, state, "Folding retains semantic selection"); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.state_context().unwrap().diagram, first_id); + assert_eq!(app.active_diagram, Some(architecture)); + assert_eq!( + app.project().diagrams[&architecture], + project.diagrams[&architecture] + ); + assert_eq!( + app.project().diagrams[&second_id], + project.diagrams[&second_id] + ); + assert_eq!(app.project().elements, project.elements); + open_state(&mut app); + let unfold = choose(&app, "Unfold contents"); + send(&mut app, unfold); + assert_eq!(app.selected, state, "Unfolding retains semantic selection"); + assert_eq!(app.project(), &project); + assert_eq!(app.active_diagram, Some(architecture)); + send(&mut app, Message::Undo); + assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &project); + + open_state(&mut app); + let stale = choose(&app, "Fold contents"); + app.behavior.diagram.diagram = Some(second_id); + send(&mut app, stale); + assert_eq!(app.project(), &project); + app.behavior.diagram.diagram = Some(first_id); + open_state(&mut app); + let hide = choose(&app, "Hide from this diagram"); + send(&mut app, hide); + assert!( + !app.project().diagrams[&first_id] + .nodes + .contains_key(&placement) + ); + assert_eq!( + app.project().diagrams[&architecture], + project.diagrams[&architecture] + ); + assert_eq!( + app.project().diagrams[&second_id], + project.diagrams[&second_id] + ); + assert_eq!(app.project().elements, project.elements); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &project); + + // Placement properties deliberately select a placement record, but an + // accepted numeric edit must keep the Behavior view's own context. + let source_before = app.document.bundle().clone(); + open_state(&mut app); + let properties = choose(&app, "Placement properties"); + send(&mut app, properties); + assert_eq!(app.selected, placement); + assert_eq!( + app.draft.as_ref().unwrap().kind, + crate::forms::RecordKind::Node(first_id) + ); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.active_diagram, Some(architecture)); + let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25; + send( + &mut app, + Message::Field("/position/x".into(), x.to_string()), + ); + assert!(app.draft_dirty()); + send(&mut app, Message::Apply); + assert!(!app.draft_dirty()); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.behavior.active_owner, Some(machine)); + assert_eq!(app.state_context().unwrap().diagram, first_id); + assert_eq!( + app.active_diagram, + Some(architecture), + "Placement Apply must not replace the Architecture view" + ); + let mut expected = project.clone(); + expected + .diagrams + .get_mut(&first_id) + .unwrap() + .nodes + .get_mut(&placement) + .unwrap() + .position + .x = x; + assert_eq!(app.project(), &expected); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &source_before); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.state_context().unwrap().diagram, first_id); + assert_eq!(app.active_diagram, Some(architecture)); + } + + #[test] + fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() { + let mut project = Project::empty(); + let machine = project + .add_element(Kind::StateMachine, Layer::Oa, "Modes", None) + .unwrap(); + let region = project + .add_element(Kind::Region, Layer::Oa, "Region", Some(machine)) + .unwrap(); + let entry = project + .add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region)) + .unwrap(); + let mut app = App::with_project(project); + send(&mut app, Message::Select(entry)); + send(&mut app, Message::Page(Page::Behavior)); + send(&mut app, open(entry, Point::ORIGIN)); + let start = choose(&app, "New transition with details…"); + send(&mut app, start); + assert_eq!(app.behavior.pending_owner(), Some(machine)); + send( + &mut app, + Message::Behavior(crate::behavior::Event::States( + crate::behavior::states::Event::Cancel, + )), + ); + send( + &mut app, + Message::Field("/name".into(), "Unapplied entry".into()), + ); + let before = app.draft.as_ref().unwrap().materialize().unwrap(); + send(&mut app, open(entry, Point::ORIGIN)); + assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!( + entry, + Entry::Action { + message: Some(_), + .. + } + ))); + assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before); + } + #[test] fn right_click_targets_actor_without_changing_selection_until_action() { let mut app = App::new(); diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md index 91471e37..a4a0ec52 100644 --- a/harness/DECISIONS.md +++ b/harness/DECISIONS.md @@ -44,3 +44,17 @@ - Each checkout uses a distinct Cargo target directory. A shared-target Linux run reused a different worktree's test executable; only explicitly rebuilt main results count as evidence. + +## Persisted state workspace and input coordinates (2026-09-06) + +State-machine views use the core projection/routing and ordinary Document commands, +with a retained behavior owner/view separate from Architecture. Palette gestures +create elements plus placements atomically; typed forms remain reachable for Local, +Internal and timed transitions. Context menus capture the state diagram so fold, +hide and placement properties cannot modify an unrelated Architecture view. + +Gesture displacement is measured in ordered window coordinates, not coordinates +relative to a canvas whose origin may move when selection changes the toolbar. +Viewport notifications fit only on actual size changes. Native testing also showed +Iced WGPU places parent meshes after clipped child meshes; backgrounds must share +their content clip to avoid hiding vector geometry while leaving text visible. diff --git a/harness/STATE.md b/harness/STATE.md index c65e5e85..2554133e 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -12,7 +12,7 @@ Authoritative objective: attachment PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain full release scope. The latest user-directed drawing/port/menu/source-tree milestone is accepted at -`fef2e0d` plus the pending tooltip-layer follow-up. It does not complete the release. +`fef2e0d` plus tooltip-layer follow-up `ad7feb5`. It does not complete the release. Root now integrates direct state-machine editing in an isolated worktree. There is meaningful implementation, independent review and real runtime progress; no external blocker or disk blocker exists. @@ -108,18 +108,59 @@ Target: `D:/CodexBuilds/Capella2-state-integration-target`. Main core is not yet strict workspace all-target Clippy passed. This baseline precedes direct gestures; there is no native state-gesture acceptance yet. -Ownership: root owns App/behavior toolbar/context integration. drawing_correctness -owns only new behavior/state_edit.rs planners; drawing_linux owns only new -behavior/state_canvas.rs rendering/input. They coordinate explicit Context/Event -APIs and use ordinary commands/shared geometry. drawing_requirements completed -representation work and is idle for independent review. Root exclusively owns -native input. Do not edit another worker's files or share Cargo targets. +Direct-state UI integration is now implemented (uncommitted): persisted canvas, +creation within explicit regions, movement, direct transitions and endpoint +reconnection, pan/zoom/Fit, typed detail forms, and contextual fold/unfold/hide. +The old computed static state scene is removed. Source/model commands remain atomic. +Independent review found removed typed-form access, unreachable folded contents, +selection relayout altering drag deltas, fallback owner resetting the first drag, +and same-size viewport events refitting the camera. Fixes preserve typed creation, +use state-specific menus, window-space ordered gesture deltas, retained owner and +actual-size resize checks. App tests also exposed Architecture placement leaking +into Behavior selection; the two view selections are now separate. + +First direct-state Windows verification:231 desktop tests and strict desktop +all-target Clippy passed, logs `D:/CodexBuilds/Capella2-state-direct-{tests,clippy}.log`. +Native trial2A4B5C11 created a new SA StateMachine and dedicated view; it reproduced +missing box fills/borders while text remained. Iced WGPU appends parent meshes after +clipped children: the opaque parent background covered every node. The fix moves +the content background into the clip and restricts the outer fill to the guidance +strip. Native re-verification remains required. Saved reproduction is +`ui-state-direct-01/project.arcadia`,42objects/25links/2 expected incomplete-machine +checks. Trial window1442250 received normal close after verified Save; re-inventory. + +Latest verification:551 Windows workspace tests (277core,236desktop,38server) +and strict workspace all-target Clippy passed. A single let-and-return lint in the +new label hit helper was fixed without changing behavior. Subsequent compact-toolbar +changes passed desktop Clippy and build. Exact logs use `state-direct-workspace-*` +and `state-compact-*` under D:/CodexBuilds. + +Native82AE5A2C closed the missing-shapes bug; root directly created Region, Initial, +State, first-drag Initial→State transition, selected without motion, moved/undid the +state, created Internal Monitor communications with generated After30s Event, +Undo/Redo of both, and Fold/Unfold via right-click. Saved trial46objects/27links/1 +remaining model check uses ui-state-direct-01/project.arcadia; its original empty +machine fixture is preserved in baseline-before-editing. Minimum1100x700 exposed +excessive toolbar height; latest build removes selected-action row and shortens +state heading. Native recheck is pending, along with reconnection, nested composite, +Linux state runtime and independent final runtime review. + +Additional review/native fixes: Fold retains semantic selection; placement property +Apply retains Behavior view without overwriting Architecture view. Initial names +choose among12 deterministic route-aware positions, and label hit testing uses the +same cached rectangle. This closes the observed rightward-line/name collision in +code; native verification of this last refinement remains pending. + +Root owns integration/native input; all workers have returned files. Every worktree +uses its own Cargo target. Main remains ad7feb5; state work is isolated and not yet +accepted into main. No release-completion claim. ## Exact next actions -1. Commit the verified main tooltip follow-up and evidence, retaining source/GUI/API - parity. Latest Windows trial is saved; no final-release claim. -2. Integrate state-edit planners and actual persisted canvas with explicit retained +1. Rebuild and verify the state rendering fix on the saved native reproduction; + complete real state creation, connections, folding, form edit, Save/reopen and + minimum-size trials. Commit only after reviewed behavior is demonstrated. +2. Finish state-edit acceptance with explicit retained behavior view, creation in a region, movement, transition drawing/reconnection, guarded forms, discoverable missing placements, pan/zoom and contextual actions. Build/run/test native at default/minimum sizes; review/reproduce/fix findings.