Wire captured saved-model group deletion and shared impact review
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(¤t.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(¤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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, _)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+37
-5
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user