mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
791 lines
35 KiB
Rust
791 lines
35 KiB
Rust
//! Auto layout behaviour suite: each case is a rule from the public documentation of auto layout
|
||
//! (hug / fill / fixed, min/max, padding, strokes, gaps, auto gaps, alignment, baselines, wrap,
|
||
//! absolute position and constraints, text, grid tracks and cells, canvas stacking) with the sizes
|
||
//! and positions the rule implies. The comment on each case is the one-line derivation. Where the
|
||
//! documentation is silent the case says so and pins the current behaviour instead.
|
||
|
||
use super::tests::FakeMeasure;
|
||
use super::*;
|
||
use figcraft_doc::color::{Color, Paint};
|
||
use figcraft_doc::{Constraints, GridTrack, Node, TextData};
|
||
use std::collections::HashMap;
|
||
|
||
use CellAlign as CA;
|
||
use LayoutMode::{Grid, Horizontal as H, Vertical as V};
|
||
use Sizing::{Fill, Fixed, Hug};
|
||
|
||
/// An auto layout frame: everything a case can set (`FRAME` holds the defaults).
|
||
#[derive(Clone, Copy)]
|
||
struct Frame<'a> {
|
||
mode: LayoutMode,
|
||
wrap: bool,
|
||
size: (f64, f64),
|
||
sizing: (Sizing, Sizing),
|
||
pad: [f64; 4],
|
||
gap: f64,
|
||
cross_gap: f64,
|
||
main: MainAlign,
|
||
cross: CrossAlign,
|
||
/// A visible stroke: weight, alignment and optional per-side weights.
|
||
stroke: Option<(f64, StrokeAlign, Option<[f64; 4]>)>,
|
||
strokes_included: bool,
|
||
min: (Option<f64>, Option<f64>),
|
||
max: (Option<f64>, Option<f64>),
|
||
columns: &'a [GridTrack],
|
||
rows: &'a [GridTrack],
|
||
}
|
||
|
||
const FRAME: Frame<'static> = Frame {
|
||
mode: H,
|
||
wrap: false,
|
||
size: (100.0, 100.0),
|
||
sizing: (Fixed, Fixed),
|
||
pad: [0.0; 4],
|
||
gap: 0.0,
|
||
cross_gap: 0.0,
|
||
main: MainAlign::Min,
|
||
cross: CrossAlign::Min,
|
||
stroke: None,
|
||
strokes_included: true,
|
||
min: (None, None),
|
||
max: (None, None),
|
||
columns: &[],
|
||
rows: &[],
|
||
};
|
||
|
||
/// A child: a rectangle, or a text (natural width, height and first baseline) when `text` is set.
|
||
#[derive(Clone, Copy)]
|
||
struct Kid {
|
||
size: (f64, f64),
|
||
sizing: (Sizing, Sizing),
|
||
min: (Option<f64>, Option<f64>),
|
||
max: (Option<f64>, Option<f64>),
|
||
text: Option<(f64, f64, f64)>,
|
||
absolute: bool,
|
||
at: (f64, f64),
|
||
constraints: (HConstraint, VConstraint),
|
||
cell: Option<(u32, u32)>,
|
||
span: (u32, u32),
|
||
align: (Option<CellAlign>, Option<CellAlign>),
|
||
}
|
||
|
||
const KID: Kid = Kid {
|
||
size: (20.0, 20.0),
|
||
sizing: (Fixed, Fixed),
|
||
min: (None, None),
|
||
max: (None, None),
|
||
text: None,
|
||
absolute: false,
|
||
at: (0.0, 0.0),
|
||
constraints: (HConstraint::Left, VConstraint::Top),
|
||
cell: None,
|
||
span: (1, 1),
|
||
align: (None, None),
|
||
};
|
||
|
||
const fn kid(w: f64, h: f64) -> Kid {
|
||
Kid { size: (w, h), ..KID }
|
||
}
|
||
|
||
const fn fixed(v: f64) -> GridTrack {
|
||
GridTrack { sizing: Fixed, value: v }
|
||
}
|
||
|
||
const fn fr(v: f64) -> GridTrack {
|
||
GridTrack { sizing: Fill, value: v }
|
||
}
|
||
|
||
const HUG: GridTrack = GridTrack { sizing: Hug, value: 0.0 };
|
||
|
||
struct Case<'a> {
|
||
rule: &'static str,
|
||
frame: Frame<'a>,
|
||
kids: &'a [Kid],
|
||
/// Expected frame size and each child's `(x, y, w, h)` in the frame.
|
||
want: (f64, f64),
|
||
rects: &'a [(f64, f64, f64, f64)],
|
||
}
|
||
|
||
fn build(c: &Case<'_>) -> (Document, NodeId, Vec<NodeId>, FakeMeasure) {
|
||
let mut d = Document::new("t");
|
||
let page = d.first_page().unwrap();
|
||
let f = c.frame;
|
||
let id = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, f.size.0, f.size.1).unwrap();
|
||
{
|
||
let n = d.get_mut(id).unwrap();
|
||
n.layout = Some(AutoLayout {
|
||
mode: f.mode,
|
||
wrap: f.wrap,
|
||
gap: f.gap,
|
||
cross_gap: f.cross_gap,
|
||
padding: f.pad,
|
||
main_align: f.main,
|
||
cross_align: f.cross,
|
||
grid_columns: f.columns.len().max(1) as u32,
|
||
grid_rows: f.rows.len().max(1) as u32,
|
||
column_tracks: f.columns.to_vec(),
|
||
row_tracks: f.rows.to_vec(),
|
||
strokes_included: f.strokes_included,
|
||
reverse_z: false,
|
||
content_align: ContentAlign::Auto,
|
||
});
|
||
(n.child_layout.sizing_h, n.child_layout.sizing_v) = f.sizing;
|
||
(n.child_layout.min_w, n.child_layout.min_h) = f.min;
|
||
(n.child_layout.max_w, n.child_layout.max_h) = f.max;
|
||
if let Some((w, align, sides)) = f.stroke {
|
||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||
n.stroke.weight = w;
|
||
n.stroke.align = align;
|
||
n.stroke.sides = sides;
|
||
}
|
||
}
|
||
let mut texts = HashMap::new();
|
||
let mut ids = Vec::new();
|
||
for k in c.kids {
|
||
let kind = if k.text.is_some() { NodeKind::Text(TextData::new("T")) } else { NodeKind::Rectangle };
|
||
let kid = d.create(id, kind, "K", k.at.0, k.at.1, k.size.0, k.size.1).unwrap();
|
||
let n = d.get_mut(kid).unwrap();
|
||
if let (Some(t), NodeKind::Text(td)) = (k.text, &mut n.kind) {
|
||
// Auto width hugs both axes; filling the width wraps (auto height).
|
||
td.auto_resize = if k.sizing.0 == Fill { TextAutoResize::Height } else { TextAutoResize::WidthAndHeight };
|
||
texts.insert(kid, t);
|
||
}
|
||
let cl = &mut n.child_layout;
|
||
(cl.sizing_h, cl.sizing_v) = k.sizing;
|
||
(cl.min_w, cl.min_h) = k.min;
|
||
(cl.max_w, cl.max_h) = k.max;
|
||
cl.absolute = k.absolute;
|
||
(cl.grid_column, cl.grid_row) = (k.cell.map(|c| c.0), k.cell.map(|c| c.1));
|
||
(cl.grid_column_span, cl.grid_row_span) = k.span;
|
||
(cl.grid_align_h, cl.grid_align_v) = k.align;
|
||
n.constraints = Constraints { horizontal: k.constraints.0, vertical: k.constraints.1 };
|
||
ids.push(kid);
|
||
}
|
||
(d, id, ids, FakeMeasure(texts))
|
||
}
|
||
|
||
fn rect_of(d: &Document, id: NodeId) -> (f64, f64, f64, f64) {
|
||
let n: &Node = d.node(id).unwrap();
|
||
let c = n.transform.as_coeffs();
|
||
(c[4], c[5], n.width, n.height)
|
||
}
|
||
|
||
fn close(a: f64, b: f64) -> bool {
|
||
(a - b).abs() < 1e-6
|
||
}
|
||
|
||
fn check(cases: &[Case<'_>]) {
|
||
let mut failures = Vec::new();
|
||
for c in cases {
|
||
let (mut d, f, ids, m) = build(c);
|
||
layout_frame(&mut d, f, &m);
|
||
let n = d.node(f).unwrap();
|
||
let got: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect();
|
||
let size_ok = close(n.width, c.want.0) && close(n.height, c.want.1);
|
||
let rects_ok = got.len() == c.rects.len()
|
||
&& got.iter().zip(c.rects).all(|(g, w)| close(g.0, w.0) && close(g.1, w.1) && close(g.2, w.2) && close(g.3, w.3));
|
||
if !size_ok || !rects_ok {
|
||
failures.push(format!("{}\n frame {:?} want {:?}\n kids {:?}\n want {:?}", c.rule, (n.width, n.height), c.want, got, c.rects));
|
||
}
|
||
// Laying out again changes nothing (one pass settles).
|
||
let before: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect();
|
||
layout_frame(&mut d, f, &m);
|
||
let after: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect();
|
||
if before != after {
|
||
failures.push(format!("{}: a second pass moved children {before:?} -> {after:?}", c.rule));
|
||
}
|
||
}
|
||
assert!(failures.is_empty(), "{} rule(s) diverge:\n{}", failures.len(), failures.join("\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn sizing_rules() {
|
||
check(&[
|
||
Case {
|
||
rule: "hug: a 40 px text plus 10 px left and right padding makes a 60 px frame",
|
||
// 10 + 40 + 10 = 60 wide; 20 tall (no vertical padding).
|
||
frame: Frame { sizing: (Hug, Hug), pad: [0.0, 10.0, 0.0, 10.0], ..FRAME },
|
||
kids: &[Kid { text: Some((40.0, 20.0, 15.0)), sizing: (Hug, Hug), ..KID }],
|
||
want: (60.0, 20.0),
|
||
rects: &[(10.0, 0.0, 40.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fixed: the frame keeps its size whatever its children need",
|
||
// 3 × 50 = 150 overflows the fixed 100 px frame; it stays 100 × 50.
|
||
frame: Frame { size: (100.0, 50.0), ..FRAME },
|
||
kids: &[kid(50.0, 20.0), kid(50.0, 20.0), kid(50.0, 20.0)],
|
||
want: (100.0, 50.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 50.0, 20.0), (100.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fill: children share the space left after padding, gaps and fixed siblings",
|
||
// 300 - 2×10 padding - 60 fixed - 2×20 gaps = 180 → 90 each.
|
||
frame: Frame { size: (300.0, 40.0), pad: [0.0, 10.0, 0.0, 10.0], gap: 20.0, ..FRAME },
|
||
kids: &[kid(60.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
|
||
want: (300.0, 40.0),
|
||
rects: &[(10.0, 0.0, 60.0, 20.0), (90.0, 0.0, 90.0, 20.0), (200.0, 0.0, 90.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fill across the flow stretches to the frame's inner height",
|
||
// 100 - 2×10 padding = 80.
|
||
frame: Frame { size: (100.0, 100.0), pad: [10.0; 4], ..FRAME },
|
||
kids: &[Kid { sizing: (Fixed, Fill), ..KID }],
|
||
want: (100.0, 100.0),
|
||
rects: &[(10.0, 10.0, 20.0, 80.0)],
|
||
},
|
||
Case {
|
||
rule: "fill on an axis the frame hugs has nothing to fill: the child keeps its size",
|
||
// A hugging frame has no free space; it hugs 50 + 30.
|
||
frame: Frame { sizing: (Hug, Hug), ..FRAME },
|
||
kids: &[kid(50.0, 20.0), Kid { size: (30.0, 40.0), sizing: (Fill, Fill), ..KID }],
|
||
want: (80.0, 40.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 30.0, 40.0)],
|
||
},
|
||
Case {
|
||
rule: "min width: a fixed child is never narrower than its minimum",
|
||
frame: Frame { sizing: (Hug, Hug), ..FRAME },
|
||
kids: &[Kid { size: (50.0, 20.0), min: (Some(80.0), None), ..KID }],
|
||
want: (80.0, 20.0),
|
||
rects: &[(0.0, 0.0, 80.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "max width on a fill child: it stops at its max and the other fill child takes the rest",
|
||
// 200 shared: one stops at 50, the other fills the remaining 150.
|
||
frame: Frame { size: (200.0, 20.0), ..FRAME },
|
||
kids: &[Kid { sizing: (Fill, Fixed), max: (Some(50.0), None), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
|
||
want: (200.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 150.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "max width on a hugging frame: it hugs up to its max",
|
||
// Content 300 wide, max 200.
|
||
frame: Frame { sizing: (Hug, Hug), max: (Some(200.0), None), ..FRAME },
|
||
kids: &[kid(300.0, 20.0)],
|
||
want: (200.0, 20.0),
|
||
rects: &[(0.0, 0.0, 300.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "min height on a hugging frame: it hugs, but never below its min",
|
||
frame: Frame { sizing: (Hug, Hug), min: (None, Some(60.0)), ..FRAME },
|
||
kids: &[kid(30.0, 20.0)],
|
||
want: (30.0, 60.0),
|
||
rects: &[(0.0, 0.0, 30.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a hugging text wider than its max width wraps at the max",
|
||
// 150 px of text at max 100 wraps to 2 lines of 20.
|
||
frame: Frame { sizing: (Hug, Hug), ..FRAME },
|
||
kids: &[Kid { text: Some((150.0, 20.0, 15.0)), sizing: (Hug, Hug), max: (Some(100.0), None), ..KID }],
|
||
want: (100.0, 40.0),
|
||
rects: &[(0.0, 0.0, 100.0, 40.0)],
|
||
},
|
||
Case {
|
||
rule: "a text filling the width wraps and its hugging frame grows to its new height",
|
||
// 150 px of text in an 80 px inner width (100 - 2×10) needs 2 lines: 40 + 20 padding.
|
||
frame: Frame { size: (100.0, 10.0), sizing: (Fixed, Hug), pad: [10.0; 4], ..FRAME },
|
||
kids: &[Kid { text: Some((150.0, 20.0, 15.0)), sizing: (Fill, Hug), ..KID }],
|
||
want: (100.0, 60.0),
|
||
rects: &[(10.0, 10.0, 80.0, 40.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn padding_and_stroke_rules() {
|
||
let inside = Some((4.0, StrokeAlign::Inside, None));
|
||
check(&[
|
||
Case {
|
||
rule: "padding: space between the frame's edge and its children, per side",
|
||
// 4 + 50 + 2 wide, 1 + 20 + 3 tall; the child sits at (left, top).
|
||
frame: Frame { sizing: (Hug, Hug), pad: [1.0, 2.0, 3.0, 4.0], ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (56.0, 24.0),
|
||
rects: &[(4.0, 1.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a fixed frame is never smaller than its padding: it sizes up to fit",
|
||
// 10 px fixed, 20 px padding each side → 40.
|
||
frame: Frame { size: (10.0, 10.0), pad: [20.0; 4], ..FRAME },
|
||
kids: &[],
|
||
want: (40.0, 40.0),
|
||
rects: &[],
|
||
},
|
||
Case {
|
||
rule: "the padding floor wins over a max size",
|
||
// max width 10 < 20 + 20 padding → 40.
|
||
frame: Frame { size: (100.0, 50.0), pad: [0.0, 20.0, 0.0, 20.0], max: (Some(10.0), None), ..FRAME },
|
||
kids: &[],
|
||
want: (40.0, 50.0),
|
||
rects: &[],
|
||
},
|
||
Case {
|
||
rule: "an empty hugging frame is its padding plus its included inside stroke",
|
||
// 8 + 2 on each side → 20 × 20.
|
||
frame: Frame { sizing: (Hug, Hug), pad: [8.0; 4], stroke: Some((2.0, StrokeAlign::Inside, None)), ..FRAME },
|
||
kids: &[],
|
||
want: (20.0, 20.0),
|
||
rects: &[],
|
||
},
|
||
Case {
|
||
rule: "strokes included: the frame's inside stroke takes up space like padding",
|
||
// 4 + 50 + 4 by 4 + 20 + 4.
|
||
frame: Frame { sizing: (Hug, Hug), stroke: inside, ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (58.0, 28.0),
|
||
rects: &[(4.0, 4.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "strokes excluded: the stroke takes no space",
|
||
frame: Frame { sizing: (Hug, Hug), stroke: inside, strokes_included: false, ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "center strokes are never counted, even when strokes are included",
|
||
frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Center, None)), ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "outside strokes are never counted, even when strokes are included",
|
||
frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Outside, None)), ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
// Docs silent on per-side weights: each side counts its own inside weight.
|
||
rule: "individual inside stroke weights count per side",
|
||
// 4 + 50 + 2 by 1 + 20 + 3.
|
||
frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Inside, Some([1.0, 2.0, 3.0, 4.0]))), ..FRAME },
|
||
kids: &[kid(50.0, 20.0)],
|
||
want: (56.0, 24.0),
|
||
rects: &[(4.0, 1.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fill children share the space inside padding and the included stroke",
|
||
// 100 - 2×(10 + 5) = 70 by 50 - 30 = 20.
|
||
frame: Frame { size: (100.0, 50.0), pad: [10.0; 4], stroke: Some((5.0, StrokeAlign::Inside, None)), ..FRAME },
|
||
kids: &[Kid { sizing: (Fill, Fill), ..KID }],
|
||
want: (100.0, 50.0),
|
||
rects: &[(15.0, 15.0, 70.0, 20.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn gap_rules() {
|
||
let three = &[kid(20.0, 20.0), kid(20.0, 20.0), kid(20.0, 20.0)];
|
||
check(&[
|
||
Case {
|
||
rule: "gap: a fixed distance between children; a hugging frame includes it",
|
||
// 20 + 10 + 20 + 10 + 20 = 80.
|
||
frame: Frame { sizing: (Hug, Hug), gap: 10.0, ..FRAME },
|
||
kids: three,
|
||
want: (80.0, 20.0),
|
||
rects: &[(0.0, 0.0, 20.0, 20.0), (30.0, 0.0, 20.0, 20.0), (60.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "negative gap: children overlap by the gap",
|
||
// 50 - 10 = 40 per step; hug 50 + 40 = 90.
|
||
frame: Frame { sizing: (Hug, Hug), gap: -10.0, ..FRAME },
|
||
kids: &[kid(50.0, 20.0), kid(50.0, 20.0)],
|
||
want: (90.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (40.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "vertical gap: the same along y",
|
||
frame: Frame { mode: V, sizing: (Hug, Hug), gap: 5.0, ..FRAME },
|
||
kids: &[kid(20.0, 30.0), kid(40.0, 10.0)],
|
||
want: (40.0, 45.0),
|
||
rects: &[(0.0, 0.0, 20.0, 30.0), (0.0, 35.0, 40.0, 10.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap between: as far apart as possible, no space at the edges",
|
||
// 200 - 60 = 140 over 2 gaps → 70.
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, gap: 10.0, ..FRAME },
|
||
kids: three,
|
||
want: (200.0, 20.0),
|
||
rects: &[(0.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (180.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap evenly: equal space in every gap and at both edges (1:1)",
|
||
// 140 over 4 spaces → 35.
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME },
|
||
kids: three,
|
||
want: (200.0, 20.0),
|
||
rects: &[(35.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (145.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap around: gaps twice the edge space (2:1)",
|
||
// 140 = 2e + 2×(2e) → e = 23.33, gap 46.67.
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceAround, ..FRAME },
|
||
kids: three,
|
||
want: (200.0, 20.0),
|
||
rects: &[(70.0 / 3.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (470.0 / 3.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap stops at 0 when the children don't fit (no overlap)",
|
||
// 30 + 30 > 50: the gap is 0, not -10.
|
||
frame: Frame { size: (50.0, 20.0), main: MainAlign::SpaceBetween, ..FRAME },
|
||
kids: &[kid(30.0, 20.0), kid(30.0, 20.0)],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 30.0, 20.0), (30.0, 0.0, 30.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap evenly stops at 0 too (children start at the edge)",
|
||
frame: Frame { size: (50.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME },
|
||
kids: &[kid(30.0, 20.0), kid(30.0, 20.0)],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 30.0, 20.0), (30.0, 0.0, 30.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a single child of auto gap between aligns to the start (left)",
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, ..FRAME },
|
||
kids: &[kid(20.0, 20.0)],
|
||
want: (200.0, 20.0),
|
||
rects: &[(0.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a single child of auto gap evenly has equal space on both sides",
|
||
// (200 - 20) / 2 = 90.
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME },
|
||
kids: &[kid(20.0, 20.0)],
|
||
want: (200.0, 20.0),
|
||
rects: &[(90.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap with fill children: they take the free space, leaving no gap",
|
||
// Fill takes 200 - 20 - 20; between has nothing left to share.
|
||
frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, gap: 10.0, ..FRAME },
|
||
kids: &[kid(20.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }, kid(20.0, 20.0)],
|
||
want: (200.0, 20.0),
|
||
rects: &[(0.0, 0.0, 20.0, 20.0), (20.0, 0.0, 160.0, 20.0), (180.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn alignment_rules() {
|
||
let one = &[kid(50.0, 20.0)];
|
||
check(&[
|
||
Case {
|
||
rule: "align top left (the default)",
|
||
frame: Frame { size: (200.0, 100.0), ..FRAME },
|
||
kids: one,
|
||
want: (200.0, 100.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "align center: centered on both axes",
|
||
// ((200 - 50) / 2, (100 - 20) / 2).
|
||
frame: Frame { size: (200.0, 100.0), main: MainAlign::Center, cross: CrossAlign::Center, ..FRAME },
|
||
kids: one,
|
||
want: (200.0, 100.0),
|
||
rects: &[(75.0, 40.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "align bottom right",
|
||
frame: Frame { size: (200.0, 100.0), main: MainAlign::Max, cross: CrossAlign::Max, ..FRAME },
|
||
kids: one,
|
||
want: (200.0, 100.0),
|
||
rects: &[(150.0, 80.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "vertical flow: main alignment is vertical, cross alignment horizontal",
|
||
// Main max → y = 100 - 20; cross center → x = 75.
|
||
frame: Frame { mode: V, size: (200.0, 100.0), main: MainAlign::Max, cross: CrossAlign::Center, ..FRAME },
|
||
kids: one,
|
||
want: (200.0, 100.0),
|
||
rects: &[(75.0, 80.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "alignment works inside the padding",
|
||
// Center of the inner 180 × 80 box at (10, 10).
|
||
frame: Frame { size: (200.0, 100.0), pad: [10.0; 4], main: MainAlign::Center, cross: CrossAlign::Center, ..FRAME },
|
||
kids: one,
|
||
want: (200.0, 100.0),
|
||
rects: &[(75.0, 40.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a hugging frame has no extra space: alignment changes nothing visible",
|
||
frame: Frame { sizing: (Hug, Hug), main: MainAlign::Max, cross: CrossAlign::Max, ..FRAME },
|
||
kids: one,
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "auto gap keeps the cross alignment (top / center / bottom)",
|
||
frame: Frame { size: (200.0, 100.0), main: MainAlign::SpaceBetween, cross: CrossAlign::Center, ..FRAME },
|
||
kids: &[kid(20.0, 20.0), kid(20.0, 40.0)],
|
||
want: (200.0, 100.0),
|
||
rects: &[(0.0, 40.0, 20.0, 20.0), (180.0, 30.0, 20.0, 40.0)],
|
||
},
|
||
Case {
|
||
rule: "text baselines: texts of different sizes sit on one baseline",
|
||
// Baselines 10 and 20: the first moves down 10; the frame hugs max(10 + 14, 28).
|
||
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, gap: 5.0, ..FRAME },
|
||
kids: &[
|
||
Kid { text: Some((30.0, 14.0, 10.0)), sizing: (Hug, Hug), ..KID },
|
||
Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID },
|
||
],
|
||
want: (95.0, 28.0),
|
||
rects: &[(0.0, 10.0, 30.0, 14.0), (35.0, 0.0, 60.0, 28.0)],
|
||
},
|
||
Case {
|
||
// Docs silent on layers without text: they keep to the top of the line.
|
||
rule: "baseline alignment: a layer without text sits at the top",
|
||
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
|
||
kids: &[kid(10.0, 10.0), Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }],
|
||
want: (70.0, 28.0),
|
||
rects: &[(0.0, 0.0, 10.0, 10.0), (10.0, 0.0, 60.0, 28.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_rules() {
|
||
let three = &[kid(40.0, 20.0), kid(40.0, 20.0), kid(40.0, 20.0)];
|
||
check(&[
|
||
Case {
|
||
rule: "horizontal wrap: left to right, a new row when the width runs out",
|
||
// 40 + 10 + 40 = 90 fits in 100; the third wraps to y = 20 + 5.
|
||
frame: Frame { wrap: true, size: (100.0, 10.0), sizing: (Fixed, Hug), gap: 10.0, cross_gap: 5.0, ..FRAME },
|
||
kids: three,
|
||
want: (100.0, 45.0),
|
||
rects: &[(0.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "vertical wrap: top to bottom, a new column when the height runs out",
|
||
// 40 + 10 + 40 = 90 fits in 100; the third starts a column at x = 20 + 5.
|
||
frame: Frame { mode: V, wrap: true, size: (10.0, 100.0), sizing: (Hug, Fixed), gap: 10.0, cross_gap: 5.0, ..FRAME },
|
||
kids: &[kid(20.0, 40.0), kid(20.0, 40.0), kid(20.0, 40.0)],
|
||
want: (45.0, 100.0),
|
||
rects: &[(0.0, 0.0, 20.0, 40.0), (0.0, 50.0, 20.0, 40.0), (25.0, 0.0, 20.0, 40.0)],
|
||
},
|
||
Case {
|
||
rule: "vertical wrap is not masonry: a column fills to the height before the next starts",
|
||
// 30 + 30 + 30 = 90 ≤ 100 stay in column 1 even though column 2 is empty.
|
||
frame: Frame { mode: V, wrap: true, size: (100.0, 100.0), ..FRAME },
|
||
kids: &[kid(20.0, 30.0), kid(20.0, 30.0), kid(20.0, 30.0), kid(20.0, 30.0)],
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 20.0, 30.0), (0.0, 30.0, 20.0, 30.0), (0.0, 60.0, 20.0, 30.0), (20.0, 0.0, 20.0, 30.0)],
|
||
},
|
||
Case {
|
||
rule: "wrap: auto gap spreads each row on its own",
|
||
// Row 1: 100 - 80 between 2 → gap 20; row 2: a single child at the start.
|
||
frame: Frame { wrap: true, size: (100.0, 100.0), main: MainAlign::SpaceBetween, gap: 10.0, cross_gap: 5.0, ..FRAME },
|
||
kids: three,
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 40.0, 20.0), (60.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "wrap: cross alignment within each row",
|
||
// Row heights 40 and 20; the short child centers in row 1: (40 - 20) / 2.
|
||
frame: Frame { wrap: true, size: (100.0, 10.0), sizing: (Fixed, Hug), gap: 10.0, cross: CrossAlign::Center, ..FRAME },
|
||
kids: &[kid(40.0, 20.0), kid(40.0, 40.0), kid(40.0, 20.0)],
|
||
want: (100.0, 60.0),
|
||
rects: &[(0.0, 10.0, 40.0, 20.0), (50.0, 0.0, 40.0, 40.0), (0.0, 40.0, 40.0, 20.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn absolute_position_rules() {
|
||
check(&[
|
||
Case {
|
||
rule: "ignore auto layout: the child keeps its place and the frame hugs only the flow",
|
||
frame: Frame { sizing: (Hug, Hug), ..FRAME },
|
||
kids: &[kid(50.0, 20.0), Kid { size: (200.0, 200.0), absolute: true, at: (5.0, 5.0), ..KID }],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (5.0, 5.0, 200.0, 200.0)],
|
||
},
|
||
Case {
|
||
rule: "absolute children follow their constraints when the frame resizes (right / bottom)",
|
||
// The frame hugs from 100 × 100 to 50 × 20: right and bottom offsets of 10 stay.
|
||
frame: Frame { sizing: (Hug, Hug), ..FRAME },
|
||
kids: &[
|
||
kid(50.0, 20.0),
|
||
Kid { size: (10.0, 10.0), absolute: true, at: (80.0, 80.0), constraints: (HConstraint::Right, VConstraint::Bottom), ..KID },
|
||
],
|
||
want: (50.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (30.0, 0.0, 10.0, 10.0)],
|
||
},
|
||
Case {
|
||
rule: "absolute children follow their constraints (left and right / center)",
|
||
// 200 → 100 wide: left and right keeps 10 px each side (width 80); center moves by -50.
|
||
frame: Frame { size: (200.0, 100.0), sizing: (Hug, Fixed), ..FRAME },
|
||
kids: &[
|
||
kid(100.0, 20.0),
|
||
Kid { size: (180.0, 10.0), absolute: true, at: (10.0, 50.0), constraints: (HConstraint::LeftRight, VConstraint::Top), ..KID },
|
||
Kid { size: (20.0, 10.0), absolute: true, at: (90.0, 0.0), constraints: (HConstraint::Center, VConstraint::Top), ..KID },
|
||
],
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 100.0, 20.0), (10.0, 50.0, 80.0, 10.0), (40.0, 0.0, 20.0, 10.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn grid_rules() {
|
||
check(&[
|
||
Case {
|
||
rule: "fill tracks share the space by fr: 1fr and 2fr take a third and two thirds",
|
||
frame: Frame { mode: Grid, size: (300.0, 50.0), columns: &[fr(1.0), fr(2.0)], ..FRAME },
|
||
kids: &[Kid { sizing: (Fill, Fill), ..KID }, Kid { sizing: (Fill, Fill), ..KID }],
|
||
want: (300.0, 50.0),
|
||
rects: &[(0.0, 0.0, 100.0, 50.0), (100.0, 0.0, 200.0, 50.0)],
|
||
},
|
||
Case {
|
||
rule: "fixed tracks keep their size; fill tracks share the rest after gaps and padding",
|
||
// 300 - 2×10 padding - 10 gap - 100 = 170.
|
||
frame: Frame { mode: Grid, size: (300.0, 50.0), pad: [0.0, 10.0, 0.0, 10.0], gap: 10.0, columns: &[fixed(100.0), fr(1.0)], ..FRAME },
|
||
kids: &[Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }],
|
||
want: (300.0, 50.0),
|
||
rects: &[(10.0, 0.0, 100.0, 20.0), (120.0, 0.0, 170.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "hug tracks keep the smallest size around their children",
|
||
frame: Frame { mode: Grid, size: (300.0, 50.0), columns: &[HUG, fr(1.0)], ..FRAME },
|
||
kids: &[kid(60.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }],
|
||
want: (300.0, 50.0),
|
||
rects: &[(0.0, 0.0, 60.0, 20.0), (60.0, 0.0, 240.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fr tracks behave like the CSS fr unit: a fixed child taller than its share keeps its row",
|
||
// Share 50 < 80: row 1 keeps 80, row 2 gets the remaining 20.
|
||
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
|
||
kids: &[kid(20.0, 80.0), Kid { sizing: (Fixed, Fill), ..KID }],
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "fr tracks: children bigger than every share keep their sizes (the grid overflows)",
|
||
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
|
||
kids: &[kid(20.0, 80.0), kid(20.0, 70.0)],
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 70.0)],
|
||
},
|
||
Case {
|
||
rule: "fr columns: a fixed child wider than its share keeps its column",
|
||
// Share 100 < 150: column 1 keeps 150; column 2 (2fr alone) gets the other 150.
|
||
frame: Frame { mode: Grid, size: (300.0, 20.0), columns: &[fr(1.0), fr(2.0)], ..FRAME },
|
||
kids: &[kid(150.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }],
|
||
want: (300.0, 20.0),
|
||
rects: &[(0.0, 0.0, 150.0, 20.0), (150.0, 0.0, 150.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "placement: left to right, top to bottom; rows grow to fit the children",
|
||
// 2 columns of 50, 3 children → 2 rows of 50 (fill rows share 100).
|
||
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0), fr(1.0)], rows: &[fr(1.0)], ..FRAME },
|
||
kids: &[kid(10.0, 10.0), kid(10.0, 10.0), kid(10.0, 10.0)],
|
||
want: (100.0, 100.0),
|
||
rects: &[(0.0, 0.0, 10.0, 10.0), (50.0, 0.0, 10.0, 10.0), (0.0, 50.0, 10.0, 10.0)],
|
||
},
|
||
Case {
|
||
rule: "gap between columns and between rows",
|
||
frame: Frame {
|
||
mode: Grid,
|
||
size: (110.0, 105.0),
|
||
gap: 10.0,
|
||
cross_gap: 5.0,
|
||
columns: &[fr(1.0), fr(1.0)],
|
||
rows: &[fr(1.0), fr(1.0)],
|
||
..FRAME
|
||
},
|
||
kids: &[kid(10.0, 10.0), kid(10.0, 10.0), kid(10.0, 10.0)],
|
||
want: (110.0, 105.0),
|
||
rects: &[(0.0, 0.0, 10.0, 10.0), (60.0, 0.0, 10.0, 10.0), (0.0, 55.0, 10.0, 10.0)],
|
||
},
|
||
Case {
|
||
rule: "a child spanning two columns fills both and the gap between them",
|
||
frame: Frame { mode: Grid, size: (110.0, 20.0), gap: 10.0, columns: &[fr(1.0), fr(1.0)], ..FRAME },
|
||
kids: &[Kid { sizing: (Fill, Fill), span: (2, 1), ..KID }],
|
||
want: (110.0, 20.0),
|
||
rects: &[(0.0, 0.0, 110.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a child anchored to a cell sits there; the rest flow around it",
|
||
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0), fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
|
||
kids: &[kid(10.0, 10.0), Kid { cell: Some((0, 0)), ..KID }],
|
||
want: (100.0, 100.0),
|
||
rects: &[(50.0, 0.0, 10.0, 10.0), (0.0, 0.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "children align in their cells (the frame's alignment)",
|
||
// Cells 50 × 50: centered → (15, 15) and (65, 15).
|
||
frame: Frame {
|
||
mode: Grid,
|
||
size: (100.0, 50.0),
|
||
main: MainAlign::Center,
|
||
cross: CrossAlign::Center,
|
||
columns: &[fr(1.0), fr(1.0)],
|
||
..FRAME
|
||
},
|
||
kids: &[kid(20.0, 20.0), kid(20.0, 20.0)],
|
||
want: (100.0, 50.0),
|
||
rects: &[(15.0, 15.0, 20.0, 20.0), (65.0, 15.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a child aligns independently in its cell (its own alignment wins)",
|
||
// The second child: right and bottom of its cell → (100 - 20, 50 - 20).
|
||
frame: Frame {
|
||
mode: Grid,
|
||
size: (100.0, 50.0),
|
||
main: MainAlign::Center,
|
||
cross: CrossAlign::Center,
|
||
columns: &[fr(1.0), fr(1.0)],
|
||
..FRAME
|
||
},
|
||
kids: &[kid(20.0, 20.0), Kid { align: (Some(CA::Max), Some(CA::Max)), ..KID }],
|
||
want: (100.0, 50.0),
|
||
rects: &[(15.0, 15.0, 20.0, 20.0), (80.0, 30.0, 20.0, 20.0)],
|
||
},
|
||
Case {
|
||
rule: "a hugging grid keeps the smallest size around its tracks (fill tracks hug too)",
|
||
// Columns 30 and 40 + gap 10 = 80; one row of 20.
|
||
frame: Frame { mode: Grid, sizing: (Hug, Hug), gap: 10.0, columns: &[fr(1.0), fr(1.0)], ..FRAME },
|
||
kids: &[kid(30.0, 20.0), kid(40.0, 10.0)],
|
||
want: (80.0, 20.0),
|
||
rects: &[(0.0, 0.0, 30.0, 20.0), (40.0, 0.0, 40.0, 10.0)],
|
||
},
|
||
Case {
|
||
rule: "padding: the space between the frame's edge and its cells",
|
||
frame: Frame { mode: Grid, sizing: (Hug, Hug), pad: [1.0, 2.0, 3.0, 4.0], columns: &[HUG], ..FRAME },
|
||
kids: &[kid(30.0, 20.0)],
|
||
want: (36.0, 24.0),
|
||
rects: &[(4.0, 1.0, 30.0, 20.0)],
|
||
},
|
||
]);
|
||
}
|
||
|
||
/// Canvas stacking changes which child is drawn (and hit) on top, never the places or the layer
|
||
/// order.
|
||
#[test]
|
||
fn canvas_stacking_rule() {
|
||
let case = Case {
|
||
rule: "negative gap stack",
|
||
frame: Frame { sizing: (Hug, Hug), gap: -20.0, ..FRAME },
|
||
kids: &[kid(50.0, 20.0), kid(50.0, 20.0)],
|
||
want: (80.0, 20.0),
|
||
rects: &[(0.0, 0.0, 50.0, 20.0), (30.0, 0.0, 50.0, 20.0)],
|
||
};
|
||
let (mut d, f, ids, m) = build(&case);
|
||
layout_frame(&mut d, f, &m);
|
||
let page = d.first_page().unwrap();
|
||
let overlap = figcraft_geom::Point::new(40.0, 10.0);
|
||
let hit = |d: &Document| d.hit_test(page, overlap, 0.0, figcraft_doc::geometry::BoolResult::default());
|
||
// Last on top by default: the right-most child.
|
||
assert_eq!(hit(&d), Some(ids[1]));
|
||
assert_eq!(d.node(f).unwrap().paint_order().collect::<Vec<_>>(), ids);
|
||
d.get_mut(f).unwrap().layout.as_mut().unwrap().reverse_z = true;
|
||
layout_frame(&mut d, f, &m);
|
||
// First on top: the left-most child is drawn and hit on top; places and layers stay.
|
||
assert_eq!(hit(&d), Some(ids[0]));
|
||
assert_eq!(d.node(f).unwrap().paint_order().collect::<Vec<_>>(), vec![ids[1], ids[0]]);
|
||
assert_eq!(d.node(f).unwrap().children, ids);
|
||
assert_eq!(rect_of(&d, ids[1]), (30.0, 0.0, 50.0, 20.0));
|
||
}
|