Fix scenario scroll cancellation, operand activations and picker cost

This commit is contained in:
2026-09-06 04:53:23 +03:00
parent 524dabb9c3
commit 7a666076bf
3 changed files with 1065 additions and 111 deletions
+86 -1
View File
@@ -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);
+692 -75
View File
@@ -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<'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<Vector>);
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<R: renderer::Renderer> 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::<Report>();
if *previous != next {
*previous = next;
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
self.context,
self.area,
offset,
)));
}
}
}
}
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Observer<'_, R> {
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<Report>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(Report::None)
}
fn children(&self) -> Vec<widget::Tree> {
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<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
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<overlay::Element<'b, Message, Theme, R>> {
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<Message>, 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<Vector> {
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<Message, Theme, ()>);
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<Message, Theme, ()>);
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<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -270,6 +563,7 @@ struct SequenceInput {
context: Option<scenario::Context>,
tool: scenario::CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<(Id, scenario::Slot)>,
press: Option<SequencePress>,
}
@@ -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<f32>,
) -> 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<Id>) -> Option<&SequenceSlot> {
self.slots
.iter()
@@ -807,6 +1183,14 @@ impl canvas::Program<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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
+287 -35
View File
@@ -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<Draft>,
canvas_tool: Option<(Id, CanvasTool)>,
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
scroll_offsets: [iced::Vector; 2],
}
#[derive(PartialEq, Eq)]
struct CallChoiceContext {
document: Id,
revision: u64,
owner: Id,
parent: Id,
before: Option<Id>,
endpoints: Option<(Option<Id>, Option<Id>)>,
}
#[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<Choice> {
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<Id> {
let draft = self.draft.as_ref()?;
fn created(command: &Command, parent: Id) -> Option<Id> {
@@ -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<Option<Command>, String> {
@@ -539,6 +587,10 @@ fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
values
}
fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
// 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<Choice> {
| 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<Choice> {
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::<BTreeSet<_>>(),
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();