From 2af40c7e27ed9750b9585c07369dbf17a046a92d Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Thu, 24 Sep 2026 15:50:56 -0700 Subject: [PATCH] Fix State transition multiline label geometry and selection --- crates/oa-core/src/routing.rs | 178 +++++++++- crates/oa-core/tests/state_label_layout.rs | 145 ++++++++ crates/oa-desktop/src/app.rs | 4 +- crates/oa-desktop/src/app/allocation_drag.rs | 4 +- crates/oa-desktop/src/app/state_diagram.rs | 2 +- .../oa-desktop/src/behavior/state_canvas.rs | 109 ++++-- crates/oa-desktop/src/behavior/state_edit.rs | 2 +- .../src/behavior/state_label_canvas_tests.rs | 325 ++++++++++++++++++ crates/oa-desktop/src/diagram.rs | 173 +++++++++- crates/oa-desktop/src/diagram/work_drag.rs | 2 +- crates/oa-desktop/src/diagram_usage.rs | 4 +- crates/oa-desktop/src/main.rs | 1 + crates/oa-desktop/src/state_label.rs | 81 +++++ harness/release-routing-contract.md | 5 + harness/state-label-layout-contract.md | 37 ++ 15 files changed, 1023 insertions(+), 49 deletions(-) create mode 100644 crates/oa-core/tests/state_label_layout.rs create mode 100644 crates/oa-desktop/src/behavior/state_label_canvas_tests.rs create mode 100644 crates/oa-desktop/src/state_label.rs create mode 100644 harness/state-label-layout-contract.md diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 4868d45b..60028611 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -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, /// 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, + pub summary_lines: Vec, + 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 { + 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, out: &mut BTreeSet 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, diff --git a/crates/oa-core/tests/state_label_layout.rs b/crates/oa-core/tests/state_label_layout.rs new file mode 100644 index 00000000..cdfe7793 --- /dev/null +++ b/crates/oa-core/tests/state_label_layout.rs @@ -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::>() + ); + 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); +} diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 2e65a5be..1bad440b 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -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, diff --git a/crates/oa-desktop/src/app/allocation_drag.rs b/crates/oa-desktop/src/app/allocation_drag.rs index b6c32949..1a7505bd 100644 --- a/crates/oa-desktop/src/app/allocation_drag.rs +++ b/crates/oa-desktop/src/app/allocation_drag.rs @@ -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) diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index e484b077..0cf7ee25 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -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.), diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 8b6d2a74..9ce7df12 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -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 { + // 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 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))); diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index 6a650ed5..11392d77 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -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) diff --git a/crates/oa-desktop/src/behavior/state_label_canvas_tests.rs b/crates/oa-desktop/src/behavior/state_label_canvas_tests.rs new file mode 100644 index 00000000..ee46ed43 --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_label_canvas_tests.rs @@ -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); +} diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 464c085b..4d92a183 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -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 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 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!( + "", + 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!("{}", 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!("{}",label.center.x,label.center.y,escape(&label.text))); + } + svg +} + pub fn export_svg(p: &Project, diagram: Id) -> Result { let d = p .diagrams @@ -2029,8 +2099,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result { .join(" "); svg.push_str(&format!("{}",edge.id,escape(p.named(edge.id)))); let label = &route.label; - if !label.text.is_empty() { - svg.push_str(&format!("{}",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 { } svg.push_str(""); } + for route in routed.edges.values() { + if route.label.state_layout.is_some() { + svg.push_str(&edge_label_svg(&route.label)); + } + } svg.push_str(""); 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 &", + "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}")), "{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 &", 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 &"]); + 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}"))); + assert!(svg.find("Target state").unwrap() < svg.find("Ready").unwrap()); + assert!(svg.contains("for <review>&")); + 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(); diff --git a/crates/oa-desktop/src/diagram/work_drag.rs b/crates/oa-desktop/src/diagram/work_drag.rs index 0cc4957d..61794306 100644 --- a/crates/oa-desktop/src/diagram/work_drag.rs +++ b/crates/oa-desktop/src/diagram/work_drag.rs @@ -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, diff --git a/crates/oa-desktop/src/diagram_usage.rs b/crates/oa-desktop/src/diagram_usage.rs index 08a11a06..2c110a47 100644 --- a/crates/oa-desktop/src/diagram_usage.rs +++ b/crates/oa-desktop/src/diagram_usage.rs @@ -327,7 +327,7 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option 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")); + } +} diff --git a/harness/release-routing-contract.md b/harness/release-routing-contract.md index da8d73bb..daeed042 100644 --- a/harness/release-routing-contract.md +++ b/harness/release-routing-contract.md @@ -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. diff --git a/harness/state-label-layout-contract.md b/harness/state-label-layout-contract.md new file mode 100644 index 00000000..81ec2e8e --- /dev/null +++ b/harness/state-label-layout-contract.md @@ -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.