Fix State transition multiline label geometry and selection
This commit is contained in:
+167
-11
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user