Keep scenario authoring visible and restore exact caller context
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -29,12 +29,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<scenario::ScrollRestore>,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
content: impl Into<Element<'a, Message, Theme, R>>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
Element::new(Observer {
|
||||
content: content.into(),
|
||||
context,
|
||||
@@ -919,20 +919,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);
|
||||
|
||||
@@ -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<f32>),
|
||||
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<Caller>,
|
||||
restore: Option<ViewportRestore>,
|
||||
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<f32>) -> 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<Choice> {
|
||||
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::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
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 {
|
||||
|
||||
@@ -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<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
|| !size.height.is_finite()
|
||||
|| size.width <= 0.
|
||||
|| size.height <= 0.
|
||||
|| self.viewport == Some(size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.viewport = Some(size);
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// 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::<Vec<_>>()
|
||||
.join(" → "),
|
||||
p.named(key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
text_input("Value expression", expression)
|
||||
.on_input({
|
||||
let key = key.clone();
|
||||
move |raw| event(Event::Value(key.clone(), raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||
}
|
||||
}
|
||||
if matches!(d.tool, Tool::Action | Tool::Observation) {
|
||||
form = form.push(picker(
|
||||
lines(p, owner),
|
||||
d.sender,
|
||||
"Observed lifeline…",
|
||||
Event::Sender,
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Reuse
|
||||
&& let Some(target) = d.target
|
||||
{
|
||||
for line in lines(p, target) {
|
||||
let referenced = line.id;
|
||||
let compatible: Vec<_> = lines(p, owner)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
compatible_binding(p, referenced, c.id)
|
||||
&& !d
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|(r, l)| *r != referenced && *l == c.id)
|
||||
})
|
||||
.collect();
|
||||
if compatible.is_empty() {
|
||||
form = form.push(label(format!(
|
||||
"Add a compatible local lifeline before binding '{}'.",
|
||||
p.named(referenced)
|
||||
)));
|
||||
}
|
||||
form = form
|
||||
.push(label(format!("Bind {}", p.named(referenced))))
|
||||
.push(picker(
|
||||
compatible,
|
||||
d.bindings.get(&referenced).copied(),
|
||||
"Compatible local lifeline…",
|
||||
move |c| Event::Binding(referenced, c),
|
||||
));
|
||||
}
|
||||
form = form.push(label(
|
||||
"Bindings are explicit, one-to-one and refer to existing lifelines.",
|
||||
));
|
||||
}
|
||||
if d.tool == Tool::Fragment {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
vec![
|
||||
Operator(FragmentOperator::Alternative),
|
||||
Operator(FragmentOperator::Parallel),
|
||||
Operator(FragmentOperator::Loop),
|
||||
Operator(FragmentOperator::Optional),
|
||||
],
|
||||
Some(Operator(d.operator)),
|
||||
|o| event(Event::Operator(o)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.operator != FragmentOperator::Parallel {
|
||||
form = form.push(
|
||||
text_input("Condition / loop guard", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
if d.operator == FragmentOperator::Alternative {
|
||||
form = form.push(
|
||||
text_input("Second alternative guard", &d.second_guard)
|
||||
.on_input(|s| event(Event::SecondGuard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions."));
|
||||
}
|
||||
let readiness = d.command(p);
|
||||
if let Err(error) = &readiness {
|
||||
form = form.push(label(error.clone()));
|
||||
}
|
||||
let footer = row![
|
||||
button("Create")
|
||||
.padding(9)
|
||||
.on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7);
|
||||
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<ScrollRestore>,
|
||||
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<ScrollRestore>,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
use super::*;
|
||||
use iced::Vector;
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
scrolls: BTreeMap<String, (Rectangle, Vector)>,
|
||||
actions: Option<Rectangle>,
|
||||
}
|
||||
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<Vec<usize>> {
|
||||
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::<Rc<()>>(),
|
||||
&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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -4,7 +4,7 @@ Owner: **Codex-9e8r6t /root/behavior_audit**. Root assigned the bounded layout s
|
||||
|
||||
## Coordination and current status
|
||||
|
||||
**Design only; production remains unchanged.** Palette_finish released its source freeze after desktop588/build/fmt/Clippy and returned Cargo to root. Separately, Codex-956atf owns the clicked-slot Action/Observation/Reuse/Fragment work on another host. Root is coordinating its patch before this layout starts; this report does not supersede that ownership. Do not launch Cargo without root's sole-writer lease. Root owns real native QA and common ledgers/Wiki.
|
||||
**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
|
||||
|
||||
@@ -22,6 +22,25 @@ Owner: **Codex-9e8r6t /root/behavior_audit**. Root assigned the bounded layout s
|
||||
- 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.
|
||||
|
||||
## Evidence
|
||||
## Implemented slice
|
||||
|
||||
No implementation checks or native acceptance have run for this slice yet. Palette588 is an upstream snapshot gate, not a scenario-workspace test pass. Append exact edits, commands, results, binary identity and unresolved acceptance after implementation.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user