//! 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, pub(super) focused: bool, pub(super) shelf: Option, 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?; // 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::>() .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, 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, 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 }