Combine source navigation and alias rename with canvas ownership guards

This commit is contained in:
2026-09-12 19:49:26 -07:00
parent 95e0e12716
commit a17ca5d066
72 changed files with 21764 additions and 189 deletions
@@ -21,6 +21,7 @@ pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
@@ -5,6 +5,7 @@ use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
pub(crate) mod focus;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
@@ -0,0 +1,207 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
result
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
}
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,276 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
if !steps.contains(&id) {
steps.push(id);
}
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
@@ -27,6 +27,8 @@ pub struct StateCanvas<'a> {
pub diagram: Id,
pub context: Context,
pub selected: Id,
/// Presentation-only navigation marker; never an authoring placement.
pub navigation_focus: Option<Rectangle>,
pub tool: Tool,
pub zoom: f32,
pub pan: Vector,
@@ -456,6 +458,171 @@ fn state_action_label(project: &Project, relation: &Relation) -> String {
format!("{phase} / {}", project.named(target))
}
/// Match a typed label field by its structural offset, never by a display-name
/// substring (names may duplicate or also occur in free-form guards).
fn retained_label_field(full: &str, painted: &str, start: usize) -> bool {
start
< full
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>()
}
fn transition_label_role(
project: &Project,
relation: &Relation,
subject: Id,
width: f32,
) -> Option<&'static str> {
let full = routing::state_transition_summary(project, relation);
transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.))
}
fn transition_label_role_in_text(
project: &Project,
relation: &Relation,
subject: Id,
painted: &str,
) -> Option<&'static str> {
let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
effect,
..
} = &relation.body
else {
return None;
};
if project.named(subject).trim().is_empty() {
return None;
}
let full = routing::state_transition_summary(project, relation);
let mut offset = format!("{transition_kind:?} · ").len();
for id in triggers {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition trigger");
}
offset += project.named(*id).len() + " OR ".len();
}
if !effects.is_empty() {
// The canonical routing summary appends typed effects before optional
// authored effect prose. Derive its offset from that exact suffix.
let mut suffix = format!(
" / {}",
effects
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(", ")
);
if !effect.is_empty() {
suffix.push_str(&format!(" / {effect}"));
}
if let Some(prefix) = full.strip_suffix(&suffix) {
offset = prefix.len() + " / ".len();
for id in effects {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition effect");
}
offset += project.named(*id).len() + ", ".len();
}
}
}
None
}
/// Actual state compartments and painted transition-reference labels. Generic
/// projected node/edge identities are handled by the shared saved-view query.
pub(crate) fn diagram_usages(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
subject: Id,
) -> Vec<crate::diagram_usage::Usage> {
use crate::diagram_usage::{Surface, Target, Usage};
if diagram.projection.state_machine().is_none() {
return vec![];
}
let mut result = Vec::new();
for node in &view.nodes {
for (relation, bounds) in state_action_rows(project, node) {
let label = state_action_label(project, relation);
let painted = short(&label, bounds.width - 12., 11.);
let role = if relation.id == subject {
Some("state action")
} else if relation.target() == subject
&& retained_label_field(
&label,
&painted,
label.len() - project.named(subject).len(),
)
{
Some("state action work")
} else {
None
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
for (relation, bounds) in internal_rows(project, node) {
let role = if relation.id == subject {
Some("internal transition")
} else {
transition_label_role(project, relation, subject, bounds.width - 12.)
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
}
for edge in routing::route_diagram(project, diagram, view)
.edges
.values()
{
let Some(relation) = project.relations.get(&edge.id) else {
continue;
};
if edge.id == subject || edge.label.text.is_empty() {
continue;
}
// Routing already shortens external summaries before the canvas applies
// its width limit. Compare against precisely that doubly shortened text.
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::Edge(edge.id),
role: role.into(),
bounds: crate::diagram_usage::edge_focus_bounds(edge),
});
}
}
result
}
impl StateCanvas<'_> {
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
@@ -1216,6 +1383,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
overlay.with_clip(content, |frame| {
frame.translate(self.offset());
frame.scale(self.scale());
if let Some(bounds) = self.navigation_focus {
frame.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2.5 / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -1659,6 +1834,189 @@ mod tests {
}
}
#[test]
fn diagram_usage_state_rows_and_label_references_follow_actual_projection() {
use crate::diagram_usage::Target;
let mut f = fixture();
let work = f
.project
.add_element(Kind::Function, Layer::Sa, "Act", None)
.unwrap();
let event = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let unused = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let action = f
.project
.relate(RelationKind::StateAction, "Entry", f.a, work);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
for (id, kind) in [
(internal, TransitionKind::Internal),
(external, TransitionKind::External),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![event];
*effects = vec![work];
}
}
let before = f.project.clone();
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id;
let action_target = Target::StateRow {
placement,
relation: action,
};
let internal_target = Target::StateRow {
placement,
relation: internal,
};
assert_eq!(
diagram_usages(&f.project, diagram, &view, action)[0].target,
action_target
);
assert_eq!(
diagram_usages(&f.project, diagram, &view, internal)[0].target,
internal_target
);
let events = diagram_usages(&f.project, diagram, &view, event);
assert_eq!(events.len(), 2);
assert!(events.iter().any(|u| u.target == internal_target));
assert!(events.iter().any(|u| u.target == Target::Edge(external)));
assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty());
// External edge identity belongs to the shared saved-view adapter.
assert!(diagram_usages(&f.project, diagram, &view, external).is_empty());
let work_usages = diagram_usages(&f.project, diagram, &view, work);
assert!(work_usages.iter().any(|u| u.target == action_target));
assert!(work_usages.iter().any(|u| u.target == internal_target));
let row = work_usages
.iter()
.find(|u| u.target == action_target)
.unwrap();
let canvas = canvas(&f, Tool::Select, 1.);
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
canvas.hit(&scene, row.bounds.center()),
Some(Hit::StateAction(action))
);
assert_eq!(f.project, before);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.collapsed = true;
let diagram = &f.project.diagrams[&f.diagram];
let folded = projection::project_diagram(&f.project, diagram);
assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty());
assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty());
}
#[test]
fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() {
let mut f = fixture();
let early = f
.project
.add_element(Kind::Event, Layer::Sa, "A", None)
.unwrap();
let late = f
.project
.add_element(Kind::Event, Layer::Sa, "B", None)
.unwrap();
let relation = f
.project
.relate(RelationKind::StateTransition, "Transition", f.a, f.b);
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut f.project.relations.get_mut(&relation).unwrap().body
{
*triggers = vec![early, late];
*guard = "B".into();
}
let relation = &f.project.relations[&relation];
assert_eq!(
transition_label_role(&f.project, relation, early, 84.),
Some("transition trigger")
);
assert_eq!(transition_label_role(&f.project, relation, late, 84.), None);
assert_eq!(
transition_label_role(&f.project, relation, late, 800.),
Some("transition trigger")
);
assert!(!retained_label_field("1234A678", "1234…", 4));
}
#[test]
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
use crate::diagram_usage::Target;
let mut f = fixture();
let first = f
.project
.add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None)
.unwrap();
let second = f
.project
.add_element(Kind::Event, Layer::Sa, "Second", None)
.unwrap();
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
for (id, kind) in [
(external, TransitionKind::External),
(internal, TransitionKind::Internal),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![first, second];
}
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = routing::route_diagram(&f.project, diagram, &view);
let edge = &routed.edges[&external];
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
// Independent painted-text oracle: 11 prefix characters + 35 As, then
// only the space before OR survives the routing limit.
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
assert!(!painted.contains("Second"));
let usages = diagram_usages(&f.project, diagram, &view, second);
assert!(!usages.iter().any(|u| u.target == Target::Edge(external)));
assert!(usages.iter().any(
|u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal)
));
assert!(
diagram_usages(&f.project, diagram, &view, first)
.iter()
.any(|u| u.target == Target::Edge(external))
);
}
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
StateCanvas {
project: &f.project,
@@ -1670,6 +2028,7 @@ mod tests {
epoch: 3,
},
selected: f.machine,
navigation_focus: None,
tool,
zoom,
pan: Vector::new(20., 30.),
@@ -169,6 +169,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
diagram: id,
context,
selected: app.selected,
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
tool: state.tool,
zoom: state.zoom,
pan: state.pan,
+130 -45
View File
@@ -5,6 +5,7 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
mod usages;
mod view;
use direct::Input;
pub(crate) use direct::{
@@ -908,6 +909,13 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
}
struct GraphNode {
id: Id,
rect: Rectangle,
@@ -916,26 +924,98 @@ struct GraphNode {
control: Option<ControlKind>,
source: Option<Id>,
preview: Vec<(OccurrenceEndpoint, String)>,
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
preview_links: Vec<PreviewLink>,
preview_total: usize,
}
impl GraphNode {
fn preview_link_points(&self, link: &PreviewLink) -> Option<[Point; 2]> {
if ![&link.source, &link.target]
.into_iter()
.all(|endpoint| self.preview.iter().any(|(shown, _)| shown == endpoint))
{
return None;
}
let a = self.endpoint_rect(&link.source);
let b = self.endpoint_rect(&link.target);
Some([
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
])
}
fn preview_link_visible_segment(&self, link: &PreviewLink) -> Option<[Point; 2]> {
visible_preview_segment(
self.preview_link_points(link)?,
(0..self.preview.len()).map(|index| self.preview_rect(index)),
)
}
fn preview_rect(&self, index: usize) -> Rectangle {
Rectangle::new(
Point::new(
self.rect.x + 12. + (index % 2) as f32 * 125.,
self.rect.y + 118. + (index / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
}
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
self.preview
.iter()
.position(|(candidate, _)| candidate == endpoint)
.map(|i| {
Rectangle::new(
Point::new(
self.rect.x + 12. + (i % 2) as f32 * 125.,
self.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
})
.map(|index| self.preview_rect(index))
.unwrap_or(self.rect)
}
}
/// Preview tiles are painted after their arrows. Focus only a remaining line
/// segment, excluding opaque tile interiors and their one-pixel borders.
fn visible_preview_segment(
[a, b]: [Point; 2],
tiles: impl Iterator<Item = Rectangle>,
) -> Option<[Point; 2]> {
let delta = b - a;
let length = (delta.x * delta.x + delta.y * delta.y).sqrt();
if !length.is_finite() || length <= 0.5 {
return None;
}
let mut hidden = Vec::new();
for tile in tiles {
let mut low = 0_f32;
let mut high = 1_f32;
for (origin, step, min, max) in [
(a.x, delta.x, tile.x - 1., tile.x + tile.width + 1.),
(a.y, delta.y, tile.y - 1., tile.y + tile.height + 1.),
] {
if step.abs() <= f32::EPSILON {
if origin < min || origin > max {
high = -1.;
break;
}
} else {
let first = (min - origin) / step;
let last = (max - origin) / step;
low = low.max(first.min(last));
high = high.min(first.max(last));
}
}
if high > low {
hidden.push((low, high));
}
}
hidden.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut cursor = 0_f32;
let mut best = (0_f32, 0_f32);
for (low, high) in hidden.into_iter().chain([(1., 1.)]) {
if low - cursor > best.1 - best.0 {
best = (cursor, low);
}
cursor = cursor.max(high);
}
((best.1 - best.0) * length > 0.5).then(|| [a + delta * best.0, a + delta * best.1])
}
struct GraphEdge {
id: Id,
source: Id,
@@ -984,6 +1064,8 @@ struct Graph<'a> {
ordered: bool,
size: Size,
selected: Id,
selected_preview: Option<OccurrenceEndpoint>,
selected_preview_link: Option<PreviewLink>,
scale: f32,
}
impl<'a> Graph<'a> {
@@ -1132,17 +1214,16 @@ impl<'a> Graph<'a> {
occurrence: r.target(),
})
})
.map(|r| {
(
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
)
.map(|r| PreviewLink {
relation: r.id,
source: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
target: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
})
.collect();
}
@@ -1317,6 +1398,8 @@ impl<'a> Graph<'a> {
ordered: !order.is_empty(),
size,
selected,
selected_preview: None,
selected_preview_link: None,
scale: 1.,
}
}
@@ -1546,39 +1629,41 @@ impl canvas::Program<Message> for Graph<'_> {
let mini: BTreeMap<_, _> = n
.preview
.iter()
.enumerate()
.map(|(i, (id, _))| {
(
id.clone(),
Rectangle::new(
Point::new(
n.rect.x + 12. + (i % 2) as f32 * 125.,
n.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
),
)
})
.map(|(id, _)| (id.clone(), n.endpoint_rect(id)))
.collect();
for (a, b) in &n.preview_links {
let a = mini[a];
let b = mini[b];
for link in &n.preview_links {
let Some(points) = n.preview_link_points(link) else {
continue;
};
let selected = self.selected_preview_link.as_ref() == Some(link);
draw_arrow(
&mut f,
&[
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
style::MUTED,
1.,
&points,
if selected { style::TEAL } else { style::MUTED },
if selected { 3. } else { 1. },
);
}
for (id, name) in &n.preview {
let r = mini[id];
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
let selected = self.selected_preview.as_ref() == Some(id);
f.fill_rectangle(
r.position(),
r.size(),
if selected {
Color::from_rgb8(224, 247, 242)
} else {
Color::WHITE
},
);
f.stroke(
&Path::rectangle(r.position(), r.size()),
Stroke::default().with_color(style::BOUNDARY),
Stroke::default()
.with_color(if selected {
style::TEAL
} else {
style::BOUNDARY
})
.with_width(if selected { 2.5 } else { 1. }),
);
draw_text(
&mut f,
@@ -0,0 +1,274 @@
//! Read-only usages of the actual process graph, including bounded reuse tiles.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The only presentation inputs needed by a background usage query. Drafts,
/// focus, camera, history and widget state never cross the worker boundary.
#[derive(Clone)]
pub(crate) struct UsageSnapshot {
identity: Option<(Id, Id)>,
expanded: BTreeSet<Id>,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
}
impl UsageSnapshot {
pub(crate) fn into_query_state(self) -> State {
State {
identity: self.identity,
expanded: self.expanded,
contextual_expanded: self.contextual_expanded,
..State::default()
}
}
}
impl State {
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
identity: self.identity,
expanded: self.expanded.clone(),
contextual_expanded: self.contextual_expanded.clone(),
}
}
}
fn owner_exists(p: &Project, owner: Id) -> bool {
p.elements
.get(&owner)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
}
fn occurrence_role(p: &Project, occurrence: Id, subject: Id) -> Option<&'static str> {
let e = p.elements.get(&occurrence)?;
if occurrence == subject {
return Some("contribution");
}
match e.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(id), ..
},
..
} if *id == subject => Some("action occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
} if *id == subject => Some("exchange occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: Some(id) },
..
} if *id == subject => Some("reused process / chain"),
_ => None,
}
}
impl Graph<'_> {
fn usage_bounds(&self, target: &Target) -> Option<Rectangle> {
match target {
Target::Whole => Some(Rectangle::new(Point::ORIGIN, self.size)),
Target::ProcessOccurrence(endpoint) => {
let root = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
let node = self.nodes.iter().find(|node| node.id == root)?;
if endpoint.references.is_empty() {
Some(node.rect)
} else {
// endpoint_rect intentionally has a proxy fallback for other callers;
// a usage promises an actual tile and must never take that fallback.
node.preview
.iter()
.any(|(shown, _)| shown == endpoint)
.then(|| node.endpoint_rect(endpoint))
}
}
Target::ProcessLink(id) => {
let edge = self.edges.iter().find(|edge| edge.id == *id)?;
if !edge.guard.is_empty() {
Some(edge.label)
} else {
let segment = edge
.points
.windows(2)
.nth(edge.points.len().saturating_sub(1) / 2)?;
let center = Point::new(
(segment[0].x + segment[1].x) / 2.,
(segment[0].y + segment[1].y) / 2.,
);
Some(Rectangle::new(
center - iced::Vector::new(16., 16.),
Size::new(32., 32.),
))
}
}
Target::ProcessPreviewLink {
relation,
source,
target,
} => {
let node = self
.nodes
.iter()
.find(|node| source.references.first() == Some(&node.id))?;
let link = node.preview_links.iter().find(|link| {
link.relation == *relation && link.source == *source && link.target == *target
})?;
let [a, b] = node.preview_link_visible_segment(link)?;
Some(Rectangle::new(
Point::new((a.x + b.x) / 2. - 16., (a.y + b.y) / 2. - 16.),
Size::new(32., 32.),
))
}
_ => None,
}
}
}
impl State {
/// Inactive owners use the same default preview policy as entering their canvas.
fn usage_graph<'a>(&self, p: &'a Project, owner: Id) -> Graph<'a> {
if self.identity.is_some_and(|(_, current)| current == owner) {
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded)
} else {
Graph::new(p, owner, owner)
}
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !owner_exists(p, owner)
|| !(p.elements.contains_key(&subject) || p.relations.contains_key(&subject))
{
return vec![];
}
let graph = self.usage_graph(p, owner);
let mut usages = vec![];
let mut add = |target: Target, role: &str| {
if let Some(bounds) = graph.usage_bounds(&target) {
usages.push(Usage {
subject,
surface: Surface::Process(owner),
target,
role: role.into(),
bounds,
});
}
};
if subject == owner {
add(Target::Whole, "process / chain canvas");
}
for node in &graph.nodes {
if let Some(role) = occurrence_role(p, node.id, subject) {
add(
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}),
role,
);
}
for (endpoint, _) in &node.preview {
if let Some(role) = occurrence_role(p, endpoint.occurrence, subject) {
add(
Target::ProcessOccurrence(endpoint.clone()),
&format!("reused {role}"),
);
}
}
for link in &node.preview_links {
if link.relation == subject {
add(
Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
},
"reused sequence link",
);
}
}
}
for edge in &graph.edges {
let role = if edge.id == subject {
Some("process link")
} else {
match p.relations.get(&edge.id).map(|r| &r.body) {
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::Exchange { exchange },
..
}) if *exchange == subject => Some("assembly exchange"),
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::CommonWork { work },
..
}) if *work == subject => Some("common work"),
_ => None,
}
};
if let Some(role) = role {
add(Target::ProcessLink(edge.id), role);
}
}
usages
}
/// Returns success, not whether the camera changed. App must separately guard
/// its other drafts and revalidate the queried subject's current usage.
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if !owner_exists(p, owner) || self.draft.is_some() {
return false;
}
let same = self.identity == Some((document, owner));
let graph = if same {
self.usage_graph(p, owner)
} else {
Graph::new(p, owner, owner)
};
let Some(bounds) = graph.usage_bounds(target) else {
return false;
};
let address = if let Target::ProcessOccurrence(endpoint) = target {
let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(p).resolve(owner, endpoint)
else {
return false;
};
(!endpoint.references.is_empty()).then_some(resolved.address)
} else {
None
};
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
if !same {
self.contextual_expanded.clear();
}
self.selected_context = address;
self.tool = CanvasTool::Select;
self.viewport = Some(viewport);
let fit = ((viewport.width - 48.).max(1.) / bounds.width.max(1.))
.min((viewport.height - 48.).max(1.) / bounds.height.max(1.));
let scale = if matches!(target, Target::Whole) {
fit.clamp(0.0001, 1.)
} else {
fit.clamp(0.6, 1.25)
};
self.zoom = Some(scale);
self.fit = false;
self.pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * scale,
viewport.height / 2. - bounds.center_y() * scale,
);
true
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,439 @@
use super::*;
fn add(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let e = occurrence(p, owner, name, body);
let id = e.id;
*p = execute_batch(p, &[Command::CreateElement(e)]).unwrap();
id
}
fn reused_fixture() -> (Project, Id, Id, Id, [Id; 2], Vec<Id>) {
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut p = f.p;
let sequence = p
.make_element(Kind::Process, Layer::Oa, "Reviewed checks", None)
.unwrap();
let sequence_id = sequence.id;
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
let mut leaves = vec![];
for name in [
"Check one",
"Check two",
"Check three",
"Check four",
"Check five",
] {
leaves.push(add(
&mut p,
sequence_id,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
));
}
let first = add(
&mut p,
f.owner,
"First invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let second = add(
&mut p,
f.owner,
"Second invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
(p, f.owner, sequence_id, f.work[0], [first, second], leaves)
}
#[test]
fn process_usages_exclude_endpoint_coincidence_and_keep_actual_occurrences_and_links() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let state = State::default();
// Both exchange endpoints have Action contributions, but there is no
// Exchange occurrence and no exchange arrow in this process.
assert!(state.diagram_usages(&p, f.owner, f.exchange).is_empty());
let direct = state.diagram_usages(&p, f.owner, f.work[0]);
assert_eq!(direct.len(), 1);
assert_eq!(
direct[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: f.nodes[0]
})
);
let link = state.diagram_usages(&p, f.owner, f.link);
assert_eq!(link.len(), 1);
assert_eq!(link[0].target, Target::ProcessLink(f.link));
let first = add(
&mut p,
f.owner,
"First exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let second = add(
&mut p,
f.owner,
"Second exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let usages = state.diagram_usages(&p, f.owner, f.exchange);
assert_eq!(usages.len(), 2);
assert_eq!(
usages
.iter()
.map(|usage| usage.target.clone())
.collect::<Vec<_>>(),
vec![
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: first
}),
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: second
}),
]
);
}
}
#[test]
fn process_usages_keep_caller_paths_and_exclude_unshown_fifth_preview() {
let (p, owner, sequence, work, calls, leaves) = reused_fixture();
let before = p.clone();
let mut state = State::default();
let leaf = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(leaf.len(), 2);
for (usage, call) in leaf.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
})
);
}
assert_ne!(leaf[0].bounds, leaf[1].bounds);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(state.diagram_usages(&p, owner, sequence).len(), 2);
// One local action and four tiles in each of two caller boxes.
assert_eq!(state.diagram_usages(&p, owner, work).len(), 9);
state.enter(Id::new_v4(), owner);
assert!(state.expand(&p, owner, calls[0]));
let fifth = state.diagram_usages(&p, owner, leaves[4]);
assert_eq!(fifth.len(), 1);
assert_eq!(
fifth[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4]
})
);
// Another active owner must not leak its expansion to this queried canvas.
state.enter(Id::new_v4(), sequence);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(p, before);
}
#[test]
fn process_usage_reveal_validates_before_state_changes_and_centers_the_exact_tile() {
let (p, owner, _, _, calls, leaves) = reused_fixture();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(880., 460.));
state.pan = iced::Vector::new(-3000., -900.);
let hidden = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4],
});
let prior = (
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone(),
);
assert!(!state.reveal_usage(&p, document, owner, &hidden));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
assert!(!state.reveal_usage(&p, document, Id::new_v4(), &Target::Whole));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
let endpoint = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let target = Target::ProcessOccurrence(endpoint.clone());
assert!(state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.selected_context.as_ref().unwrap().endpoint, endpoint);
let bounds = state
.diagram_usages(&p, owner, leaves[0])
.into_iter()
.find(|usage| usage.target == target)
.unwrap()
.bounds;
let scale = state.zoom.unwrap();
assert!(scale >= 0.6);
assert!((bounds.center_x() * scale + state.pan.x - 440.).abs() < 0.01);
assert!((bounds.center_y() * scale + state.pan.y - 230.).abs() < 0.01);
assert!(state.reveal_usage(&p, document, owner, &Target::Whole));
assert!(state.selected_context.is_none());
state
.update(&p, Event::Start(owner, Tool::Work, None))
.unwrap();
let draft = state.recovery_snapshot().unwrap();
let camera = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.recovery_snapshot().unwrap(), draft);
assert_eq!((state.pan, state.zoom, state.fit), camera);
assert_eq!(p, before);
}
#[test]
fn process_contextual_purpose_and_painted_preview_links_keep_distinct_identities() {
let (mut p, owner, sequence, work, calls, leaves) = reused_fixture();
let source = OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[0],
};
let target = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let relation = Relation {
id: Id::new_v4(),
alias: "same_check".into(),
name: "One common check".into(),
description: String::new(),
body: RelationBody::ContextualLink {
owner,
source,
target,
purpose: ContextualLinkPurpose::CommonWork { work },
},
};
let contextual = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let local_link = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let state = State::default();
assert!(
state
.diagram_usages(&p, owner, work)
.iter()
.any(|usage| usage.target == Target::ProcessLink(contextual)
&& usage.role == "common work")
);
assert_eq!(state.diagram_usages(&p, owner, contextual).len(), 1);
let previews = state.diagram_usages(&p, owner, local_link);
assert_eq!(previews.len(), 2);
for (usage, call) in previews.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessPreviewLink {
relation: local_link,
source: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
},
target: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[1]
},
}
);
}
assert_eq!(state.diagram_usages(&p, sequence, local_link).len(), 1);
}
#[test]
fn preview_arrow_focus_is_in_the_painted_gap_and_hidden_endpoints_never_fallback() {
let (mut p, owner, _, _, calls, leaves) = reused_fixture();
let Command::CreateRelation(shown) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let shown_id = shown.id;
p = execute_batch(&p, &[Command::CreateRelation(shown)]).unwrap();
let Command::CreateRelation(hidden) = link(&p, leaves[0], leaves[4], "later") else {
unreachable!()
};
let hidden_id = hidden.id;
p = execute_batch(&p, &[Command::CreateRelation(hidden)]).unwrap();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(800., 460.));
let rows = state.diagram_usages(&p, owner, shown_id);
assert_eq!(rows.len(), 2);
let graph = Graph::new(&p, owner, owner);
for (row, call) in rows.iter().zip(calls) {
let node = graph.nodes.iter().find(|n| n.id == call).unwrap();
// The painter places 113px tiles 125px apart, beginning 12px in.
// The 12px gap is centered 131px into the caller, 134px below its top.
assert_eq!(
row.bounds.center(),
Point::new(node.rect.x + 131., node.rect.y + 134.)
);
assert!(
node.preview
.iter()
.all(|(e, _)| !node.endpoint_rect(e).contains(row.bounds.center()))
);
}
assert_ne!(rows[0].bounds, rows[1].bounds);
assert!(state.diagram_usages(&p, owner, hidden_id).is_empty());
assert!(state.reveal_usage(&p, document, owner, &rows[1].target));
assert!(
state.selected_context.is_none(),
"an arrow is not an occurrence authoring context"
);
let zoom = state.zoom.unwrap();
assert!((rows[1].bounds.center_x() * zoom + state.pan.x - 400.).abs() < 0.01);
assert!((rows[1].bounds.center_y() * zoom + state.pan.y - 230.).abs() < 0.01);
assert!(state.expand(&p, owner, calls[0]));
let expanded = state.diagram_usages(&p, owner, hidden_id);
assert_eq!(expanded.len(), 1);
assert!(
matches!(&expanded[0].target, Target::ProcessPreviewLink { source, .. }
if source.references == vec![calls[0]])
);
let mut stale = rows[1].target.clone();
if let Target::ProcessPreviewLink { source, .. } = &mut stale {
source.references = vec![Id::new_v4()];
}
let prior = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &stale));
assert_eq!((state.pan, state.zoom, state.fit), prior);
assert_eq!(p, before);
}
#[test]
fn visible_preview_segment_excludes_all_opaque_tiles_for_reverse_and_cross_row_arrows() {
let tiles = [
Rectangle::new(Point::new(12., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(12., 162.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 162.), Size::new(113., 32.)),
];
for points in [
[Point::new(250., 134.), Point::new(12., 134.)],
[Point::new(125., 134.), Point::new(137., 178.)],
[Point::new(250., 178.), Point::new(12., 134.)],
] {
let [a, b] = visible_preview_segment(points, tiles.into_iter()).unwrap();
for fraction in [0.1, 0.5, 0.9] {
let point = a + (b - a) * fraction;
assert!(tiles.iter().all(|tile| !tile.contains(point)));
}
}
assert!(
visible_preview_segment(
[Point::new(30., 134.), Point::new(100., 134.)],
tiles.into_iter()
)
.is_none()
);
assert!(
visible_preview_segment([Point::new(1., 1.), Point::new(1., 1.)], tiles.into_iter())
.is_none()
);
}
#[test]
fn expanded_nested_tiles_do_not_invent_links_the_preview_painter_does_not_emit() {
let (mut p, owner, sequence, work, calls, _) = reused_fixture();
let nested = p
.make_element(Kind::Process, Layer::Oa, "Nested checks", None)
.unwrap();
let nested_id = nested.id;
p = execute_batch(&p, &[Command::CreateElement(nested)]).unwrap();
let leaves: Vec<_> = ["Nested one", "Nested two"]
.into_iter()
.map(|name| {
add(
&mut p,
nested_id,
name,
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
)
})
.collect();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let relation_id = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let reference = add(
&mut p,
sequence,
"Nested invocation",
OccurrenceBody::SequenceReference {
sequence: Some(nested_id),
},
);
let mut state = State::default();
state.enter(Id::new_v4(), owner);
state.expanded.insert(calls[0]);
state.contextual_expanded.insert(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: reference,
});
let shown = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(shown.len(), 1);
assert!(
matches!(&shown[0].target, Target::ProcessOccurrence(endpoint)
if endpoint.references == vec![calls[0], reference])
);
assert!(state.diagram_usages(&p, owner, relation_id).is_empty());
assert_eq!(state.diagram_usages(&p, nested_id, relation_id).len(), 1);
}
@@ -183,6 +183,32 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.expanded,
&state.contextual_expanded,
);
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
address
.endpoint
.references
.first()
.copied()
.unwrap_or(address.endpoint.occurrence)
== app.selected
})
.map(|address| address.endpoint.clone());
graph.selected_preview_link = app
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
.and_then(|usage| match &usage.target {
crate::diagram_usage::Target::ProcessPreviewLink {
relation,
source,
target,
} => Some(PreviewLink {
relation: *relation,
source: source.clone(),
target: target.clone(),
}),
_ => None,
});
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.camera_pan = state.pan;
graph.pending_insertion = state