Fix scenario scroll cancellation, operand activations and picker cost

This commit is contained in:
2026-09-06 04:53:23 +03:00
parent 524dabb9c3
commit 7a666076bf
3 changed files with 1065 additions and 111 deletions
+287 -35
View File
@@ -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();