Fix State transition multiline label geometry and selection

This commit is contained in:
2026-09-24 15:50:56 -07:00
parent 715036e132
commit 2af40c7e27
15 changed files with 1023 additions and 49 deletions
+167 -11
View File
@@ -53,6 +53,8 @@ pub struct RoutedEdge {
pub struct EdgeLabelGeometry {
/// Empty for edge kinds without a permanently displayed label.
pub text: String,
/// Dedicated State-canvas two-block presentation; raw summary stays in text.
pub state_layout: Option<StateLabelLayout>,
/// Effective arc-length fraction, including an automatic collision-avoidance
/// choice. Use this when the first label drag becomes authored metadata.
pub fraction: f32,
@@ -61,6 +63,108 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived hard-line layout for the dedicated State projection, never persisted.
/// Excerpts are chosen on raw text before display-only separator/tab expansion.
#[derive(Debug, Clone, PartialEq)]
pub struct StateLabelLayout {
pub title_lines: Vec<String>,
pub summary_lines: Vec<String>,
size: Size,
}
impl StateLabelLayout {
pub const FONT_SIZE: f32 = 10.0;
pub const LINE_HEIGHT: f32 = 12.0;
pub const HORIZONTAL_PADDING: f32 = 5.0;
pub const MIN_HEIGHT: f32 = 24.0;
/// `summary` is the existing raw, globally shortened routing summary.
/// Measurement receives each final display line at FONT_SIZE, without breaks.
/// Invalid measurements fall back to the deterministic approximate width.
pub fn new(title: &str, summary: &str, measure: &dyn Fn(&str) -> f32) -> Self {
let budget = (7 * summary.chars().count() + 6) / 6;
let title = if title.chars().count() > budget {
title
.chars()
.take(budget.saturating_sub(1))
.chain(std::iter::once('…'))
.collect()
} else {
title.to_owned()
};
let title_lines = state_display_lines(&title);
let summary_lines = state_display_lines(summary);
let width = title_lines
.iter()
.chain(&summary_lines)
.map(|line| {
let width = measure(line);
if width.is_finite() && width >= 0.0 {
width
} else {
approximate_state_line_width(line)
}
})
.fold(0.0_f32, f32::max);
let size = Size {
width: width + 2.0 * Self::HORIZONTAL_PADDING,
height: (Self::LINE_HEIGHT * (title_lines.len() + summary_lines.len()) as f32)
.max(Self::MIN_HEIGHT),
};
Self {
title_lines,
summary_lines,
size,
}
}
pub fn size(&self) -> Size {
self.size
}
/// Local origin relative to EdgeLabelGeometry.bounds.position.
pub fn title_line_position(&self, index: usize) -> Position {
Position {
x: Self::HORIZONTAL_PADDING,
y: index as f32 * Self::LINE_HEIGHT,
}
}
/// Local origin relative to EdgeLabelGeometry.bounds.position.
pub fn summary_line_position(&self, index: usize) -> Position {
Position {
x: Self::HORIZONTAL_PADDING,
y: (self.title_lines.len() + index) as f32 * Self::LINE_HEIGHT,
}
}
}
fn state_display_lines(raw: &str) -> Vec<String> {
if raw.is_empty() {
return Vec::new();
}
let mut lines = vec![String::new()];
let mut chars = raw.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
lines.push(String::new());
}
'\n' | '\u{85}' | '\u{2028}' | '\u{2029}' => lines.push(String::new()),
'\t' => lines.last_mut().unwrap().push_str(" "),
_ => lines.last_mut().unwrap().push(ch),
}
}
lines
}
fn approximate_state_line_width(line: &str) -> f32 {
line.chars().count() as f32 * 7.0
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
@@ -161,6 +265,7 @@ pub fn association_end_geometry(
source_end,
label: EdgeLabelGeometry {
text,
state_layout: None,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
@@ -436,7 +541,24 @@ fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id
/// Search caps are presentation limits, reported on the affected edge, never
/// semantic errors or a reason to discard an authored point.
pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) -> RoutedView {
route_diagram_impl(project, diagram, view, None)
route_diagram_with_state_label_measurement(
project,
diagram,
view,
&approximate_state_line_width,
)
}
/// Route with frontend line measurements for dedicated State labels before collision placement.
/// The legacy entrypoint uses a deterministic 7-units-per-scalar approximation,
/// not a promise of shaped glyph containment. All other label kinds are unchanged.
pub fn route_diagram_with_state_label_measurement(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
route_diagram_impl(project, diagram, view, None, measure)
}
/// Transient route/label editing with unchanged projected nodes and ports.
@@ -451,7 +573,26 @@ pub fn route_edge_preview(
baseline: &RoutedView,
edge: Id,
) -> RoutedView {
route_diagram_impl(project, diagram, view, Some((baseline, edge)))
route_edge_preview_with_state_label_measurement(
project,
diagram,
view,
baseline,
edge,
&approximate_state_line_width,
)
}
/// Preview counterpart using the same State text measurer as the settled scene.
pub fn route_edge_preview_with_state_label_measurement(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
baseline: &RoutedView,
edge: Id,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
route_diagram_impl(project, diagram, view, Some((baseline, edge)), measure)
}
fn route_diagram_impl(
@@ -459,6 +600,7 @@ fn route_diagram_impl(
diagram: &Diagram,
view: &ProjectedView,
preview: Option<(&RoutedView, Id)>,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
@@ -639,13 +781,14 @@ fn route_diagram_impl(
.flatten();
routed.label = make_label(
project,
diagram.view_options.exchange_labels,
diagram,
edge.id,
&routed.points,
authored_label,
&obstacles,
&previous_labels,
&previous,
measure,
);
}
if !routed.label.text.is_empty() {
@@ -1345,6 +1488,7 @@ fn label_text(project: &Project, edge: Id) -> String {
fn empty_label() -> EdgeLabelGeometry {
EdgeLabelGeometry {
text: String::new(),
state_layout: None,
fraction: 0.5,
anchor: pos(0.0, 0.0),
center: pos(0.0, 0.0),
@@ -1362,14 +1506,16 @@ fn empty_label() -> EdgeLabelGeometry {
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
labels: crate::view_options::ExchangeLabels,
diagram: &Diagram,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
obstacles: &[Rect],
previous_labels: &[RoutingBounds],
strokes: &[Segment],
measure: &dyn Fn(&str) -> f32,
) -> EdgeLabelGeometry {
let labels = diagram.view_options.exchange_labels;
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
@@ -1385,14 +1531,23 @@ fn make_label(
} else {
label_text(project, edge)
};
let size = Size {
width: if text.is_empty() {
0.0
} else {
text.chars().count() as f32 * 7.0 + 16.0
let state_layout = (diagram.projection.state_machine().is_some()
&& project
.relations
.get(&edge)
.is_some_and(|r| r.kind() == RelationKind::StateTransition))
.then(|| StateLabelLayout::new(project.named(edge), &text, measure));
let size = state_layout.as_ref().map_or_else(
|| Size {
width: if text.is_empty() {
0.0
} else {
text.chars().count() as f32 * 7.0 + 16.0
},
height: if text.is_empty() { 0.0 } else { 24.0 },
},
height: if text.is_empty() { 0.0 } else { 24.0 },
};
StateLabelLayout::size,
);
let mut offset = authored.map_or(pos(0.0, -18.0), |l| l.offset);
let geometry = |fraction, offset: Position| {
let anchor = point_along_polyline(points, fraction).unwrap_or(pos(0.0, 0.0));
@@ -1474,6 +1629,7 @@ fn make_label(
let (anchor, center, bounds) = geometry(fraction, offset);
EdgeLabelGeometry {
text,
state_layout,
fraction,
anchor,
center,
+145
View File
@@ -0,0 +1,145 @@
use oa_core::{projection::project_diagram, routing::*, *};
fn fixture() -> (Project, Id, Id) {
let mut project = language::parse(include_str!(
"../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia"
))
.unwrap();
let edge = Id::parse_str("ea202922-0033-4000-8000-000000000041").unwrap();
let diagram = Id::parse_str("ea202922-0033-4000-8000-000000000200").unwrap();
let relation = project.relations.get_mut(&edge).unwrap();
relation.name = "Ready\nfor review".into();
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = "request_ready".into();
}
(project, diagram, edge)
}
#[test]
fn state_two_block_layout_separates_lines_and_keeps_authored_geometry_and_project() {
let (project, diagram, edge) = fixture();
let before = project.clone();
let diagram = &project.diagrams[&diagram];
let view = project_diagram(&project, diagram);
let routed = route_diagram(&project, diagram, &view);
let edge_geometry = &routed.edges[&edge];
let label = &edge_geometry.label;
let layout = label.state_layout.as_ref().unwrap();
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(
layout.summary_lines,
["External · Completion [request_ready]"]
);
assert_eq!(label.text, "External · Completion [request_ready]");
assert_eq!(layout.title_line_position(1), Position { x: 5., y: 12. });
assert_eq!(layout.summary_line_position(0), Position { x: 5., y: 24. });
assert_eq!(
label.bounds.size,
Size {
width: 269.,
height: 36.
}
);
let authored = diagram
.routes
.iter()
.find(|r| r.edge == edge)
.unwrap()
.label
.as_ref()
.unwrap();
assert_eq!(label.fraction, authored.fraction);
let anchor = point_along_polyline(&edge_geometry.points, authored.fraction).unwrap();
assert_eq!(label.anchor, anchor);
assert_eq!(
label.center,
Position {
x: anchor.x + authored.offset.x,
y: anchor.y + authored.offset.y
}
);
assert_eq!(project, before);
}
#[test]
fn state_display_breaks_tabs_and_blank_lines_are_single_sourced() {
let summary = "A sufficiently long unchanged summary";
for title in [
"Ready\nfor review",
"Ready\r\nfor review",
"Ready\rfor review",
"Ready\u{85}for review",
"Ready\u{2028}for review",
"Ready\u{2029}for review",
] {
let layout = StateLabelLayout::new(title, summary, &|s| s.chars().count() as f32);
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(layout.summary_line_position(0).y, 24.);
}
let layout = StateLabelLayout::new("Ready\n\nfor\treview\n", summary, &|s| {
s.chars().count() as f32
});
assert_eq!(layout.title_lines, ["Ready", "", "for review", ""]);
assert_eq!(layout.summary_line_position(0).y, 48.);
assert_eq!(layout.size().height, 60.);
let empty = StateLabelLayout::new("", "", &|_| 0.);
assert!(empty.title_lines.is_empty() && empty.summary_lines.is_empty());
assert_eq!(empty.size().height, 24.);
}
#[test]
fn state_summary_raw_prefix_and_title_budget_survive_display_normalization() {
let (mut project, diagram, edge) = fixture();
let relation = project.relations.get_mut(&edge).unwrap();
relation.name = "T".repeat(80);
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = format!("first\r\n{}", "guard".repeat(20));
}
let full = state_transition_summary(&project, &project.relations[&edge]);
let raw: String = full.chars().take(47).chain(std::iter::once('…')).collect();
let d = &project.diagrams[&diagram];
let routed = route_diagram(&project, d, &project_diagram(&project, d));
let label = &routed.edges[&edge].label;
assert_eq!(label.text, raw);
assert!(label.text.contains("\r\n"));
let layout = label.state_layout.as_ref().unwrap();
assert_eq!(layout.title_lines, [format!("{}…", "T".repeat(56))]);
assert_eq!(
layout.summary_lines,
raw.replace("\r\n", "\n")
.split('\n')
.map(str::to_owned)
.collect::<Vec<_>>()
);
assert_eq!(label.text.chars().count(), 48);
}
#[test]
fn supplied_measurement_controls_settled_and_preview_state_bounds_only() {
let (project, diagram, edge) = fixture();
let d = &project.diagrams[&diagram];
let view = project_diagram(&project, d);
let baseline = route_diagram(&project, d, &view);
let wide = |_: &str| 400.;
let measured = route_diagram_with_state_label_measurement(&project, d, &view, &wide);
let label = &measured.edges[&edge].label;
assert_eq!(
label.bounds.size,
Size {
width: 410.,
height: 36.
}
);
assert_eq!(label.center, baseline.edges[&edge].label.center);
assert_eq!(measured.edges[&edge].points, baseline.edges[&edge].points);
let preview =
route_edge_preview_with_state_label_measurement(&project, d, &view, &measured, edge, &wide);
assert_eq!(preview.edges[&edge].label, *label);
let mut ordinary = d.clone();
ordinary.projection = ProjectionKind::Allocation;
let ordinary_measured =
route_diagram_with_state_label_measurement(&project, &ordinary, &view, &wide);
let ordinary_default = route_diagram(&project, &ordinary, &view);
assert!(ordinary_measured.edges[&edge].label.state_layout.is_none());
assert_eq!(ordinary_measured, ordinary_default);
}
+2 -2
View File
@@ -5241,7 +5241,7 @@ impl App {
if route.waypoints.len() >= 128 {
return;
}
let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view);
let geometry = crate::state_label::route_diagram(self.project(), diagram, &view);
let edge = &geometry.edges[&self.selected];
// Mandatory port leads are inside the card's clearance. A pin
// starts beyond those leads, including coincident short stubs.
@@ -5321,7 +5321,7 @@ impl App {
self.pan = iced::Vector::new(30., 30.);
return;
}
let geometry = oa_core::routing::route_diagram(
let geometry = crate::state_label::route_diagram(
self.project(),
&self.project().diagrams[&self.active_diagram.unwrap()],
&view,
+2 -2
View File
@@ -920,7 +920,7 @@ impl App {
};
let frozen = affected.map(|affected| {
let d = &self.project().diagrams[&diagram];
let routed = routing::route_diagram(self.project(), d, &view);
let routed = crate::state_label::route_diagram(self.project(), d, &view);
Arc::new(Frozen::capture(
self.project(),
d,
@@ -1066,7 +1066,7 @@ impl App {
self.reconcile_multiselect();
}
} else {
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
let routed = Arc::new(crate::state_label::route_diagram(candidate.project(), placed.diagram(), placed.view()));
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
}
Ok(label)
+1 -1
View File
@@ -85,7 +85,7 @@ impl App {
};
let diagram = &self.project().diagrams[&context.diagram];
let view = projection::project_diagram(self.project(), diagram);
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
let bounds = crate::state_label::route_diagram(self.project(), diagram, &view).bounds;
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
+85 -24
View File
@@ -744,7 +744,7 @@ pub(crate) fn diagram_usages(
}
}
}
for edge in routing::route_diagram(project, diagram, view)
for edge in crate::state_label::route_diagram(project, diagram, view)
.edges
.values()
{
@@ -754,10 +754,10 @@ pub(crate) fn diagram_usages(
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) {
// The shared plan paints the complete retained raw summary. Keep its
// original prefix here: display normalization must not shift field offsets.
let painted = &edge.label.text;
if let Some(role) = transition_label_role_in_text(project, relation, subject, painted) {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
@@ -1078,7 +1078,7 @@ impl StateCanvas<'_> {
}
let view = projection::project_diagram(self.project, &diagram);
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
routing::route_edge_preview(
crate::state_label::route_edge_preview(
self.project,
&diagram,
&view,
@@ -1086,7 +1086,7 @@ impl StateCanvas<'_> {
gesture.route.edge,
)
} else {
routing::route_diagram(self.project, &diagram, &view)
crate::state_label::route_diagram(self.project, &diagram, &view)
};
let symbol_labels = symbol_labels(self.project, &view, &routed);
let scene = Arc::new(Scene {
@@ -1153,6 +1153,19 @@ impl StateCanvas<'_> {
.map(|n| n.element)
}
fn hit(&self, scene: &Scene, world: Point) -> Option<Hit> {
// State labels paint opaque backgrounds above nodes and rows. Select
// the last painted label at an overlapping pixel, including its padding.
// Endpoint/bend handling remains ahead of this ordinary hit path.
if let Some(edge) = scene.routed.edges.values().rev().find(|edge| {
edge.label.state_layout.is_some()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
)
.contains(world)
}) {
return Some(Hit::Transition(edge.id));
}
let node = self.node_at(scene, world);
if let Some(node) = node {
if let Some((relation, _)) = state_action_rows(self.project, node)
@@ -2107,21 +2120,29 @@ impl canvas::Program<Event> for StateCanvas<'_> {
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
let width = label.bounds.size.width - 10.;
label_text(
frame,
short(self.project.named(edge.id), width, 10.),
point(label.bounds.position) + Vector::new(5., 0.),
10.,
color,
);
label_text(
frame,
short(&label.text, width, 10.),
point(label.bounds.position) + Vector::new(5., 12.),
10.,
style::MUTED,
);
if let Some(plan) = &label.state_layout {
for (line, offset, ink) in plan
.title_lines
.iter()
.enumerate()
.map(|(index, line)| (line, plan.title_line_position(index), color))
.chain(plan.summary_lines.iter().enumerate().map(|(index, line)| {
(line, plan.summary_line_position(index), style::MUTED)
}))
{
frame.fill_text(Text {
content: line.clone(),
position: point(label.bounds.position)
+ Vector::new(offset.x, offset.y),
size: routing::StateLabelLayout::FONT_SIZE.into(),
line_height: iced::widget::text::LineHeight::Absolute(
routing::StateLabelLayout::LINE_HEIGHT.into(),
),
color: ink,
..Default::default()
});
}
}
if active
&& self
.current_selection()
@@ -2595,6 +2616,7 @@ mod tests {
include!("state_bend_tests.rs");
include!("state_multiselect_canvas_tests.rs");
include!("state_label_canvas_tests.rs");
struct Fixture {
project: Project,
@@ -2803,6 +2825,45 @@ mod tests {
assert!(!retained_label_field("1234A678", "1234…", 4));
}
#[test]
fn state_label_usage_keeps_raw_crlf_prefix_for_later_typed_fields() {
let mut f = fixture();
let first = f
.project
.add_element(Kind::Event, Layer::Sa, "漢\r\nB", None)
.unwrap();
let second = f
.project
.add_element(Kind::Event, Layer::Sa, "Visible", None)
.unwrap();
let edge = f
.project
.relate(RelationKind::StateTransition, "Ready\nfor review", f.a, f.b);
if let RelationBody::StateTransition { triggers, .. } =
&mut f.project.relations.get_mut(&edge).unwrap().body
{
*triggers = vec![first, second];
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = crate::state_label::route_diagram(&f.project, diagram, &view);
let label = &routed.edges[&edge].label;
assert_eq!(label.text, "External · 漢\r\nB OR Visible");
assert_eq!(
label.state_layout.as_ref().unwrap().summary_lines,
["External · 漢", "B OR Visible"]
);
let usages = diagram_usages(&f.project, diagram, &view, second);
assert!(
usages
.iter()
.any(|u| u.target == Target::Edge(edge) && u.role == "transition trigger")
);
// Feeding normalized display text to raw-prefix matching would lose
// the second field at CRLF. Its raw name and source remain unchanged.
assert_eq!(f.project.named(first), "漢\r\nB");
}
#[test]
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
use crate::diagram_usage::Target;
@@ -2837,9 +2898,9 @@ mod tests {
}
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 routed = crate::state_label::route_diagram(&f.project, diagram, &view);
let edge = &routed.edges[&external];
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
let painted = &edge.label.text;
// 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)));
+1 -1
View File
@@ -328,7 +328,7 @@ pub fn bend_route(
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = oa_core::routing::route_diagram(project, stored, &view)
let geometry = crate::state_label::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
@@ -0,0 +1,325 @@
// Included in state_canvas::tests. Fixed world points exercise the real scene
// and Program input, rather than choosing a successful point from returned bounds.
fn multiline_label_fixture() -> (Fixture, Id, Id) {
let mut f = fixture();
let work = f
.project
.add_element(Kind::Function, Layer::Sa, "Record request", None)
.unwrap();
let action = f
.project
.relate(RelationKind::StateAction, "Record shared work", f.a, work);
if let RelationBody::StateAction { phase, .. } =
&mut f.project.relations.get_mut(&action).unwrap().body
{
*phase = StateActionKind::Entry;
}
let generated = f
.project
.relate(RelationKind::StateTransition, "Ready\nfor review", f.b, f.c);
let edge = Id::from_u128(36_001);
let mut relation = f.project.relations.remove(&generated).unwrap();
relation.id = edge;
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = "request_ready".into();
}
f.project.relations.insert(edge, relation);
// B is (760,180), 224x94. Its bottom midpoint is (872,274).
// Fraction zero plus this authored offset fixes the label center at (440,260),
// over A's entry row (y244..270), independently of text width/routing choices.
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.5,
}),
label: Some(EdgeLabel {
fraction: 0.,
offset: Position { x: -432., y: -14. },
}),
});
validate_candidate(&f.project).unwrap();
(f, edge, action)
}
#[test]
fn state_multiline_label_owns_covered_row_and_node_pixels_but_not_exposed_row() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
for zoom in [0.5, 1., 2.] {
let canvas = canvas(&f, Tool::Select, zoom); // Nonzero pan and canvas origin.
let scene = canvas.scene(&State::default()).unwrap();
let label = &scene.routed.edges[&edge].label;
let layout = label
.state_layout
.as_ref()
.expect("dedicated State label plan");
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(
layout.summary_lines,
["External · Completion [request_ready]"]
);
assert_eq!(label.center, Position { x: 440., y: 260. });
assert_eq!(label.bounds.size.height, 36.);
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
let row = state_action_rows(&f.project, node)[0].1;
assert!(row.contains(Point::new(440., 260.)));
assert!(!row.contains(Point::new(440., 272.)));
// Second title cell covers a row; summary covers the State header below it.
for at in [Point::new(440., 260.), Point::new(440., 272.)] {
assert_eq!(canvas.hit(&scene, at), Some(Hit::Transition(edge)));
let events = click(&canvas, &mut State::default(), at);
assert!(
matches!(events.as_slice(), [Event::Select(context, id)]
if *context == canvas.context && *id == edge),
"{events:?}"
);
}
let exposed = Point::new(200., 260.);
assert!(row.contains(exposed));
assert_eq!(
canvas.hit(&scene, exposed),
Some(Hit::Row {
placement: node.id,
relation: action
})
);
let events = click(&canvas, &mut State::default(), exposed);
assert!(
matches!(events.as_slice(), [Event::Select(_, id)] if *id == action),
"{events:?}"
);
for (at, object, target) in [
(Point::new(440., 272.), edge, Target::Edge(edge)),
(
exposed,
action,
Target::StateRow {
placement: node.id,
relation: action,
},
),
] {
let mut state = State::default();
let at = screen(&canvas, at);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { object: actual, selection_target: Some(actual_target), .. })
if actual == object && actual_target == target)
);
}
}
assert_eq!(
f.project, before,
"Hit testing and input cannot author layout or semantics"
);
}
#[test]
fn state_overlapping_label_background_selects_last_painted_even_if_lower_is_selected() {
let (mut f, lower, _) = multiline_label_fixture();
let upper = Id::from_u128(36_002);
let mut relation = f.project.relations[&lower].clone();
relation.id = upper;
relation.alias = "second_multiline_transition".into();
f.project.relations.insert(upper, relation);
let mut route = f.project.diagrams[&f.diagram].routes[0].clone();
route.edge = upper;
// Reverse authored route order: stable edge paint order must still decide.
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.insert(0, route);
validate_candidate(&f.project).unwrap();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = lower;
let selection = group_selection(&canvas, [Target::Edge(lower)]);
canvas.selection = Some(&selection);
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
scene.routed.edges[&lower].label.bounds,
scene.routed.edges[&upper].label.bounds
);
let at = Point::new(440., 260.);
assert_eq!(canvas.hit(&scene, at), Some(Hit::Transition(upper)));
let events = click(&canvas, &mut State::default(), at);
assert!(
matches!(events.as_slice(), [Event::Selection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Replace))]
if *id == upper),
"{events:?}"
);
}
#[test]
fn state_multiline_label_and_exposed_row_keep_shift_identity_and_refuse_mixed_drag() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
let mut canvas = canvas(&f, Tool::Select, 0.75);
let row = Target::StateRow {
placement: placement(&f, f.a),
relation: action,
};
let selection = group_selection(&canvas, [Target::Edge(edge), row]);
let snapshot = selection
.snapshot(selection_context(canvas.context))
.unwrap();
canvas.selection = Some(&selection);
canvas.selected = edge;
for (world, target) in [
(Point::new(440., 260.), Target::Edge(edge)),
(Point::new(200., 260.), row),
] {
let at = screen(&canvas, world);
let mut state = State::default();
assert!(
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT
)),
bounds(),
mouse::Cursor::Unavailable
)
.is_none()
);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
Some(Event::Selection(SelectionEvent::Choose(context, actual, Choice::Toggle)))
if context == selection_context(canvas.context) && actual == target)
);
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
at
)
.is_none()
);
let mut state = State::default();
motion(&canvas, &mut state, at, at);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at
)
.is_none()
);
assert!(state.group.is_some() && state.press.is_none() && state.bend.is_none());
let end = at + Vector::new(30., 20.);
motion(&canvas, &mut state, end, end);
assert!(canvas.group_preview_key(&state, Some(bounds())).is_none());
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
Some(Event::Selection(SelectionEvent::Refuse(actual, _))) if actual == snapshot)
);
assert!(state.group.is_none() && state.bend_preview.is_none());
}
assert_eq!(
selection
.snapshot(selection_context(canvas.context))
.unwrap(),
snapshot
);
assert_eq!(f.project, before);
}
#[test]
fn state_multiline_label_group_preview_replaces_old_hit_rectangle_and_cancel_restores_it() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
let mut canvas = canvas(&f, Tool::Select, 0.75);
let selection = group_selection(
&canvas,
[
Target::Placement(placement(&f, f.b)),
Target::Placement(placement(&f, f.c)),
],
);
canvas.selection = Some(&selection);
canvas.selected = f.c;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let old = Point::new(440., 260.);
let moved = Point::new(440., 580.);
assert_eq!(canvas.hit(&baseline, old), Some(Hit::Transition(edge)));
let start = screen(&canvas, Point::new(780., 200.));
let end = start + Vector::new(0., 320. * canvas.scale());
motion(&canvas, &mut state, start, end);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
end
)
.is_none()
);
assert!(state.group.is_some());
motion(&canvas, &mut state, end, end);
let preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_eq!(
preview.routed.edges[&edge].label.center,
Position { x: 440., y: 580. }
);
assert_eq!(
preview.routed.edges[&edge].label.state_layout,
baseline.routed.edges[&edge].label.state_layout
);
assert_eq!(canvas.hit(&preview, moved), Some(Hit::Transition(edge)));
assert_eq!(
canvas.hit(&preview, old),
Some(Hit::Row {
placement: placement(&f, f.a),
relation: action
})
);
assert_eq!(f.project, before, "Group preview is derived only");
assert!(
group_key(
&canvas,
&mut state,
keyboard::key::Named::Escape,
keyboard::Modifiers::empty()
)
.is_none()
);
assert!(state.group.is_none());
let restored = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_eq!(
restored.routed.edges[&edge].label,
baseline.routed.edges[&edge].label
);
assert_eq!(canvas.hit(&restored, old), Some(Hit::Transition(edge)));
assert_ne!(canvas.hit(&restored, moved), Some(Hit::Transition(edge)));
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(f.project, before);
}
+168 -5
View File
@@ -1,6 +1,7 @@
use crate::{
app::Message,
route_edit::{self, Context, Event as RouteEvent, Gesture, Handle, point, position},
state_label::route_diagram,
style,
views::short,
};
@@ -13,7 +14,7 @@ use iced::{
};
use oa_core::{
projection::{ProjectedNode, ProjectedView, project_view},
routing::{RoutedView, route_diagram},
routing::{RoutedView, StateLabelLayout},
*,
};
use std::{
@@ -569,7 +570,7 @@ impl DiagramCanvas<'_> {
.as_ref()
.filter(|g| g.context == self.context)
{
oa_core::routing::route_edge_preview(
crate::state_label::route_edge_preview(
self.project,
&diagram,
&view,
@@ -1348,7 +1349,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
Stroke::default().with_color(color).with_width(1.7),
);
}
if !routed.label.text.is_empty() {
if routed.label.state_layout.is_none() && !routed.label.text.is_empty() {
let label = &routed.label;
f.fill_rectangle(
point(label.bounds.position),
@@ -1487,6 +1488,47 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.fill(&Path::circle(center, 1.5), color);
}
}
// State labels follow nodes, matching the dedicated State canvas layer.
for routed in scene.routed.edges.values() {
let label = &routed.label;
let active = self.selected == routed.id
|| highlighted.contains(&routed.id)
|| self.selection_contains(crate::diagram_usage::Target::Edge(routed.id));
if label.state_layout.is_some() && !label.text.is_empty() {
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
if let Some(plan) = &label.state_layout {
for (lines, title) in [(&plan.title_lines, true), (&plan.summary_lines, false)]
{
for (index, line) in lines.iter().enumerate() {
let offset = if title {
plan.title_line_position(index)
} else {
plan.summary_line_position(index)
};
f.fill_text(Text {
content: line.clone(),
position: point(label.bounds.position)
+ Vector::new(offset.x, offset.y),
color: if title {
if active { style::TEAL } else { style::INK }
} else {
style::MUTED
},
size: StateLabelLayout::FONT_SIZE.into(),
line_height: iced::widget::text::LineHeight::Absolute(
StateLabelLayout::LINE_HEIGHT.into(),
),
..Default::default()
});
}
}
}
}
}
if let Some(bounds) = self.navigation_focus {
f.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
@@ -1943,6 +1985,34 @@ impl std::fmt::Display for ExportError {
}
impl std::error::Error for ExportError {}
fn edge_label_svg(label: &routing::EdgeLabelGeometry) -> String {
if label.text.is_empty() {
return String::new();
}
let mut svg = format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/>",
label.bounds.position.x,
label.bounds.position.y,
label.bounds.size.width,
label.bounds.size.height
);
if let Some(plan) = &label.state_layout {
for (lines, title) in [(&plan.title_lines, true), (&plan.summary_lines, false)] {
for (index, line) in lines.iter().enumerate() {
let offset = if title {
plan.title_line_position(index)
} else {
plan.summary_line_position(index)
};
svg.push_str(&format!("<text x=\"{}\" y=\"{}\" dominant-baseline=\"text-before-edge\" xml:space=\"preserve\" font-family=\"sans-serif\" font-size=\"{}\" fill=\"{}\">{}</text>", label.bounds.position.x + offset.x, label.bounds.position.y + offset.y, StateLabelLayout::FONT_SIZE, hex(if title { style::INK } else { style::MUTED }), escape(line)));
}
}
} else {
svg.push_str(&format!("<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.center.x,label.center.y,escape(&label.text)));
}
svg
}
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
let d = p
.diagrams
@@ -2029,8 +2099,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
.join(" ");
svg.push_str(&format!("<path data-edge=\"{}\" d=\"{path}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",edge.id,escape(p.named(edge.id))));
let label = &route.label;
if !label.text.is_empty() {
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
if label.state_layout.is_none() {
svg.push_str(&edge_label_svg(label));
}
if let Some(relation) = p.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, route) {
@@ -2089,6 +2159,11 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
}
svg.push_str("</g>");
}
for route in routed.edges.values() {
if route.label.state_layout.is_some() {
svg.push_str(&edge_label_svg(&route.label));
}
}
svg.push_str("</svg>");
Ok(svg)
}
@@ -2946,6 +3021,94 @@ mod tests {
);
assert!(state.route_drag.is_none());
}
#[test]
fn state_svg_paints_separate_escaped_lines_at_shared_layout_origins() {
let plan = StateLabelLayout::new(
"Ready\r\nfor <review>&",
"External\nCompletion [ready]",
&|line| line.len() as f32 * 8.0,
);
let label = routing::EdgeLabelGeometry {
text: "External\nCompletion [ready]".into(),
fraction: 0.5,
anchor: Position { x: 0.0, y: 0.0 },
center: Position { x: 0.0, y: 0.0 },
bounds: routing::RoutingBounds {
position: Position { x: 100.0, y: 200.0 },
size: plan.size(),
},
state_layout: Some(plan),
};
let svg = edge_label_svg(&label);
assert_eq!(svg.matches("<text ").count(), 4);
for (y, color, text) in [
(200, "#18212c", "Ready"),
(212, "#18212c", "for &lt;review&gt;&amp;"),
(224, "#4b5968", "External"),
(236, "#4b5968", "Completion [ready]"),
] {
assert!(svg.contains(&format!("<text x=\"105\" y=\"{y}\" dominant-baseline=\"text-before-edge\" xml:space=\"preserve\" font-family=\"sans-serif\" font-size=\"10\" fill=\"{color}\">{text}</text>")), "{svg}");
}
assert!(svg.contains("height=\"48\""));
assert!(!svg.contains("text-anchor=\"middle\""));
}
#[test]
fn state_export_routes_measured_multiline_label_above_nodes() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p
.add_element(Kind::State, Layer::Oa, "Source state", Some(region))
.unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "Target state", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Ready\nfor <review>&", a, b);
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "state_export".into(),
name: "State export".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
p.place(diagram, a, Position { x: 100.0, y: 100.0 });
p.place(diagram, b, Position { x: 600.0, y: 100.0 });
let view = project_view(&p, diagram);
let routed = route_diagram(&p, &p.diagrams[&diagram], &view);
let label = &routed.edges[&edge].label;
let plan = label.state_layout.as_ref().unwrap();
assert_eq!(plan.title_lines, ["Ready", "for <review>&"]);
assert_eq!(plan.summary_lines.len(), 1);
assert_eq!(label.bounds.size.height, 36.0);
let svg = export_svg(&p, diagram).unwrap();
let label_svg = edge_label_svg(label);
assert!(svg.ends_with(&format!("{label_svg}</svg>")));
assert!(svg.find("Target state</text>").unwrap() < svg.find("Ready</text>").unwrap());
assert!(svg.contains("for &lt;review&gt;&amp;</text>"));
assert!(svg.contains(&format!(
"x=\"{}\" y=\"{}\" dominant-baseline=\"text-before-edge\"",
label.bounds.position.x + 5.0,
label.bounds.position.y + 24.0
)));
}
#[test]
fn routed_svg_has_same_polyline_label_and_bounds_as_canvas_scene() {
let (mut p, diagram, edge) = route_fixture();
+1 -1
View File
@@ -6,7 +6,7 @@ impl DiagramCanvas<'_> {
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
let diagram = &self.project.diagrams[&self.diagram];
let view = self.projected(diagram);
let routed = routing::route_diagram(self.project, diagram, &view);
let routed = crate::state_label::route_diagram(self.project, diagram, &view);
self.work_dirty_press(
&view,
&routed,
+2 -2
View File
@@ -327,7 +327,7 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
"state machine view"
}
.into(),
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
bounds: routed_bounds(crate::state_label::route_diagram(p, diagram, &view).bounds),
});
}
for node in &view.nodes {
@@ -407,7 +407,7 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
}
}
if view.edges.iter().any(|edge| edge.id == subject)
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
&& let Some(edge) = crate::state_label::route_diagram(p, diagram, &view)
.edges
.get(&subject)
{
+1
View File
@@ -34,6 +34,7 @@ mod source_navigation;
mod source_rename;
mod source_tree;
mod source_workspace;
mod state_label;
mod style;
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
//! Desktop routing uses the same font/shaping defaults as canvas text.
use iced::advanced::{
graphics::text::Paragraph,
text::{self, Paragraph as _},
};
use oa_core::{
Diagram, Id, Project,
projection::ProjectedView,
routing::{self, RoutedView, StateLabelLayout},
};
fn measure_line(line: &str) -> f32 {
let defaults = iced::widget::canvas::Text::default();
Paragraph::with_text(text::Text {
content: line,
bounds: iced::Size::INFINITE,
size: StateLabelLayout::FONT_SIZE.into(),
line_height: text::LineHeight::Absolute(StateLabelLayout::LINE_HEIGHT.into()),
font: defaults.font,
align_x: defaults.align_x,
align_y: defaults.align_y,
shaping: defaults.shaping,
wrapping: text::Wrapping::None,
})
.min_width()
}
pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) -> RoutedView {
routing::route_diagram_with_state_label_measurement(project, diagram, view, &measure_line)
}
pub fn route_edge_preview(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
baseline: &RoutedView,
edge: Id,
) -> RoutedView {
routing::route_edge_preview_with_state_label_measurement(
project,
diagram,
view,
baseline,
edge,
&measure_line,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wide_glyphs_use_shaped_width_instead_of_character_estimate() {
let wide = "W".repeat(40);
let narrow = "i".repeat(40);
let width = measure_line(&wide);
assert!(width > measure_line(&narrow) * 2.0);
assert!(width > 40.0 * 7.0);
let plan = StateLabelLayout::new(&wide, &wide, &measure_line);
assert_eq!(
plan.size().width,
width + 2.0 * StateLabelLayout::HORIZONTAL_PADDING
);
}
#[test]
fn unicode_fallback_and_combining_text_have_finite_measured_bounds() {
for line in ["漢字 Ελληνικά العربية", "e\u{301} cafe\u{301}", "🙂 🧭"]
{
let width = measure_line(line);
assert!(width.is_finite() && width > 0.0, "{line:?}: {width}");
let plan = StateLabelLayout::new(line, line, &measure_line);
assert_eq!(
plan.size().width,
width + 2.0 * StateLabelLayout::HORIZONTAL_PADDING
);
}
assert!(measure_line("e\u{301}") < measure_line("ee"));
}
}
+5
View File
@@ -204,3 +204,8 @@ are recorded in release-routing-verification.md. The measured release full solve
is 119 ms, so the initial settled target is not claimed as passed. Current Linux
verification and broader release acceptance remain separate from the completed
routing workflows.
Dedicated State transition title/summary layout, measured desktop label bounds
and painted-label hit ownership are specified in
[the State label layout contract](state-label-layout-contract.md). This derived
presentation follow-up does not change authored routing metadata.
+37
View File
@@ -0,0 +1,37 @@
# State transition label layout
Derived presentation contract for [issue #36](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/36), based on source `715036e132677dd2bc2fb37ed316d93dc9987aec`. This follow-up is separate from the frozen September 24 prerelease. Implementation and acceptance remain in progress; this document does not claim a passed native gate.
## Scope and decision
The reproduced external transition title `Ready\nfor review` occupies two lines, while the old painter reserved only one line before its summary `External · Completion [request_ready]`. Routing sized the box from the summary alone. The correction is one derived title/summary layout on routed labels in dedicated State projections. Other projections, State headers, action rows and internal-transition rows retain their existing layout.
The layout is prepared before label placement, so painting, collision avoidance, hit bounds, focus, previews and SVG share it. Core accepts a line-width measurement callback without a font dependency. Desktop consumers use the same Iced Paragraph font, size, shaping and line-height configuration as their canvas text. Core-only callers retain an explicitly approximate deterministic width fallback. No semantic model or serialized layout field is introduced.
Flattening line breaks would lose the authored presentation. Enlarging only the paint background would leave focus and selection inconsistent. A scalar-width estimate is insufficient for a width-dominating title such as 40 `W` characters. Dynamic node/row layout is outside this reproduced defect and would change unrelated State placement and group-editing contracts.
## Excerpts and display lines
Keep the existing summary excerpt: at most 48 Unicode scalars of the original summary, taking 47 and an ellipsis when longer. `EdgeLabelGeometry.text` retains this raw excerpt. Typed usage visibility compares this original prefix with the original summary; normalization must never change the field offsets or expose text after the cutoff.
For compatibility, the title budget is fixed from the old summary width, before new measurement: `floor((7*N+6)/6)`, where `N` is the retained raw summary's scalar count. Apply the existing whole-prefix-and-ellipsis rule to the raw title. This is a bounded excerpt, not a promise to retain breaks beyond its cutoff.
In each retained excerpt, CRLF is one display break; LF, bare CR, NEL, Unicode line separator and paragraph separator are also breaks. Tabs display as four spaces. These transformations do not modify source or raw usage provenance. Empty blocks have zero lines. Nonempty trailing and blank lines are preserved: `A\n` and `\n` each have two cells. A CR at an excerpt boundary is a break even if its LF was truncated.
Each line uses font size 10 and an explicit 12-unit line cell, without wrapping. The summary begins after all title cells. Width is the maximum measured displayed-line advance plus five units of padding on each side. Height is at least 24 units and otherwise 12 times the total line count. Lines start at `(5, 12*index)` relative to the shared box. Authored fraction, offset, center, attachments and waypoints remain unchanged. Automatic label positions may change because their obstacles are now accurate.
## Hit ownership and consumers
The dedicated State canvas paints opaque label backgrounds after nodes and rows. Its ordinary hit test therefore checks those same rectangles before nodes/rows, in reverse label paint order. Padding and blank cells belong to the rectangle. Existing selected endpoint/bend handling remains earlier; path-distance ties, group admission and `(placement, relation)` row identities remain unchanged. Exposed adjacent row pixels still select that exact row. This deliberately does not model every crossing route-stroke pixel.
Scene routing, edge/group previews, usage/focus queries, generic canvas paths receiving a State plan and SVG all consume the measured plan. SVG emits explicit positions for each line and the same font-size declaration; exact appearance under an external viewer's substituted font is not guaranteed.
## Acceptance
- Exact peer fixture: two title cells followed by one summary cell, readable and selectable at its last line, with full Project/source conservation.
- Bounds falsifiers: 40 `W` title characters and a fallback/combining-text case, plus blank lines and separators. Inspect actual native glyph containment; Paragraph advance alone is not a universal ink-overhang proof.
- Raw-prefix provenance across CRLF and multibyte text, typed fields at/after the cutoff, and duplicate names in unrelated prose.
- Label over node selects the Edge; adjacent exposed row keeps its StateRow identity; overlapping labels choose the last painted. Shift selection, mixed-group refusal and existing handles keep their contracts.
- Preview and settled scene bounds agree after movement; nonzero pan/zoom has no stale hit rectangle. Save/reopen and Undo/Redo preserve the independently declared source/model oracles.
Use meaningful automated checks and the actual Windows GUI. Linux is excluded by the user's current instructions. Publish verification and limitations with the implementation before closing #36.