//! Readable behavioral projections. Selection edits the same typed records as text. use crate::{ app::{App, Choice, Message}, style, views::{action, heading, label, short}, }; use iced::{ Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme, mouse, widget::{ Action, button, canvas::{self, Frame, Path, Stroke, Text}, column, container, pick_list, row, scrollable, text, }, }; use oa_core::*; use std::collections::BTreeMap; pub(crate) mod scenario; pub(crate) mod state_canvas; pub(crate) mod state_edit; pub(crate) mod state_menu; pub(crate) mod state_view; pub(crate) mod states; pub(crate) mod topology; #[derive(Debug, Clone)] pub enum Event { Topology(topology::Event), Scenario(scenario::Event), States(states::Event), Submit, } impl Event { pub fn starts_form(&self) -> bool { matches!( self, Self::Topology(topology::Event::Start(..)) | Self::Scenario(scenario::Event::Start(..)) | Self::States(states::Event::Start(..) | states::Event::Edit(..)) ) } pub fn is_submit(&self) -> bool { matches!( self, Self::Submit | Self::Topology(topology::Event::Submit) | Self::Scenario(scenario::Event::Submit) | Self::States(states::Event::Submit) ) } } #[derive(Default)] pub struct State { pub active_owner: Option, pub diagram: state_view::State, topology: topology::State, pub(crate) scenario: scenario::State, states: states::State, } const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another."; impl State { pub fn has_pending(&self) -> bool { self.pending_owner().is_some() } pub fn pending_owner(&self) -> Option { self.topology .pending_owner() .or_else(|| self.scenario.pending_owner()) .or_else(|| self.states.pending_owner()) } /// Prepare the active form without discarding inputs. The caller must execute /// this exact command and acknowledge only a successful document transaction. pub fn prepare_pending(&self, p: &Project) -> Result, String> { if self.topology.pending_owner().is_some() { self.topology.prepare_pending(p).map(Some) } else if self.scenario.pending_owner().is_some() { self.scenario.prepare_pending(p).map(Some) } else if self.states.pending_owner().is_some() { self.states.prepare_pending(p).map(Some) } else { Ok(None) } } /// Clear the active form after its prepared command commits successfully. /// Rejected commands must leave the form available for repair and retry. pub fn acknowledge_commit(&mut self) { self.topology.acknowledge_commit(); self.scenario.acknowledge_commit(); self.states.acknowledge_commit(); } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { if self.has_pending() && matches!( &event, Event::Topology(topology::Event::Start(..)) | Event::Scenario(scenario::Event::Start(..)) | Event::States(states::Event::Start(..) | states::Event::Edit(..)) ) { return Err(PENDING_DRAFT.into()); } let cancels_pending = match &event { Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(), Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(), Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(), _ => false, }; if cancels_pending { self.acknowledge_commit(); return Ok(None); } match event { Event::Topology(event) => self.topology.update(p, event), Event::Scenario(event) => self.scenario.update(p, event), Event::States(event) => self.states.update(p, event), Event::Submit => self .prepare_pending(p)? .map(Some) .ok_or_else(|| "Choose a behavior modeling tool.".into()), } } } pub fn view(app: &App) -> Element<'_, Message> { let p = app.project(); let choices = p .elements .values() .filter(|e| { e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) }) .map(|e| Choice { id: e.id, label: format!("{} · {}", e.kind(), e.name), }) .collect::>(); let owner = selected_owner(app); let selected = owner .and_then(|id| choices.iter().find(|c| c.id == id)) .or(choices.first()) .cloned(); let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14); let Some(selected) = selected else { return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into(); }; let e = &p.elements[&selected.id]; if e.kind() == Kind::StateMachine { out = column![text("States & modes").size(20)].spacing(8); } out = out.push( pick_list(choices, Some(selected), Message::BehaviorOwner) .width(Fill) .text_size(13) .padding(10), ); if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return topology::view(app, e.id, out); } if e.kind() == Kind::Scenario { return scenario::view(app, e.id, out); } state_view::view(app, e.id, out) } pub fn selected_owner(app: &App) -> Option { let valid = |id: &Id| { app.project().elements.get(id).is_some_and(|e| { e.layer() == app.layer && (e.kind().is_sequence() || e.kind() == Kind::StateMachine) }) }; app.behavior .pending_owner() .or(app.behavior.active_owner.filter(valid)) .or_else(|| selected_behavior(app.project(), app.selected).filter(valid)) .or_else(|| app.project().elements.keys().find(|id| valid(id)).copied()) } /// Mirror the core's legacy-versus-explicit process interpretation. Existing /// exchange-only lists still describe ordered paths until explicitly migrated. fn uses_explicit_topology(p: &Project, owner: Id) -> bool { let Some(e) = p.elements.get(&owner) else { return false; }; if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return false; } p.relations.values().any(|r| { r.kind() == RelationKind::SequenceLink && p.elements.get(&r.source()).and_then(|e| e.parent) == Some(owner) && p.elements.get(&r.target()).and_then(|e| e.parent) == Some(owner) }) || e.steps().into_iter().flatten().any(|id| { matches!( p.elements.get(id).and_then(|e| e.shared()), Some(SharedBody::Occurrence { body: OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. }, .. }) ) }) } pub fn selected_behavior(p: &Project, selected: Id) -> Option { let mut current = Some( p.relations .get(&selected) .filter(|r| { matches!( r.kind(), RelationKind::StateTransition | RelationKind::SequenceLink ) }) .map(|r| r.source()) .unwrap_or(selected), ); while let Some(id) = current { let e = p.elements.get(&id)?; if e.kind().is_sequence() || e.kind() == Kind::StateMachine { return Some(id); } current = e.parent; } None } fn in_machine(p: &Project, id: Id, machine: Id) -> bool { let mut current = Some(id); while let Some(id) = current { if id == machine { return true; } current = p.elements.get(&id).and_then(|e| e.parent); } false } struct BoxItem { id: Id, rect: Rectangle, title: String, detail: String, marker: Option, } struct Line { id: Id, points: Vec, label: String, label_at: Point, dashed: bool, } struct Scene { boxes: Vec, lines: Vec, size: Size, selected: Id, scale: f32, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, lanes: BTreeMap, slots: Vec, rows: BTreeMap, open_calls: Vec, } struct SequenceSlot { slot: scenario::Slot, y: f32, label: String, } #[derive(Default)] struct SequenceInput { context: Option, tool: scenario::CanvasTool, pointer: Option, source: Option<(Id, scenario::Slot)>, press: Option, } #[derive(Clone, Copy)] enum SequencePress { Message { source: Id, slot: scenario::Slot, start: Point, first: bool, moved: bool, }, Reorder { occurrence: Id, parent: Id, start: Point, moved: bool, }, } impl SequenceInput { fn cancel(&mut self) { self.source = None; self.press = None; } } impl Scene { fn sequence(p: &Project, owner: Id, selected: Id) -> Self { let mut scene = Self { scale: 1., boxes: vec![], lines: vec![], size: Size::new(700., 200.), selected, interaction: None, lanes: BTreeMap::new(), slots: vec![], rows: BTreeMap::new(), open_calls: vec![], }; let mut lanes = p .children(owner) .filter(|e| e.kind() == Kind::Lifeline) .map(|e| { ( e.id, e.name.clone(), match e.shared() { Some(SharedBody::Lifeline { represented: Some(id), }) => p.named(*id).to_owned(), _ => "Unassigned participant".into(), }, ) }) .collect::>(); let scenario = p.elements[&owner].kind() == Kind::Scenario; if !scenario { fn endpoints(p: &Project, steps: &[Id], ids: &mut Vec) { for id in steps { if let Some(SharedBody::Occurrence { body, .. }) = p.elements.get(id).and_then(|e| e.shared()) { match body { OccurrenceBody::Exchange { exchange: Some(id), .. } => { if let Some(r) = p.relations.get(id) { for endpoint in [r.source(), r.target()] { if !ids.contains(&endpoint) { ids.push(endpoint); } } } } OccurrenceBody::Fragment { branches, .. } => { endpoints(p, branches, ids) } OccurrenceBody::Branch { occurrences, .. } => { endpoints(p, occurrences, ids) } _ => {} } } } } let mut ids = vec![]; endpoints(p, p.elements[&owner].steps().unwrap_or(&vec![]), &mut ids); lanes = ids .into_iter() .map(|id| { ( id, p.named(id).to_owned(), p.elements .get(&id) .map(|e| e.kind().label()) .unwrap_or("Endpoint") .into(), ) }) .collect(); } let lane_x = lanes .iter() .enumerate() .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); if scenario { scene.lanes = lane_x .iter() .filter(|(id, _)| { matches!( p.elements.get(id).and_then(|e| e.shared()), Some(SharedBody::Lifeline { represented: Some(_) }) ) }) .map(|(id, x)| (*id, *x)) .collect(); } let mut y = if scenario { 148. } else { 120. }; scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); if scenario { for (&id, &(_, top)) in &scene.rows { let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, receiver: Some(receiver), .. }, .. }) = p.elements[&id].shared() else { continue; }; let Some(x) = lane_x.get(receiver) else { continue; }; let ends: Vec<_> = scene.rows.iter().filter_map(|(reply, (_, y))| { matches!(p.elements[reply].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Reply, reply_to: Some(call), .. }, .. }) if *call == id && *y > top).then_some(*y) }).collect(); let bottom = ends .iter() .copied() .max_by(f32::total_cmp) .unwrap_or(top + 26.); scene.boxes.push(BoxItem { id, rect: Rectangle::new(Point::new(*x - 4., top), Size::new(8., bottom - top)), title: String::new(), detail: String::new(), marker: None, }); if ends.is_empty() { scene.open_calls.push(Point::new(*x + 8., bottom - 7.)); } for end in ends { scene.boxes.push(BoxItem { id, rect: Rectangle::new(Point::new(*x - 7., end - 1.), Size::new(14., 2.)), title: String::new(), detail: String::new(), marker: None, }); } } } scene.size.height = (y + 45.).max(210.); for (id, name, detail) in lanes { let x = lane_x[&id]; scene.lines.insert( 0, Line { id, points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)], label: String::new(), label_at: Point::ORIGIN, dashed: false, }, ); scene.boxes.push(BoxItem { id, rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), title: name, detail, marker: None, }); } scene } fn occurrences( &mut self, p: &Project, owner: Id, scenario: bool, lanes: &BTreeMap, y: &mut f32, depth: usize, ) { let steps = p .elements .get(&owner) .and_then(|e| { e.steps().map(Vec::as_slice).or_else(|| { if let Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. }) = e.shared() { Some(branches.as_slice()) } else { None } }) }) .unwrap_or(&[]); let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some(); for (index, id) in steps.iter().enumerate() { let Some(e) = p.elements.get(id) else { continue; }; let Some(SharedBody::Occurrence { body, .. }) = e.shared() else { continue; }; if ordered { let slot = scenario::Slot { parent: owner, before: Some(*id), }; self.slots.push(SequenceSlot { slot, y: *y - 42., label: scenario::slot_label(p, slot), }); self.rows.insert(*id, (owner, *y)); } match body { OccurrenceBody::Fragment { operator, .. } => { let top = *y; *y += 55.; self.occurrences(p, *id, scenario, lanes, y, depth + 1); let rect = Rectangle::new( Point::new(18. + depth as f32 * 10., top), Size::new( self.size.width - 36. - depth as f32 * 20., (*y - top).max(95.), ), ); self.boxes.insert( 0, BoxItem { id: *id, rect, title: format!("{operator:?} · {}", e.name), detail: String::new(), marker: Some(Kind::Occurrence), }, ); *y += if scenario { 64. } else { 20. }; } OccurrenceBody::Branch { guard, .. } => { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new( Point::new(36. + depth as f32 * 10., *y), Size::new(self.size.width - 85. - depth as f32 * 20., 43.), ), title: format!( "[{}]", if guard.is_empty() { "guard not specified" } else { guard } ), detail: String::new(), marker: Some(Kind::Occurrence), }); *y += if scenario { 90. } else { 62. }; self.occurrences(p, *id, scenario, lanes, y, depth + 1); } OccurrenceBody::Exchange { exchange, sender, receiver, message_kind, .. } => { let r = exchange.and_then(|id| p.relations.get(&id)); let source = if scenario { *sender } else { r.map(|r| r.source()) }; let target = if scenario { if matches!(message_kind, MessageKind::Timer { .. }) { *sender } else { *receiver } } else { r.map(|r| r.target()) }; if let (Some(a), Some(b)) = ( source.and_then(|id| lanes.get(&id)), target.and_then(|id| lanes.get(&id)), ) { let points = if a == b { vec![ Point::new(*a, *y), Point::new(a + 75., *y), Point::new(a + 75., *y + 24.), Point::new(*a, *y + 24.), ] } else { vec![Point::new(*a, *y), Point::new(*b, *y)] }; let kind = if !scenario { "" } else { match message_kind { MessageKind::Asynchronous => "signal · ", MessageKind::Call => "call · ", MessageKind::Reply => "reply · ", MessageKind::Create => "create · ", MessageKind::Delete => "delete · ", MessageKind::Timer { .. } => "timer · ", } }; let message_label = if let MessageKind::Timer { expression } = message_kind { expression.as_str() } else { r.map(|r| r.name.as_str()).unwrap_or(&e.name) }; self.lines.push(Line { id: *id, points, label: format!("{}. {kind}{}", index + 1, message_label), label_at: Point::new(a.min(*b) + 12., *y - 26.), dashed: matches!(message_kind, MessageKind::Reply), }); } else { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new( Point::new(65., *y - 20.), Size::new(self.size.width - 130., 65.), ), title: e.name.clone(), detail: "Choose an exchange and its lifelines in Properties".into(), marker: None, }); } *y += 92.; } OccurrenceBody::Action { action, lifeline } | OccurrenceBody::State { state: action, lifeline, } => { let x = lifeline .and_then(|id| lanes.get(&id)) .copied() .unwrap_or(180.); self.boxes.push(BoxItem { id: *id, rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)), title: e.name.clone(), detail: action .map(|id| p.named(id).to_owned()) .unwrap_or("Choose observed behavior".into()), marker: None, }); *y += 95.; } OccurrenceBody::Control { kind } => { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)), title: format!("{kind:?} · {}", e.name), detail: "Contextual process control".into(), marker: None, }); *y += 95.; } OccurrenceBody::SequenceReference { sequence } => { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)), title: e.name.clone(), detail: sequence .map(|id| format!("Use {}", p.named(id))) .unwrap_or("Choose a reusable process or chain".into()), marker: None, }); *y += 95.; } OccurrenceBody::ScenarioUse { scenario, bindings } => { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new( Point::new(65., *y - 20.), Size::new(self.size.width - 130., 70.), ), title: format!("ref · {}", e.name), detail: scenario .map(|id| { format!( "{} · {} explicit lifeline bindings", p.named(id), bindings.len() ) }) .unwrap_or("Choose a scenario".into()), marker: Some(Kind::Occurrence), }); *y += 95.; } } } if ordered { let slot = scenario::Slot { parent: owner, before: None, }; self.slots.push(SequenceSlot { slot, y: *y - 42., label: scenario::slot_label(p, slot), }); } } fn slot_at(&self, point: Point, parent: Option) -> Option<&SequenceSlot> { self.slots .iter() .min_by(|a, b| (a.y - point.y).abs().total_cmp(&(b.y - point.y).abs())) .filter(|slot| parent.is_none_or(|id| id == slot.slot.parent)) } fn lane_at(&self, point: Point) -> Option { if let Some(header) = self .boxes .iter() .find(|b| self.lanes.contains_key(&b.id) && b.rect.contains(point)) { return Some(header.id); } self.lanes .iter() .filter(|(_, x)| (**x - point.x).abs() < 12. / self.scale) .min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs())) .map(|(id, _)| *id) } fn reorder_handle(&self) -> Option { let (_, y) = self.rows.get(&self.selected)?; Some(Rectangle::new( Point::new(6., *y - 12.), Size::new(22., 24.), )) } fn hit(&self, point: Point) -> Option { self.boxes .iter() .rev() .find(|b| { !b.title.is_empty() && b.rect.contains(point) && (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.) }) .map(|b| b.id) .or_else(|| { self.lines .iter() .find(|line| { !line.label.is_empty() && (Rectangle::new( line.label_at, Size::new( (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25., ), ) .contains(point) || line.points.windows(2).any(|p| { crate::diagram::distance_to_segment(point, p[0], p[1]) < 8. / self.scale })) }) .map(|line| line.id) }) .or_else(|| { self.boxes .iter() .rev() .find(|b| b.rect.contains(point) && b.title.is_empty()) .map(|b| b.id) }) .or_else(|| self.lane_at(point)) .or_else(|| { self.lines .iter() .find(|line| { line.label.is_empty() && line.points.windows(2).any(|p| { crate::diagram::distance_to_segment(point, p[0], p[1]) < 8. / self.scale }) }) .map(|line| line.id) }) } } impl canvas::Program for Scene { type State = SequenceInput; fn update( &self, state: &mut SequenceInput, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { use scenario::{CanvasEvent, CanvasTool}; let (context, tool, editable) = self .interaction .map_or((None, CanvasTool::Select, false), |(c, t, e)| { (Some(c), t, e) }); if state.context != context || state.tool != tool { state.cancel(); state.pointer = None; state.context = context; state.tool = tool; } if !editable { state.cancel(); } // Scrollable marks a pointer outside its visible viewport unavailable, // even when it lies inside the full (scrolled) canvas rectangle. let visible = cursor.position().is_some(); if !visible && matches!( event, canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) ) { state.cancel(); return Some(Action::request_redraw()); } let cursor = match event { canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => { state.pointer = Some(*position); if let Some(press) = &mut state.press { match press { SequencePress::Message { start, moved, .. } | SequencePress::Reorder { start, moved, .. } => { *moved |= position.distance(*start) >= 3. } } } mouse::Cursor::Available(*position) } canvas::Event::Mouse(mouse::Event::CursorLeft) | canvas::Event::Window(iced::window::Event::Unfocused) => { state.cancel(); state.pointer = None; return Some(Action::request_redraw()); } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), ) => state.pointer.map_or(cursor, mouse::Cursor::Available), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { state.pointer = cursor.position(); cursor } canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), .. }) if state.source.is_some() || state.press.is_some() => { state.cancel(); return Some(Action::request_redraw().and_capture()); } _ => cursor, }; let world = cursor .position_in(bounds) .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if !visible { return None; } let point = world?; if let Some(context) = context { if !editable { return None; } if let CanvasTool::Message(_) = tool { let first = state.source.is_none(); let (source, slot) = state.source.or_else(|| { Some((self.lane_at(point)?, self.slot_at(point, None)?.slot)) })?; state.source = Some((source, slot)); state.press = Some(SequencePress::Message { source, slot, start: cursor.position()?, first, moved: false, }); return Some(Action::request_redraw().and_capture()); } if self.reorder_handle().is_some_and(|r| r.contains(point)) { state.press = Some(SequencePress::Reorder { occurrence: self.selected, parent: self.rows[&self.selected].0, start: cursor.position()?, moved: false, }); return Some(Action::request_redraw().and_capture()); } return self.hit(point).map(|id| { Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, id))) .and_capture() }); } self.hit(point) .map(|id| Action::publish(Message::Select(id)).and_capture()) } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { let press = state.press.take()?; let context = context?; match press { SequencePress::Message { source, slot, .. } if tool == CanvasTool::Message(scenario::MessageStyle::Timer) && world.is_some() => { state.cancel(); return Some( Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { context, slot, sender: source, receiver: source, style: scenario::MessageStyle::Timer, })) .and_capture(), ); } SequencePress::Message { source, slot, first, moved, .. } if moved || !first => { if let Some(receiver) = world.and_then(|p| self.lane_at(p)) { let CanvasTool::Message(style) = tool else { return None; }; state.cancel(); return Some( Action::publish(Message::ScenarioCanvas(CanvasEvent::Begin { context, slot, sender: source, receiver, style, })) .and_capture(), ); } } SequencePress::Reorder { occurrence, parent, moved: true, .. } => { if let Some(slot) = world.and_then(|p| self.slot_at(p, Some(parent))) { return Some( Action::publish(Message::ScenarioCanvas(CanvasEvent::Reorder { context, occurrence, slot: slot.slot, })) .and_capture(), ); } } _ => {} } Some(Action::request_redraw().and_capture()) } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => { Some(Action::request_redraw()) } _ => None, } } fn draw( &self, state: &SequenceInput, renderer: &Renderer, _: &Theme, bounds: Rectangle, cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); f.scale(self.scale); for b in &self.boxes { let outline = matches!( b.marker, Some(Kind::Region | Kind::StateMachine | Kind::Occurrence) ); let color = if b.id == self.selected { style::TEAL } else { style::LINE }; if outline { f.stroke( &Path::rectangle(b.rect.position(), b.rect.size()), Stroke::default().with_color(color), ); } else { let path = Path::rounded_rectangle(b.rect.position(), b.rect.size(), 8.into()); f.fill(&path, Color::from_rgb8(243, 248, 248)); f.stroke( &path, Stroke::default() .with_color(color) .with_width(if b.id == self.selected { 2.5 } else { 1. }), ); } } for l in &self.lines { let color = if l.id == self.selected { style::TEAL } else { style::MUTED }; f.stroke( &Path::new(|p| { p.move_to(l.points[0]); for point in &l.points[1..] { p.line_to(*point); } }), Stroke { line_dash: canvas::LineDash { segments: if l.dashed { &[6., 4.] } else { &[] }, offset: 0, }, ..Stroke::default() .with_color(color) .with_width(if l.id == self.selected { 2.5 } else { 1.2 }) }, ); if !l.label.is_empty() { let end = l.points[l.points.len() - 1]; let prev = l.points[l.points.len() - 2]; let delta = end - prev; let len = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1.); let d = delta / len; f.stroke( &Path::new(|p| { p.move_to(end - d * 9. + iced::Vector::new(-d.y, d.x) * 4.); p.line_to(end); p.line_to(end - d * 9. - iced::Vector::new(-d.y, d.x) * 4.); }), Stroke::default().with_color(color), ); f.fill_rectangle( l.label_at, Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 22.), Color::WHITE, ); f.fill_text(Text { content: short(&l.label, 32), position: l.label_at, color, size: 12.into(), ..Default::default() }); } } for b in &self.boxes { f.fill_text(Text { content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize), position: Point::new(b.rect.x + 12., b.rect.y + 12.), size: 13.into(), color: if b.id == self.selected { style::TEAL } else { style::INK }, ..Default::default() }); if !b.detail.is_empty() { f.fill_text(Text { content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize), position: Point::new(b.rect.x + 12., b.rect.y + 39.), size: 11.into(), color: style::MUTED, ..Default::default() }); } } for point in &self.open_calls { f.fill_text(Text { content: "…".into(), position: *point, size: 11.into(), color: style::MUTED, ..Default::default() }); } if let Some((context, tool, true)) = self.interaction { let active = state.context == Some(context) && state.tool == tool; let point = cursor .position_in(bounds) .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); let source = active.then_some(state.source).flatten(); let reorder = active.then_some(state.press).flatten().and_then(|press| { if let SequencePress::Reorder { parent, .. } = press { Some(parent) } else { None } }); if tool == scenario::CanvasTool::Select && let Some(handle) = self.reorder_handle() { f.fill_rectangle(handle.position(), handle.size(), style::TEAL); f.fill_text(Text { content: "↕".into(), position: handle.position() + iced::Vector::new(5., 2.), size: 16.into(), color: Color::WHITE, ..Default::default() }); } let slot = if let Some((_, slot)) = source { self.slots.iter().find(|s| s.slot == slot) } else if matches!(tool, scenario::CanvasTool::Message(_)) || reorder.is_some() { point.and_then(|p| self.slot_at(p, reorder)) } else { None }; if let Some(slot) = slot { f.stroke( &Path::line( Point::new(32., slot.y), Point::new(self.size.width - 24., slot.y), ), Stroke::default().with_color(style::TEAL).with_width(1.5), ); f.fill_rectangle( Point::new(34., slot.y - 20.), Size::new((self.size.width - 70.).min(650.), 19.), Color::WHITE, ); f.fill_text(Text { content: short(&format!("Insert: {}", slot.label), 88), position: Point::new(38., slot.y - 19.), size: 11.into(), color: style::TEAL, ..Default::default() }); if let Some((source, _)) = source && let Some(x) = self.lanes.get(&source) && let Some(point) = point { let to = self .lane_at(point) .and_then(|id| self.lanes.get(&id)) .copied() .unwrap_or(point.x); f.stroke( &Path::line(Point::new(*x, slot.y), Point::new(to, slot.y)), Stroke::default().with_color(style::TEAL).with_width(3.), ); f.fill(&Path::circle(Point::new(to, slot.y), 5.), style::TEAL); } } } vec![f.into_geometry()] } fn mouse_interaction( &self, _: &SequenceInput, bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { if cursor.is_over(bounds) { mouse::Interaction::Pointer } else { mouse::Interaction::default() } } } #[cfg(test)] mod direct_canvas_tests { use super::*; use scenario::{CanvasEvent, CanvasTool, MessageStyle}; fn scene() -> (scenario::direct_tests::Fixture, Scene) { let f = scenario::direct_tests::fixture(); let mut scene = Scene::sequence(&f.project, f.owner, f.call); scene.interaction = Some(( scenario::direct_tests::context(f.owner), CanvasTool::Message(MessageStyle::CallReply), true, )); (f, scene) } fn send( scene: &Scene, state: &mut SequenceInput, event: mouse::Event, final_cursor: Point, bounds: Rectangle, ) -> Option { canvas::Program::update( scene, state, &canvas::Event::Mouse(event), bounds, mouse::Cursor::Available(final_cursor), ) .and_then(|action| action.into_inner().0) .and_then(|message| { if let Message::ScenarioCanvas(event) = message { Some(event) } else { None } }) } #[test] fn direct_drag_and_click_click_use_ordered_sources_and_publish_once() { for drag in [false, true] { let (f, scene) = scene(); let mut state = SequenceInput::default(); let bounds = Rectangle::new(Point::new(30., 50.), scene.size); let a = Point::new( bounds.x + scene.lanes[&f.sender], bounds.y + scene.slots[0].y, ); let b = Point::new(bounds.x + scene.lanes[&f.receiver], a.y); send( &scene, &mut state, mouse::Event::CursorMoved { position: a }, b, bounds, ); assert!( send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), b, bounds ) .is_none() ); if !drag { assert!( send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds ) .is_none() ); assert_eq!(state.source.unwrap().0, f.sender); } send( &scene, &mut state, mouse::Event::CursorMoved { position: b }, b, bounds, ); if !drag { send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), b, bounds, ); } let result = send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds, ); assert!( matches!(result, Some(CanvasEvent::Begin { sender, receiver, slot, .. }) if sender == f.sender && receiver == f.receiver && slot.before == Some(f.call)) ); assert!( send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds ) .is_none() ); assert!(state.source.is_none() && state.press.is_none()); } } #[test] fn direct_scroll_origin_changes_do_not_create_motion_and_unavailable_release_cancels() { let (f, scene) = scene(); let mut state = SequenceInput::default(); let bounds = Rectangle::new(Point::new(20., 70.), scene.size); let at = Point::new( bounds.x + scene.lanes[&f.sender], bounds.y + scene.slots[0].y, ); send( &scene, &mut state, mouse::Event::CursorMoved { position: at }, at, bounds, ); send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at, bounds, ); let shifted = Rectangle::new( bounds.position() - iced::Vector::new(15., 32.), bounds.size(), ); assert!( send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at, shifted ) .is_none() ); assert!( state.source.is_some(), "stationary first click only arms its source" ); canvas::Program::update( &scene, &mut state, &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), shifted, mouse::Cursor::Unavailable, ); assert!(state.source.is_none()); } #[test] fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() { for cancellation in 0..5 { let (f, mut scene) = scene(); let mut state = SequenceInput::default(); let bounds = Rectangle::new(Point::ORIGIN, scene.size); let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y); let b = Point::new(scene.lanes[&f.receiver], a.y); send( &scene, &mut state, mouse::Event::CursorMoved { position: a }, a, bounds, ); send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), a, bounds, ); send( &scene, &mut state, mouse::Event::CursorMoved { position: b }, b, bounds, ); match cancellation { 0 => { scene.interaction.as_mut().unwrap().0.revision += 1; } 1 => { scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; } 2 => { scene.interaction.as_mut().unwrap().2 = false; } 3 => { canvas::Program::update( &scene, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds, mouse::Cursor::Unavailable, ); } _ => { canvas::Program::update( &scene, &mut state, &canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), modified_key: iced::keyboard::Key::Named( iced::keyboard::key::Named::Escape, ), physical_key: iced::keyboard::key::Physical::Code( iced::keyboard::key::Code::Escape, ), location: iced::keyboard::Location::Standard, modifiers: iced::keyboard::Modifiers::empty(), text: None, repeat: false, }), bounds, mouse::Cursor::Available(b), ); } } assert!( send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds ) .is_none() ); assert!(state.press.is_none() && state.source.is_none()); } } #[test] fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() { let (f, mut scene) = scene(); scene.interaction.as_mut().unwrap().1 = CanvasTool::Select; let bounds = Rectangle::new(Point::ORIGIN, scene.size); let header = scene .boxes .iter() .find(|b| b.id == f.sender) .unwrap() .rect .center(); assert_eq!(scene.hit(header), Some(f.sender)); assert_eq!( scene.hit(Point::new(scene.lanes[&f.sender], scene.size.height - 28.)), Some(f.sender) ); let line = scene.lines.iter().find(|line| line.id == f.reply).unwrap(); assert_eq!( scene.hit(line.label_at + iced::Vector::new(4., 4.)), Some(f.reply) ); let mut state = SequenceInput::default(); let start = scene.reorder_handle().unwrap().center(); let end = Point::new(start.x, scene.slots.last().unwrap().y); send( &scene, &mut state, mouse::Event::CursorMoved { position: start }, start, bounds, ); send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start, bounds, ); send( &scene, &mut state, mouse::Event::CursorMoved { position: end }, end, bounds, ); assert!( matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end, bounds), Some(CanvasEvent::Reorder { occurrence, slot, .. }) if occurrence == f.call && slot.parent == f.owner && slot.before.is_none()) ); } #[test] fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() { let (f, scene) = scene(); let bar = scene .boxes .iter() .find(|b| b.id == f.call && b.rect.width == 8.) .unwrap(); assert_eq!(bar.rect.y + bar.rect.height, scene.rows[&f.reply].1); assert!(scene.open_calls.is_empty()); let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); let scene = Scene::sequence(&p, f.owner, f.call); assert_eq!(scene.open_calls.len(), 1); assert_eq!( scene .boxes .iter() .find(|b| b.id == f.call && b.rect.width == 8.) .unwrap() .rect .height, 26. ); } } #[cfg(test)] mod tests { use super::*; use crate::document::{Document, DocumentError}; use oa_core::source_project::{self, ProjectLimits}; #[test] fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() { let p = Project::demo(); let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id; let process = owner(Kind::Process); let scenario = owner(Kind::Scenario); let machine = owner(Kind::StateMachine); let transition = p .relations .values() .find(|r| r.kind() == RelationKind::StateTransition) .unwrap() .id; let starts = [ Event::Topology(topology::Event::Start( process, topology::Tool::Control, None, )), Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)), Event::States(states::Event::Start(machine, states::Tool::Region, machine)), ]; let names = [ Event::Topology(topology::Event::Name("Keep this draft".into())), Event::Scenario(scenario::Event::Name("Keep this draft".into())), Event::States(states::Event::Name("Keep this draft".into())), ]; let cancels = [ Event::Topology(topology::Event::Cancel), Event::Scenario(scenario::Event::Cancel), Event::States(states::Event::Cancel), ]; for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() { let mut state = State::default(); assert!(!state.has_pending()); assert!(state.prepare_pending(&p).unwrap().is_none()); state.update(&p, starts[index].clone()).unwrap(); state.update(&p, names[index].clone()).unwrap(); if index == 1 { state .update( &p, Event::Scenario(scenario::Event::Expression("approved".into())), ) .unwrap(); } assert_eq!(state.pending_owner(), Some(expected_owner)); for replacement in starts .iter() .cloned() .chain([Event::States(states::Event::Edit(transition))]) { assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT); assert_eq!(state.pending_owner(), Some(expected_owner)); } state.update(&p, cancels[(index + 1) % 3].clone()).unwrap(); assert!(state.has_pending(), "Another editor's cancel is stale"); let command = state.update(&p, Event::Submit).unwrap().unwrap(); assert!(state.has_pending(), "Preparation must retain the form"); let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); assert!(expected.elements.values().any(|e| { !p.elements.contains_key(&e.id) && e.name == "Keep this draft" && e.parent == Some(expected_owner) })); let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); document.execute(command).unwrap(); assert_eq!(document.project(), &expected); state.acknowledge_commit(); assert!(!state.has_pending()); assert!(state.prepare_pending(document.project()).unwrap().is_none()); assert_eq!(document.revision(), 1); assert!(document.undo()); assert_eq!(document.project(), &p); assert!(!document.can_undo()); state.update(&p, starts[index].clone()).unwrap(); state.update(&p, cancels[index].clone()).unwrap(); assert!(!state.has_pending(), "Explicit cancel discards this form"); state.update(&p, starts[(index + 1) % 3].clone()).unwrap(); } } #[test] fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() { let mut p = Project::empty(); let owner = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let machine = p .add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner)) .unwrap(); let region = p .add_element(Kind::Region, Layer::Sa, "Region", Some(machine)) .unwrap(); let source = p .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) .unwrap(); let target = p .add_element(Kind::State, Layer::Sa, "Waiting", Some(region)) .unwrap(); let work = p .add_element(Kind::Function, Layer::Sa, "Monitor", None) .unwrap(); let choice = |id| Choice { id, label: p.named(id).into(), }; let mut state = State::default(); for event in [ states::Event::Start(machine, states::Tool::Transition, source), states::Event::Name("Transition".repeat(1024)), states::Event::Trigger(choice(work)), states::Event::Effect(choice(work)), states::Event::TriggerNotes("On a monitor result".into()), states::Event::Guard("connection unavailable".into()), states::Event::EffectNotes("Record the timeout".into()), states::Event::Timed(true), states::Event::TimeKind("At".into()), states::Event::Expression("12:30 UTC".into()), ] { state.update(&p, Event::States(event)).unwrap(); } assert!( state.prepare_pending(&p).is_err(), "A target is still missing" ); assert_eq!(state.pending_owner(), Some(machine)); state .update(&p, Event::States(states::Event::Target(choice(target)))) .unwrap(); let command = state.prepare_pending(&p).unwrap().unwrap(); let bundle = source_project::format_bundle(&p); let mut limits = ProjectLimits::default(); limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::() + 2000; let mut document = Document::from_bundle(bundle.clone(), limits).unwrap(); assert!(matches!( document.execute(command), Err(DocumentError::Source(_)) )); assert_eq!(document.project(), &p); assert_eq!(document.bundle(), &bundle); assert_eq!(document.revision(), 0); assert!(!document.can_undo()); assert!(state.has_pending()); state .update( &p, Event::States(states::Event::Name("Wait for recovery".into())), ) .unwrap(); let command = state.prepare_pending(&p).unwrap().unwrap(); let Command::Batch(commands) = &command else { panic!("Expected one atomic transition batch") }; assert_eq!(commands.len(), 2); let Command::CreateElement(event) = &commands[0] else { panic!("Expected a typed time event") }; assert!( matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC") ); let Command::CreateRelation(relation) = &commands[1] else { panic!("Expected the transition") }; let RelationBody::StateTransition { triggers, effects, trigger, guard, effect, .. } = &relation.body else { panic!() }; assert_eq!(triggers, &[work, event.id]); assert_eq!(effects, &[work]); assert_eq!(trigger, "On a monitor result"); assert_eq!(guard, "connection unavailable"); assert_eq!(effect, "Record the timeout"); let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); document.execute(command).unwrap(); assert_eq!( document.project(), &expected, "Commit the exact prepared identities" ); state.acknowledge_commit(); assert!(!state.has_pending()); assert_eq!(document.revision(), 1); assert!(document.undo()); assert_eq!(document.bundle(), &bundle); assert_eq!(document.project(), &p); assert!(!document.can_undo()); } #[test] fn legacy_process_and_chain_keep_their_ordered_exchange_scene() { let p = Project::demo(); let owners: Vec<_> = p .elements .values() .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) .collect(); assert!(owners.iter().any(|e| e.kind() == Kind::Process)); assert!(owners.iter().any(|e| e.kind() == Kind::FunctionalChain)); for owner in owners { assert!(!uses_explicit_topology(&p, owner.id)); let expected: Vec<_> = owner .steps() .unwrap() .iter() .enumerate() .map(|(index, id)| { let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(exchange), .. }, .. }) = p.elements[id].shared() else { panic!("Demo legacy sequence should contain exchange occurrences"); }; format!("{}. {}", index + 1, p.relations[exchange].name) }) .collect(); assert!(!expected.is_empty()); let scene = Scene::sequence(&p, owner.id, owner.id); let actual: Vec<_> = scene .lines .iter() .filter(|line| !line.label.is_empty()) .map(|line| line.label.clone()) .collect(); assert_eq!( actual, expected, "Legacy rendering must preserve the model's ordered exchange path" ); } } #[test] fn selecting_state_transition_keeps_its_behavior_context() { let p = Project::demo(); for r in p .relations .values() .filter(|r| r.kind() == RelationKind::StateTransition) { let machine = selected_behavior(&p, r.id).expect("Transition remains in its machine"); assert_eq!(p.elements[&machine].kind(), Kind::StateMachine); assert!(in_machine(&p, r.source(), machine)); assert!(in_machine(&p, r.target(), machine)); } } }