//! 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, pub(super) focused: bool, pub(super) shelf: Option, pub(super) pan: iced::Vector, pub(super) zoom: Option, 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) -> 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 { 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::>() .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, 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 }