From 7a666076bfcfd1f2258d2f5508328db5adf40825 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 04:53:23 +0300 Subject: [PATCH] Fix scenario scroll cancellation, operand activations and picker cost --- crates/oa-desktop/src/app.rs | 87 ++- crates/oa-desktop/src/behavior.rs | 767 +++++++++++++++++++-- crates/oa-desktop/src/behavior/scenario.rs | 322 ++++++++- 3 files changed, 1065 insertions(+), 111 deletions(-) diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 5fee08d6..1e14766a 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -43,6 +43,70 @@ mod direct_scenario_tests { } } #[test] + fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() { + let (mut app, f) = setup(); + let original = app.document.bundle().clone(); + let event = begin(&app, &f); + let context = app.scenario_context().unwrap(); + let size = app.window_size; + let _ = app.update(Message::WindowResized(size)); + assert_eq!(app.scenario_context(), Some(context)); + let _ = app.update(Message::WindowResized(iced::Size::new( + size.width + 20., + size.height, + ))); + assert_ne!(app.scenario_context(), Some(context)); + let _ = app.update(Message::ScenarioCanvas(event)); + assert!(!app.behavior.has_pending()); + let context = app.scenario_context().unwrap(); + for (area, offset) in [ + (scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)), + (scenario::ScrollArea::Page, iced::Vector::new(0., 90.)), + ] { + let before = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + context, area, offset, + ))); + assert_ne!(app.scenario_context(), Some(before)); + let current = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + context, area, offset, + ))); + assert_eq!( + app.scenario_context(), + Some(current), + "unchanged redraw notification retains context" + ); + } + let event = begin(&app, &f); + let _ = app.update(Message::ScenarioCanvas(event)); + form(&mut app, Event::Name("Keep my draft".into())); + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + context, + scenario::ScrollArea::Page, + iced::Vector::new(0., 200.), + ))); + assert!(app.behavior.has_pending()); + assert_eq!(app.document.bundle(), &original); + let current = app.scenario_context().unwrap(); + for dimension in 0..3 { + let mut stale = current; + match dimension { + 0 => stale.document = Id::new_v4(), + 1 => stale.scenario = Id::new_v4(), + _ => stale.revision += 1, + } + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled( + stale, + scenario::ScrollArea::Canvas, + iced::Vector::new(0., 900.), + ))); + assert_eq!(app.scenario_context(), Some(current)); + } + assert!(!app.document.can_undo()); + } + #[test] fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() { let (mut app, f) = setup(); let before = app.document.snapshot(); @@ -1100,6 +1164,20 @@ impl App { Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); + if let CanvasEvent::Scrolled(_, area, offset) = event { + // Inner and outer scroll notifications may come from the + // same frame. Accept both across the first epoch bump, + // while still rejecting another owner/document/revision. + if self.scenario_context().is_some_and(|current| { + current.document == context.document + && current.scenario == context.scenario + && current.revision == context.revision + }) && self.behavior.scenario.scrolled(area, offset) + { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + return Task::none(); + } if self.scenario_context() != Some(context) { return Task::none(); } @@ -2479,7 +2557,14 @@ impl App { Err(e) => e, }; } - Message::WindowResized(size) => self.window_size = size, + Message::WindowResized(size) => { + // The canvas can retain its capped size during a real window + // resize. Invalidate any scenario pointer/slot gesture anyway. + if self.window_size != size && self.scenario_context().is_some() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + self.window_size = size; + } Message::CanvasResized(size) => { let first_measurement = self.canvas_size.is_none(); let size_changed = self.canvas_size != Some(size); diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 34b13c3d..da4cf658 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -21,6 +21,297 @@ pub(crate) mod state_menu; pub(crate) mod state_view; pub(crate) mod states; pub(crate) mod topology; +// The standard on_scroll callback only reports active scrolling. This local +// wrapper reads the existing Scrollable's actual translation as well, including +// initial mount and passive size clamping. It forwards layout, input and drawing. +mod scenario_scroll { + use super::*; + use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget}; + use iced::{Length, Vector, overlay}; + + pub(super) fn observe<'a>( + context: scenario::Context, + area: scenario::ScrollArea, + content: impl Into>, + ) -> Element<'a, Message> { + Element::new(Observer { + content: content.into(), + context, + area, + }) + } + struct Observer<'a, R = Renderer> { + content: Element<'a, Message, Theme, R>, + context: scenario::Context, + area: scenario::ScrollArea, + } + type Report = Option<(Id, Id, Vector)>; + #[derive(Default)] + struct Translation(Option); + impl widget::Operation for Translation { + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + _: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + self.0 = Some(translation); + } + } + impl Observer<'_, R> { + fn report( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + shell: &mut Shell<'_, Message>, + ) { + let mut offset = Translation::default(); + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut offset, + ); + if let Some(offset) = offset.0 { + let next = Some((self.context.document, self.context.scenario, offset)); + let previous = tree.state.downcast_mut::(); + if *previous != next { + *previous = next; + shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled( + self.context, + self.area, + offset, + ))); + } + } + } + } + impl Widget for Observer<'_, R> { + fn tag(&self) -> widget::tree::Tag { + widget::tree::Tag::of::() + } + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Report::None) + } + fn children(&self) -> Vec { + vec![widget::Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &R, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + fn update( + &mut self, + tree: &mut widget::Tree, + event: &iced::Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + // A passive clamp notification precedes any pointer result produced + // with the old context; App rejects that result after the epoch bump. + self.report(tree, layout, renderer, shell); + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + self.report(tree, layout, renderer, shell); + } + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &R, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ); + } + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut widget::Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } + } + // Iced exposes its null renderer only in debug builds. + #[cfg(all(test, debug_assertions))] + mod tests { + use super::*; + fn observer(height: f32, context: scenario::Context) -> Observer<'static, ()> { + Observer { + context, + area: scenario::ScrollArea::Canvas, + content: scrollable(iced::widget::Space::new().width(200.).height(height)) + .width(200.) + .height(200.) + .into(), + } + } + fn update( + observer: &mut Observer<'_, ()>, + tree: &mut widget::Tree, + event: iced::Event, + ) -> (Vec, bool) { + let node = observer.layout( + tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(800., 700.)), + ); + let mut messages = vec![]; + let mut shell = Shell::new(&mut messages); + observer.update( + tree, + &event, + Layout::new(&node), + mouse::Cursor::Available(Point::new(50., 50.)), + &(), + &mut iced::advanced::clipboard::Null, + &mut shell, + &Rectangle::with_size(Size::new(800., 700.)), + ); + let captured = shell.is_event_captured(); + (messages, captured) + } + fn offsets(messages: &[Message]) -> Vec { + messages + .iter() + .filter_map(|message| { + if let Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(_, _, offset)) = + message + { + Some(*offset) + } else { + None + } + }) + .collect() + } + #[test] + fn direct_scroll_observer_reports_actual_initial_clamped_and_remounted_offsets_once() { + let context = scenario::direct_tests::context(Id::new_v4()); + let redraw = || { + iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )) + }; + let mut view = observer(600., context); + let mut tree = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + let (messages, captured) = update( + &mut view, + &mut tree, + iced::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. }, + }), + ); + assert!(captured, "the observer forwards normal Scrollable capture"); + assert_eq!(offsets(&messages), vec![Vector::new(0., 180.)]); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(250., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 50.)] + ); + assert!(update(&mut view, &mut tree, redraw()).0.is_empty()); + view = observer(600., context); + view.diff(&mut tree); + assert_eq!( + offsets(&update(&mut view, &mut tree, redraw()).0), + vec![Vector::new(0., 180.)] + ); + let mut remounted = widget::Tree::new(&view as &dyn Widget); + assert_eq!( + offsets(&update(&mut view, &mut remounted, redraw()).0), + vec![Vector::ZERO] + ); + assert!(update(&mut view, &mut remounted, redraw()).0.is_empty()); + } + } +} #[derive(Debug, Clone)] pub enum Event { Topology(topology::Event), @@ -254,11 +545,13 @@ struct Scene { size: Size, selected: Id, scale: f32, + scroll_offset: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, lanes: BTreeMap, slots: Vec, rows: BTreeMap, open_calls: Vec, + sequence_bounds: BTreeMap, } struct SequenceSlot { slot: scenario::Slot, @@ -270,6 +563,7 @@ struct SequenceInput { context: Option, tool: scenario::CanvasTool, pointer: Option, + bounds: Option, source: Option<(Id, scenario::Slot)>, press: Option, } @@ -299,6 +593,7 @@ impl Scene { fn sequence(p: &Project, owner: Id, selected: Id) -> Self { let mut scene = Self { scale: 1., + scroll_offset: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), @@ -308,6 +603,7 @@ impl Scene { slots: vec![], rows: BTreeMap::new(), open_calls: vec![], + sequence_bounds: BTreeMap::new(), }; let mut lanes = p .children(owner) @@ -395,50 +691,28 @@ impl Scene { 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, - }); - } + let calls: Vec<_> = scene + .rows + .keys() + .filter_map(|&id| { + if let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Call, + receiver: Some(receiver), + .. + }, + .. + }) = p.elements[&id].shared() + { + Some((id, *lane_x.get(receiver)?)) + } else { + None + } + }) + .collect(); + for (call, x) in calls { + scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None); } } scene.size.height = (y + 45.).max(210.); @@ -491,6 +765,7 @@ impl Scene { }) .unwrap_or(&[]); let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some(); + let sequence_top = *y - 42.; for (index, id) in steps.iter().enumerate() { let Some(e) = p.elements.get(id) else { continue; @@ -696,6 +971,7 @@ impl Scene { } } if ordered { + self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); let slot = scenario::Slot { parent: owner, before: None, @@ -707,6 +983,106 @@ impl Scene { }); } } + fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) { + if bottom <= top { + return; + } + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new(Point::new(x - 4., top), Size::new(8., bottom - top)), + title: String::new(), + detail: String::new(), + marker: None, + }); + } + /// Follow a single correlation through the ordered tree. Branches are drawn + /// as independent segments, never as one chronological stretch through ALT + /// operands. The result says whether a path can leave this sequence open. + fn activation_sequence( + &mut self, + p: &Project, + owner: Id, + call: Id, + x: f32, + mut start: Option, + ) -> bool { + let entered_open = start.is_some(); + let mut split = false; + for id in p.elements[&owner].steps().into_iter().flatten() { + let Some(&(_, y)) = self.rows.get(id) else { + continue; + }; + if *id == call { + start = Some(y); + } + let Some(top) = start else { + continue; + }; + match p.elements[id].shared() { + Some(SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: MessageKind::Reply, + reply_to: Some(correlation), + .. + }, + .. + }) if *correlation == call => { + self.activation_segment(call, x, top, y); + self.boxes.push(BoxItem { + id: call, + rect: Rectangle::new(Point::new(x - 7., y - 1.), Size::new(14., 2.)), + title: String::new(), + detail: String::new(), + marker: None, + }); + start = None; + } + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { operator, branches }, + .. + }) => { + split = true; + self.activation_segment(call, x, top, y); + let bottom = self + .boxes + .iter() + .find(|b| b.id == *id && !b.title.is_empty()) + .map(|b| b.rect.y + b.rect.height) + .unwrap_or(y); + let outcomes: Vec<_> = branches + .iter() + .filter_map(|branch| { + let &(top, _) = self.sequence_bounds.get(branch)?; + Some(self.activation_sequence(p, *branch, call, x, Some(top))) + }) + .collect(); + let open = match operator { + FragmentOperator::Alternative => { + outcomes.is_empty() || outcomes.iter().any(|open| *open) + } + FragmentOperator::Parallel => outcomes.iter().all(|open| *open), + // Optional/repeated bodies do not guarantee execution. + FragmentOperator::Optional | FragmentOperator::Loop => true, + }; + start = open.then_some(bottom); + } + _ => {} + } + } + if let Some(top) = start { + let bottom = if entered_open || split { + self.sequence_bounds + .get(&owner) + .map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.)) + } else { + top + 26. + }; + self.activation_segment(call, x, top, bottom); + self.open_calls.push(Point::new(x + 8., bottom - 7.)); + } + start.is_some() + } fn slot_at(&self, point: Point, parent: Option) -> Option<&SequenceSlot> { self.slots .iter() @@ -807,6 +1183,14 @@ impl canvas::Program for Scene { state.context = context; state.tool = tool; } + // Scrolling either enclosing Scrollable changes the canvas origin. A + // captured insertion slot belongs to the previous view, even if the + // pointer itself did not move and the semantic context is unchanged. + if state.bounds.is_some_and(|previous| previous != bounds) { + state.cancel(); + state.pointer = None; + } + state.bounds = Some(bounds); if !editable { state.cancel(); } @@ -833,9 +1217,11 @@ impl canvas::Program for Scene { } } } - mouse::Cursor::Available(*position) + mouse::Cursor::Available(*position + self.scroll_offset) } - canvas::Event::Mouse(mouse::Event::CursorLeft) + canvas::Event::Mouse(mouse::Event::WheelScrolled { .. }) + | canvas::Event::Window(iced::window::Event::Resized(_)) + | canvas::Event::Mouse(mouse::Event::CursorLeft) | canvas::Event::Window(iced::window::Event::Unfocused) => { state.cancel(); state.pointer = None; @@ -843,9 +1229,11 @@ impl canvas::Program for Scene { } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), - ) => state.pointer.map_or(cursor, mouse::Cursor::Available), + ) => state + .pointer + .map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)), canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => { - state.pointer = cursor.position(); + state.pointer = cursor.position().map(|p| p - self.scroll_offset); cursor } canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { @@ -879,7 +1267,7 @@ impl canvas::Program for Scene { state.press = Some(SequencePress::Message { source, slot, - start: cursor.position()?, + start: cursor.position()? - self.scroll_offset, first, moved: false, }); @@ -889,7 +1277,7 @@ impl canvas::Program for Scene { state.press = Some(SequencePress::Reorder { occurrence: self.selected, parent: self.rows[&self.selected].0, - start: cursor.position()?, + start: cursor.position()? - self.scroll_offset, moved: false, }); return Some(Action::request_redraw().and_capture()); @@ -1295,54 +1683,164 @@ mod direct_canvas_tests { } } #[test] - fn direct_scroll_origin_changes_do_not_create_motion_and_unavailable_release_cancels() { + fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() { + let (f, mut scene) = scene(); + scene.scroll_offset = iced::Vector::new(35., 140.); + let bounds = Rectangle::new(Point::new(20., 230.), scene.size); + let slot = scene.slots.last().unwrap().slot; + let y = scene.slots.last().unwrap().y; + let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset; + let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset; + let mut state = SequenceInput::default(); + // Iced Scrollable passes raw event coordinates and a translated cursor. + for event in [ + mouse::Event::CursorMoved { position: a }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: b }, + ] { + send(&scene, &mut state, event, b + scene.scroll_offset, bounds); + } + assert!( + matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot) + ); + } + #[test] + fn direct_scroll_resize_and_bounds_changes_cancel_captured_slots() { + for change in 0..5 { + let (f, scene) = scene(); + let mut state = SequenceInput::default(); + let bounds = Rectangle::new(Point::new(20., 70.), scene.size); + let a = Point::new( + bounds.x + scene.lanes[&f.sender], + bounds.y + scene.slots[0].y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: a }, + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + a, + bounds, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + a, + bounds, + ); + assert!(state.source.is_some()); + let changed = match change { + 0 => Rectangle::new( + bounds.position() - iced::Vector::new(15., 32.), + bounds.size(), + ), + 1 => Rectangle::new(bounds.position(), bounds.size() + Size::new(20., 10.)), + _ => bounds, + }; + let event = match change { + 2 => canvas::Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: -1. }, + }), + 3 => canvas::Event::Window(iced::window::Event::Resized(Size::new(1100., 700.))), + _ => canvas::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + }; + let cursor = if change == 4 { + mouse::Cursor::Unavailable + } else { + mouse::Cursor::Available(a) + }; + let event = if change == 4 { + canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + } else { + event + }; + canvas::Program::update(&scene, &mut state, &event, changed, cursor); + assert!( + state.source.is_none() && state.press.is_none(), + "change {change} must cancel the old slot" + ); + let b = Point::new( + changed.x + scene.lanes[&f.receiver], + changed.y + scene.slots.last().unwrap().y, + ); + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + changed, + ); + send( + &scene, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + b, + changed, + ); + assert!( + send( + &scene, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + b, + changed + ) + .is_none(), + "a click after change {change} must start a fresh gesture" + ); + assert_eq!(state.source.unwrap().0, f.receiver); + assert_ne!(state.source.unwrap().1.before, Some(f.call)); + } + } + #[test] + fn direct_bounds_change_during_held_drag_never_commits_old_slot() { let (f, scene) = scene(); let mut state = SequenceInput::default(); - let bounds = Rectangle::new(Point::new(20., 70.), scene.size); - let at = Point::new( - bounds.x + scene.lanes[&f.sender], - bounds.y + scene.slots[0].y, - ); + 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: at }, - at, + mouse::Event::CursorMoved { position: a }, + a, bounds, ); send( &scene, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), - at, + a, bounds, ); - let shifted = Rectangle::new( - bounds.position() - iced::Vector::new(15., 32.), - bounds.size(), + send( + &scene, + &mut state, + mouse::Event::CursorMoved { position: b }, + b, + bounds, ); + let shifted = Rectangle::new(Point::new(0., -40.), scene.size); assert!( send( &scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), - at, + b, 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()); + assert!(state.source.is_none() && state.press.is_none()); } #[test] fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() { @@ -1478,6 +1976,125 @@ mod direct_canvas_tests { ); } #[test] + fn direct_alt_execution_stops_each_operand_at_its_reply_and_marks_open_operand() { + use super::topology::occurrence; + let f = scenario::direct_tests::fixture(); + let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let fragment = occurrence( + &p, + f.owner, + "Alternatives", + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + ); + let fragment_id = fragment.id; + let mut commands = vec![Command::CreateElement(fragment)]; + let mut branches = vec![]; + let mut replies = vec![]; + let mut actions = vec![]; + for index in 0..3 { + let mut branch = occurrence( + &p, + f.owner, + &format!("Operand {index}"), + OccurrenceBody::Branch { + guard: format!("path {index}"), + occurrences: vec![], + }, + ); + branch.parent = Some(fragment_id); + let branch_id = branch.id; + branches.push(branch_id); + commands.push(Command::CreateElement(branch)); + let mut steps = vec![]; + if index < 2 { + let mut reply = occurrence( + &p, + f.owner, + &format!("Reply {index}"), + OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: Some(f.receiver), + receiver: Some(f.sender), + message_kind: MessageKind::Reply, + reply_to: Some(f.call), + values: vec![], + }, + ); + reply.parent = Some(branch_id); + replies.push(reply.id); + steps.push(reply.id); + commands.push(Command::CreateElement(reply)); + } + let mut action = occurrence( + &p, + f.owner, + &format!("Action {index}"), + OccurrenceBody::Action { + action: Some(p.relations[&f.exchange].target()), + lifeline: Some(f.receiver), + }, + ); + action.parent = Some(branch_id); + actions.push(action.id); + steps.push(action.id); + commands.push(Command::CreateElement(action)); + commands.push(Command::SetSteps(branch_id, steps)); + } + if let Command::CreateElement(element) = &mut commands[0] + && let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Fragment { + branches: stored, .. + }, + .. + }) = element.shared_mut() + { + *stored = branches.clone(); + } + p = execute_batch(&p, &commands).unwrap(); + let scene = Scene::sequence(&p, f.owner, f.call); + let bars: Vec<_> = scene + .boxes + .iter() + .filter(|b| b.id == f.call && b.rect.width == 8.) + .map(|b| b.rect) + .collect(); + for reply in &replies { + let y = scene.rows[reply].1; + assert!( + bars.iter().any(|r| r.y <= y && r.y + r.height == y), + "every reply terminates its own operand segment" + ); + } + for action in &actions[..2] { + let y = scene.rows[action].1; + assert!( + !bars.iter().any(|r| r.y <= y && r.y + r.height > y), + "a returned operand must not remain active through its following action" + ); + } + let last_action = scene.rows[&actions[2]].1; + assert!( + scene.open_calls.iter().any(|p| p.y > last_action), + "the reply-free operand remains explicitly open" + ); + let header = scene + .boxes + .iter() + .find(|b| b.id == branches[1] && !b.title.is_empty()) + .unwrap() + .rect; + assert!( + !bars + .iter() + .any(|r| r.y < header.y + header.height && r.y + r.height > header.y), + "segments do not join mutually exclusive operand headers" + ); + } + #[test] fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() { let (f, scene) = scene(); let bar = scene diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index fea1f5a8..623f5746 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -3,7 +3,7 @@ use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; -use std::collections::BTreeSet; +use std::{cell::RefCell, collections::BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { @@ -69,6 +69,7 @@ pub enum CanvasTool { } #[derive(Debug, Clone)] pub enum CanvasEvent { + Scrolled(Context, ScrollArea, iced::Vector), Choose(Context, CanvasTool), Select(Context, Id), Begin { @@ -84,10 +85,16 @@ pub enum CanvasEvent { slot: Slot, }, } +#[derive(Debug, Clone, Copy)] +pub enum ScrollArea { + Canvas, + Page, +} impl CanvasEvent { pub fn context(&self) -> Context { match self { - Self::Choose(context, _) + Self::Scrolled(context, _, _) + | Self::Choose(context, _) | Self::Select(context, _) | Self::Begin { context, .. } | Self::Reorder { context, .. } => *context, @@ -141,6 +148,17 @@ const VALUE_PAGE_SIZE: usize = 24; pub struct State { draft: Option, canvas_tool: Option<(Id, CanvasTool)>, + call_choices: RefCell)>>, + scroll_offsets: [iced::Vector; 2], +} +#[derive(PartialEq, Eq)] +struct CallChoiceContext { + document: Id, + revision: u64, + owner: Id, + parent: Id, + before: Option, + endpoints: Option<(Option, Option)>, } #[derive(Clone)] struct Draft { @@ -169,6 +187,35 @@ struct Draft { choice_page: usize, } impl State { + pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool { + let index = match area { + ScrollArea::Canvas => 0, + ScrollArea::Page => 1, + }; + let changed = self.scroll_offsets[index] != offset; + self.scroll_offsets[index] = offset; + changed + } + fn scroll_offset(&self) -> iced::Vector { + self.scroll_offsets[0] + self.scroll_offsets[1] + } + fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec { + let key = CallChoiceContext { + document: context.document, + revision: context.revision, + owner: draft.owner, + parent: draft.parent, + before: draft.before, + endpoints: draft + .fixed_endpoints + .then_some((draft.sender, draft.receiver)), + }; + let mut cached = self.call_choices.borrow_mut(); + if cached.as_ref().is_none_or(|(previous, _)| *previous != key) { + *cached = Some((key, calls(p, draft))); + } + cached.as_ref().unwrap().1.clone() + } pub(crate) fn created_selection(&self, command: &Command) -> Option { let draft = self.draft.as_ref()?; fn created(command: &Command, parent: Id) -> Option { @@ -238,6 +285,7 @@ impl State { pub(super) fn acknowledge_commit(&mut self) { self.draft = None; + *self.call_choices.get_mut() = None; } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { @@ -539,6 +587,10 @@ fn sources(p: &Project, d: &Draft) -> Vec { values } fn exchanges(p: &Project, d: &Draft) -> Vec { + // Candidate browsing concerns the declared viewpoint and represented + // endpoints. The complete transaction still validates the chosen slot, + // lifecycle, payload and paired reply on Apply. + let viewpoint = viewpoint(p, d.owner); p.relations .values() .filter(|r| { @@ -549,30 +601,25 @@ fn exchanges(p: &Project, d: &Draft) -> Vec { | RelationKind::FunctionalExchange | RelationKind::BoundaryExchange ) - && 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![], - }, - ) + && match viewpoint { + ScenarioViewpoint::OperationalActivity => r.kind() == RelationKind::Interaction, + ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange, + ScenarioViewpoint::ParticipantInteraction => matches!( + r.kind(), + RelationKind::Interaction | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::BoundaryInterface => matches!( + r.kind(), + RelationKind::FunctionalExchange | RelationKind::BoundaryExchange + ), + ScenarioViewpoint::MixedLegacy => true, } + && (!d.fixed_endpoints + || d.sender.zip(d.receiver).is_some_and(|(sender, receiver)| { + validation::lifeline_matches_endpoint(p, sender, r.source()) == Some(true) + && validation::lifeline_matches_endpoint(p, receiver, r.target()) + == Some(true) + })) }) .map(|r| Choice { id: r.id, @@ -610,9 +657,10 @@ fn calls(p: &Project, d: &Draft) -> Vec { let mut candidate = d.clone(); candidate.style = MessageStyle::Reply; candidate.call = Some(e.id); - if candidate.exchange != *exchange { - candidate.reset_values(); - } + // Candidate availability must not fluctuate while typing names or + // expressions. These draft values are validated only on Apply. + candidate.reset_values(); + candidate.name.clear(); candidate.exchange = *exchange; candidate.sender = *receiver; candidate.receiver = *sender; @@ -1121,7 +1169,7 @@ fn bounded_picker<'a>( 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.")); + out = out.push(label("No matching exchange or eligible earlier call. Change the search, endpoints or destination, or cancel.")); } else { out = out .push( @@ -1250,9 +1298,12 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen tool, !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), )); + scene.scroll_offset = app.behavior.scenario.scroll_offset(); let size = scene.size; out = out.push( - container( + container(super::scenario_scroll::observe( + context, + ScrollArea::Canvas, scrollable( canvas::Canvas::new(scene) .width(size.width) @@ -1267,7 +1318,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen } else { (app.window_size.height - 410.).clamp(180., 340.) }), - ) + )) .padding(8) .style(style::card), ); @@ -1341,7 +1392,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen ); if d.style == MessageStyle::Reply { form = form.push(bounded_picker( - calls(p, d), + app.behavior.scenario.call_choices(p, context, d), d, d.call, "Choose the earlier call…", @@ -1360,6 +1411,9 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen "Choose the exchange…", Event::Exchange, )); + form = form.push(label( + "Apply checks that the message is valid at this insertion position.", + )); } form = form.push(picker( d.line_choices(p, true), @@ -1486,10 +1540,11 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen ); } 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(); + out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card)); + } else { + out = sequence_rows(p, owner, out); } - out = sequence_rows(p, owner, out); - scrollable(out.padding(24)).into() + super::scenario_scroll::observe(context, ScrollArea::Page, scrollable(out.padding(24))) } fn sequence_rows<'a>( p: &'a Project, @@ -1758,6 +1813,203 @@ pub(crate) mod direct_tests { ); } #[test] + fn direct_reply_choices_cache_keeps_typing_fast_and_revalidates_changed_contexts() { + let f = fixture(); + let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap(); + let mut commands = vec![]; + for index in 1..100 { + commands.push(Command::CreateElement(occurrence( + &p, + f.owner, + &format!("Call {index}"), + OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: Some(f.sender), + receiver: Some(f.receiver), + message_kind: MessageKind::Call, + reply_to: None, + values: vec![], + }, + ))); + } + p = execute_batch(&p, &commands).unwrap(); + let mut state = State::default(); + let mut context = context(f.owner); + state + .begin_message( + &p, + context, + Slot { + parent: f.owner, + before: None, + }, + f.receiver, + f.sender, + MessageStyle::Reply, + ) + .unwrap(); + let start = std::time::Instant::now(); + let expected: BTreeSet<_> = state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .into_iter() + .map(|c| c.id) + .collect(); + assert_eq!(expected.len(), 100); + eprintln!( + "100 exact-slot earlier-call choices cold: {:?}", + start.elapsed() + ); + for event in [ + Event::Name("unfinished name".into()), + Event::ChoiceSearch("Call".into()), + Event::ChoicePage(2), + Event::Value( + crate::data_values::FieldPathKey { + path: vec![], + field: Id::new_v4(), + }, + "unfinished expression".into(), + ), + ] { + state.update(&p, event).unwrap(); + let start = std::time::Instant::now(); + assert_eq!( + state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .into_iter() + .map(|c| c.id) + .collect::>(), + expected + ); + eprintln!( + "100 earlier-call choices after draft edit: {:?}", + start.elapsed() + ); + } + state.draft.as_mut().unwrap().before = Some(f.call); + assert!( + state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .is_empty(), + "changed insertion invalidates cached reply order" + ); + state.update(&p, Event::AppendInstead).unwrap(); + assert_eq!( + state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .len(), + 100 + ); + let deleted = *expected.iter().find(|id| **id != f.call).unwrap(); + p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap(); + context.revision += 1; + assert_eq!( + state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .len(), + 99 + ); + let deleted = *expected + .iter() + .find(|id| **id != f.call && **id != deleted) + .unwrap(); + p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap(); + context.document = Id::new_v4(); + assert_eq!( + state + .call_choices(&p, context, state.draft.as_ref().unwrap()) + .len(), + 98, + "another document with the same IDs and revision still invalidates cached choices" + ); + } + #[test] + fn direct_exchange_compatibility_browsing_preserves_final_slot_validation() { + let f = fixture(); + let mut state = State::default(); + state + .begin_message( + &f.project, + context(f.owner), + Slot { + parent: f.owner, + before: Some(f.call), + }, + f.sender, + f.receiver, + MessageStyle::Delete, + ) + .unwrap(); + assert!( + exchanges(&f.project, state.draft.as_ref().unwrap()) + .iter() + .any(|c| c.id == f.exchange) + ); + state + .update( + &f.project, + Event::Exchange(named_choice(&f.project, f.exchange)), + ) + .unwrap(); + assert!( + state.prepare_pending(&f.project).is_err(), + "deleting the callee before the existing call fails exact-slot lifecycle validation" + ); + state.update(&f.project, Event::AppendInstead).unwrap(); + assert!(state.prepare_pending(&f.project).is_ok()); + state.draft.as_mut().unwrap().sender = Some(f.receiver); + state.draft.as_mut().unwrap().receiver = Some(f.sender); + assert!( + !exchanges(&f.project, state.draft.as_ref().unwrap()) + .iter() + .any(|c| c.id == f.exchange), + "reversed represented endpoints are not offered" + ); + } + #[test] + fn direct_five_hundred_exchange_choice_render_probe() { + let mut f = fixture(); + let template = f.project.relations[&f.exchange].clone(); + for index in 1..500 { + let id = f.project.relate( + RelationKind::Interaction, + &format!("Contract {index:03}"), + 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(); + let started = std::time::Instant::now(); + assert_eq!( + exchanges(&f.project, state.draft.as_ref().unwrap()).len(), + 500 + ); + eprintln!("500 compatible exchange choices: {:?}", started.elapsed()); + state + .update(&f.project, Event::Name("Draft name changed".into())) + .unwrap(); + let started = std::time::Instant::now(); + assert_eq!( + exchanges(&f.project, state.draft.as_ref().unwrap()).len(), + 500 + ); + eprintln!("500 choices after name keystroke: {:?}", started.elapsed()); + } + #[test] fn direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values() { let mut f = fixture(); let template = f.project.relations[&f.exchange].clone();