Files
ARCADIA-Studio/crates/oa-desktop/src/navigation.rs
T

294 lines
8.6 KiB
Rust

//! Read-only semantic navigation derived from the core's reference visitors.
use crate::{
app::Message,
style,
views::{action, label, short},
};
use iced::{
Element, Fill,
widget::{column, container, text},
};
use oa_core::{
references::{element_references, relation_references},
*,
};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum InspectorTab {
#[default]
Properties,
Connections,
}
struct Group {
title: &'static str,
ids: BTreeSet<Id>,
}
fn groups(p: &Project, selected: Id) -> Vec<Group> {
let parent = p.elements.get(&selected).and_then(|e| e.parent);
let children = p.children(selected).map(|e| e.id).collect::<BTreeSet<_>>();
let mut direct = p
.elements
.get(&selected)
.map(element_references)
.or_else(|| p.relations.get(&selected).map(relation_references))
.unwrap_or_default()
.into_iter()
.collect::<BTreeSet<_>>();
direct.retain(|id| Some(*id) != parent && !children.contains(id));
let outgoing = p
.relations
.values()
.filter(|r| r.source() == selected)
.map(|r| r.id)
.collect();
let incoming = p
.relations
.values()
.filter(|r| r.target() == selected)
.map(|r| r.id)
.collect();
let users = p
.elements
.values()
.filter(|e| {
e.id != selected
&& Some(e.id) != parent
&& e.parent != Some(selected)
&& element_references(e).contains(&selected)
})
.map(|e| e.id)
.chain(
p.relations
.values()
.filter(|r| {
r.source() != selected
&& r.target() != selected
&& relation_references(r).contains(&selected)
})
.map(|r| r.id),
)
.collect();
let counterparts = p
.traces
.values()
.filter_map(|t| {
if t.oa == selected {
Some(t.sa)
} else if t.sa == selected {
Some(t.oa)
} else {
None
}
})
.collect();
let artifacts = p
.artifact_links
.values()
.filter(|a| a.owner == selected)
.map(|a| a.id)
.collect();
vec![
Group {
title: "OWNED BY",
ids: parent.into_iter().collect(),
},
Group {
title: "CONTAINS",
ids: children,
},
Group {
title: "REFERENCES",
ids: direct,
},
Group {
title: "OUTGOING RELATIONSHIPS",
ids: outgoing,
},
Group {
title: "INCOMING RELATIONSHIPS",
ids: incoming,
},
Group {
title: "USED BY BEHAVIOR & CONTRACTS",
ids: users,
},
Group {
title: "COUNTERPARTS",
ids: counterparts,
},
Group {
title: "ENGINEERING ARTIFACTS",
ids: artifacts,
},
]
}
pub fn diagrams_containing(p: &Project, selected: Id) -> Vec<Id> {
let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]);
p.diagrams
.values()
.filter(|d| {
let placed = |id| {
let e = p.elements.get(&id);
let owner = e
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id);
let kind = p.elements.get(&owner).map(|e| e.kind());
kind.is_none_or(|kind| d.projection.includes(kind))
&& d.nodes.values().any(|n| n.element == owner || n.id == id)
};
placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed))
})
.map(|d| d.id)
.collect()
}
pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
let mut content = column![
text(short(p.named(selected), 50)).size(18),
text("Follow ownership, usage and engineering relationships.")
.size(12)
.color(style::MUTED)
]
.spacing(12);
let mut count = 0;
for group in groups(p, selected) {
if group.ids.is_empty() {
continue;
}
count += group.ids.len();
let mut entries = group.ids.into_iter().collect::<Vec<_>>();
entries.sort_by_key(|id| p.named(*id).to_lowercase());
content = content.push(label(group.title));
for id in entries {
let detail = p
.elements
.get(&id)
.map(|e| format!("{} · {}", e.kind(), e.layer()))
.or_else(|| {
p.relations.get(&id).map(|r| {
format!(
"{} · {} → {}",
r.kind(),
p.named(r.source()),
p.named(r.target())
)
})
})
.or_else(|| p.artifact_links.get(&id).map(|a| a.uri.clone()))
.unwrap_or_default();
let mut entry = column![
action(&short(p.named(id), 42), Message::Select(id)).width(Fill),
text(detail).size(11).color(style::MUTED)
]
.spacing(3);
if let Some(r) = p.relations.get(&id) {
let other = if r.source() == selected {
r.target()
} else {
r.source()
};
entry = entry.push(
action(
&format!("Open {}", short(p.named(other), 32)),
Message::Select(other),
)
.width(Fill),
);
}
content = content.push(container(entry).padding(8).style(style::subtle));
}
}
let diagrams = diagrams_containing(p, selected);
if !diagrams.is_empty() {
content = content.push(label("SHOWN IN DIAGRAMS"));
for id in diagrams {
content = content.push(
action(
&short(p.named(id), 42),
Message::RevealInDiagram(id, selected),
)
.width(Fill),
);
}
} else if p.elements.contains_key(&selected) {
content = content.push(
text("This element has no diagram placement yet.")
.size(12)
.color(style::MUTED),
);
}
if count == 0 {
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
}
content.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semantic_browser_finds_behavior_users_without_confusing_containment() {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let process = p
.add_element(Kind::Process, Layer::Oa, "Response route", None)
.unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(process))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let result = groups(&p, work);
assert!(
result
.iter()
.find(|g| g.title == "USED BY BEHAVIOR & CONTRACTS")
.unwrap()
.ids
.contains(&occurrence)
);
let result = groups(&p, occurrence);
assert!(
result
.iter()
.find(|g| g.title == "OWNED BY")
.unwrap()
.ids
.contains(&process)
);
assert!(
result
.iter()
.find(|g| g.title == "REFERENCES")
.unwrap()
.ids
.contains(&work)
);
assert!(
!result
.iter()
.find(|g| g.title == "REFERENCES")
.unwrap()
.ids
.contains(&process)
);
}
}