Preserve authored projection content and contextual canvas selection

This commit is contained in:
2026-09-24 22:40:21 -07:00
parent 715036e132
commit 0341bca846
24 changed files with 958 additions and 62 deletions
+60 -6
View File
@@ -340,6 +340,18 @@ pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
},
}
}
pub(crate) fn diagnostic_with_issue(
source: &str,
error: Error,
issue: Option<validation::Issue>,
) -> Diagnostic {
let mut result = diagnostic(source, error);
if let Some(issue) = issue {
result.object_id = issue.element;
result.field = issue.field;
}
result
}
fn reserved(word: &str) -> bool {
crate::reserved_alias(word)
|| declaration_kind(word).is_some()
@@ -622,6 +634,7 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
return v2_source::parse(source, tokens, limits);
}
let mut migration_issue = None;
Parser {
source,
tokens,
@@ -629,8 +642,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
value_depth: 0,
limits,
}
.document()
.map_err(|e| vec![diagnostic(source, e)])
.document(&mut migration_issue)
.map_err(|e| vec![diagnostic_with_issue(source, e, migration_issue)])
}
fn offset_for(source: &str, line: usize, column: usize) -> usize {
let mut offset = 0;
@@ -1449,7 +1462,10 @@ impl Parser<'_> {
},
}
}
fn document(&mut self) -> Result<ParsedDocument, Error> {
fn document(
&mut self,
migration_issue: &mut Option<validation::Issue>,
) -> Result<ParsedDocument, Error> {
let unit = self.unit(true, false)?;
let header = unit.header.expect("entry header");
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
@@ -1476,9 +1492,14 @@ impl Parser<'_> {
.map(|(key, span)| ((d.id, key.clone()), span.start)),
);
}
let decoded =
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
.map_err(|(_, error)| error)?;
let decoded = resolve_declarations(
header.project,
&unit.declarations,
&aliases,
self.limits,
migration_issue,
)
.map_err(|(_, error)| error)?;
Ok(ParsedDocument {
project: decoded.project,
migration: decoded.migration,
@@ -1682,6 +1703,7 @@ pub(crate) fn resolve_declarations(
declarations: &[Declaration],
aliases: &BTreeMap<String, Id>,
limits: ParseLimits,
migration_issue: &mut Option<validation::Issue>,
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
let legacy = project.schema_version == 3;
let mut legacy_elements = serde_json::Map::new();
@@ -1989,6 +2011,38 @@ pub(crate) fn resolve_declarations(
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
// These frozen admission checks formerly ran in final validation.
// Keep their diagnostic code and reference location after moving
// them before schema normalization.
if let crate::persistence::FileError::Model { issues, .. } = &error
&& let Some(issue) = issues.first()
&& let Some(code) = match issue.code.as_str() {
"diagram_layer" => Some("diagram_layer"),
"view_options" => Some("view_options"),
_ => None,
}
{
let index = issue
.element
.and_then(|id| declarations.iter().position(|d| d.id == id))
.unwrap_or(usize::MAX);
let offset = declarations.get(index).map_or(0, |d| {
issue
.field
.as_ref()
.and_then(|field| d.field_spans.get(field))
.map_or(d.offset, |span| span.start)
});
*migration_issue = Some(issue.clone());
return (
index,
Error {
code,
offset,
message: issue.message.clone(),
},
);
}
(
usize::MAX,
Error {
+9 -3
View File
@@ -69,8 +69,8 @@ impl Project4 {
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
let mut project = Project {
schema_version: self.schema_version,
id: self.id,
alias: self.alias,
name: self.name,
@@ -84,7 +84,11 @@ impl Project4 {
.into_iter()
.map(|(id, trace)| (id, trace.into()))
.collect(),
})
};
// Schema3's adapter also enters here, without calling `finish`.
crate::persistence::check_pre7_diagram_admission(&project)?;
project.schema_version = SCHEMA_VERSION;
Ok(project)
}
}
@@ -104,6 +108,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
// Source4 enters here directly rather than through `Project4`.
crate::persistence::check_pre7_diagram_admission(&project)?;
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
+46
View File
@@ -341,6 +341,50 @@ pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), Fil
}
Ok(())
}
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
/// only in Traceability. Check before migration discards the advertised version;
/// current ordinary projections may retain these records as latent presentation.
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
let mut issues = Vec::new();
for diagram in project.diagrams.values() {
let option_issues = if diagram.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
} else {
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
};
for issue in option_issues {
validation::error(
&mut issues,
diagram.id,
"view_options",
issue.field,
issue.message,
);
}
for node in diagram.nodes.values() {
if !project.elements.get(&node.element).is_some_and(|element| {
diagram.projection == ProjectionKind::Traceability
|| element.layer() == diagram.layer
}) {
validation::error(
&mut issues,
node.id,
"diagram_layer",
"element",
"Diagram nodes reference existing elements in the selected layer.",
);
}
}
}
if let Some(issue) = issues.into_iter().next() {
return Err(FileError::Model {
message: issue.message.clone(),
issues: vec![issue],
});
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
@@ -348,6 +392,7 @@ pub(crate) fn migrate_v5(
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre7_diagram_admission(&project)?;
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
@@ -420,6 +465,7 @@ pub(crate) fn migrate_v6(
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
check_pre7_diagram_admission(&project)?;
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
+1
View File
@@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
.filter(|n| {
p.elements.get(&n.element).is_some_and(|e| {
e.kind() != Kind::Port
&& (d.projection == ProjectionKind::Traceability || e.layer() == d.layer)
&& d.projection.includes(e.kind())
&& d.projection
.state_machine()
+11
View File
@@ -589,6 +589,17 @@ fn apply_command(
.map(|d| d.id)
.ok_or_else(missing)?;
if source != *diagram {
let destination = &p.diagrams[diagram];
let element = p.diagrams[&source].nodes[node].element;
if destination.projection != ProjectionKind::Traceability
&& p.elements
.get(&element)
.is_none_or(|e| e.layer() != destination.layer)
{
return Err(ModelError(
"Move the placement to a view in its layer or a traceability view.".into(),
));
}
let placement = p
.diagrams
.get_mut(&source)
+20 -10
View File
@@ -573,16 +573,26 @@ fn compile_once(
span,
}),
};
let decoded =
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic(&bundle.files[path], error),
related: Vec::new(),
}]
})?;
let mut migration_issue = None;
let decoded = language::resolve_declarations(
header.project,
&declarations,
&aliases,
limits.model,
&mut migration_issue,
)
.map_err(|(index, error)| {
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
vec![SourceDiagnostic {
path: path.clone(),
diagnostic: language::diagnostic_with_issue(
&bundle.files[path],
error,
migration_issue,
),
related: Vec::new(),
}]
})?;
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
let errors: Vec<_> = diagnostics
.iter()
+12 -7
View File
@@ -620,11 +620,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
// Ordinary projections are reversible filters over the same authored view.
// Retain opposite-layer visibility choices when leaving Traceability.
let option_issues =
if d.projection.state_machine().is_none() && d.projection.scenario().is_none() {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
for issue in option_issues {
error(&mut out, d.id, "view_options", issue.field, issue.message);
}
@@ -798,8 +801,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
);
}
match p.elements.get(&n.element) {
Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => {
}
Some(e)
if (d.projection.state_machine().is_none()
&& d.projection.scenario().is_none())
|| e.layer() == d.layer => {}
_ => error(
&mut out,
n.id,
+3 -1
View File
@@ -218,7 +218,9 @@ pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
/// Ordinary saved views retain references from either architecture layer so
/// projection changes preserve authored visibility choices. Projection decides
/// which layer is visible; typed State/Scenario views use layer-scoped validation.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
@@ -0,0 +1,203 @@
//! Current latent presentation must not broaden frozen source/JSON admission.
use oa_core::{view_options::*, *};
fn legacy_json(project: &Project, version: u32) -> Vec<u8> {
let mut value = serde_json::to_value(project).unwrap();
value["schema_version"] = version.into();
for diagram in value["diagrams"].as_object_mut().unwrap().values_mut() {
let fields = diagram.as_object_mut().unwrap();
fields.remove("layout");
if version < 6 {
fields.remove("view_options");
}
}
serde_json::to_vec(&value).unwrap()
}
fn model_issue(error: persistence::FileError) -> validation::Issue {
match error {
persistence::FileError::Model { mut issues, .. } => issues.remove(0),
other => panic!("Expected a located model issue, got {other:?}"),
}
}
#[test]
fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() {
for version in 3..=6 {
let source = format!(
"arcadia {version}; project compat \"Compatibility\" {{\n\
sa_function work \"Counterpart\" {{}}\n\
diagram view \"OA concern\" {{ layer Oa; projection Traceability; }}\n\
node occurrence {{ diagram @view; element @work; }}\n}}"
);
let mixed = language::parse(&source).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&mixed, version)).unwrap(),
mixed
);
let ordinary = source.replace("projection Traceability", "projection Overview");
let same_layer = ordinary.replace("layer Oa", "layer Sa");
let ordinary_project = language::parse(&same_layer).unwrap();
assert_eq!(
persistence::decode(&legacy_json(&ordinary_project, version)).unwrap(),
ordinary_project
);
let errors = language::parse(&ordinary).unwrap_err();
// Source3 already reported migration errors with the offending location.
let code = if version == 3 {
"migration_error"
} else {
"diagram_layer"
};
assert!(
errors.iter().any(|error| error.code == code
&& error.line == 4
&& error.message.contains("selected layer")),
"{version}: {errors:?}"
);
let mut latent = mixed.clone();
let diagram = latent.diagrams.values_mut().next().unwrap();
diagram.projection = ProjectionKind::Overview;
let node = diagram.nodes.values().next().unwrap().id;
if version >= 4 {
let diagnostic = errors
.iter()
.find(|error| error.code == "diagram_layer")
.unwrap();
assert_eq!(diagnostic.object_id, Some(node));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
let bundle = source_project::SourceBundle {
entry: "model.arcadia".into(),
files: std::collections::BTreeMap::from([("model.arcadia".into(), ordinary)]),
};
let errors =
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default())
.unwrap_err();
let diagnostic = &errors
.iter()
.find(|error| error.diagnostic.code == "diagram_layer")
.unwrap()
.diagnostic;
assert_eq!(diagnostic.object_id, Some(node));
assert_eq!(diagnostic.field.as_deref(), Some("element"));
}
let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
// The exact same record is valid under the current presentation contract.
assert_eq!(language::parse(&language::format(&latent)).unwrap(), latent);
assert_eq!(
persistence::decode(&serde_json::to_vec(&latent).unwrap()).unwrap(),
latent
);
let mut dangling = mixed;
dangling
.diagrams
.values_mut()
.next()
.unwrap()
.nodes
.values_mut()
.next()
.unwrap()
.element = Id::new_v4();
let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err());
assert_eq!(issue.code, "diagram_layer");
assert_eq!(issue.element, Some(node));
assert_eq!(issue.field.as_deref(), Some("element"));
}
}
#[test]
fn frozen_6_checks_every_layer_sensitive_saved_selector_before_upgrade() {
let mut project = Project::empty();
let a = project
.add_element(Kind::Activity, Layer::Oa, "A", None)
.unwrap();
let b = project
.add_element(Kind::Activity, Layer::Oa, "B", None)
.unwrap();
let edge = project.relate(RelationKind::Interaction, "Interaction", a, b);
let category = project
.add_element(Kind::ExchangeCategory, Layer::Oa, "Work category", None)
.unwrap();
let participant_category = project
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Participant category",
None,
)
.unwrap();
*project
.elements
.get_mut(&participant_category)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Participant,
exchanges: vec![],
};
let context = project
.add_element(Kind::Process, Layer::Oa, "Process", None)
.unwrap();
let diagram = project.ensure_diagram(Layer::Sa);
for (field, options) in [
(
"hidden_edges",
ViewOptions {
hidden_edges: vec![edge],
..Default::default()
},
),
(
"work_categories",
ViewOptions {
work_categories: CategorySelection::Any(vec![category]),
..Default::default()
},
),
(
"participant_categories",
ViewOptions {
participant_categories: CategorySelection::Any(vec![participant_category]),
..Default::default()
},
),
(
"work_contexts",
ViewOptions {
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
},
),
] {
project.diagrams.get_mut(&diagram).unwrap().view_options = options;
let issue = model_issue(persistence::decode(&legacy_json(&project, 6)).unwrap_err());
assert_eq!(issue.code, "view_options");
assert_eq!(issue.element, Some(diagram));
assert_eq!(issue.field.as_deref(), Some(field));
let current_source = language::format(&project);
assert_eq!(language::parse(&current_source).unwrap(), project);
let source6 = current_source.replacen("arcadia 7;", "arcadia 6;", 1);
let errors = language::parse(&source6).unwrap_err();
let diagnostic = errors
.iter()
.find(|error| error.code == "view_options")
.unwrap();
assert_eq!(diagnostic.object_id, Some(diagram));
assert_eq!(diagnostic.field.as_deref(), Some(field));
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
assert_eq!(
persistence::decode(&legacy_json(&project, 6)).unwrap(),
project
);
let source6 = language::format(&project).replacen("arcadia 7;", "arcadia 6;", 1);
assert_eq!(language::parse(&source6).unwrap(), project);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
}
}
@@ -0,0 +1,156 @@
use oa_core::{source_project::*, view_options::*, *};
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Assess alarm", None)
.unwrap();
let oa2 = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "Assess alarm", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Alarm", oa, oa2);
let category = p
.add_element(Kind::ExchangeCategory, Layer::Oa, "Alarm data", None)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: ExchangeFamily::Work,
exchanges: vec![edge],
};
let context = p
.add_element(Kind::Process, Layer::Oa, "Alarm handling", None)
.unwrap();
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
for (i, element) in [oa, oa2, sa].into_iter().enumerate() {
p.place(
d,
element,
Position {
x: 300. * i as f32,
y: 40.,
},
);
}
p.diagrams.get_mut(&d).unwrap().view_options = ViewOptions {
hidden_edges: vec![edge],
work_categories: CategorySelection::Any(vec![category]),
work_contexts: ContextSelection::Any(vec![context]),
..Default::default()
};
validate_candidate(&p).unwrap();
(p, d, oa, sa, category)
}
#[test]
fn projection_switching_preserves_complete_model_history_and_all_serializations() {
for layer in [Layer::Oa, Layer::Sa] {
let (baseline, diagram, _, _, _) = fixture(layer);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&diagram).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(
diagram,
expected.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &expected);
let view = projection::project_view(session.project(), diagram);
assert!(
view.nodes
.iter()
.all(|n| expected.elements[&n.element].layer() == layer)
);
assert_eq!(
view,
projection::project_view_with_index(&expected, diagram, &ViewIndex::new(&expected))
);
let source = language::format(&expected);
assert_eq!(language::parse(&source).unwrap(), expected);
assert_eq!(
persistence::decode(&serde_json::to_vec(&expected).unwrap()).unwrap(),
expected
);
let bundle = format_bundle(&expected);
assert_eq!(
compile_bundle(&bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
session.undo();
assert_eq!(session.project(), &baseline);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(
diagram,
baseline.diagrams[&diagram].clone(),
))
.unwrap();
assert_eq!(session.project(), &baseline);
assert_eq!(
projection::project_view(session.project(), diagram),
projection::project_view(&baseline, diagram)
);
}
}
}
#[test]
fn projection_switching_does_not_admit_missing_elements_or_invalid_filter_kinds() {
let (mut p, d, oa, _, category) = fixture(Layer::Sa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
validate_candidate(&p).unwrap();
let valid = p.clone();
let node = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == oa)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.element = Id::new_v4();
assert!(validate_candidate(&p).is_err());
p = valid.clone();
p.diagrams.get_mut(&d).unwrap().view_options.work_categories = CategorySelection::Any(vec![oa]);
assert!(validate_candidate(&p).is_err());
p = valid;
p.elements.remove(&category);
assert!(validate_candidate(&p).is_err());
}
#[test]
fn projection_switching_keeps_typed_state_membership_strict() {
let (mut p, d, _, _, _) = fixture(Layer::Sa);
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
.unwrap();
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
assert!(
validation::validate(&p)
.iter()
.any(|i| i.code == "state_diagram_member")
);
assert!(validate_candidate(&p).is_err());
}
+12 -1
View File
@@ -67,7 +67,18 @@ fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches
.any(|e| e.id == interaction)
);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
assert!(validate_candidate(&p).is_err());
validate_candidate(&p).unwrap();
let ordinary = projection::project_view_with_index(&p, d, &index);
assert!(
ordinary
.nodes
.iter()
.all(|n| p.elements[&n.element].layer() == Layer::Oa)
);
assert!(ordinary.edges.iter().any(|e| e.id == interaction));
assert!(!ordinary.edges.iter().any(|e| e.id == fe));
assert_eq!(p.diagrams[&d].nodes.len(), 4);
assert_eq!(p.diagrams[&d].view_options.hidden_edges, vec![fe]);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
assert!(
+42 -15
View File
@@ -4,6 +4,8 @@ pub(crate) mod architecture_authoring;
mod architecture_authoring_tests;
#[cfg(test)]
mod behavior_removal_tests;
#[cfg(test)]
mod canvas_selection_tests;
mod checkpoints;
#[cfg(test)]
mod diagram_focus_tests;
@@ -381,6 +383,7 @@ pub enum Message {
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
CanvasSelect(crate::route_edit::Context, Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
@@ -1217,6 +1220,24 @@ impl App {
}
self.refresh_draft();
}
/// Inspect an already validated canvas target without following its model layer.
fn inspect_canvas_target(&mut self, id: Id) -> bool {
if self.selected == id {
return true;
}
if !self.project().contains(id) || !self.apply() {
return false;
}
if !self.project().contains(id) {
return false;
}
self.selected = id;
self.selected_node = None;
self.selected_attachment = None;
self.selected_bend = None;
self.refresh_draft();
true
}
fn reveal_explorer_selection(&mut self) {
if let Some(group) = self.explorer_index.group(self.selected) {
self.explorer_groups_collapsed.remove(&group);
@@ -1363,6 +1384,11 @@ impl App {
{
return Task::none();
}
if let Message::CanvasSelect(captured, _) = &message
&& (self.page != Page::Diagram || self.route_context() != Some(*captured))
{
return Task::none();
}
// Selection can change without advancing the captured canvas context.
if let Message::Route(
crate::route_edit::Event::Commit(captured, changed, ..)
@@ -2118,6 +2144,7 @@ impl App {
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
Message::Projection(projection) => active || projection.scenario().is_some(),
Message::SelectNode(_)
| Message::CanvasSelect(..)
| Message::PlaceSelected
| Message::HideSelected
| Message::ToggleCollapse
@@ -2718,10 +2745,10 @@ impl App {
e.kind() != Kind::Port && d.projection.includes(e.kind())
})
});
if show_in_mixed_view {
self.selected = menu.request.object;
self.selected_attachment = None;
self.refresh_draft();
if show_in_mixed_view || menu.request.canvas.is_some() {
if !self.inspect_canvas_target(menu.request.object) {
return Task::none();
}
} else {
self.select(menu.request.object);
}
@@ -2889,6 +2916,14 @@ impl App {
self.status = "Property draft reverted".into();
}
Message::Select(id) => self.select(id),
Message::CanvasSelect(context, id) => {
let view = project_view(self.project(), context.diagram);
if view.ports.iter().any(|port| port.element == id)
|| view.edges.iter().any(|edge| edge.id == id)
{
self.inspect_canvas_target(id);
}
}
Message::DiagramSelection(event) => self.multiselect_event(event),
Message::EditProperties(id) => {
self.select(id);
@@ -5086,17 +5121,9 @@ impl App {
&project_view(self.project(), context.diagram),
id,
);
if session.tool == crate::connection_tool::Tool::Allocation
&& self.project().diagrams[&context.diagram].projection
== ProjectionKind::Traceability
{
// Mixed views can allocate either perspective. Choosing
// a visible source must not navigate to its preferred view.
self.selected = id;
self.selected_attachment = None;
self.refresh_draft();
} else {
self.select(id);
if !self.inspect_canvas_target(id) {
self.connection = Some(session);
return;
}
self.status = if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
@@ -89,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
);
assert!(!messages.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
assert_eq!(f.app.document.bundle(), &before);
assert_eq!((f.app.selected, f.app.selected_node), selected);
@@ -237,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
));
assert!(!events.iter().any(|m| matches!(
m,
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
Message::Select(_)
| Message::CanvasSelect(..)
| Message::SelectNode(_)
| Message::MoveNode(..)
)));
send(&mut f.app, events);
assert_eq!((f.app.selected, f.app.selected_node), selection);
@@ -0,0 +1,278 @@
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
use super::*;
use crate::diagram_usage::Target;
use crate::model_menu::{Event as MenuEvent, Request};
struct Fixture {
app: App,
diagram: Id,
process: Id,
placement: Id,
edge: Id,
}
fn fixture() -> Fixture {
let mut p = Project::empty();
let process = p
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
.unwrap();
let capability = p
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
.unwrap();
let edge = p.relate(
RelationKind::ChainInvolvement,
"Enables",
process,
capability,
);
p.ensure_diagram(Layer::Oa);
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
p.place(diagram, capability, Position { x: 500., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Sa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.edges
.iter()
.any(|e| e.id == edge)
);
Fixture {
app,
diagram,
process,
placement,
edge,
}
}
fn assert_surface(f: &Fixture) {
assert_eq!(f.app.page, Page::Diagram);
assert_eq!(f.app.layer, Layer::Sa);
assert_eq!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
let _ = f
.app
.update(Message::Projection(ProjectionKind::Allocation));
assert_surface(&f);
assert_eq!(f.app.project(), &expected);
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
let switched = f.app.document.snapshot();
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(), &switched.decoded.project);
assert_eq!(f.app.document.bundle(), &switched.bundle);
let _ = f
.app
.update(Message::Projection(ProjectionKind::Traceability));
assert_surface(&f);
assert_eq!(f.app.project(), &before.decoded.project);
assert!(
project_view(f.app.project(), f.diagram)
.edges
.iter()
.any(|e| e.id == f.edge)
);
}
#[test]
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
for grouped in [false, true] {
let mut f = fixture();
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
if grouped {
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Placement(f.placement),
multiselect::Choice::Replace,
)));
}
let message = if grouped {
Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Add,
))
} else {
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
};
let _ = f.app.update(message);
assert_surface(&f);
assert_eq!(f.app.selected, f.edge);
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
assert_eq!(
f.app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Edge(f.edge))
);
assert_eq!(
f.app.multiselect_snapshot().unwrap().targets().len(),
if grouped { 2 } else { 1 }
);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
assert!(!f.app.document.can_undo());
}
}
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
let mut request = Request::object(object, iced::Point::ORIGIN);
request.canvas = f.app.route_context();
request.placement = placement;
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
let token = f.app.model_menu.as_ref().unwrap().token;
let _ = f
.app
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
}
#[test]
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
let mut f = fixture();
let edge = f.edge;
invoke(&mut f, edge, None, Message::EditProperties(edge));
assert_surface(&f);
assert_eq!(f.app.selected, edge);
assert!(!f.app.document.can_undo());
let before = f.app.document.snapshot();
let process = f.process;
let placement = f.placement;
invoke(&mut f, process, Some(placement), Message::HideSelected);
assert_surface(&f);
assert!(
!f.app.project().diagrams[&f.diagram]
.nodes
.contains_key(&placement)
);
assert!(f.app.project().elements.contains_key(&process));
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
#[test]
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
let mut f = fixture();
let process = f.process;
let edge = f.edge;
invoke(&mut f, edge, None, Message::Select(process));
assert_eq!(f.app.selected, process);
assert_eq!(f.app.layer, Layer::Oa);
assert_ne!(f.app.active_diagram, Some(f.diagram));
}
#[test]
fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() {
let mut f = fixture();
let context = f.app.route_context().unwrap();
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert_surface(&f);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f
.app
.update(Message::DiagramSelection(multiselect::Event::Choose(
f.app.multiselect_context().unwrap(),
Target::Edge(f.edge),
multiselect::Choice::Replace,
)));
assert_eq!(f.app.selected, f.diagram);
assert_eq!(
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
draft
);
let _ = f.app.update(Message::RevertProperties);
f.app.canvas_epoch += 1;
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
assert_eq!(f.app.selected, f.diagram);
assert!(!f.app.document.can_undo());
}
#[test]
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let port = Id::new_v4();
let element = make_port(
&p,
port,
owner,
"Output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
)
.unwrap();
p.elements.insert(port, element);
p.ensure_diagram(Layer::Sa);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
let mut app = App::with_project(p);
app.page = Page::Diagram;
app.layer = Layer::Oa;
app.active_diagram = Some(diagram);
app.selected = diagram;
app.refresh_draft();
app.reconcile_multiselect();
assert!(
project_view(app.project(), diagram)
.ports
.iter()
.any(|p| p.element == port)
);
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
assert_eq!(app.selected, port);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(
app.multiselect_snapshot().unwrap().primary(),
Some(&Target::Port { port, placement })
);
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
app.selected = diagram;
app.refresh_draft();
let context = app.route_context().unwrap();
// Mixed canvases can retain a tool from either perspective.
app.connection = Some(crate::connection_edit::Session::new(
app.project(),
&project_view(app.project(), diagram),
context,
crate::connection_tool::Tool::FunctionalExchange,
));
let _ = app.update(Message::Connection(
crate::connection_edit::Event::Endpoint(context, port),
));
assert_eq!(app.selected, port);
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
assert!(!app.document.can_undo());
}
+9 -6
View File
@@ -993,7 +993,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
world,
));
}
return Some(Action::publish(Message::Select(port.element)).and_capture());
return Some(
Action::publish(Message::CanvasSelect(self.context, port.element))
.and_capture(),
);
}
if let Some(id) = self.hit(view, p) {
let position = self
@@ -1010,7 +1013,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
.hit_label(&scene.routed, p)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
Some(Action::publish(Message::Select(id)).and_capture())
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
if id == self.selected
&& self.editable
@@ -1026,7 +1029,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
self.begin_route(state, world, Handle::Bend(index), route);
Some(Action::capture())
} else {
Some(Action::publish(Message::Select(id)).and_capture())
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
}
} else {
state.pan_start = Some((mouse::Button::Left, p));
@@ -2441,7 +2444,7 @@ mod tests {
.unwrap()
.into_inner()
.0;
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
assert!(matches!(selected,Some(Message::CanvasSelect(_, id)) if id==port.element));
let request = canvas.context_request(
&state,
click,
@@ -3054,7 +3057,7 @@ mod tests {
let mut state = State::default();
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::CanvasSelect(_, id)) if id==edge)
);
assert!(
state.route_drag.is_none(),
@@ -3104,7 +3107,7 @@ mod tests {
let center = scene.routed.edges[&edge].label.center;
let screen = point(center) + canvas.pan;
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::CanvasSelect(_, id)) if id==edge)
);
canvas.selected = edge;
pointer(
@@ -179,7 +179,7 @@ fn coalesced_port_drag_commits_once_through_app_and_source_history() {
zoom,
);
assert_eq!(messages.len(), 2);
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
assert!(matches!(&messages[0],Message::CanvasSelect(_, id)if *id==output));
assert!(
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
);
@@ -251,7 +251,7 @@ fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
b,
);
let picked = match &press {
Message::Select(id) => Some(*id),
Message::CanvasSelect(_, id) => Some(*id),
_ => None,
};
let release_debug = format!("{release:?}");
@@ -300,7 +300,7 @@ fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
.filter_map(|e| event(&c, &mut state, e, b))
.collect();
assert!(
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
matches!(messages.first(),Some(Message::CanvasSelect(_, id)) if *id==output),
"Stationary source was lost between batches: {messages:?}"
);
assert!(
@@ -165,7 +165,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
.unwrap()
.into_inner()
.0;
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
assert!(matches!(press, Some(Message::CanvasSelect(_, id)) if id == port.element));
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
let menu = canvas.context_request(&state, at, bounds);
assert_eq!(menu.object, port.element);
+1 -1
View File
@@ -375,7 +375,7 @@ mod tests {
messages.extend(message);
}
}
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
assert!(matches!(messages.first(), Some(Message::CanvasSelect(_, id)) if *id == port));
assert!(
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
);
+1 -1
View File
@@ -147,7 +147,7 @@ schemas["ViewOptions"] = obj({
"coverage": {**ref("Coverage"), "default": "All"},
"work_contexts": {**ref("ContextSelection"), "default": "All"},
"exchange_labels": {**ref("ExchangeLabels"), "default": "Name"}}, [])
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
schemas["Diagram"]["properties"]["view_options"] = {**ref("ViewOptions"), "default": {}}
def upsert_variant(variants, name, value):