Keep scenario authoring visible and restore exact caller context

This commit is contained in:
2026-09-10 12:03:17 -07:00
parent 81668f94ed
commit 3978bbe23c
7 changed files with 1720 additions and 511 deletions
+61 -492
View File
@@ -4,6 +4,8 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod workspace;
pub use workspace::{Shelf, view};
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
@@ -94,6 +96,10 @@ impl std::fmt::Display for CanvasTool {
pub enum CanvasEvent {
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
Zoom(Context, Option<f32>),
Focus(Context),
Shelf(Context, Shelf),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
@@ -120,6 +126,7 @@ pub enum CanvasEvent {
#[derive(Debug, Clone, Copy)]
pub enum ScrollArea {
Canvas,
// Retained navigation channel, now the independent Selection/outline shelf.
Page,
}
impl CanvasEvent {
@@ -127,6 +134,10 @@ impl CanvasEvent {
match self {
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| Self::Zoom(context, _)
| Self::Focus(context)
| Self::Shelf(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
@@ -191,6 +202,7 @@ pub struct State {
scroll_offsets: [iced::Vector; 2],
return_to: Vec<Caller>,
restore: Option<ViewportRestore>,
presentation: workspace::Presentation,
}
const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller {
@@ -200,6 +212,7 @@ pub(crate) struct Caller {
target: Id,
tool: CanvasTool,
offsets: [iced::Vector; 2],
presentation: workspace::Presentation,
}
struct ViewportRestore {
document: Id,
@@ -259,6 +272,32 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
focused: self.presentation.focused,
..Default::default()
};
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
}
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn focused(&self) -> bool {
self.presentation.focused
}
pub(crate) fn toggle_focus(&mut self) {
self.presentation.focused = !self.presentation.focused;
}
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -279,6 +318,7 @@ impl State {
target,
tool: self.canvas_tool(context.scenario),
offsets: self.scroll_offsets,
presentation: self.presentation.clone(),
});
self.canvas_tool = Some((target, CanvasTool::Select));
self.scroll_offsets = [iced::Vector::ZERO; 2];
@@ -309,6 +349,8 @@ impl State {
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.scroll_offsets = frame.offsets;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.restore = Some(ViewportRestore {
document: context.document,
owner: frame.owner,
@@ -378,6 +420,9 @@ impl State {
}
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
@@ -387,7 +432,8 @@ impl State {
changed
}
fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
}
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
let key = CallChoiceContext {
@@ -1634,493 +1680,6 @@ fn bounded_picker<'a>(
}
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) {
ScenarioViewpoint::OperationalActivity => {
"Operational activity scenario · lifelines represent activities"
}
ScenarioViewpoint::Functional => {
"Functional scenario · lifelines represent system functions"
}
ScenarioViewpoint::ParticipantInteraction => {
"Participant scenario · lifelines represent entities, actors or roles"
}
ScenarioViewpoint::BoundaryInterface => {
"Interface scenario · lifelines represent the system and external actors"
}
ScenarioViewpoint::MixedLegacy => {
"Mixed scenario · lifelines may represent work or participants"
}
}));
let pending = app.behavior.has_pending();
let context = app.scenario_context().expect("visible scenario context");
let tool = app.behavior.scenario.canvas_tool(owner);
// Keep the canvas at a stable widget-tree position when a form replaces
// the palette, so Iced retains the user's current sequence viewport.
let mut controls = column![].spacing(6);
if !pending {
controls = controls.push(
row![
button("Select / reorder")
.on_press(Message::ScenarioCanvas(CanvasEvent::Choose(
context,
CanvasTool::Select
)))
.style(if tool == CanvasTool::Select {
style::selected
} else {
style::nav
}),
pick_list(
vec![
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Message(MessageStyle::Asynchronous),
CanvasTool::Message(MessageStyle::Call),
CanvasTool::Message(MessageStyle::Reply),
CanvasTool::Message(MessageStyle::Create),
CanvasTool::Message(MessageStyle::Delete),
CanvasTool::Message(MessageStyle::Timer),
CanvasTool::Add(Tool::Action),
CanvasTool::Add(Tool::Observation),
CanvasTool::Add(Tool::Reuse),
CanvasTool::Add(Tool::Fragment),
],
Some(tool),
move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool))
)
.text_size(12)
.padding(8)
.width(240),
action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))),
action("Properties", Message::EditProperties(owner)),
]
.spacing(6)
.wrap(),
);
controls = controls.push(label(match tool {
CanvasTool::Message(_) => "Draw message · choose a sequence position on a lifeline, then click or drag to another lifeline · Escape cancels",
CanvasTool::Add(Tool::Action | Tool::Observation) => "Click a highlighted sequence position · click on a lifeline to preselect it · Escape cancels",
CanvasTool::Add(Tool::Reuse) => "Click a highlighted sequence position to reuse a scenario and bind its lifelines · Escape cancels",
CanvasTool::Add(Tool::Fragment) => "Click a highlighted sequence position to insert an empty fragment with operands · Escape cancels",
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder within its sequence",
}));
} else {
controls = controls.push(label(
"Pending form · the canvas remains visible · apply or cancel below",
));
}
let clean = !pending && !app.draft_dirty() && !app.source_dirty();
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(button("Open definition").on_press_maybe(clean.then_some(
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, app.selected)),
)));
}
if app.behavior.scenario.can_back(p, context) {
navigation =
navigation.push(button("Back to caller").on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
controls = controls.push(navigation.wrap());
out = out.push(controls);
let mut scene = Scene::sequence(p, owner, app.selected);
scene.interaction = Some((
context,
tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
scene.scroll_offset = app.behavior.scenario.scroll_offset();
scene.pending_placement = app.behavior.scenario.pending_placement(owner);
let size = scene.size;
out = out.push(
container(super::scenario_scroll::observe(
context,
ScrollArea::Canvas,
app.behavior
.scenario
.scroll_restore(context, ScrollArea::Canvas),
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 - 660.).clamp(110., 190.)
} else {
(app.window_size.height - 410.).clamp(180., 340.)
}),
))
.padding(8)
.style(style::card),
);
if let Some(d) = app
.behavior
.scenario
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
]
.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),
"Destination sequence / operand…",
Event::Parent,
));
}
if matches!(
d.tool,
Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation
) {
form = form.push(picker(
sources(p, d),
d.target,
"Choose a named source…",
Event::Target,
));
}
if d.tool == Tool::Message {
form = form.push(
pick_list(
vec![
MessageStyle::Asynchronous,
MessageStyle::Call,
MessageStyle::CallReply,
MessageStyle::Reply,
MessageStyle::Create,
MessageStyle::Delete,
MessageStyle::Timer,
],
Some(d.style),
|style| event(Event::Style(style)),
)
.width(Fill)
.padding(9)
.text_size(12),
);
if d.style == MessageStyle::Reply {
form = form.push(bounded_picker(
app.behavior.scenario.call_choices(p, context, d),
d,
d.call,
"Choose the earlier call…",
Event::Call,
));
if d.call.is_some() {
form = form.push(label(
"Reply reuses the call exchange in reverse and retains its correlation.",
));
}
} else if d.style != MessageStyle::Timer {
form = form.push(bounded_picker(
exchanges(p, d),
d,
d.exchange,
"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),
d.sender,
"Sending lifeline…",
Event::Sender,
));
if d.style != MessageStyle::Timer {
form = form.push(picker(
d.line_choices(p, false),
d.receiver,
"Receiving lifeline…",
Event::Receiver,
));
}
if d.style == MessageStyle::Timer {
form = form.push(
text_input("Duration / deadline, e.g. 30 s", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if p.layer_of(owner) == Some(Layer::Sa)
&& viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface
&& matches!(
d.style,
MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous
)
{
form = form.push(
iced::widget::checkbox(d.operation.is_some())
.label("Invoke a specific interface operation")
.on_toggle(|enabled| event(Event::OperationIntent(enabled))),
);
if let Some(operation) = &d.operation {
form = form
.push(bounded_picker(
crate::data_values::operation_choices(p, &d.proposed_message(p)),
d,
operation.allocation,
"Choose the exact interface operation allocation…",
Event::Operation,
))
.push(
button("Keep operation unresolved")
.on_press(event(Event::ClearOperation)),
);
if let Some(id) = operation.allocation {
form = form.push(label(crate::data_values::operation_identity(p, id)));
}
}
}
if let Some(description) =
crate::data_values::operation_label(p, &d.proposed_message(p))
{
form = form.push(label(description));
}
match d.signature(p) {
Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)),
Err(problem) => {
form = form.push(label(problem));
for (key, expression) in d.authored_values() {
form = form
.push(
text(format!(
"Retained value · {} / {}",
key.path
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" → "),
p.named(key.field)
))
.size(11),
)
.push(
text_input("Value expression", expression)
.on_input({
let key = key.clone();
move |raw| event(Event::Value(key.clone(), raw))
})
.padding(8)
.size(12),
);
}
}
Ok(None) => {}
}
if d.style == MessageStyle::CallReply {
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
}
}
if matches!(d.tool, Tool::Action | Tool::Observation) {
form = form.push(picker(
lines(p, owner),
d.sender,
"Observed lifeline…",
Event::Sender,
));
}
if d.tool == Tool::Reuse
&& let Some(target) = d.target
{
for line in lines(p, target) {
let referenced = line.id;
let compatible: Vec<_> = lines(p, owner)
.into_iter()
.filter(|c| {
compatible_binding(p, referenced, c.id)
&& !d
.bindings
.iter()
.any(|(r, l)| *r != referenced && *l == c.id)
})
.collect();
if compatible.is_empty() {
form = form.push(label(format!(
"Add a compatible local lifeline before binding '{}'.",
p.named(referenced)
)));
}
form = form
.push(label(format!("Bind {}", p.named(referenced))))
.push(picker(
compatible,
d.bindings.get(&referenced).copied(),
"Compatible local lifeline…",
move |c| Event::Binding(referenced, c),
));
}
form = form.push(label(
"Bindings are explicit, one-to-one and refer to existing lifelines.",
));
}
if d.tool == Tool::Fragment {
form = form.push(
pick_list(
vec![
Operator(FragmentOperator::Alternative),
Operator(FragmentOperator::Parallel),
Operator(FragmentOperator::Loop),
Operator(FragmentOperator::Optional),
],
Some(Operator(d.operator)),
|o| event(Event::Operator(o)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
if d.operator != FragmentOperator::Parallel {
form = form.push(
text_input("Condition / loop guard", &d.expression)
.on_input(|s| event(Event::Expression(s)))
.padding(9)
.size(12),
);
}
if d.operator == FragmentOperator::Alternative {
form = form.push(
text_input("Second alternative guard", &d.second_guard)
.on_input(|s| event(Event::SecondGuard(s)))
.padding(9)
.size(12),
);
}
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
}
let readiness = d.command(p);
if let Err(error) = &readiness {
form = form.push(label(error.clone()));
}
let footer = row![
button("Create")
.padding(9)
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)),
footer
]
.spacing(9),
)
.padding(14)
.style(style::card),
);
}
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
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 if !pending {
out = sequence_rows(p, context, owner, out);
}
super::scenario_scroll::observe(
context,
ScrollArea::Page,
app.behavior
.scenario
.scroll_restore(context, ScrollArea::Page),
scrollable(out.padding(24)),
)
}
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
owner: Id,
mut out: Column<'a, Message>,
) -> Column<'a, Message> {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get(&owner).and_then(|e| e.shared())
{
for branch in branches {
out = out.push(action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
));
out = sequence_rows(p, context, *branch, out);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
for (i, id) in steps.iter().enumerate() {
out =
out.push(
row![
action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id)
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(i + 1 < steps.len()).then_some(Message::Command(Command::MoveStep(
owner,
i,
i + 1
)))
)
]
.spacing(6),
);
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::ScenarioUse {
scenario: Some(target),
..
},
..
}) = p.elements[id].shared()
{
out = out.push(action(
&format!("Open {} →", p.named(*target)),
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
));
}
out = sequence_rows(p, context, *id, out);
}
}
out
}
#[cfg(test)]
pub(crate) mod direct_tests {
@@ -2180,7 +1739,7 @@ pub(crate) mod direct_tests {
epoch: 0,
}
}
fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
let mut f = fixture();
let p = &mut f.project;
let machine = p
@@ -2334,7 +1893,12 @@ pub(crate) mod direct_tests {
);
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 250.)
iced::Vector::new(25., 170.)
);
assert_eq!(
app.behavior.scenario.scroll_offsets[1],
iced::Vector::new(0., 80.),
"The caller's independent outline scroll is restored separately"
);
assert!(
!app.behavior
@@ -2471,6 +2035,11 @@ pub(crate) mod direct_tests {
assert!(app.document.can_undo(), "{}", app.status);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, if edit == 0 { first } else { b });
assert_eq!(
app.scenario_context().unwrap().scenario,
if edit == 0 { a } else { b },
"Back must restore the caller workspace even if a rename already selected its UUID"
);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert_eq!(app.document.revision(), revision);
@@ -2909,7 +2478,7 @@ pub(crate) mod direct_tests {
assert!(app.busy);
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 140.)
iced::Vector::new(25., 100.)
);
assert_ne!(
app.scenario_context(),
@@ -2925,7 +2494,7 @@ pub(crate) mod direct_tests {
)));
assert_eq!(
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 140.)
iced::Vector::new(25., 100.)
);
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
let receipt = crate::app::FileSnapshot {