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