Keep scenario authoring visible and restore exact caller context

This commit is contained in:
2026-09-10 12:03:17 -07:00
parent 81668f94ed
commit 3978bbe23c
7 changed files with 1720 additions and 511 deletions
+50 -2
View File
@@ -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);
+10 -9
View File
@@ -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);
+61 -492
View File
@@ -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}"
);
}
}
+7 -5
View File
@@ -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(