Wire exact Scenario group selection and contextual admission
This commit is contained in:
@@ -25,6 +25,7 @@ mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_multiselect;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
@@ -1348,6 +1349,34 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
|
||||
{
|
||||
return self.scenario_selection_event(*event);
|
||||
}
|
||||
// A delayed Scenario menu cannot retire the current reveal/gesture even
|
||||
// when it uses the same semantic owner after a representation round trip.
|
||||
if let Message::Menu(crate::model_menu::Event::Open(request)) = &message
|
||||
&& request.scenario.is_some()
|
||||
&& crate::model_menu::Opened::new(self, request.clone(), self.menu_sequence).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::Menu(crate::model_menu::Event::Invoke(token, _)) = &message
|
||||
&& let Some(menu) = &self.model_menu
|
||||
&& menu.request.scenario.is_some()
|
||||
&& (menu.token != *token || !menu.valid(self))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Command(Command::MoveStep(..)) | Message::Delete
|
||||
)
|
||||
{
|
||||
self.status = "Group movement and removal need a whole-selection operation. Select one occurrence to edit it individually.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
@@ -1518,6 +1547,16 @@ impl App {
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
event,
|
||||
behavior::scenario::CanvasEvent::Select(..)
|
||||
| behavior::scenario::CanvasEvent::Remove(..)
|
||||
| behavior::scenario::CanvasEvent::Reorder { .. }
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Passive layout/form reports retain their existing epoch rules;
|
||||
// camera input uses its separate lifetime below. All other actions
|
||||
// must match the exact context before shared reveal reconciliation.
|
||||
@@ -2666,6 +2705,9 @@ impl App {
|
||||
if request.process.is_some() {
|
||||
request.process = self.topology_context().map(Box::new);
|
||||
}
|
||||
if request.scenario.is_some() {
|
||||
request.scenario = self.scenario_context().map(Box::new);
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
@@ -2698,6 +2740,8 @@ impl App {
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(*action, Message::ScenarioCanvas(_)))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ impl App {
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
self.scenario_context(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
@@ -109,6 +110,9 @@ impl App {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
if let Some(current) = self.scenario_context() {
|
||||
return Some(crate::behavior::scenario::selection::context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
@@ -119,6 +123,14 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.scenario_context() {
|
||||
return crate::behavior::scenario::selection::visible(
|
||||
self.project(),
|
||||
context,
|
||||
self.selected,
|
||||
)
|
||||
.contains(target);
|
||||
}
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
@@ -152,6 +164,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
// Scenario input additionally needs its representation and visit.
|
||||
if self.scenario_context().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
@@ -219,6 +235,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.reconcile_scenario_multiselect();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
@@ -284,6 +304,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn inspect_multiselect_primary(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Exact rendered Scenario selection, independent of semantic movement permission.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{self, selection};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_selection_envelope(&self, event: &selection::Event) -> bool {
|
||||
self.scenario_context() == Some(event.context)
|
||||
&& self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(&event.snapshot, selection::context(event.context))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_scenario_multiselect(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
let scope = selection::context(context).scope;
|
||||
let visible = selection::visible(self.project(), context, self.selected);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let legacy = Target::ScenarioObject(self.selected);
|
||||
let held = old.as_ref() == Some(&legacy);
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selected = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(scope));
|
||||
selected.reconcile(scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selected.primary() != old.as_ref() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
} else if selected.primary() != Some(&legacy) {
|
||||
if visible.contains(&legacy) {
|
||||
selected.replace(legacy, |target| visible.contains(target));
|
||||
} else {
|
||||
selected.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_scenario_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
self.selected = match self.diagram_selection.as_ref().and_then(Selection::primary) {
|
||||
Some(Target::ScenarioObject(id)) => *id,
|
||||
_ => context.scenario,
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
/// This is dispatched before shared navigation/gesture retirement. Invalid
|
||||
/// captures and unchanged selection must not disturb a newer input owner.
|
||||
pub(super) fn scenario_selection_event(&mut self, event: selection::Event) -> Task<Message> {
|
||||
use multiselect::Choice;
|
||||
use selection::Intent;
|
||||
if !self.scenario_selection_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
let visible = selection::visible(self.project(), event.context, self.selected);
|
||||
if !event.snapshot.targets().iter().all(|t| visible.contains(t)) {
|
||||
return Task::none();
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.source_rename.open()
|
||||
|| self.behavior.scenario.canvas_tool(event.context.scenario)
|
||||
!= scenario::CanvasTool::Select
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the Scenario selection.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let selected = self.diagram_selection.as_mut().expect("admitted selection");
|
||||
let valid = |target: &Target| visible.contains(target);
|
||||
let changed = match &event.intent {
|
||||
Intent::Choose(target, choice) => {
|
||||
if !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
match choice {
|
||||
Choice::Replace => selected.replace(target.clone(), valid),
|
||||
Choice::Add => selected.add(target.clone(), valid),
|
||||
Choice::Remove => selected.remove(target),
|
||||
Choice::Toggle => selected.toggle(target.clone(), valid),
|
||||
Choice::Inspect => selected.set_primary(target),
|
||||
}
|
||||
}
|
||||
Intent::Collapse(target) => {
|
||||
if !selected.contains(target) || !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
selected.replace(target.clone(), valid)
|
||||
}
|
||||
Intent::Clear => selected.clear(),
|
||||
Intent::Refuse(reason) => {
|
||||
self.status = reason.clone();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.source_interaction();
|
||||
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
||||
scenario::CanvasEvent::Selection(Box::new(event)),
|
||||
));
|
||||
self.behavior.scenario.cancel_spacing();
|
||||
// Every generation change, including a secondary membership ABA,
|
||||
// retires old singleton spacing packets as well as group captures.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn scenario_group_selected(&self) -> bool {
|
||||
self.scenario_context().is_some()
|
||||
&& self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection::Intent, spacing};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Surface;
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn setup(saved: bool) -> (App, Id, Id, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = spacing::controller::tests::fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
if saved {
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
} else {
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
(app, owner, diagram, lane, message)
|
||||
}
|
||||
|
||||
fn capture(app: &App, intent: Intent) -> selection::Event {
|
||||
selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent,
|
||||
}
|
||||
}
|
||||
fn send(app: &mut App, event: selection::Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
|
||||
send(
|
||||
app,
|
||||
capture(app, Intent::Choose(Target::ScenarioObject(id), choice)),
|
||||
);
|
||||
}
|
||||
fn inert(app: &mut App, messages: Vec<Message>, preserve_status: bool) {
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let source = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
let selected = app.multiselect_snapshot();
|
||||
let primary = app.selected;
|
||||
let context = app.scenario_context();
|
||||
let camera = app.camera_boundary();
|
||||
let status = app.status.clone();
|
||||
let spacing = (
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token(),
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
assert_eq!(app.multiselect_snapshot(), selected);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token()
|
||||
),
|
||||
spacing
|
||||
);
|
||||
if preserve_status {
|
||||
assert_eq!(app.status, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn packet(event: selection::Event) -> Message {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_scenario_membership_primary_and_noops_preserve_model_source_and_history() {
|
||||
for saved in [false, true] {
|
||||
let (mut app, owner, diagram, lane, message) = setup(saved);
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.targets(),
|
||||
&[
|
||||
Target::ScenarioObject(message),
|
||||
Target::ScenarioObject(lane)
|
||||
]
|
||||
);
|
||||
assert_eq!(snapshot.primary(), Some(&Target::ScenarioObject(message)));
|
||||
assert_eq!(
|
||||
snapshot.context().scope.surface,
|
||||
if saved {
|
||||
Surface::Saved(diagram)
|
||||
} else {
|
||||
Surface::Scenario(owner)
|
||||
}
|
||||
);
|
||||
assert_eq!(app.selected, message);
|
||||
let repeated = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(message), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(repeated)], true);
|
||||
let stale_collapse = capture(&app, Intent::Collapse(Target::ScenarioObject(lane)));
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
snapshot.primary()
|
||||
);
|
||||
inert(&mut app, vec![packet(stale_collapse)], true);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_representation_round_trip_clears_group_and_rejects_old_packets() {
|
||||
let (mut app, owner, diagram, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let old = capture(&app, Intent::Clear);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let mut second = app.project().diagrams[&diagram].clone();
|
||||
let other = Id::new_v4();
|
||||
second.id = other;
|
||||
second.alias = "other_saved".into();
|
||||
second.name = "Other saved".into();
|
||||
// Navigation itself is tested against a model with both saved identities.
|
||||
let mut project = app.project().clone();
|
||||
project.diagrams.insert(other, second);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "A".into(),
|
||||
}));
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let first = capture(&app, Intent::Clear);
|
||||
let first_visit = first.context.visit;
|
||||
for representation in [
|
||||
Representation::Saved { diagram: other },
|
||||
Representation::Derived { owner },
|
||||
Representation::Saved { diagram },
|
||||
] {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
representation,
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().len() <= 1);
|
||||
}
|
||||
assert_ne!(app.scenario_context().unwrap().visit, first_visit);
|
||||
choose(&mut app, message, multiselect::Choice::Replace);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
inert(&mut app, vec![packet(first), packet(old)], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_bare_generic_packets_are_inert_before_side_effects() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let current = app.multiselect_context().unwrap();
|
||||
let events = [
|
||||
Target::Whole,
|
||||
Target::Placement(message),
|
||||
Target::ScenarioObject(Id::new_v4()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| {
|
||||
packet(capture(
|
||||
&app,
|
||||
Intent::Choose(target, multiselect::Choice::Replace),
|
||||
))
|
||||
})
|
||||
.chain([
|
||||
Message::DiagramSelection(multiselect::Event::Clear(current)),
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
current,
|
||||
Target::ScenarioObject(message),
|
||||
multiselect::Choice::Replace,
|
||||
)),
|
||||
])
|
||||
.collect();
|
||||
inert(&mut app, events, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_rejects_legacy_singleton_remove_reorder_and_index_arrows() {
|
||||
let (mut app, owner, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let context = app.scenario_context().unwrap();
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Select(context, message)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Remove(context, lane)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: message,
|
||||
slot: scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
},
|
||||
}),
|
||||
Message::Command(Command::MoveStep(owner, 0, 1)),
|
||||
Message::Delete,
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let old = spacing::controller::tests::packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let group_begin = spacing::controller::tests::packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(group_begin))],
|
||||
true,
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
|
||||
true,
|
||||
);
|
||||
let current = spacing::controller::tests::packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let spacing::Event::Begin { press_screen, .. } = current else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(old)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
})),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
})),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
spacing::Event::Finish {
|
||||
stamp: current.stamp(),
|
||||
token: current.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
},
|
||||
)));
|
||||
assert!(app.document.can_undo());
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(message)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_primary_edit_is_not_applied_by_selection_input() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending primary name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let event = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(lane), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(event)], false);
|
||||
assert!(app.draft_dirty());
|
||||
assert_ne!(app.project().named(message), "Pending primary name");
|
||||
}
|
||||
@@ -21,6 +21,12 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Saved group motion has no implicit primary-only spacing operation.
|
||||
// Selection generation changes also advance canvas_epoch, rejecting old
|
||||
// singleton packets after adding then removing a secondary member.
|
||||
if self.scenario_group_selected() {
|
||||
return Task::none();
|
||||
}
|
||||
let state = &self.behavior.scenario;
|
||||
let matches =
|
||||
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
|
||||
@@ -80,6 +86,7 @@ impl App {
|
||||
.into()
|
||||
}
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
@@ -1243,6 +1243,7 @@ struct Scene {
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
spacing_stamp: Option<scenario::spacing::Stamp>,
|
||||
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1262,6 +1263,8 @@ struct SequenceSlot {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct SequenceInput {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group_press: Option<ScenarioGroupPress>,
|
||||
context: Option<scenario::Context>,
|
||||
tool: scenario::CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
@@ -1269,6 +1272,13 @@ struct SequenceInput {
|
||||
source: Option<(Id, scenario::Slot)>,
|
||||
press: Option<SequencePress>,
|
||||
}
|
||||
struct ScenarioGroupPress {
|
||||
context: scenario::Context,
|
||||
snapshot: crate::diagram_selection::Snapshot,
|
||||
target: crate::diagram_usage::Target,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Spacing {
|
||||
@@ -1309,11 +1319,48 @@ impl SequenceInput {
|
||||
cancel
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.group_press = None;
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Scene {
|
||||
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
ids.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter(|b| !b.title.is_empty())
|
||||
.map(|b| b.id),
|
||||
);
|
||||
ids.extend(self.lines.iter().map(|line| line.id));
|
||||
ids.extend(self.activations.iter().map(|segment| segment.call));
|
||||
ids.extend(self.render_lanes.keys().copied());
|
||||
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
|
||||
ids.into_iter()
|
||||
.map(crate::diagram_usage::Target::ScenarioObject)
|
||||
.collect()
|
||||
}
|
||||
fn is_selected(&self, id: Id) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.map_or(id == self.selected, |(_, snapshot)| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
|
||||
})
|
||||
}
|
||||
fn is_group(&self) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
|
||||
}
|
||||
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
|
||||
let (context, snapshot) = self.selection.as_ref()?;
|
||||
Some(
|
||||
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
|
||||
)
|
||||
}
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
@@ -1340,6 +1387,7 @@ impl Scene {
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
selection: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
@@ -1982,7 +2030,24 @@ impl canvas::Program<Message> for Scene {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use crate::{app::multiselect::Choice, diagram_usage::Target};
|
||||
use scenario::selection::Intent;
|
||||
use scenario::{CanvasEvent, CanvasTool};
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
if state.group_press.as_ref().is_some_and(|press| {
|
||||
self.selection.as_ref().is_none_or(|(context, snapshot)| {
|
||||
*context != press.context || *snapshot != press.snapshot
|
||||
})
|
||||
}) {
|
||||
state.cancel();
|
||||
}
|
||||
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
@@ -2006,7 +2071,11 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.group_press = None;
|
||||
}
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
@@ -2037,6 +2106,9 @@ impl canvas::Program<Message> for Scene {
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(press) = &mut state.group_press {
|
||||
press.moved |= position.distance(press.start) >= 3.;
|
||||
}
|
||||
if let Some(press) = &mut state.press {
|
||||
match press {
|
||||
SequencePress::Message { start, moved, .. }
|
||||
@@ -2075,6 +2147,9 @@ impl canvas::Program<Message> for Scene {
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.is_group() {
|
||||
return self.selection_action(Intent::Refuse("Removing multiple Scenario objects is being implemented; select one occurrence to remove it".into()));
|
||||
}
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
@@ -2089,7 +2164,10 @@ impl canvas::Program<Message> for Scene {
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
}) if state.source.is_some()
|
||||
|| state.press.is_some()
|
||||
|| state.group_press.is_some() =>
|
||||
{
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
@@ -2103,6 +2181,18 @@ impl canvas::Program<Message> for Scene {
|
||||
)
|
||||
});
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
|
||||
let (context, _) = self.selection.as_ref()?;
|
||||
let hit = self.hit_item(world?)?;
|
||||
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
|
||||
request.scenario = Some(Box::new(*context));
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
|
||||
state.cancel();
|
||||
Some(
|
||||
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
|
||||
.and_capture(),
|
||||
)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
return None;
|
||||
@@ -2112,6 +2202,73 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if self.selection.is_some() {
|
||||
let hit = self.hit_item(point).or_else(|| {
|
||||
(!self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
))
|
||||
.then(|| {
|
||||
self.reorder_handle()
|
||||
.filter(|handle| handle.contains(point))
|
||||
})
|
||||
.flatten()
|
||||
.map(|_| SceneHit {
|
||||
id: self.selected,
|
||||
role: SceneHitRole::Box,
|
||||
})
|
||||
});
|
||||
if tool != CanvasTool::Select && self.is_group() {
|
||||
return self.selection_action(Intent::Refuse(
|
||||
"Select one occurrence before using an authoring tool".into(),
|
||||
));
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(hit) = hit {
|
||||
let target = Target::ScenarioObject(hit.id);
|
||||
if state.modifiers.shift() {
|
||||
state.cancel();
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Toggle));
|
||||
}
|
||||
if self.is_group() && self.is_selected(hit.id) {
|
||||
let (context, snapshot) =
|
||||
self.selection.as_ref().expect("selection capture");
|
||||
state.group_press = Some(ScenarioGroupPress {
|
||||
context: *context,
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: cursor.position()?,
|
||||
moved: false,
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
// Preserve the existing first-press singleton
|
||||
// spacing/reorder gesture. Replacing a displayed
|
||||
// group is an explicit selection change first.
|
||||
if (self.is_group() && !self.is_selected(hit.id))
|
||||
|| !self.coordinate_input
|
||||
{
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Replace));
|
||||
}
|
||||
} else {
|
||||
state.cancel();
|
||||
if state.modifiers.shift() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
let (context, snapshot) = self.selection.as_ref()?;
|
||||
// Background clearing shares this press with camera panning.
|
||||
return Some(Action::publish(scenario::selection::message(
|
||||
*context,
|
||||
snapshot,
|
||||
Intent::Clear,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
@@ -2214,6 +2371,27 @@ impl canvas::Program<Message> for Scene {
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if let Some(press) = state.group_press.take() {
|
||||
if world.is_none() {
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let intent = if press.moved {
|
||||
Intent::Refuse(
|
||||
"Group movement is being implemented; select one occurrence to edit it"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Intent::Collapse(press.target)
|
||||
};
|
||||
return Some(
|
||||
Action::publish(scenario::selection::message(
|
||||
press.context,
|
||||
&press.snapshot,
|
||||
intent,
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
@@ -2365,7 +2543,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if guide.id == self.selected {
|
||||
.with_color(if self.is_selected(guide.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2391,7 +2569,7 @@ impl canvas::Program<Message> for Scene {
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if activation.call == self.selected {
|
||||
.with_color(if self.is_selected(activation.call) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2408,7 +2586,7 @@ impl canvas::Program<Message> for Scene {
|
||||
b.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
);
|
||||
let color = if b.id == self.selected {
|
||||
let color = if self.is_selected(b.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2430,7 +2608,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let color = if l.id == self.selected {
|
||||
let color = if self.is_selected(l.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2486,7 +2664,7 @@ impl canvas::Program<Message> for Scene {
|
||||
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
||||
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
||||
size: 13.into(),
|
||||
color: if b.id == self.selected {
|
||||
color: if self.is_selected(b.id) {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
@@ -2516,6 +2694,19 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if self.selection.is_some() {
|
||||
// Lifecycle glyphs use their exact painted hit identity too.
|
||||
for (id, point) in self.lifecycle.selection_markers() {
|
||||
if self.is_selected(id) {
|
||||
f.stroke(
|
||||
&Path::circle(point, 12.),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(if id == self.selected { 3. } else { 1.5 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
@@ -2542,6 +2733,8 @@ impl canvas::Program<Message> for Scene {
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
)
|
||||
&& !self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_reorder;
|
||||
pub(crate) mod selection;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
@@ -144,6 +145,7 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Selection(Box<selection::Event>),
|
||||
Spacing(spacing::Event),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
@@ -187,6 +189,7 @@ impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Spacing(event) => event.stamp().context,
|
||||
Self::Selection(event) => event.context,
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Captured exact Scenario selection input; membership grants no movement permission.
|
||||
use super::{Context, presentation};
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
diagram_selection,
|
||||
diagram_usage::{Surface, Target},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Event {
|
||||
pub context: Context,
|
||||
pub snapshot: diagram_selection::Snapshot,
|
||||
pub intent: Intent,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Intent {
|
||||
Choose(Target, Choice),
|
||||
Clear,
|
||||
Collapse(Target),
|
||||
Refuse(String),
|
||||
}
|
||||
pub(crate) fn context(context: Context) -> diagram_selection::Context {
|
||||
diagram_selection::Context {
|
||||
scope: diagram_selection::Scope {
|
||||
document: context.document,
|
||||
surface: match context.representation {
|
||||
super::Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
super::Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
},
|
||||
revision: context.revision,
|
||||
epoch: context.epoch,
|
||||
}
|
||||
}
|
||||
pub(crate) fn visible(project: &Project, context: Context, selected: Id) -> Vec<Target> {
|
||||
let shown = presentation::present(project, context.representation, selected);
|
||||
shown
|
||||
.scene()
|
||||
.map_or_else(Vec::new, |scene| scene.selection_targets())
|
||||
}
|
||||
pub(crate) fn message(
|
||||
context: Context,
|
||||
snapshot: &diagram_selection::Snapshot,
|
||||
intent: Intent,
|
||||
) -> crate::app::Message {
|
||||
crate::app::Message::ScenarioCanvas(super::CanvasEvent::Selection(Box::new(Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
Scene, SequenceInput,
|
||||
scenario::{self, CanvasEvent, CanvasTool},
|
||||
};
|
||||
use crate::diagram_selection::Selection;
|
||||
use iced::{
|
||||
Point, Rectangle, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
|
||||
fn fixture(group: bool) -> (scenario::direct_tests::Fixture, Scene, Point) {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let context = scenario::direct_tests::context(f.owner);
|
||||
let mut scene = Scene::sequence(&f.project, f.owner, f.reply);
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let mut selection = Selection::new(super::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
if group {
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
}
|
||||
scene.selection = Some((
|
||||
context,
|
||||
selection.snapshot(super::context(context)).unwrap(),
|
||||
));
|
||||
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
||||
let point = line.label_at + Vector::new(8., 8.);
|
||||
assert_eq!(scene.hit(point), Some(f.call));
|
||||
(f, scene, point)
|
||||
}
|
||||
fn send(
|
||||
scene: &Scene,
|
||||
state: &mut SequenceInput,
|
||||
event: canvas::Event,
|
||||
point: Option<Point>,
|
||||
) -> Option<CanvasEvent> {
|
||||
scene
|
||||
.update(
|
||||
state,
|
||||
&event,
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
.and_then(|message| match message {
|
||||
crate::app::Message::ScenarioCanvas(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn mouse_event(event: mouse::Event) -> canvas::Event {
|
||||
canvas::Event::Mouse(event)
|
||||
}
|
||||
#[test]
|
||||
fn group_press_retains_snapshot_and_collapses_only_stationary_release_once() {
|
||||
let (f, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
let captured = state.group_press.as_ref().unwrap().snapshot.clone();
|
||||
assert_eq!(captured.primary(), Some(&Target::ScenarioObject(f.reply)));
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("captured collapse")
|
||||
};
|
||||
assert_eq!(event.snapshot, captured);
|
||||
assert!(matches!(event.intent, Intent::Collapse(Target::ScenarioObject(id)) if id == f.call));
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn crossed_threshold_return_to_origin_refuses_whole_set_and_outside_release_cancels() {
|
||||
for outside in [false, true] {
|
||||
let (_, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let moved = point + Vector::new(12., 0.);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: moved }),
|
||||
Some(moved),
|
||||
);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: point }),
|
||||
Some(point),
|
||||
);
|
||||
let event = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
(!outside).then_some(point),
|
||||
);
|
||||
if outside {
|
||||
assert!(event.is_none());
|
||||
} else {
|
||||
let Some(CanvasEvent::Selection(event)) = event else {
|
||||
panic!("whole-set refusal")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Refuse(_)));
|
||||
assert_eq!(event.snapshot.targets().len(), 2);
|
||||
}
|
||||
assert!(state.group_press.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn passive_shift_survives_context_refresh_and_window_loss_clears_it() {
|
||||
let (f, mut scene, point) = fixture(false);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("toggle")
|
||||
};
|
||||
assert!(
|
||||
matches!(event.intent, Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if id == f.call)
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
scene.interaction.as_mut().unwrap().0.epoch += 1;
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
None,
|
||||
);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn stale_selection_and_fallback_release_do_not_move_primary() {
|
||||
let (_, mut scene, point) = fixture(true);
|
||||
scene.coordinate_input = false;
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("fallback stationary selection remains usable")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Collapse(_)));
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
scene.selection.as_mut().unwrap().0.visit = Id::new_v4();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.group_press.is_none());
|
||||
}
|
||||
@@ -75,6 +75,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// Resolve once per accepted view. An active spacing session owns its exact
|
||||
@@ -138,6 +139,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.selection = snapshot.clone().map(|snapshot| (context, snapshot));
|
||||
scene.spacing_stamp = (coordinate_input
|
||||
&& !app.source_rename.open()
|
||||
&& keyboard
|
||||
@@ -367,7 +369,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
sequence_rows(p, context, owner, details, clean, snapshot.as_ref()).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
@@ -908,7 +910,24 @@ fn sequence_rows<'a>(
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
enabled: bool,
|
||||
snapshot: Option<&crate::diagram_selection::Snapshot>,
|
||||
) -> Column<'a, Message> {
|
||||
let choose = |id| {
|
||||
snapshot.map_or_else(
|
||||
|| Message::ScenarioCanvas(CanvasEvent::Select(context, id)),
|
||||
|snapshot| {
|
||||
selection::message(
|
||||
context,
|
||||
snapshot,
|
||||
selection::Intent::Choose(
|
||||
crate::diagram_usage::Target::ScenarioObject(id),
|
||||
crate::app::multiselect::Choice::Replace,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
let order_enabled = enabled && snapshot.is_none_or(|snapshot| snapshot.targets().len() <= 1);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
@@ -917,10 +936,10 @@ fn sequence_rows<'a>(
|
||||
for branch in branches {
|
||||
out = out.push(sequence_action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
Message::Select(*branch),
|
||||
choose(*branch),
|
||||
enabled,
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out, enabled);
|
||||
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
@@ -929,17 +948,17 @@ fn sequence_rows<'a>(
|
||||
row![
|
||||
sequence_action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
Message::Select(*id),
|
||||
choose(*id),
|
||||
enabled
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
||||
.on_press_maybe((order_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())
|
||||
(order_enabled && i + 1 < steps.len())
|
||||
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
||||
)
|
||||
]
|
||||
@@ -960,7 +979,7 @@ fn sequence_rows<'a>(
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out, enabled);
|
||||
out = sequence_rows(p, context, *id, out, enabled, snapshot);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -234,6 +234,13 @@ impl Rendering {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Exact painted marker subjects and centers for shared selection adapters.
|
||||
pub(in crate::behavior) fn selection_markers(&self) -> impl Iterator<Item = (Id, Point)> + '_ {
|
||||
self.markers
|
||||
.iter()
|
||||
.map(|marker| (marker.event.occurrence, marker.center))
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
||||
self.markers
|
||||
.iter()
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct Request {
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub process: Option<Box<crate::behavior::topology::Context>>,
|
||||
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
@@ -34,6 +35,7 @@ impl Request {
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
scenario: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -55,6 +57,7 @@ pub struct Opened {
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
scenario_selection: Option<crate::diagram_selection::Snapshot>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
@@ -67,9 +70,18 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.topology_context())
|
||||
|| request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.scenario_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scenario_selection = if request.scenario.is_some() {
|
||||
Some(app.multiselect_snapshot()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
@@ -79,6 +91,7 @@ impl Opened {
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
scenario_selection,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
@@ -99,6 +112,15 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.topology_context())
|
||||
&& self
|
||||
.request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.scenario_context())
|
||||
&& self
|
||||
.scenario_selection
|
||||
.as_ref()
|
||||
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
@@ -109,9 +131,19 @@ impl Opened {
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
if request.scenario.is_some()
|
||||
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if let Some(context) = request.scenario.as_deref() {
|
||||
return Some(*context) == app.scenario_context()
|
||||
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
|
||||
&& app.multiselect_target_visible(target);
|
||||
}
|
||||
if (request.canvas.is_none() && request.process.is_none())
|
||||
|| !app.multiselect_target_visible(target)
|
||||
{
|
||||
@@ -159,6 +191,17 @@ fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| {
|
||||
app.scenario_context()
|
||||
.map(|context| match context.representation {
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner } => {
|
||||
owner
|
||||
}
|
||||
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
|
||||
diagram
|
||||
}
|
||||
})
|
||||
})
|
||||
.or_else(|| app.state_context().map(|context| context.diagram))
|
||||
} else {
|
||||
app.active_diagram
|
||||
@@ -172,49 +215,56 @@ pub fn open(object: Id, anchor: Point) -> Message {
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn actions_request(app: &App, anchor: Point) -> Request {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
crate::diagram_usage::Target::ScenarioObject(id) => {
|
||||
request.object = *id;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
@@ -261,6 +311,78 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if let Some(context) = request.scenario.as_deref().copied() {
|
||||
use crate::app::multiselect::Choice;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection};
|
||||
let snapshot = opened.scenario_selection.as_ref()?;
|
||||
let selection_message = |intent| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
};
|
||||
if let Some(target) = request.selection_target.as_deref() {
|
||||
let selected = snapshot.targets().contains(target);
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if snapshot.targets().len() > 1 {
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
// A menu on an unselected subject must not expose singleton model actions
|
||||
// while the canvas still depicts a group. Selection changes are explicit.
|
||||
if snapshot.targets().len() > 1 {
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(Entry::disabled(
|
||||
"Move selected occurrences (not yet available)",
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Remove selected occurrences (not yet available)",
|
||||
));
|
||||
entries.push(Entry::title(
|
||||
"Select one occurrence for its individual actions",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (request.canvas.is_some() || request.process.is_some())
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
@@ -798,6 +920,10 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_menu_scenario_tests.rs"]
|
||||
mod scenario_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
behavior::scenario::{CanvasEvent, Context, selection},
|
||||
behavior::scenario_layout::Representation,
|
||||
diagram_usage::Target,
|
||||
};
|
||||
|
||||
fn saved(project: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("scenario_menu_view_{}", project.diagrams.len()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
nodes: Default::default(),
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture() -> (App, [Id; 3], Id, [Id; 2]) {
|
||||
let (mut project, [owner, _, _], [first, second, hidden]) =
|
||||
crate::behavior::scenario::direct_tests::navigation_fixture();
|
||||
let mut third = project.elements[&first].clone();
|
||||
third.id = Id::new_v4();
|
||||
third.alias = "third_menu_reference".into();
|
||||
third.name = "Third reference".into();
|
||||
let third_id = third.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(third)]).unwrap();
|
||||
let views = [
|
||||
saved(&mut project, owner, "A"),
|
||||
saved(&mut project, owner, "B"),
|
||||
];
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(owner));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
(app, [first, second, third_id], hidden, views)
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id, choice: Choice) {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event))),
|
||||
);
|
||||
}
|
||||
|
||||
fn request(app: &App, id: Id) -> Request {
|
||||
let mut request = Request::object(id, Point::new(120., 80.));
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(id)));
|
||||
request
|
||||
}
|
||||
|
||||
fn action(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled Scenario menu action {caption}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_requests_require_exact_visible_target_and_complete_exclusive_context() {
|
||||
let (mut app, [first, second, _], hidden, [view, _]) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let valid = request(&app, first);
|
||||
assert!(Opened::new(&app, valid.clone(), 1).is_some());
|
||||
let current = app.scenario_context().unwrap();
|
||||
for captured in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
scenario: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
representation: Representation::Saved { diagram: view },
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
visit: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
let mut stale = valid.clone();
|
||||
stale.scenario = Some(Box::new(captured));
|
||||
assert!(Opened::new(&app, stale, 2).is_none());
|
||||
}
|
||||
let mut wrong = valid.clone();
|
||||
wrong.object = second;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
assert!(app.project().contains(hidden));
|
||||
assert!(!app.multiselect_target_visible(&Target::ScenarioObject(hidden)));
|
||||
assert!(Opened::new(&app, request(&app, hidden), 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.placement = Some(Id::new_v4());
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.canvas = Some(route_edit::Context {
|
||||
document: current.document,
|
||||
diagram: view,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
});
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.process = Some(Box::new(crate::behavior::topology::Context {
|
||||
document: current.document,
|
||||
owner: current.scenario,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
}));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.selection_target = Some(Box::new(Target::Edge(first)));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid;
|
||||
wrong.scenario = None;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_rejects_saved_visit_roundtrip_even_with_equal_generic_snapshot() {
|
||||
let (mut app, [first, second, _], _, [a, b]) = fixture();
|
||||
let switch = |app: &mut App, diagram| {
|
||||
let context = app.scenario_context().unwrap();
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Saved { diagram },
|
||||
)),
|
||||
);
|
||||
};
|
||||
switch(&mut app, a);
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let opened = Opened::new(&app, request(&app, first), 1).unwrap();
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.diagram_selection.take().unwrap();
|
||||
switch(&mut app, b);
|
||||
assert!(!opened.valid(&app));
|
||||
switch(&mut app, a);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, old_context.visit);
|
||||
// Restore the same shared selection and epoch deliberately. Only the full
|
||||
// Scenario visit may distinguish the old request from this returned view.
|
||||
app.canvas_epoch = old_context.epoch;
|
||||
app.diagram_selection = Some(selected);
|
||||
app.selected = second;
|
||||
assert_eq!(app.multiselect_snapshot(), opened.scenario_selection);
|
||||
assert!(!opened.valid(&app));
|
||||
assert!(Opened::new(&app, opened.request.clone(), 2).is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_menus_capture_set_and_never_expose_partial_model_actions() {
|
||||
let (mut app, [first, second, third], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let before = app.document.snapshot();
|
||||
let capture = actions_request(&app, Point::ORIGIN);
|
||||
assert_eq!(capture.object, second);
|
||||
assert_eq!(
|
||||
capture.selection_target.as_deref(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
assert_eq!(capture.scenario.as_deref().copied(), app.scenario_context());
|
||||
assert!(capture.canvas.is_none() && capture.process.is_none() && capture.placement.is_none());
|
||||
|
||||
for target in [first, third] {
|
||||
let captured_request = request(&app, target);
|
||||
send(&mut app, Message::Menu(Event::Open(captured_request)));
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let menu = popup(&app).unwrap();
|
||||
assert!(
|
||||
menu.entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, Entry::Title(label) if label == "2 selected"))
|
||||
);
|
||||
let mut enabled = 0;
|
||||
for entry in menu.entries {
|
||||
match entry {
|
||||
Entry::Action {
|
||||
label,
|
||||
message: Some(Message::Menu(Event::Invoke(_, message))),
|
||||
..
|
||||
} => {
|
||||
let Message::ScenarioCanvas(CanvasEvent::Selection(event)) = *message else {
|
||||
panic!("Scenario group menu exposed a singleton model action");
|
||||
};
|
||||
assert_eq!(event.context, app.scenario_context().unwrap());
|
||||
assert_eq!(event.snapshot, snapshot);
|
||||
let selection::Intent::Choose(actual_target, choice) = event.intent else {
|
||||
panic!("Unexpected group selection intent");
|
||||
};
|
||||
assert_eq!(actual_target, Target::ScenarioObject(target));
|
||||
assert!(matches!(
|
||||
(label.as_str(), choice),
|
||||
("Add to selection", Choice::Add)
|
||||
| ("Remove from selection", Choice::Remove)
|
||||
| ("Inspect this element", Choice::Inspect)
|
||||
| ("Select only this element", Choice::Replace)
|
||||
));
|
||||
enabled += 1;
|
||||
}
|
||||
Entry::Action {
|
||||
label,
|
||||
message: None,
|
||||
..
|
||||
} => {
|
||||
assert!(!label.contains("Hide"));
|
||||
}
|
||||
Entry::Action {
|
||||
message: Some(_), ..
|
||||
} => panic!("Uncaptured menu action"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(enabled, if target == first { 3 } else { 2 });
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(app.selected, second);
|
||||
}
|
||||
let selected_request = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(selected_request)));
|
||||
let inspect = action(&app, "Inspect this element");
|
||||
send(&mut app, inspect);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
let remove_request = request(&app, second);
|
||||
send(&mut app, Message::Menu(Event::Open(remove_request)));
|
||||
let remove = action(&app, "Remove from selection");
|
||||
send(&mut app, remove);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_snapshot_rejects_selection_aba_and_singleton_actions_stay_available() {
|
||||
let (mut app, [first, second, _], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let captured = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(captured)));
|
||||
let _ = action(&app, "Properties");
|
||||
let _ = action(&app, "Delete from model…");
|
||||
let opened = app.model_menu.take().unwrap();
|
||||
let context = app.scenario_context();
|
||||
// Isolate generation admission from the adapter's additional epoch defense.
|
||||
let selected = app.diagram_selection.as_mut().unwrap();
|
||||
selected.add(Target::ScenarioObject(second), |_| true);
|
||||
selected.remove(&Target::ScenarioObject(second));
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert_eq!(app.selected, first);
|
||||
assert!(!opened.valid(&app));
|
||||
}
|
||||
Reference in New Issue
Block a user