//! 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, pub(super) visit: Id, pub(super) viewport: Option, pub(super) shelf: Option, pub(super) pan: iced::Vector, pub(super) zoom: Option, 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) -> 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> = 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::>(); 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::>() .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, 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 }