Add direct scenario gestures and atomic ordered message insertion
This commit is contained in:
@@ -50,6 +50,51 @@ impl std::fmt::Display for MessageStyle {
|
||||
}
|
||||
}
|
||||
#[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);
|
||||
impl std::fmt::Display for Operator {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -82,6 +127,9 @@ pub enum Event {
|
||||
ValueFocus(crate::data_values::FieldPathKey),
|
||||
ValueBrowse(Option<crate::data_values::FieldPathKey>),
|
||||
ValuePage(ValueSection, usize),
|
||||
ChoiceSearch(String),
|
||||
ChoicePage(usize),
|
||||
AppendInstead,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ValueSection {
|
||||
@@ -92,11 +140,14 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Draft {
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
fixed_endpoints: bool,
|
||||
tool: Tool,
|
||||
name: String,
|
||||
target: Option<Id>,
|
||||
@@ -114,8 +165,63 @@ struct Draft {
|
||||
value_browse: Option<crate::data_values::FieldPathKey>,
|
||||
value_browse_page: usize,
|
||||
value_authored_page: usize,
|
||||
choice_search: String,
|
||||
choice_page: usize,
|
||||
}
|
||||
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> {
|
||||
self.draft.as_ref().map(|d| d.owner)
|
||||
}
|
||||
@@ -146,6 +252,8 @@ impl State {
|
||||
self.draft = Some(Draft {
|
||||
owner,
|
||||
parent: owner,
|
||||
before: None,
|
||||
fixed_endpoints: false,
|
||||
tool,
|
||||
name: String::new(),
|
||||
target: None,
|
||||
@@ -163,6 +271,8 @@ impl State {
|
||||
value_browse: None,
|
||||
value_browse_page: 0,
|
||||
value_authored_page: 0,
|
||||
choice_search: String::new(),
|
||||
choice_page: 0,
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
@@ -181,13 +291,26 @@ impl State {
|
||||
if d.exchange != Some(c.id) {
|
||||
d.exchange = Some(c.id);
|
||||
d.reset_values();
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
if !d.fixed_endpoints {
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Sender(c) => d.sender = 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) => {
|
||||
let previous = d.style;
|
||||
d.style = style;
|
||||
@@ -318,6 +441,62 @@ fn parents(p: &Project, owner: Id) -> Vec<Choice> {
|
||||
);
|
||||
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 {
|
||||
represented(p, referenced)
|
||||
.zip(represented(p, local))
|
||||
@@ -370,18 +549,30 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
)
|
||||
&& 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![],
|
||||
},
|
||||
)
|
||||
&& if d.fixed_endpoints {
|
||||
let mut candidate = d.clone();
|
||||
if candidate.exchange != Some(r.id) {
|
||||
candidate.reset_values();
|
||||
}
|
||||
candidate.exchange = Some(r.id);
|
||||
candidate
|
||||
.command(p)
|
||||
.ok()
|
||||
.is_some_and(|command| execute_batch(p, &[command]).is_ok())
|
||||
} else {
|
||||
valid_body_choice(
|
||||
p,
|
||||
d.owner,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(r.id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: d.kind(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.map(|r| Choice {
|
||||
id: r.id,
|
||||
@@ -413,6 +604,9 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if d.fixed_endpoints && (d.sender != *receiver || d.receiver != *sender) {
|
||||
return None;
|
||||
}
|
||||
let mut candidate = d.clone();
|
||||
candidate.style = MessageStyle::Reply;
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -871,6 +1089,70 @@ fn picker<'a>(
|
||||
.text_size(12)
|
||||
.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> {
|
||||
let p = app.project();
|
||||
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"
|
||||
}
|
||||
}));
|
||||
out = out
|
||||
.push(
|
||||
let pending = app.behavior.has_pending();
|
||||
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![
|
||||
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
|
||||
action("+ Message", event(Event::Start(owner, Tool::Message))),
|
||||
action("Reuse…", event(Event::Start(owner, Tool::Reuse)))
|
||||
button("Select / reorder").on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context,
|
||||
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),
|
||||
)
|
||||
.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),
|
||||
.spacing(6),
|
||||
);
|
||||
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
|
||||
.behavior
|
||||
.scenario
|
||||
@@ -927,6 +1287,21 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
]
|
||||
.spacing(9);
|
||||
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(
|
||||
parents(p, owner),
|
||||
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),
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(picker(
|
||||
form = form.push(bounded_picker(
|
||||
calls(p, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier 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 {
|
||||
form = form.push(picker(
|
||||
form = form.push(bounded_picker(
|
||||
exchanges(p, d),
|
||||
d,
|
||||
d.exchange,
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
@@ -1102,29 +1479,15 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
]
|
||||
.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) {
|
||||
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);
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
@@ -1191,6 +1554,318 @@ fn sequence_rows<'a>(
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user