From 865cd55e21e622985322d711b23e6d653b563d3d Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 10 Sep 2026 12:03:17 -0700 Subject: [PATCH] Keep scenario authoring visible and restore exact caller context --- crates/oa-desktop/src/app.rs | 52 +- crates/oa-desktop/src/behavior.rs | 19 +- crates/oa-desktop/src/behavior/scenario.rs | 553 ++---------- .../src/behavior/scenario/workspace.rs | 748 ++++++++++++++++ .../src/behavior/scenario/workspace/tests.rs | 822 ++++++++++++++++++ crates/oa-desktop/src/views.rs | 12 +- .../release-scenario-workspace-20260910.md | 46 + 7 files changed, 1744 insertions(+), 508 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/scenario/workspace.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/workspace/tests.rs create mode 100644 harness/release-scenario-workspace-20260910.md diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 44bf6d85..82135f1b 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -1103,6 +1103,7 @@ impl App { Message::ScenarioCanvas( crate::behavior::scenario::CanvasEvent::Scrolled(..) | crate::behavior::scenario::CanvasEvent::Restored(..) + | crate::behavior::scenario::CanvasEvent::Viewport(..) ) | Message::TopologyCanvas( crate::behavior::topology::CanvasEvent::Scrolled(..) | crate::behavior::topology::CanvasEvent::Viewport(..) @@ -1294,6 +1295,11 @@ impl App { .topology .enter(context.document, context.owner); } + if let Some(context) = self.scenario_context() { + self.behavior + .scenario + .enter(context.document, context.scenario); + } if self.connection.as_ref().is_some_and(|session| { Some(session.context) != self.route_context() || self.page != Page::Diagram @@ -1364,6 +1370,17 @@ impl App { Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); + if let CanvasEvent::Viewport(_, size) = event { + if self.scenario_context().is_some_and(|current| { + current.document == context.document + && current.scenario == context.scenario + && current.revision == context.revision + }) && self.behavior.scenario.viewport(size) + { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + return Task::none(); + } if let CanvasEvent::Restored(_, token, area, offset) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario @@ -1377,7 +1394,7 @@ impl App { return Task::none(); } if let CanvasEvent::Scrolled(_, area, offset) = event { - // Inner and outer scroll notifications may come from the + // Canvas and outline notifications may come from the // same frame. Accept both across the first epoch bump, // while still rejecting another owner/document/revision. if self.scenario_context().is_some_and(|current| { @@ -1394,6 +1411,19 @@ impl App { return Task::none(); } match event { + CanvasEvent::Focus(_) => { + self.behavior.scenario.toggle_focus(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + CanvasEvent::Shelf(_, shelf) => { + self.behavior.scenario.toggle_shelf(shelf); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + CanvasEvent::Zoom(_, scale) => { + if self.behavior.scenario.zoom(scale) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + } CanvasEvent::OpenUse(_, occurrence) => { if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { @@ -1405,6 +1435,9 @@ impl App { self.behavior.scenario = state; match target { Ok(target) => { + // A command can preselect this target while the + // caller remains visible, bypassing select below. + self.behavior.active_owner = Some(target); self.select(target); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } @@ -1423,12 +1456,23 @@ impl App { let caller = state.back(self.project(), context); self.behavior.scenario = state; if let Some(caller) = caller { + // A command may already have selected this UUID while + // leaving the referenced definition open. select's + // same-UUID fast path must not keep that old owner. + self.behavior.active_owner = Some(caller.owner); self.select(caller.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); if let Some(context) = self.scenario_context() { self.behavior.scenario.restore_caller(context, caller); } } else { + // An edit can select a retargeted caller outside the + // definition that remains visible after rejected Back. + if crate::behavior::selected_behavior(self.project(), self.selected) + != Some(context.scenario) + { + self.select(context.scenario); + } self.status = "The calling scenario reference is no longer available.".into(); } @@ -1443,7 +1487,11 @@ impl App { } } CanvasEvent::Choose(_, tool) => { - if self.behavior.has_pending() || !self.apply() { + if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() + { + self.status = + "Apply or cancel pending edits before choosing a scenario tool." + .into(); return Task::none(); } self.behavior.scenario.choose_tool(context.scenario, tool); diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 8726fd75..c3605f65 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -32,12 +32,12 @@ mod scenario_scroll { use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, renderer, widget}; use iced::{Length, Vector, overlay}; - pub(super) fn observe<'a>( + pub(super) fn observe<'a, R: renderer::Renderer + 'a>( context: scenario::Context, area: scenario::ScrollArea, restore: Option, - content: impl Into>, - ) -> Element<'a, Message> { + content: impl Into>, + ) -> Element<'a, Message, Theme, R> { Element::new(Observer { content: content.into(), context, @@ -922,20 +922,21 @@ pub fn view(app: &App) -> Element<'_, Message> { return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into(); }; let e = &p.elements[&selected.id]; - let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain); + let compact_canvas = matches!( + e.kind(), + Kind::Process | Kind::FunctionalChain | Kind::Scenario + ); if e.kind() == Kind::StateMachine { out = column![text("States & modes").size(20)].spacing(8); - } else if compact_process { - // The process canvas owns its compact header and remaining-height layout. + } else if compact_canvas { + // The process/scenario canvas owns its compact remaining-height layout. out = column![].spacing(6); - } else if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { - out = column![text("Processes & functional chains").size(20)].spacing(8); } out = out.push( pick_list(choices, Some(selected), Message::BehaviorOwner) .width(Fill) .text_size(13) - .padding(if compact_process { 6 } else { 10 }), + .padding(if compact_canvas { 6 } else { 10 }), ); if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) { return topology::view(app, e.id, out); diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 0d7f86d7..82f37e95 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -4,6 +4,8 @@ use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; +mod workspace; +pub use workspace::{Shelf, view}; #[cfg(test)] #[path = "scenario_operation_tests.rs"] @@ -94,6 +96,10 @@ impl std::fmt::Display for CanvasTool { pub enum CanvasEvent { Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), + Viewport(Context, Size), + Zoom(Context, Option), + Focus(Context), + Shelf(Context, Shelf), OpenUse(Context, Id), Back(Context), Choose(Context, CanvasTool), @@ -120,6 +126,7 @@ pub enum CanvasEvent { #[derive(Debug, Clone, Copy)] pub enum ScrollArea { Canvas, + // Retained navigation channel, now the independent Selection/outline shelf. Page, } impl CanvasEvent { @@ -127,6 +134,10 @@ impl CanvasEvent { match self { Self::Scrolled(context, _, _) | Self::Restored(context, ..) + | Self::Viewport(context, _) + | Self::Zoom(context, _) + | Self::Focus(context) + | Self::Shelf(context, _) | Self::OpenUse(context, _) | Self::Back(context) | Self::Choose(context, _) @@ -191,6 +202,7 @@ pub struct State { scroll_offsets: [iced::Vector; 2], return_to: Vec, restore: Option, + presentation: workspace::Presentation, } const CALLER_LIMIT: usize = 32; pub(crate) struct Caller { @@ -200,6 +212,7 @@ pub(crate) struct Caller { target: Id, tool: CanvasTool, offsets: [iced::Vector; 2], + presentation: workspace::Presentation, } struct ViewportRestore { document: Id, @@ -259,6 +272,32 @@ struct Draft { choice_page: usize, } impl State { + pub(crate) fn enter(&mut self, document: Id, owner: Id) { + if self.presentation.identity != Some((document, owner)) { + self.presentation = workspace::Presentation { + identity: Some((document, owner)), + focused: self.presentation.focused, + ..Default::default() + }; + self.scroll_offsets = [iced::Vector::ZERO; 2]; + self.restore = None; + } + } + pub(crate) fn viewport(&mut self, size: Size) -> bool { + self.presentation.viewport(size) + } + pub(crate) fn zoom(&mut self, scale: Option) -> bool { + self.presentation.zoom(scale) + } + pub(crate) fn focused(&self) -> bool { + self.presentation.focused + } + pub(crate) fn toggle_focus(&mut self) { + self.presentation.focused = !self.presentation.focused; + } + pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) { + self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf); + } pub(crate) fn open_use( &mut self, p: &Project, @@ -279,6 +318,7 @@ impl State { target, tool: self.canvas_tool(context.scenario), offsets: self.scroll_offsets, + presentation: self.presentation.clone(), }); self.canvas_tool = Some((target, CanvasTool::Select)); self.scroll_offsets = [iced::Vector::ZERO; 2]; @@ -309,6 +349,8 @@ impl State { } self.canvas_tool = Some((frame.owner, frame.tool)); self.scroll_offsets = frame.offsets; + self.presentation = frame.presentation; + self.presentation.identity = Some((context.document, context.scenario)); self.restore = Some(ViewportRestore { document: context.document, owner: frame.owner, @@ -378,6 +420,9 @@ impl State { } pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool { + if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. { + return false; + } let index = match area { ScrollArea::Canvas => 0, ScrollArea::Page => 1, @@ -387,7 +432,8 @@ impl State { changed } fn scroll_offset(&self) -> iced::Vector { - self.scroll_offsets[0] + self.scroll_offsets[1] + // The former page scroll now belongs to a sibling outline shelf. + self.scroll_offsets[0] } fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec { let key = CallChoiceContext { @@ -1634,493 +1680,6 @@ fn bounded_picker<'a>( } out.into() } -pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> { - let p = app.project(); - out = out.push(label(match viewpoint(p, owner) { - ScenarioViewpoint::OperationalActivity => { - "Operational activity scenario · lifelines represent activities" - } - ScenarioViewpoint::Functional => { - "Functional scenario · lifelines represent system functions" - } - ScenarioViewpoint::ParticipantInteraction => { - "Participant scenario · lifelines represent entities, actors or roles" - } - ScenarioViewpoint::BoundaryInterface => { - "Interface scenario · lifelines represent the system and external actors" - } - ScenarioViewpoint::MixedLegacy => { - "Mixed scenario · lifelines may represent work or participants" - } - })); - let pending = app.behavior.has_pending(); - let context = app.scenario_context().expect("visible scenario context"); - let tool = app.behavior.scenario.canvas_tool(owner); - // Keep the canvas at a stable widget-tree position when a form replaces - // the palette, so Iced retains the user's current sequence viewport. - let mut controls = column![].spacing(6); - if !pending { - controls = controls.push( - row![ - button("Select / reorder") - .on_press(Message::ScenarioCanvas(CanvasEvent::Choose( - context, - CanvasTool::Select - ))) - .style(if tool == CanvasTool::Select { - style::selected - } else { - style::nav - }), - 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), - move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool)) - ) - .text_size(12) - .padding(8) - .width(240), - action("+ Lifeline", event(Event::Start(owner, Tool::Lifeline))), - action("Properties", Message::EditProperties(owner)), - ] - .spacing(6) - .wrap(), - ); - controls = controls.push(label(match tool { - CanvasTool::Message(_) => "Draw message · choose a sequence position on a lifeline, then click or drag to another lifeline · Escape cancels", - CanvasTool::Add(Tool::Action | Tool::Observation) => "Click a highlighted sequence position · click on a lifeline to preselect it · Escape cancels", - CanvasTool::Add(Tool::Reuse) => "Click a highlighted sequence position to reuse a scenario and bind its lifelines · Escape cancels", - CanvasTool::Add(Tool::Fragment) => "Click a highlighted sequence position to insert an empty fragment with operands · Escape cancels", - _ => "Select a lifeline or occurrence · drag its ↕ handle to reorder within its sequence", - })); - } else { - controls = controls.push(label( - "Pending form · the canvas remains visible · apply or cancel below", - )); - } - let clean = !pending && !app.draft_dirty() && !app.source_dirty(); - let mut navigation = row![].spacing(6); - if referenced_scenario(p, owner, app.selected).is_some() { - navigation = navigation.push(button("Open definition").on_press_maybe(clean.then_some( - Message::ScenarioCanvas(CanvasEvent::OpenUse(context, app.selected)), - ))); - } - if app.behavior.scenario.can_back(p, context) { - navigation = - navigation.push(button("Back to caller").on_press_maybe( - clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))), - )); - } - controls = controls.push(navigation.wrap()); - out = out.push(controls); - let mut scene = Scene::sequence(p, owner, app.selected); - scene.interaction = Some(( - context, - tool, - !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(), - )); - scene.scroll_offset = app.behavior.scenario.scroll_offset(); - scene.pending_placement = app.behavior.scenario.pending_placement(owner); - let size = scene.size; - out = out.push( - container(super::scenario_scroll::observe( - context, - ScrollArea::Canvas, - app.behavior - .scenario - .scroll_restore(context, ScrollArea::Canvas), - scrollable( - canvas::Canvas::new(scene) - .width(size.width) - .height(size.height), - ) - .direction(scrollable::Direction::Both { - vertical: scrollable::Scrollbar::default(), - horizontal: scrollable::Scrollbar::default(), - }) - .height(if pending { - (app.window_size.height - 660.).clamp(110., 190.) - } else { - (app.window_size.height - 410.).clamp(180., 340.) - }), - )) - .padding(8) - .style(style::card), - ); - 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); - out = out.push( - container( - column![ - scrollable(form).height((app.window_size.height - 650.).clamp(110., 240.)), - footer - ] - .spacing(9), - ) - .padding(14) - .style(style::card), - ); - } - if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) { - out = out.push(container(label("Add lifelines for the participating work or actors, then create messages between them.")).padding(16).width(Fill).style(style::card)); - } else if !pending { - out = sequence_rows(p, context, owner, out); - } - super::scenario_scroll::observe( - context, - ScrollArea::Page, - app.behavior - .scenario - .scroll_restore(context, ScrollArea::Page), - scrollable(out.padding(24)), - ) -} -fn sequence_rows<'a>( - p: &'a Project, - context: Context, - owner: Id, - mut out: Column<'a, Message>, -) -> 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(action( - &format!("Operand: {}", p.named(*branch)), - Message::Select(*branch), - )); - out = sequence_rows(p, context, *branch, out); - } - } - if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) { - for (i, id) in steps.iter().enumerate() { - out = - out.push( - row![ - action( - &format!("{}. {}", i + 1, p.named(*id)), - Message::Select(*id) - ) - .width(Fill), - button("↑") - .padding(8) - .on_press_maybe((i > 0).then_some(Message::Command( - Command::MoveStep(owner, i, i.saturating_sub(1)) - ))), - button("↓").padding(8).on_press_maybe( - (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(action( - &format!("Open {} →", p.named(*target)), - Message::ScenarioCanvas(CanvasEvent::OpenUse(context, *id)), - )); - } - out = sequence_rows(p, context, *id, out); - } - } - out -} #[cfg(test)] pub(crate) mod direct_tests { @@ -2180,7 +1739,7 @@ pub(crate) mod direct_tests { epoch: 0, } } - fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) { + pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) { let mut f = fixture(); let p = &mut f.project; let machine = p @@ -2334,7 +1893,12 @@ pub(crate) mod direct_tests { ); assert_eq!( app.behavior.scenario.scroll_offset(), - iced::Vector::new(25., 250.) + iced::Vector::new(25., 170.) + ); + assert_eq!( + app.behavior.scenario.scroll_offsets[1], + iced::Vector::new(0., 80.), + "The caller's independent outline scroll is restored separately" ); assert!( !app.behavior @@ -2471,6 +2035,11 @@ pub(crate) mod direct_tests { assert!(app.document.can_undo(), "{}", app.status); navigation_event(&mut app, CanvasEvent::Back); assert_eq!(app.selected, if edit == 0 { first } else { b }); + assert_eq!( + app.scenario_context().unwrap().scenario, + if edit == 0 { a } else { b }, + "Back must restore the caller workspace even if a rename already selected its UUID" + ); assert_eq!(app.project(), &project); assert_eq!(app.document.bundle(), &source); assert_eq!(app.document.revision(), revision); @@ -2909,7 +2478,7 @@ pub(crate) mod direct_tests { assert!(app.busy); assert_eq!( app.behavior.scenario.scroll_offset(), - iced::Vector::new(25., 140.) + iced::Vector::new(25., 100.) ); assert_ne!( app.scenario_context(), @@ -2925,7 +2494,7 @@ pub(crate) mod direct_tests { ))); assert_eq!( app.behavior.scenario.scroll_offset(), - iced::Vector::new(25., 140.) + iced::Vector::new(25., 100.) ); let root = std::path::PathBuf::from("scenario-scroll-save-fixture"); let receipt = crate::app::FileSnapshot { diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs new file mode 100644 index 00000000..ea63b579 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -0,0 +1,748 @@ +//! 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), + )) + .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 +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs new file mode 100644 index 00000000..8829a024 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -0,0 +1,822 @@ +use super::*; +use iced::Vector; +use iced::advanced::{Layout, Shell, layout, widget}; + +#[derive(Default)] +struct Geometry { + scrolls: BTreeMap, + actions: Option, +} +impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("scenario-actions")) { + self.actions = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + for name in ["scenario-canvas", "scenario-details", "scenario-form"] { + if id == Some(&widget::Id::new(name)) { + assert!( + self.scrolls + .insert(name.into(), (bounds, translation)) + .is_none() + ); + } + } + } +} +type TestView = iced::Element<'static, Message, Theme, ()>; +fn fixture_view(state: &State, context: Context, pending: bool, header: f32) -> TestView { + workspace_layout( + Space::new().height(header).into(), + details_pane( + context, + state.scroll_restore(context, ScrollArea::Page), + Space::new().height(1800.).into(), + state.presentation.shelf.is_some(), + pending, + ), + canvas_pane( + context, + state.scroll_restore(context, ScrollArea::Canvas), + Space::new().width(1800.).height(2400.).into(), + ), + editor_panel( + Space::new().height(32.).into(), + Space::new().height(2000.).into(), + ), + pending, + ) +} +fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) { + let node = view + .as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, size)); + let mut geometry = Geometry::default(); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), &mut geometry); + (node, geometry) +} +fn scroll( + view: &mut TestView, + tree: &mut widget::Tree, + node: &layout::Node, + name: &'static str, + offset: Vector, +) { + let mut operation = widget::operation::scrollable::scroll_to::<()>( + widget::Id::new(name), + widget::operation::scrollable::AbsoluteOffset { + x: Some(offset.x), + y: Some(offset.y), + }, + ); + view.as_widget_mut() + .operate(tree, Layout::new(node), &(), &mut operation); +} +fn redraw(view: &mut TestView, tree: &mut widget::Tree, size: Size, app: &mut App) { + let (node, _) = geometry(view, tree, size); + let mut messages = vec![]; + view.as_widget_mut().update( + tree, + &iced::Event::Window(iced::window::Event::RedrawRequested( + std::time::Instant::now(), + )), + Layout::new(&node), + mouse::Cursor::Unavailable, + &(), + &mut iced::advanced::clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::with_size(size), + ); + for message in messages { + let _ = app.update(message); + } +} + +#[test] +fn scenario_workspace_allocates_remaining_height_and_pins_actions_at_minimum_and_wide_sizes() { + for size in [ + Size::new(500., 570.), + Size::new(1050., 570.), + Size::new(840., 770.), + Size::new(1394., 802.), + ] { + for pending in [false, true] { + for shelf in [None, Some(Shelf::Tools), Some(Shelf::Selection)] { + for header in [68., 100., 132.] { + let mut state = State::default(); + state.presentation.shelf = shelf; + let mut view = fixture_view( + &state, + super::super::direct_tests::context(Id::new_v4()), + pending, + header, + ); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, actual) = geometry(&mut view, &mut tree, size); + assert_eq!(node.size(), size); + let canvas = actual.scrolls["scenario-canvas"].0; + assert!(canvas.x >= 0. && canvas.y >= header); + assert!( + canvas.x + canvas.width <= size.width + && canvas.y + canvas.height <= size.height + ); + assert!( + canvas.height >= if pending { 145. } else { 290. }, + "{size:?} header={header} pending={pending} shelf={shelf:?}: {canvas:?}" + ); + if pending { + let form = actual.scrolls["scenario-form"].0; + let actions = actual.actions.unwrap(); + assert!(form.height >= 100. && form.y + form.height <= size.height); + assert!(actions.y >= canvas.y + canvas.height); + assert!(actions.y + actions.height <= form.y); + scroll( + &mut view, + &mut tree, + &node, + "scenario-form", + Vector::new(0., 600.), + ); + let (_, after) = geometry(&mut view, &mut tree, size); + assert_eq!(after.actions, Some(actions)); + assert_eq!(after.scrolls["scenario-form"].1.y, 600.); + assert_eq!(after.scrolls["scenario-canvas"].1, Vector::ZERO); + } + } + } + } + } +} + +#[test] +fn scenario_workspace_canvas_and_form_scroll_survive_disclosures_focus_width_and_resize() { + let context = super::super::direct_tests::context(Id::new_v4()); + let mut state = State::default(); + let mut view = fixture_view(&state, context, false, 68.); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, _) = geometry(&mut view, &mut tree, Size::new(500., 570.)); + let offset = Vector::new(90., 180.); + scroll(&mut view, &mut tree, &node, "scenario-canvas", offset); + let mut prior_form = false; + for (pending, shelf, header, size) in [ + (false, Some(Shelf::Tools), 100., Size::new(500., 570.)), + (true, Some(Shelf::Selection), 68., Size::new(1050., 570.)), + (true, None, 68., Size::new(1394., 802.)), + (true, Some(Shelf::Tools), 100., Size::new(500., 570.)), + (false, None, 100., Size::new(500., 570.)), + ] { + state.presentation.shelf = shelf; + view = fixture_view(&state, context, pending, header); + tree.diff(view.as_widget()); + let (node, actual) = geometry(&mut view, &mut tree, size); + assert_eq!(actual.scrolls["scenario-canvas"].1, offset); + if pending { + if prior_form { + assert_eq!(actual.scrolls["scenario-form"].1.y, 600.); + } + scroll( + &mut view, + &mut tree, + &node, + "scenario-form", + Vector::new(0., 600.), + ); + let (_, after) = geometry(&mut view, &mut tree, size); + assert_eq!(after.actions, actual.actions); + assert_eq!(after.scrolls["scenario-canvas"].1, offset); + } + prior_form = pending; + } +} + +#[test] +fn scenario_workspace_caller_restores_actual_mounted_canvas_and_shelf_with_camera() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (p, [a, b, _], [reference, _, _]) = navigation_fixture(); + let mut app = App::with_project(p); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); + navigation_event(&mut app, CanvasEvent::Focus); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); + let source = app.document.snapshot(); + let size = Size::new(1050., 570.); + let mut view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + let mut tree = widget::Tree::new(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (node, _) = geometry(&mut view, &mut tree, size); + let canvas_offset = Vector::new(90., 180.); + scroll( + &mut view, + &mut tree, + &node, + "scenario-canvas", + canvas_offset, + ); + scroll( + &mut view, + &mut tree, + &node, + "scenario-details", + Vector::new(0., 80.), + ); + redraw(&mut view, &mut tree, size, &mut app); + assert_eq!( + app.behavior.scenario.scroll_offset(), + canvas_offset, + "shelf is not a canvas ancestor" + ); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.selected, b); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (_, opened) = geometry(&mut view, &mut tree, size); + assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.))); + navigation_event(&mut app, CanvasEvent::Focus); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, reference); + assert!(app.behavior.scenario.focused()); + assert_eq!( + app.behavior.scenario.presentation.shelf, + Some(Shelf::Selection) + ); + assert_eq!( + app.behavior + .scenario + .presentation + .scale(Size::new(800., 1200.)), + 0.75 + ); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (node, returned) = geometry(&mut view, &mut tree, size); + assert_eq!(returned.scrolls["scenario-canvas"].1, canvas_offset); + assert_eq!(returned.scrolls["scenario-details"].1, Vector::new(0., 80.)); + for area in [ScrollArea::Canvas, ScrollArea::Page] { + assert!( + app.behavior + .scenario + .scroll_restore(app.scenario_context().unwrap(), area) + .is_none() + ); + } + scroll( + &mut view, + &mut tree, + &node, + "scenario-canvas", + Vector::new(90., 220.), + ); + redraw(&mut view, &mut tree, size, &mut app); + assert_eq!( + geometry(&mut view, &mut tree, size).1.scrolls["scenario-canvas"] + .1 + .y, + 220., + "receipt cannot snap a later gesture back" + ); + assert!(std::sync::Arc::ptr_eq(&source, &app.document.snapshot())); + assert!(!app.document.can_undo()); +} + +#[test] +fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inputs() { + let graph = Size::new(900., 1400.); + let mut presentation = Presentation::default(); + assert_eq!(presentation.scale(graph), 1.); + presentation.zoom(None); + assert_eq!( + presentation.scale(graph), + 1., + "no invented window-height fallback" + ); + assert!(presentation.viewport(Size::new(484., 204.))); + assert!(!presentation.viewport(Size::new(484., 204.))); + let scale = presentation.scale(graph); + assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); + assert!(!presentation.fit_limited(graph)); + for bad in [f32::NAN, f32::INFINITY, 0., -1.] { + assert!(!presentation.zoom(Some(bad))); + assert!(!presentation.viewport(Size::new(bad, 100.))); + assert!(!presentation.viewport(Size::new(100., bad))); + assert_eq!(presentation.scale(graph), scale); + } + assert!(presentation.fit_limited(Size::new(900., 10000.))); + assert!(presentation.zoom(Some(1.25))); + presentation.viewport(Size::new(200., 100.)); + assert_eq!( + presentation.scale(graph), + 1.25, + "manual zoom survives form/viewport resize" + ); +} + +#[test] +fn scenario_workspace_open_and_back_restore_owner_and_mounted_scroll_after_preselection() { + use super::super::direct_tests::{navigation_event, navigation_fixture}; + let (p, [a, b, _], [reference, _, _]) = navigation_fixture(); + let mut app = App::with_project(p); + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + navigation_event(&mut app, |ctx| { + CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) + }); + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection)); + navigation_event(&mut app, CanvasEvent::Focus); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75))); + let size = Size::new(1050., 570.); + let mut view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + let mut tree = widget::Tree::new(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (node, _) = geometry(&mut view, &mut tree, size); + let offset = Vector::new(90., 180.); + scroll(&mut view, &mut tree, &node, "scenario-canvas", offset); + scroll( + &mut view, + &mut tree, + &node, + "scenario-details", + Vector::new(0., 80.), + ); + redraw(&mut view, &mut tree, size, &mut app); + + let _ = app.update(Message::Command(Command::Rename( + b, + "Renamed definition".into(), + ))); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, a); + let before_open = app.document.snapshot(); + navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + assert_eq!(app.selected, b); + assert_eq!(app.scenario_context().unwrap().scenario, b); + assert!(std::sync::Arc::ptr_eq( + &before_open, + &app.document.snapshot() + )); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (_, opened) = geometry(&mut view, &mut tree, size); + assert_eq!(opened.scrolls["scenario-canvas"].1, Vector::ZERO); + + let _ = app.update(Message::Command(Command::Rename( + reference, + "Renamed caller use".into(), + ))); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, b); + let before_back = app.document.snapshot(); + let revision = app.document.revision(); + navigation_event(&mut app, CanvasEvent::Back); + assert_eq!(app.selected, reference); + assert_eq!(app.scenario_context().unwrap().scenario, a); + assert_eq!( + app.behavior.scenario.canvas_tool(a), + CanvasTool::Message(MessageStyle::Timer) + ); + assert!(app.behavior.scenario.focused()); + assert_eq!( + app.behavior.scenario.presentation.shelf, + Some(Shelf::Selection) + ); + assert_eq!(app.behavior.scenario.presentation.zoom, Some(0.75)); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + false, + 68., + ); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, size, &mut app); + let (_, restored) = geometry(&mut view, &mut tree, size); + assert_eq!(restored.scrolls["scenario-canvas"].1, offset); + assert_eq!(restored.scrolls["scenario-details"].1, Vector::new(0., 80.)); + assert_eq!(app.behavior.scenario.scroll_offset(), offset); + assert!(app.behavior.scenario.restore.is_none()); + assert!(std::sync::Arc::ptr_eq( + &before_back, + &app.document.snapshot() + )); + assert_eq!(app.document.revision(), revision); + assert!(app.document.can_undo()); + assert!(!app.document.can_redo()); +} + +#[test] +fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_forms() { + use super::super::direct_tests::{fixture, navigation_event}; + let f = fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let _ = app.update(event(Event::Start(f.owner, Tool::Message))); + let _ = app.update(event(Event::Name(" raw 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + let context = app.scenario_context().unwrap(); + app.busy = true; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + Size::new(500., 200.), + ))); + assert_ne!(app.scenario_context(), Some(context)); + let epoch = app.canvas_epoch; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + Size::new(500., 200.), + ))); + assert_eq!(app.canvas_epoch, epoch); + for mismatch in 0..3 { + let mut stale = context; + match mismatch { + 0 => stale.document = Id::new_v4(), + 1 => stale.scenario = Id::new_v4(), + _ => stale.revision += 1, + } + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + stale, + Size::new(900., 400.), + ))); + assert_eq!(app.canvas_epoch, epoch); + } + app.busy = false; + navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Tools)); + navigation_event(&mut app, CanvasEvent::Focus); + navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None)); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); +} + +#[test] +fn scenario_workspace_choosing_tools_preserves_valid_and_invalid_pending_editors() { + use super::super::direct_tests::{fixture, navigation_event}; + for editor in 0..3 { + for valid in [false, true] { + let f = fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + match editor { + 0 => { + let _ = app.update(Message::Field( + if valid { "/name" } else { "/id" }.into(), + if valid { + " Retained property 雨 " + } else { + "not-a-uuid" + } + .into(), + )); + assert!(app.draft_dirty()); + assert_eq!(app.draft.as_ref().unwrap().command().is_ok(), valid); + } + 1 => { + let raw = if valid { + format!("// Retained source 雨\n{}", app.source_editor.text()) + } else { + "? invalid source 雨".into() + }; + let _ = app.update(Message::SourceEdit( + iced::widget::text_editor::Action::SelectAll, + )); + let _ = app.update(Message::SourceEdit( + iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Paste(std::sync::Arc::new(raw)), + ), + )); + assert!(app.source_dirty()); + assert_eq!( + crate::source_workspace::compile(&app.source_workspace.bundle()).is_ok(), + valid + ); + } + _ => { + let _ = app.update(event(Event::Start( + f.owner, + if valid { Tool::Fragment } else { Tool::Action }, + ))); + let _ = app.update(event(Event::Name(" Retained behavior 雨 ".into()))); + let _ = app.update(event(Event::Expression("ready".into()))); + let _ = app.update(event(Event::SecondGuard("otherwise".into()))); + assert!(app.behavior.has_pending()); + assert_eq!( + app.behavior + .scenario + .draft + .as_ref() + .unwrap() + .command(app.project()) + .is_ok(), + valid + ); + } + } + let source = serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(); + let properties = app + .draft + .as_ref() + .map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()); + let behavior = app.behavior.recovery_snapshot().unwrap(); + let text = app.source_editor.text(); + let accepted = app.document.snapshot(); + let selected = app.selected; + let context = app.scenario_context(); + let tool = app.behavior.scenario.canvas_tool(f.owner); + navigation_event(&mut app, |ctx| { + CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer)) + }); + assert!( + app.status.contains("Apply or cancel"), + "editor={editor} valid={valid}: {}", + app.status + ); + assert_eq!(app.source_editor.text(), text); + assert_eq!( + serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(), + source + ); + assert_eq!( + app.draft + .as_ref() + .map(|draft| serde_json::to_value(draft.recovery_snapshot()).unwrap()), + properties + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior); + assert_eq!(app.selected, selected); + assert_eq!(app.scenario_context(), context); + assert_eq!(app.behavior.scenario.canvas_tool(f.owner), tool); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); + assert!(!app.document.can_redo()); + } + } +} + +#[test] +fn scenario_workspace_whole_app_canvas_tree_survives_pending_shelves_and_focus() { + use iced::advanced::widget::tree; + use std::rc::Rc; + fn path_to(tree: &widget::Tree, tag: tree::Tag) -> Option> { + if tree.tag == tag { + return Some(vec![]); + } + tree.children.iter().enumerate().find_map(|(i, child)| { + let mut path = path_to(child, tag)?; + path.insert(0, i); + Some(path) + }) + } + fn at<'a>(mut tree: &'a mut widget::Tree, path: &[usize]) -> &'a mut widget::Tree { + for &index in path { + tree = &mut tree.children[index]; + } + tree + } + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.call)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let canvas: Element<'_, Message> = + canvas::Canvas::new(Scene::sequence(app.project(), f.owner, f.call)).into(); + let tag = widget::Tree::new(canvas.as_widget()).tag; + drop(canvas); + let mut tree = widget::Tree::new(crate::views::view(&app).as_widget()); + let mut path = path_to(&tree, tag).unwrap(); + path.pop(); + let marker = Rc::new(()); + at(&mut tree, &path).state = tree::State::new(Rc::clone(&marker)); + let _ = app.update(event(Event::Start(f.owner, Tool::Message))); + let _ = app.update(event(Event::Name(" untouched 雨 ".into()))); + let raw = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] { + super::super::direct_tests::navigation_event(&mut app, |ctx| { + CanvasEvent::Shelf(ctx, shelf) + }); + super::super::direct_tests::navigation_event(&mut app, CanvasEvent::Focus); + tree.diff(crate::views::view(&app).as_widget()); + let mut current = path_to(&tree, tag).unwrap(); + current.pop(); + assert_eq!(current, path); + assert_eq!(Rc::strong_count(&marker), 2); + assert!(Rc::ptr_eq( + at(&mut tree, &path).state.downcast_ref::>(), + &marker + )); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert_eq!(app.selected, f.call); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + } +} + +#[test] +fn scenario_workspace_scaled_and_scrolled_scene_keeps_clicked_slots_and_message_endpoints() { + let f = super::super::direct_tests::fixture(); + for scale in [0.25, 0.5, 1., 2.] { + for drag in [false, true] { + let mut scene = Scene::sequence(&f.project, f.owner, f.call); + let context = super::super::direct_tests::context(f.owner); + scene.scale = scale; + scene.scroll_offset = Vector::new(35., 40.); + scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true)); + let slot = scene.slots.last().unwrap(); + let expected = slot.slot; + let bounds = Rectangle::new( + Point::new(20., 80.), + Size::new(scene.size.width * scale, scene.size.height * scale), + ); + let screen = |world: Point| { + Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale) + - scene.scroll_offset + }; + let a = screen(Point::new(scene.lanes[&f.sender], slot.y)); + let b = screen(Point::new(scene.lanes[&f.receiver], slot.y)); + let mut input = super::super::super::SequenceInput::default(); + let mut messages = vec![]; + let mut events = vec![ + (mouse::Event::CursorMoved { position: a }, a), + (mouse::Event::ButtonPressed(mouse::Button::Left), a), + ]; + if !drag { + events.push((mouse::Event::ButtonReleased(mouse::Button::Left), a)); + } + events.push((mouse::Event::CursorMoved { position: b }, b)); + if !drag { + events.push((mouse::Event::ButtonPressed(mouse::Button::Left), b)); + } + events.push((mouse::Event::ButtonReleased(mouse::Button::Left), b)); + for (event, cursor) in events { + if let Some(action) = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(cursor + scene.scroll_offset), + ) { + if let Some(message) = action.into_inner().0 { + messages.push(message); + } + } + } + assert!( + matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Begin { sender, receiver, slot, .. })] + if *sender == f.sender && *receiver == f.receiver && *slot == expected), + "scale={scale} drag={drag}" + ); + } + } +} + +#[test] +fn scenario_workspace_scaled_nested_occurrences_and_reorder_keep_exact_sequence_identity() { + let (f, branch, anchor, _, _, _) = super::super::direct_tests::placement_fixture(); + for scale in [0.25, 0.5, 1., 2.] { + let mut scene = Scene::sequence(&f.project, f.owner, f.call); + let context = super::super::direct_tests::context(f.owner); + scene.scale = scale; + scene.scroll_offset = Vector::new(35., 40.); + let bounds = Rectangle::new( + Point::new(20., 80.), + Size::new(scene.size.width * scale, scene.size.height * scale), + ); + let screen = |world: Point| { + Point::new(bounds.x + world.x * scale, bounds.y + world.y * scale) + - Vector::new(35., 40.) + }; + let send = + |scene: &Scene, input: &mut super::super::super::SequenceInput, event, cursor| { + canvas::Program::update( + scene, + input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(cursor + scene.scroll_offset), + ) + .and_then(|action| action.into_inner().0) + }; + for tool in [Tool::Action, Tool::Observation, Tool::Reuse, Tool::Fragment] { + scene.interaction = Some((context, CanvasTool::Add(tool), true)); + for expected in [ + Slot { + parent: f.owner, + before: Some(f.reply), + }, + Slot { + parent: branch, + before: Some(anchor), + }, + ] { + let slot = scene + .slots + .iter() + .find(|slot| slot.slot == expected) + .unwrap(); + let point = screen(Point::new(scene.lanes[&f.sender], slot.y)); + let mut input = super::super::super::SequenceInput::default(); + send( + &scene, + &mut input, + mouse::Event::CursorMoved { position: point }, + point, + ); + assert!( + send( + &scene, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + point + ) + .is_none() + ); + let message = send( + &scene, + &mut input, + mouse::Event::ButtonReleased(mouse::Button::Left), + point, + ) + .unwrap(); + assert!( + matches!(message, Message::ScenarioCanvas(CanvasEvent::BeginOccurrence { tool: captured, slot, lifeline, .. }) + if captured == tool && slot == expected && lifeline == tool.uses_lifeline().then_some(f.sender)), + "scale={scale} tool={tool:?} slot={expected:?}" + ); + } + } + scene.interaction = Some((context, CanvasTool::Select, true)); + let mut input = super::super::super::SequenceInput::default(); + let expected = scene + .slots + .iter() + .rev() + .find(|slot| slot.slot.parent == f.owner) + .unwrap(); + let start = screen(scene.reorder_handle().unwrap().center()); + let end = screen(Point::new(17., expected.y)); + send( + &scene, + &mut input, + mouse::Event::CursorMoved { position: start }, + start, + ); + send( + &scene, + &mut input, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + ); + send( + &scene, + &mut input, + mouse::Event::CursorMoved { position: end }, + end, + ); + assert!( + matches!(send(&scene, &mut input, mouse::Event::ButtonReleased(mouse::Button::Left), end), + Some(Message::ScenarioCanvas(CanvasEvent::Reorder { occurrence, slot, .. })) + if occurrence == f.call && slot == expected.slot), + "scale={scale}" + ); + } +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index ff6b762e..54608744 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -228,13 +228,15 @@ pub fn view(app: &App) -> View<'_, Message> { Page::Guide => crate::guide::view(&app.guide), }; let process_layout = app.topology_context().is_some(); - let focus_process = process_layout && app.behavior.topology.focused; + let scenario_layout = app.scenario_context().is_some(); + let focus_behavior = (process_layout && app.behavior.topology.focused) + || (scenario_layout && app.behavior.scenario.focused()); let mut body = if app.page == Page::Home { row![container(main).width(Fill).height(Fill)].height(Fill) - } else if process_layout { + } else if process_layout || scenario_layout { // Keep main at the same widget-tree path across Focus toggles. The // collapsed slot has no controls, so it cannot paint over or hit the canvas. - let process_sidebar: View<'_, Message> = if focus_process { + let process_sidebar: View<'_, Message> = if focus_behavior { Space::new().width(0).into() } else { sidebar.into() @@ -242,7 +244,7 @@ pub fn view(app: &App) -> View<'_, Message> { // Row::push would discard the zero-width slot and shift main again. widget::Row::from_vec(vec![ container(process_sidebar) - .width(if focus_process { 0 } else { 260 }) + .width(if focus_behavior { 0 } else { 260 }) .height(Fill) .clip(true) .into(), @@ -253,7 +255,7 @@ pub fn view(app: &App) -> View<'_, Message> { } else { row![sidebar, container(main).width(Fill).height(Fill)].height(Fill) }; - if !focus_process && !matches!(app.page, Page::Home | Page::Text | Page::Guide) { + if !focus_behavior && !matches!(app.page, Page::Home | Page::Text | Page::Guide) { body = body.push(inspector(app)); } let footer = container( diff --git a/harness/release-scenario-workspace-20260910.md b/harness/release-scenario-workspace-20260910.md new file mode 100644 index 00000000..44468041 --- /dev/null +++ b/harness/release-scenario-workspace-20260910.md @@ -0,0 +1,46 @@ +# Scenario workspace — 2026-09-10 + +Owner: **Codex-9e8r6t /root/behavior_audit**. Root assigned the bounded layout slice after [the source audit](release-behavior-gap-audit-20260910.md). Scope is the scenario canvas, compact shelves, focus and independently scrollable form. Existing semantics, source grammar, REST commands and raw-draft recovery remain authoritative. No owner creation, missing-source creation, reuse semantics, state naming or commits/pushes are included. + +## Coordination and current status + +**Implementation in progress after integrated `057eddd` and F1 follow-up `a70de55`.** Root released the Rust source hold after palette_finish's integrated checkpoint compile, then assigned this worker the next sole Cargo lease after release_audit's metadata run. Codex-956atf's clicked-slot Action/Observation/Reuse/Fragment and caller-navigation work is integrated and retained; its separate lifetime projection remains owned on the other host in standalone `scenario_lifecycle.rs`. Root owns real native QA, integration and common ledgers/Wiki. No commits/pushes by this worker. + +## Bounded implementation design + +1. Integrate the other host's clicked-slot work first. Then move the existing scenario view/outline to `behavior/scenario/workspace.rs`, leaving its updated draft/planner/event semantics in `scenario.rs`. Keep the extracted fields/forms behavior unchanged before replacing its outer layout. +2. Use a compact stable header for owner, focus and shelf/camera actions. Tools and Selection are disclosures; place the existing sequence outline inside a scrolling shelf rather than beneath the diagram. Default shelves closed. Active-tool guidance is concise and always visible. +3. Keep a fixed widget-tree slot for the closed shelf (`Column::from_vec`; a zero-height child must not disappear). Allocate header and shelf once, then divide actual remaining height between the canvas and pending editor. Keep both canvas and form slots stable when the form opens/closes. Fields scroll independently; Create/Cancel remain outside the field scroller. No enclosing page scrollable. +4. Extend the existing process focus layout slot in `views.rs` to scenarios without moving the main workspace in the widget tree. Focus must retain selection, canvas state and raw draft. Coordinate any App change with root. +5. Evaluate measured viewport Fit/manual scale against actual Scene geometry. Existing Scene draws with `scale` and transforms pointer positions by that scale. If included, get viewport size from a sensor around the actual canvas scrollable, accept passive viewport changes under document/owner/revision guards, and invalidate old pointer epochs on geometry/scale changes. Use a readable default of 100%; tool changes must not force another scale. A Fit overview must be explicit, and limited overview scale must be disclosed. Do not infer available height from total window size. + +## Planned verification + +- Use the exact generic production pane builders with real Iced layout and scroll operations at minimum/wide center-pane sizes, with short/wrapped headers, open/closed shelves, pending forms and focused widths. Assert all canvas/action/field rectangles lie within the actual parent allocation; assert remaining space grows with the viewport. +- Establish real canvas/form scroll offsets, then diff across shelves, form open/cancel, focus and resize. Verify stable widget state and independent scrolling, not just widget counts. Full App tree-diff tests also preserve selected UUID, raw behavior recovery snapshot and the accepted document Arc. +- If camera controls are included, test default/manual/Fit against the scene bounds and measured viewport; finite inputs only; correct same-size notifications; stale context rejection; production click-click, drag and reorder at multiple scales with nonzero scroll origins. +- Retain existing scenario paired-call/branch/reorder/source/Undo/Redo/recovery tests. New geometry events remain passive during async saves and modal guards; drawing continues to reject pending source/properties/behavior edits. +- Root-owned native before/after task at 1440×900 and 1100×700: draw into a visible ALT operand, open a message/value form, scroll fields, reach Create/Cancel, open/close shelves and Focus, then Save/Undo/Redo/reopen. Actual Windows and Linux acceptance remain separate from unit/build results. + +## Implemented slice + +- Extracted the current scenario form/sequence outline into `behavior/scenario/workspace.rs`. Existing field choices, clicked slots, command planning, operation identity, correlation, raw recovery and ordered occurrence semantics remain in the original scenario module. Scene row/scope projection maps are unchanged for the separately owned lifetime renderer work. +- Replaced window-height subtraction/clamps and the enclosing page scroller with a permanent header/shelf/body tree. The canvas and form split the actual remaining height; opening/cancelling a form does not change the canvas tree path. Create/Cancel remain pinned above independently scrolling form fields. Tools and Selection are compact disclosures, the current tool remains visible, and Back to caller is reachable in the compact header. +- Added scenario presentation-only Focus, shelf and zoom events with exact context checks, plus passive measured viewport messages guarded by document/owner/revision. Default scale is 100%; explicit Fit uses the actual inner Scrollable with scrollbar allowance, with a disclosed 5% lower bound. Manual scale is retained across viewport changes. Nonfinite camera/scroll values are rejected. +- Caller navigation now retains scale/shelf/focus alongside both actual scroll offsets. The old Page scroll channel becomes the independent outline shelf; only the canvas channel contributes to canvas hit coordinates. Existing mounted observer restoration/receipt handling is retained, with a renderer-generic entry point for real Iced layout tests. +- Root's immutable integrated 618-test run independently exposed an existing Back failure after editing a caller reference. Diagnosed two related cases: a retarget edit can select a UUID outside the visible definition, and a rename can preselect the caller UUID so `select` skips restoring its active owner. The narrow Back handler now explicitly restores a valid caller's owner and normalizes a rejected Back's cross-owner selection to the still-visible definition. The existing test's source/history assertions are retained and its active-owner assertion is strengthened. The other host's independent [review #79](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-79) identified the mirrored OpenUse preselection case; it now explicitly establishes the target owner. A dedicated mounted regression exercises both cases and their actual scroll restoration, tool/camera state, unchanged accepted Arc and history. +- Found and fixed an existing tool-selection inconsistency during the layout review: `CanvasEvent::Choose` called `App::apply`, which could commit a valid property draft when merely changing a drawing tool. Root authorized an explicit dirty-editor guard. The Tools shelf now disables its tool control and + Lifeline while source, property or behavior inputs are pending. An actual App-path regression covers valid and invalid drafts from all three editors, checking their validity and exact raw/accepted-state retention. Generic behavior Start handling remains a separately coordinated boundary for the new owner-creation work. + +## Verification in progress + +New `behavior/scenario/workspace/tests.rs` exercises the production pane builders with Iced's null renderer: minimum/wide geometry, pinned actions, canvas/form scroll retention across disclosures and resizing, mounted caller canvas/shelf restoration with camera state, measured Fit bounds and nonfinite rejection, passive/stale viewport events with raw drafts, full App tree identity, and scaled click-click/drag endpoints, root/ALT occurrences and reordering with nonzero scroll offsets. + +The first focused compile reported an ambiguous `column!` import and an invariant Button lifetime introduced by extraction; both were corrected. The second compile passed six of seven tests, exposing an idle shelf allocation that left 282 px instead of the required 290 px canvas in the smallest, tallest-header case. Reducing the idle disclosure from 112 to 100 px resolved this without imposing a synthetic canvas-height clamp. + +The third focused compile passed **8/8 workspace tests, 620 filtered**, after 5m27s compilation and 1.63s test execution. Across the full matrix, real Iced layout establishes at least 290 px idle canvas, 145 px pending canvas and 100 px pending form fields, with pinned actions. The tightest 500×570 / 132 px header / open-shelf allocation calculates to 294 px idle and 150 px pending canvas. These pane tests do not establish whole-window native acceptance. Logs/status: `.local/scenario-workspace-20260910/workspace-tests{,-2,-3}.log` and matching `-status.json`. + +After root integrated the upstream explicit-enum F1 seed repair as `81668f9`, the complete scenario filter passed **52/52**, 579 filtered, in 29.60s after a 3m20s compile. This includes all ten new workspace tests and the unchanged original failing Back assertion with the added active-owner assertion. The history filter then passed **42/42**, 589 filtered, in 25.50s, including both formerly failing exhaustive/UUID-prose cases and the explicit-enum seed regression. Logs and exit-status JSON: `.local/scenario-workspace-20260910/{scenario-tests,history-tests}{.log,-status.json}`. + +Root received an immutable **631-test** executable in `.local/scenario-workspace-20260910/tests-81668f9/arcadia-studio-tests.exe`, 104,007,680 bytes, SHA256 `FF5999466032E4FBDD615969E349720EC6B29A5945F7E3A57CCF976D8A2736E7`. The sibling `libunwind.dll` is 190,976 bytes, SHA256 `F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4`. `manifest.json` records the source HEAD and 252 working-tree input hashes (including clearly marked present but undeclared creation modules), SHA256 `D06C72EBBE9B39A7CE2CCCFF888D2DE7892DFD07EA166FC308155642D37708EE`. Root owns the full run of this immutable artifact while this worker builds the GUI and performs fmt/strict Clippy under the coordinated source freeze. + +Scoped rustfmt and diff checks pass. No current GUI binary or native acceptance is claimed at this checkpoint. Root's earlier immutable artifact had 615 passes and three failures; all three failing cases now pass the focused current gates above.