Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/scenario/workspace.rs
T

697 lines
25 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) 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,
}
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?;
// 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.)),
)
}
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.)
}
}
}
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);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
scene.pending_placement = state.pending_placement(owner);
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),
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
)
]
.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 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 = 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 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;
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
}