750 lines
27 KiB
Rust
750 lines
27 KiB
Rust
//! 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),
|
||
))
|
||
.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>(
|
||
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
|
||
}
|