//! 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), 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, String>, } pub(crate) struct Panel { pub token: Id, pub context: Context, pub usages: Vec, pub loading: bool, pub search: String, pub page: usize, pub cursor: usize, pub destination: Option, pub notice: Option, } impl Panel { pub fn new(context: Context, usages: Vec) -> 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 { 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 { 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::>() .join(" / "); } Target::ProcessPreviewLink { relation, source, .. } => { return source .references .iter() .copied() .chain([*relation]) .map(|id| format!("{} (@{})", p.named(id), alias(p, id))) .collect::>() .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 { 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) -> Vec { 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;