From d1d8f0bbed5aa0112189138a1cc025affe26c09b Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 04:33:13 +0300 Subject: [PATCH] Add direct scenario gestures and atomic ordered message insertion --- crates/oa-desktop/src/app.rs | 243 +++++- crates/oa-desktop/src/behavior.rs | 870 +++++++++++++++++++-- crates/oa-desktop/src/behavior/scenario.rs | 783 +++++++++++++++++-- 3 files changed, 1788 insertions(+), 108 deletions(-) diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 8d395af1..5fee08d6 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -12,6 +12,152 @@ use oa_core::{ use serde_json::{Value, json}; use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; +#[cfg(test)] +mod direct_scenario_tests { + use super::*; + use crate::behavior::{ + self, + scenario::{self, CanvasEvent, Event, MessageStyle, Slot}, + }; + fn setup() -> (App, scenario::direct_tests::Fixture) { + let f = scenario::direct_tests::fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(Page::Behavior)); + assert_eq!(app.scenario_context().unwrap().scenario, f.owner); + (app, f) + } + fn form(app: &mut App, event: Event) { + let _ = app.update(Message::Behavior(behavior::Event::Scenario(event))); + } + fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent { + CanvasEvent::Begin { + context: app.scenario_context().unwrap(), + slot: Slot { + parent: f.owner, + before: None, + }, + sender: f.sender, + receiver: f.receiver, + style: MessageStyle::Asynchronous, + } + } + #[test] + fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() { + let (mut app, f) = setup(); + let before = app.document.snapshot(); + for dimension in 0..4 { + let mut event = begin(&app, &f); + let CanvasEvent::Begin { context, .. } = &mut event else { + unreachable!() + }; + match dimension { + 0 => context.document = Id::new_v4(), + 1 => context.scenario = Id::new_v4(), + 2 => context.revision += 1, + _ => context.epoch += 1, + } + let _ = app.update(Message::ScenarioCanvas(event)); + assert!(!app.behavior.has_pending()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + } + let _ = app.update(Message::Field( + "/name".into(), + "Pending scenario name".into(), + )); + assert!(app.draft_dirty()); + let event = begin(&app, &f); + let _ = app.update(Message::ScenarioCanvas(event)); + assert!(!app.behavior.has_pending()); + assert!(app.draft_dirty()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + } + #[test] + fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() { + let (mut app, f) = setup(); + let original = app.document.bundle().clone(); + let event = begin(&app, &f); + let _ = app.update(Message::ScenarioCanvas(event)); + form( + &mut app, + Event::Exchange(Choice { + id: f.exchange, + label: "Contract".into(), + }), + ); + form(&mut app, Event::Submit); + assert!(!app.behavior.has_pending()); + let created = app.selected; + assert_ne!(created, f.owner); + assert_eq!( + app.project().elements[&f.owner].steps().unwrap(), + &[f.call, f.reply, created] + ); + let before_reorder = app.document.bundle().clone(); + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select( + context, f.sender, + ))); + assert_eq!( + app.scenario_context(), + Some(context), + "same-owner selection retains gesture context" + ); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder { + context, + occurrence: created, + slot: Slot { + parent: f.owner, + before: Some(f.call), + }, + })); + assert_eq!(app.selected, created); + assert_eq!( + app.project().elements[&f.owner].steps().unwrap(), + &[created, f.call, f.reply] + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before_reorder); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &original); + assert_eq!(app.project(), &f.project); + assert!(!app.document.can_undo()); + } + #[test] + fn direct_scenario_source_failure_and_navigation_keep_form_until_success() { + let (mut app, f) = setup(); + let bundle = app.document.bundle().clone(); + let mut limits = oa_core::source_project::ProjectLimits::default(); + limits.model.max_source_bytes = + bundle.files.values().map(String::len).sum::() + 4000; + app.document = Document::from_bundle(bundle.clone(), limits).unwrap(); + app.sync(); + let event = begin(&app, &f); + let _ = app.update(Message::ScenarioCanvas(event)); + form( + &mut app, + Event::Exchange(Choice { + id: f.exchange, + label: "Contract".into(), + }), + ); + form(&mut app, Event::Name("Long message".repeat(1024))); + let _ = app.update(Message::Page(Page::Catalog)); + assert_eq!(app.page, Page::Behavior); + form(&mut app, Event::Submit); + assert!(app.behavior.has_pending()); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo()); + form(&mut app, Event::Name("Recovered message".into())); + form(&mut app, Event::Submit); + assert!(!app.behavior.has_pending()); + assert_eq!(app.project().named(app.selected), "Recovered message"); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo()); + } +} + #[cfg(test)] mod behavior_lifecycle_tests; mod state_diagram; @@ -147,6 +293,7 @@ pub enum Message { BehaviorOwner(Choice), BehaviorDiagram(Choice), StateDiagram(crate::behavior::state_edit::Event), + ScenarioCanvas(crate::behavior::scenario::CanvasEvent), PlaceSelected, HideSelected, ToggleCollapse, @@ -579,9 +726,10 @@ impl App { } fn execute(&mut self, command: Command) -> bool { let command = crate::port_edit::repair_owner_change(self.project(), command); + let scenario_selection = self.behavior.scenario.created_selection(&command); match self.document.execute(command) { Ok(selected) => { - if let Some(id) = selected { + if let Some(id) = scenario_selection.or(selected) { self.selected = id; } self.sync(); @@ -936,8 +1084,101 @@ impl App { task } } + pub(crate) fn scenario_context(&self) -> Option { + let scenario = crate::behavior::selected_owner(self)?; + (self.page == Page::Behavior + && self.project().elements.get(&scenario)?.kind() == Kind::Scenario) + .then_some(crate::behavior::scenario::Context { + document: self.canvas_document, + scenario, + revision: self.document.revision(), + epoch: self.canvas_epoch, + }) + } fn handle(&mut self, message: Message) -> Task { match message { + Message::ScenarioCanvas(event) => { + use crate::behavior::scenario::{self, CanvasEvent}; + let context = event.context(); + if self.scenario_context() != Some(context) { + return Task::none(); + } + match event { + CanvasEvent::Select(_, id) => { + if !self.behavior.has_pending() + && crate::behavior::selected_behavior(self.project(), id) + == Some(context.scenario) + { + self.select(id); + self.selected_node = None; + } + } + CanvasEvent::Choose(_, tool) => { + if self.behavior.has_pending() || !self.apply() { + return Task::none(); + } + self.behavior.scenario.choose_tool(context.scenario, tool); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + mutation => { + if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() + { + self.status = + "Apply or cancel pending edits before changing the scenario." + .into(); + return Task::none(); + } + match mutation { + CanvasEvent::Begin { + slot, + sender, + receiver, + style, + .. + } => { + let mut state = std::mem::take(&mut self.behavior.scenario); + let result = state.begin_message( + self.project(), + context, + slot, + sender, + receiver, + style, + ); + self.behavior.scenario = state; + match result { + Ok(()) => { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.status = "Choose the message exchange and apply at the highlighted sequence position.".into(); + } + Err(error) => self.status = error, + } + } + CanvasEvent::Reorder { + occurrence, slot, .. + } => { + match scenario::reorder( + self.project(), + context.scenario, + occurrence, + slot, + ) { + Ok(command) => { + if self.execute(command) { + self.selected = occurrence; + self.selected_node = None; + self.refresh_draft(); + self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into(); + } + } + Err(error) => self.status = error, + } + } + _ => unreachable!(), + } + } + } + } Message::StateDiagram(event) => return self.edit_state_diagram(event), Message::BehaviorOwner(choice) => { if self.apply() diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 874cbc0a..34b13c3d 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -52,7 +52,7 @@ pub struct State { pub active_owner: Option, pub diagram: state_view::State, topology: topology::State, - scenario: scenario::State, + pub(crate) scenario: scenario::State, states: states::State, } const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another."; @@ -254,6 +254,46 @@ struct Scene { 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 { @@ -263,6 +303,11 @@ impl Scene { 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) @@ -333,15 +378,69 @@ impl Scene { .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); - let mut y = 120.; - scene.occurrences( - p, - p.elements[&owner].steps().map(Vec::as_slice).unwrap_or(&[]), - scenario, - &lane_x, - &mut y, - 0, - ); + 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]; @@ -368,12 +467,30 @@ impl Scene { fn occurrences( &mut self, p: &Project, - steps: &[Id], + 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; @@ -381,11 +498,23 @@ impl Scene { 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, branches } => { + OccurrenceBody::Fragment { operator, .. } => { let top = *y; *y += 55.; - self.occurrences(p, branches, scenario, lanes, y, depth + 1); + self.occurrences(p, *id, scenario, lanes, y, depth + 1); let rect = Rectangle::new( Point::new(18. + depth as f32 * 10., top), Size::new( @@ -403,9 +532,9 @@ impl Scene { marker: Some(Kind::Occurrence), }, ); - *y += 20.; + *y += if scenario { 64. } else { 20. }; } - OccurrenceBody::Branch { guard, occurrences } => { + OccurrenceBody::Branch { guard, .. } => { self.boxes.push(BoxItem { id: *id, rect: Rectangle::new( @@ -423,8 +552,8 @@ impl Scene { detail: String::new(), marker: Some(Kind::Occurrence), }); - *y += 62.; - self.occurrences(p, occurrences, scenario, lanes, y, depth + 1); + *y += if scenario { 90. } else { 62. }; + self.occurrences(p, *id, scenario, lanes, y, depth + 1); } OccurrenceBody::Exchange { exchange, @@ -487,15 +616,6 @@ impl Scene { label_at: Point::new(a.min(*b) + 12., *y - 26.), dashed: matches!(message_kind, MessageKind::Reply), }); - if scenario && matches!(message_kind, MessageKind::Call) { - self.boxes.push(BoxItem { - id: *id, - rect: Rectangle::new(Point::new(*b - 4., *y), Size::new(8., 80.)), - title: String::new(), - detail: String::new(), - marker: None, - }); - } } else { self.boxes.push(BoxItem { id: *id, @@ -575,51 +695,291 @@ impl Scene { } } } + 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 = (); + type State = SequenceInput; fn update( &self, - _: &mut (), + state: &mut SequenceInput, event: &canvas::Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { - if matches!( - event, - canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) - ) && let Some(p) = cursor.position_in(bounds) + 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)) + ) { - 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) && (b.marker != Some(Kind::Occurrence) || p.y < b.rect.y + 38.) - }) { - return Some(Action::publish(Message::Select(b.id)).and_capture()); + 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) } - if let Some(l) = self.lines.iter().find(|l| { - !l.label.is_empty() - && (Rectangle::new( - l.label_at, - Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.), - ) - .contains(p) - || l.points - .windows(2) - .any(|v| crate::diagram::distance_to_segment(p, v[0], v[1]) < 8.)) - }) { - return Some(Action::publish(Message::Select(l.id)).and_capture()); + 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, } - None } fn draw( &self, - _: &(), + state: &SequenceInput, renderer: &Renderer, _: &Theme, bounds: Rectangle, - _: mouse::Cursor, + cursor: mouse::Cursor, ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); @@ -723,11 +1083,89 @@ impl canvas::Program for Scene { }); } } + 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 { @@ -739,6 +1177,332 @@ impl canvas::Program for Scene { } } +#[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::*; diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index af4c81f3..fea1f5a8 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -50,6 +50,51 @@ impl std::fmt::Display for MessageStyle { } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Context { + pub document: Id, + pub scenario: Id, + pub revision: u64, + pub epoch: u64, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Slot { + pub parent: Id, + pub before: Option, +} +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CanvasTool { + #[default] + Select, + Message(MessageStyle), +} +#[derive(Debug, Clone)] +pub enum CanvasEvent { + Choose(Context, CanvasTool), + Select(Context, Id), + Begin { + context: Context, + slot: Slot, + sender: Id, + receiver: Id, + style: MessageStyle, + }, + Reorder { + context: Context, + occurrence: Id, + slot: Slot, + }, +} +impl CanvasEvent { + pub fn context(&self) -> Context { + match self { + Self::Choose(context, _) + | Self::Select(context, _) + | Self::Begin { context, .. } + | Self::Reorder { context, .. } => *context, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Operator(pub FragmentOperator); impl std::fmt::Display for Operator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -82,6 +127,9 @@ pub enum Event { ValueFocus(crate::data_values::FieldPathKey), ValueBrowse(Option), ValuePage(ValueSection, usize), + ChoiceSearch(String), + ChoicePage(usize), + AppendInstead, } #[derive(Debug, Clone, Copy)] pub enum ValueSection { @@ -92,11 +140,14 @@ const VALUE_PAGE_SIZE: usize = 24; #[derive(Default)] pub struct State { draft: Option, + canvas_tool: Option<(Id, CanvasTool)>, } #[derive(Clone)] struct Draft { owner: Id, parent: Id, + before: Option, + fixed_endpoints: bool, tool: Tool, name: String, target: Option, @@ -114,8 +165,63 @@ struct Draft { value_browse: Option, value_browse_page: usize, value_authored_page: usize, + choice_search: String, + choice_page: usize, } impl State { + pub(crate) fn created_selection(&self, command: &Command) -> Option { + let draft = self.draft.as_ref()?; + fn created(command: &Command, parent: Id) -> Option { + match command { + Command::CreateElement(element) if element.parent == Some(parent) => { + Some(element.id) + } + Command::Batch(commands) => { + commands.iter().find_map(|command| created(command, parent)) + } + _ => None, + } + } + created(command, draft.parent) + } + pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool { + self.canvas_tool + .filter(|(id, _)| *id == owner) + .map(|(_, tool)| tool) + .unwrap_or_default() + } + pub(crate) fn choose_tool(&mut self, owner: Id, tool: CanvasTool) { + self.canvas_tool = Some((owner, tool)); + } + pub(crate) fn begin_message( + &mut self, + p: &Project, + context: Context, + slot: Slot, + sender: Id, + receiver: Id, + style: MessageStyle, + ) -> Result<(), String> { + slot_index(p, context.scenario, slot)?; + for line in [sender, receiver] { + if p.elements + .get(&line) + .is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(context.scenario)) + || represented(p, line).is_none() + { + return Err("Choose represented lifelines in this scenario.".into()); + } + } + self.update(p, Event::Start(context.scenario, Tool::Message))?; + let draft = self.draft.as_mut().expect("Start installed the draft"); + draft.parent = slot.parent; + draft.before = slot.before; + draft.fixed_endpoints = true; + draft.sender = Some(sender); + draft.receiver = (style != MessageStyle::Timer).then_some(receiver); + draft.style = style; + Ok(()) + } pub(super) fn pending_owner(&self) -> Option { self.draft.as_ref().map(|d| d.owner) } @@ -146,6 +252,8 @@ impl State { self.draft = Some(Draft { owner, parent: owner, + before: None, + fixed_endpoints: false, tool, name: String::new(), target: None, @@ -163,6 +271,8 @@ impl State { value_browse: None, value_browse_page: 0, value_authored_page: 0, + choice_search: String::new(), + choice_page: 0, }); } Event::Cancel => self.acknowledge_commit(), @@ -181,13 +291,26 @@ impl State { if d.exchange != Some(c.id) { d.exchange = Some(c.id); d.reset_values(); - d.sender = None; - d.receiver = None; + if !d.fixed_endpoints { + d.sender = None; + d.receiver = None; + } } } Event::Sender(c) => d.sender = Some(c.id), Event::Receiver(c) => d.receiver = Some(c.id), - Event::Parent(c) => d.parent = c.id, + Event::Parent(c) => { + if d.parent != c.id { + d.parent = c.id; + d.before = None; + } + } + Event::ChoiceSearch(value) => { + d.choice_search = value; + d.choice_page = 0; + } + Event::ChoicePage(page) => d.choice_page = page, + Event::AppendInstead => d.before = None, Event::Style(style) => { let previous = d.style; d.style = style; @@ -318,6 +441,62 @@ fn parents(p: &Project, owner: Id) -> Vec { ); out } +fn slot_index(p: &Project, owner: Id, slot: Slot) -> Result { + if !parents(p, owner) + .iter() + .any(|choice| choice.id == slot.parent) + { + return Err("Choose an insertion slot in this scenario or one of its operands.".into()); + } + let steps = p + .elements + .get(&slot.parent) + .and_then(oa_core::Element::steps) + .ok_or("This sequence no longer exists.")?; + slot.before.map_or(Ok(steps.len()), |id| { + steps + .iter() + .position(|step| *step == id) + .ok_or_else(|| "The insertion slot changed. Choose its position again.".into()) + }) +} +pub(super) fn slot_label(p: &Project, slot: Slot) -> String { + match slot.before { + Some(id) => format!( + "{} · before {}", + named_choice(p, slot.parent).label, + named_choice(p, id).label + ), + None => format!("{} · append", named_choice(p, slot.parent).label), + } +} +pub(crate) fn reorder( + p: &Project, + owner: Id, + occurrence: Id, + slot: Slot, +) -> Result { + let to = slot_index(p, owner, slot)?; + if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) { + return Err( + "Reorder inside the occurrence's current sequence; dragging never changes its operand." + .into(), + ); + } + let mut steps = p.elements[&slot.parent] + .steps() + .expect("slot validated") + .clone(); + let from = steps + .iter() + .position(|id| *id == occurrence) + .ok_or("This occurrence is not in the sequence.")?; + steps.remove(from); + steps.insert(to - usize::from(from < to), occurrence); + let command = Command::SetSteps(slot.parent, steps); + execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?; + Ok(command) +} fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool { represented(p, referenced) .zip(represented(p, local)) @@ -370,18 +549,30 @@ fn exchanges(p: &Project, d: &Draft) -> Vec { | RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ) - && valid_body_choice( - p, - d.owner, - OccurrenceBody::Exchange { - exchange: Some(r.id), - sender: None, - receiver: None, - message_kind: d.kind(), - reply_to: None, - values: vec![], - }, - ) + && if d.fixed_endpoints { + let mut candidate = d.clone(); + if candidate.exchange != Some(r.id) { + candidate.reset_values(); + } + candidate.exchange = Some(r.id); + candidate + .command(p) + .ok() + .is_some_and(|command| execute_batch(p, &[command]).is_ok()) + } else { + valid_body_choice( + p, + d.owner, + OccurrenceBody::Exchange { + exchange: Some(r.id), + sender: None, + receiver: None, + message_kind: d.kind(), + reply_to: None, + values: vec![], + }, + ) + } }) .map(|r| Choice { id: r.id, @@ -413,6 +604,9 @@ fn calls(p: &Project, d: &Draft) -> Vec { else { return None; }; + if d.fixed_endpoints && (d.sender != *receiver || d.receiver != *sender) { + return None; + } let mut candidate = d.clone(); candidate.style = MessageStyle::Reply; candidate.call = Some(e.id); @@ -711,6 +905,30 @@ impl Draft { } } } + if self.tool != Tool::Lifeline { + let slot = Slot { + parent: self.parent, + before: self.before, + }; + let index = slot_index(p, self.owner, slot)?; + let added: Vec<_> = commands + .iter() + .filter_map(|command| match command { + Command::CreateElement(e) + if e.parent == Some(self.parent) && e.kind() == Kind::Occurrence => + { + Some(e.id) + } + _ => None, + }) + .collect(); + let mut steps = p.elements[&self.parent] + .steps() + .expect("slot validated") + .clone(); + steps.splice(index..index, added); + commands.push(Command::SetSteps(self.parent, steps)); + } Ok(unique_batch(p, commands)) } } @@ -871,6 +1089,70 @@ fn picker<'a>( .text_size(12) .into() } +fn bounded_picker<'a>( + mut choices: Vec, + d: &'a Draft, + selected: Option, + placeholder: &'a str, + change: impl Fn(Choice) -> Event + 'a, +) -> Element<'a, Message> { + let current = choices + .iter() + .find(|choice| Some(choice.id) == selected) + .cloned(); + let query = d.choice_search.trim().to_lowercase(); + choices.retain(|choice| choice.label.to_lowercase().contains(&query)); + choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); + let len = choices.len(); + let range = value_page(len, d.choice_page); + let page = range.start / VALUE_PAGE_SIZE; + let mut out = column![ + text_input("Search compatible exchanges / calls…", &d.choice_search) + .on_input(|value| event(Event::ChoiceSearch(value))) + .padding(8) + .size(12) + ] + .spacing(6); + if let Some(choice) = current.as_ref().filter(|choice| { + !choices[range.clone()] + .iter() + .any(|visible| visible.id == choice.id) + }) { + out = out.push(label(format!("Selected: {}", choice.label))); + } + if len == 0 { + out = out.push(label("No matching compatible exchange or earlier call at this position. Change the search, endpoints or destination, or cancel.")); + } else { + out = out + .push( + row![ + button("Previous") + .on_press_maybe((page > 0).then(|| event(Event::ChoicePage(page - 1)))), + text(format!( + "{} choices · page {}/{}", + len, + page + 1, + len.div_ceil(VALUE_PAGE_SIZE) + )) + .size(11), + button("Next").on_press_maybe( + (range.end < len).then(|| event(Event::ChoicePage(page + 1))) + ), + ] + .spacing(6), + ) + .push( + pick_list(choices[range].to_vec(), current, move |choice| { + event(change(choice)) + }) + .placeholder(placeholder) + .padding(9) + .text_size(12) + .width(Fill), + ); + } + out.into() +} pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { let p = app.project(); out = out.push(label(match viewpoint(p, owner) { @@ -890,27 +1172,105 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen "Mixed scenario · lifelines may represent work or participants" } })); - out = out - .push( + let pending = app.behavior.has_pending(); + if !pending { + out = out + .push( + row![ + action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))), + action("+ Message", event(Event::Start(owner, Tool::Message))), + action("Reuse…", event(Event::Start(owner, Tool::Reuse))) + ] + .spacing(7), + ) + .push( + row![ + action("+ Fragment", event(Event::Start(owner, Tool::Fragment))), + action("+ Action", event(Event::Start(owner, Tool::Action))), + action( + "Observe state", + event(Event::Start(owner, Tool::Observation)) + ), + action("Properties", Message::EditProperties(owner)) + ] + .spacing(7), + ); + } + let context = app.scenario_context().expect("visible scenario context"); + let tool = app.behavior.scenario.canvas_tool(owner); + let style = if let CanvasTool::Message(style) = tool { + style + } else { + MessageStyle::CallReply + }; + if !pending { + out = out.push( row![ - action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))), - action("+ Message", event(Event::Start(owner, Tool::Message))), - action("Reuse…", event(Event::Start(owner, Tool::Reuse))) + button("Select / reorder").on_press(Message::ScenarioCanvas(CanvasEvent::Choose( + context, + CanvasTool::Select + ))), + pick_list( + vec![ + MessageStyle::Asynchronous, + MessageStyle::Call, + MessageStyle::CallReply, + MessageStyle::Reply, + MessageStyle::Create, + MessageStyle::Delete, + MessageStyle::Timer + ], + Some(style), + move |style| Message::ScenarioCanvas(CanvasEvent::Choose( + context, + CanvasTool::Message(style) + )) + ) + .text_size(12) + .padding(8) + .width(Fill), + button("Draw message").on_press(Message::ScenarioCanvas(CanvasEvent::Choose( + context, + CanvasTool::Message(style) + ))), ] - .spacing(7), - ) - .push( - row![ - action("+ Fragment", event(Event::Start(owner, Tool::Fragment))), - action("+ Action", event(Event::Start(owner, Tool::Action))), - action( - "Observe state", - event(Event::Start(owner, Tool::Observation)) - ), - action("Properties", Message::EditProperties(owner)) - ] - .spacing(7), + .spacing(6), ); + out = out.push(label(if matches!(tool, CanvasTool::Message(_)) { + "Draw message · choose the highlighted sequence position on a lifeline, then click or drag to another lifeline · Escape cancels" + } else { "Select a lifeline or occurrence · drag the selected occurrence’s ↕ handle to reorder within its sequence" })); + } else { + out = out.push(label( + "Pending form · the canvas remains visible · apply or cancel below", + )); + } + let mut scene = Scene::sequence(p, owner, app.selected); + scene.interaction = Some(( + context, + tool, + !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), + )); + let size = scene.size; + out = out.push( + 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(if pending { + (app.window_size.height - 500.).clamp(160., 260.) + } else { + (app.window_size.height - 410.).clamp(180., 340.) + }), + ) + .padding(8) + .style(style::card), + ); if let Some(d) = app .behavior .scenario @@ -927,6 +1287,21 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen ] .spacing(9); if d.tool != Tool::Lifeline { + form = form.push(label(format!( + "Insert: {}", + slot_label( + p, + Slot { + parent: d.parent, + before: d.before + } + ) + ))); + if d.before.is_some() { + form = form.push( + button("Append to this sequence instead").on_press(event(Event::AppendInstead)), + ); + } form = form.push(picker( parents(p, owner), Some(d.parent), @@ -965,8 +1340,9 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen .text_size(12), ); if d.style == MessageStyle::Reply { - form = form.push(picker( + form = form.push(bounded_picker( calls(p, d), + d, d.call, "Choose the earlier call…", Event::Call, @@ -977,8 +1353,9 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen )); } } else if d.style != MessageStyle::Timer { - form = form.push(picker( + form = form.push(bounded_picker( exchanges(p, d), + d, d.exchange, "Choose the exchange…", Event::Exchange, @@ -1102,29 +1479,15 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen ] .spacing(7), ); - out = out.push(container(form).padding(14).style(style::card)); + out = out.push( + container(scrollable(form).height((app.window_size.height - 470.).clamp(190., 280.))) + .padding(14) + .style(style::card), + ); } if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) { return scrollable(out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card)).padding(24)).into(); } - let scene = Scene::sequence(p, owner, app.selected); - let size = scene.size; - out = out.push( - 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(200., 460.)), - ) - .padding(8) - .style(style::card), - ); out = sequence_rows(p, owner, out); scrollable(out.padding(24)).into() } @@ -1191,6 +1554,318 @@ fn sequence_rows<'a>( out } +#[cfg(test)] +pub(crate) mod direct_tests { + use super::*; + pub struct Fixture { + pub project: Project, + pub owner: Id, + pub exchange: Id, + pub sender: Id, + pub receiver: Id, + pub call: Id, + pub reply: Id, + } + pub fn fixture() -> Fixture { + let f = crate::data_values::tests::payload(); + let mut p = f.project; + let mut lines = vec![]; + for (name, represented) in [ + ("Caller", p.relations[&f.exchange].source()), + ("Callee", p.relations[&f.exchange].target()), + ] { + let id = p + .add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario)) + .unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + lines.push(id); + } + let mut state = State::default(); + for event in [ + Event::Start(f.scenario, Tool::Message), + Event::Exchange(named_choice(&p, f.exchange)), + Event::Sender(named_choice(&p, lines[0])), + Event::Receiver(named_choice(&p, lines[1])), + ] { + state.update(&p, event).unwrap(); + } + p = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); + let steps = p.elements[&f.scenario].steps().unwrap(); + let (call, reply) = (steps[0], steps[1]); + Fixture { + project: p, + owner: f.scenario, + exchange: f.exchange, + sender: lines[0], + receiver: lines[1], + call, + reply, + } + } + pub fn context(owner: Id) -> Context { + Context { + document: Id::new_v4(), + scenario: owner, + revision: 0, + epoch: 0, + } + } + #[test] + fn direct_middle_pair_insertion_is_one_exact_source_and_model_undo() { + let f = fixture(); + let mut state = State::default(); + let slot = Slot { + parent: f.owner, + before: Some(f.reply), + }; + state + .begin_message( + &f.project, + context(f.owner), + slot, + f.sender, + f.receiver, + MessageStyle::CallReply, + ) + .unwrap(); + assert!( + exchanges(&f.project, state.draft.as_ref().unwrap()) + .iter() + .any(|choice| choice.id == f.exchange) + ); + state + .update( + &f.project, + Event::Exchange(named_choice(&f.project, f.exchange)), + ) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().sender, Some(f.sender)); + assert_eq!(state.draft.as_ref().unwrap().receiver, Some(f.receiver)); + let mut document = + crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap(); + let source = document.bundle().clone(); + document + .execute(state.prepare_pending(&f.project).unwrap()) + .unwrap(); + let p = document.project(); + let steps = p.elements[&f.owner].steps().unwrap(); + assert_eq!(steps.len(), 4); + assert_eq!((steps[0], steps[3]), (f.call, f.reply)); + assert!( + matches!(p.elements[&steps[2]].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { + reply_to: Some(call), message_kind: MessageKind::Reply, exchange: Some(exchange), .. }, .. }) if *call == steps[1] && *exchange == f.exchange) + ); + assert_eq!(p.relations, f.project.relations); + assert_eq!(language::parse(&language::format(p)).unwrap(), *p); + assert!(document.undo()); + assert_eq!(document.project(), &f.project); + assert_eq!(document.bundle(), &source); + assert!(!document.undo()); + } + #[test] + fn direct_reply_choices_validate_the_actual_slot_and_reorder_rejects_reverse_order() { + let f = fixture(); + let p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let mut state = State::default(); + state + .begin_message( + &p, + context(f.owner), + Slot { + parent: f.owner, + before: Some(f.call), + }, + f.receiver, + f.sender, + MessageStyle::Reply, + ) + .unwrap(); + assert!(calls(&p, state.draft.as_ref().unwrap()).is_empty()); + state.update(&p, Event::AppendInstead).unwrap(); + assert!( + calls(&p, state.draft.as_ref().unwrap()) + .iter() + .any(|choice| choice.id == f.call) + ); + state + .update(&p, Event::Call(named_choice(&p, f.call))) + .unwrap(); + let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); + let reply = *result.elements[&f.owner].steps().unwrap().last().unwrap(); + assert!( + reorder( + &result, + f.owner, + reply, + Slot { + parent: f.owner, + before: Some(f.call) + } + ) + .unwrap_err() + .contains("call") + ); + assert_eq!(result.elements[&f.owner].steps().unwrap(), &[f.call, reply]); + } + #[test] + fn direct_operand_pair_keeps_parent_sequence_and_cannot_reorder_across_operands() { + let f = fixture(); + let mut state = State::default(); + state + .update(&f.project, Event::Start(f.owner, Tool::Fragment)) + .unwrap(); + state + .update(&f.project, Event::Expression("ready".into())) + .unwrap(); + let p = execute_batch(&f.project, &[state.prepare_pending(&f.project).unwrap()]).unwrap(); + let fragment = *p.elements[&f.owner].steps().unwrap().last().unwrap(); + let branches: Vec<_> = p.children(fragment).map(|e| e.id).collect(); + let root_steps = p.elements[&f.owner].steps().unwrap().clone(); + let mut state = State::default(); + state + .begin_message( + &p, + context(f.owner), + Slot { + parent: branches[0], + before: None, + }, + f.sender, + f.receiver, + MessageStyle::CallReply, + ) + .unwrap(); + state + .update(&p, Event::Exchange(named_choice(&p, f.exchange))) + .unwrap(); + let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap(); + assert_eq!(result.elements[&f.owner].steps().unwrap(), &root_steps); + let branch_steps = result.elements[&branches[0]].steps().unwrap(); + assert_eq!(branch_steps.len(), 2); + assert!( + reorder( + &result, + f.owner, + branch_steps[0], + Slot { + parent: branches[1], + before: None + } + ) + .is_err() + ); + } + #[test] + fn direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values() { + let mut f = fixture(); + let template = f.project.relations[&f.exchange].clone(); + for index in 0..70 { + let id = f.project.relate( + RelationKind::Interaction, + &format!("Other contract {index:02}"), + template.source(), + template.target(), + ); + f.project.relations.get_mut(&id).unwrap().body = template.body.clone(); + } + let mut state = State::default(); + state + .begin_message( + &f.project, + context(f.owner), + Slot { + parent: f.owner, + before: None, + }, + f.sender, + f.receiver, + MessageStyle::CallReply, + ) + .unwrap(); + state + .update( + &f.project, + Event::Exchange(named_choice(&f.project, f.exchange)), + ) + .unwrap(); + let key = crate::data_values::roots( + &f.project, + &f.project.relations[&f.exchange], + &MessageKind::Call, + )[0] + .key + .clone(); + state + .update(&f.project, Event::Value(key.clone(), "pending(".into())) + .unwrap(); + let choices = exchanges(&f.project, state.draft.as_ref().unwrap()); + assert_eq!(choices.len(), 71); + let mut visited = BTreeSet::new(); + for page in 0..choices.len().div_ceil(VALUE_PAGE_SIZE) { + state.update(&f.project, Event::ChoicePage(page)).unwrap(); + let d = state.draft.as_ref().unwrap(); + for choice in &choices[value_page(choices.len(), d.choice_page)] { + visited.insert(choice.id); + } + let view = bounded_picker(choices.clone(), d, d.exchange, "Choose", Event::Exchange); + let tree = iced::advanced::widget::Tree::new(view.as_widget()); + fn count(tree: &iced::advanced::widget::Tree) -> usize { + 1 + tree.children.iter().map(count).sum::() + } + assert!(count(&tree) < 20); + } + assert_eq!(visited.len(), 71); + state + .update(&f.project, Event::ChoiceSearch("Other contract 69".into())) + .unwrap(); + assert_eq!(state.draft.as_ref().unwrap().choice_page, 0); + assert_eq!(state.draft.as_ref().unwrap().values[&key], "pending("); + assert_eq!(state.draft.as_ref().unwrap().exchange, Some(f.exchange)); + } + #[test] + fn direct_form_keeps_the_same_canvas_in_the_widget_tree() { + let f = fixture(); + let mut app = App::with_project(f.project.clone()); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let canvas: Element<'_, Message> = + canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into(); + let tag = iced::advanced::widget::Tree::new(canvas.as_widget()).tag; + fn count( + tree: &iced::advanced::widget::Tree, + tag: iced::advanced::widget::tree::Tag, + ) -> usize { + usize::from(tree.tag == tag) + + tree + .children + .iter() + .map(|child| count(child, tag)) + .sum::() + } + for pending in [false, true] { + if pending { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Begin { + context, + slot: Slot { + parent: f.owner, + before: None, + }, + sender: f.sender, + receiver: f.receiver, + style: MessageStyle::CallReply, + })); + assert!(app.behavior.has_pending()); + } + let view = view(&app, f.owner, Column::new()); + let tree = iced::advanced::widget::Tree::new(view.as_widget()); + assert_eq!(count(&tree, tag), 1); + } + } +} + #[cfg(test)] mod tests { use super::*;