Wire captured saved-model group deletion and shared impact review

This commit is contained in:
2026-09-24 22:12:36 -07:00
parent 8688faf8c5
commit 3ab63ef028
36 changed files with 2205 additions and 365 deletions
@@ -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(),
+37 -14
View File
@@ -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<scenario_reorder::Event>),
ScenarioRemoval(Box<scenario_removal::Event>),
ProcessRemoval(Box<process_removal::Event>),
SavedRemoval(Box<saved_removal::Event>),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
@@ -540,6 +543,7 @@ pub struct App {
pub selected: Id,
pub selected_node: Option<Id>,
pub diagram_selection: Option<crate::diagram_selection::Selection>,
pub(crate) saved_removal_input: crate::removal_input::Lifetime,
pub draft: Option<Draft>,
pub active_diagram: Option<Id>,
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();
}
@@ -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(),
@@ -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,
@@ -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,
@@ -17,6 +17,7 @@ pub struct Confirmation {
pub behavior_owner: Option<Id>,
pub(crate) scenario: Option<super::scenario_removal::Review>,
pub(crate) process: Option<super::process_removal::Review>,
pub(crate) saved: Option<super::saved_removal::Review>,
}
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();
+8 -1
View File
@@ -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,
}))
@@ -126,6 +126,7 @@ impl App {
placement: None,
behavior_owner: Some(context.owner),
scenario: None,
saved: None,
process: Some(Review {
input_generation,
context,
+25 -144
View File
@@ -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<ImpactRow> {
let aliases: BTreeMap<Id, &str> = 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<super::super::removal_review::ImpactRow> {
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>(
+148
View File
@@ -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<Id>,
changed: &BTreeSet<Id>,
selected: &BTreeSet<Id>,
interpretation: &[crate::removal_effects::InterpretationChange],
affected_uses: &BTreeMap<Id, BTreeSet<Id>>,
query: &str,
) -> Vec<ImpactRow> {
let aliases: BTreeMap<Id, &str> = 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()
}
+279
View File
@@ -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<Snapshot>,
},
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<Context> {
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(&current.diagram)?;
(saved.projection.is_architecture() || saved.projection.state_machine().is_some())
.then_some(current)
}
pub(crate) fn begin(app: &App) -> Option<Message> {
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<Message> {
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}"),
}
}
}
@@ -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<Id>,
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(&current, &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());
}
@@ -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::ImpactRow> {
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()
}
@@ -108,6 +108,7 @@ impl App {
placement: None,
behavior_owner: Some(context.scenario),
process: None,
saved: None,
scenario: Some(Review {
context,
snapshot,
@@ -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()
+67 -1
View File
@@ -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<crate::removal_input::Lease>,
removal_viewport: Option<(f32, Vector, Rectangle)>,
context: Option<Context>,
tool: Option<Tool>,
pointer: Option<Point>,
@@ -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<Event> for StateCanvas<'_> {
_ => None,
}
}
}
impl canvas::Program<Event> for StateCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Event>> {
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 {
+4 -1
View File
@@ -26,6 +26,7 @@ pub enum Tool {
#[derive(Debug, Clone)]
pub enum Event {
Selection(crate::app::multiselect::Event),
ModelRemoval(Box<crate::app::saved_removal::Event>),
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, _)
+12 -3
View File
@@ -43,6 +43,7 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
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),
);
+1 -1
View File
@@ -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)]
@@ -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<Id, BTreeSet<Id>>,
}
#[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<Id>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Interpretation {
/// None means explicit; Some includes the empty legacy order.
pub(crate) local_order: Option<Vec<Id>>,
/// Definition-level implicit order used within contextual expansion. This
/// does not claim complete expanded connectivity or boundary analysis.
pub(crate) expanded_order: Option<Vec<Id>>,
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<Id, Interpretation> {
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<Id, BTreeSet<Id>>) {
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<Id, BTreeSet<Id>>,
surviving: &BTreeMap<Id, Interpretation>,
) -> BTreeSet<Id> {
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<T: PartialEq>(
before: &BTreeMap<Id, T>,
after: &BTreeMap<Id, T>,
@@ -143,36 +29,6 @@ fn changed_records<T: PartialEq>(
);
}
fn definition_owner(project: &Project, id: Id) -> Option<Id> {
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<Plan, String> {
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,
+74 -1
View File
@@ -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<crate::app::allocation_drag::Capability<'a>>,
pub project: &'a Project,
@@ -73,6 +73,8 @@ pub struct DiagramCanvas<'a> {
}
#[derive(Default)]
pub struct State {
removal_lease: Option<crate::removal_input::Lease>,
removal_viewport: Option<(f32, Vector, Rectangle)>,
modifiers: iced::keyboard::Modifiers,
group: Option<multiselect::Press>,
work: Option<Arc<crate::app::allocation_drag::Press>>,
@@ -1245,6 +1247,67 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
_ => None,
}
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
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,
@@ -98,6 +98,7 @@ fn fixture(control: Control, cover: Option<Kind>) -> 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(),
@@ -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(),
@@ -14,6 +14,9 @@ pub(crate) struct Plan {
/// Actual identity and surviving-record differences after the complete batch.
pub(crate) removed: BTreeSet<Id>,
pub(crate) changed: BTreeSet<Id>,
pub(crate) interpretation: Vec<crate::removal_effects::InterpretationChange>,
/// Changed or deleted definitions and surviving transitive callers.
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
}
pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result<Plan, String> {
@@ -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,
})
}
@@ -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));
}
}
}
@@ -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,
+16 -1
View File
@@ -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());
}
@@ -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,
@@ -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,
@@ -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 {
@@ -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,
+2
View File
@@ -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;
+11 -5
View File
@@ -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<Menu<Message>> {
));
}
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<Menu<Message>> {
));
}
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,
+230
View File
@@ -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<InterpretationChange>,
/// 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<Id, BTreeSet<Id>>,
}
#[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<Id>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Interpretation {
/// None means explicit; Some includes the empty legacy order.
pub(crate) local_order: Option<Vec<Id>>,
/// Definition-level implicit order used within contextual expansion. This
/// does not claim complete expanded connectivity or boundary analysis.
pub(crate) expanded_order: Option<Vec<Id>>,
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<Id, Interpretation> {
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<Id, BTreeSet<Id>>) {
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<Id, BTreeSet<Id>>,
surviving: &BTreeMap<Id, Interpretation>,
) -> BTreeSet<Id> {
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<Id> {
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<Id>,
changed: &BTreeSet<Id>,
) -> 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,
}
}
@@ -9,18 +9,50 @@ use std::sync::{
};
#[derive(Clone, Default)]
pub(super) struct Lifetime(Arc<AtomicU64>);
pub(crate) struct Lifetime(Arc<AtomicU64>);
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<Lease>, 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<u64> {
pub(crate) fn idle(&self) -> Option<u64> {
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<Lease> {
pub(crate) fn acquire(&self) -> Option<Lease> {
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,
}
+19 -3
View File
@@ -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),
);