mod anchors;
mod flex;
mod grid;
mod ir;
mod links;
mod path_bbox;
mod primitives;
mod text;
mod values;
pub(crate) use anchors::is_pinned;
pub use ir::*;
pub(crate) use links::cross;
pub use links::{Rule, Severity, Violation, node_rect};
pub(crate) use text::{approx_height, approx_width};
use crate::error::Error;
use crate::resolve::{Program, ResolvedInst, ResolvedValue, ShapeKind};
use crate::span::Span;
use flex::Axis;
type GapGrowth = std::collections::BTreeMap<String, (f64, f64)>;
pub fn layout(program: &Program) -> Result<LaidOut, Error> {
layout_mode(program, true)
}
pub fn layout_raw(program: &Program) -> Result<LaidOut, Error> {
layout_mode(program, false)
}
fn layout_mode(program: &Program, growth_on: bool) -> Result<LaidOut, Error> {
let mut growth = GapGrowth::new();
let mut best = attempt(program, &growth)?;
if growth_on && !best.routing.starved.is_empty() {
let mut starved = best.routing.starved.clone();
for _ in 0..2 {
if !grow(&mut growth, &starved, program) {
break;
}
let next = attempt(program, &growth)?;
starved = next.routing.starved.clone();
if better(&next, &best) {
best = next;
}
if starved.is_empty() {
break;
}
}
}
finish(program, best)
}
struct Attempt {
nodes: Vec<PlacedNode>,
bbox: Bbox,
routing: links::Routing,
}
fn attempt(program: &Program, growth: &GapGrowth) -> Result<Attempt, Error> {
let mut top_nodes = Vec::with_capacity(program.scene.nodes.len());
for inst in &program.scene.nodes {
top_nodes.push(layout_inst(inst, growth, &child_path("", inst))?);
}
let (bbox, _) = lay_out_container_children(
&mut top_nodes,
&program.scene.attrs,
Span::empty(),
gap_bump(growth, ""),
)?;
let routing = links::route_links(program, &top_nodes)?;
Ok(Attempt {
nodes: top_nodes,
bbox,
routing,
})
}
fn better(a: &Attempt, b: &Attempt) -> bool {
let key = |t: &Attempt| {
let crossings = t
.routing
.report
.iter()
.filter(|v| v.rule == Rule::Crossing)
.count();
(t.routing.links.len(), std::cmp::Reverse(crossings))
};
key(a) > key(b)
}
fn grow(growth: &mut GapGrowth, starved: &GapGrowth, program: &Program) -> bool {
let mut grew = false;
for (path, &(dy, dx)) in starved {
if !growable(program, path) {
continue;
}
let (gy, gx) = growth.entry(path.clone()).or_insert((0.0, 0.0));
*gy += dy;
*gx += dx;
grew |= dy > 0.0 || dx > 0.0;
}
grew
}
fn growable(program: &Program, path: &str) -> bool {
if path.is_empty() {
return true;
}
let mut nodes = &program.scene.nodes;
let mut found: Option<&ResolvedInst> = None;
for seg in path.split('.') {
match nodes.iter().find(|n| n.id.as_deref() == Some(seg)) {
Some(inst) => {
nodes = &inst.children;
found = Some(inst);
}
None => return false,
}
}
found
.is_some_and(|inst| inst.attrs.get("width").is_none() && inst.attrs.get("height").is_none())
}
fn gap_bump(growth: &GapGrowth, path: &str) -> (f64, f64) {
growth.get(path).copied().unwrap_or((0.0, 0.0))
}
fn child_path(parent: &str, inst: &ResolvedInst) -> String {
let id = inst.id.as_deref().unwrap_or("#");
if parent.is_empty() {
id.to_owned()
} else {
format!("{parent}.{id}")
}
}
fn accumulate_extent(n: &PlacedNode, ox: f64, oy: f64, bbox: &mut Bbox) {
let (wx, wy) = (ox + n.cx, oy + n.cy);
*bbox = bbox.union(n.bbox.shifted(wx, wy));
for c in &n.children {
accumulate_extent(c, wx, wy, bbox);
}
}
fn finish(program: &Program, attempt: Attempt) -> Result<LaidOut, Error> {
let pad = primitives::padding(&program.scene.attrs, Span::empty())?;
let mut bbox = attempt.bbox;
for n in &attempt.nodes {
accumulate_extent(n, 0.0, 0.0, &mut bbox);
}
let routing = attempt.routing;
let link_points = routing.links.iter().flat_map(|w| &w.path);
let air_points = routing.strays.iter().flat_map(|a| [&a.from, &a.to]);
for &(x, y) in link_points.chain(air_points) {
bbox.min_x = bbox.min_x.min(x);
bbox.min_y = bbox.min_y.min(y);
bbox.max_x = bbox.max_x.max(x);
bbox.max_y = bbox.max_y.max(y);
}
for t in routing.links.iter().flat_map(|w| &w.texts) {
let size = t.attrs.number("font-size").unwrap_or(0.0);
let ls = t.attrs.number("letter-spacing").unwrap_or(0.0);
let lsp = t.attrs.number("line-spacing").unwrap_or(0.0);
let (hw, hh) = (
text::approx_width(&t.content, size, ls) / 2.0,
text::approx_height(&t.content, size, lsp) / 2.0,
);
bbox.min_x = bbox.min_x.min(t.position.0 - hw);
bbox.min_y = bbox.min_y.min(t.position.1 - hh);
bbox.max_x = bbox.max_x.max(t.position.0 + hw);
bbox.max_y = bbox.max_y.max(t.position.1 + hh);
}
let vb = ViewBox {
x: bbox.min_x - pad.left,
y: bbox.min_y - pad.top,
w: bbox.w() + pad.left + pad.right,
h: bbox.h() + pad.top + pad.bottom,
};
let canvas_fill = program.scene.attrs.get("fill").cloned();
Ok(LaidOut {
viewbox: vb,
nodes: attempt.nodes,
links: routing.links,
link_report: routing.report,
strays: routing.strays,
vars: program.vars.clone(),
sheet: program.sheet.clone(),
canvas_fill,
gradients: Vec::new(),
})
}
pub fn validate_routing(laid: &LaidOut) -> Vec<Violation> {
let mut out = laid.link_report.clone();
out.extend(links::validate_routing(
&laid.nodes,
&laid.links,
&laid.link_report,
));
out
}
fn layout_inst(inst: &ResolvedInst, growth: &GapGrowth, path: &str) -> Result<PlacedNode, Error> {
let mut children: Vec<PlacedNode> = Vec::with_capacity(inst.children.len());
for c in &inst.children {
children.push(layout_inst(c, growth, &child_path(path, c))?);
}
let mut dividers: Vec<GridRule> = Vec::new();
let bbox = if children.is_empty() {
primitives::leaf_bbox(inst)?
} else {
let (content_bbox, rules) = lay_out_container_children(
&mut children,
&inst.attrs,
inst.span,
gap_bump(growth, path),
)?;
dividers = rules;
let b = primitives::closed_bbox(inst, content_bbox)?;
let text_only = children.iter().all(|c| c.shape == ShapeKind::Text);
const CLOUD_LABEL_DROP: f64 = 0.1;
const CYL_LABEL_DROP: f64 = 0.03;
let label_drop = match inst.shape {
ShapeKind::Cloud => CLOUD_LABEL_DROP,
ShapeKind::Cyl => CYL_LABEL_DROP,
_ => 0.0,
};
if label_drop > 0.0 && text_only {
let dy = b.h() * label_drop;
for c in &mut children {
c.cy += dy;
}
}
b
};
let rotation = inst.attrs.number("rotate").unwrap_or(0.0);
Ok(PlacedNode {
id: inst.id.clone(),
shape: inst.shape,
type_chain: inst.type_chain.clone(),
applied_styles: inst.applied_styles.clone(),
label: inst.label.clone(),
attrs: inst.attrs.clone(),
own_style: inst.own_style.clone(),
markers: inst.markers.clone(),
cx: 0.0,
cy: 0.0,
bbox,
rotation,
children,
dividers,
span: inst.span,
})
}
fn one_d_dividers(
children: &[PlacedNode],
flow: &[usize],
mode: LayoutMode,
flow_bbox: Bbox,
) -> Vec<GridRule> {
let row = matches!(mode, LayoutMode::Row);
let main = |i: usize| if row { children[i].cx } else { children[i].cy };
let half = |i: usize| {
if row {
children[i].bbox.w() / 2.0
} else {
children[i].bbox.h() / 2.0
}
};
let mut order: Vec<usize> = flow.to_vec();
order.sort_by(|&a, &b| main(a).total_cmp(&main(b)));
let mut segs = Vec::new();
for pair in order.windows(2) {
let mid = (main(pair[0]) + half(pair[0]) + main(pair[1]) - half(pair[1])) / 2.0;
if row {
segs.push((mid, flow_bbox.min_y, mid, flow_bbox.max_y));
} else {
segs.push((flow_bbox.min_x, mid, flow_bbox.max_x, mid));
}
}
segs
}
fn lay_out_container_children(
children: &mut [PlacedNode],
container_attrs: &crate::resolve::AttrMap,
span: Span,
grow: (f64, f64),
) -> Result<(Bbox, Vec<GridRule>), Error> {
if children.is_empty() {
return Ok((Bbox::empty(), Vec::new()));
}
let grown;
let container_attrs = if grow == (0.0, 0.0) {
container_attrs
} else {
let (gy, gx) = primitives::gap(container_attrs, span)?;
let mut attrs = container_attrs.clone();
attrs.insert(
"gap",
ResolvedValue::Tuple(vec![
ResolvedValue::Number(gy + grow.0),
ResolvedValue::Number(gx + grow.1),
]),
);
grown = attrs;
&grown
};
let mut flow_indices: Vec<usize> = Vec::new();
let mut pinned_indices: Vec<usize> = Vec::new();
for (i, c) in children.iter().enumerate() {
match anchors::child_role(&c.attrs, c.span)? {
anchors::Role::Flow => flow_indices.push(i),
anchors::Role::Pinned => pinned_indices.push(i),
}
}
let mode = read_layout_mode(container_attrs, span)?;
let pad = primitives::padding(container_attrs, span)?;
let avail = (
container_attrs
.number("width")
.map(|w| (w - pad.left - pad.right).max(0.0)),
container_attrs
.number("height")
.map(|h| (h - pad.top - pad.bottom).max(0.0)),
);
let mut grid_rules: Vec<GridRule> = Vec::new();
let flow_bbox = if !flow_indices.is_empty() {
let mut flow_children: Vec<PlacedNode> =
flow_indices.iter().map(|i| children[*i].clone()).collect();
let bbox = match mode {
LayoutMode::Row => {
flex::lay_out_flex(Axis::Row, &mut flow_children, container_attrs, span, avail)?
}
LayoutMode::Column => flex::lay_out_flex(
Axis::Column,
&mut flow_children,
container_attrs,
span,
avail,
)?,
LayoutMode::Grid => {
if grid::is_inset_grid(container_attrs) {
for c in &mut flow_children {
c.bbox = c.bbox.expand(pad.top, pad.right, pad.bottom, pad.left);
}
}
let (bbox, rules) = grid::lay_out_grid(&mut flow_children, container_attrs, span)?;
grid_rules = rules;
bbox
}
};
for (slot, placed) in flow_indices.iter().zip(flow_children) {
children[*slot] = placed;
}
bbox
} else {
Bbox::empty()
};
let (off_x, off_y) = if grid::is_inset_grid(container_attrs) {
(0.0, 0.0)
} else {
((pad.left - pad.right) / 2.0, (pad.top - pad.bottom) / 2.0)
};
if (off_x, off_y) != (0.0, 0.0) {
for &i in &flow_indices {
children[i].cx += off_x;
children[i].cy += off_y;
}
}
if matches!(mode, LayoutMode::Row | LayoutMode::Column)
&& grid::read_divider(container_attrs) != grid::Divider::None
&& flow_indices.len() > 1
{
grid_rules = one_d_dividers(
children,
&flow_indices,
mode,
flow_bbox.shifted(off_x, off_y),
);
}
let body_bbox = flow_bbox;
let anchor_parent_bbox = container_anchor_bbox(container_attrs).unwrap_or_else(|| {
if grid::is_inset_grid(container_attrs) {
body_bbox
} else {
Bbox::centered(
body_bbox.w() + pad.left + pad.right,
body_bbox.h() + pad.top + pad.bottom,
)
}
});
for &i in &pinned_indices {
let pin = anchors::read_pin(&children[i].attrs, children[i].span)?
.expect("pinned child carries pin:");
let (cx, cy) = pin.target(anchor_parent_bbox, children[i].bbox);
children[i].cx = cx;
children[i].cy = cy;
}
for c in children.iter_mut() {
if let Some((dx, dy)) = anchors::translate(&c.attrs, c.span)? {
c.cx += dx;
c.cy += dy;
}
}
Ok((body_bbox, grid_rules))
}
#[derive(Clone, Copy, Debug)]
enum LayoutMode {
Row,
Column,
Grid,
}
fn read_layout_mode(attrs: &crate::resolve::AttrMap, span: Span) -> Result<LayoutMode, Error> {
match attrs.get("layout") {
None => Ok(LayoutMode::Column),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"row" => Ok(LayoutMode::Row),
"column" => Ok(LayoutMode::Column),
"grid" => Ok(LayoutMode::Grid),
other => Err(Error::at(
span,
format!("unknown layout '{}' — expected row, column, or grid", other),
)),
},
Some(_) => Err(Error::at(span, "'layout' expects row, column, or grid")),
}
}
fn container_anchor_bbox(attrs: &crate::resolve::AttrMap) -> Option<Bbox> {
let w = attrs.number("width")?;
let h = attrs.number("height")?;
Some(Bbox::centered(w, h))
}
#[cfg(test)]
mod tests {
use super::*;
fn lay_out(src: &str) -> LaidOut {
let tokens = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(&tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
layout(&program).expect("layout")
}
#[test]
fn empty_closed_shape_is_two_paddings() {
let n = &lay_out("|box|\n").nodes[0];
assert!((n.bbox.w() - 42.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 42.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn explicit_dims_are_border_box() {
let n = &lay_out("|box| { width: 100; height: 50; }\n").nodes[0];
assert!((n.bbox.w() - 102.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 52.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn stroke_width_counts_toward_the_bbox() {
let n = &lay_out("|box| { width: 100; height: 50; stroke-width: 4; }\n").nodes[0];
assert!((n.bbox.w() - 104.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 54.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn label_auto_sizes_to_content_plus_padding() {
let n = &lay_out("|box| \"hi\"\n").nodes[0];
assert!(n.bbox.w() > 55.0 && n.bbox.w() < 65.0, "w={}", n.bbox.w());
}
#[test]
fn dims_are_independent_per_axis() {
let n = &lay_out("|box| { width: 200 } \"hi\"\n").nodes[0];
assert!((n.bbox.w() - 202.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 57.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn explicit_size_is_a_floor_not_a_clip() {
let grown = &lay_out("|box| { width: 40 } \"a long label\"\n").nodes[0];
assert!(
grown.bbox.w() > 60.0,
"floor grows to content: w={}",
grown.bbox.w()
);
let kept = &lay_out("|box| { width: 300 } \"hi\"\n").nodes[0];
assert!((kept.bbox.w() - 302.0).abs() < 0.01, "w={}", kept.bbox.w());
}
#[test]
fn asymmetric_padding_offsets_the_content() {
let off = &lay_out("|box| { padding: 0 0 0 20 } \"x\"\n").nodes[0];
assert!(
(off.children[0].cx - 10.0).abs() < 0.01,
"cx={}",
off.children[0].cx
);
let mid = &lay_out("|box| { padding: 8 } \"x\"\n").nodes[0];
assert!(
mid.children[0].cx.abs() < 0.01,
"centred: cx={}",
mid.children[0].cx
);
}
#[test]
fn oval_uses_width_height() {
let n = &lay_out("|oval| { width: 100; height: 50; }\n").nodes[0];
assert!((n.bbox.w() - 102.0).abs() < 0.01, "w={}", n.bbox.w());
assert!((n.bbox.h() - 52.0).abs() < 0.01, "h={}", n.bbox.h());
}
#[test]
fn text_sizes_to_its_glyphs_without_padding() {
let n = &lay_out("\"hi\"\n").nodes[0];
assert!((n.bbox.w() - 18.0).abs() < 0.5, "w={}", n.bbox.w()); assert!((n.bbox.h() - 15.0).abs() < 0.5, "h={}", n.bbox.h());
}
#[test]
fn row_layout_stacks_horizontally() {
let l = lay_out(
"{ layout: row; gap: 10; }\n\
|box| { width: 100; height: 40; }\n\
|box| { width: 60; height: 40; }\n",
);
assert_eq!(l.nodes.len(), 2);
let dx = l.nodes[1].cx - l.nodes[0].cx;
assert!((dx - 92.0).abs() < 0.5, "dx={}", dx);
assert!((l.nodes[0].cy - l.nodes[1].cy).abs() < 0.01);
}
#[test]
fn column_layout_stacks_vertically() {
let l = lay_out(
"{ layout: column; gap: 20; }\n\
|box| { width: 100; height: 40; }\n\
|box| { width: 100; height: 60; }\n",
);
let dy = l.nodes[1].cy - l.nodes[0].cy;
assert!((dy - 72.0).abs() < 0.5, "dy={}", dy);
assert!((l.nodes[0].cx - l.nodes[1].cx).abs() < 0.01);
}
#[test]
fn viewbox_wraps_content_with_scene_padding() {
let l = lay_out("|box| { width: 100; height: 40; }\n");
assert!((l.viewbox.w - 142.0).abs() < 0.01, "w={}", l.viewbox.w);
assert!((l.viewbox.h - 82.0).abs() < 0.01, "h={}", l.viewbox.h);
}
#[test]
fn caption_overlay_does_not_grow_the_group() {
let h = |src: &str| lay_out(src).nodes[0].bbox.h();
let plain = h("g |group| [\n a |box| { width: 80; height: 30; }\n]\n");
let capped =
h("g |group| [\n |caption| \"Cap\"\n a |box| { width: 80; height: 30; }\n]\n");
assert!(
(capped - plain).abs() < 0.01,
"caption is an overlay, no extra height: plain={plain} capped={capped}"
);
}
#[test]
fn caption_sits_above_the_content() {
let l =
lay_out("g |group| [\n |caption| \"Cap\"\n a |box| { width: 80; height: 30; }\n]\n");
let g = &l.nodes[0];
let cap = g
.children
.iter()
.find(|c| c.type_chain.iter().any(|t| t == "caption"))
.expect("caption child");
let a = g
.children
.iter()
.find(|c| c.id.as_deref() == Some("a"))
.expect("box child");
assert!(cap.cy < a.cy, "cap.cy={} a.cy={}", cap.cy, a.cy);
}
#[test]
fn justify_orders_children_start_center_end() {
let first_cx = |j: &str| {
let src = format!(
"g |row| {{ width: 300; justify: {j} }} [\n a |box| {{ width: 40; height: 20; }}\n b |box| {{ width: 40; height: 20; }}\n]\n"
);
lay_out(&src).nodes[0].children[0].cx
};
let (start, center, end) = (first_cx("start"), first_cx("center"), first_cx("end"));
assert!(
start < center && center < end,
"start={start} center={center} end={end}"
);
}
#[test]
fn justify_evenly_spaces_children_equally() {
let l = lay_out(
"g |row| { width: 300; justify: evenly } [\n a |box| { width: 20; height: 20; }\n b |box| { width: 20; height: 20; }\n c |box| { width: 20; height: 20; }\n]\n",
);
let cx: Vec<f64> = l.nodes[0].children.iter().map(|c| c.cx).collect();
assert!(
((cx[1] - cx[0]) - (cx[2] - cx[1])).abs() < 0.01,
"centers {cx:?}"
);
}
#[test]
fn align_stretch_fills_the_cross_axis() {
let l = lay_out("g |row| { height: 80; align: stretch } [\n a |box| { width: 40; }\n]\n");
let a = &l.nodes[0].children[0];
assert!((a.bbox.h() - 80.0).abs() < 1.0, "a.h={}", a.bbox.h());
}
#[test]
fn no_slack_means_no_distribution() {
let span = |j: &str| {
let src = format!(
"g |row| {{ justify: {j} }} [\n a |box| {{ width: 40; height: 20; }}\n b |box| {{ width: 40; height: 20; }}\n]\n"
);
let l = lay_out(&src);
l.nodes[0].children[1].cx - l.nodes[0].children[0].cx
};
assert!(
(span("start") - span("end")).abs() < 0.01,
"auto row: justify is a no-op"
);
}
#[test]
fn grid_fixed_columns_place_children_in_order() {
let l = lay_out(
"{ layout: grid; columns: 80 80 80; gap: 0; }\n\
a |box| { width: 40; height: 40; }\n\
b |box| { width: 40; height: 40; }\n\
c |box| { width: 40; height: 40; }\n",
);
let cx: Vec<f64> = l.nodes.iter().map(|n| n.cx).collect();
assert!((cx[1] - cx[0] - 80.0).abs() < 0.5, "dx={}", cx[1] - cx[0]);
assert!((cx[2] - cx[1] - 80.0).abs() < 0.5);
assert!((l.nodes[0].cy - l.nodes[1].cy).abs() < 0.01);
}
#[test]
fn grid_repeat_makes_auto_columns_and_wraps() {
let l = lay_out(
"{ layout: grid; columns: repeat(2); }\n\
a |box| { width: 30; height: 30; }\n\
b |box| { width: 30; height: 30; }\n\
c |box| { width: 30; height: 30; }\n",
);
assert!(l.nodes[2].cy > l.nodes[0].cy, "c below a");
}
#[test]
fn grid_cell_pins_placement() {
let l = lay_out(
"{ layout: grid; columns: repeat(3); }\n\
a |box| { cell: 3 1; }\n\
b |box|\n",
);
assert!(
l.nodes[0].cx > l.nodes[1].cx,
"a (col 3) right of b (col 1)"
);
}
#[test]
fn grid_cell_fills_its_track_under_stretch() {
let l = lay_out(
"{ layout: grid; columns: 120 120; gap: 0; }\n\
a |box| { justify: stretch; align: stretch; }\n\
b |box|\n",
);
assert!(
(l.nodes[0].bbox.w() - 120.0).abs() < 1.0,
"a.w={}",
l.nodes[0].bbox.w()
);
}
#[test]
fn grid_rows_track_list_is_a_floor_implicit_rows_overflow() {
let l = lay_out(
"{ layout: grid; columns: 40 40; rows: auto; }\n\
a |box| { width: 30; height: 30; }\n\
b |box| { width: 30; height: 30; }\n\
c |box| { width: 30; height: 30; }\n\
d |box| { width: 30; height: 30; }\n",
);
assert!(l.nodes[2].cy > l.nodes[0].cy, "c (row 2) below a (row 1)");
assert!(
(l.nodes[2].cy - l.nodes[3].cy).abs() < 0.01,
"c, d share row 2"
);
}
#[test]
fn grid_without_columns_is_an_error() {
let tokens = crate::lexer::lex("{ layout: grid; }\na |box|\nb |box|\n").expect("lex");
let file = crate::syntax::parser::parse(&tokens).expect("parse");
let lowered = crate::desugar::desugar(&file).expect("desugar");
let program = crate::resolve::resolve_with_theme(&lowered, &[]).expect("resolve");
assert!(layout(&program).is_err());
}
#[test]
fn table_draws_interior_dividers_no_frame() {
let l = lay_out("t |table| { columns: 40 40 } [\n \"a\" \"b\" \"c\" \"d\"\n]\n");
assert!(
!l.nodes[0].dividers.is_empty(),
"table has interior dividers"
);
assert!(
lay_out("g |group| [ x |box| ]\n").nodes[0]
.dividers
.is_empty()
);
}
#[test]
fn grid_dividers_stay_within_the_content_box() {
let l = lay_out(
"t |table| { columns: 40 40; gap: 20 } [\n \"a\"\n \"b\"\n \"c\"\n \"d\"\n]\n",
);
let t = &l.nodes[0];
let (hw, hh) = (t.bbox.w() / 2.0 + 0.01, t.bbox.h() / 2.0 + 0.01);
for (x1, y1, x2, y2) in &t.dividers {
for (x, y) in [(x1, y1), (x2, y2)] {
assert!(x.abs() <= hw, "divider x {x} exceeds half-width {hw}");
assert!(y.abs() <= hh, "divider y {y} exceeds half-height {hh}");
}
}
}
#[test]
fn one_d_divider_falls_between_flow_children() {
let l = lay_out(
"g |row| { divider: all } [\n a |box| { width: 30; height: 30; }\n b |box| { width: 30; height: 30; }\n c |box| { width: 30; height: 30; }\n]\n",
);
assert_eq!(
l.nodes[0].dividers.len(),
2,
"two separators between three children"
);
}
}