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