Fix scenario scroll cancellation, operand activations and picker cost
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -69,6 +69,7 @@ pub enum CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Choose(Context, CanvasTool),
|
||||
Select(Context, Id),
|
||||
Begin {
|
||||
@@ -84,10 +85,16 @@ pub enum CanvasEvent {
|
||||
slot: Slot,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollArea {
|
||||
Canvas,
|
||||
Page,
|
||||
}
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Choose(context, _)
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Choose(context, _)
|
||||
| Self::Select(context, _)
|
||||
| Self::Begin { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
@@ -141,6 +148,17 @@ const VALUE_PAGE_SIZE: usize = 24;
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
canvas_tool: Option<(Id, CanvasTool)>,
|
||||
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
|
||||
scroll_offsets: [iced::Vector; 2],
|
||||
}
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct CallChoiceContext {
|
||||
document: Id,
|
||||
revision: u64,
|
||||
owner: Id,
|
||||
parent: Id,
|
||||
before: Option<Id>,
|
||||
endpoints: Option<(Option<Id>, Option<Id>)>,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Draft {
|
||||
@@ -169,6 +187,35 @@ struct Draft {
|
||||
choice_page: usize,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
|
||||
let index = match area {
|
||||
ScrollArea::Canvas => 0,
|
||||
ScrollArea::Page => 1,
|
||||
};
|
||||
let changed = self.scroll_offsets[index] != offset;
|
||||
self.scroll_offsets[index] = offset;
|
||||
changed
|
||||
}
|
||||
fn scroll_offset(&self) -> iced::Vector {
|
||||
self.scroll_offsets[0] + self.scroll_offsets[1]
|
||||
}
|
||||
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
|
||||
let key = CallChoiceContext {
|
||||
document: context.document,
|
||||
revision: context.revision,
|
||||
owner: draft.owner,
|
||||
parent: draft.parent,
|
||||
before: draft.before,
|
||||
endpoints: draft
|
||||
.fixed_endpoints
|
||||
.then_some((draft.sender, draft.receiver)),
|
||||
};
|
||||
let mut cached = self.call_choices.borrow_mut();
|
||||
if cached.as_ref().is_none_or(|(previous, _)| *previous != key) {
|
||||
*cached = Some((key, calls(p, draft)));
|
||||
}
|
||||
cached.as_ref().unwrap().1.clone()
|
||||
}
|
||||
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
|
||||
let draft = self.draft.as_ref()?;
|
||||
fn created(command: &Command, parent: Id) -> Option<Id> {
|
||||
@@ -238,6 +285,7 @@ impl State {
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
*self.call_choices.get_mut() = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
@@ -539,6 +587,10 @@ fn sources(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
values
|
||||
}
|
||||
fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
// Candidate browsing concerns the declared viewpoint and represented
|
||||
// endpoints. The complete transaction still validates the chosen slot,
|
||||
// lifecycle, payload and paired reply on Apply.
|
||||
let viewpoint = viewpoint(p, d.owner);
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
@@ -549,30 +601,25 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
)
|
||||
&& if d.fixed_endpoints {
|
||||
let mut candidate = d.clone();
|
||||
if candidate.exchange != Some(r.id) {
|
||||
candidate.reset_values();
|
||||
}
|
||||
candidate.exchange = Some(r.id);
|
||||
candidate
|
||||
.command(p)
|
||||
.ok()
|
||||
.is_some_and(|command| execute_batch(p, &[command]).is_ok())
|
||||
} else {
|
||||
valid_body_choice(
|
||||
p,
|
||||
d.owner,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(r.id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: d.kind(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)
|
||||
&& match viewpoint {
|
||||
ScenarioViewpoint::OperationalActivity => r.kind() == RelationKind::Interaction,
|
||||
ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange,
|
||||
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||
),
|
||||
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||
r.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
),
|
||||
ScenarioViewpoint::MixedLegacy => true,
|
||||
}
|
||||
&& (!d.fixed_endpoints
|
||||
|| d.sender.zip(d.receiver).is_some_and(|(sender, receiver)| {
|
||||
validation::lifeline_matches_endpoint(p, sender, r.source()) == Some(true)
|
||||
&& validation::lifeline_matches_endpoint(p, receiver, r.target())
|
||||
== Some(true)
|
||||
}))
|
||||
})
|
||||
.map(|r| Choice {
|
||||
id: r.id,
|
||||
@@ -610,9 +657,10 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
let mut candidate = d.clone();
|
||||
candidate.style = MessageStyle::Reply;
|
||||
candidate.call = Some(e.id);
|
||||
if candidate.exchange != *exchange {
|
||||
candidate.reset_values();
|
||||
}
|
||||
// Candidate availability must not fluctuate while typing names or
|
||||
// expressions. These draft values are validated only on Apply.
|
||||
candidate.reset_values();
|
||||
candidate.name.clear();
|
||||
candidate.exchange = *exchange;
|
||||
candidate.sender = *receiver;
|
||||
candidate.receiver = *sender;
|
||||
@@ -1121,7 +1169,7 @@ fn bounded_picker<'a>(
|
||||
out = out.push(label(format!("Selected: {}", choice.label)));
|
||||
}
|
||||
if len == 0 {
|
||||
out = out.push(label("No matching compatible exchange or earlier call at this position. Change the search, endpoints or destination, or cancel."));
|
||||
out = out.push(label("No matching exchange or eligible earlier call. Change the search, endpoints or destination, or cancel."));
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
@@ -1250,9 +1298,12 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
tool,
|
||||
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
|
||||
));
|
||||
scene.scroll_offset = app.behavior.scenario.scroll_offset();
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
container(
|
||||
container(super::scenario_scroll::observe(
|
||||
context,
|
||||
ScrollArea::Canvas,
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
@@ -1267,7 +1318,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
} else {
|
||||
(app.window_size.height - 410.).clamp(180., 340.)
|
||||
}),
|
||||
)
|
||||
))
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
);
|
||||
@@ -1341,7 +1392,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
);
|
||||
if d.style == MessageStyle::Reply {
|
||||
form = form.push(bounded_picker(
|
||||
calls(p, d),
|
||||
app.behavior.scenario.call_choices(p, context, d),
|
||||
d,
|
||||
d.call,
|
||||
"Choose the earlier call…",
|
||||
@@ -1360,6 +1411,9 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
"Choose the exchange…",
|
||||
Event::Exchange,
|
||||
));
|
||||
form = form.push(label(
|
||||
"Apply checks that the message is valid at this insertion position.",
|
||||
));
|
||||
}
|
||||
form = form.push(picker(
|
||||
d.line_choices(p, true),
|
||||
@@ -1486,10 +1540,11 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
);
|
||||
}
|
||||
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
|
||||
return scrollable(out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card)).padding(24)).into();
|
||||
out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card));
|
||||
} else {
|
||||
out = sequence_rows(p, owner, out);
|
||||
}
|
||||
out = sequence_rows(p, owner, out);
|
||||
scrollable(out.padding(24)).into()
|
||||
super::scenario_scroll::observe(context, ScrollArea::Page, scrollable(out.padding(24)))
|
||||
}
|
||||
fn sequence_rows<'a>(
|
||||
p: &'a Project,
|
||||
@@ -1758,6 +1813,203 @@ pub(crate) mod direct_tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_reply_choices_cache_keeps_typing_fast_and_revalidates_changed_contexts() {
|
||||
let f = fixture();
|
||||
let mut p = execute_batch(&f.project, &[Command::Delete(f.reply)]).unwrap();
|
||||
let mut commands = vec![];
|
||||
for index in 1..100 {
|
||||
commands.push(Command::CreateElement(occurrence(
|
||||
&p,
|
||||
f.owner,
|
||||
&format!("Call {index}"),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: Some(f.sender),
|
||||
receiver: Some(f.receiver),
|
||||
message_kind: MessageKind::Call,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
)));
|
||||
}
|
||||
p = execute_batch(&p, &commands).unwrap();
|
||||
let mut state = State::default();
|
||||
let mut context = context(f.owner);
|
||||
state
|
||||
.begin_message(
|
||||
&p,
|
||||
context,
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
},
|
||||
f.receiver,
|
||||
f.sender,
|
||||
MessageStyle::Reply,
|
||||
)
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let expected: BTreeSet<_> = state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.into_iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
assert_eq!(expected.len(), 100);
|
||||
eprintln!(
|
||||
"100 exact-slot earlier-call choices cold: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
for event in [
|
||||
Event::Name("unfinished name".into()),
|
||||
Event::ChoiceSearch("Call".into()),
|
||||
Event::ChoicePage(2),
|
||||
Event::Value(
|
||||
crate::data_values::FieldPathKey {
|
||||
path: vec![],
|
||||
field: Id::new_v4(),
|
||||
},
|
||||
"unfinished expression".into(),
|
||||
),
|
||||
] {
|
||||
state.update(&p, event).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(
|
||||
state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.into_iter()
|
||||
.map(|c| c.id)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
expected
|
||||
);
|
||||
eprintln!(
|
||||
"100 earlier-call choices after draft edit: {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
state.draft.as_mut().unwrap().before = Some(f.call);
|
||||
assert!(
|
||||
state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.is_empty(),
|
||||
"changed insertion invalidates cached reply order"
|
||||
);
|
||||
state.update(&p, Event::AppendInstead).unwrap();
|
||||
assert_eq!(
|
||||
state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.len(),
|
||||
100
|
||||
);
|
||||
let deleted = *expected.iter().find(|id| **id != f.call).unwrap();
|
||||
p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap();
|
||||
context.revision += 1;
|
||||
assert_eq!(
|
||||
state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.len(),
|
||||
99
|
||||
);
|
||||
let deleted = *expected
|
||||
.iter()
|
||||
.find(|id| **id != f.call && **id != deleted)
|
||||
.unwrap();
|
||||
p = execute_batch(&p, &[Command::Delete(deleted)]).unwrap();
|
||||
context.document = Id::new_v4();
|
||||
assert_eq!(
|
||||
state
|
||||
.call_choices(&p, context, state.draft.as_ref().unwrap())
|
||||
.len(),
|
||||
98,
|
||||
"another document with the same IDs and revision still invalidates cached choices"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_exchange_compatibility_browsing_preserves_final_slot_validation() {
|
||||
let f = fixture();
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin_message(
|
||||
&f.project,
|
||||
context(f.owner),
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
},
|
||||
f.sender,
|
||||
f.receiver,
|
||||
MessageStyle::Delete,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
exchanges(&f.project, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == f.exchange)
|
||||
);
|
||||
state
|
||||
.update(
|
||||
&f.project,
|
||||
Event::Exchange(named_choice(&f.project, f.exchange)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
state.prepare_pending(&f.project).is_err(),
|
||||
"deleting the callee before the existing call fails exact-slot lifecycle validation"
|
||||
);
|
||||
state.update(&f.project, Event::AppendInstead).unwrap();
|
||||
assert!(state.prepare_pending(&f.project).is_ok());
|
||||
state.draft.as_mut().unwrap().sender = Some(f.receiver);
|
||||
state.draft.as_mut().unwrap().receiver = Some(f.sender);
|
||||
assert!(
|
||||
!exchanges(&f.project, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == f.exchange),
|
||||
"reversed represented endpoints are not offered"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn direct_five_hundred_exchange_choice_render_probe() {
|
||||
let mut f = fixture();
|
||||
let template = f.project.relations[&f.exchange].clone();
|
||||
for index in 1..500 {
|
||||
let id = f.project.relate(
|
||||
RelationKind::Interaction,
|
||||
&format!("Contract {index:03}"),
|
||||
template.source(),
|
||||
template.target(),
|
||||
);
|
||||
f.project.relations.get_mut(&id).unwrap().body = template.body.clone();
|
||||
}
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin_message(
|
||||
&f.project,
|
||||
context(f.owner),
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
},
|
||||
f.sender,
|
||||
f.receiver,
|
||||
MessageStyle::CallReply,
|
||||
)
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(
|
||||
exchanges(&f.project, state.draft.as_ref().unwrap()).len(),
|
||||
500
|
||||
);
|
||||
eprintln!("500 compatible exchange choices: {:?}", started.elapsed());
|
||||
state
|
||||
.update(&f.project, Event::Name("Draft name changed".into()))
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(
|
||||
exchanges(&f.project, state.draft.as_ref().unwrap()).len(),
|
||||
500
|
||||
);
|
||||
eprintln!("500 choices after name keystroke: {:?}", started.elapsed());
|
||||
}
|
||||
#[test]
|
||||
fn direct_exchange_picker_is_bounded_and_browsing_keeps_pending_values() {
|
||||
let mut f = fixture();
|
||||
let template = f.project.relations[&f.exchange].clone();
|
||||
|
||||
Reference in New Issue
Block a user