Fix scenario scroll cancellation, operand activations and picker cost
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user