mod anchors;
mod chart;
pub(crate) mod drawing;
mod flex;
mod grid;
pub(crate) mod ir;
mod note;
pub(crate) mod path_bbox; mod pattern;
mod prim; mod primitives; pub(crate) mod sequence;
mod text;
mod values;
pub(crate) use anchors::is_pinned;
pub use ir::*;
pub(crate) use text::{approx_height, approx_width};
pub(crate) use values::as_pair;
use crate::error::Error;
use crate::resolve::{NodeKind, Program, ResolvedInst, ResolvedValue};
use crate::routing;
use crate::span::Span;
use flex::Axis;
pub fn layout(program: &Program) -> Result<LaidOut, Error> {
sequence::validate(program)?;
if drawing::is_drawing(&program.scene.attrs) {
let (top_nodes, bbox) = drawing::layout_root(program)?;
let links = routing::owned_links(&top_nodes);
let routed = routing::Routing {
links,
..Default::default()
};
return finish(program, top_nodes, bbox, routed);
}
let ctx = Ctx {
scale: effective_scale(&program.scene.attrs, 1.0, Span::empty())?,
drawing: false,
};
let mut top_nodes = Vec::with_capacity(program.scene.nodes.len());
for inst in &program.scene.nodes {
top_nodes.push(layout_inst(inst, &child_path("", inst), program, ctx)?);
}
if sequence::is_sequence(&program.scene.attrs) {
let (bbox, mut links) = sequence::layout_root(&mut top_nodes, program)?;
links.extend(routing::owned_links(&top_nodes));
let routed = routing::Routing {
links,
..Default::default()
};
return finish(program, top_nodes, bbox, routed);
}
let (bbox, _) = lay_out_container_children(
&mut top_nodes,
&program.scene.attrs,
Span::empty(),
ctx.scale,
)?;
let routed = routing::route(program, &top_nodes)?;
finish(program, top_nodes, bbox, routed)
}
#[derive(Clone, Copy)]
pub(crate) struct Ctx {
pub scale: f64,
pub drawing: bool,
}
impl Ctx {
pub(crate) fn sheet() -> Self {
Ctx {
scale: 1.0,
drawing: false,
}
}
}
pub(crate) fn effective_scale(
attrs: &crate::resolve::AttrMap,
inherited: f64,
span: Span,
) -> Result<f64, Error> {
match attrs.get("scale") {
None => Ok(inherited),
Some(v) => match v.as_number() {
Some(s) if s > 0.0 => Ok(s),
_ => Err(Error::at(span, "'scale' must be > 0")),
},
}
}
pub(crate) fn scope_attrs<'a>(
program: &'a Program,
scope: &str,
) -> Option<&'a crate::resolve::AttrMap> {
if scope.is_empty() {
Some(&program.scene.attrs)
} else {
node_at(program, scope).map(|i| &i.attrs)
}
}
pub(super) fn node_at<'a>(program: &'a Program, path: &str) -> Option<&'a ResolvedInst> {
let mut nodes = &program.scene.nodes;
let mut found = None;
for seg in path.split('.') {
let inst = nodes.iter().find(|n| n.id.as_deref() == Some(seg))?;
found = Some(inst);
nodes = &inst.children;
}
found
}
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, rot: f64, bbox: &mut Bbox) {
let turn = |x: f64, y: f64, deg: f64| -> (f64, f64) {
if deg == 0.0 {
return (x, y);
}
let (s, c) = deg.to_radians().sin_cos();
(x * c - y * s, x * s + y * c)
};
let (dx, dy) = turn(n.cx, n.cy, rot);
let (wx, wy) = (ox + dx, oy + dy);
let total = rot + n.rotation;
let b = &n.bbox;
for (x, y) in [
(b.min_x, b.min_y),
(b.max_x, b.min_y),
(b.min_x, b.max_y),
(b.max_x, b.max_y),
] {
let (px, py) = turn(x, y, total);
*bbox = bbox.union(Bbox {
min_x: wx + px,
min_y: wy + py,
max_x: wx + px,
max_y: wy + py,
});
}
for c in &n.children {
accumulate_extent(c, wx, wy, total, bbox);
}
}
fn finish(
program: &Program,
nodes: Vec<PlacedNode>,
scene_bbox: Bbox,
routing: routing::Routing,
) -> Result<LaidOut, Error> {
let pad = primitives::padding(&program.scene.attrs, Span::empty())?;
let mut bbox = scene_bbox;
for n in &nodes {
accumulate_extent(n, 0.0, 0.0, 0.0, &mut bbox);
}
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,
links: routing.links,
link_report: routing.report,
strays: routing.strays,
vars: program.vars.clone(),
sheet: program.sheet.clone(),
canvas_fill,
gradients: Vec::new(),
hatches: Vec::new(),
})
}
pub fn validate_routing(laid: &LaidOut) -> Vec<routing::Violation> {
let mut out = laid.link_report.clone();
out.extend(routing::validate_routing(
&laid.nodes,
&laid.links,
&laid.link_report,
));
out
}
fn layout_inst(
inst: &ResolvedInst,
path: &str,
program: &Program,
ctx: Ctx,
) -> Result<PlacedNode, Error> {
let funcs = &program.funcs;
if inst.attrs.get("break").is_some() && inst.kind != NodeKind::Sketch {
return Err(Error::at(
inst.span,
"'break' cuts a '|sketch|' — draw the profile with the pen",
));
}
let engine = if chart::is_chart(&inst.attrs) {
Some(chart::layout_chart(inst, funcs)?)
} else if chart::is_pie(&inst.attrs) {
Some(chart::layout_pie(inst)?)
} else if sequence::is_sequence(&inst.attrs) {
Some(sequence::layout_node(inst, path, program)?)
} else if drawing::is_drawing(&inst.attrs) {
Some(drawing::layout_node(inst, path, program, ctx)?)
} else {
None
};
if let Some(mut placed) = engine {
if placed.attrs.get("pattern").is_some() {
let own = effective_scale(&inst.attrs, ctx.scale, inst.span)?;
pattern::expand(&mut placed, own)?;
}
return Ok(placed);
}
if ctx.drawing && drawing::chrome::is_chrome(&inst.attrs) {
return Ok(drawing::chrome::placeholder(inst));
}
let own = effective_scale(&inst.attrs, ctx.scale, inst.span)?;
let part =
ctx.drawing && !owns_layout(&inst.attrs) && !drawing::is_sheet(inst.kind, &inst.type_chain);
let child_ctx = Ctx {
scale: own,
drawing: part,
};
let mut children: Vec<PlacedNode> = Vec::with_capacity(inst.children.len());
for c in &inst.children {
children.push(layout_inst(c, &child_path(path, c), program, child_ctx)?);
}
let mut gutters: Vec<Gutter> = Vec::new();
let mut sketch_d: Option<String> = None;
let mut sketch_geo = None;
let bbox = if inst.kind == NodeKind::Sketch {
if !children.is_empty() && !part {
let _ = lay_out_container_children(&mut children, &inst.attrs, inst.span, own)?;
}
let folded = drawing::pen::fold(inst, own)?;
let half = inst.attrs.number("stroke-width").unwrap_or(0.0) / 2.0;
sketch_d = Some(folded.d);
drawing::breaks::fill_chrome(&mut children, &folded.cuts);
sketch_geo = Some(std::sync::Arc::new(drawing::SketchGeo {
segments: folded.segments,
mirrors: folded.mirror_axes,
outline: folded.subs,
view: folded.view,
}));
folded.geometry.inflate(half)
} else if part {
drawing::part_bbox(inst, own)?
} else if children.is_empty() {
primitives::leaf_bbox(inst, own)?
} else {
let (content_bbox, rects) =
lay_out_container_children(&mut children, &inst.attrs, inst.span, own)?;
gutters = rects;
let b = if inst.kind == NodeKind::Icon {
primitives::icon_square_bbox(inst, content_bbox, own)?
} else {
primitives::closed_bbox(inst, content_bbox, own)?
};
let text_only = children.iter().all(|c| c.kind == NodeKind::Text);
const CYL_LABEL_DROP: f64 = 0.03;
let label_drop = match inst.kind {
NodeKind::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
};
if part {
let half = inst.attrs.number("stroke-width").unwrap_or(0.0) / 2.0;
drawing::place_features(&mut children, own, sketch_geo.as_ref().map(|g| &g.view))?;
drawing::chrome::fill(&mut children, bbox.inflate(-half));
}
let rotation = inst.attrs.number("rotate").unwrap_or(0.0);
let mut placed = PlacedNode {
id: inst.id.clone(),
kind: inst.kind,
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,
gutters,
links: Vec::new(),
sketch: sketch_geo,
span: inst.span,
};
if let Some(d) = sketch_d {
placed.attrs.insert("path", ResolvedValue::String(d));
}
if own != 1.0 {
values::scale_points_attr(&mut placed.attrs, own);
}
if placed.kind == NodeKind::Block && placed.type_chain.iter().any(|t| t == "note") {
note::fold(&mut placed);
}
if placed.attrs.get("pattern").is_some() {
pattern::expand(&mut placed, own)?;
}
Ok(placed)
}
fn owns_layout(attrs: &crate::resolve::AttrMap) -> bool {
attrs.get("layout").is_some() || attrs.get("direction").is_some()
}
fn one_d_gutters(
children: &[PlacedNode],
flow: &[usize],
axis: Axis,
flow_bbox: Bbox,
gap: f64,
) -> Vec<Gutter> {
let row = axis == Axis::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 (cx, cy) = (
(flow_bbox.min_x + flow_bbox.max_x) / 2.0,
(flow_bbox.min_y + flow_bbox.max_y) / 2.0,
);
let mut order: Vec<usize> = flow.to_vec();
order.sort_by(|&a, &b| main(a).total_cmp(&main(b)));
let mut out = 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 {
out.push((mid, cy, gap, flow_bbox.h()));
} else {
out.push((cx, mid, flow_bbox.w(), gap));
}
}
out
}
fn lay_out_container_children(
children: &mut [PlacedNode],
container_attrs: &crate::resolve::AttrMap,
span: Span,
scale: f64,
) -> Result<(Bbox, Vec<Gutter>), Error> {
if children.is_empty() {
return Ok((Bbox::empty(), Vec::new()));
}
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 flow_axis = match mode {
LayoutMode::Flow => Some(read_flow_direction(container_attrs, span)?),
LayoutMode::Grid => None,
};
let pad = primitives::padding(container_attrs, span)?;
let avail = (
container_attrs
.number("width")
.map(|w| (w * scale - pad.left - pad.right).max(0.0)),
container_attrs
.number("height")
.map(|h| (h * scale - pad.top - pad.bottom).max(0.0)),
);
let mut gutters: Vec<Gutter> = 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::Flow => flex::lay_out_flex(
flow_axis.expect("a flow has an axis"),
&mut flow_children,
container_attrs,
span,
avail,
)?,
LayoutMode::Grid => {
let (bbox, rects) = grid::lay_out_grid(&mut flow_children, container_attrs, span)?;
gutters = rects;
bbox
}
};
for (slot, placed) in flow_indices.iter().zip(flow_children) {
children[*slot] = placed;
}
bbox
} else {
Bbox::empty()
};
let (off_x, off_y) = ((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 let Some(axis) = flow_axis
&& grid::has_gap_fill(container_attrs)
&& flow_indices.len() > 1
{
let (gap_y, gap_x) = primitives::gap(container_attrs, span)?;
let main_gap = match axis {
Axis::Row => gap_x,
Axis::Column => gap_y,
};
if main_gap > 0.0 {
gutters = one_d_gutters(
children,
&flow_indices,
axis,
flow_bbox.shifted(off_x, off_y),
main_gap,
);
}
}
let body_bbox = flow_bbox;
let anchor_parent_bbox = container_anchor_bbox(container_attrs, scale).unwrap_or_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 (i, c) in children.iter_mut().enumerate() {
if let Some((dx, dy)) = anchors::translate(&c.attrs, c.span)? {
let s = if pinned_indices.contains(&i) {
1.0
} else {
scale
};
c.cx += dx * s;
c.cy += dy * s;
}
}
Ok((body_bbox, gutters))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LayoutMode {
Flow,
Grid,
}
fn read_layout_mode(attrs: &crate::resolve::AttrMap, span: Span) -> Result<LayoutMode, Error> {
match attrs.get("layout") {
None => Ok(LayoutMode::Flow),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"flow" => Ok(LayoutMode::Flow),
"grid" => Ok(LayoutMode::Grid),
dir @ ("row" | "column") => Err(Error::at(
span,
format!(
"'layout: {dir}' is not a layout — flow is the default; set 'direction: {dir}'"
),
)),
other => Err(Error::at(
span,
format!("unknown layout '{other}' — expected flow or grid"),
)),
},
Some(_) => Err(Error::at(span, "'layout' expects flow or grid")),
}
}
fn read_flow_direction(attrs: &crate::resolve::AttrMap, span: Span) -> Result<Axis, Error> {
match attrs.get("direction") {
None => Ok(Axis::Column),
Some(ResolvedValue::Ident(s)) => match s.as_str() {
"column" => Ok(Axis::Column),
"row" => Ok(Axis::Row),
"radial" => Err(Error::at(
span,
"'direction: radial' is only valid in a chart — a flow is row or column",
)),
other => Err(Error::at(
span,
format!("unknown direction '{other}' — expected row or column"),
)),
},
Some(_) => Err(Error::at(span, "'direction' expects row or column")),
}
}
fn container_anchor_bbox(attrs: &crate::resolve::AttrMap, scale: f64) -> Option<Bbox> {
let w = attrs.number("width")?;
let h = attrs.number("height")?;
Some(Bbox::centered(w * scale, h * scale))
}
#[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_primitive_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| \"hi\" { width: 200 }\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| \"a long label\" { width: 40 }\n").nodes[0];
assert!(
grown.bbox.w() > 60.0,
"floor grows to content: w={}",
grown.bbox.w()
);
let kept = &lay_out("|box| \"hi\" { width: 300 }\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| \"x\" { padding: 0 0 0 20 }\n").nodes[0];
assert!(
(off.children[0].cx - 10.0).abs() < 0.01,
"cx={}",
off.children[0].cx
);
let mid = &lay_out("|box| \"x\" { padding: 8 }\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(
"{ direction: 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(
"{ direction: 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);
}
fn lay_out_err(src: &str) -> Error {
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");
match layout(&program) {
Ok(_) => panic!("expected a layout error"),
Err(e) => e,
}
}
#[test]
fn layout_row_and_column_are_removed() {
for dir in ["row", "column"] {
let err = lay_out_err(&format!("{{ layout: {dir}; }}\n|box|\n|box|\n"));
assert!(
err.message.contains(&format!("direction: {dir}")),
"msg={}",
err.message
);
}
}
#[test]
fn direction_radial_is_rejected_in_a_flow() {
let err = lay_out_err("{ direction: radial; }\n|box|\n|box|\n");
assert!(err.message.contains("chart"), "msg={}", err.message);
}
#[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("|group#g| [\n |box#a| { width: 80; height: 30; }\n]\n");
let capped =
h("|group#g| [\n |caption| \"Cap\"\n |box#a| { 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("|group#g| [\n |caption| \"Cap\"\n |box#a| { 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!(
"|row#g| {{ width: 300; justify: {j} }} [\n |box#a| {{ width: 40; height: 20; }}\n |box#b| {{ 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(
"|row#g| { width: 300; justify: evenly } [\n |box#a| { width: 20; height: 20; }\n |box#b| { width: 20; height: 20; }\n |box#c| { 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("|row#g| { height: 80; align: stretch } [\n |box#a| { 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!(
"|row#g| {{ justify: {j} }} [\n |box#a| {{ width: 40; height: 20; }}\n |box#b| {{ 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\
|box#a| { width: 40; height: 40; }\n\
|box#b| { width: 40; height: 40; }\n\
|box#c| { 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\
|box#a| { width: 30; height: 30; }\n\
|box#b| { width: 30; height: 30; }\n\
|box#c| { 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\
|box#a| { cell: 3 1; }\n\
|box#b|\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\
|box#a| { justify: stretch; align: stretch; }\n\
|box#b|\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\
|box#a| { width: 30; height: 30; }\n\
|box#b| { width: 30; height: 30; }\n\
|box#c| { width: 30; height: 30; }\n\
|box#d| { 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; }\n|box#a|\n|box#b|\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_fills_interior_gutters_no_frame() {
let l = lay_out("|table#t| { columns: 40 40 } [\n \"a\" \"b\" \"c\" \"d\"\n]\n");
assert!(!l.nodes[0].gutters.is_empty(), "table has interior gutters");
assert!(
lay_out("|group#g| [ |box#x| ]\n").nodes[0]
.gutters
.is_empty()
);
}
#[test]
fn grid_gutters_stay_within_the_content_box() {
let l = lay_out(
"|table#t| { 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 (cx, cy, w, h) in &t.gutters {
assert!(cx.abs() + w / 2.0 <= hw, "gutter x {cx}±{} > {hw}", w / 2.0);
assert!(cy.abs() + h / 2.0 <= hh, "gutter y {cy}±{} > {hh}", h / 2.0);
}
}
#[test]
fn one_d_gutter_falls_between_flow_children() {
let l = lay_out(
"|row#g| { gap-fill: --stroke } [\n |box#a| { width: 30; height: 30; }\n |box#b| { width: 30; height: 30; }\n |box#c| { width: 30; height: 30; }\n]\n",
);
assert_eq!(
l.nodes[0].gutters.len(),
2,
"two gutters between three children"
);
}
#[test]
fn gap_fill_per_axis_selects_gutters() {
let rows_only = lay_out(
"|grid#g| { columns: 40 40; gap: 4 0; gap-fill: --stroke } [\n \"a\" \"b\"\n \"c\" \"d\"\n]\n",
);
let (_, _, w, h) = rows_only.nodes[0].gutters[0];
assert_eq!(rows_only.nodes[0].gutters.len(), 1, "row gap → one gutter");
assert!(w > h, "horizontal gutter is wide: w={w} h={h}");
let cols_only = lay_out(
"|grid#g| { columns: 40 40; gap: 0 4; gap-fill: --stroke } [\n \"a\" \"b\"\n \"c\" \"d\"\n]\n",
);
let (_, _, w2, h2) = cols_only.nodes[0].gutters[0];
assert_eq!(cols_only.nodes[0].gutters.len(), 1, "col gap → one gutter");
assert!(h2 > w2, "vertical gutter is tall: w={w2} h={h2}");
}
#[test]
fn scale_multiplies_the_shape_never_text_or_stroke() {
let plain = &lay_out("|box#a| \"hi\" { width: 100; height: 40 }\n").nodes[0];
let scaled = &lay_out("|box#a| \"hi\" { width: 100; height: 40; scale: 2 }\n").nodes[0];
assert!(
(scaled.bbox.w() - 202.0).abs() < 0.01,
"w={}",
scaled.bbox.w()
);
assert!(
(scaled.bbox.h() - 82.0).abs() < 0.01,
"h={}",
scaled.bbox.h()
);
assert!((scaled.children[0].bbox.w() - plain.children[0].bbox.w()).abs() < 0.01);
}
#[test]
fn scale_inherits_nearest_ancestor_wins() {
let l = lay_out(
"{ scale: 2 }\n|rect#a| { width: 50; height: 20 }\n|note#n| { width: 50; height: 20 }\n",
);
let a = &l.nodes[0];
assert!(
(a.bbox.w() - 102.0).abs() < 0.01,
"inherited: w={}",
a.bbox.w()
);
let n = &l.nodes[1];
assert!(
n.bbox.w() < 60.0,
"the note is sheet chrome: w={}",
n.bbox.w()
);
}
#[test]
fn translate_scales_by_the_parent() {
let nudge = |src: &str| {
let l = lay_out(src);
l.nodes[1].cx - l.nodes[0].cx
};
let plain = nudge(
"|rect#a| { width: 10; height: 10 }\n|rect#b| { width: 10; height: 10; translate: 5 0 }\n",
);
let scaled = nudge(
"{ scale: 3 }\n|rect#a| { width: 10; height: 10 }\n|rect#b| { width: 10; height: 10; translate: 5 0 }\n",
);
assert!((plain - 5.0).abs() < 0.01, "plain={plain}");
assert!((scaled - 15.0).abs() < 0.01, "scaled={scaled}");
}
#[test]
fn a_scaled_sketch_in_a_flow_doubles_its_geometry() {
let one = &lay_out("|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close() }\n")
.nodes[0];
let two = &lay_out(
"|sketch#s| { draw: move(0, 0) right(40) down(20) left(40) close(); scale: 2 }\n",
)
.nodes[0];
assert!((two.bbox.w() - one.bbox.w() - 40.0).abs() < 0.01);
assert!(
matches!(two.attrs.get("path"), Some(ResolvedValue::String(d)) if d.contains("80")),
"scaled path"
);
}
#[test]
fn scale_must_be_positive() {
let err = lay_out_err("|box#a| { scale: 0 }\n");
assert_eq!(err.message, "'scale' must be > 0");
}
#[test]
fn a_patterned_box_in_a_flow_unions_its_copies() {
let l = lay_out("|rect#a| { width: 20; height: 20; pattern: grid(3, 1, 30, 0) }\n");
let a = &l.nodes[0];
assert!((a.bbox.w() - 82.0).abs() < 0.01, "w={}", a.bbox.w());
assert_eq!(a.children.len(), 3, "three copies");
assert!(a.id.as_deref() == Some("a"), "the carrier keeps the id");
}
#[test]
fn a_filled_grid_cell_aligns_its_text_by_its_own_align() {
let text_cx = |a: &str| {
let src = format!(
"|grid#g| {{ columns: 200; align: stretch }} [\n |block#c| \"x\" {{ align: {a} }}\n]\n"
);
let l = lay_out(&src);
let text = &l.nodes[0].children[0].children[0];
assert_eq!(text.kind, NodeKind::Text);
text.cx
};
assert!(text_cx("start") < -50.0, "start: {}", text_cx("start"));
assert!(text_cx("end") > 50.0, "end: {}", text_cx("end"));
assert!(
text_cx("center").abs() < 5.0,
"center: {}",
text_cx("center")
);
}
}