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,