Publish in-progress navigation source and independent canvas reviews

This commit is contained in:
2026-09-12 06:58:08 -07:00
parent 7aa007b077
commit fdf756e6a4
48 changed files with 16281 additions and 125 deletions
@@ -0,0 +1,171 @@
use super::*;
use oa_core::{Kind, Layer, Position, RelationKind};
#[test]
fn saved_membership_respects_filters_and_does_not_change_project() {
let mut p = Project::empty();
let first = p.add(Kind::Activity, "Same name", 20., 20.);
let second = p.add(Kind::Activity, "Same name", 340., 20.);
let relation = p.relate(RelationKind::Interaction, "Handoff", first, second);
let shown = p.ensure_diagram(Layer::Oa);
let mut hidden = p.diagrams[&shown].clone();
hidden.id = Id::new_v4();
hidden.alias = "filtered_copy".into();
hidden.nodes = hidden
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("filtered_{}", node.alias);
(node.id, node)
})
.collect();
hidden.view_options.hidden_edges.push(relation);
let hidden_id = hidden.id;
p.diagrams.insert(hidden_id, hidden);
let before = p.clone();
let hits = saved_usages(&p, relation);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(shown));
assert_eq!(hits[0].target, Target::Edge(relation));
let hits = saved_usages(&p, first);
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|hit| match hit.target {
Target::Placement(node) => p.diagrams[&hit.surface.id()].nodes[&node].element == first,
_ => false,
}));
assert_eq!(p, before);
}
#[test]
fn folded_child_is_not_a_visible_node_and_placement_identity_stays_exact() {
let mut p = Project::empty();
let parent = p.add(Kind::Activity, "Container", 20., 20.);
let child = p
.add_element(Kind::Activity, Layer::Oa, "Nested", Some(parent))
.unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
let child_node = p.place(diagram, child, Position { x: 50., y: 100. });
let parent_node = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == parent)
.unwrap()
.id;
let hits = saved_usages(&p, child_node);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].target, Target::Placement(child_node));
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&parent_node)
.unwrap()
.collapsed = true;
let before = p.clone();
assert!(saved_usages(&p, child).is_empty());
assert!(saved_usages(&p, child_node).is_empty());
assert_eq!(saved_usages(&p, parent).len(), 1);
assert_eq!(p, before);
}
#[test]
fn ports_are_found_at_the_visible_owner_and_unplaced_elements_do_not_match_names() {
let mut p = Project::empty();
let owner = p.add(Kind::Function, "Compute", 20., 20.);
let port = p
.add_element(Kind::Port, Layer::Sa, "Input", Some(owner))
.unwrap();
let unrelated = p
.add_element(Kind::Function, Layer::Sa, "Compute", None)
.unwrap();
let hits = saved_usages(&p, port);
assert_eq!(hits.len(), 1);
assert!(matches!(hits[0].target, Target::Port { port: found, .. } if found == port));
assert!(saved_usages(&p, unrelated).is_empty());
assert!(saved_usages(&p, Id::new_v4()).is_empty());
}
#[test]
fn reveal_camera_centers_target_without_whole_view_shrink() {
let bounds = Rectangle {
x: 100_000.,
y: -50_000.,
width: 224.,
height: 94.,
};
let viewport = iced::Size::new(700., 500.);
let (zoom, pan) = camera(bounds, viewport);
assert_eq!(zoom, 1.25);
assert!((bounds.center_x() * zoom + pan.x - 350.).abs() < 0.01);
assert!((bounds.center_y() * zoom + pan.y - 250.).abs() < 0.01);
}
#[test]
fn oversized_container_reveal_targets_its_labelled_header() {
let mut p = Project::empty();
let container = p.add(Kind::Activity, "Large enclosing activity", 40., 70.);
let diagram = p.ensure_diagram(Layer::Oa);
let node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.values_mut()
.next()
.unwrap();
node.size = oa_core::Size {
width: 4000.,
height: 4000.,
};
let before = p.clone();
let hits = saved_usages(&p, container);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].bounds.x, 40.);
assert_eq!(hits[0].bounds.y, 70.);
assert!(hits[0].bounds.width <= 420.);
assert!(hits[0].bounds.height <= 64.);
let (zoom, pan) = camera(hits[0].bounds, iced::Size::new(500., 300.));
assert!((hits[0].bounds.center_x() * zoom + pan.x - 250.).abs() < 0.01);
assert!(70. * zoom + pan.y >= 0.);
assert_eq!(p, before);
}
#[test]
fn state_machine_owner_finds_its_view_without_a_machine_placement() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Monitor", None)
.unwrap();
let unrelated = p
.add_element(Kind::StateMachine, Layer::Sa, "Other", None)
.unwrap();
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection =
oa_core::ProjectionKind::StateMachine { machine };
let before = p.clone();
let hits = saved_usages(&p, machine);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(diagram));
assert_eq!(hits[0].target, Target::Whole);
assert_eq!(hits[0].role, "state machine view");
assert!(saved_usages(&p, unrelated).is_empty());
assert_eq!(p, before);
}
#[test]
fn whole_diagram_camera_fits_large_bounds_instead_of_individual_object_scale() {
let bounds = Rectangle {
x: 2000.,
y: -4000.,
width: 100_000.,
height: 80_000.,
};
let (zoom, pan) = camera_for(&Target::Whole, bounds, iced::Size::new(500., 300.));
assert!(zoom < 0.01);
assert!(bounds.x * zoom + pan.x >= 0.);
assert!(bounds.y * zoom + pan.y >= 0.);
assert!((bounds.x + bounds.width) * zoom + pan.x <= 500.);
assert!((bounds.y + bounds.height) * zoom + pan.y <= 300.);
}
+220
View File
@@ -0,0 +1,220 @@
use super::*;
use crate::{
app::{App, Message},
style,
views::{action, short},
};
use iced::{
Element, Fill,
widget::{self, column, container, row, text, text_input, tooltip},
};
use std::collections::BTreeSet;
const PAGE_SIZE: usize = 6;
pub(crate) fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
let Some(panel) = &app.diagram_usages else {
return base;
};
let p = app.project();
let token = panel.token;
let message = |event| Message::DiagramUsage(event);
let matched = panel.matches(p);
let mut surfaces = Vec::new();
let mut seen = BTreeSet::new();
for usage in &matched {
if seen.insert(usage.surface) {
surfaces.push(usage.surface);
}
}
let targets: Vec<_> = matched
.iter()
.copied()
.filter(|usage| Some(usage.surface) == panel.destination)
.collect();
let count = if panel.destination.is_some() {
targets.len()
} else {
surfaces.len()
};
let last = count.saturating_sub(1) / PAGE_SIZE;
let page = panel.page.min(last);
let total_surfaces = panel
.usages
.iter()
.map(|u| u.surface)
.collect::<BTreeSet<_>>()
.len();
let mut content = column![
row![
text("Show in diagrams").size(22).width(Fill),
action("Close", message(Event::Close(token)))
]
.spacing(12),
tooltip(
text(format!(
"{} · @{}",
short(p.named(panel.context.subject), 48),
short(alias(p, panel.context.subject), 48)
))
.size(14),
container(
widget::scrollable(
text(format!(
"{}\n@{}",
p.named(panel.context.subject),
alias(p, panel.context.subject)
))
.size(13)
)
.height(220)
)
.padding(10)
.width(620)
.style(style::card),
tooltip::Position::Bottom
),
text(if panel.loading {
"Searching rendered objects…".into()
} else {
format!(
"{} diagram destinations · {} representations",
total_surfaces,
panel.usages.len()
)
})
.size(12)
.color(style::MUTED),
text_input("Filter diagram, occurrence, or alias", &panel.search)
.id(format!("diagram-usage-filter-{}", panel.token))
.padding(9)
.on_input(move |value| Message::DiagramUsage(Event::Search(token, value)))
.on_submit(message(Event::Activate(token))),
]
.spacing(10);
if let Some(notice) = &panel.notice {
content = content.push(text(notice).size(13));
}
if let Some(surface) = panel.destination {
content = content.push(
row![
action("All diagrams", message(Event::Destination(token, None))),
text(short(p.named(surface.id()), 55)).size(15),
]
.spacing(10),
);
for (index, usage) in targets
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let name = target_name(p, usage);
content = content.push(tooltip(
action(
&format!("{} · {}", usage.role, short(&name, 70)),
message(Event::Choose(token, usage.clone())),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
} else {
for (index, surface) in surfaces
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let targets = matched.iter().filter(|u| u.surface == surface).count();
let name = format!(
"{} · {} (@{}) · {} representations",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
targets
);
let layer = p
.diagrams
.get(&surface.id())
.map(|d| d.layer)
.or_else(|| p.layer_of(surface.id()))
.map(|l| l.to_string())
.unwrap_or_default();
content = content.push(tooltip(
action(
&format!(
"{} · {} · {} · {}",
surface.category(p),
layer,
short(p.named(surface.id()), 34),
targets
),
message(Event::Destination(token, Some(surface))),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
}
if panel.loading {
content = content.push(text("Finding diagram representations…").size(13));
} else if count == 0 {
content = content.push(text(if panel.usages.is_empty() {
"No currently rendered representation found. Folded or filtered objects and unexpanded reuse contents are excluded."
} else { "No results match this filter." }).size(13));
}
let mut paging = row![
text(format!(
"Page {} of {} · {} matches",
page + 1,
last + 1,
count
))
.size(12)
.width(Fill)
]
.spacing(8);
if page > 0 {
paging = paging.push(action("Previous", message(Event::Page(token, page - 1))));
}
if page < last {
paging = paging.push(action("Next", message(Event::Page(token, page + 1))));
}
content = content.push(paging).push(text("↑ ↓ choose · Enter opens · Escape closes. Source and saved view settings are preserved.").size(12).color(style::MUTED));
widget::stack![
base,
widget::opaque(
container(
container(content)
.padding(22)
.width((app.window_size.width - 80.).clamp(620., 820.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}