Keep scenario authoring visible and restore exact caller context

This commit is contained in:
2026-09-11 01:21:45 -07:00
committed by Codex
parent 6d2b9619e3
commit 865cd55e21
7 changed files with 1744 additions and 508 deletions
@@ -0,0 +1,748 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::scenario_scroll;
use super::*;
use iced::widget::{Space, column, sensor};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
|| !size.height.is_finite()
|| size.width <= 0.
|| size.height <= 0.
|| self.viewport == Some(size)
{
return false;
}
self.viewport = Some(size);
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// This is the measured inner Scrollable, never a window-height estimate.
// Leave room for the overlay scrollbars on both axes.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
}
fn fit_limited(&self, scene: Size) -> bool {
self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE)
}
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let state = &app.behavior.scenario;
let presentation = &state.presentation;
let pending = state
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let context = app.scenario_context().expect("visible scenario context");
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
let limited = presentation.fit_limited(scene.size);
scene.scale = scale;
scene.interaction = Some((context, tool, clean));
// The outline is a sibling shelf, not an ancestor of this canvas.
scene.scroll_offset = state.scroll_offset();
scene.pending_placement = state.pending_placement(owner);
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
let canvas = canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
);
let mut navigation = row![
out.width(Fill),
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
]
.spacing(6)
.align_y(iced::Center);
if state.can_back(p, context) {
navigation = navigation.push(
button(text("Back to caller").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
),
);
}
let mut header = column![
navigation,
row![
button(text("Tools").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Tools
))),
button(text("Selection").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Selection
))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
text(format!("{:.0}%", scale * 100.)).size(12),
button(text("+").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.align_y(iced::Center)
.wrap(),
text(if pending {
"Pending form · the highlighted sequence position is retained".to_owned()
} else {
let guidance = match tool {
CanvasTool::Message(_) => {
"Click a source position, then click or drag to a target · Esc cancels"
}
CanvasTool::Add(Tool::Action | Tool::Observation) => {
"Click a highlighted position · a lifeline click preselects that lifeline"
}
CanvasTool::Add(Tool::Reuse | Tool::Fragment) => {
"Click a highlighted sequence position to open its form"
}
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder",
};
format!("{tool} · {guidance}")
})
.size(11)
]
.spacing(6);
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
}
let details = match presentation.shelf {
Some(Shelf::Tools) => {
let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool));
let tool_picker: Element<'_, Message> = if clean {
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),
choose,
)
.text_size(12)
.padding(6)
.width(Fill)
.into()
} else {
button(text(format!("{tool} · finish pending edits to change tools")).size(12))
.padding(6)
.width(Fill)
.into()
};
column![
tool_picker,
row![
button(text("+ Lifeline").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(event(Event::Start(owner, Tool::Lifeline)))
),
button(text("Scenario properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(owner))),
]
.spacing(6)
.wrap(),
text(match viewpoint(p, owner) {
ScenarioViewpoint::OperationalActivity =>
"Activity scenario · lifelines represent activities",
ScenarioViewpoint::Functional =>
"Functional scenario · lifelines represent system functions",
ScenarioViewpoint::ParticipantInteraction =>
"Participant scenario · entities, actors or roles",
ScenarioViewpoint::BoundaryInterface =>
"Interface scenario · system and external actors",
ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants",
})
.size(11),
]
.spacing(6)
}
_ => {
let mut details =
column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6);
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(
button(text("Open definition").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::ScenarioCanvas(
CanvasEvent::OpenUse(context, app.selected),
))),
);
}
navigation = navigation.push(
button(text("Properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected))),
);
details = details.push(navigation.wrap());
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
details.push(
text("Use Tools to add lifelines, then draw messages or insert occurrences.")
.size(12),
)
} else {
sequence_rows(p, context, owner, details, clean)
}
}
};
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
details.into(),
presentation.shelf.is_some(),
pending,
);
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
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);
form_panel = editor_panel(footer.into(), form.into());
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
}
fn sequence_action<'a>(
title: &str,
message: Message,
enabled: bool,
) -> iced::widget::Button<'a, Message> {
button(text(title.to_owned()).size(12))
.padding(8)
.style(style::nav)
.on_press_maybe(enabled.then_some(message))
}
// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures
// must remain in the tree: Column::push otherwise drops them and remounts siblings.
fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, Theme, R>,
details: iced::Element<'a, Message, Theme, R>,
canvas: iced::Element<'a, Message, Theme, R>,
form: iced::Element<'a, Message, Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
let panes = Column::from_vec(vec![
container(canvas).width(Fill).height(Fill).into(),
container(form)
.width(Fill)
.height(if pending {
Fill
} else {
iced::Length::Fixed(0.)
})
.clip(true)
.into(),
])
.spacing(if pending { 6 } else { 0 })
.width(Fill)
.height(Fill);
Column::from_vec(vec![header, details, panes.into()])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
details: iced::Element<'a, Message, Theme, R>,
open: bool,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
scenario_scroll::observe(
context,
ScrollArea::Page,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(if open {
if pending { 72. } else { 100. }
} else {
0.
}),
)
}
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
content: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
sensor(scenario_scroll::observe(
context,
ScrollArea::Canvas,
restore,
scrollable(content)
.id("scenario-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.width(Fill)
.height(Fill),
))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
actions: iced::Element<'a, Message, Theme, R>,
fields: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
column![
container(actions).id("scenario-actions").width(Fill),
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
]
.spacing(6),
)
.padding(10)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
#[cfg(all(test, debug_assertions))]
mod tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
owner: Id,
mut out: Column<'a, Message>,
enabled: bool,
) -> 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(sequence_action(
&format!("Operand: {}", p.named(*branch)),
Message::Select(*branch),
enabled,
));
out = sequence_rows(p, context, *branch, out, enabled);
}
}
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
for (i, id) in steps.iter().enumerate() {
out = out.push(
row![
sequence_action(
&format!("{}. {}", i + 1, p.named(*id)),
Message::Select(*id),
enabled
)
.width(Fill),
button("↑")
.padding(8)
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
Command::MoveStep(owner, i, i.saturating_sub(1))
))),
button("↓").padding(8).on_press_maybe(
(enabled && 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(sequence_action(
&format!("Open {} →", p.named(*target)),
Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)),
enabled,
));
}
out = sequence_rows(p, context, *id, out, enabled);
}
}
out
}