Integrate persistent canvas controls with scenario lifetime rendering

This commit is contained in:
2026-09-11 12:44:06 -07:00
parent 32334c6631
commit a57c334b30
42 changed files with 11751 additions and 2163 deletions
@@ -1,11 +1,12 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::scenario_scroll;
use super::super::{form_session, scenario_scroll};
use super::*;
use iced::widget::{Space, column, sensor};
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
@@ -15,6 +16,7 @@ pub(super) struct Presentation {
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
@@ -44,23 +46,19 @@ impl Presentation {
}
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.
// Fit uses the actual mounted viewport with a small visual gutter.
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 {
pub(super) 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> {
@@ -76,249 +74,188 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
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.camera_pan = presentation.pan;
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 scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(tool == CanvasTool::Select),
)
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
let mut navigation = row![
out.width(Fill),
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
super::super::canvas_workspace::Control::new(
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
scope,
"Panels",
keyboard,
Some(super::super::canvas_workspace::intent(
scope,
"Panels",
Message::ScenarioCanvas(CanvasEvent::Focus(context))
)),
camera
)
.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 if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
"Start with a lifeline representing existing or explicitly new work / participants."
.into()
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
} 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 !pending {
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
header = header.push(
row![
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
button("Add first lifeline").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
}))
]
.spacing(8),
);
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
header = header.push(
row![
text("No occurrences yet").size(12),
button("Add first message").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
})),
button("Add first action").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
}))
]
.spacing(8),
);
}
if state.can_back(p, context) {
navigation =
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
let header = column![
navigation,
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
scope.epoch,
super::super::canvas_workspace::Event::Focus
))),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"L",
"Lifeline: click to choose represented work or participant",
CanvasTool::Add(Tool::Lifeline),
),
(
"C/R",
"Call and correlated reply",
CanvasTool::Message(MessageStyle::CallReply),
),
(
"M",
"Asynchronous message",
CanvasTool::Message(MessageStyle::Asynchronous),
),
(
"C",
"Synchronous call",
CanvasTool::Message(MessageStyle::Call),
),
(
"R",
"Reply to existing call",
CanvasTool::Message(MessageStyle::Reply),
),
(
"+L",
"Create participant",
CanvasTool::Message(MessageStyle::Create),
),
(
"-L",
"Delete participant",
CanvasTool::Message(MessageStyle::Delete),
),
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
(
"A",
"Action: click a sequence position",
CanvasTool::Add(Tool::Action),
),
(
"St",
"State observation: click a sequence position",
CanvasTool::Add(Tool::Observation),
),
(
"ref",
"Reuse scenario: click a sequence position",
CanvasTool::Add(Tool::Reuse),
),
(
"alt",
"Fragment: click a sequence position",
CanvasTool::Add(Tool::Fragment),
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
context,
app.selected,
))),
));
}
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,
sequence_rows(p, context, owner, details, clean).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
Space::new().height(0).into(),
Space::new().height(0).into(),
);
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
if let Some(d) = app
.behavior
.scenario
@@ -342,6 +279,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.id(focus::name_id(
state.focus_stamp(context).expect("visible parent form")
))
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
@@ -619,10 +559,20 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
action("Cancel", event(Event::Cancel))
]
.spacing(7);
form_panel = editor_panel(footer.into(), form.into());
let session = state.form_session().expect("visible scenario draft");
let mount = state.form_mount().expect("visible parent form");
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
.key(mount)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
.into();
}
}
workspace_layout(header.into(), details, canvas, form_panel, pending)
let editor = if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn sequence_action<'a>(
@@ -636,101 +586,38 @@ fn sequence_action<'a>(
.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,
false,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(if open {
if pending { 72. } else { 100. }
} else {
0.
}),
.height(Fill),
)
}
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),
))
.key((context.document, context.scenario))
.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>(
session: Id,
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),
form_session::reset_on_change(
session,
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
),
]
.spacing(6),
)