504 lines
16 KiB
Rust
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;
|