From 3ab63ef028b1817924635571fbc5e8072558d2fc Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 24 Sep 2026 22:12:36 -0700 Subject: [PATCH] Wire captured saved-model group deletion and shared impact review --- crates/oa-desktop/src/allocation_input.rs | 1 + crates/oa-desktop/src/app.rs | 51 +- .../src/app/allocation_drag_tests.rs | 1 + .../src/app/architecture_authoring/tests.rs | 1 + .../src/app/architecture_authoring_tests.rs | 2 + crates/oa-desktop/src/app/diagram_removal.rs | 2 + crates/oa-desktop/src/app/escape.rs | 9 +- crates/oa-desktop/src/app/process_removal.rs | 1 + .../src/app/process_removal/view.rs | 169 +--- crates/oa-desktop/src/app/removal_review.rs | 148 ++++ crates/oa-desktop/src/app/saved_removal.rs | 279 +++++++ .../oa-desktop/src/app/saved_removal/tests.rs | 781 ++++++++++++++++++ .../oa-desktop/src/app/saved_removal/view.rs | 145 ++++ crates/oa-desktop/src/app/scenario_removal.rs | 1 + crates/oa-desktop/src/app/state_diagram.rs | 1 + .../oa-desktop/src/behavior/state_canvas.rs | 68 +- crates/oa-desktop/src/behavior/state_edit.rs | 5 +- crates/oa-desktop/src/behavior/state_view.rs | 15 +- crates/oa-desktop/src/behavior/topology.rs | 2 +- .../src/behavior/topology/group_removal.rs | 188 +---- crates/oa-desktop/src/diagram.rs | 75 +- .../src/diagram/allocation_group_tests.rs | 1 + .../src/diagram/attachment_tests.rs | 1 + .../oa-desktop/src/diagram/group_removal.rs | 9 + .../src/diagram/group_removal/tests.rs | 262 ++++++ .../oa-desktop/src/diagram/lifecycle_tests.rs | 1 + crates/oa-desktop/src/diagram/multiselect.rs | 17 +- .../src/diagram/multiselect_tests.rs | 3 + .../oa-desktop/src/diagram/pointer_tests.rs | 1 + .../oa-desktop/src/diagram/port_hit_tests.rs | 17 +- crates/oa-desktop/src/diagram/port_labels.rs | 1 + crates/oa-desktop/src/main.rs | 2 + crates/oa-desktop/src/model_menu.rs | 16 +- crates/oa-desktop/src/removal_effects.rs | 230 ++++++ .../{behavior/topology => }/removal_input.rs | 42 +- crates/oa-desktop/src/views.rs | 22 +- 36 files changed, 2205 insertions(+), 365 deletions(-) create mode 100644 crates/oa-desktop/src/app/removal_review.rs create mode 100644 crates/oa-desktop/src/app/saved_removal.rs create mode 100644 crates/oa-desktop/src/app/saved_removal/tests.rs create mode 100644 crates/oa-desktop/src/app/saved_removal/view.rs create mode 100644 crates/oa-desktop/src/removal_effects.rs rename crates/oa-desktop/src/{behavior/topology => }/removal_input.rs (63%) diff --git a/crates/oa-desktop/src/allocation_input.rs b/crates/oa-desktop/src/allocation_input.rs index 21442bd5..5e9578dd 100644 --- a/crates/oa-desktop/src/allocation_input.rs +++ b/crates/oa-desktop/src/allocation_input.rs @@ -60,6 +60,7 @@ impl Root<'_> { return false; } let canvas = DiagramCanvas { + removal_input: &self.app.saved_removal_input, selection: self.app.diagram_selection.as_ref(), allocation: Some(self.app.allocation_capability()), project: self.app.project(), diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 3e462aed..bc8853a1 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -21,8 +21,10 @@ pub(crate) mod multiselect; mod multiselect_tests; pub(crate) mod process_removal; mod reference_authoring; +mod removal_review; #[cfg(test)] mod review_navigation_tests; +pub(crate) mod saved_removal; #[cfg(test)] mod scenario_details_tests; mod scenario_diagram; @@ -447,6 +449,7 @@ pub enum Message { ScenarioReorder(Box), ScenarioRemoval(Box), ProcessRemoval(Box), + SavedRemoval(Box), TopologyCanvas(crate::behavior::topology::CanvasEvent), PlaceSelected, HideSelected, @@ -540,6 +543,7 @@ pub struct App { pub selected: Id, pub selected_node: Option, pub diagram_selection: Option, + pub(crate) saved_removal_input: crate::removal_input::Lifetime, pub draft: Option, pub active_diagram: Option, pub search: String, @@ -663,6 +667,7 @@ impl App { selected, selected_node: None, diagram_selection: None, + saved_removal_input: Default::default(), draft, active_diagram, search: String::new(), @@ -1363,13 +1368,18 @@ impl App { // Group dialogs only accept their stamped Cancel button. A delayed // singleton dialog packet cannot close a later aggregate review. if matches!(message, Message::CancelDelete) - && self - .delete_target - .as_ref() - .is_some_and(|target| target.scenario.is_some() || target.process.is_some()) + && self.delete_target.as_ref().is_some_and(|target| { + target.scenario.is_some() || target.process.is_some() || target.saved.is_some() + }) { return Task::none(); } + if let Message::SavedRemoval(event) = message { + return self.saved_removal_event(*event); + } + if let Message::StateDiagram(behavior::state_edit::Event::ModelRemoval(event)) = message { + return self.saved_removal_event(*event); + } if let Message::ProcessRemoval(event) = message { return self.process_removal_event(*event); } @@ -1378,7 +1388,7 @@ impl App { && self .delete_target .as_ref() - .is_some_and(|target| target.process.is_some()) + .is_some_and(|target| target.process.is_some() || target.saved.is_some()) { return Task::none(); } @@ -1393,6 +1403,14 @@ impl App { { return Task::none(); } + if self + .delete_target + .as_ref() + .is_some_and(|target| target.saved.is_some()) + { + self.confirm_saved_removal(*token); + return Task::none(); + } if self .delete_target .as_ref() @@ -1422,14 +1440,16 @@ impl App { // 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() || request.process.is_some()) + && (request.scenario.is_some() || request.process.is_some() || request.canvas.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.request.process.is_some()) + && (menu.request.scenario.is_some() + || menu.request.process.is_some() + || menu.request.canvas.is_some()) && (menu.token != *token || !menu.valid(self)) { return Task::none(); @@ -2294,6 +2314,7 @@ impl App { Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event), Message::ScenarioRemoval(event) => return self.scenario_removal_event(*event), Message::ProcessRemoval(event) => return self.process_removal_event(*event), + Message::SavedRemoval(event) => return self.saved_removal_event(*event), Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); @@ -2834,12 +2855,14 @@ impl App { "Apply or revert pending edits before using modeling actions.".into(); return Task::none(); } - if matches!(*action, Message::DiagramSelection(_)) - || (menu.request.process.is_some() - && matches!( - *action, - Message::TopologyCanvas(_) | Message::ProcessRemoval(_) - )) + if matches!( + *action, + Message::DiagramSelection(_) | Message::SavedRemoval(_) + ) || (menu.request.process.is_some() + && matches!( + *action, + Message::TopologyCanvas(_) | Message::ProcessRemoval(_) + )) || (menu.request.scenario.is_some() && matches!( *action, @@ -3763,7 +3786,7 @@ impl App { self.status = if self.topology_context().is_some() { "Use Remove selected above the canvas to review the complete Process selection." } else { - "Group model-deletion review is not available yet. Select one element or use Hide selected." + "Use Delete from model above the canvas to review the complete selection, or Hide selected to remove placements." }.into(); return Task::none(); } diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 46be13a0..78ea806a 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -63,6 +63,7 @@ fn fixture(layer: Layer) -> Fixture { } fn canvas(app: &App) -> DiagramCanvas<'_> { DiagramCanvas { + removal_input: &app.saved_removal_input, selection: app.diagram_selection.as_ref(), allocation: Some(app.allocation_capability()), project: app.project(), diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests.rs b/crates/oa-desktop/src/app/architecture_authoring/tests.rs index 0dbb14e2..76ebc561 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/tests.rs @@ -67,6 +67,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() { let camera = (app.zoom, app.pan); let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.)); let canvas = crate::diagram::DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, diff --git a/crates/oa-desktop/src/app/architecture_authoring_tests.rs b/crates/oa-desktop/src/app/architecture_authoring_tests.rs index 44843766..5000cb14 100644 --- a/crates/oa-desktop/src/app/architecture_authoring_tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring_tests.rs @@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() { y: (local.y - app.pan.y) / app.zoom, }; let canvas = crate::diagram::DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, @@ -299,6 +300,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() { let diagrams = app.project().diagrams.clone(); let camera = (app.zoom, app.pan); let canvas = crate::diagram::DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, diff --git a/crates/oa-desktop/src/app/diagram_removal.rs b/crates/oa-desktop/src/app/diagram_removal.rs index 9820d563..310cca13 100644 --- a/crates/oa-desktop/src/app/diagram_removal.rs +++ b/crates/oa-desktop/src/app/diagram_removal.rs @@ -17,6 +17,7 @@ pub struct Confirmation { pub behavior_owner: Option, pub(crate) scenario: Option, pub(crate) process: Option, + pub(crate) saved: Option, } pub fn enabled(app: &App) -> bool { @@ -213,6 +214,7 @@ impl App { behavior_owner: behavior_owner(self), scenario: None, process: None, + saved: None, } }); self.delete_pending = self.delete_target.is_some(); diff --git a/crates/oa-desktop/src/app/escape.rs b/crates/oa-desktop/src/app/escape.rs index 3c78d2ca..8bd2402d 100644 --- a/crates/oa-desktop/src/app/escape.rs +++ b/crates/oa-desktop/src/app/escape.rs @@ -27,8 +27,15 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>( removal_cancel: app .delete_target .as_ref() - .filter(|target| app.delete_pending && target.process.is_some()) + .filter(|target| { + app.delete_pending && (target.process.is_some() || target.saved.is_some()) + }) .map(|target| { + if target.saved.is_some() { + return Message::SavedRemoval(Box::new(super::saved_removal::Event::Cancel { + token: target.snapshot.token, + })); + } Message::ProcessRemoval(Box::new(super::process_removal::Event::Cancel { token: target.snapshot.token, })) diff --git a/crates/oa-desktop/src/app/process_removal.rs b/crates/oa-desktop/src/app/process_removal.rs index 7bebf25a..3ad81a2b 100644 --- a/crates/oa-desktop/src/app/process_removal.rs +++ b/crates/oa-desktop/src/app/process_removal.rs @@ -126,6 +126,7 @@ impl App { placement: None, behavior_owner: Some(context.owner), scenario: None, + saved: None, process: Some(Review { input_generation, context, diff --git a/crates/oa-desktop/src/app/process_removal/view.rs b/crates/oa-desktop/src/app/process_removal/view.rs index eafeba77..372c00fb 100644 --- a/crates/oa-desktop/src/app/process_removal/view.rs +++ b/crates/oa-desktop/src/app/process_removal/view.rs @@ -5,155 +5,36 @@ use crate::diagram_usage::Target; use crate::style; use iced::widget::{button, column, container, row, scrollable, text, text_input}; use iced::{Element, Length::Fill}; -use oa_core::{Id, Project}; -use std::collections::{BTreeMap, BTreeSet}; +use oa_core::Project; +use std::collections::BTreeSet; const PAGE_SIZE: usize = 6; -pub(super) struct ImpactRow { - pub(super) label: String, - pub(super) disposition: String, -} - -pub(super) fn matching_rows(project: &Project, review: &Review) -> Vec { - let aliases: BTreeMap = project.identities().into_iter().collect(); - let label = |id: Id| { - let name = project.named(id); - let identity = aliases - .get(&id) - .map_or_else(|| id.to_string(), |alias| format!("@{alias}")); - if name == "Missing object" || name.is_empty() { - identity - } else { - format!("{name} ({identity})") - } - }; - let selected = |id: Id| { - review.snapshot.targets().iter().any(|target| match target { - Target::ProcessOccurrence(endpoint) => { - endpoint.references.is_empty() && endpoint.occurrence == id +pub(super) fn matching_rows( + project: &Project, + review: &Review, +) -> Vec { + let selected = review + .snapshot + .targets() + .iter() + .filter_map(|target| match target { + Target::ProcessOccurrence(endpoint) if endpoint.references.is_empty() => { + Some(endpoint.occurrence) } - Target::ProcessLink(link) => *link == id, - _ => false, + Target::ProcessLink(id) => Some(*id), + _ => None, }) - }; - let mut rows = vec![]; - for id in &review.plan.removed { - rows.push(ImpactRow { - label: label(*id), - disposition: if selected(*id) { - "Removed · selected" - } else { - "Additional removed" - } - .into(), - }); - } - for id in &review.plan.changed { - rows.push(ImpactRow { - label: label(*id), - disposition: "Updated · preserved".into(), - }); - } - // Interpretation is separate from stored record deltas: callers may keep - // identical records while their reused definition or analysis changes. - for impact in &review.plan.interpretation { - let owner = label(impact.owner); - let shared_order = impact.before.local_order == impact.before.expanded_order - && impact.after.local_order == impact.after.expanded_order; - for (dimension, before, after, shown) in [ - ( - if shared_order { - "Listed and reused order" - } else { - "Local listed order" - }, - &impact.before.local_order, - &impact.after.local_order, - true, - ), - ( - "Order inside reused definitions", - &impact.before.expanded_order, - &impact.after.expanded_order, - !shared_order, - ), - ] { - if !shown || before == after { - continue; - } - for (when, order) in [("Before", before), ("After", after)] { - let summary = match order { - None => "Explicit topology; no implicit listed order".to_owned(), - Some(ids) if ids.is_empty() => { - "Empty order; no ordered pair; contextual flow may be incomplete".to_owned() - } - Some(ids) if ids.len() == 1 => { - "One item in this order; no ordered pair".to_owned() - } - Some(ids) => format!( - "Implicit listed order; {} adjacent ordered pairs", - ids.len() - 1 - ), - }; - rows.push(ImpactRow { - label: format!("{owner}: {summary}"), - disposition: format!("{when} · {dimension}"), - }); - if let Some(ids) = order { - for (index, id) in ids.iter().enumerate() { - rows.push(ImpactRow { - label: format!("{owner}: #{} {}", index + 1, label(*id)), - disposition: format!("{when} · {dimension} · listed position"), - }); - } - } - } - } - if impact.before.contextual_analysis != impact.after.contextual_analysis { - rows.push(ImpactRow { - label: format!( - "{owner}: contextual analysis {} → {}", - if impact.before.contextual_analysis { - "enabled" - } else { - "disabled" - }, - if impact.after.contextual_analysis { - "enabled" - } else { - "disabled" - } - ), - disposition: - "Analysis mode changes; this is not a claim of complete graph connectivity" - .into(), - }); - } - } - let mut affected = review.plan.affected_uses.clone(); - for impact in &review.plan.interpretation { - affected - .entry(impact.owner) - .or_default() - .extend(impact.callers.iter().copied()); - } - for (definition, callers) in affected { - for caller in callers { - rows.push(ImpactRow { - label: format!("{} uses {}", label(caller), label(definition)), - disposition: "Uses changed definition · affected caller definition".into(), - }); - } - } - let query = review.query.trim().to_lowercase(); - rows.into_iter() - .filter(|row| { - query.is_empty() - || row.label.to_lowercase().contains(&query) - || row.disposition.to_lowercase().contains(&query) - }) - .collect() + .collect(); + super::super::removal_review::matching_rows( + project, + &review.plan.removed, + &review.plan.changed, + &selected, + &review.plan.interpretation, + &review.plan.affected_uses, + &review.query, + ) } pub(crate) fn view<'a>( diff --git a/crates/oa-desktop/src/app/removal_review.rs b/crates/oa-desktop/src/app/removal_review.rs new file mode 100644 index 00000000..aac263a3 --- /dev/null +++ b/crates/oa-desktop/src/app/removal_review.rs @@ -0,0 +1,148 @@ +//! Shared stored-record and derived Process effect descriptions. +use oa_core::{Id, Project}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) struct ImpactRow { + pub(crate) label: String, + pub(crate) disposition: String, +} + +pub(crate) fn matching_rows( + project: &Project, + removed: &BTreeSet, + changed: &BTreeSet, + selected: &BTreeSet, + interpretation: &[crate::removal_effects::InterpretationChange], + affected_uses: &BTreeMap>, + query: &str, +) -> Vec { + let aliases: BTreeMap = project.identities().into_iter().collect(); + let label = |id: Id| { + let name = project.named(id); + let identity = aliases + .get(&id) + .map_or_else(|| id.to_string(), |alias| format!("@{alias}")); + if name == "Missing object" || name.is_empty() { + identity + } else { + format!("{name} ({identity})") + } + }; + let mut rows = vec![]; + for id in removed { + rows.push(ImpactRow { + label: label(*id), + disposition: if selected.contains(id) { + "Removed · selected" + } else { + "Additional removed" + } + .into(), + }); + } + for id in changed { + rows.push(ImpactRow { + label: label(*id), + disposition: "Updated · preserved".into(), + }); + } + // Interpretation is separate from stored record deltas: callers may keep + // identical records while their reused definition or analysis changes. + for impact in interpretation { + let owner = label(impact.owner); + let shared_order = impact.before.local_order == impact.before.expanded_order + && impact.after.local_order == impact.after.expanded_order; + for (dimension, before, after, shown) in [ + ( + if shared_order { + "Listed and reused order" + } else { + "Local listed order" + }, + &impact.before.local_order, + &impact.after.local_order, + true, + ), + ( + "Order inside reused definitions", + &impact.before.expanded_order, + &impact.after.expanded_order, + !shared_order, + ), + ] { + if !shown || before == after { + continue; + } + for (when, order) in [("Before", before), ("After", after)] { + let summary = match order { + None => "Explicit topology; no implicit listed order".to_owned(), + Some(ids) if ids.is_empty() => { + "Empty order; no ordered pair; contextual flow may be incomplete".to_owned() + } + Some(ids) if ids.len() == 1 => { + "One item in this order; no ordered pair".to_owned() + } + Some(ids) => format!( + "Implicit listed order; {} adjacent ordered pairs", + ids.len() - 1 + ), + }; + rows.push(ImpactRow { + label: format!("{owner}: {summary}"), + disposition: format!("{when} · {dimension}"), + }); + if let Some(ids) = order { + for (index, id) in ids.iter().enumerate() { + rows.push(ImpactRow { + label: format!("{owner}: #{} {}", index + 1, label(*id)), + disposition: format!("{when} · {dimension} · listed position"), + }); + } + } + } + } + if impact.before.contextual_analysis != impact.after.contextual_analysis { + rows.push(ImpactRow { + label: format!( + "{owner}: contextual analysis {} → {}", + if impact.before.contextual_analysis { + "enabled" + } else { + "disabled" + }, + if impact.after.contextual_analysis { + "enabled" + } else { + "disabled" + } + ), + disposition: + "Analysis mode changes; this is not a claim of complete graph connectivity" + .into(), + }); + } + } + let mut affected = affected_uses.clone(); + for impact in interpretation { + affected + .entry(impact.owner) + .or_default() + .extend(impact.callers.iter().copied()); + } + for (definition, callers) in affected { + for caller in callers { + rows.push(ImpactRow { + label: format!("{} uses {}", label(caller), label(definition)), + disposition: "Uses changed definition · affected caller definition".into(), + }); + } + } + let query = query.trim().to_lowercase(); + rows.into_iter() + .filter(|row| { + query.is_empty() + || row.label.to_lowercase().contains(&query) + || row.disposition.to_lowercase().contains(&query) + }) + .collect() +} diff --git a/crates/oa-desktop/src/app/saved_removal.rs b/crates/oa-desktop/src/app/saved_removal.rs new file mode 100644 index 00000000..a9d5bf1d --- /dev/null +++ b/crates/oa-desktop/src/app/saved_removal.rs @@ -0,0 +1,279 @@ +//! Explicit saved-view model deletion: one captured group and one transaction. +use super::*; +use crate::diagram::group_removal; +use crate::diagram_selection::Snapshot; +use crate::diagram_usage::Target; +use crate::{model_menu, route_edit::Context}; + +#[cfg(test)] +mod tests; +pub(crate) mod view; + +#[derive(Debug, Clone)] +pub(crate) enum Event { + Begin { + input_generation: u64, + context: Context, + snapshot: Box, + }, + Query { + token: u64, + query: String, + }, + Page { + token: u64, + page: usize, + }, + Cancel { + token: u64, + }, +} + +pub(crate) struct Review { + input_generation: u64, + pub(crate) context: Context, + pub(crate) snapshot: Snapshot, + pub(crate) plan: group_removal::Plan, + pub(crate) query: String, + pub(crate) page: usize, +} + +fn context(app: &App) -> Option { + let current = match app.page { + Page::Diagram => app.route_context()?, + Page::Behavior if app.topology_context().is_none() && app.scenario_context().is_none() => { + app.state_context()? + } + _ => return None, + }; + let saved = app.project().diagrams.get(¤t.diagram)?; + (saved.projection.is_architecture() || saved.projection.state_machine().is_some()) + .then_some(current) +} + +pub(crate) fn begin(app: &App) -> Option { + let input_generation = app.saved_removal_input.idle()?; + let context = context(app)?; + let snapshot = app.multiselect_snapshot()?; + (snapshot.targets().len() > 1).then_some(Message::SavedRemoval(Box::new(Event::Begin { + input_generation, + context, + snapshot: Box::new(snapshot), + }))) +} + +impl App { + fn saved_removal_selecting(&self) -> bool { + self.connection.is_none() + && self.selected_bend.is_none() + && match self.page { + Page::Diagram => self + .architecture + .as_ref() + .is_none_or(|state| !state.inline() && !state.saved.placing), + Page::Behavior => { + self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select + } + _ => false, + } + } + + pub(crate) fn saved_removal_enabled(&self) -> bool { + diagram_removal::enabled(self) + && !self.source_rename.open() + && self.saved_removal_selecting() + && self.saved_removal_input.idle().is_some() + && context(self).is_some() + } + + fn saved_removal_matches(&self, captured: Context, snapshot: &Snapshot) -> bool { + context(self) == Some(captured) + && self.diagram_selection.as_ref().is_some_and(|selection| { + selection.matches(snapshot, multiselect::context(captured)) + }) + && snapshot + .targets() + .iter() + .all(|target| self.multiselect_target_visible(target)) + } + + pub(super) fn saved_removal_event(&mut self, event: Event) -> Task { + match event { + Event::Begin { + input_generation, + context: captured, + snapshot, + } => { + // Refuse before source/editor/selection boundaries can disturb a + // newer interaction, including a later press in the same batch. + if self.saved_removal_input.idle() != Some(input_generation) + || !self.saved_removal_matches(captured, &snapshot) + || !self.saved_removal_enabled() + || snapshot.targets().len() < 2 + { + return Task::none(); + } + let plan = match group_removal::prepare( + self.project(), + captured.diagram, + snapshot.targets(), + ) { + Ok(plan) => plan, + Err(reason) => { + self.status = reason; + return Task::none(); + } + }; + let Some(primary_target) = snapshot.primary().cloned() else { + return Task::none(); + }; + let (primary, placement) = match &primary_target { + Target::Placement(id) => ( + self.project().diagrams[&captured.diagram].nodes[id].element, + Some(*id), + ), + Target::Port { port, .. } => (*port, None), + Target::Edge(id) => (*id, None), + Target::StateRow { relation, .. } => (*relation, None), + _ => return Task::none(), + }; + let Some(token) = self.menu_sequence.checked_add(1) else { + return Task::none(); + }; + self.source_interaction(); + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + let context = context(self).expect("admitted saved view"); + let snapshot = self.multiselect_snapshot().expect("admitted selection"); + let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN); + request.canvas = Some(context); + request.placement = placement; + request.selection_target = Some(Box::new(primary_target)); + self.menu_sequence = token; + self.delete_target = model_menu::Opened::new(self, request, token).map(|opened| { + diagram_removal::Confirmation { + snapshot: opened, + selection: self.selected, + placement, + behavior_owner: None, + scenario: None, + process: None, + saved: Some(Review { + input_generation, + context, + snapshot, + plan, + query: String::new(), + page: 0, + }), + } + }); + self.delete_pending = self.delete_target.is_some(); + } + Event::Query { token, query } => { + if let Some(review) = self + .delete_target + .as_mut() + .filter(|target| target.snapshot.token == token) + .and_then(|target| target.saved.as_mut()) + { + review.query = query; + review.page = 0; + } + } + Event::Page { token, page } => { + if let Some(review) = self + .delete_target + .as_mut() + .filter(|target| target.snapshot.token == token) + .and_then(|target| target.saved.as_mut()) + { + review.page = page; + } + } + Event::Cancel { token } => { + if self + .delete_target + .as_ref() + .is_some_and(|target| target.snapshot.token == token && target.saved.is_some()) + { + self.delete_target = None; + self.delete_pending = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.status = "Model deletion cancelled. Model and selection unchanged.".into(); + } + } + } + Task::none() + } + + pub(crate) fn saved_removal_review_valid( + &self, + target: &diagram_removal::Confirmation, + ) -> bool { + let Some(review) = &target.saved else { + return false; + }; + self.delete_pending + && !self.busy + && target.snapshot.valid(self) + && self.saved_removal_input.idle() == Some(review.input_generation) + && self.saved_removal_matches(review.context, &review.snapshot) + && self.selected == target.selection + && !self.source_dirty() + && !self.draft_dirty() + && !self.behavior.has_pending() + && !self.source_rename.open() + && self.saved_removal_selecting() + } + + pub(super) fn confirm_saved_removal(&mut self, token: u64) { + let Some(target) = self + .delete_target + .as_ref() + .filter(|target| target.snapshot.token == token && target.saved.is_some()) + else { + return; + }; + let valid = self.saved_removal_review_valid(target); + let target = self.delete_target.take().expect("captured saved review"); + self.delete_pending = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + if !valid { + self.status = "Model deletion review expired. Nothing was deleted; review the current selection again.".into(); + return; + } + let review = target.saved.expect("saved review"); + let machine = self.project().diagrams[&review.context.diagram] + .projection + .state_machine(); + match self.document.execute(review.plan.command) { + Ok(_) => { + self.selected_node = None; + self.behavior.diagram.placement = None; + if self.page == Page::Behavior + && let Some(machine) = machine.filter(|id| self.project().contains(*id)) + { + self.selected = machine; + self.behavior.active_owner = Some(machine); + } else { + self.selected = if self + .project() + .diagrams + .contains_key(&review.context.diagram) + { + review.context.diagram + } else { + self.project().id + }; + } + self.sync(); + self.reconcile_multiselect(); + self.status = format!( + "Deleted {} selected model subjects in one change across all views. Undo restores the model.", + review.plan.selected.len() + ); + } + Err(error) => self.status = format!("Nothing deleted: {error}"), + } + } +} diff --git a/crates/oa-desktop/src/app/saved_removal/tests.rs b/crates/oa-desktop/src/app/saved_removal/tests.rs new file mode 100644 index 00000000..9f0103f6 --- /dev/null +++ b/crates/oa-desktop/src/app/saved_removal/tests.rs @@ -0,0 +1,781 @@ +//! Saved semantic removal through App, with independently edited complete models. +use super::*; +use crate::diagram_usage::Target; +use oa_core::*; + +fn point(x: f32, y: f32) -> Position { + Position { x, y } +} + +fn saved(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id { + let id = project.ensure_diagram(layer); + let view = project.diagrams.get_mut(&id).unwrap(); + view.projection = projection; + view.description = "Authored view metadata must survive".into(); + id +} + +fn architecture(project: Project, diagram: Id) -> App { + validate_candidate(&project).unwrap(); + let layer = project.diagrams[&diagram].layer; + let mut app = App::with_project(project); + app.page = Page::Diagram; + app.layer = layer; + app.active_diagram = Some(diagram); + app.selected = diagram; + app.selected_node = None; + app.refresh_draft(); + app.reconcile_multiselect(); + app.window_size = iced::Size::new(1600., 1000.); + app +} + +fn choose(app: &mut App, targets: &[Target]) { + for (index, target) in targets.iter().enumerate() { + let context = app.multiselect_context().unwrap(); + let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose( + context, + target.clone(), + if index == 0 { + multiselect::Choice::Replace + } else { + multiselect::Choice::Add + }, + ))); + } + assert_eq!(app.multiselect_snapshot().unwrap().targets(), targets); +} + +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::SavedRemoval(Box::new(event))); +} + +fn open(app: &mut App) -> u64 { + assert!(app.saved_removal_enabled()); + let message = begin(app).expect("captured saved group"); + let _ = app.update(message); + let confirmation = app.delete_target.as_ref().expect("saved group review"); + assert!(app.saved_removal_review_valid(confirmation)); + assert!(confirmation.saved.is_some()); + confirmation.snapshot.token +} + +fn source_matches(app: &App) { + let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap(); + assert_eq!(&compiled.decoded.project, app.project()); + assert_eq!(&compiled.bundle, app.document.bundle()); + assert_eq!( + source_workspace::reachable(&app.source_workspace.bundle()).unwrap(), + *app.document.bundle() + ); +} + +struct Architecture { + app: App, + diagram: Id, + parent: Id, + child: Id, + survivor: Id, + parent_node: Id, + child_node: Id, + survivor_node: Id, + edge: Id, + port: Option, + expected: Project, +} + +fn architecture_fixture(layer: Layer) -> Architecture { + let mut project = Project::empty(); + let kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let parent = project.add_element(kind, layer, "Parent", None).unwrap(); + let child = project + .add_element(kind, layer, "Child", Some(parent)) + .unwrap(); + let survivor = project.add_element(kind, layer, "Survivor", None).unwrap(); + project.elements.get_mut(&survivor).unwrap().description = + "Unrelated authored description".into(); + let edge = project.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Incident", + child, + survivor, + ); + let port = match project.relations[&edge].body { + RelationBody::FunctionalExchange { source_port, .. } => Some(source_port), + _ => None, + }; + let diagram = saved(&mut project, layer, ProjectionKind::Overview); + let parent_node = project.place(diagram, parent, point(0., 0.)); + let child_node = project.place(diagram, child, point(40., 100.)); + let survivor_node = project.place(diagram, survivor, point(800., 50.)); + project + .diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge, + source_attachment: None, + target_attachment: None, + waypoints: vec![point(450.25, 90.5)], + label: None, + }); + let other = saved(&mut project, layer, ProjectionKind::Overview); + let other_child = project.place(other, child, point(-40., 100.)); + project.place(other, survivor, point(700., 100.)); + project + .diagrams + .get_mut(&other) + .unwrap() + .view_options + .hidden_edges + .push(edge); + let mut expected = project.clone(); + expected.elements.remove(&parent); + expected.elements.remove(&child); + if let Some(port) = port { + expected.elements.remove(&port); + } + expected.relations.remove(&edge); + let view = expected.diagrams.get_mut(&diagram).unwrap(); + view.nodes.remove(&parent_node); + view.nodes.remove(&child_node); + view.routes.clear(); + let view = expected.diagrams.get_mut(&other).unwrap(); + view.nodes.remove(&other_child); + view.view_options.hidden_edges.clear(); + validate_candidate(&expected).unwrap(); + let mut app = architecture(project, diagram); + let mut targets = vec![ + Target::Placement(parent_node), + Target::Placement(child_node), + Target::Edge(edge), + ]; + if let Some(port) = port { + targets.push(Target::Port { + port, + placement: child_node, + }); + } + choose(&mut app, &targets); + Architecture { + app, + diagram, + parent, + child, + survivor, + parent_node, + child_node, + survivor_node, + edge, + port, + expected, + } +} + +#[test] +fn saved_architecture_mixed_semantics_are_one_exact_model_source_transaction_in_oa_and_sa() { + for layer in [Layer::Oa, Layer::Sa] { + let mut f = architecture_fixture(layer); + let before = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot().unwrap(); + let camera = (f.app.zoom, f.app.pan); + let token = open(&mut f.app); + let review = f + .app + .delete_target + .as_ref() + .unwrap() + .saved + .as_ref() + .unwrap(); + let mut subjects: BTreeSet<_> = [f.parent, f.child, f.edge].into_iter().collect(); + subjects.extend(f.port); + assert_eq!(review.plan.selected, subjects); + assert_eq!(review.snapshot.targets(), selection.targets()); + assert_eq!(review.snapshot.primary(), selection.primary()); + assert_eq!(f.app.document.bundle(), &before.bundle); + let _ = f.app.update(Message::ConfirmDelete(token)); + assert_eq!(f.app.project(), &f.expected); + assert_eq!(f.app.selected, f.diagram); + assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty()); + assert_eq!((f.app.zoom, f.app.pan), camera); + source_matches(&f.app); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &f.expected); + assert_eq!(f.app.document.bundle(), &after); + source_matches(&f.app); + } +} + +struct States { + app: App, + diagram: Id, + other: Id, + machine: Id, + region: Id, + a: Id, + b: Id, + na: Id, + nb: Id, + work: Id, + action: Id, + row: Id, + edge: Id, +} + +fn state_fixture(layer: Layer) -> States { + let mut project = Project::empty(); + let owner = project + .add_element( + if layer == Layer::Oa { + Kind::Entity + } else { + Kind::System + }, + layer, + "Owner", + None, + ) + .unwrap(); + let machine = project + .add_element(Kind::StateMachine, layer, "Modes", Some(owner)) + .unwrap(); + let region = project + .add_element(Kind::Region, layer, "Region", Some(machine)) + .unwrap(); + let a = project + .add_element(Kind::State, layer, "Operating", Some(region)) + .unwrap(); + let b = project + .add_element(Kind::State, layer, "Standby", Some(region)) + .unwrap(); + let work = project + .add_element( + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + "Reusable work", + None, + ) + .unwrap(); + let action = project.relate(RelationKind::StateAction, "Entry work", a, work); + let row = project.relate(RelationKind::StateTransition, "Internal check", a, a); + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut project.relations.get_mut(&row).unwrap().body + { + *transition_kind = TransitionKind::Internal; + } + let edge = project.relate(RelationKind::StateTransition, "Change mode", a, b); + let diagram = saved( + &mut project, + layer, + ProjectionKind::StateMachine { machine }, + ); + let na = project.place(diagram, a, point(10., 20.)); + let nb = project.place(diagram, b, point(600., 20.)); + let other = saved( + &mut project, + layer, + ProjectionKind::StateMachine { machine }, + ); + project.place(other, a, point(70., 80.)); + project.place(other, b, point(670., 80.)); + let architecture_view = saved(&mut project, layer, ProjectionKind::Overview); + project.place(architecture_view, owner, point(0., 0.)); + validate_candidate(&project).unwrap(); + let mut app = architecture(project, architecture_view); + app.page = Page::Behavior; + app.behavior.active_owner = Some(machine); + app.behavior.diagram = crate::behavior::state_view::State { + diagram: Some(diagram), + ..Default::default() + }; + app.selected = diagram; + app.refresh_draft(); + app.reconcile_multiselect(); + States { + app, + diagram, + other, + machine, + region, + a, + b, + na, + nb, + work, + action, + row, + edge, + } +} + +#[test] +fn saved_state_rows_delete_relations_not_referenced_work_and_state_overlap_is_exact() { + for layer in [Layer::Oa, Layer::Sa] { + for include_owner in [false, true] { + let mut f = state_fixture(layer); + let mut targets = vec![ + Target::StateRow { + placement: f.na, + relation: f.action, + }, + Target::StateRow { + placement: f.na, + relation: f.row, + }, + ]; + if include_owner { + targets.push(Target::Placement(f.na)); + } else { + targets.push(Target::Placement(f.nb)); + } + choose(&mut f.app, &targets); + let before = f.app.document.snapshot(); + let camera = (f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan); + let mut expected = f.app.project().clone(); + expected.relations.remove(&f.action); + expected.relations.remove(&f.row); + expected.relations.remove(&f.edge); + let removed_state = if include_owner { f.a } else { f.b }; + expected.elements.remove(&removed_state); + for view in expected.diagrams.values_mut() { + view.nodes.retain(|_, node| node.element != removed_state); + } + validate_candidate(&expected).unwrap(); + let token = open(&mut f.app); + let review = f + .app + .delete_target + .as_ref() + .unwrap() + .saved + .as_ref() + .unwrap(); + assert_eq!( + review.context.diagram, f.diagram, + "must not resolve stale Architecture field" + ); + assert_eq!( + review.plan.selected, + [f.action, f.row, removed_state].into_iter().collect() + ); + assert!(!review.plan.removed.contains(&f.work)); + let _ = f.app.update(Message::ConfirmDelete(token)); + assert_eq!(f.app.project(), &expected); + assert!(f.app.project().contains(f.work)); + assert_eq!( + (f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan), + camera + ); + assert!(f.app.project().contains(f.app.selected)); + source_matches(&f.app); + let after = f.app.document.bundle().clone(); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &after); + } + } +} + +#[test] +fn saved_cancel_and_stale_modal_packets_preserve_exact_redo_source_selection_and_camera() { + let mut f = architecture_fixture(Layer::Sa); + f.app + .document + .execute(Command::Rename(f.survivor, "Future name".into())) + .unwrap(); + let future = f.app.document.snapshot(); + assert!(f.app.document.undo()); + f.app.sync(); + f.app.reconcile_multiselect(); + let before = f.app.document.snapshot(); + let editor = f.app.source_editor.text(); + let selected = f.app.multiselect_snapshot().unwrap(); + let camera = (f.app.zoom, f.app.pan); + let old_begin = begin(&f.app).unwrap(); + let first = open(&mut f.app); + send(&mut f.app, Event::Cancel { token: first }); + let second = open(&mut f.app); + let epoch = f.app.canvas_epoch; + for message in [ + old_begin, + Message::ConfirmDelete(first), + Message::CancelDelete, + Message::Escape, + Message::SavedRemoval(Box::new(Event::Cancel { token: first })), + Message::SavedRemoval(Box::new(Event::Query { + token: first, + query: "stale".into(), + })), + Message::SavedRemoval(Box::new(Event::Page { + token: first, + page: 999, + })), + ] { + let _ = f.app.update(message); + assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, second); + assert_eq!(f.app.canvas_epoch, epoch); + let review = f + .app + .delete_target + .as_ref() + .unwrap() + .saved + .as_ref() + .unwrap(); + assert!(review.query.is_empty()); + assert_eq!(review.page, 0); + } + send(&mut f.app, Event::Cancel { token: second }); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!(f.app.source_editor.text(), editor); + assert_eq!(f.app.source_workspace.bundle(), before.bundle); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + selected.primary() + ); + assert_eq!((f.app.zoom, f.app.pan), camera); + assert!(f.app.document.can_redo() && !f.app.document.can_undo()); + let _ = f.app.update(Message::Redo); + assert_eq!(f.app.project(), &future.decoded.project); + assert_eq!(f.app.document.bundle(), &future.bundle); +} + +#[test] +fn saved_begin_and_confirm_reject_selection_membership_and_primary_aba() { + for after_open in [false, true] { + for primary_only in [false, true] { + let mut f = architecture_fixture(Layer::Oa); + let delayed = begin(&f.app).unwrap(); + let token = after_open.then(|| open(&mut f.app)); + let before = f.app.document.snapshot(); + let captured = f.app.multiselect_snapshot().unwrap(); + let selected = f.app.diagram_selection.as_mut().unwrap(); + let primary = captured.primary().unwrap(); + if primary_only { + assert!(selected.set_primary(&Target::Placement(f.parent_node))); + assert!(selected.set_primary(primary)); + } else { + assert!(selected.remove(primary)); + assert!(selected.add(primary.clone(), |_| true)); + } + let restored = f.app.multiselect_snapshot().unwrap(); + assert_eq!(restored.targets(), captured.targets()); + assert_eq!(restored.primary(), captured.primary()); + assert_ne!(restored.generation(), captured.generation()); + let epoch = f.app.canvas_epoch; + let _ = f + .app + .update(token.map(Message::ConfirmDelete).unwrap_or(delayed)); + assert!(f.app.delete_target.is_none()); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + restored.targets() + ); + if !after_open { + assert_eq!(f.app.canvas_epoch, epoch); + } + } + } +} + +#[test] +fn saved_stale_context_packets_do_not_retire_current_selection_or_history() { + for change in 0..4 { + let mut f = architecture_fixture(Layer::Sa); + let delayed = begin(&f.app).unwrap(); + match change { + 0 => { + f.app.canvas_document = Id::new_v4(); + } + 1 => { + f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1); + } + 2 => { + f.app + .document + .execute(Command::Rename(f.survivor, "New revision".into())) + .unwrap(); + } + _ => { + f.app.active_diagram = f + .app + .project() + .diagrams + .keys() + .copied() + .find(|id| *id != f.diagram); + } + } + let current = f.app.document.snapshot(); + let selection = f.app.multiselect_snapshot(); + let epoch = f.app.canvas_epoch; + let _ = f.app.update(delayed); + assert!(f.app.delete_target.is_none()); + assert!(Arc::ptr_eq(¤t, &f.app.document.snapshot())); + assert_eq!(f.app.multiselect_snapshot(), selection); + assert_eq!(f.app.canvas_epoch, epoch); + } +} + +#[test] +fn saved_admission_preserves_property_source_and_state_editor_drafts_and_nonselect_tool() { + for pending in ["properties", "source", "form", "tool", "busy"] { + let mut f = state_fixture(Layer::Sa); + choose( + &mut f.app, + &[Target::Placement(f.na), Target::Placement(f.nb)], + ); + let delayed = begin(&f.app).unwrap(); + match pending { + "properties" => { + let _ = f + .app + .update(Message::Field("/name".into(), "Unapplied".into())); + } + "source" => { + let text = format!("{}\n// pending", f.app.source_workspace.text()); + let _ = f + .app + .update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(text)), + ))); + } + "form" => { + let _ = f.app.update(Message::Behavior(behavior::Event::States( + behavior::states::Event::Edit(f.edge), + ))); + let _ = f.app.update(Message::Behavior(behavior::Event::States( + behavior::states::Event::Guard("Retained guard".into()), + ))); + } + "tool" => { + f.app.behavior.diagram.tool = behavior::state_edit::Tool::Transition; + } + _ => f.app.busy = true, + } + let before = f.app.document.snapshot(); + let source = f.app.source_workspace.bundle(); + let selection = f.app.multiselect_snapshot(); + let draft = f + .app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + let form = f.app.behavior.recovery_snapshot(); + let epoch = f.app.canvas_epoch; + let _ = f.app.update(delayed); + assert!(f.app.delete_target.is_none(), "{pending}"); + assert!( + Arc::ptr_eq(&before, &f.app.document.snapshot()), + "{pending}" + ); + assert_eq!(f.app.source_workspace.bundle(), source); + assert_eq!(f.app.multiselect_snapshot(), selection); + assert_eq!( + f.app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + assert_eq!(f.app.behavior.recovery_snapshot(), form); + assert_eq!(f.app.canvas_epoch, epoch); + } +} + +#[test] +fn saved_input_lease_and_finished_gesture_reject_old_packets_at_begin_and_confirm() { + for after_open in [false, true] { + for finished in [false, true] { + let mut f = architecture_fixture(Layer::Oa); + let delayed = begin(&f.app).unwrap(); + let token = after_open.then(|| open(&mut f.app)); + let before = f.app.document.snapshot(); + let selected = f.app.multiselect_snapshot().unwrap(); + let lease = f.app.saved_removal_input.acquire().unwrap(); + let mut lease = Some(lease); + if finished { + drop(lease.take()); + } + let _ = f + .app + .update(token.map(Message::ConfirmDelete).unwrap_or(delayed)); + assert!(f.app.delete_target.is_none()); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert_eq!( + f.app.multiselect_snapshot().unwrap().primary(), + selected.primary() + ); + drop(lease); + } + } +} + +#[test] +fn saved_context_menu_keeps_the_whole_group_even_when_unselected_object_was_clicked() { + for unselected in [false, true] { + let mut f = architecture_fixture(Layer::Sa); + let selected = f.app.multiselect_snapshot().unwrap(); + let (object, placement) = if unselected { + (f.survivor, f.survivor_node) + } else { + (f.child, f.child_node) + }; + let mut request = model_menu::Request::object(object, iced::Point::ORIGIN); + request.canvas = f.app.route_context(); + request.placement = Some(placement); + request.selection_target = Some(Box::new(Target::Placement(placement))); + let _ = f + .app + .update(Message::Menu(model_menu::Event::Open(request))); + let entries = model_menu::popup(&f.app).unwrap().entries; + let invoke = entries + .into_iter() + .find_map(|entry| match entry { + crate::context_menu::Entry::Action { label, message, .. } + if label == "Delete selected from model…" => + { + message + } + _ => None, + }) + .expect("explicit semantic group menu action"); + let _ = f.app.update(invoke); + let review = f + .app + .delete_target + .as_ref() + .unwrap() + .saved + .as_ref() + .unwrap(); + assert_eq!(review.snapshot.targets(), selected.targets()); + assert!(!review.plan.selected.contains(&f.survivor)); + } +} + +#[test] +fn saved_hide_and_plain_delete_never_promote_mixed_targets_to_model_removal() { + for message_kind in ["delete", "hide"] { + let mut f = architecture_fixture(Layer::Sa); + let before = f.app.document.snapshot(); + let selected = f.app.multiselect_snapshot().unwrap(); + let _ = f.app.update(if message_kind == "delete" { + Message::Delete + } else { + Message::HideSelected + }); + assert!(Arc::ptr_eq(&before, &f.app.document.snapshot())); + assert_eq!( + f.app.multiselect_snapshot().unwrap().targets(), + selected.targets() + ); + assert!(f.app.delete_target.is_none()); + let token = open(&mut f.app); + let _ = f.app.update(Message::ConfirmDelete(token)); + assert_eq!(f.app.project(), &f.expected); + } + let mut f = architecture_fixture(Layer::Oa); + choose( + &mut f.app, + &[ + Target::Placement(f.parent_node), + Target::Placement(f.survivor_node), + ], + ); + let mut expected = f.app.project().clone(); + let view = expected.diagrams.get_mut(&f.diagram).unwrap(); + view.nodes.remove(&f.parent_node); + view.nodes.remove(&f.survivor_node); + let _ = f.app.update(Message::HideSelected); + assert_eq!(f.app.project(), &expected); + assert!(f.app.project().contains(f.parent) && f.app.project().contains(f.survivor)); + source_matches(&f.app); +} + +#[test] +fn saved_state_owner_deletion_removes_owned_views_and_chooses_surviving_inspection_context() { + let mut f = state_fixture(Layer::Sa); + let mut project = f.app.project().clone(); + let owner_node = project.place(f.diagram, f.machine, point(-400., -200.)); + f.app.document = Document::from_project(project.clone(), LIMITS).unwrap(); + f.app.source_workspace = Workspace::new(f.app.document.bundle(), true); + f.app.sync(); + f.app.reconcile_multiselect(); + choose( + &mut f.app, + &[Target::Placement(owner_node), Target::Placement(f.na)], + ); + let before = f.app.document.snapshot(); + let mut expected = project; + for id in [f.machine, f.region, f.a, f.b] { + expected.elements.remove(&id); + } + for id in [f.action, f.row, f.edge] { + expected.relations.remove(&id); + } + expected.diagrams.remove(&f.diagram); + expected.diagrams.remove(&f.other); + validate_candidate(&expected).unwrap(); + let token = open(&mut f.app); + let _ = f.app.update(Message::ConfirmDelete(token)); + assert_eq!(f.app.project(), &expected); + assert!(f.app.project().contains(f.app.selected)); + assert!( + f.app + .behavior + .active_owner + .is_none_or(|id| f.app.project().contains(id)) + ); + assert!( + f.app + .behavior + .diagram + .diagram + .is_none_or(|id| f.app.project().diagrams.contains_key(&id)) + ); + assert!( + f.app + .multiselect_snapshot() + .is_none_or(|s| s.targets().is_empty()) + ); + source_matches(&f.app); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!(!f.app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/saved_removal/view.rs b/crates/oa-desktop/src/app/saved_removal/view.rs new file mode 100644 index 00000000..afe8a7df --- /dev/null +++ b/crates/oa-desktop/src/app/saved_removal/view.rs @@ -0,0 +1,145 @@ +//! Review the complete captured removal impact before one explicit confirmation. +use super::{Event, Review}; +use crate::app::{App, Message, diagram_removal}; +use crate::style; +use iced::widget::{button, column, container, row, scrollable, text, text_input}; +use iced::{Element, Length::Fill}; +use oa_core::Project; +use std::collections::BTreeSet; + +const PAGE_SIZE: usize = 6; + +pub(super) fn matching_rows( + project: &Project, + review: &Review, +) -> Vec { + super::super::removal_review::matching_rows( + project, + &review.plan.removed, + &review.plan.changed, + &review.plan.selected, + &review.plan.interpretation, + &review.plan.affected_uses, + &review.query, + ) +} + +pub(crate) fn view<'a>( + app: &'a App, + target: &'a diagram_removal::Confirmation, +) -> Element<'a, Message> { + let Some(review) = target.saved.as_ref() else { + return text("Model deletion review is unavailable.").into(); + }; + let token = target.snapshot.token; + let valid = app.saved_removal_review_valid(target); + let rows = matching_rows(app.project(), review); + let callers: BTreeSet<_> = review + .plan + .affected_uses + .values() + .flatten() + .chain( + review + .plan + .interpretation + .iter() + .flat_map(|impact| impact.callers.iter()), + ) + .copied() + .collect(); + let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() { + "No detected Process order/analysis changes or affected Process caller definitions." + .to_owned() + } else { + format!( + "Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.", + review.plan.interpretation.len(), + callers.len() + ) + }; + let pages = rows.len().div_ceil(PAGE_SIZE).max(1); + let page = review.page.min(pages - 1); + let mut impacts = column![].spacing(12).width(Fill); + for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) { + impacts = impacts.push( + column![ + text(impact.disposition.clone()) + .size(12) + .color(style::MUTED), + text(impact.label.clone()) + .size(13) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ] + .spacing(3) + .width(Fill), + ); + } + if rows.is_empty() { + impacts = impacts.push(text("No matching effects. Try a name, alias or effect.").size(13)); + } + let previous = button("Previous").on_press_maybe((page > 0).then(|| { + Message::SavedRemoval(Box::new(Event::Page { + token, + page: page - 1, + })) + })); + let next = button("Next").on_press_maybe((page + 1 < pages).then(|| { + Message::SavedRemoval(Box::new(Event::Page { + token, + page: page + 1, + })) + })); + let mut footer = column![].spacing(8).width(Fill); + if !valid { + footer = footer.push( + text("This review expired. Cancel and review the current selection again.") + .size(13) + .width(Fill) + .wrapping(iced::widget::text::Wrapping::WordOrGlyph), + ); + } + footer = footer.push( + row![ + container( + button("Cancel") + .padding(10) + .on_press(Message::SavedRemoval(Box::new(Event::Cancel { token }))) + ) + .id("delete-cancel"), + container( + button("Delete selected from model") + .style(button::danger) + .padding(10) + .on_press_maybe(valid.then_some(Message::ConfirmDelete(token))) + ) + .id("delete-confirm"), + ] + .spacing(10), + ); + column![ + text(format!("Delete {} model subjects?", review.plan.selected.len())).size(21), + text(format!("{} selected diagram objects · {} unique model subjects", review.snapshot.targets().len(), review.plan.selected.len())).size(13), + text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14), + text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph), + text_input("Search affected names, aliases or effects", &review.query) + .id("saved-removal-query") + .on_input(move |query| Message::SavedRemoval(Box::new(Event::Query { token, query }))) + .size(13), + row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next] + .spacing(10).align_y(iced::Alignment::Center), + scrollable(column![ + text("Deletes the selected model subjects and their listed dependent records across all views. This is different from hiding placements. Removing a State row preserves its referenced work; deleting a trace preserves its endpoints unless separately selected or owned by another deleted subject. Reused Process definitions may change; review their listed effects and callers. One Undo restores the entire model and source change.") + .size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph), + impacts, + ].spacing(14).width(Fill)) + .width(Fill).height(Fill).spacing(8).id("saved-removal-impact"), + footer, + ] + .spacing(12) + .width(Fill) + // Keep confirmation and search outside the flexible, scrollable impact body. + .height((app.window_size.height - 160.).clamp(260., 570.)) + .into() +} diff --git a/crates/oa-desktop/src/app/scenario_removal.rs b/crates/oa-desktop/src/app/scenario_removal.rs index d3c2e3e4..f78f3f25 100644 --- a/crates/oa-desktop/src/app/scenario_removal.rs +++ b/crates/oa-desktop/src/app/scenario_removal.rs @@ -108,6 +108,7 @@ impl App { placement: None, behavior_owner: Some(context.scenario), process: None, + saved: None, scenario: Some(Review { context, snapshot, diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index e484b077..d92fea06 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -116,6 +116,7 @@ impl App { } match event { Event::Selection(event) => self.multiselect_event(event), + Event::ModelRemoval(event) => return self.saved_removal_event(*event), Event::Remove(intent) => self.remove_from_diagram(intent), Event::Viewport(_, size) => { if size.width.is_finite() diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 8b6d2a74..8dbc76ac 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -29,6 +29,7 @@ use std::{ pub const GUIDANCE_HEIGHT: f32 = 42.; pub struct StateCanvas<'a> { + pub removal_input: &'a crate::removal_input::Lifetime, pub project: &'a Project, pub diagram: Id, pub context: Context, @@ -44,6 +45,8 @@ pub struct StateCanvas<'a> { #[derive(Default)] pub struct State { + removal_lease: Option, + removal_viewport: Option<(f32, Vector, Rectangle)>, context: Option, tool: Option, pointer: Option, @@ -877,6 +880,21 @@ impl StateCanvas<'_> { } if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) { let snapshot = selection.snapshot(selection_context(self.context))?; + if *modifiers == keyboard::Modifiers::SHIFT { + let Some(input_generation) = self.removal_input.idle() else { + return Some(Action::capture()); + }; + return Some( + Action::publish(Event::ModelRemoval(Box::new( + crate::app::saved_removal::Event::Begin { + input_generation, + context: self.context, + snapshot: Box::new(snapshot), + }, + ))) + .and_capture(), + ); + } let event = if modifiers.is_empty() && crate::app::multiselect::placements(&snapshot).is_some() { @@ -885,7 +903,7 @@ impl StateCanvas<'_> { SelectionEvent::Refuse(snapshot, if modifiers.is_empty() { "Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group." } else { - "Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements." + "Use Shift+Delete to review model deletion or Hide selected for placements." }.into()) }; return Some(Action::publish(Event::Selection(event)).and_capture()); @@ -2002,6 +2020,51 @@ impl canvas::Program for StateCanvas<'_> { _ => None, } } +} +impl canvas::Program for StateCanvas<'_> { + type State = State; + fn update( + &self, + state: &mut State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + self.removal_input.sync( + &mut state.removal_lease, + state.press.is_some() + || state.group.is_some() + || state.source.is_some() + || state.bend.is_some() + || state.pan_from.is_some(), + ); + self.removal_input.observe(event); + let viewport = (self.zoom, self.pan, bounds); + if state + .removal_viewport + .is_some_and(|previous| previous != viewport) + { + self.removal_input.revoke_idle(); + } + state.removal_viewport = Some(viewport); + let action = self.update_input(state, event, bounds, cursor); + if matches!( + event, + canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) + | canvas::Event::Mouse(mouse::Event::CursorLeft) + ) { + state.reset(); + } + self.removal_input.sync( + &mut state.removal_lease, + state.press.is_some() + || state.group.is_some() + || state.source.is_some() + || state.bend.is_some() + || state.pan_from.is_some(), + ); + action + } fn draw( &self, state: &State, @@ -2597,6 +2660,7 @@ mod tests { include!("state_multiselect_canvas_tests.rs"); struct Fixture { + removal_input: crate::removal_input::Lifetime, project: Project, diagram: Id, machine: Id, @@ -2658,6 +2722,7 @@ mod tests { } validate_candidate(&project).unwrap(); Fixture { + removal_input: Default::default(), project, diagram, machine, @@ -2858,6 +2923,7 @@ mod tests { fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> { StateCanvas { + removal_input: &f.removal_input, project: &f.project, diagram: f.diagram, context: Context { diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index 6a650ed5..9a3e3a22 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -26,6 +26,7 @@ pub enum Tool { #[derive(Debug, Clone)] pub enum Event { Selection(crate::app::multiselect::Event), + ModelRemoval(Box), Remove(crate::app::diagram_removal::Intent), Choose(Tool), CreateView(Id), @@ -104,7 +105,9 @@ impl Event { match self { Self::Remove(intent) => Some(intent.context), // Group events carry their own exact selection envelope. - Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None, + Self::Selection(_) | Self::ModelRemoval(_) | Self::Choose(_) | Self::CreateView(_) => { + None + } Self::Select(c, _) | Self::Pan(c, _) | Self::Zoom(c, _) diff --git a/crates/oa-desktop/src/behavior/state_view.rs b/crates/oa-desktop/src/behavior/state_view.rs index e7d5785f..f4b31739 100644 --- a/crates/oa-desktop/src/behavior/state_view.rs +++ b/crates/oa-desktop/src/behavior/state_view.rs @@ -43,6 +43,7 @@ pub fn diagram(app: &App, machine: Id) -> Option { fn event(e: Event) -> Message { match e { Event::Selection(selection) => Message::DiagramSelection(selection), + Event::ModelRemoval(removal) => Message::SavedRemoval(removal), other => Message::StateDiagram(other), } } @@ -169,6 +170,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem ); } let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas { + removal_input: &app.saved_removal_input, project: p, diagram: id, context, @@ -223,15 +225,22 @@ fn selection_controls(app: &App) -> Element<'_, Message> { Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())), )); } + controls = controls.push( + button(text("Delete from model…").size(12)) + .padding([6, 8]) + .on_press_maybe( + crate::app::saved_removal::begin(app).filter(|_| app.saved_removal_enabled()), + ), + ); controls = controls .push(action( - "Clear selection", + "Clear", Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())), )) .push( label(format!( - "Properties edit only {}", - crate::views::short(app.project().named(app.selected), 18) + "Edit only {}", + crate::views::short(app.project().named(app.selected), 12) )) .wrapping(iced::widget::text::Wrapping::None), ); diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index d9159df6..9a987659 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -7,7 +7,7 @@ pub(crate) mod contextual; mod direct; pub(crate) mod group_removal; pub(crate) mod group_reorder; -mod removal_input; +use crate::removal_input; mod usages; mod view; #[cfg(test)] diff --git a/crates/oa-desktop/src/behavior/topology/group_removal.rs b/crates/oa-desktop/src/behavior/topology/group_removal.rs index 36e212f0..daa5b72c 100644 --- a/crates/oa-desktop/src/behavior/topology/group_removal.rs +++ b/crates/oa-desktop/src/behavior/topology/group_removal.rs @@ -2,7 +2,9 @@ //! effects. Visibility, source drafts and captured UI lifetime belong to the App. use super::*; use crate::diagram_usage::Target; -use oa_core::topology_context::TopologyIndex; +#[cfg(test)] +use crate::removal_effects::Interpretation; +pub(crate) use crate::removal_effects::InterpretationChange; #[derive(Debug)] pub(crate) struct Plan { @@ -15,122 +17,6 @@ pub(crate) struct Plan { pub(crate) affected_uses: BTreeMap>, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct InterpretationChange { - pub(crate) owner: Id, - pub(crate) before: Interpretation, - pub(crate) after: Interpretation, - pub(crate) callers: BTreeSet, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Interpretation { - /// None means explicit; Some includes the empty legacy order. - pub(crate) local_order: Option>, - /// Definition-level implicit order used within contextual expansion. This - /// does not claim complete expanded connectivity or boundary analysis. - pub(crate) expanded_order: Option>, - pub(crate) contextual_analysis: bool, -} - -fn exchange(project: &Project, id: Id) -> bool { - matches!( - project.elements.get(&id).and_then(|e| e.shared()), - Some(SharedBody::Occurrence { - body: OccurrenceBody::Exchange { .. }, - .. - }) - ) -} - -fn interpretations(project: &Project) -> BTreeMap { - let index = TopologyIndex::new(project); - // This is exactly TopologyIndex::with_cache_capacity's relation ownership - // rule. Its link_definitions is private; do not expand repeated reuse trees - // just to answer this finite definition-level question. - let linked: BTreeSet<_> = project - .relations - .values() - .filter_map(|relation| { - relation.contextual_owner().or_else(|| { - (relation.kind() == RelationKind::SequenceLink) - .then(|| { - project - .elements - .get(&relation.source()) - .and_then(|e| e.parent) - }) - .flatten() - }) - }) - .collect(); - project - .elements - .values() - .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) - .map(|e| { - let steps = e.steps().expect("Sequence definitions have ordered steps"); - // legacy_order alone loses the explicit-versus-empty distinction. - let local_order = (!uses_explicit_topology(project, e.id) - && steps.iter().all(|id| exchange(project, *id))) - .then(|| steps.clone()); - let children = index.contributions(e.id); - // Mirrors topology_context/analysis.rs Builder::expand's legacy-order - // discriminator: all indexed contributions (including unlisted ones), - // and neither SequenceLink nor ContextualLink definitions. Tests retain - // literal discriminators where this intentionally differs from desktop. - let expanded_order = (!linked.contains(&e.id) - && children.iter().all(|id| exchange(project, *id))) - .then_some(children); - ( - e.id, - Interpretation { - local_order, - expanded_order, - contextual_analysis: index.has_contextual_links(e.id), - }, - ) - }) - .collect() -} - -fn caller_edges(project: &Project, edges: &mut BTreeMap>) { - for element in project.elements.values() { - if let ( - Some(parent), - Some(SharedBody::Occurrence { - body: - OccurrenceBody::SequenceReference { - sequence: Some(sequence), - }, - .. - }), - ) = (element.parent, element.shared()) - { - edges.entry(*sequence).or_default().insert(parent); - } - } -} - -fn callers( - owner: Id, - edges: &BTreeMap>, - surviving: &BTreeMap, -) -> BTreeSet { - let mut seen = BTreeSet::from([owner]); - let mut pending = vec![owner]; - while let Some(sequence) = pending.pop() { - for &caller in edges.get(&sequence).into_iter().flatten() { - if seen.insert(caller) { - pending.push(caller); - } - } - } - seen.remove(&owner); - seen.retain(|id| surviving.contains_key(id)); - seen -} - fn changed_records( before: &BTreeMap, after: &BTreeMap, @@ -143,36 +29,6 @@ fn changed_records( ); } -fn definition_owner(project: &Project, id: Id) -> Option { - let owner = if let Some(element) = project.elements.get(&id) { - if matches!(element.kind(), Kind::Process | Kind::FunctionalChain) { - Some(id) - } else { - element - .parent - .filter(|parent| is_contribution(project, *parent, id)) - } - } else { - project.relations.get(&id).and_then(|relation| { - relation.contextual_owner().or_else(|| { - (relation.kind() == RelationKind::SequenceLink) - .then(|| { - project - .elements - .get(&relation.source()) - .and_then(|e| e.parent) - }) - .flatten() - }) - }) - }?; - project - .elements - .get(&owner) - .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) - .map(|_| owner) -} - pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result { let element = project .elements @@ -275,40 +131,10 @@ pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Resul changed_records(&diagram.nodes, &after.nodes, &mut changed); } } - let before = interpretations(project); - let after = interpretations(&candidate); - let mut edges = BTreeMap::new(); - caller_edges(project, &mut edges); - caller_edges(&candidate, &mut edges); - let affected_owners: BTreeSet<_> = removed - .iter() - .chain(&changed) - .flat_map(|&id| { - [ - definition_owner(project, id), - definition_owner(&candidate, id), - ] - .into_iter() - .flatten() - }) - .filter(|owner| after.contains_key(owner)) - .collect(); - let affected_uses = affected_owners - .into_iter() - .map(|owner| (owner, callers(owner, &edges, &after))) - .collect(); - let interpretation = after - .iter() - .filter_map(|(&owner, next)| { - let previous = before.get(&owner)?; - (previous != next).then(|| InterpretationChange { - owner, - before: previous.clone(), - after: next.clone(), - callers: callers(owner, &edges, &after), - }) - }) - .collect(); + let crate::removal_effects::DerivedEffects { + interpretation, + affected_uses, + } = crate::removal_effects::analyze(project, &candidate, &removed, &changed); Ok(Plan { command, removed, diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 6f023afe..da4b6cb4 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -43,7 +43,6 @@ pub use port_labels::PortLabels; #[path = "diagram/focus.rs"] pub(crate) mod focus; pub(crate) mod group_gesture; -#[cfg(test)] pub(crate) mod group_removal; mod multiselect; #[cfg(test)] @@ -54,6 +53,7 @@ pub use work_drag::WorkFeedback; #[derive(Clone, Copy)] pub struct DiagramCanvas<'a> { + pub removal_input: &'a crate::removal_input::Lifetime, pub selection: Option<&'a crate::diagram_selection::Selection>, pub allocation: Option>, pub project: &'a Project, @@ -73,6 +73,8 @@ pub struct DiagramCanvas<'a> { } #[derive(Default)] pub struct State { + removal_lease: Option, + removal_viewport: Option<(f32, Vector, Rectangle)>, modifiers: iced::keyboard::Modifiers, group: Option, work: Option>, @@ -1245,6 +1247,67 @@ impl canvas::Program for DiagramCanvas<'_> { _ => None, } } +} +impl canvas::Program for DiagramCanvas<'_> { + type State = State; + fn update( + &self, + state: &mut State, + event: &canvas::Event, + bounds: Rectangle, + cursor: mouse::Cursor, + ) -> Option> { + self.removal_input.sync( + &mut state.removal_lease, + state.group.is_some() + || state.work.is_some() + || state.drag.is_some() + || state.pan_start.is_some() + || state.route_drag.is_some() + || state.connection_press.is_some() + || state.port_drag.is_some(), + ); + self.removal_input.observe(event); + let viewport = (self.zoom, self.pan, bounds); + if state + .removal_viewport + .is_some_and(|previous| previous != viewport) + { + self.removal_input.revoke_idle(); + } + state.removal_viewport = Some(viewport); + let action = self.update_input(state, event, bounds, cursor); + if matches!( + event, + canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) + | canvas::Event::Mouse(mouse::Event::CursorLeft) + ) { + // The viewport can synthesize Unfocused when it takes camera input. + // Never leave a private drag or its lease waiting for a lost release. + state.group = None; + state.work = None; + state.drag = None; + state.preview = None; + state.pan_start = None; + state.route_drag = None; + state.attachment_viewport = None; + state.connection_press = None; + state.connection_hover = None; + state.port_drag = None; + state.pointer = None; + } + self.removal_input.sync( + &mut state.removal_lease, + state.group.is_some() + || state.work.is_some() + || state.drag.is_some() + || state.pan_start.is_some() + || state.route_drag.is_some() + || state.connection_press.is_some() + || state.port_drag.is_some(), + ); + action + } fn draw( &self, state: &State, @@ -2330,6 +2393,7 @@ mod tests { y: original.y - 200., }; let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -2408,6 +2472,7 @@ mod tests { .unwrap() .id; let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -2537,6 +2602,7 @@ mod tests { }; let session = Session::new(&p, &view, context, Tool::FunctionalExchange); let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -2713,6 +2779,7 @@ mod tests { for zoom in [0.5, 1., 1.5] { let (p, diagram, edge) = route_fixture(); let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -2792,6 +2859,7 @@ mod tests { fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() { let (p, diagram, edge) = route_fixture(); let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -2892,6 +2960,7 @@ mod tests { fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() { let (p, diagram, edge) = route_fixture(); let mut canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -3023,6 +3092,7 @@ mod tests { assert!(!svg.contains("data-navigation=\"source\"")); assert!(svg.contains("data-navigation=\"target\"")); let mut canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -3080,6 +3150,7 @@ mod tests { offset: Position { x: 0., y: -160. }, }); let mut canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -3157,6 +3228,7 @@ mod tests { }, }); let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, @@ -3193,6 +3265,7 @@ mod tests { routed.edges.insert(duplicate, duplicate_route); let selected = edge.max(duplicate); // UUID ordering must not override selection. let canvas = DiagramCanvas { + removal_input: &crate::removal_input::Lifetime::default(), selection: None, project: &p, view_index: None, diff --git a/crates/oa-desktop/src/diagram/allocation_group_tests.rs b/crates/oa-desktop/src/diagram/allocation_group_tests.rs index ca4936a4..fe52a80a 100644 --- a/crates/oa-desktop/src/diagram/allocation_group_tests.rs +++ b/crates/oa-desktop/src/diagram/allocation_group_tests.rs @@ -98,6 +98,7 @@ fn fixture(control: Control, cover: Option) -> Fixture { } fn canvas(app: &App) -> DiagramCanvas<'_> { DiagramCanvas { + removal_input: &app.saved_removal_input, selection: app.diagram_selection.as_ref(), allocation: Some(app.allocation_capability()), project: app.project(), diff --git a/crates/oa-desktop/src/diagram/attachment_tests.rs b/crates/oa-desktop/src/diagram/attachment_tests.rs index 974df43f..da8a9a1b 100644 --- a/crates/oa-desktop/src/diagram/attachment_tests.rs +++ b/crates/oa-desktop/src/diagram/attachment_tests.rs @@ -34,6 +34,7 @@ fn fixture() -> App { fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, allocation: None, project: app.project(), diff --git a/crates/oa-desktop/src/diagram/group_removal.rs b/crates/oa-desktop/src/diagram/group_removal.rs index f6bcc43d..cfa6abdf 100644 --- a/crates/oa-desktop/src/diagram/group_removal.rs +++ b/crates/oa-desktop/src/diagram/group_removal.rs @@ -14,6 +14,9 @@ pub(crate) struct Plan { /// Actual identity and surviving-record differences after the complete batch. pub(crate) removed: BTreeSet, pub(crate) changed: BTreeSet, + pub(crate) interpretation: Vec, + /// Changed or deleted definitions and surviving transitive callers. + pub(crate) affected_uses: BTreeMap>, } pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result { @@ -126,11 +129,17 @@ pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Res changed_records(&diagram.nodes, &after.nodes, &mut changed); } } + let crate::removal_effects::DerivedEffects { + interpretation, + affected_uses, + } = crate::removal_effects::analyze(project, &candidate, &removed, &changed); Ok(Plan { command, selected, removed, changed, + interpretation, + affected_uses, }) } diff --git a/crates/oa-desktop/src/diagram/group_removal/tests.rs b/crates/oa-desktop/src/diagram/group_removal/tests.rs index d1f8a689..d25fbdd4 100644 --- a/crates/oa-desktop/src/diagram/group_removal/tests.rs +++ b/crates/oa-desktop/src/diagram/group_removal/tests.rs @@ -894,3 +894,265 @@ fn scenario_surface_and_invalid_duplicate_placements_are_rejected_before_plannin .contains("duplicate_placement") ); } + +fn sequence(project: &mut Project, layer: Layer, name: &str) -> Id { + project + .add_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + name, + None, + ) + .unwrap() +} + +fn contribution(project: &mut Project, owner: Id, body: OccurrenceBody) -> Id { + let layer = project.elements[&owner].layer(); + let id = project + .add_element(Kind::Occurrence, layer, "Contribution", Some(owner)) + .unwrap(); + if let Some(SharedBody::Occurrence { body: current, .. }) = + project.elements.get_mut(&id).unwrap().shared_mut() + { + *current = body; + } else { + panic!("Expected occurrence"); + } + project + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +fn exchange_contribution(project: &mut Project, owner: Id, exchange: Id) -> Id { + contribution( + project, + owner, + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ) +} + +fn reuse(project: &mut Project, owner: Id, definition: Id) -> Id { + contribution( + project, + owner, + OccurrenceBody::SequenceReference { + sequence: Some(definition), + }, + ) +} + +#[test] +fn architecture_exchange_deletion_reports_implicit_order_and_unchanged_stored_callers() { + use crate::removal_effects::{Interpretation, InterpretationChange}; + for layer in [Layer::Oa, Layer::Sa] { + for explicit in [false, true] { + let mut project = Project::empty(); + let work_kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let exchange_kind = if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }; + let a = project.add_element(work_kind, layer, "A", None).unwrap(); + let b = project.add_element(work_kind, layer, "B", None).unwrap(); + let retained_exchange = project.relate(exchange_kind, "Retained exchange", a, b); + let deleted_exchange = project.relate(exchange_kind, "Deleted exchange", a, b); + let owner = sequence(&mut project, layer, "Definition"); + let first = exchange_contribution(&mut project, owner, retained_exchange); + let middle = exchange_contribution(&mut project, owner, deleted_exchange); + let last = exchange_contribution(&mut project, owner, retained_exchange); + if explicit { + contribution( + &mut project, + owner, + OccurrenceBody::Action { + action: Some(a), + lifeline: None, + }, + ); + } + let caller = sequence(&mut project, layer, "Caller"); + let caller_use = reuse(&mut project, caller, owner); + reuse(&mut project, caller, owner); + let outer = sequence(&mut project, layer, "Outer"); + let outer_use = reuse(&mut project, outer, caller); + let unrelated = sequence(&mut project, layer, "Unrelated"); + let view = diagram(&mut project, layer, ProjectionKind::Overview); + project.place(view, a, pos(0., 0.)); + project.place(view, b, pos(700., 0.)); + project + .diagrams + .get_mut(&view) + .unwrap() + .routes + .push(route(deleted_exchange)); + let mut expected = project.clone(); + expected.relations.remove(&deleted_exchange); + expected.elements.remove(&middle); + expected + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .retain(|id| *id != middle); + expected.diagrams.get_mut(&view).unwrap().routes.clear(); + let plan = check( + &project, + view, + &[Target::Edge(deleted_exchange)], + &expected, + &[deleted_exchange], + &[deleted_exchange, middle], + &[owner, view], + ); + assert_eq!( + plan.affected_uses, + BTreeMap::from([(owner, BTreeSet::from([caller, outer]))]) + ); + for id in [caller, caller_use, outer, outer_use, unrelated] { + assert_eq!(expected.elements[&id], project.elements[&id]); + assert!(!plan.changed.contains(&id)); + } + if explicit { + assert!( + plan.interpretation.is_empty(), + "Unchanged explicit modes do not imply unchanged reused meaning" + ); + } else { + assert_eq!( + plan.interpretation, + vec![InterpretationChange { + owner, + before: Interpretation { + local_order: Some(vec![first, middle, last]), + expanded_order: Some(vec![first, middle, last]), + contextual_analysis: false + }, + after: Interpretation { + local_order: Some(vec![first, last]), + expanded_order: Some(vec![first, last]), + contextual_analysis: false + }, + callers: BTreeSet::from([caller, outer]), + }] + ); + } + } + } +} + +#[test] +fn architecture_deleted_definitions_keep_surviving_callers_from_before_graph() { + for layer in [Layer::Oa, Layer::Sa] { + for delete_caller in [false, true] { + let mut project = Project::empty(); + let owner = sequence(&mut project, layer, "Deleted definition"); + let marker = contribution( + &mut project, + owner, + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let caller = sequence(&mut project, layer, "Caller"); + let caller_use = reuse(&mut project, caller, owner); + let outer = sequence(&mut project, layer, "Surviving outer"); + let outer_use = reuse(&mut project, outer, caller); + let view = diagram(&mut project, layer, ProjectionKind::Overview); + let owner_node = project.place(view, owner, pos(0., 0.)); + let caller_node = project.place(view, caller, pos(700., 0.)); + let mut targets = vec![Target::Placement(owner_node)]; + let mut selected = vec![owner]; + let mut removed = vec![owner, marker, owner_node]; + let mut expected = project.clone(); + for id in [owner, marker] { + expected.elements.remove(&id); + } + expected + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .remove(&owner_node); + let cleared_reference = if delete_caller { + targets.push(Target::Placement(caller_node)); + selected.push(caller); + removed.extend([caller, caller_use, caller_node]); + expected.elements.remove(&caller); + expected.elements.remove(&caller_use); + expected + .diagrams + .get_mut(&view) + .unwrap() + .nodes + .remove(&caller_node); + outer_use + } else { + caller_use + }; + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::SequenceReference { sequence }, + .. + }) = expected + .elements + .get_mut(&cleared_reference) + .unwrap() + .shared_mut() + { + *sequence = None; + } else { + panic!("Expected reuse"); + } + let plan = check( + &project, + view, + &targets, + &expected, + &selected, + &removed, + &[view, cleared_reference], + ); + let affected = if delete_caller { + BTreeMap::from([ + (owner, BTreeSet::from([outer])), + (caller, BTreeSet::from([outer])), + (outer, BTreeSet::new()), + ]) + } else { + BTreeMap::from([ + (owner, BTreeSet::from([caller, outer])), + (caller, BTreeSet::from([outer])), + ]) + }; + assert_eq!(plan.affected_uses, affected); + assert!( + plan.interpretation.is_empty(), + "Deleted definitions have no after interpretation; unresolved reuse retains explicit mode" + ); + assert_eq!(expected.elements[&outer], project.elements[&outer]); + assert!(!plan.changed.contains(&outer)); + } + } +} diff --git a/crates/oa-desktop/src/diagram/lifecycle_tests.rs b/crates/oa-desktop/src/diagram/lifecycle_tests.rs index b9ff22e2..235cfe9a 100644 --- a/crates/oa-desktop/src/diagram/lifecycle_tests.rs +++ b/crates/oa-desktop/src/diagram/lifecycle_tests.rs @@ -53,6 +53,7 @@ fn bounds() -> Rectangle { } fn canvas(app: &App) -> DiagramCanvas<'_> { DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, diff --git a/crates/oa-desktop/src/diagram/multiselect.rs b/crates/oa-desktop/src/diagram/multiselect.rs index 5f95b92c..c78d8eaf 100644 --- a/crates/oa-desktop/src/diagram/multiselect.rs +++ b/crates/oa-desktop/src/diagram/multiselect.rs @@ -178,10 +178,25 @@ impl DiagramCanvas<'_> { } if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) { let snapshot = selection.snapshot(context(self.context))?; + if *modifiers == iced::keyboard::Modifiers::SHIFT { + let Some(input_generation) = self.removal_input.idle() else { + return Some(Action::capture()); + }; + return Some( + Action::publish(Message::SavedRemoval(Box::new( + crate::app::saved_removal::Event::Begin { + input_generation, + context: self.context, + snapshot: Box::new(snapshot), + }, + ))) + .and_capture(), + ); + } let action = if modifiers.is_empty() { Event::Hide(snapshot) } else { - Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into()) + Event::Refuse(snapshot, "Use plain Delete to hide placements or Shift+Delete to review deletion from the model.".into()) }; return Some(Action::publish(Message::DiagramSelection(action)).and_capture()); } diff --git a/crates/oa-desktop/src/diagram/multiselect_tests.rs b/crates/oa-desktop/src/diagram/multiselect_tests.rs index 0152ec1e..6440fd53 100644 --- a/crates/oa-desktop/src/diagram/multiselect_tests.rs +++ b/crates/oa-desktop/src/diagram/multiselect_tests.rs @@ -18,6 +18,7 @@ fn bounds() -> Rectangle { } struct Fixture { + removal_input: crate::removal_input::Lifetime, project: Project, diagram: Id, a: Id, @@ -70,6 +71,7 @@ impl Fixture { .collect(); validate_candidate(&project).unwrap(); Self { + removal_input: Default::default(), project, diagram, a, @@ -105,6 +107,7 @@ impl Fixture { _ => self.diagram, }; DiagramCanvas { + removal_input: &self.removal_input, selection: Some(selection), project: &self.project, view_index: None, diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs index 746fd5db..fa4bbce8 100644 --- a/crates/oa-desktop/src/diagram/pointer_tests.rs +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point { } fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs index dc312fd1..39df5566 100644 --- a/crates/oa-desktop/src/diagram/port_hit_tests.rs +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -1,7 +1,13 @@ use super::*; -fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> { +fn canvas<'a>( + project: &'a Project, + diagram: Id, + zoom: f32, + removal_input: &'a crate::removal_input::Lifetime, +) -> DiagramCanvas<'a> { DiagramCanvas { + removal_input, selection: None, project, view_index: None, @@ -58,7 +64,8 @@ fn nearest_port_center_wins_on_all_borders_and_zoom_levels() { ..Default::default() }; for zoom in [0.0001, 0.35, 1., 3.] { - let canvas = canvas(&project, diagram, zoom); + let removal_input = crate::removal_input::Lifetime::default(); + let canvas = canvas(&project, diagram, zoom, &removal_input); for _ in 0..2 { for port in &view.ports { assert_eq!( @@ -91,7 +98,8 @@ fn coincident_ports_keep_selection_then_use_stable_identity() { .collect(), ..Default::default() }; - let mut canvas = canvas(&project, diagram, 0.35); + let removal_input = crate::removal_input::Lifetime::default(); + let mut canvas = canvas(&project, diagram, 0.35, &removal_input); let at = local(&canvas, view.ports[0].position); for _ in 0..2 { canvas.selected = Id::nil(); @@ -147,7 +155,8 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() { }) .collect(); validate_candidate(&project).unwrap(); - let canvas = canvas(&project, diagram, 0.35); + let removal_input = crate::removal_input::Lifetime::default(); + let canvas = canvas(&project, diagram, 0.35, &removal_input); let view = project_view(&project, diagram); let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.)); for port in &view.ports { diff --git a/crates/oa-desktop/src/diagram/port_labels.rs b/crates/oa-desktop/src/diagram/port_labels.rs index 0e6a03cb..78782367 100644 --- a/crates/oa-desktop/src/diagram/port_labels.rs +++ b/crates/oa-desktop/src/diagram/port_labels.rs @@ -177,6 +177,7 @@ mod tests { fn program(app: &App) -> PortLabels<'_> { PortLabels(DiagramCanvas { + removal_input: &app.saved_removal_input, selection: None, project: app.project(), view_index: None, diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 188a21f2..c0dbe24b 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -26,6 +26,8 @@ mod port_edit; mod port_preview; mod port_workflow; mod recovery; +mod removal_effects; +mod removal_input; mod route_edit; mod semantic_review; mod source_annotations; diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index c11d5c26..a5482fa2 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -77,7 +77,13 @@ impl Opened { { return None; } - let scenario_selection = if request.scenario.is_some() || request.process.is_some() { + let scenario_selection = if request.scenario.is_some() + || request.process.is_some() + || (request.canvas.is_some() + && app + .multiselect_snapshot() + .is_some_and(|s| s.targets().len() > 1)) + { Some(app.multiselect_snapshot()?) } else { None @@ -463,7 +469,7 @@ pub fn popup(app: &App) -> Option> { )); } if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1) - && (selected || request.process.is_some()) + && (selected || request.process.is_some() || request.canvas.is_some()) { entries.push(Entry::title(format!( "{} selected", @@ -508,9 +514,9 @@ pub fn popup(app: &App) -> Option> { )); } if request.process.is_none() { - entries.push(Entry::disabled( - "Delete selected from model (not yet available)", - )); + if let Some(removal) = crate::app::saved_removal::begin(app) { + entries.push(item("Delete selected from model…", removal, true)); + } } return Some(Menu { token: opened.token, diff --git a/crates/oa-desktop/src/removal_effects.rs b/crates/oa-desktop/src/removal_effects.rs new file mode 100644 index 00000000..0084b25c --- /dev/null +++ b/crates/oa-desktop/src/removal_effects.rs @@ -0,0 +1,230 @@ +//! Definition-level Process/FunctionalChain effects of a complete removal candidate. +//! Stored record differences and unchanged interpretation modes do not establish +//! unchanged caller behavior. This bounded analysis does not expand reuse trees. +use oa_core::topology_context::TopologyIndex; +use oa_core::{Id, Kind, OccurrenceBody, Project, RelationKind, SharedBody}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug)] +pub(crate) struct DerivedEffects { + pub(crate) interpretation: Vec, + /// Definitions whose records or authored topology changed, including deleted + /// definitions. Values identify surviving direct and transitive callers in + /// either project, even if their own stored records or modes did not change. + pub(crate) affected_uses: BTreeMap>, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InterpretationChange { + pub(crate) owner: Id, + pub(crate) before: Interpretation, + pub(crate) after: Interpretation, + pub(crate) callers: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Interpretation { + /// None means explicit; Some includes the empty legacy order. + pub(crate) local_order: Option>, + /// Definition-level implicit order used within contextual expansion. This + /// does not claim complete expanded connectivity or boundary analysis. + pub(crate) expanded_order: Option>, + pub(crate) contextual_analysis: bool, +} + +fn exchange(project: &Project, id: Id) -> bool { + matches!( + project.elements.get(&id).and_then(|e| e.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + ) +} + +fn interpretations(project: &Project) -> BTreeMap { + let index = TopologyIndex::new(project); + // This is exactly TopologyIndex::with_cache_capacity's relation ownership + // rule. Its link_definitions is private; do not expand repeated reuse trees + // just to answer this finite definition-level question. + let linked: BTreeSet<_> = project + .relations + .values() + .filter_map(|relation| { + relation.contextual_owner().or_else(|| { + (relation.kind() == RelationKind::SequenceLink) + .then(|| { + project + .elements + .get(&relation.source()) + .and_then(|e| e.parent) + }) + .flatten() + }) + }) + .collect(); + project + .elements + .values() + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|e| { + let steps = e.steps().expect("Sequence definitions have ordered steps"); + // All-exchange steps exclude every non-exchange explicit marker, so only + // local SequenceLinks remain to test (the desktop discriminator). + // legacy_order alone loses the explicit-versus-empty distinction. + let local_order = (!project.relations.values().any(|relation| { + relation.kind() == RelationKind::SequenceLink + && project + .elements + .get(&relation.source()) + .and_then(|element| element.parent) + == Some(e.id) + && project + .elements + .get(&relation.target()) + .and_then(|element| element.parent) + == Some(e.id) + }) && steps.iter().all(|id| exchange(project, *id))) + .then(|| steps.clone()); + let children = index.contributions(e.id); + // Mirrors topology_context/analysis.rs Builder::expand's legacy-order + // discriminator: all indexed contributions (including unlisted ones), + // and neither SequenceLink nor ContextualLink definitions. Tests retain + // literal discriminators where this intentionally differs from desktop. + let expanded_order = (!linked.contains(&e.id) + && children.iter().all(|id| exchange(project, *id))) + .then_some(children); + ( + e.id, + Interpretation { + local_order, + expanded_order, + contextual_analysis: index.has_contextual_links(e.id), + }, + ) + }) + .collect() +} + +fn caller_edges(project: &Project, edges: &mut BTreeMap>) { + for element in project.elements.values() { + if let ( + Some(parent), + Some(SharedBody::Occurrence { + body: + OccurrenceBody::SequenceReference { + sequence: Some(sequence), + }, + .. + }), + ) = (element.parent, element.shared()) + { + edges.entry(*sequence).or_default().insert(parent); + } + } +} + +fn callers( + owner: Id, + edges: &BTreeMap>, + surviving: &BTreeMap, +) -> BTreeSet { + let mut seen = BTreeSet::from([owner]); + let mut pending = vec![owner]; + while let Some(sequence) = pending.pop() { + for &caller in edges.get(&sequence).into_iter().flatten() { + if seen.insert(caller) { + pending.push(caller); + } + } + } + seen.remove(&owner); + seen.retain(|id| surviving.contains_key(id)); + seen +} + +fn definition_owner(project: &Project, id: Id) -> Option { + let owner = if let Some(element) = project.elements.get(&id) { + if matches!(element.kind(), Kind::Process | Kind::FunctionalChain) { + Some(id) + } else { + element.parent.filter(|_| { + matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Action { .. } + | OccurrenceBody::Exchange { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. }, + .. + }) + ) + }) + } + } else { + project.relations.get(&id).and_then(|relation| { + relation.contextual_owner().or_else(|| { + (relation.kind() == RelationKind::SequenceLink) + .then(|| { + project + .elements + .get(&relation.source()) + .and_then(|e| e.parent) + }) + .flatten() + }) + }) + }?; + project + .elements + .get(&owner) + .filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain)) + .map(|_| owner) +} + +/// Analyze the actual accepted before/after projects and their complete record +/// differences. Both graphs participate: deleting a definition can clear its +/// surviving reference records, but must not erase their previous dependency. +pub(crate) fn analyze( + project: &Project, + candidate: &Project, + removed: &BTreeSet, + changed: &BTreeSet, +) -> DerivedEffects { + let before = interpretations(project); + let after = interpretations(candidate); + let mut edges = BTreeMap::new(); + caller_edges(project, &mut edges); + caller_edges(candidate, &mut edges); + let affected_owners: BTreeSet<_> = removed + .iter() + .chain(changed) + .flat_map(|&id| { + [ + definition_owner(project, id), + definition_owner(candidate, id), + ] + .into_iter() + .flatten() + }) + .collect(); + let affected_uses = affected_owners + .into_iter() + .map(|owner| (owner, callers(owner, &edges, &after))) + .collect(); + let interpretation = after + .iter() + .filter_map(|(&owner, next)| { + let previous = before.get(&owner)?; + (previous != next).then(|| InterpretationChange { + owner, + before: previous.clone(), + after: next.clone(), + callers: callers(owner, &edges, &after), + }) + }) + .collect(); + DerivedEffects { + interpretation, + affected_uses, + } +} diff --git a/crates/oa-desktop/src/behavior/topology/removal_input.rs b/crates/oa-desktop/src/removal_input.rs similarity index 63% rename from crates/oa-desktop/src/behavior/topology/removal_input.rs rename to crates/oa-desktop/src/removal_input.rs index 276fd8aa..4d49125f 100644 --- a/crates/oa-desktop/src/behavior/topology/removal_input.rs +++ b/crates/oa-desktop/src/removal_input.rs @@ -9,18 +9,50 @@ use std::sync::{ }; #[derive(Clone, Default)] -pub(super) struct Lifetime(Arc); +pub(crate) struct Lifetime(Arc); impl Lifetime { + /// Keep a retained canvas's private gesture ownership visible to App even + /// when its update returns early or emits no application message. + pub(crate) fn sync(&self, lease: &mut Option, busy: bool) { + if !busy + || lease + .as_ref() + .is_some_and(|lease| !Arc::ptr_eq(&self.0, &lease.lifetime.0)) + { + *lease = None; + } + if busy && lease.is_none() { + *lease = self.acquire(); + } + } + + pub(crate) fn observe(&self, event: &iced::widget::canvas::Event) { + use iced::{keyboard, mouse, widget::canvas, window}; + let boundary = matches!( + event, + canvas::Event::Window(window::Event::Unfocused | window::Event::Resized(_)) + | canvas::Event::Mouse( + mouse::Event::ButtonPressed(_) + | mouse::Event::WheelScrolled { .. } + | mouse::Event::CursorLeft + ) + ) || matches!(event, canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, .. }) + if *key != keyboard::Key::Named(keyboard::key::Named::Delete)); + if boundary { + self.revoke_idle(); + } + } + /// Even generations are idle; odd generations belong to one live gesture. - pub(super) fn idle(&self) -> Option { + pub(crate) fn idle(&self) -> Option { let generation = self.0.load(Ordering::SeqCst); generation.is_multiple_of(2).then_some(generation) } /// Retire an idle packet at a native input boundary (including the /// viewport's synthetic Unfocused event when it takes camera ownership). - pub(super) fn revoke_idle(&self) { + pub(crate) fn revoke_idle(&self) { if let Some(generation) = self.idle() { // Keep overflow terminal and unavailable rather than reusing zero. let next = generation.saturating_add(2); @@ -30,7 +62,7 @@ impl Lifetime { } } - pub(super) fn acquire(&self) -> Option { + pub(crate) fn acquire(&self) -> Option { let generation = self.idle()?; let active = generation.checked_add(1)?; self.0 @@ -43,7 +75,7 @@ impl Lifetime { } } -pub(super) struct Lease { +pub(crate) struct Lease { lifetime: Lifetime, active: u64, } diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index c03006d2..ddcf64ef 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -326,6 +326,13 @@ pub fn view(app: &App) -> View<'_, Message> { ); } if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) { + if target.saved.is_some() { + return modal_width( + root, + crate::app::saved_removal::view::view(app, target), + 700., + ); + } if target.process.is_some() { return modal_width( root, @@ -482,6 +489,7 @@ fn diagram(app: &App) -> View<'_, Message> { .align_y(iced::Center); let canvas: View<'_, Message> = if let Some(id) = app.active_diagram { let program = DiagramCanvas { + removal_input: &app.saved_removal_input, selection: app.diagram_selection.as_ref(), allocation: Some(app.allocation_capability()), project: app.project(), @@ -674,17 +682,25 @@ fn diagram(app: &App) -> View<'_, Message> { )), )); } + selection_controls = selection_controls.push( + button(text("Delete from model…").size(12)) + .padding([6, 8]) + .on_press_maybe( + crate::app::saved_removal::begin(app) + .filter(|_| app.saved_removal_enabled()), + ), + ); selection_controls = selection_controls .push(action( - "Clear selection", + "Clear", Message::DiagramSelection(crate::app::multiselect::Event::Clear( snapshot.context(), )), )) .push( label(format!( - "Properties edit only {}", - short(app.project().named(app.selected), 36) + "Edit only {}", + short(app.project().named(app.selected), 12) )) .wrapping(widget::text::Wrapping::None), );