Publish in-progress navigation source and independent canvas reviews
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
//! 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 },
|
||||
ProcessOccurrence(OccurrenceEndpoint),
|
||||
ProcessLink(Id),
|
||||
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,
|
||||
}
|
||||
|
||||
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(_) => "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::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(" / ");
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
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))
|
||||
{
|
||||
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.,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
Reference in New Issue
Block a user