Fix scenario scroll cancellation, operand activations and picker cost
This commit is contained in:
@@ -43,6 +43,70 @@ mod direct_scenario_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
|
||||||
let (mut app, f) = setup();
|
let (mut app, f) = setup();
|
||||||
let before = app.document.snapshot();
|
let before = app.document.snapshot();
|
||||||
@@ -1100,6 +1164,20 @@ impl App {
|
|||||||
Message::ScenarioCanvas(event) => {
|
Message::ScenarioCanvas(event) => {
|
||||||
use crate::behavior::scenario::{self, CanvasEvent};
|
use crate::behavior::scenario::{self, CanvasEvent};
|
||||||
let context = event.context();
|
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) {
|
if self.scenario_context() != Some(context) {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
@@ -2479,7 +2557,14 @@ impl App {
|
|||||||
Err(e) => e,
|
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) => {
|
Message::CanvasResized(size) => {
|
||||||
let first_measurement = self.canvas_size.is_none();
|
let first_measurement = self.canvas_size.is_none();
|
||||||
let size_changed = self.canvas_size != Some(size);
|
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 state_view;
|
||||||
pub(crate) mod states;
|
pub(crate) mod states;
|
||||||
pub(crate) mod topology;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
Topology(topology::Event),
|
Topology(topology::Event),
|
||||||
@@ -254,11 +545,13 @@ struct Scene {
|
|||||||
size: Size,
|
size: Size,
|
||||||
selected: Id,
|
selected: Id,
|
||||||
scale: f32,
|
scale: f32,
|
||||||
|
scroll_offset: iced::Vector,
|
||||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||||
lanes: BTreeMap<Id, f32>,
|
lanes: BTreeMap<Id, f32>,
|
||||||
slots: Vec<SequenceSlot>,
|
slots: Vec<SequenceSlot>,
|
||||||
rows: BTreeMap<Id, (Id, f32)>,
|
rows: BTreeMap<Id, (Id, f32)>,
|
||||||
open_calls: Vec<Point>,
|
open_calls: Vec<Point>,
|
||||||
|
sequence_bounds: BTreeMap<Id, (f32, f32)>,
|
||||||
}
|
}
|
||||||
struct SequenceSlot {
|
struct SequenceSlot {
|
||||||
slot: scenario::Slot,
|
slot: scenario::Slot,
|
||||||
@@ -270,6 +563,7 @@ struct SequenceInput {
|
|||||||
context: Option<scenario::Context>,
|
context: Option<scenario::Context>,
|
||||||
tool: scenario::CanvasTool,
|
tool: scenario::CanvasTool,
|
||||||
pointer: Option<Point>,
|
pointer: Option<Point>,
|
||||||
|
bounds: Option<Rectangle>,
|
||||||
source: Option<(Id, scenario::Slot)>,
|
source: Option<(Id, scenario::Slot)>,
|
||||||
press: Option<SequencePress>,
|
press: Option<SequencePress>,
|
||||||
}
|
}
|
||||||
@@ -299,6 +593,7 @@ impl Scene {
|
|||||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||||
let mut scene = Self {
|
let mut scene = Self {
|
||||||
scale: 1.,
|
scale: 1.,
|
||||||
|
scroll_offset: iced::Vector::ZERO,
|
||||||
boxes: vec![],
|
boxes: vec![],
|
||||||
lines: vec![],
|
lines: vec![],
|
||||||
size: Size::new(700., 200.),
|
size: Size::new(700., 200.),
|
||||||
@@ -308,6 +603,7 @@ impl Scene {
|
|||||||
slots: vec![],
|
slots: vec![],
|
||||||
rows: BTreeMap::new(),
|
rows: BTreeMap::new(),
|
||||||
open_calls: vec![],
|
open_calls: vec![],
|
||||||
|
sequence_bounds: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
let mut lanes = p
|
let mut lanes = p
|
||||||
.children(owner)
|
.children(owner)
|
||||||
@@ -395,50 +691,28 @@ impl Scene {
|
|||||||
let mut y = if scenario { 148. } else { 120. };
|
let mut y = if scenario { 148. } else { 120. };
|
||||||
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
|
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
|
||||||
if scenario {
|
if scenario {
|
||||||
for (&id, &(_, top)) in &scene.rows {
|
let calls: Vec<_> = scene
|
||||||
let Some(SharedBody::Occurrence {
|
.rows
|
||||||
body:
|
.keys()
|
||||||
OccurrenceBody::Exchange {
|
.filter_map(|&id| {
|
||||||
message_kind: MessageKind::Call,
|
if let Some(SharedBody::Occurrence {
|
||||||
receiver: Some(receiver),
|
body:
|
||||||
..
|
OccurrenceBody::Exchange {
|
||||||
},
|
message_kind: MessageKind::Call,
|
||||||
..
|
receiver: Some(receiver),
|
||||||
}) = p.elements[&id].shared()
|
..
|
||||||
else {
|
},
|
||||||
continue;
|
..
|
||||||
};
|
}) = p.elements[&id].shared()
|
||||||
let Some(x) = lane_x.get(receiver) else {
|
{
|
||||||
continue;
|
Some((id, *lane_x.get(receiver)?))
|
||||||
};
|
} else {
|
||||||
let ends: Vec<_> = scene.rows.iter().filter_map(|(reply, (_, y))| {
|
None
|
||||||
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();
|
.collect();
|
||||||
let bottom = ends
|
for (call, x) in calls {
|
||||||
.iter()
|
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
|
||||||
.copied()
|
|
||||||
.max_by(f32::total_cmp)
|
|
||||||
.unwrap_or(top + 26.);
|
|
||||||
scene.boxes.push(BoxItem {
|
|
||||||
id,
|
|
||||||
rect: Rectangle::new(Point::new(*x - 4., top), Size::new(8., bottom - top)),
|
|
||||||
title: String::new(),
|
|
||||||
detail: String::new(),
|
|
||||||
marker: None,
|
|
||||||
});
|
|
||||||
if ends.is_empty() {
|
|
||||||
scene.open_calls.push(Point::new(*x + 8., bottom - 7.));
|
|
||||||
}
|
|
||||||
for end in ends {
|
|
||||||
scene.boxes.push(BoxItem {
|
|
||||||
id,
|
|
||||||
rect: Rectangle::new(Point::new(*x - 7., end - 1.), Size::new(14., 2.)),
|
|
||||||
title: String::new(),
|
|
||||||
detail: String::new(),
|
|
||||||
marker: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scene.size.height = (y + 45.).max(210.);
|
scene.size.height = (y + 45.).max(210.);
|
||||||
@@ -491,6 +765,7 @@ impl Scene {
|
|||||||
})
|
})
|
||||||
.unwrap_or(&[]);
|
.unwrap_or(&[]);
|
||||||
let ordered = p.elements.get(&owner).and_then(|e| e.steps()).is_some();
|
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() {
|
for (index, id) in steps.iter().enumerate() {
|
||||||
let Some(e) = p.elements.get(id) else {
|
let Some(e) = p.elements.get(id) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -696,6 +971,7 @@ impl Scene {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ordered {
|
if ordered {
|
||||||
|
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||||
let slot = scenario::Slot {
|
let slot = scenario::Slot {
|
||||||
parent: owner,
|
parent: owner,
|
||||||
before: None,
|
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> {
|
fn slot_at(&self, point: Point, parent: Option<Id>) -> Option<&SequenceSlot> {
|
||||||
self.slots
|
self.slots
|
||||||
.iter()
|
.iter()
|
||||||
@@ -807,6 +1183,14 @@ impl canvas::Program<Message> for Scene {
|
|||||||
state.context = context;
|
state.context = context;
|
||||||
state.tool = tool;
|
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 {
|
if !editable {
|
||||||
state.cancel();
|
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) => {
|
| canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||||
state.cancel();
|
state.cancel();
|
||||||
state.pointer = None;
|
state.pointer = None;
|
||||||
@@ -843,9 +1229,11 @@ impl canvas::Program<Message> for Scene {
|
|||||||
}
|
}
|
||||||
canvas::Event::Mouse(
|
canvas::Event::Mouse(
|
||||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
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(_)) => {
|
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||||
state.pointer = cursor.position();
|
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
|
||||||
cursor
|
cursor
|
||||||
}
|
}
|
||||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||||
@@ -879,7 +1267,7 @@ impl canvas::Program<Message> for Scene {
|
|||||||
state.press = Some(SequencePress::Message {
|
state.press = Some(SequencePress::Message {
|
||||||
source,
|
source,
|
||||||
slot,
|
slot,
|
||||||
start: cursor.position()?,
|
start: cursor.position()? - self.scroll_offset,
|
||||||
first,
|
first,
|
||||||
moved: false,
|
moved: false,
|
||||||
});
|
});
|
||||||
@@ -889,7 +1277,7 @@ impl canvas::Program<Message> for Scene {
|
|||||||
state.press = Some(SequencePress::Reorder {
|
state.press = Some(SequencePress::Reorder {
|
||||||
occurrence: self.selected,
|
occurrence: self.selected,
|
||||||
parent: self.rows[&self.selected].0,
|
parent: self.rows[&self.selected].0,
|
||||||
start: cursor.position()?,
|
start: cursor.position()? - self.scroll_offset,
|
||||||
moved: false,
|
moved: false,
|
||||||
});
|
});
|
||||||
return Some(Action::request_redraw().and_capture());
|
return Some(Action::request_redraw().and_capture());
|
||||||
@@ -1295,54 +1683,164 @@ mod direct_canvas_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[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 (f, scene) = scene();
|
||||||
let mut state = SequenceInput::default();
|
let mut state = SequenceInput::default();
|
||||||
let bounds = Rectangle::new(Point::new(20., 70.), scene.size);
|
let bounds = Rectangle::new(Point::ORIGIN, scene.size);
|
||||||
let at = Point::new(
|
let a = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
|
||||||
bounds.x + scene.lanes[&f.sender],
|
let b = Point::new(scene.lanes[&f.receiver], a.y);
|
||||||
bounds.y + scene.slots[0].y,
|
|
||||||
);
|
|
||||||
send(
|
send(
|
||||||
&scene,
|
&scene,
|
||||||
&mut state,
|
&mut state,
|
||||||
mouse::Event::CursorMoved { position: at },
|
mouse::Event::CursorMoved { position: a },
|
||||||
at,
|
a,
|
||||||
bounds,
|
bounds,
|
||||||
);
|
);
|
||||||
send(
|
send(
|
||||||
&scene,
|
&scene,
|
||||||
&mut state,
|
&mut state,
|
||||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||||
at,
|
a,
|
||||||
bounds,
|
bounds,
|
||||||
);
|
);
|
||||||
let shifted = Rectangle::new(
|
send(
|
||||||
bounds.position() - iced::Vector::new(15., 32.),
|
&scene,
|
||||||
bounds.size(),
|
&mut state,
|
||||||
|
mouse::Event::CursorMoved { position: b },
|
||||||
|
b,
|
||||||
|
bounds,
|
||||||
);
|
);
|
||||||
|
let shifted = Rectangle::new(Point::new(0., -40.), scene.size);
|
||||||
assert!(
|
assert!(
|
||||||
send(
|
send(
|
||||||
&scene,
|
&scene,
|
||||||
&mut state,
|
&mut state,
|
||||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||||
at,
|
b,
|
||||||
shifted
|
shifted
|
||||||
)
|
)
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(state.source.is_none() && state.press.is_none());
|
||||||
state.source.is_some(),
|
|
||||||
"stationary first click only arms its source"
|
|
||||||
);
|
|
||||||
canvas::Program::update(
|
|
||||||
&scene,
|
|
||||||
&mut state,
|
|
||||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
|
||||||
shifted,
|
|
||||||
mouse::Cursor::Unavailable,
|
|
||||||
);
|
|
||||||
assert!(state.source.is_none());
|
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() {
|
fn direct_context_escape_unfocus_and_pending_form_cancel_gestures() {
|
||||||
@@ -1478,6 +1976,125 @@ mod direct_canvas_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn direct_execution_span_ends_at_its_correlated_reply_and_marks_an_open_call() {
|
||||||
let (f, scene) = scene();
|
let (f, scene) = scene();
|
||||||
let bar = scene
|
let bar = scene
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use super::topology::{occurrence, unique_batch};
|
use super::topology::{occurrence, unique_batch};
|
||||||
use super::*;
|
use super::*;
|
||||||
use iced::widget::{Column, column, text_input};
|
use iced::widget::{Column, column, text_input};
|
||||||
use std::collections::BTreeSet;
|
use std::{cell::RefCell, collections::BTreeSet};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tool {
|
pub enum Tool {
|
||||||
@@ -69,6 +69,7 @@ pub enum CanvasTool {
|
|||||||
}
|
}
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum CanvasEvent {
|
pub enum CanvasEvent {
|
||||||
|
Scrolled(Context, ScrollArea, iced::Vector),
|
||||||
Choose(Context, CanvasTool),
|
Choose(Context, CanvasTool),
|
||||||
Select(Context, Id),
|
Select(Context, Id),
|
||||||
Begin {
|
Begin {
|
||||||
@@ -84,10 +85,16 @@ pub enum CanvasEvent {
|
|||||||
slot: Slot,
|
slot: Slot,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ScrollArea {
|
||||||
|
Canvas,
|
||||||
|
Page,
|
||||||
|
}
|
||||||
impl CanvasEvent {
|
impl CanvasEvent {
|
||||||
pub fn context(&self) -> Context {
|
pub fn context(&self) -> Context {
|
||||||
match self {
|
match self {
|
||||||
Self::Choose(context, _)
|
Self::Scrolled(context, _, _)
|
||||||
|
| Self::Choose(context, _)
|
||||||
| Self::Select(context, _)
|
| Self::Select(context, _)
|
||||||
| Self::Begin { context, .. }
|
| Self::Begin { context, .. }
|
||||||
| Self::Reorder { context, .. } => *context,
|
| Self::Reorder { context, .. } => *context,
|
||||||
@@ -141,6 +148,17 @@ const VALUE_PAGE_SIZE: usize = 24;
|
|||||||
pub struct State {
|
pub struct State {
|
||||||
draft: Option<Draft>,
|
draft: Option<Draft>,
|
||||||
canvas_tool: Option<(Id, CanvasTool)>,
|
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)]
|
#[derive(Clone)]
|
||||||
struct Draft {
|
struct Draft {
|
||||||
@@ -169,6 +187,35 @@ struct Draft {
|
|||||||
choice_page: usize,
|
choice_page: usize,
|
||||||
}
|
}
|
||||||
impl State {
|
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> {
|
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||||
let draft = self.draft.as_ref()?;
|
let draft = self.draft.as_ref()?;
|
||||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||||
@@ -238,6 +285,7 @@ impl State {
|
|||||||
|
|
||||||
pub(super) fn acknowledge_commit(&mut self) {
|
pub(super) fn acknowledge_commit(&mut self) {
|
||||||
self.draft = None;
|
self.draft = None;
|
||||||
|
*self.call_choices.get_mut() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
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
|
values
|
||||||
}
|
}
|
||||||
fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
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
|
p.relations
|
||||||
.values()
|
.values()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
@@ -549,30 +601,25 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
|||||||
| RelationKind::FunctionalExchange
|
| RelationKind::FunctionalExchange
|
||||||
| RelationKind::BoundaryExchange
|
| RelationKind::BoundaryExchange
|
||||||
)
|
)
|
||||||
&& if d.fixed_endpoints {
|
&& match viewpoint {
|
||||||
let mut candidate = d.clone();
|
ScenarioViewpoint::OperationalActivity => r.kind() == RelationKind::Interaction,
|
||||||
if candidate.exchange != Some(r.id) {
|
ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange,
|
||||||
candidate.reset_values();
|
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||||
}
|
r.kind(),
|
||||||
candidate.exchange = Some(r.id);
|
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||||
candidate
|
),
|
||||||
.command(p)
|
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||||
.ok()
|
r.kind(),
|
||||||
.is_some_and(|command| execute_batch(p, &[command]).is_ok())
|
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||||
} else {
|
),
|
||||||
valid_body_choice(
|
ScenarioViewpoint::MixedLegacy => true,
|
||||||
p,
|
|
||||||
d.owner,
|
|
||||||
OccurrenceBody::Exchange {
|
|
||||||
exchange: Some(r.id),
|
|
||||||
sender: None,
|
|
||||||
receiver: None,
|
|
||||||
message_kind: d.kind(),
|
|
||||||
reply_to: None,
|
|
||||||
values: vec![],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
&& (!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 {
|
.map(|r| Choice {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -610,9 +657,10 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
|||||||
let mut candidate = d.clone();
|
let mut candidate = d.clone();
|
||||||
candidate.style = MessageStyle::Reply;
|
candidate.style = MessageStyle::Reply;
|
||||||
candidate.call = Some(e.id);
|
candidate.call = Some(e.id);
|
||||||
if candidate.exchange != *exchange {
|
// Candidate availability must not fluctuate while typing names or
|
||||||
candidate.reset_values();
|
// expressions. These draft values are validated only on Apply.
|
||||||
}
|
candidate.reset_values();
|
||||||
|
candidate.name.clear();
|
||||||
candidate.exchange = *exchange;
|
candidate.exchange = *exchange;
|
||||||
candidate.sender = *receiver;
|
candidate.sender = *receiver;
|
||||||
candidate.receiver = *sender;
|
candidate.receiver = *sender;
|
||||||
@@ -1121,7 +1169,7 @@ fn bounded_picker<'a>(
|
|||||||
out = out.push(label(format!("Selected: {}", choice.label)));
|
out = out.push(label(format!("Selected: {}", choice.label)));
|
||||||
}
|
}
|
||||||
if len == 0 {
|
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 {
|
} else {
|
||||||
out = out
|
out = out
|
||||||
.push(
|
.push(
|
||||||
@@ -1250,9 +1298,12 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
|||||||
tool,
|
tool,
|
||||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||||
));
|
));
|
||||||
|
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||||
let size = scene.size;
|
let size = scene.size;
|
||||||
out = out.push(
|
out = out.push(
|
||||||
container(
|
container(super::scenario_scroll::observe(
|
||||||
|
context,
|
||||||
|
ScrollArea::Canvas,
|
||||||
scrollable(
|
scrollable(
|
||||||
canvas::Canvas::new(scene)
|
canvas::Canvas::new(scene)
|
||||||
.width(size.width)
|
.width(size.width)
|
||||||
@@ -1267,7 +1318,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
|||||||
} else {
|
} else {
|
||||||
(app.window_size.height - 410.).clamp(180., 340.)
|
(app.window_size.height - 410.).clamp(180., 340.)
|
||||||
}),
|
}),
|
||||||
)
|
))
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.style(style::card),
|
.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 {
|
if d.style == MessageStyle::Reply {
|
||||||
form = form.push(bounded_picker(
|
form = form.push(bounded_picker(
|
||||||
calls(p, d),
|
app.behavior.scenario.call_choices(p, context, d),
|
||||||
d,
|
d,
|
||||||
d.call,
|
d.call,
|
||||||
"Choose the earlier 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…",
|
"Choose the exchange…",
|
||||||
Event::Exchange,
|
Event::Exchange,
|
||||||
));
|
));
|
||||||
|
form = form.push(label(
|
||||||
|
"Apply checks that the message is valid at this insertion position.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
form = form.push(picker(
|
form = form.push(picker(
|
||||||
d.line_choices(p, true),
|
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) {
|
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);
|
super::scenario_scroll::observe(context, ScrollArea::Page, scrollable(out.padding(24)))
|
||||||
scrollable(out.padding(24)).into()
|
|
||||||
}
|
}
|
||||||
fn sequence_rows<'a>(
|
fn sequence_rows<'a>(
|
||||||
p: &'a Project,
|
p: &'a Project,
|
||||||
@@ -1758,6 +1813,203 @@ pub(crate) mod direct_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values() {
|
||||||
let mut f = fixture();
|
let mut f = fixture();
|
||||||
let template = f.project.relations[&f.exchange].clone();
|
let template = f.project.relations[&f.exchange].clone();
|
||||||
|
|||||||
Reference in New Issue
Block a user