Files
figcraft/crates/layout/src/nested_tests.rs
T
BFlatandClaude Opus 5.5 71c467879d M2: auto layout nesting: one-pass nested hug/fill, coherent sizing, drag across nested frames (#45)
Layout (crates/layout):
- Fill children take their size before the frame is sized, so wrapping fill
  text, frames that hug it and grid rows settle in a single pass (three
  levels deep included).
- Fill children share space like CSS flexbox: min and max violations are
  resolved in turn instead of freezing both at once.
- A single child with space-between sits at the start; a wrapped row of only
  fill-height children keeps their height; grids size fill-width children
  before their rows.
- coherent_sizing: Fill only in an auto layout flow on an axis the parent
  doesn't hug, Hug only on auto layout frames and text (a text hugs what its
  resize mode grows); anything else becomes Fixed at its size.

Engine:
- Setting a child to Fill turns a hugging parent Fixed on that axis; a parent
  that starts to hug, layers leaving auto layout and layout.remove fix the
  rest (applied in normalize).
- Moving layers: the deepest auto layout frame under the pointer wins (plain
  frames in a flow are items, not destinations; the dragged layers and their
  descendants are skipped); the frame they come from keeps them near its
  edge. They take the slot (or grid cell) there with the insertion line drawn
  in the gap and the receiving frame outlined; out of auto layout they become
  positioned children of the canvas or plain frame at the drop point. One
  undo step per drag. Slots skip hidden and absolute children.

Tests: crates/layout/src/nested_tests.rs, crates/engine/src/al_nesting_tests.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:41:11 +07:00

383 lines
17 KiB
Rust

//! Nested hug/fill edge cases: every expectation holds after a single layout pass.
use super::tests::{FakeMeasure, pos, text};
use super::*;
use figcraft_doc::{AutoLayout, Document, NodeKind};
/// An auto layout frame under `parent` (gap 10, no padding) with the given sizing.
fn al(d: &mut Document, parent: NodeId, mode: LayoutMode, (w, h): (f64, f64), (sh, sv): (Sizing, Sizing)) -> NodeId {
let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, w, h).unwrap();
let n = d.get_mut(f).unwrap();
n.layout = Some(AutoLayout { mode, gap: 10.0, padding: [0.0; 4], ..Default::default() });
n.child_layout.sizing_h = sh;
n.child_layout.sizing_v = sv;
f
}
fn rect(d: &mut Document, parent: NodeId, w: f64, h: f64) -> NodeId {
d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, w, h).unwrap()
}
fn sizing(d: &mut Document, id: NodeId, sh: Sizing, sv: Sizing) {
let cl = &mut d.get_mut(id).unwrap().child_layout;
cl.sizing_h = sh;
cl.sizing_v = sv;
}
fn size(d: &Document, id: NodeId) -> (f64, f64) {
let n = d.node(id).unwrap();
(n.width, n.height)
}
fn layout_mut(d: &mut Document, id: NodeId) -> &mut AutoLayout {
d.get_mut(id).unwrap().layout.as_mut().unwrap()
}
fn page(d: &Document) -> NodeId {
d.first_page().unwrap()
}
/// A text that wraps (auto height) when it fills a width.
fn fill_text(d: &mut Document, parent: NodeId) -> NodeId {
let t = text(d, parent, "Wrapping text");
sizing(d, t, Sizing::Fill, Sizing::Hug);
if let NodeKind::Text(td) = &mut d.get_mut(t).unwrap().kind {
td.auto_resize = TextAutoResize::Height;
}
t
}
#[test]
fn hug_parent_treats_fill_children_as_fixed() {
let mut d = Document::new("t");
let p = page(&d);
let f = al(&mut d, p, LayoutMode::Horizontal, (10.0, 10.0), (Sizing::Hug, Sizing::Hug));
rect(&mut d, f, 50.0, 20.0);
let b = rect(&mut d, f, 30.0, 40.0);
sizing(&mut d, b, Sizing::Fill, Sizing::Fill);
layout_frame(&mut d, f, &NoMeasure);
// Nothing to fill: the fill child keeps its size and the frame gets the content size.
assert_eq!(size(&d, f), (90.0, 40.0));
assert_eq!(pos(&d, b), (60.0, 0.0, 30.0, 40.0));
// Vertical, fill on the cross axis.
let g = al(&mut d, p, LayoutMode::Vertical, (10.0, 10.0), (Sizing::Hug, Sizing::Hug));
let c = rect(&mut d, g, 60.0, 20.0);
sizing(&mut d, c, Sizing::Fill, Sizing::Fixed);
rect(&mut d, g, 40.0, 30.0);
layout_frame(&mut d, g, &NoMeasure);
assert_eq!(size(&d, g), (60.0, 60.0));
assert_eq!(size(&d, c), (60.0, 20.0));
}
#[test]
fn fill_children_redistribute_when_they_hit_min_or_max() {
let mut d = Document::new("t");
let p = page(&d);
// Inner width 300 after two gaps.
let f = al(&mut d, p, LayoutMode::Horizontal, (320.0, 50.0), (Sizing::Fixed, Sizing::Fixed));
let k: Vec<NodeId> = (0..3).map(|_| rect(&mut d, f, 10.0, 10.0)).collect();
for c in &k {
sizing(&mut d, *c, Sizing::Fill, Sizing::Fixed);
}
let widths = |d: &mut Document| {
layout_frame(d, f, &NoMeasure);
k.iter().map(|c| size(d, *c).0).collect::<Vec<_>>()
};
assert_eq!(widths(&mut d), vec![100.0, 100.0, 100.0]);
// One hits its max: the others share what it leaves, and a second one hitting its max then
// hands the rest to the last.
d.get_mut(k[0]).unwrap().child_layout.max_w = Some(50.0);
assert_eq!(widths(&mut d), vec![50.0, 125.0, 125.0]);
d.get_mut(k[1]).unwrap().child_layout.max_w = Some(100.0);
assert_eq!(widths(&mut d), vec![50.0, 100.0, 150.0]);
// A max and a min at once: the max wins first (the space it leaves lifts the other past its
// min), so the min child isn't frozen at its minimum.
d.get_mut(k[1]).unwrap().child_layout.max_w = None;
d.get_mut(k[1]).unwrap().child_layout.min_w = Some(120.0);
assert_eq!(widths(&mut d), vec![50.0, 125.0, 125.0]);
// Mins that don't fit: they hold, the remaining fill child gets nothing and the row overflows.
d.get_mut(k[0]).unwrap().child_layout.max_w = None;
d.get_mut(k[0]).unwrap().child_layout.min_w = Some(200.0);
d.get_mut(k[1]).unwrap().child_layout.min_w = Some(200.0);
assert_eq!(widths(&mut d), vec![200.0, 200.0, 0.0]);
assert_eq!(pos(&d, k[2]).0, 420.0);
}
#[test]
fn fill_text_wraps_and_the_parent_hugs_its_new_height() {
let mut d = Document::new("t");
let p = page(&d);
// Vertical: the text fills the width (cross axis).
let f = al(&mut d, p, LayoutMode::Vertical, (200.0, 10.0), (Sizing::Fixed, Sizing::Hug));
layout_mut(&mut d, f).padding = [10.0; 4];
let t = fill_text(&mut d, f);
// Horizontal: the text fills the width (main axis) next to a fixed rectangle.
let g = al(&mut d, p, LayoutMode::Horizontal, (200.0, 10.0), (Sizing::Fixed, Sizing::Hug));
rect(&mut d, g, 50.0, 30.0);
let t2 = fill_text(&mut d, g);
let m = FakeMeasure([(t, (300.0, 20.0, 15.0)), (t2, (450.0, 20.0, 15.0))].into_iter().collect());
layout_frame(&mut d, f, &m);
// 180 wide → two lines.
assert_eq!(size(&d, t), (180.0, 40.0));
assert_eq!(size(&d, f), (200.0, 60.0));
layout_frame(&mut d, g, &m);
// 200 - 50 - 10 = 140 wide → four lines.
assert_eq!(size(&d, t2), (140.0, 80.0));
assert_eq!(size(&d, g), (200.0, 80.0));
// Nested: a card that fills a fixed column hugs the text it wraps.
let col = al(&mut d, p, LayoutMode::Vertical, (300.0, 10.0), (Sizing::Fixed, Sizing::Hug));
let card = al(&mut d, col, LayoutMode::Vertical, (1.0, 1.0), (Sizing::Fill, Sizing::Hug));
layout_mut(&mut d, card).padding = [10.0; 4];
let t3 = fill_text(&mut d, card);
let m = FakeMeasure([(t3, (560.0, 20.0, 15.0))].into_iter().collect());
layout_page(&mut d, p, &m);
assert_eq!(size(&d, t3), (280.0, 40.0));
assert_eq!(size(&d, card), (300.0, 60.0));
assert_eq!(size(&d, col), (300.0, 60.0));
}
#[test]
fn three_levels_of_mixed_sizing_settle_in_one_pass() {
let mut d = Document::new("t");
let p = page(&d);
let outer = al(&mut d, p, LayoutMode::Vertical, (400.0, 1.0), (Sizing::Fixed, Sizing::Hug));
layout_mut(&mut d, outer).padding = [10.0; 4];
let header = rect(&mut d, outer, 1.0, 20.0);
sizing(&mut d, header, Sizing::Fill, Sizing::Fixed);
let middle = al(&mut d, outer, LayoutMode::Horizontal, (1.0, 1.0), (Sizing::Fill, Sizing::Hug));
let icon = rect(&mut d, middle, 50.0, 20.0);
let inner = al(&mut d, middle, LayoutMode::Vertical, (1.0, 1.0), (Sizing::Fill, Sizing::Hug));
let bar = rect(&mut d, inner, 1.0, 30.0);
sizing(&mut d, bar, Sizing::Fill, Sizing::Fixed);
let tag = rect(&mut d, inner, 20.0, 40.0);
let side = al(&mut d, middle, LayoutMode::Horizontal, (1.0, 1.0), (Sizing::Hug, Sizing::Hug));
layout_mut(&mut d, side).padding = [5.0; 4];
rect(&mut d, side, 10.0, 10.0);
layout_page(&mut d, p, &NoMeasure);
assert_eq!(size(&d, header), (380.0, 20.0));
assert_eq!(size(&d, side), (20.0, 20.0));
// 380 - icon 50 - side 20 - two gaps.
assert_eq!(pos(&d, inner), (60.0, 0.0, 290.0, 80.0));
assert_eq!(size(&d, bar), (290.0, 30.0));
assert_eq!(pos(&d, tag), (0.0, 40.0, 20.0, 40.0));
assert_eq!(pos(&d, middle), (10.0, 40.0, 380.0, 80.0));
assert_eq!(pos(&d, side).0, 360.0);
assert_eq!(pos(&d, icon), (0.0, 0.0, 50.0, 20.0));
assert_eq!(size(&d, outer), (400.0, 130.0));
// The outer column shrinks: everything follows in one pass.
set_size(&mut d, outer, 300.0, 130.0);
layout_page(&mut d, p, &NoMeasure);
assert_eq!(size(&d, inner), (190.0, 80.0));
assert_eq!(size(&d, bar), (190.0, 30.0));
assert_eq!(pos(&d, side).0, 260.0);
}
#[test]
fn hug_ignores_absolute_and_hidden_children() {
let mut d = Document::new("t");
let p = page(&d);
let outer = al(&mut d, p, LayoutMode::Vertical, (1.0, 1.0), (Sizing::Hug, Sizing::Hug));
let f = al(&mut d, outer, LayoutMode::Horizontal, (1.0, 1.0), (Sizing::Hug, Sizing::Hug));
rect(&mut d, f, 50.0, 20.0);
let hidden = rect(&mut d, f, 100.0, 100.0);
d.get_mut(hidden).unwrap().visible = false;
let b = rect(&mut d, f, 30.0, 10.0);
let abs = d.create(f, NodeKind::Rectangle, "Abs", 300.0, 300.0, 40.0, 40.0).unwrap();
d.get_mut(abs).unwrap().child_layout.absolute = true;
layout_page(&mut d, p, &NoMeasure);
// No gap for the hidden child either.
assert_eq!(size(&d, f), (90.0, 20.0));
assert_eq!(pos(&d, b).0, 60.0);
assert_eq!(size(&d, outer), (90.0, 20.0));
// The absolute child stays where it was.
assert_eq!(pos(&d, abs), (300.0, 300.0, 40.0, 40.0));
}
#[test]
fn space_between_with_one_child_starts_at_the_start() {
let mut d = Document::new("t");
let p = page(&d);
for mode in [LayoutMode::Horizontal, LayoutMode::Vertical] {
let f = al(&mut d, p, mode, (300.0, 300.0), (Sizing::Fixed, Sizing::Fixed));
layout_mut(&mut d, f).padding = [5.0; 4];
layout_mut(&mut d, f).main_align = MainAlign::SpaceBetween;
let a = rect(&mut d, f, 50.0, 50.0);
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(pos(&d, a), (5.0, 5.0, 50.0, 50.0), "{mode:?}");
// A second child goes to the far end.
let b = rect(&mut d, f, 50.0, 50.0);
layout_frame(&mut d, f, &NoMeasure);
let far = if mode == LayoutMode::Horizontal { pos(&d, b).0 } else { pos(&d, b).1 };
assert_eq!(far, 245.0, "{mode:?}");
}
}
#[test]
fn wrap_with_fill_children() {
let mut d = Document::new("t");
let p = page(&d);
// Fixed height: fill height has room to fill (a frame hugging its height reads it as fixed).
let f = al(&mut d, p, LayoutMode::Horizontal, (200.0, 100.0), (Sizing::Fixed, Sizing::Fixed));
layout_mut(&mut d, f).wrap = true;
let a = rect(&mut d, f, 10.0, 20.0);
let b = rect(&mut d, f, 10.0, 20.0);
for c in [a, b] {
sizing(&mut d, c, Sizing::Fill, Sizing::Fixed);
d.get_mut(c).unwrap().child_layout.min_w = Some(80.0);
}
let c = rect(&mut d, f, 50.0, 30.0);
// Fill height in a wrapped row: as tall as the row.
let e = rect(&mut d, f, 40.0, 10.0);
sizing(&mut d, e, Sizing::Fixed, Sizing::Fill);
layout_frame(&mut d, f, &NoMeasure);
// Row 1: the two fill children (at their 80 min they fit, 50 more doesn't) share 190.
assert_eq!(pos(&d, a), (0.0, 0.0, 95.0, 20.0));
assert_eq!(pos(&d, b), (105.0, 0.0, 95.0, 20.0));
// Row 2: the fixed child and the fill-height one, which takes the row's height.
assert_eq!(pos(&d, c), (0.0, 30.0, 50.0, 30.0));
assert_eq!(pos(&d, e), (60.0, 30.0, 40.0, 30.0));
assert_eq!(size(&d, f), (200.0, 100.0));
// A row of nothing but fill-height children keeps their own height.
let g = al(&mut d, p, LayoutMode::Horizontal, (100.0, 200.0), (Sizing::Fixed, Sizing::Fixed));
layout_mut(&mut d, g).wrap = true;
let tall = rect(&mut d, g, 60.0, 50.0);
let lone = rect(&mut d, g, 60.0, 25.0);
sizing(&mut d, lone, Sizing::Fixed, Sizing::Fill);
layout_frame(&mut d, g, &NoMeasure);
assert_eq!(pos(&d, tall), (0.0, 0.0, 60.0, 50.0));
assert_eq!(pos(&d, lone), (0.0, 60.0, 60.0, 25.0));
// Hugging its height, the frame wraps both rows.
d.get_mut(g).unwrap().child_layout.sizing_v = Sizing::Hug;
layout_frame(&mut d, g, &NoMeasure);
assert_eq!(size(&d, g), (100.0, 85.0));
}
#[test]
fn padding_and_included_strokes() {
let mut d = Document::new("t");
let p = page(&d);
let f = al(&mut d, p, LayoutMode::Horizontal, (1.0, 1.0), (Sizing::Hug, Sizing::Hug));
{
let n = d.get_mut(f).unwrap();
n.strokes = vec![figcraft_doc::color::Paint::solid(figcraft_doc::color::Color::BLACK)];
n.stroke.weight = 5.0;
}
layout_mut(&mut d, f).padding = [1.0, 2.0, 3.0, 4.0];
let a = rect(&mut d, f, 50.0, 20.0);
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(size(&d, f), (56.0, 24.0));
assert_eq!(pos(&d, a), (4.0, 1.0, 50.0, 20.0));
layout_mut(&mut d, f).strokes_included = true;
layout_frame(&mut d, f, &NoMeasure);
assert_eq!(size(&d, f), (66.0, 34.0));
assert_eq!(pos(&d, a), (9.0, 6.0, 50.0, 20.0));
// A fixed frame: fill children share the space inside padding and the included stroke.
let g = al(&mut d, p, LayoutMode::Horizontal, (100.0, 50.0), (Sizing::Fixed, Sizing::Fixed));
{
let n = d.get_mut(g).unwrap();
n.strokes = vec![figcraft_doc::color::Paint::solid(figcraft_doc::color::Color::BLACK)];
n.stroke.weight = 5.0;
}
layout_mut(&mut d, g).padding = [10.0; 4];
layout_mut(&mut d, g).strokes_included = true;
let c = rect(&mut d, g, 10.0, 10.0);
sizing(&mut d, c, Sizing::Fill, Sizing::Fill);
layout_frame(&mut d, g, &NoMeasure);
assert_eq!(pos(&d, c), (15.0, 15.0, 70.0, 20.0));
}
#[test]
fn grid_hug_tracks_and_wrapping_text_rows() {
use figcraft_doc::GridTrack;
let mut d = Document::new("t");
let p = page(&d);
let f = al(&mut d, p, LayoutMode::Grid, (300.0, 1.0), (Sizing::Fixed, Sizing::Hug));
{
let l = layout_mut(&mut d, f);
l.grid_columns = 2;
l.grid_rows = 1;
l.column_tracks = vec![GridTrack::hug(), GridTrack::fill(1.0)];
}
let a = rect(&mut d, f, 60.0, 20.0);
let t = fill_text(&mut d, f);
let m = FakeMeasure([(t, (480.0, 20.0, 15.0))].into_iter().collect());
layout_frame(&mut d, f, &m);
// The hug column fits the rectangle; the text wraps in the rest (230) on three lines and the
// hugging row (and frame) grow to fit it in the same pass.
assert_eq!(pos(&d, a), (0.0, 0.0, 60.0, 20.0));
assert_eq!(pos(&d, t), (70.0, 0.0, 230.0, 60.0));
assert_eq!(size(&d, f), (300.0, 60.0));
// A fill child in a hug column asks for its min width.
sizing(&mut d, a, Sizing::Fill, Sizing::Fixed);
d.get_mut(a).unwrap().child_layout.min_w = Some(40.0);
layout_frame(&mut d, f, &m);
assert_eq!(pos(&d, a).2, 40.0);
assert_eq!(pos(&d, t).0, 50.0);
}
#[test]
fn fill_in_a_plain_frame_behaves_as_fixed() {
let mut d = Document::new("t");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "Plain", 0.0, 0.0, 200.0, 100.0).unwrap();
let r = d.create(f, NodeKind::Rectangle, "R", 10.0, 10.0, 50.0, 20.0).unwrap();
sizing(&mut d, r, Sizing::Fill, Sizing::Fill);
layout_page(&mut d, p, &NoMeasure);
assert_eq!(pos(&d, r), (10.0, 10.0, 50.0, 20.0));
// Resizing the frame applies the child's constraints (left/top), not fill.
set_size(&mut d, f, 400.0, 300.0);
apply_constraints(&mut d, f, 200.0, 100.0);
assert_eq!(pos(&d, r), (10.0, 10.0, 50.0, 20.0));
// And the sizing itself is made coherent: fill without an auto layout parent is fixed.
assert!(coherent_sizing(&mut d, r));
let cl = &d.node(r).unwrap().child_layout;
assert_eq!((cl.sizing_h, cl.sizing_v), (Sizing::Fixed, Sizing::Fixed));
}
#[test]
fn coherent_sizing_rules() {
let mut d = Document::new("t");
let p = page(&d);
let f = al(&mut d, p, LayoutMode::Horizontal, (200.0, 50.0), (Sizing::Hug, Sizing::Fixed));
let a = rect(&mut d, f, 10.0, 10.0);
sizing(&mut d, a, Sizing::Fill, Sizing::Fill);
let abs = rect(&mut d, f, 10.0, 10.0);
sizing(&mut d, abs, Sizing::Fill, Sizing::Fixed);
d.get_mut(abs).unwrap().child_layout.absolute = true;
// A plain rectangle can't hug.
let r = rect(&mut d, p, 10.0, 10.0);
sizing(&mut d, r, Sizing::Hug, Sizing::Fixed);
// The parent hugs its width: the child's fill width becomes fixed; its fill height stays.
assert!(coherent_sizing(&mut d, f));
let cl = |d: &Document, id: NodeId| {
let c = &d.node(id).unwrap().child_layout;
(c.sizing_h, c.sizing_v)
};
assert_eq!(cl(&d, a), (Sizing::Fixed, Sizing::Fill));
assert_eq!(cl(&d, abs), (Sizing::Fixed, Sizing::Fixed));
assert_eq!(cl(&d, f), (Sizing::Hug, Sizing::Fixed));
assert!(!coherent_sizing(&mut d, f), "already coherent");
assert!(coherent_sizing(&mut d, r));
assert_eq!(cl(&d, r), (Sizing::Fixed, Sizing::Fixed));
// Text hugs without auto layout; a missing node is a no-op.
let t = text(&mut d, p, "T");
sizing(&mut d, t, Sizing::Hug, Sizing::Hug);
assert!(!coherent_sizing(&mut d, t));
// A text's sizing follows its resize mode: auto height hugs the height, a fixed box neither.
let mode = |d: &mut Document, m: TextAutoResize| {
if let NodeKind::Text(td) = &mut d.get_mut(t).unwrap().kind {
td.auto_resize = m;
}
};
mode(&mut d, TextAutoResize::None);
assert!(coherent_sizing(&mut d, t));
assert_eq!(cl(&d, t), (Sizing::Fixed, Sizing::Fixed));
mode(&mut d, TextAutoResize::Height);
assert!(coherent_sizing(&mut d, t));
assert_eq!(cl(&d, t), (Sizing::Fixed, Sizing::Hug));
mode(&mut d, TextAutoResize::WidthAndHeight);
assert!(coherent_sizing(&mut d, t));
assert_eq!(cl(&d, t), (Sizing::Hug, Sizing::Hug));
assert!(!coherent_sizing(&mut d, NodeId(u64::MAX)));
}