Combine source navigation and alias rename with canvas ownership guards
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user