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

504 lines
16 KiB
Rust

//! Read-only identities for objects actually represented by a diagram renderer.
use iced::Rectangle;
use oa_core::{Id, OccurrenceEndpoint, Project, projection, routing, view_options::ViewIndex};
pub(crate) mod view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Context {
pub document: Id,
pub revision: u64,
pub subject: Id,
}
#[derive(Debug, Clone)]
pub(crate) enum Event {
Open(Context),
Close(Id),
Search(Id, String),
Page(Id, usize),
Destination(Id, Option<Surface>),
Choose(Id, Usage),
Move(Id, i8),
Activate(Id),
}
/// One completed immutable accepted-model lookup. The token owns one worker slot.
#[derive(Debug, Clone)]
pub(crate) struct Completion {
pub token: Id,
pub context: Context,
pub result: Result<Vec<Usage>, String>,
}
pub(crate) struct Panel {
pub token: Id,
pub context: Context,
pub usages: Vec<Usage>,
pub loading: bool,
pub search: String,
pub page: usize,
pub cursor: usize,
pub destination: Option<Surface>,
pub notice: Option<String>,
}
impl Panel {
pub fn new(context: Context, usages: Vec<Usage>) -> Self {
Self {
token: Id::new_v4(),
context,
usages,
loading: false,
search: String::new(),
page: 0,
cursor: 0,
destination: None,
notice: None,
}
}
pub fn matches<'a>(&'a self, p: &Project) -> Vec<&'a Usage> {
let query = self.search.to_lowercase();
if query.is_empty() {
return self.usages.iter().collect();
}
self.usages
.iter()
.filter(|usage| {
let surface = usage.surface;
let searchable = format!(
"{} {} {} {} {}",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
target_name(p, usage),
usage.role
);
searchable.to_lowercase().contains(&query)
})
.collect()
}
pub fn choices(&self, p: &Project) -> Vec<Event> {
if self.loading {
return Vec::new();
}
let matches = self.matches(p);
if let Some(surface) = self.destination {
matches
.into_iter()
.filter(|u| u.surface == surface)
.map(|u| Event::Choose(self.token, u.clone()))
.collect()
} else {
let mut seen = std::collections::BTreeSet::new();
matches
.into_iter()
.filter(|u| seen.insert(u.surface))
.map(|u| Event::Destination(self.token, Some(u.surface)))
.collect()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Surface {
Saved(Id),
Process(Id),
Scenario(Id),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Whole,
Placement(Id),
Port {
port: Id,
placement: Id,
},
Edge(Id),
StateRow {
placement: Id,
relation: Id,
},
AllocationGrouping {
relation: Id,
work_placement: Id,
performer_placement: Id,
},
ProcessOccurrence(OccurrenceEndpoint),
ProcessLink(Id),
ProcessPreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
},
ScenarioObject(Id),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Usage {
pub subject: Id,
pub surface: Surface,
pub target: Target,
/// Concise, truthful explanation, e.g. "action occurrence" or "entry action".
pub role: String,
/// Actual current renderer coordinates. Recompute before activation.
pub bounds: Rectangle,
}
/// A navigation highlight is presentation state, never an edit/Delete target.
pub(crate) struct Focus {
pub context: Context,
pub usage: Usage,
pub selected: Id,
}
impl Surface {
pub fn id(self) -> Id {
match self {
Self::Saved(id) | Self::Process(id) | Self::Scenario(id) => id,
}
}
pub fn category(self, p: &Project) -> &'static str {
match self {
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.state_machine().is_some()) =>
{
"State views"
}
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.scenario().is_some()) =>
{
"Scenarios"
}
Self::Saved(_) => "Architecture",
Self::Process(_) => "Processes and chains",
Self::Scenario(_) => "Scenarios",
}
}
}
pub(crate) fn collect(
p: &Project,
subject: Id,
process: &crate::behavior::topology::State,
scenario: &crate::behavior::scenario::State,
) -> Vec<Usage> {
let mut usages = saved_usages(p, subject);
for element in p.elements.values() {
match element.kind() {
oa_core::Kind::Process | oa_core::Kind::FunctionalChain => {
usages.extend(process.diagram_usages(p, element.id, subject));
}
oa_core::Kind::Scenario => {
usages.extend(scenario.diagram_usages(p, element.id, subject));
}
_ => {}
}
}
usages.sort_by_cached_key(|u| {
(
u.surface.category(p),
p.named(u.surface.id()).to_lowercase(),
u.surface,
target_name(p, u).to_lowercase(),
)
});
usages
.dedup_by(|a, b| a.subject == b.subject && a.surface == b.surface && a.target == b.target);
usages
}
pub(crate) fn alias(p: &Project, id: Id) -> &str {
p.elements
.get(&id)
.map(|e| e.alias.as_str())
.or_else(|| p.relations.get(&id).map(|r| r.alias.as_str()))
.or_else(|| p.diagrams.get(&id).map(|d| d.alias.as_str()))
.or_else(|| p.traces.get(&id).map(|t| t.alias.as_str()))
.or_else(|| {
p.diagrams
.values()
.find_map(|d| d.nodes.get(&id).map(|n| n.alias.as_str()))
})
.unwrap_or("")
}
pub(crate) fn target_name(p: &Project, usage: &Usage) -> String {
let id = match &usage.target {
Target::Whole => usage.surface.id(),
Target::Placement(id) => p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
.map_or(*id, |n| n.element),
Target::Port { port, .. } => *port,
Target::Edge(id)
| Target::StateRow { relation: id, .. }
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessLink(id)
| Target::ScenarioObject(id) => *id,
Target::ProcessOccurrence(endpoint) => {
return endpoint
.references
.iter()
.copied()
.chain([endpoint.occurrence])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
Target::ProcessPreviewLink {
relation, source, ..
} => {
return source
.references
.iter()
.copied()
.chain([*relation])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
};
format!("{} (@{})", p.named(id), alias(p, id))
}
/// Saved view membership is the renderer's final projection, including folds,
/// projection-kind exclusions and explicit view filters. Stored route metadata
/// and merely having both relation endpoints placed do not imply visibility.
pub(crate) fn saved_usages(p: &Project, subject: Id) -> Vec<Usage> {
saved_usages_for(p, subject, None)
}
/// Revalidate only the requested saved surface before activation. The optional
/// filter is applied before projection and routing, never after a whole lookup.
pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id>) -> Vec<Usage> {
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
return Vec::new();
}
if let Some(diagram) = only_diagram
.and_then(|id| p.diagrams.get(&id))
.filter(|d| d.projection.scenario().is_some())
{
return crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
);
}
let index = ViewIndex::new(p);
let mut usages = Vec::new();
for diagram in p
.diagrams
.values()
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
{
if diagram.projection.scenario().is_some() {
usages.extend(
crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
),
);
continue;
}
let view = projection::project_diagram_with_index(p, diagram, &index);
let surface = Surface::Saved(diagram.id);
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
usages.push(Usage {
subject,
surface,
target: Target::Whole,
role: if subject == diagram.id {
"diagram"
} else {
"state machine view"
}
.into(),
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
});
}
for node in &view.nodes {
if subject == node.element || subject == node.id {
usages.push(Usage {
subject,
surface,
target: Target::Placement(node.id),
role: "node".into(),
bounds: Rectangle {
x: node.position.x,
y: node.position.y,
// Reveal the labelled header, not the empty interior of
// a large expanded container at the minimum zoom.
width: node.size.width.min(420.),
height: node.size.height.min(64.),
},
});
}
}
for port in &view.ports {
if subject == port.element {
usages.push(Usage {
subject,
surface,
target: Target::Port {
port: port.element,
placement: port.owner,
},
role: "port".into(),
bounds: Rectangle {
x: port.position.x - 12.,
y: port.position.y - 12.,
width: 24.,
height: 24.,
},
});
}
}
// Allocation arrows are suppressed in this projection. A direct
// relation is represented only by its actual visible containment chain;
// aggregate summaries never manufacture inherited relations.
if diagram.projection == oa_core::ProjectionKind::Allocation
&& let Some(relation) = p.relations.get(&subject)
&& relation.kind() == oa_core::RelationKind::Allocation
{
let placements: std::collections::BTreeMap<_, _> =
view.nodes.iter().map(|node| (node.id, node)).collect();
for work in view.nodes.iter().filter(|n| n.element == relation.target()) {
let mut parent = work.visual_parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent.filter(|id| seen.insert(*id)) {
let Some(performer) = placements.get(&id) else {
break;
};
if performer.element == relation.source() {
usages.push(Usage {
subject,
surface,
target: Target::AllocationGrouping {
relation: subject,
work_placement: work.id,
performer_placement: performer.id,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: work.position.x,
y: work.position.y,
width: work.size.width.min(420.),
height: work.size.height.min(64.),
},
});
break;
}
parent = performer.visual_parent;
}
}
}
if view.edges.iter().any(|edge| edge.id == subject)
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
.edges
.get(&subject)
{
usages.push(Usage {
subject,
surface,
target: Target::Edge(subject),
role: "relationship".into(),
bounds: edge_focus_bounds(edge),
});
}
if diagram.projection.state_machine().is_some() {
usages.extend(crate::behavior::state_canvas::diagram_usages(
p, diagram, &view, subject,
));
}
}
usages
}
pub(crate) fn routed_bounds(bounds: routing::RoutingBounds) -> Rectangle {
Rectangle {
x: bounds.position.x,
y: bounds.position.y,
width: bounds.size.width,
height: bounds.size.height,
}
}
/// Focus a named edge at its label. For an unlabelled line, focus its middle
/// segment instead of shrinking a long connector to fit the whole diagram.
pub(crate) fn edge_focus_bounds(edge: &routing::RoutedEdge) -> Rectangle {
if !edge.label.text.is_empty() {
return routed_bounds(edge.label.bounds);
}
let point = edge
.points
.get(edge.points.len() / 2)
.copied()
.unwrap_or(edge.label.center);
Rectangle {
x: point.x - 40.,
y: point.y - 24.,
width: 80.,
height: 48.,
}
}
/// Whole-canvas navigation fits its entire extent; an individual target keeps
/// the readable minimum zoom of `camera` rather than shrinking unrelated space.
pub(crate) fn camera_for(
target: &Target,
bounds: Rectangle,
viewport: iced::Size,
) -> (f32, iced::Vector) {
if !matches!(target, Target::Whole) {
return camera(bounds, viewport);
}
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.0001, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
/// Return a readable, centered camera without modifying authored geometry.
pub(crate) fn camera(bounds: Rectangle, viewport: iced::Size) -> (f32, iced::Vector) {
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.5, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod coverage_tests;