From 879eb1d5c8792a720433022e49f415d2c58ff72c Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 00:24:08 -0700 Subject: [PATCH] Wire exact Scenario group selection and contextual admission --- crates/oa-desktop/src/app.rs | 44 +++ crates/oa-desktop/src/app/multiselect.rs | 24 ++ .../src/app/scenario_multiselect.rs | 136 +++++++ .../src/app/scenario_multiselect/tests.rs | 333 ++++++++++++++++++ crates/oa-desktop/src/app/scenario_spacing.rs | 7 + crates/oa-desktop/src/behavior.rs | 205 ++++++++++- crates/oa-desktop/src/behavior/scenario.rs | 3 + .../src/behavior/scenario/selection.rs | 54 +++ .../src/behavior/scenario/selection/tests.rs | 204 +++++++++++ .../src/behavior/scenario/workspace.rs | 33 +- .../src/behavior/scenario_lifecycle/render.rs | 7 + crates/oa-desktop/src/model_menu.rs | 204 +++++++++-- .../src/model_menu_scenario_tests.rs | 303 ++++++++++++++++ 13 files changed, 1505 insertions(+), 52 deletions(-) create mode 100644 crates/oa-desktop/src/app/scenario_multiselect.rs create mode 100644 crates/oa-desktop/src/app/scenario_multiselect/tests.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/selection.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/selection/tests.rs create mode 100644 crates/oa-desktop/src/model_menu_scenario_tests.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 2e65a5be..3bd5f097 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -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 { + 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); } diff --git a/crates/oa-desktop/src/app/multiselect.rs b/crates/oa-desktop/src/app/multiselect.rs index a4bbacf1..b5f6a241 100644 --- a/crates/oa-desktop/src/app/multiselect.rs +++ b/crates/oa-desktop/src/app/multiselect.rs @@ -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 { @@ -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; diff --git a/crates/oa-desktop/src/app/scenario_multiselect.rs b/crates/oa-desktop/src/app/scenario_multiselect.rs new file mode 100644 index 00000000..dbc0ce9f --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_multiselect.rs @@ -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 { + 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) + } +} diff --git a/crates/oa-desktop/src/app/scenario_multiselect/tests.rs b/crates/oa-desktop/src/app/scenario_multiselect/tests.rs new file mode 100644 index 00000000..6b9e932b --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_multiselect/tests.rs @@ -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, 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"); +} diff --git a/crates/oa-desktop/src/app/scenario_spacing.rs b/crates/oa-desktop/src/app/scenario_spacing.rs index 4a3840e6..1b4ef565 100644 --- a/crates/oa-desktop/src/app/scenario_spacing.rs +++ b/crates/oa-desktop/src/app/scenario_spacing.rs @@ -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!(), } diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 29f3225d..8d4004c0 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -1243,6 +1243,7 @@ struct Scene { camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, spacing_stamp: Option, + selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>, coordinate_input: bool, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, @@ -1262,6 +1263,8 @@ struct SequenceSlot { } #[derive(Default)] struct SequenceInput { + modifiers: iced::keyboard::Modifiers, + group_press: Option, context: Option, tool: scenario::CanvasTool, pointer: Option, @@ -1269,6 +1272,13 @@ struct SequenceInput { source: Option<(Id, scenario::Slot)>, press: Option, } +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 { + 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> { + 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 for Scene { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 56dab6a6..d310fd31 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -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), 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, _) diff --git a/crates/oa-desktop/src/behavior/scenario/selection.rs b/crates/oa-desktop/src/behavior/scenario/selection.rs new file mode 100644 index 00000000..6ea7ac63 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/selection.rs @@ -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 { + 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; diff --git a/crates/oa-desktop/src/behavior/scenario/selection/tests.rs b/crates/oa-desktop/src/behavior/scenario/selection/tests.rs new file mode 100644 index 00000000..1e25ddd4 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/selection/tests.rs @@ -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, +) -> Option { + 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()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 5d5ee058..f0b853a0 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -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 diff --git a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs index 24b54876..203b5803 100644 --- a/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs +++ b/crates/oa-desktop/src/behavior/scenario_lifecycle/render.rs @@ -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 + '_ { + self.markers + .iter() + .map(|marker| (marker.event.occurrence, marker.center)) + } + pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option { self.markers .iter() diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 054b71b2..30a7fc99 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -19,6 +19,7 @@ pub struct Request { pub creation_point: Option, pub canvas: Option, pub process: Option>, + pub scenario: Option>, pub source_file: Option, } 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, + scenario_selection: Option, } impl Opened { pub fn new(app: &App, request: Request, token: u64) -> Option { @@ -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 { 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> { 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> { }); } 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::*; diff --git a/crates/oa-desktop/src/model_menu_scenario_tests.rs b/crates/oa-desktop/src/model_menu_scenario_tests.rs new file mode 100644 index 00000000..3a5b6f45 --- /dev/null +++ b/crates/oa-desktop/src/model_menu_scenario_tests.rs @@ -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)); +}