Experiment with measured multiline label layout on bounded witness
This commit is contained in:
@@ -5417,11 +5417,17 @@ impl App {
|
||||
self.pan = iced::Vector::new(30., 30.);
|
||||
return;
|
||||
}
|
||||
let geometry = oa_core::routing::route_diagram(
|
||||
let mut geometry = oa_core::routing::route_diagram(
|
||||
self.project(),
|
||||
&self.project().diagrams[&self.active_diagram.unwrap()],
|
||||
&view,
|
||||
);
|
||||
crate::diagram::apply_label_experiment(
|
||||
self.project(),
|
||||
&self.project().diagrams[&self.active_diagram.unwrap()],
|
||||
&view,
|
||||
&mut geometry,
|
||||
);
|
||||
let left = geometry.bounds.position.x;
|
||||
let top = geometry.bounds.position.y;
|
||||
let right = left + geometry.bounds.size.width;
|
||||
|
||||
@@ -37,6 +37,9 @@ mod port_hit_tests;
|
||||
#[path = "diagram/view_options_tests.rs"]
|
||||
mod view_options_tests;
|
||||
|
||||
#[path = "diagram/label_layout_experiment.rs"]
|
||||
mod label_layout_experiment;
|
||||
|
||||
#[path = "diagram/port_labels.rs"]
|
||||
mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
@@ -88,6 +91,9 @@ pub struct State {
|
||||
cache: RefCell<Option<CachedScene>>,
|
||||
}
|
||||
struct Scene {
|
||||
label_layout: Option<label_layout_experiment::Layout>,
|
||||
profile: label_layout_experiment::Profile,
|
||||
font_version: Option<iced::advanced::graphics::text::Version>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Preview>>,
|
||||
view: ProjectedView,
|
||||
routed: Arc<RoutedView>,
|
||||
@@ -514,6 +520,14 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
}
|
||||
fn scene(&self, state: &State) -> Arc<Scene> {
|
||||
self.scene_with_profile(state, label_layout_experiment::profile())
|
||||
}
|
||||
fn scene_with_profile(
|
||||
&self,
|
||||
state: &State,
|
||||
profile: label_layout_experiment::Profile,
|
||||
) -> Arc<Scene> {
|
||||
let font_version = label_layout_experiment::font_version(profile);
|
||||
if let Some(preview) = self.work_preview()
|
||||
&& state
|
||||
.work
|
||||
@@ -522,13 +536,30 @@ impl DiagramCanvas<'_> {
|
||||
{
|
||||
if let Some(scene) = state.work_cache.borrow().as_ref()
|
||||
&& scene.work.as_ref().is_some_and(|p| Arc::ptr_eq(p, preview))
|
||||
&& scene.profile == profile
|
||||
&& scene.font_version == font_version
|
||||
{
|
||||
return scene.clone();
|
||||
}
|
||||
let mut routed = preview.routed.clone();
|
||||
let label_layout = if profile == label_layout_experiment::Profile::Baseline {
|
||||
None
|
||||
} else {
|
||||
label_layout_experiment::apply(
|
||||
preview.project(),
|
||||
&preview.placement.diagram,
|
||||
&preview.placement.view,
|
||||
Arc::make_mut(&mut routed),
|
||||
profile,
|
||||
)
|
||||
};
|
||||
let scene = Arc::new(Scene {
|
||||
label_layout,
|
||||
profile,
|
||||
font_version,
|
||||
work: Some(preview.clone()),
|
||||
view: preview.placement.view.clone(),
|
||||
routed: preview.routed.clone(),
|
||||
routed,
|
||||
});
|
||||
*state.work_cache.borrow_mut() = Some(scene.clone());
|
||||
return scene;
|
||||
@@ -554,6 +585,8 @@ impl DiagramCanvas<'_> {
|
||||
&& cache.preview == preview
|
||||
&& cache.route == route
|
||||
&& cache.port == port
|
||||
&& cache.scene.profile == profile
|
||||
&& cache.scene.font_version == font_version
|
||||
{
|
||||
return cache.scene.clone();
|
||||
}
|
||||
@@ -563,7 +596,7 @@ impl DiagramCanvas<'_> {
|
||||
self.project.diagrams[&self.diagram].clone()
|
||||
};
|
||||
let view = self.projected(&diagram);
|
||||
let routed = Arc::new(
|
||||
let mut routed = Arc::new(
|
||||
if let Some(gesture) = state
|
||||
.route_drag
|
||||
.as_ref()
|
||||
@@ -580,7 +613,21 @@ impl DiagramCanvas<'_> {
|
||||
route_diagram(self.project, &diagram, &view)
|
||||
},
|
||||
);
|
||||
let label_layout = if profile == label_layout_experiment::Profile::Baseline {
|
||||
None
|
||||
} else {
|
||||
label_layout_experiment::apply(
|
||||
self.project,
|
||||
&diagram,
|
||||
&view,
|
||||
Arc::make_mut(&mut routed),
|
||||
profile,
|
||||
)
|
||||
};
|
||||
let scene = Arc::new(Scene {
|
||||
label_layout,
|
||||
profile,
|
||||
font_version,
|
||||
work: None,
|
||||
view,
|
||||
routed,
|
||||
@@ -1355,16 +1402,27 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
Size::new(label.bounds.size.width, label.bounds.size.height),
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: label.text.clone(),
|
||||
position: point(label.center),
|
||||
color: if active { style::TEAL } else { style::MUTED },
|
||||
size: 13.into(),
|
||||
align_x: iced::widget::text::Alignment::Center,
|
||||
align_y: iced::alignment::Vertical::Center,
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(layout) = scene
|
||||
.label_layout
|
||||
.as_ref()
|
||||
.filter(|_| edge.id == label_layout_experiment::edge_id())
|
||||
{
|
||||
for line in layout.canvas_lines(if active { style::TEAL } else { style::MUTED })
|
||||
{
|
||||
f.fill_text(line);
|
||||
}
|
||||
} else {
|
||||
f.fill_text(Text {
|
||||
content: label.text.clone(),
|
||||
position: point(label.center),
|
||||
color: if active { style::TEAL } else { style::MUTED },
|
||||
size: 13.into(),
|
||||
align_x: iced::widget::text::Alignment::Center,
|
||||
align_y: iced::alignment::Vertical::Center,
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, routed) {
|
||||
@@ -1943,7 +2001,30 @@ impl std::fmt::Display for ExportError {
|
||||
}
|
||||
impl std::error::Error for ExportError {}
|
||||
|
||||
/// Private opt-in trial adapter for consumers that already own routed geometry.
|
||||
pub(crate) fn apply_label_experiment(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
view: &ProjectedView,
|
||||
routed: &mut RoutedView,
|
||||
) {
|
||||
let _ = label_layout_experiment::apply(
|
||||
p,
|
||||
diagram,
|
||||
view,
|
||||
routed,
|
||||
label_layout_experiment::profile(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
||||
export_svg_with_profile(p, diagram, label_layout_experiment::profile())
|
||||
}
|
||||
fn export_svg_with_profile(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
profile: label_layout_experiment::Profile,
|
||||
) -> Result<String, ExportError> {
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
@@ -1952,7 +2033,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
||||
return Err(ExportError::UnsupportedScenario(diagram));
|
||||
}
|
||||
let view = project_view(p, diagram);
|
||||
let routed = route_diagram(p, d, &view);
|
||||
let mut routed = route_diagram(p, d, &view);
|
||||
let label_layout = label_layout_experiment::apply(p, d, &view, &mut routed, profile);
|
||||
let left = routed.bounds.position.x - 30.;
|
||||
let top = routed.bounds.position.y - 30.;
|
||||
let right = routed.bounds.position.x + routed.bounds.size.width + 30.;
|
||||
@@ -2029,7 +2111,12 @@ 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() {
|
||||
if let Some(layout) = label_layout
|
||||
.as_ref()
|
||||
.filter(|_| edge.id == label_layout_experiment::edge_id())
|
||||
{
|
||||
svg.push_str(&layout.svg());
|
||||
} else 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 let Some(relation) = p.relations.get(&edge.id) {
|
||||
|
||||
@@ -422,6 +422,9 @@ fn coincident_attachment_handles_honor_explicit_end_choice() {
|
||||
)));
|
||||
let c = canvas(&app, 1.);
|
||||
let mut scene = Scene {
|
||||
label_layout: None,
|
||||
profile: label_layout_experiment::Profile::Baseline,
|
||||
font_version: None,
|
||||
work: None,
|
||||
view: projection::project_view(c.project, c.diagram),
|
||||
routed: Arc::new(route_diagram(
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
//! Explicit, one-fixture visual experiment. No persisted formatting policy.
|
||||
use super::*;
|
||||
use iced::advanced::{graphics::text as graphics_text, text as shaped};
|
||||
use shaped::Paragraph as _;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const DIAGRAM: u128 = 0x00000000003840008000000000000001;
|
||||
const EDGE: u128 = 0x63009b5f539e5914b630ec6b7fefb4d5;
|
||||
pub(super) fn edge_id() -> Id {
|
||||
Id::from_u128(EDGE)
|
||||
}
|
||||
|
||||
const NAME: &str = "Technician service conversation";
|
||||
const FONT_SIZE: f32 = 13.;
|
||||
const ADVANCE: f32 = 16.;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Profile {
|
||||
#[default]
|
||||
Baseline,
|
||||
E,
|
||||
F,
|
||||
}
|
||||
pub(super) fn profile() -> Profile {
|
||||
static PROFILE: OnceLock<Profile> = OnceLock::new();
|
||||
*PROFILE.get_or_init(
|
||||
|| match std::env::var("ARCADIA_LABEL_EXPERIMENT").as_deref() {
|
||||
Ok("E") => Profile::E,
|
||||
Ok("F") => Profile::F,
|
||||
_ => Profile::Baseline,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct MeasuredLine {
|
||||
text: &'static str,
|
||||
width: f32,
|
||||
baseline: f32,
|
||||
family: String,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Line {
|
||||
pub text: &'static str,
|
||||
pub position: Position,
|
||||
pub baseline_y: f32,
|
||||
pub width: f32,
|
||||
pub family: String,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Layout {
|
||||
pub canonical_text: String,
|
||||
pub lines: Vec<Line>,
|
||||
pub geometry: routing::EdgeLabelGeometry,
|
||||
/// Authored moves retain their shape even outside the automatic guard.
|
||||
pub clear: bool,
|
||||
}
|
||||
|
||||
pub(super) fn font_version(profile: Profile) -> Option<graphics_text::Version> {
|
||||
(profile != Profile::Baseline).then(|| {
|
||||
graphics_text::font_system()
|
||||
.read()
|
||||
.expect("Read font system")
|
||||
.version()
|
||||
})
|
||||
}
|
||||
|
||||
fn measured(profile: Profile) -> Option<Arc<Vec<MeasuredLine>>> {
|
||||
type Entry = (Profile, graphics_text::Version, Arc<Vec<MeasuredLine>>);
|
||||
static CACHE: OnceLock<Mutex<Vec<Entry>>> = OnceLock::new();
|
||||
let version = font_version(profile)?;
|
||||
let mut cache = CACHE.get_or_init(|| Mutex::new(Vec::new())).lock().ok()?;
|
||||
if let Some((_, _, lines)) = cache
|
||||
.iter()
|
||||
.find(|(p, v, _)| *p == profile && *v == version)
|
||||
{
|
||||
return Some(lines.clone());
|
||||
}
|
||||
let strings: &[&'static str] = match profile {
|
||||
Profile::Baseline => return None,
|
||||
Profile::E => &["Technician service", "conversation"],
|
||||
Profile::F => &["Technician", "service", "conversation"],
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for &content in strings {
|
||||
let paragraph = graphics_text::Paragraph::with_text(shaped::Text {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size: FONT_SIZE.into(),
|
||||
line_height: shaped::LineHeight::Absolute(ADVANCE.into()),
|
||||
font: iced::Font::default(),
|
||||
align_x: shaped::Alignment::Left,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: shaped::Shaping::Basic,
|
||||
wrapping: shaped::Wrapping::None,
|
||||
});
|
||||
let mut runs = paragraph.buffer().layout_runs();
|
||||
let run = runs.next()?;
|
||||
if runs.next().is_some() || run.rtl || run.glyphs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let width = paragraph.min_width();
|
||||
if !width.is_finite() || width <= 0. || paragraph.min_height() > ADVANCE + 0.01 {
|
||||
return None;
|
||||
}
|
||||
// This fixed English witness must resolve to one actual font face.
|
||||
// Mixed fallback is not silently exported as a generic SVG font.
|
||||
let font_id = run.glyphs.first()?.font_id;
|
||||
if run.glyphs.iter().any(|glyph| glyph.font_id != font_id) {
|
||||
return None;
|
||||
}
|
||||
let family = graphics_text::font_system()
|
||||
.write()
|
||||
.ok()?
|
||||
.raw()
|
||||
.db()
|
||||
.face(font_id)?
|
||||
.families
|
||||
.first()?
|
||||
.0
|
||||
.clone();
|
||||
lines.push(MeasuredLine {
|
||||
text: content,
|
||||
width,
|
||||
baseline: run.line_y,
|
||||
family,
|
||||
});
|
||||
}
|
||||
let lines = Arc::new(lines);
|
||||
cache.retain(|(_, v, _)| *v == version);
|
||||
cache.push((profile, version, lines.clone()));
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
/// Runs before any scene consumer; never edits the model or any routed points.
|
||||
pub(super) fn apply(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
view: &ProjectedView,
|
||||
routed: &mut RoutedView,
|
||||
profile: Profile,
|
||||
) -> Option<Layout> {
|
||||
if profile == Profile::Baseline || diagram.id.as_u128() != DIAGRAM {
|
||||
return None;
|
||||
}
|
||||
let id = Id::from_u128(EDGE);
|
||||
let edge = routed.edges.get(&id)?;
|
||||
if edge.label.text != NAME
|
||||
|| project.relations.get(&id)?.name != NAME
|
||||
|| edge.custom_suspended
|
||||
|| view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| node.visual_parent.is_some() || node.collapsed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let metrics = measured(profile)?;
|
||||
let width = metrics.iter().map(|line| line.width).fold(0., f32::max) + 16.;
|
||||
let height = 8. + ADVANCE * metrics.len() as f32;
|
||||
let authored = diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == id)
|
||||
.and_then(|route| route.label.as_ref());
|
||||
let fraction = authored.map_or(if profile == Profile::E { 0.5 } else { 0.55 }, |label| {
|
||||
label.fraction
|
||||
});
|
||||
let anchor = routing::point_along_polyline(&edge.points, fraction)?;
|
||||
let offset = authored.map_or(
|
||||
Position {
|
||||
x: if profile == Profile::E {
|
||||
0.
|
||||
} else {
|
||||
18. + width / 2.
|
||||
},
|
||||
y: 0.,
|
||||
},
|
||||
|label| label.offset,
|
||||
);
|
||||
let center = Position {
|
||||
x: anchor.x + offset.x,
|
||||
y: anchor.y + offset.y,
|
||||
};
|
||||
let bounds = routing::RoutingBounds {
|
||||
position: Position {
|
||||
x: center.x - width / 2.,
|
||||
y: center.y - height / 2.,
|
||||
},
|
||||
size: oa_core::Size { width, height },
|
||||
};
|
||||
let clear = guarded(project, view, routed, id, bounds, profile);
|
||||
if authored.is_none() && !clear {
|
||||
return None;
|
||||
}
|
||||
let geometry = routing::EdgeLabelGeometry {
|
||||
text: edge.label.text.clone(),
|
||||
fraction,
|
||||
anchor,
|
||||
center,
|
||||
bounds,
|
||||
};
|
||||
let lines = metrics
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
let top = bounds.position.y + 4. + ADVANCE * index as f32;
|
||||
Line {
|
||||
text: line.text,
|
||||
position: Position {
|
||||
x: center.x - line.width / 2.,
|
||||
y: top,
|
||||
},
|
||||
baseline_y: top + line.baseline,
|
||||
width: line.width,
|
||||
family: line.family.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let layout = Layout {
|
||||
canonical_text: edge.label.text.clone(),
|
||||
lines,
|
||||
geometry,
|
||||
clear,
|
||||
};
|
||||
routed.edges.get_mut(&id)?.label = layout.geometry.clone();
|
||||
// Retain conservative existing bounds; add authored placements outside them.
|
||||
// The fixed automatic witnesses are inside the unchanged node extents.
|
||||
let old = routed.bounds;
|
||||
let left = old.position.x.min(bounds.position.x);
|
||||
let top = old.position.y.min(bounds.position.y);
|
||||
let right = (old.position.x + old.size.width).max(bounds.position.x + width);
|
||||
let bottom = (old.position.y + old.size.height).max(bounds.position.y + height);
|
||||
routed.bounds = routing::RoutingBounds {
|
||||
position: Position { x: left, y: top },
|
||||
size: oa_core::Size {
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
},
|
||||
};
|
||||
Some(layout)
|
||||
}
|
||||
|
||||
fn rectangle(bounds: routing::RoutingBounds, padding: f32) -> Rectangle {
|
||||
Rectangle {
|
||||
x: bounds.position.x - padding,
|
||||
y: bounds.position.y - padding,
|
||||
width: bounds.size.width + 2. * padding,
|
||||
height: bounds.size.height + 2. * padding,
|
||||
}
|
||||
}
|
||||
fn overlap(a: Rectangle, b: Rectangle) -> bool {
|
||||
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
|
||||
}
|
||||
fn crossing(bounds: Rectangle, span: &[Position]) -> bool {
|
||||
let [a, b] = [span[0], span[1]];
|
||||
if a.x == b.x {
|
||||
a.x >= bounds.x
|
||||
&& a.x <= bounds.x + bounds.width
|
||||
&& a.y.min(b.y) <= bounds.y + bounds.height
|
||||
&& a.y.max(b.y) >= bounds.y
|
||||
} else if a.y == b.y {
|
||||
a.y >= bounds.y
|
||||
&& a.y <= bounds.y + bounds.height
|
||||
&& a.x.min(b.x) <= bounds.x + bounds.width
|
||||
&& a.x.max(b.x) >= bounds.x
|
||||
} else {
|
||||
// The bounded witness supports orthogonal geometry only.
|
||||
true
|
||||
}
|
||||
}
|
||||
fn guarded(
|
||||
project: &Project,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
id: Id,
|
||||
bounds: routing::RoutingBounds,
|
||||
profile: Profile,
|
||||
) -> bool {
|
||||
let label = rectangle(bounds, 0.);
|
||||
if view.nodes.iter().any(|node| {
|
||||
overlap(
|
||||
label,
|
||||
Rectangle {
|
||||
x: node.position.x - 12.,
|
||||
y: node.position.y - 12.,
|
||||
width: node.size.width + 24.,
|
||||
height: node.size.height + 24.,
|
||||
},
|
||||
)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
let stroke_bounds = rectangle(bounds, 3.);
|
||||
let mut own_crossings = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
if edge.id != id
|
||||
&& !edge.label.text.is_empty()
|
||||
&& overlap(label, rectangle(edge.label.bounds, 4.))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
if !end.label.text.is_empty() && overlap(label, rectangle(end.label.bounds, 4.)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for span in edge.points.windows(2) {
|
||||
if crossing(stroke_bounds, span) {
|
||||
if edge.id != id || profile == Profile::F {
|
||||
return false;
|
||||
}
|
||||
own_crossings.push(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
if profile == Profile::E {
|
||||
let [span] = own_crossings.as_slice() else {
|
||||
return false;
|
||||
};
|
||||
let [a, b] = [span[0], span[1]];
|
||||
// One complete straight crossing, not a bend, lead, tip, or shared run.
|
||||
(a.x == b.x
|
||||
&& a.y.min(b.y) < stroke_bounds.y
|
||||
&& a.y.max(b.y) > stroke_bounds.y + stroke_bounds.height)
|
||||
|| (a.y == b.y
|
||||
&& a.x.min(b.x) < stroke_bounds.x
|
||||
&& a.x.max(b.x) > stroke_bounds.x + stroke_bounds.width)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub(super) fn canvas_lines(&self, color: Color) -> impl Iterator<Item = Text> + '_ {
|
||||
self.lines.iter().map(move |line| Text {
|
||||
content: line.text.into(),
|
||||
position: point(line.position),
|
||||
color,
|
||||
size: FONT_SIZE.into(),
|
||||
line_height: shaped::LineHeight::Absolute(ADVANCE.into()),
|
||||
font: iced::Font::default(),
|
||||
align_x: shaped::Alignment::Left,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: shaped::Shaping::Basic,
|
||||
max_width: f32::INFINITY,
|
||||
})
|
||||
}
|
||||
pub(super) fn svg(&self) -> String {
|
||||
let bounds = self.geometry.bounds;
|
||||
let mut out = format!(
|
||||
"<g data-label-experiment=\"wrapped\"><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/>",
|
||||
escape(&self.canonical_text),
|
||||
bounds.position.x,
|
||||
bounds.position.y,
|
||||
bounds.size.width,
|
||||
bounds.size.height
|
||||
);
|
||||
for line in &self.lines {
|
||||
out.push_str(&format!("<text x=\"{}\" y=\"{}\" font-family=\"{}\" font-size=\"13\" fill=\"#637487\">{}</text>",
|
||||
line.position.x, line.baseline_y, escape(&line.family), escape(line.text)));
|
||||
}
|
||||
out.push_str("</g>");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (Project, Id) {
|
||||
let p: Project = serde_json::from_slice(include_bytes!(
|
||||
"../../../../harness/experiments/wrapped-label-20261001/fixture/eight-card.json"
|
||||
))
|
||||
.unwrap();
|
||||
(p, Id::from_u128(DIAGRAM))
|
||||
}
|
||||
fn canvas(p: &Project, diagram: Id) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge_id(),
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn baseline_is_exact_and_wrapping_changes_only_target_derived_geometry() {
|
||||
let (p, id) = fixture();
|
||||
let before = serde_json::to_vec(&p).unwrap();
|
||||
let diagram = &p.diagrams[&id];
|
||||
let view = project_view(&p, id);
|
||||
let baseline = route_diagram(&p, diagram, &view);
|
||||
assert_eq!(baseline.edges.len(), 12);
|
||||
let mut unchanged = baseline.clone();
|
||||
assert!(apply(&p, diagram, &view, &mut unchanged, Profile::Baseline).is_none());
|
||||
assert_eq!(unchanged, baseline);
|
||||
for profile in [Profile::E, Profile::F] {
|
||||
let mut routed = baseline.clone();
|
||||
let layout =
|
||||
apply(&p, diagram, &view, &mut routed, profile).expect("Measured witness must fit");
|
||||
assert!(layout.clear);
|
||||
assert_eq!(layout.canonical_text, NAME);
|
||||
assert_eq!(
|
||||
layout
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
NAME
|
||||
);
|
||||
assert_eq!(routed.edges[&edge_id()].label.text, NAME);
|
||||
for (id, old) in &baseline.edges {
|
||||
assert_eq!(routed.edges[id].points, old.points);
|
||||
if *id != edge_id() {
|
||||
assert_eq!(&routed.edges[id], old);
|
||||
}
|
||||
}
|
||||
let mut restored = routed.clone();
|
||||
restored.edges.get_mut(&edge_id()).unwrap().label =
|
||||
baseline.edges[&edge_id()].label.clone();
|
||||
assert_eq!(
|
||||
restored, baseline,
|
||||
"No paths, ports, solver state, other labels or scene extents change"
|
||||
);
|
||||
assert_eq!(
|
||||
layout.geometry.bounds.size.height,
|
||||
8. + ADVANCE * layout.lines.len() as f32
|
||||
);
|
||||
let max_width = layout
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.width)
|
||||
.fold(0., f32::max);
|
||||
assert_eq!(layout.geometry.bounds.size.width, max_width + 16.);
|
||||
assert!(
|
||||
layout
|
||||
.lines
|
||||
.iter()
|
||||
.all(|line| !line.family.is_empty() && line.width > 0.)
|
||||
);
|
||||
if profile == Profile::F {
|
||||
assert_eq!(layout.geometry.fraction, 0.55);
|
||||
assert!(
|
||||
(layout.geometry.bounds.position.x - layout.geometry.anchor.x - 18.).abs()
|
||||
< 0.001
|
||||
);
|
||||
assert!(
|
||||
layout.geometry.bounds.size.width < 114.5,
|
||||
"Measured F loses own-is-closer margin"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(layout.geometry.fraction, 0.5);
|
||||
assert_eq!(layout.geometry.center, layout.geometry.anchor);
|
||||
}
|
||||
println!(
|
||||
"{profile:?}: bounds={:?}; lines={:?}",
|
||||
layout.geometry.bounds, layout.lines
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_vec(&p).unwrap(), before);
|
||||
}
|
||||
#[test]
|
||||
fn measured_backing_canvas_svg_picking_and_focus_share_geometry() {
|
||||
let (p, id) = fixture();
|
||||
let c = canvas(&p, id);
|
||||
for profile in [Profile::E, Profile::F] {
|
||||
let state = State::default();
|
||||
let scene = c.scene_with_profile(&state, profile);
|
||||
let layout = scene.label_layout.as_ref().unwrap();
|
||||
let bounds = rectangle(layout.geometry.bounds, 0.);
|
||||
let texts = layout.canvas_lines(Color::BLACK).collect::<Vec<_>>();
|
||||
let svg = super::super::export_svg_with_profile(&p, id, profile).unwrap();
|
||||
assert!(svg.contains(&layout.svg()));
|
||||
assert_eq!(
|
||||
crate::diagram_usage::edge_focus_bounds(&scene.routed.edges[&edge_id()]),
|
||||
bounds
|
||||
);
|
||||
for (line, text) in layout.lines.iter().zip(texts) {
|
||||
assert_eq!(text.content, line.text);
|
||||
assert_eq!(text.position, point(line.position));
|
||||
assert_eq!(text.size.0, FONT_SIZE);
|
||||
assert_eq!(
|
||||
text.line_height,
|
||||
shaped::LineHeight::Absolute(ADVANCE.into())
|
||||
);
|
||||
assert!(line.position.x >= bounds.x + 7.99);
|
||||
assert!(line.position.x + line.width <= bounds.x + bounds.width - 7.99);
|
||||
assert_eq!(
|
||||
c.hit_label(
|
||||
&scene.routed,
|
||||
Point::new(line.position.x + line.width / 2., line.position.y + 8.)
|
||||
),
|
||||
Some(edge_id())
|
||||
);
|
||||
assert!(svg.contains(&format!(
|
||||
"x=\"{}\" y=\"{}\" font-family=\"{}\"",
|
||||
line.position.x,
|
||||
line.baseline_y,
|
||||
escape(&line.family)
|
||||
)));
|
||||
}
|
||||
assert_eq!(
|
||||
c.hit_label(&scene.routed, Point::new(bounds.x + 1., bounds.y + 1.)),
|
||||
Some(edge_id())
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn automatic_guard_refuses_foreign_coincident_stroke_and_card() {
|
||||
let (p, id) = fixture();
|
||||
let diagram = &p.diagrams[&id];
|
||||
let view = project_view(&p, id);
|
||||
let baseline = route_diagram(&p, diagram, &view);
|
||||
for profile in [Profile::E, Profile::F] {
|
||||
let mut valid = baseline.clone();
|
||||
let layout = apply(&p, diagram, &view, &mut valid, profile).unwrap();
|
||||
let mut blocked = baseline.clone();
|
||||
let foreign = blocked
|
||||
.edges
|
||||
.keys()
|
||||
.copied()
|
||||
.find(|id| *id != edge_id())
|
||||
.unwrap();
|
||||
let bounds = layout.geometry.bounds;
|
||||
blocked.edges.get_mut(&foreign).unwrap().points = vec![
|
||||
Position {
|
||||
x: layout.geometry.center.x,
|
||||
y: bounds.position.y - 40.,
|
||||
},
|
||||
Position {
|
||||
x: layout.geometry.center.x,
|
||||
y: bounds.position.y + bounds.size.height + 40.,
|
||||
},
|
||||
];
|
||||
let snapshot = blocked.clone();
|
||||
assert!(apply(&p, diagram, &view, &mut blocked, profile).is_none());
|
||||
assert_eq!(blocked, snapshot);
|
||||
let mut occluded_view = view.clone();
|
||||
occluded_view.nodes[0].position = bounds.position;
|
||||
let mut routed = baseline.clone();
|
||||
assert!(apply(&p, diagram, &occluded_view, &mut routed, profile).is_none());
|
||||
assert_eq!(routed, baseline);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn authored_fraction_offset_keep_shape_even_during_conflicting_move() {
|
||||
let (mut p, id) = fixture();
|
||||
let view = project_view(&p, id);
|
||||
for profile in [Profile::E, Profile::F] {
|
||||
let original = route_diagram(&p, &p.diagrams[&id], &view);
|
||||
let mut automatic = original.clone();
|
||||
let initial = apply(&p, &p.diagrams[&id], &view, &mut automatic, profile).unwrap();
|
||||
let offset = Position {
|
||||
x: initial.geometry.center.x - initial.geometry.anchor.x + 17.,
|
||||
y: -9.,
|
||||
};
|
||||
p.diagrams.get_mut(&id).unwrap().routes = vec![EdgeRoute {
|
||||
edge: edge_id(),
|
||||
waypoints: vec![],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: initial.geometry.fraction,
|
||||
offset,
|
||||
}),
|
||||
}];
|
||||
let c = canvas(&p, id);
|
||||
let state = State::default();
|
||||
let scene = c.scene_with_profile(&state, profile);
|
||||
let moved = scene.label_layout.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
moved.lines.iter().map(|line| line.text).collect::<Vec<_>>(),
|
||||
initial
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.text)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(moved.geometry.fraction, initial.geometry.fraction);
|
||||
assert!((moved.geometry.center.x - moved.geometry.anchor.x - offset.x).abs() < 0.001);
|
||||
assert!((moved.geometry.center.y - moved.geometry.anchor.y - offset.y).abs() < 0.001);
|
||||
let mut preview = State {
|
||||
context: Some(c.context),
|
||||
..State::default()
|
||||
};
|
||||
preview.route_drag = Some(Gesture {
|
||||
context: c.context,
|
||||
start: point(moved.geometry.center),
|
||||
handle: Handle::Label { original: offset },
|
||||
route: p.diagrams[&id].routes[0].clone(),
|
||||
changed: true,
|
||||
baseline: scene.routed.clone(),
|
||||
});
|
||||
preview
|
||||
.route_drag
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.route
|
||||
.label
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset
|
||||
.x += 100.;
|
||||
let dragged = c.scene_with_profile(&preview, profile);
|
||||
let layout = dragged.label_layout.as_ref().unwrap();
|
||||
assert_eq!(layout.lines.len(), moved.lines.len());
|
||||
assert!(
|
||||
(layout.geometry.center.x - layout.geometry.anchor.x - offset.x - 100.).abs()
|
||||
< 0.001
|
||||
);
|
||||
for (id, edge) in &scene.routed.edges {
|
||||
assert_eq!(dragged.routed.edges[id].points, edge.points);
|
||||
}
|
||||
p.diagrams.get_mut(&id).unwrap().routes.clear();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn scene_and_measurement_cache_reuse_and_geometry_invalidation() {
|
||||
let (p, id) = fixture();
|
||||
let c = canvas(&p, id);
|
||||
let mut state = State::default();
|
||||
let first = c.scene_with_profile(&state, Profile::F);
|
||||
state.pointer = Some(Point::new(100., 200.));
|
||||
let changed_camera = DiagramCanvas {
|
||||
pan: Vector::new(40., 30.),
|
||||
zoom: 0.5,
|
||||
selected: p.id,
|
||||
..c
|
||||
};
|
||||
assert!(Arc::ptr_eq(
|
||||
&first,
|
||||
&changed_camera.scene_with_profile(&state, Profile::F)
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
&measured(Profile::F).unwrap(),
|
||||
&measured(Profile::F).unwrap()
|
||||
));
|
||||
let other_profile = c.scene_with_profile(&state, Profile::E);
|
||||
assert!(!Arc::ptr_eq(&first, &other_profile));
|
||||
let next_revision = DiagramCanvas {
|
||||
context: Context {
|
||||
revision: 1,
|
||||
..c.context
|
||||
},
|
||||
..c
|
||||
};
|
||||
assert!(!Arc::ptr_eq(
|
||||
&other_profile,
|
||||
&next_revision.scene_with_profile(&state, Profile::E)
|
||||
));
|
||||
let plain = c.scene_with_profile(&state, Profile::Baseline);
|
||||
assert!(plain.label_layout.is_none());
|
||||
assert_eq!(
|
||||
*plain.routed,
|
||||
route_diagram(&p, &p.diagrams[&id], &plain.view)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ 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 mut routed = routing::route_diagram(self.project, diagram, &view);
|
||||
super::apply_label_experiment(self.project, diagram, &view, &mut routed);
|
||||
self.work_dirty_press(
|
||||
&view,
|
||||
&routed,
|
||||
|
||||
@@ -327,7 +327,11 @@ 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: {
|
||||
let mut routed = routing::route_diagram(p, diagram, &view);
|
||||
crate::diagram::apply_label_experiment(p, diagram, &view, &mut routed);
|
||||
routed_bounds(routed.bounds)
|
||||
},
|
||||
});
|
||||
}
|
||||
for node in &view.nodes {
|
||||
@@ -407,9 +411,13 @@ 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)
|
||||
.edges
|
||||
.get(&subject)
|
||||
&& let Some(edge) = {
|
||||
let mut routed = routing::route_diagram(p, diagram, &view);
|
||||
crate::diagram::apply_label_experiment(p, diagram, &view, &mut routed);
|
||||
routed
|
||||
}
|
||||
.edges
|
||||
.get(&subject)
|
||||
{
|
||||
usages.push(Usage {
|
||||
subject,
|
||||
|
||||
Reference in New Issue
Block a user