968 lines
37 KiB
Rust
968 lines
37 KiB
Rust
//! Scenario presentation. Layout/camera controls never construct model commands.
|
|
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 {
|
|
Selection,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(super) struct Presentation {
|
|
pub(super) identity: Option<(Id, Id)>,
|
|
pub(super) representation: Option<Representation>,
|
|
pub(super) visit: Id,
|
|
pub(super) viewport: Option<Size>,
|
|
pub(super) shelf: Option<Shelf>,
|
|
pub(super) pan: iced::Vector,
|
|
pub(super) zoom: Option<f32>,
|
|
pub(super) fit: bool,
|
|
}
|
|
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 {
|
|
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
|
|
}
|
|
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
|
|
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
|
|
}
|
|
// Legacy tests call size-only scale; actual display always passes full bounds.
|
|
#[cfg(test)]
|
|
pub(super) fn scale(&self, size: Size) -> f32 {
|
|
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct ArrangementChoice {
|
|
representation: Representation,
|
|
label: String,
|
|
}
|
|
impl std::fmt::Display for ArrangementChoice {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.label)
|
|
}
|
|
}
|
|
|
|
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 Some(context) = app.scenario_context() else {
|
|
return column![
|
|
out,
|
|
text("Scenario view is unavailable."),
|
|
button("Open selected source").on_press(Message::SourceForSelection)
|
|
]
|
|
.spacing(8)
|
|
.into();
|
|
};
|
|
let tool = state.canvas_tool(owner);
|
|
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
|
// Resolve once per accepted view. An active spacing session owns its exact
|
|
// prepared geometry; borrowing it also retains the canvas input state.
|
|
let preview = state.spacing_paint(context, app.camera_epoch);
|
|
let shown = preview
|
|
.is_none()
|
|
.then(|| presentation::present(p, context.representation, app.selected));
|
|
let capabilities = shown.as_ref().map_or(
|
|
presentation::PresentationCapabilities {
|
|
geometry_navigation: true,
|
|
coordinate_input: true,
|
|
},
|
|
ScenarioPresentation::capabilities,
|
|
);
|
|
let coordinate_input = clean && capabilities.coordinate_input;
|
|
let notice = if let Some(paint) = &preview {
|
|
display_notice_parts(
|
|
p,
|
|
context.representation,
|
|
&presentation::PresentationStatus::Ready,
|
|
paint.original_diagnostics,
|
|
paint.original_latent,
|
|
)
|
|
} else {
|
|
display_notice(p, shown.as_ref().expect("normal presentation"))
|
|
};
|
|
let camera_state = preview
|
|
.as_ref()
|
|
.map(|paint| camera::Camera {
|
|
scale: paint.scene.scale,
|
|
pan: paint.scene.camera_pan,
|
|
})
|
|
.or_else(|| {
|
|
shown
|
|
.as_ref()
|
|
.and_then(ScenarioPresentation::bounds)
|
|
.map(|bounds| presentation.camera(bounds))
|
|
});
|
|
let scale = camera_state.map_or(1., |camera| camera.scale);
|
|
let scope = super::super::canvas_workspace::Scope {
|
|
document: context.document,
|
|
owner,
|
|
revision: context.revision,
|
|
epoch: app.camera_epoch,
|
|
scenario_view: Some((context.representation, context.visit)),
|
|
};
|
|
let keyboard = clean
|
|
&& capabilities.geometry_navigation
|
|
&& super::super::canvas_workspace::keyboard_enabled(app);
|
|
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
|
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
|
|
Some(
|
|
canvas::Canvas::new(paint.scene)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.into(),
|
|
)
|
|
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
|
|
let camera_state = camera_state.expect("displayed Scene has complete bounds");
|
|
scene.scale = camera_state.scale;
|
|
scene.camera_pan = camera_state.pan;
|
|
scene.interaction = Some((context, tool, clean));
|
|
scene.spacing_stamp = (coordinate_input
|
|
&& !app.source_rename.open()
|
|
&& keyboard
|
|
&& matches!(context.representation, Representation::Saved { .. }))
|
|
.then_some(spacing::Stamp {
|
|
context,
|
|
camera_epoch: app.camera_epoch,
|
|
});
|
|
scene.coordinate_input = capabilities.coordinate_input;
|
|
scene.pending_placement = coordinate_input
|
|
.then(|| state.pending_placement(owner))
|
|
.flatten();
|
|
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
|
|
} else {
|
|
None
|
|
};
|
|
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
|
|
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
|
// on Begin would discard its pointer capture before Move/Finish.
|
|
let mut layers = iced::widget::stack![raw_canvas];
|
|
if let Some(paint) = &preview {
|
|
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
|
layers = layers.push(
|
|
container(
|
|
container(text(feedback).size(12))
|
|
.padding(6)
|
|
.width(Fill)
|
|
.style(style::card),
|
|
)
|
|
.width(Fill)
|
|
.align_bottom(Fill)
|
|
.padding(8),
|
|
);
|
|
}
|
|
container(
|
|
sensor(
|
|
super::super::canvas_workspace::Viewport::new(
|
|
layers, scope, scale, keyboard, camera,
|
|
)
|
|
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
|
|
)
|
|
.key((
|
|
context.document,
|
|
format!("{:?}", context.representation),
|
|
context.visit,
|
|
))
|
|
.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()
|
|
} else {
|
|
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
|
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
|
};
|
|
let mut navigation = row![out.width(Fill)].spacing(6);
|
|
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))),
|
|
));
|
|
}
|
|
let mut header = column![navigation, notice].spacing(4);
|
|
let saved_views = p
|
|
.diagrams
|
|
.values()
|
|
.filter(|diagram| {
|
|
presentation::checked_owner(
|
|
p,
|
|
Representation::Saved {
|
|
diagram: diagram.id,
|
|
},
|
|
) == Some(owner)
|
|
})
|
|
.map(|diagram| ArrangementChoice {
|
|
representation: Representation::Saved {
|
|
diagram: diagram.id,
|
|
},
|
|
label: short(&diagram.name, 64),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if !saved_views.is_empty() {
|
|
let mut choices = vec![ArrangementChoice {
|
|
representation: Representation::Derived { owner },
|
|
label: "Automatic arrangement".into(),
|
|
}];
|
|
choices.extend(saved_views);
|
|
let selected = choices
|
|
.iter()
|
|
.find(|choice| choice.representation == context.representation)
|
|
.cloned();
|
|
header = header.push(
|
|
container(
|
|
pick_list(choices, selected, move |choice: ArrangementChoice| {
|
|
Message::ScenarioCanvas(CanvasEvent::Representation(
|
|
context,
|
|
choice.representation,
|
|
))
|
|
})
|
|
.placeholder("Choose arrangement")
|
|
.text_size(12)
|
|
.padding(5)
|
|
.width(Fill),
|
|
)
|
|
.width(Fill)
|
|
.id("scenario-arrangement-picker"),
|
|
);
|
|
}
|
|
if capabilities.geometry_navigation {
|
|
header = header.push(super::super::canvas_workspace::controls(
|
|
scope,
|
|
keyboard,
|
|
scale,
|
|
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
|
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
|
camera,
|
|
));
|
|
}
|
|
let entries = [
|
|
(
|
|
"S",
|
|
if matches!(context.representation, Representation::Saved { .. }) {
|
|
"Select / move messages and lifelines"
|
|
} else {
|
|
"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 && (choice == CanvasTool::Select || capabilities.coordinate_input))
|
|
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
|
context, choice,
|
|
))),
|
|
)
|
|
}),
|
|
);
|
|
let mut details = column![
|
|
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
|
container(button(text("Properties").size(12)).on_press_maybe(
|
|
(clean && properties_target(p, context, app.selected)).then_some(
|
|
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
|
|
),
|
|
))
|
|
.id("scenario-properties")
|
|
]
|
|
.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,
|
|
))),
|
|
));
|
|
}
|
|
details = details.push(text("Scenario order").size(13));
|
|
if matches!(context.representation, Representation::Saved { .. }) {
|
|
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
|
|
}
|
|
let details = details_pane(
|
|
context,
|
|
state.scroll_restore(context, ScrollArea::Page),
|
|
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(),
|
|
);
|
|
if let Some(d) = app
|
|
.behavior
|
|
.scenario
|
|
.draft
|
|
.as_ref()
|
|
.filter(|d| d.owner == owner)
|
|
{
|
|
if let Some(child) = d.source.view(p) {
|
|
form_panel = column![
|
|
text(format!(
|
|
"Consumer name: {}",
|
|
crate::views::short(&d.name, 60)
|
|
))
|
|
.size(12),
|
|
child.map(|e| event(Event::Source(e)))
|
|
]
|
|
.spacing(6)
|
|
.height(Fill)
|
|
.into();
|
|
} else {
|
|
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)
|
|
]
|
|
.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::Lifeline {
|
|
if let Some(title) = d.source.label(d.target) {
|
|
form = form.push(label(title)).push(label(
|
|
"Create commits one new source and this lifeline together.",
|
|
));
|
|
}
|
|
if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy {
|
|
form = form.push(button("Create new source…").on_press(event(Event::Source(
|
|
super::super::source_draft::Event::Open(d.invocation),
|
|
))));
|
|
} else {
|
|
form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available."));
|
|
}
|
|
}
|
|
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);
|
|
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();
|
|
}
|
|
}
|
|
let editor = if pending {
|
|
Some(form_panel)
|
|
} else {
|
|
presentation.shelf.map(|_| details)
|
|
};
|
|
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
|
}
|
|
|
|
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
|
|
display_notice_parts(
|
|
p,
|
|
shown.requested(),
|
|
shown.status(),
|
|
shown.diagnostics(),
|
|
shown.latent_messages(),
|
|
)
|
|
}
|
|
|
|
fn display_notice_parts<'a>(
|
|
p: &Project,
|
|
requested: Representation,
|
|
status: &presentation::PresentationStatus,
|
|
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
|
latent_messages: &[Id],
|
|
) -> Element<'a, Message> {
|
|
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
|
|
let title = match requested {
|
|
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|
|
|| "Unavailable saved Scenario view".into(),
|
|
|view| format!("Saved view · {}", view.name),
|
|
),
|
|
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
|
|
};
|
|
let mut notice = column![text(title).size(14)].spacing(4);
|
|
match status {
|
|
PresentationStatus::Ready => {
|
|
for diagnostic in diagnostics.iter().take(3) {
|
|
let explanation = match diagnostic {
|
|
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
|
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
|
p.named(*before),
|
|
p.named(*after)
|
|
),
|
|
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
|
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
|
p.named(*frame),
|
|
p.named(*following)
|
|
),
|
|
};
|
|
notice = notice.push(text(explanation).size(12));
|
|
}
|
|
if diagnostics.len() > 3 {
|
|
notice = notice.push(
|
|
text(format!(
|
|
"{} additional spacing notices are retained.",
|
|
diagnostics.len() - 3
|
|
))
|
|
.size(12),
|
|
);
|
|
}
|
|
}
|
|
PresentationStatus::Fallback { requested_issues } => {
|
|
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
|
|
if let Some(issue) = requested_issues.first() {
|
|
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
|
}
|
|
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
|
}
|
|
PresentationStatus::Unavailable { .. } => {
|
|
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
|
}
|
|
}
|
|
if !latent_messages.is_empty() {
|
|
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
|
|
}
|
|
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
|
|
container(notice)
|
|
.id("scenario-display-notice")
|
|
.padding(6)
|
|
.width(Fill)
|
|
.into()
|
|
}
|
|
|
|
fn spacing_feedback(
|
|
p: &Project,
|
|
refusal: Option<&spacing::Refusal>,
|
|
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
|
) -> String {
|
|
if let Some(refusal) = refusal {
|
|
let reason = match refusal {
|
|
spacing::Refusal::Geometry(issues) => issues
|
|
.first()
|
|
.map(|issue| geometry_problem(p, issue))
|
|
.unwrap_or_else(|| "This position cannot be displayed.".into()),
|
|
spacing::Refusal::Command(error) => error.clone(),
|
|
_ => "This position is unavailable in the current view.".into(),
|
|
};
|
|
return format!(
|
|
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
|
|
);
|
|
}
|
|
let explanation = match diagnostics.first() {
|
|
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
|
|
before,
|
|
after,
|
|
..
|
|
}) => format!(
|
|
" '{}' is below '{}'; Scenario order stays unchanged.",
|
|
p.named(*before),
|
|
p.named(*after)
|
|
),
|
|
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
|
|
frame,
|
|
following,
|
|
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
|
|
None => " Scenario order stays unchanged.".into(),
|
|
};
|
|
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
|
|
}
|
|
|
|
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
|
|
use super::super::scenario_layout::GeometryIssue;
|
|
match issue {
|
|
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
|
|
"The position of '{}' conflicts with the execution of '{}'.",
|
|
p.named(*event),
|
|
p.named(*call)
|
|
),
|
|
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
|
|
"The distinct endpoints of '{}' have the same horizontal position.",
|
|
p.named(*message)
|
|
),
|
|
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
|
|
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
|
|
"The position of '{}' falls outside its supported execution interval.",
|
|
p.named(*call)
|
|
),
|
|
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
|
|
"The saved spacing would move the fixed header of '{}'.",
|
|
p.named(*frame)
|
|
),
|
|
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
|
|
"The spacing of '{}' extends outside its enclosing frame.",
|
|
p.named(*operand)
|
|
),
|
|
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
|
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
|
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
|
|
"The saved spacing cannot preserve the participant lifetime geometry.".into()
|
|
}
|
|
_ => "These saved positions are outside the geometry currently supported by the canvas."
|
|
.into(),
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
|
|
context: Context,
|
|
restore: Option<ScrollRestore>,
|
|
details: iced::Element<'a, Message, Theme, R>,
|
|
) -> iced::Element<'a, Message, Theme, R> {
|
|
scenario_scroll::observe(
|
|
context,
|
|
ScrollArea::Page,
|
|
false,
|
|
restore,
|
|
scrollable(details)
|
|
.id("scenario-details")
|
|
.width(Fill)
|
|
.height(Fill),
|
|
)
|
|
}
|
|
|
|
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),
|
|
form_session::reset_on_change(
|
|
session,
|
|
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;
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod spacing_tests;
|
|
|
|
#[cfg(all(test, debug_assertions))]
|
|
mod details_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
|
|
}
|