//! FigCraft layout. //! //! - [`layout_page`] / [`layout_frame`]: Figma-style auto layout. Children of an auto layout frame //! are stacked horizontally or vertically (optionally wrapping, optionally aligned on their first //! text baseline) or placed on a grid with fixed / hug / `fr` tracks and cells that span, with //! gap, padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the //! free space), within its min/max size. A Hug container sizes to its children. Absolute //! children (ignoring auto layout) are not placed; they follow their constraints instead. //! - [`coherent_sizing`]: Fill and Hug only where they can apply (Fill in an auto layout flow on an //! axis the parent doesn't hug; Hug on auto layout frames and text), else Fixed. //! - [`apply_constraints`]: children of a regular frame follow their constraints when it resizes. //! - [`grid_lines`]: the column/row rectangles of a layout grid, for drawing and snapping. //! //! Text measurement is injected ([`Measure`]) so this crate doesn't depend on fonts. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use figcraft_doc::{ AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, StrokeAlign, TextAutoResize, VConstraint, }; use figcraft_geom::{Affine, Point, Rect}; /// Most layout calls nested in one another (about one per level of layers): deeper layers don't /// follow a resize, so a deeply nested document can't run the stack out (#210). pub const MAX_NESTING: usize = 256; thread_local! { static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// One level of nested layout calls, released when dropped. struct Nested; impl Nested { /// Enter one more level; `None` when [`MAX_NESTING`] are in progress. fn enter() -> Option { NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self)) } } impl Drop for Nested { fn drop(&mut self) { NESTING.with(|n| n.set(n.get().saturating_sub(1))); } } /// Measures text for a given width (auto-height text inside a Fill container). pub trait Measure { /// The height a text node needs at `width`. fn text_height(&self, doc: &Document, id: NodeId, width: f64) -> Option; /// Natural (auto width, unwrapped) size of a text node, whatever its resize mode. fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)>; /// Distance from the top of a text node's box to its first line's baseline (for baseline /// alignment). `None` when unknown (non-text, no fonts); the item then aligns to the top. fn text_baseline(&self, _doc: &Document, _id: NodeId) -> Option { None } } /// A measurer that never changes text sizes. pub struct NoMeasure; impl Measure for NoMeasure { fn text_height(&self, _: &Document, _: NodeId, _: f64) -> Option { None } fn text_size(&self, _: &Document, _: NodeId) -> Option<(f64, f64)> { None } } /// Clamp a size to `[min, max]` (non-finite or negative sizes become 0; when min > max, min wins). pub fn clamp_size(v: f64, min: Option, max: Option) -> f64 { let mut v = if v.is_finite() { v.max(0.0) } else { 0.0 }; if let Some(m) = max.filter(|m| m.is_finite()) { v = v.min(m); } if let Some(m) = min.filter(|m| m.is_finite()) { v = v.max(m); } v } fn finite_or_zero(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } /// The child's box size as seen by the parent (rotation → bounding box). fn outer_size(doc: &Document, id: NodeId) -> (f64, f64) { let Some(n) = doc.node(id) else { return (0.0, 0.0) }; let b = figcraft_geom::transform_rect_bbox(linear(n.transform), n.local_rect()); (b.width(), b.height()) } fn linear(a: Affine) -> Affine { let c = a.as_coeffs(); Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]) } /// How much a layer's own width and height scale (no skew: it keeps its rotation and flip) when /// its parent's space scales by `kx`, `ky`: how much each of its axes (`xf`'s linear part) /// stretches. A layer turned 90° swaps the two; one turned 45° gets a mix. pub fn axis_factors(xf: Affine, kx: f64, ky: f64) -> (f64, f64) { let [a, b, c, d, _, _] = xf.as_coeffs(); let along = |x: f64, y: f64, fallback: f64| { let len = x.hypot(y); let k = (kx * x).hypot(ky * y) / len; if len > 1e-12 && k.is_finite() { k } else { fallback } }; (along(a, b, kx), along(c, d, ky)) } /// Place `id` so its (rotated) bounding box's top-left is at `(x, y)` in the parent. fn place(doc: &mut Document, id: NodeId, x: f64, y: f64) { let Some(n) = doc.node(id) else { return }; let lin = linear(n.transform); let b = figcraft_geom::transform_rect_bbox(lin, n.local_rect()); let t = Affine::translate((x - b.x0, y - b.y0)) * lin; if let Some(n) = doc.node_mut(id) { n.transform = t; } } /// Resize a node's own box (vectors scale their network to stay inside it). pub fn set_size(doc: &mut Document, id: NodeId, w: f64, h: f64) { let Some(n) = doc.node_mut(id) else { return }; let (w, h) = (if w.is_finite() { w.max(0.0) } else { n.width }, if h.is_finite() { h.max(0.0) } else { n.height }); let (ow, oh) = (n.width, n.height); if (ow - w).abs() < 1e-9 && (oh - h).abs() < 1e-9 { return; } let sx = if ow > 1e-9 { w / ow } else { 1.0 }; let sy = if oh > 1e-9 { h / oh } else { 1.0 }; match &mut n.kind { NodeKind::Vector { network } => network.scale(sx, sy), NodeKind::Text(figcraft_doc::TextData { path: Some(tp), .. }) => tp.scale(sx, sy), _ => {} } n.width = w; n.height = h; } /// Lay out every auto layout frame on a page (children before parents). pub fn layout_page(doc: &mut Document, page: NodeId, m: &dyn Measure) { let order = doc.subtree(page); for id in order.into_iter().rev() { if doc.node(id).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) { layout_frame(doc, id, m); } } } /// Lay out the ancestors of `id` that are auto layout frames (innermost first), and `id` itself. pub fn layout_up(doc: &mut Document, id: NodeId, m: &dyn Measure) { let mut chain = vec![id]; chain.extend(doc.ancestors(id)); for c in chain { if doc.node(c).is_some_and(|n| n.layout.is_some() && n.kind.is_frame_like()) { layout_frame(doc, c, m); } } } /// The children an auto layout frame places: visible and not ignoring auto layout. pub fn flow_children(doc: &Document, frame: NodeId) -> Vec { let Some(f) = doc.node(frame) else { return Vec::new() }; f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect() } /// Make a layer's sizing coherent with where it sits (after an edit). Fill needs room to fill: a /// layer that isn't in an auto layout flow (no auto layout parent, or ignoring auto layout), or /// whose parent hugs that axis, gets Fixed at its current size instead. Hug needs auto layout or /// text; otherwise it is Fixed too. A text hugs what its resize mode grows. The same applies to the /// layer's own children, so a frame that starts to hug (or loses auto layout) fixes the children /// that filled it. Returns whether anything changed. pub fn coherent_sizing(doc: &mut Document, id: NodeId) -> bool { let kids = doc.node(id).map(|n| n.children.clone()).unwrap_or_default(); let mut changed = coherent_own_sizing(doc, id); for c in kids { changed |= coherent_own_sizing(doc, c); } changed } fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool { let Some(n) = doc.node(id) else { return false }; let flow_parent = n.parent.and_then(|p| doc.node(p)).filter(|p| p.layout.is_some() && p.kind.is_frame_like() && !n.child_layout.absolute); let parent_hugs = |horizontal: bool| flow_parent.map(|p| if horizontal { p.child_layout.sizing_h } else { p.child_layout.sizing_v } == Sizing::Hug); let can_hug = (n.layout.is_some() && n.kind.is_frame_like()) || matches!(n.kind, NodeKind::Text(_)); let coherent = |s: Sizing, horizontal: bool| match s { Sizing::Fill if parent_hugs(horizontal) != Some(false) => Sizing::Fixed, Sizing::Hug if !can_hug => Sizing::Fixed, s => s, }; let (mut h, mut v) = (coherent(n.child_layout.sizing_h, true), coherent(n.child_layout.sizing_v, false)); // A text's resize mode is its sizing: auto width hugs both axes, auto height hugs its height, // a fixed box hugs neither. if let NodeKind::Text(t) = &n.kind { let hug_if_fixed = |s: Sizing| if s == Sizing::Fixed { Sizing::Hug } else { s }; let fixed_if_hug = |s: Sizing| if s == Sizing::Hug { Sizing::Fixed } else { s }; (h, v) = match t.auto_resize { TextAutoResize::WidthAndHeight => (hug_if_fixed(h), hug_if_fixed(v)), TextAutoResize::Height => (h, hug_if_fixed(v)), TextAutoResize::None | TextAutoResize::Truncate => (fixed_if_hug(h), fixed_if_hug(v)), }; } if (h, v) == (n.child_layout.sizing_h, n.child_layout.sizing_v) { return false; } let Some(n) = doc.node_mut(id) else { return false }; n.child_layout.sizing_h = h; n.child_layout.sizing_v = v; true } /// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the frame's own /// inside stroke (per side) when strokes are included in the layout; center and outside strokes /// never take up space. fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) { let included = al.strokes_included && f.has_visible_stroke() && f.stroke.align == StrokeAlign::Inside; let stroke = if included { f.stroke.sides.unwrap_or([f.stroke.weight; 4]) } else { [0.0; 4] }; let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).max(0.0)); let [st, sr, sb, sl] = stroke.map(|w| finite_or_zero(w).max(0.0)); (pt + st, pr + sr, pb + sb, pl + sl) } /// Where the first child starts and the step between children along the main axis, from the /// free space (`inner` minus the children's total size). Auto gaps share the free space and never /// go below 0; a single child of "space between" sits at the start. fn main_offsets(align: MainAlign, inner: f64, total: f64, n: usize, gap: f64) -> (f64, f64) { let gaps = gap * n.saturating_sub(1) as f64; let free = inner - total; let k = n.max(1) as f64; match align { MainAlign::Min => (0.0, gap), MainAlign::Center => ((free - gaps) / 2.0, gap), MainAlign::Max => (free - gaps, gap), MainAlign::SpaceBetween => (0.0, if n > 1 { (free / (n - 1) as f64).max(0.0) } else { 0.0 }), // Gaps are twice the edge space: n gaps of 2e fill the free space. MainAlign::SpaceAround => { let g = (free / k).max(0.0); (g / 2.0, g) } MainAlign::SpaceEvenly => { let g = (free / (k + 1.0)).max(0.0); (g, g) } } } /// Text that hugs its width: refresh its natural size, honouring min/max width by wrapping (a /// hugging text wider than its max width wraps at the max; narrower than its min width gets the min). fn fit_hug_text(doc: &mut Document, id: NodeId, m: &dyn Measure) { let Some(n) = doc.node(id) else { return }; let NodeKind::Text(t) = &n.kind else { return }; let cl = &n.child_layout; let auto_width = t.auto_resize == TextAutoResize::WidthAndHeight; // A hugging text that wrapped at its max width earlier is auto height now. let wrapped_hug = cl.sizing_h == Sizing::Hug && t.auto_resize == TextAutoResize::Height; if !auto_width && !wrapped_hug { return; } let (min_w, max_w) = (cl.min_w, cl.max_w); let Some((nw, nh)) = m.text_size(doc, id) else { return }; let w = clamp_size(nw, min_w, max_w); let limited = (w - nw).abs() > 1e-6; let h = if limited { m.text_height(doc, id, w).unwrap_or(nh) } else { nh }; if let Some(n) = doc.node_mut(id) && let NodeKind::Text(t) = &mut n.kind { let mode = if limited { TextAutoResize::Height } else { TextAutoResize::WidthAndHeight }; if t.auto_resize != mode { t.auto_resize = mode; } } set_size(doc, id, w, h); } /// Clamp an in-flow child's own size to its min/max (fill axes are clamped when distributed). fn enforce_limits(doc: &mut Document, id: NodeId, m: &dyn Measure) { let Some(n) = doc.node(id) else { return }; let cl = &n.child_layout; if cl.min_w.is_none() && cl.max_w.is_none() && cl.min_h.is_none() && cl.max_h.is_none() { return; } let (w, h) = (n.width, n.height); let (nw, nh) = (clamp_size(w, cl.min_w, cl.max_w), clamp_size(h, cl.min_h, cl.max_h)); if (nw - w).abs() < 1e-9 && (nh - h).abs() < 1e-9 { return; } if matches!(n.kind, NodeKind::Text(_)) { // Keep the text's resize mode (hugging width was handled by `fit_hug_text`). set_size(doc, id, nw, nh); } else { resize_child(doc, id, nw, nh, m); } } /// Distance from the top of a child's box to its first text baseline: text measures it, an auto /// layout frame uses its first placed child's baseline. `None` for other layers and rotated ones. fn item_baseline(doc: &Document, id: NodeId, m: &dyn Measure, depth: usize) -> Option { let n = doc.node(id)?; let c = n.transform.as_coeffs(); if c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0 { return None; } match &n.kind { NodeKind::Text(_) => m.text_baseline(doc, id).filter(|b| b.is_finite()), _ if n.layout.is_some() && n.kind.is_frame_like() && depth < 32 => flow_children(doc, id).into_iter().find_map(|k| { let b = item_baseline(doc, k, m, depth + 1)?; let kn = doc.node(k)?; let top = figcraft_geom::transform_rect_bbox(kn.transform, kn.local_rect()).y0; Some(top + b) }), _ => None, } } struct Item { id: NodeId, main: f64, cross: f64, fill_main: bool, fill_cross: bool, min_main: Option, max_main: Option, min_cross: Option, max_cross: Option, /// Baseline alignment: distance from the item's top to its first baseline. baseline: Option, /// Baseline alignment: offset from the line's top (computed per line). base_off: f64, } /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(frame) else { return }; let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug); let (ow, oh) = (f.width, f.height); // The frame's own min/max apply whatever its sizing; it is never smaller than its padding. let fcl = f.child_layout.clone(); let (pt, pr, pb, pl) = insets(f, &al); let (fw, fh) = (clamp_size(ow, fcl.min_w, fcl.max_w).max(pl + pr), clamp_size(oh, fcl.min_h, fcl.max_h).max(pt + pb)); let kids = flow_children(doc, frame); // Text children refresh their natural size, then every child honours its min/max. for c in &kids { fit_hug_text(doc, *c, m); enforce_limits(doc, *c, m); } if matches!(al.mode, LayoutMode::Grid) { layout_grid(doc, frame, &al, m); return; } let inner_main_fixed = if horizontal { fw - pl - pr } else { fh - pt - pb }; let inner_cross_fixed = if horizontal { fh - pt - pb } else { fw - pl - pr }; let hug_main = if horizontal { hug_w } else { hug_h }; let hug_cross = if horizontal { hug_h } else { hug_w }; let baseline = horizontal && al.cross_align == CrossAlign::Baseline; let mut items: Vec = Vec::with_capacity(kids.len()); for c in &kids { let Some(n) = doc.node(*c) else { continue }; let (w, h) = outer_size(doc, *c); let cl = &n.child_layout; let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) }; let (min_main, max_main, min_cross, max_cross) = if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) }; items.push(Item { id: *c, main: if horizontal { w } else { h }, cross: if horizontal { h } else { w }, // Fill needs room to fill: on an axis the frame hugs it reads as fixed. fill_main: sm == Sizing::Fill && !hug_main, fill_cross: sc == Sizing::Fill && !hug_cross, min_main, max_main, min_cross, max_cross, baseline: None, base_off: 0.0, }); } let gap = finite_or_zero(al.gap); let auto_gap = al.main_align.is_auto_gap(); let wraps = al.wrap && !hug_main; // Fill across a single line: the size is the frame's inner cross size, known up front. Resize // those children first, so text that wraps (or a frame hugging it) reports its final main size. if !wraps { for it in items.iter_mut().filter(|it| it.fill_cross && !it.fill_main) { let cross = clamp_size(inner_cross_fixed, it.min_cross, it.max_cross); let (w, h) = if horizontal { (it.main, cross) } else { (cross, it.main) }; resize_child(doc, it.id, w, h, m); let (w, h) = outer_size(doc, it.id); it.main = if horizontal { w } else { h }; } } // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one). let mut lines: Vec> = Vec::new(); if wraps { let mut cur: Vec = Vec::new(); let mut used = 0.0; for (i, it) in items.iter().enumerate() { let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main }; let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz }; if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 { lines.push(std::mem::take(&mut cur)); used = sz; } else { used += add; } cur.push(i); } if !cur.is_empty() { lines.push(cur); } } else { lines.push((0..items.len()).collect()); } // Share each line's free space among its fill children. for line in &lines { let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum(); let gaps = gap * line.len().saturating_sub(1) as f64; let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect(); if !fills.is_empty() { distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps }); } } // Fill children take their main size now, so their cross size (wrapped text, a hugging frame) // is final before the lines and the frame are sized. for it in items.iter_mut().filter(|it| it.fill_main) { let cross = if it.fill_cross && !wraps { clamp_size(inner_cross_fixed, it.min_cross, it.max_cross) } else { it.cross }; let (w, h) = if horizontal { (it.main, cross) } else { (cross, it.main) }; resize_child(doc, it.id, w, h, m); let (w, h) = outer_size(doc, it.id); it.cross = if horizontal { h } else { w }; } if baseline { for it in items.iter_mut().filter(|it| !it.fill_cross) { it.baseline = item_baseline(doc, it.id, m, 0); } } // Line extents: (main used, cross size). let mut line_extents: Vec<(f64, f64)> = Vec::new(); for line in &lines { let gaps = gap * line.len().saturating_sub(1) as f64; // Baseline alignment: the line's baseline sits at the deepest first baseline. let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max); for i in line { if let Some(it) = items.get_mut(*i) { it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0); } } let in_line = || line.iter().filter_map(|i| items.get(*i)); let main_used: f64 = in_line().map(|it| it.main).sum::() + gaps; // A line of nothing but fill-across children keeps their own size. let all_fill = in_line().all(|it| it.fill_cross); let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max); line_extents.push((main_used, cross)); } // Container size (on a hugging axis fill children can't fill: they count with their own size). let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max); let cross_gap = finite_or_zero(al.cross_gap); let content_cross: f64 = line_extents.iter().map(|l| l.1).sum::() + cross_gap * lines.len().saturating_sub(1) as f64; let (mut nw, mut nh) = (fw, fh); if hug_main { let v = content_main + if horizontal { pl + pr } else { pt + pb }; if horizontal { nw = v; } else { nh = v; } } if hug_cross { let v = content_cross + if horizontal { pt + pb } else { pl + pr }; if horizontal { nh = v; } else { nw = v; } } let (nw, nh) = (clamp_size(nw, fcl.min_w, fcl.max_w).max(pl + pr), clamp_size(nh, fcl.min_h, fcl.max_h).max(pt + pb)); set_size(doc, frame, nw, nh); apply_constraints(doc, frame, ow, oh); let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb }; let inner_cross = if horizontal { nh - pt - pb } else { nw - pl - pr }; // Position. Several lines share the free space across the flow: packed lines sit where the // cross alignment says (lines of nothing but fill children stretch over it instead), or the // space goes between them. let mut cross_pos = if horizontal { pt } else { pl }; let single = lines.len() == 1; let mut cross_gap = cross_gap; if lines.len() > 1 { let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum(); let between = (lines.len() - 1) as f64; let spread = (inner_cross - lines_cross) / between; if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 { cross_gap = spread; } else { if al.content_align == ContentAlign::SpaceBetween { cross_gap = 0.0; } let free = inner_cross - lines_cross - cross_gap * between; if free > 0.0 && items.iter().all(|it| it.fill_cross) { let extra = free / lines.len() as f64; line_extents.iter_mut().for_each(|l| l.1 += extra); } else { cross_pos += match al.cross_align { CrossAlign::Min | CrossAlign::Baseline => 0.0, CrossAlign::Center => free / 2.0, CrossAlign::Max => free, }; } } } for (li, line) in lines.iter().enumerate() { let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0)); let line_cross = if single { inner_cross } else { line_cross }; let total = used - gap * line.len().saturating_sub(1) as f64; let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap); pos += if horizontal { pl } else { pt }; for i in line { let Some(it) = items.get(*i) else { continue }; let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross }; let off = match al.cross_align { CrossAlign::Min => 0.0, // Baseline applies to horizontal layouts; elsewhere it reads as top/left. CrossAlign::Baseline => it.base_off, CrossAlign::Center => (line_cross - cross_size) / 2.0, CrossAlign::Max => line_cross - cross_size, }; // Fill across a wrapped line: its size is known only now. if it.fill_cross && wraps { let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) }; resize_child(doc, it.id, w, h, m); } let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) }; place(doc, it.id, x, y); pos += it.main + step_gap; } cross_pos += line_cross + cross_gap; } } /// Share `free` main-axis space among the fill items `open` like CSS flexbox: each takes an equal /// share within its min/max; when clamping adds more than it removes, the items held at their min /// keep it (otherwise those held at their max do) and the rest share what is left again. fn distribute(items: &mut [Item], mut open: Vec, free: f64) { let mut free = free.max(0.0); while !open.is_empty() { let share = free / open.len() as f64; let sized: Vec<(usize, f64)> = open.iter().filter_map(|i| items.get(*i).map(|it| (*i, clamp_size(share, it.min_main, it.max_main)))).collect(); let violation: f64 = sized.iter().map(|(_, v)| v - share).sum(); let frozen = |v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > share + 1e-9) || (violation < 0.0 && v < share - 1e-9); for (i, v) in &sized { if frozen(*v) && let Some(it) = items.get_mut(*i) { it.main = *v; free -= *v; } } free = free.max(0.0); open = sized.into_iter().filter(|(_, v)| !frozen(*v)).map(|(i, _)| i).collect(); } } fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) { let is_text_height = doc .node(id) .is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.auto_resize != TextAutoResize::None && t.auto_resize != TextAutoResize::Truncate)); if is_text_height { if let Some(n) = doc.node_mut(id) && let NodeKind::Text(t) = &mut n.kind && t.auto_resize == TextAutoResize::WidthAndHeight { t.auto_resize = TextAutoResize::Height; } let th = m.text_height(doc, id, w).unwrap_or(h); set_size(doc, id, w, th); } else { let old = doc.node(id).map(|n| (n.width, n.height)); set_size(doc, id, w, h); if let Some((ow, oh)) = old && doc.node(id).is_some_and(|n| n.kind.is_frame_like()) { apply_constraints(doc, id, ow, oh); } if doc.node(id).is_some_and(|n| n.layout.is_some()) { layout_frame(doc, id, m); } } } /// Largest grid (cells) the layout handles; rows beyond it overlap the last row. const MAX_GRID_CELLS: usize = 1_000_000; const MAX_TRACKS: usize = 1000; /// A grid auto layout child's cell (0-based anchor column/row and spans, clamped to the grid). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct GridCell { pub id: NodeId, pub column: usize, pub row: usize, pub column_span: usize, pub row_span: usize, } /// A grid auto layout frame's resolved geometry (frame-local): column x ranges, row y ranges, /// each placed child's cell, and the frame size the grid wants. #[derive(Clone, Debug, PartialEq)] pub struct GridGeometry { pub columns: Vec<(f64, f64)>, pub rows: Vec<(f64, f64)>, pub cells: Vec, pub width: f64, pub height: f64, } impl GridGeometry { /// The frame-local rectangle a cell (with its spans) covers. pub fn cell_rect(&self, c: &GridCell) -> Rect { let x0 = self.columns.get(c.column).map(|t| t.0).unwrap_or(0.0); let x1 = self.columns.get(c.column + c.column_span - 1).map(|t| t.1).unwrap_or(x0); let y0 = self.rows.get(c.row).map(|t| t.0).unwrap_or(0.0); let y1 = self.rows.get(c.row + c.row_span - 1).map(|t| t.1).unwrap_or(y0); Rect::new(x0, y0, x1.max(x0), y1.max(y0)) } } /// Place grid children in cells: children with an anchor column and row go there first; the rest /// flow left to right, top to bottom into the next free cells that fit their spans. Returns the /// cells (in child order) and the row count (at least the frame's row count). pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec, usize) { let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS); let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS); let mut occ: Vec> = Vec::new(); let free = |occ: &Vec>, c: usize, r: usize, cs: usize, rs: usize| { (r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false)))) }; let mark = |occ: &mut Vec>, c: usize, r: usize, cs: usize, rs: usize| { while occ.len() < r + rs { occ.push(vec![false; cols]); } for row in occ.iter_mut().skip(r).take(rs) { for cell in row.iter_mut().skip(c).take(cs) { *cell = true; } } }; let spans = |id: NodeId| { let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default(); let cs = (cl.column_span() as usize).min(cols); let rs = (cl.row_span() as usize).min(max_rows); (cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs) }; let mut placed: Vec> = vec![None; kids.len()]; // 1. Fully anchored children. for (i, id) in kids.iter().enumerate() { if let (Some(c), Some(r), cs, rs) = spans(*id) { mark(&mut occ, c, r, cs, rs); if let Some(slot) = placed.get_mut(i) { *slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs }); } } } // 2. The rest, in order (a cursor keeps auto-placed children in reading order). let mut cursor = (0usize, 0usize); // (row, column) for (i, id) in kids.iter().enumerate() { if placed.get(i).is_some_and(Option::is_some) { continue; } let (col, row, cs, rs) = spans(*id); let last_row = max_rows - rs; let (c, r) = match (col, row) { (Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)), (None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)), (None, None) => { let mut found = None; let (mut r, mut c) = cursor; while r <= last_row { if c + cs <= cols && free(&occ, c, r, cs, rs) { found = Some((c, r)); break; } c += 1; if c + cs > cols { c = 0; r += 1; } } let (c, r) = found.unwrap_or((0, last_row)); cursor = (r, c + cs); (c, r) } }; mark(&mut occ, c, r, cs, rs); if let Some(slot) = placed.get_mut(i) { *slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs }); } } let cells: Vec = placed.into_iter().flatten().collect(); let rows = cells.iter().map(|c| c.row + c.row_span).max().unwrap_or(0).max((al.grid_rows as usize).clamp(1, MAX_TRACKS)); (cells, rows) } /// Track `i`'s sizing and sanitised value (missing tracks are `1fr`). fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) { let t = specs.get(i).copied().unwrap_or_default(); let v = if t.value.is_finite() { t.value.clamp(0.0, 1e6) } else { 0.0 }; (t.sizing, v) } /// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks /// share what is left by their fractional units like the CSS `fr` unit (a track never gets less /// than its content: one whose share is too small keeps its content size and the others share the /// rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too). fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option) -> Vec { let mut out: Vec = (0..content.len()) .map(|i| match track_spec(specs, i) { (Sizing::Fixed, v) => v, _ => content.get(i).copied().unwrap_or(0.0), }) .collect(); let Some(a) = avail else { return out }; let fr = |i: usize| track_spec(specs, i).1; let base = |out: &[f64], i: usize| out.get(i).copied().unwrap_or(0.0); let (mut flex, fixed): (Vec, Vec) = (0..out.len()).partition(|i| track_spec(specs, *i).0 == Sizing::Fill); let mut used: f64 = fixed.iter().map(|i| base(&out, *i)).sum(); // Each round either sizes every flexible track or freezes at least one at its content size. while !flex.is_empty() { let total: f64 = flex.iter().map(|i| fr(*i)).sum(); let unit = if total > 1e-12 { (a - used).max(0.0) / total } else { 0.0 }; let (open, frozen): (Vec, Vec) = flex.iter().partition(|i| unit * fr(**i) >= base(&out, **i)); if frozen.is_empty() { for i in open { if let Some(s) = out.get_mut(i) { *s = unit * fr(i); } } break; } used += frozen.iter().map(|i| base(&out, *i)).sum::(); flex = open; } out } /// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content): the /// largest single-span child (for a fill track, the least it can shrink to); a spanning child that /// needs more than its tracks give it grows the content-sized tracks it spans (unless a flexible /// track can absorb it). fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridTrack], n: usize, gap: f64, columns: bool, hug: bool) -> Vec { let by_content = |i: usize| track_spec(specs, i).0 != Sizing::Fixed; let mut content: Vec = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect(); let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) }; for (c, s) in cells.iter().zip(sizes) { let (start, len) = span(c); if len == 1 && by_content(start) && let Some(t) = content.get_mut(start) { *t = t.max(*s); } } for (c, s) in cells.iter().zip(sizes) { let (start, len) = span(c); if len < 2 || (!hug && (start..start + len).any(|i| track_spec(specs, i).0 == Sizing::Fill)) { continue; } let have: f64 = content.iter().skip(start).take(len).sum::() + gap * (len - 1) as f64; let grow: Vec = (start..start + len).filter(|i| by_content(*i)).collect(); if *s > have && !grow.is_empty() { let extra = (*s - have) / grow.len() as f64; for i in grow { if let Some(t) = content.get_mut(i) { *t += extra; } } } } content } fn track_ranges(start: f64, sizes: &[f64], gap: f64) -> Vec<(f64, f64)> { let mut x = start; sizes .iter() .map(|s| { let r = (x, x + s); x += s + gap; r }) .collect() } /// Resolve a grid auto layout frame's tracks and cells from its children's current sizes. pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option { let f = doc.node(frame)?; let al = f.layout.as_ref()?; if al.mode != LayoutMode::Grid { return None; } let (pt, pr, pb, pl) = insets(f, al); let kids = flow_children(doc, frame); let (cells, rows) = grid_place(doc, al, &kids); let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS); let gx = finite_or_zero(al.gap).max(0.0); let gy = finite_or_zero(al.cross_gap).max(0.0); let hug_w = f.child_layout.sizing_h == Sizing::Hug; let hug_h = f.child_layout.sizing_v == Sizing::Hug; // What each child asks of its tracks: its size, or (filling a non-hugging axis) its minimum. let (mut ws, mut hs) = (Vec::with_capacity(cells.len()), Vec::with_capacity(cells.len())); for c in &cells { let (w, h) = outer_size(doc, c.id); let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default(); ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w }); hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h }); } let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w); let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h); let gaps_w = gx * cols.saturating_sub(1) as f64; let gaps_h = gy * rows.saturating_sub(1) as f64; let avail_w = (!hug_w).then_some(f.width - pl - pr - gaps_w); let avail_h = (!hug_h).then_some(f.height - pt - pb - gaps_h); let col_w = resolve_tracks(&al.column_tracks, &col_content, avail_w); let row_h = resolve_tracks(&al.row_tracks, &row_content, avail_h); let cl = &f.child_layout; let width = if hug_w { col_w.iter().sum::() + gaps_w + pl + pr } else { f.width }; let height = if hug_h { row_h.iter().sum::() + gaps_h + pt + pb } else { f.height }; Some(GridGeometry { columns: track_ranges(pl, &col_w, gx), rows: track_ranges(pt, &row_h, gy), cells, width: clamp_size(width, cl.min_w, cl.max_w).max(pl + pr), height: clamp_size(height, cl.min_h, cl.max_h).max(pt + pb), }) } fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measure) { let Some(f) = doc.node(frame) else { return }; let (fw, fh) = (f.width, f.height); let fcl = f.child_layout.clone(); let (hug_w, hug_h) = (fcl.sizing_h == Sizing::Hug, fcl.sizing_v == Sizing::Hug); // A fixed axis takes its own min/max (and the padding floor) first, so the tracks see the // final size. let (pt, pr, pb, pl) = insets(f, al); let w = if hug_w { fw } else { clamp_size(fw, fcl.min_w, fcl.max_w).max(pl + pr) }; let h = if hug_h { fh } else { clamp_size(fh, fcl.min_h, fcl.max_h).max(pt + pb) }; set_size(doc, frame, w, h); let Some(g) = grid_geometry(doc, frame) else { return }; // Fill-width children take their column width before the rows are sized, so text that wraps // (or a frame hugging it) gives its row its final height. let mut widened = false; for c in &g.cells { let Some(n) = doc.node(c.id) else { continue }; let cl = &n.child_layout; if cl.sizing_h == Sizing::Fill && !hug_w { let nw = clamp_size(g.cell_rect(c).width(), cl.min_w, cl.max_w); let (_, h) = outer_size(doc, c.id); resize_child(doc, c.id, nw, h, m); widened = true; } } let g = if widened { grid_geometry(doc, frame).unwrap_or(g) } else { g }; set_size(doc, frame, g.width, g.height); apply_constraints(doc, frame, fw, fh); // The frame's alignment in each cell; a child's own alignment wins. let frame_h = match al.main_align { MainAlign::Center => CellAlign::Center, MainAlign::Max => CellAlign::Max, _ => CellAlign::Min, }; let frame_v = match al.cross_align { CrossAlign::Center => CellAlign::Center, CrossAlign::Max => CellAlign::Max, _ => CellAlign::Min, }; let offset = |a: CellAlign, free: f64| match a { CellAlign::Min => 0.0, CellAlign::Center => free / 2.0, CellAlign::Max => free, }; for c in &g.cells { let cell = g.cell_rect(c); let Some(n) = doc.node(c.id) else { continue }; let cl = n.child_layout.clone(); let fill_w = cl.sizing_h == Sizing::Fill && !hug_w; let fill_h = cl.sizing_v == Sizing::Fill && !hug_h; let (w, h) = outer_size(doc, c.id); let nw = if fill_w { clamp_size(cell.width(), cl.min_w, cl.max_w) } else { w }; let nh = if fill_h { clamp_size(cell.height(), cl.min_h, cl.max_h) } else { h }; if fill_w || fill_h { resize_child(doc, c.id, nw, nh, m); } let (nw, nh) = outer_size(doc, c.id); let ox = offset(cl.grid_align_h.unwrap_or(frame_h), cell.width() - nw); let oy = offset(cl.grid_align_v.unwrap_or(frame_v), cell.height() - nh); place(doc, c.id, cell.x0 + ox, cell.y0 + oy); } } /// After frame `id` changed size from `(ow, oh)`, move/resize its children per their constraints /// (recursively for children frames that resize). In an auto layout frame only the children that /// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(id) else { return }; let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); if (nw - ow).abs() < 1e-9 && (nh - oh).abs() < 1e-9 { return; } let sx = if ow > 1e-9 { nw / ow } else { 1.0 }; let sy = if oh > 1e-9 { nh / oh } else { 1.0 }; let kids = f.children.clone(); // Group children follow the group's own bounds; constraints apply to frames' children. if f.kind.bounds_follow_children() { return; } for c in kids { let Some(n) = doc.node(c) else { continue }; if absolute_only && !n.child_layout.absolute { continue; } let lin = linear(n.transform); let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); let (cw, ch) = (n.width, n.height); let (bw, bh) = (b.width(), b.height()); let (mut x0, mut x1) = (b.x0, b.x1); let (mut y0, mut y1) = (b.y0, b.y1); match n.constraints.horizontal { HConstraint::Left => {} HConstraint::Right => { x0 += nw - ow; x1 += nw - ow; } HConstraint::LeftRight => x1 += nw - ow, HConstraint::Center => { let d = (nw - ow) / 2.0; x0 += d; x1 += d; } HConstraint::Scale => { x0 *= sx; x1 *= sx; } } match n.constraints.vertical { VConstraint::Top => {} VConstraint::Bottom => { y0 += nh - oh; y1 += nh - oh; } VConstraint::TopBottom => y1 += nh - oh, VConstraint::Center => { let d = (nh - oh) / 2.0; y0 += d; y1 += d; } VConstraint::Scale => { y0 *= sy; y1 *= sy; } } // New local size from the bbox change, along the child's own axes (axis-aligned children, // turned or flipped, exactly; others approximately, never skewed). let (kw, kh) = axis_factors(lin, if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 }, if bh > 1e-9 { (y1 - y0) / bh } else { 1.0 }); let (new_w, new_h) = (cw * kw, ch * kh); let resized = (new_w - cw).abs() > 1e-9 || (new_h - ch).abs() > 1e-9; if resized { set_size(doc, c, new_w, new_h); if let Some(cn) = doc.node_mut(c) { cn.transform = lin; } } place(doc, c, x0, y0); if resized && doc.node(c).is_some_and(|n| n.kind.is_frame_like() || n.kind.bounds_follow_children()) { if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) { scale_group_children(doc, c, kw, kh); } else { apply_constraints(doc, c, cw, ch); } } } } /// Scale a group's children when the group is resized (groups scale their contents). /// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its /// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { let Some(_nested) = Nested::enter() else { return }; let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; let center = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()).center(); let (w, h) = (cn.width, cn.height); let (kw, kh) = axis_factors(cn.transform, kx, ky); set_size(doc, c, w * kw, h * kh); let (nw, nh) = outer_size(doc, c); place(doc, c, center.x * kx - nw / 2.0, center.y * ky - nh / 2.0); if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) { scale_group_children(doc, c, kw, kh); } else if doc.node(c).is_some_and(|n| n.kind.is_frame_like()) { apply_constraints(doc, c, w, h); } } } /// The rectangles (frame-local) a layout grid draws: columns, rows or the square grid's lines. pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { let mut out = Vec::new(); match g.kind { GridKind::Grid => { let s = if g.size.is_finite() && g.size >= 1.0 { g.size } else { 10.0 }; let mut x = 0.0; let mut guard = 0; while x <= w && guard < 4000 { out.push(Rect::new(x, 0.0, x, h)); x += s; guard += 1; } let mut y = 0.0; guard = 0; while y <= h && guard < 4000 { out.push(Rect::new(0.0, y, w, y)); y += s; guard += 1; } } GridKind::Columns | GridKind::Rows => { let cols = g.kind == GridKind::Columns; let total = if cols { w } else { h }; let gutter = if g.gutter.is_finite() { g.gutter.max(0.0) } else { 0.0 }; let offset = if g.offset.is_finite() { g.offset.max(0.0) } else { 0.0 }; let count = match g.count { // Auto: as many fixed-size columns (rows) as fit in the room the type leaves them. 0 if g.align != GridAlign::Stretch => { let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; let room = if g.align == GridAlign::Center { total } else { total - offset }; ((room + gutter) / (size + gutter)).floor().clamp(1.0, 500.0) as usize } n => n.clamp(1, 500) as usize, }; let (start, size) = match g.align { GridAlign::Stretch => { let size = ((total - 2.0 * offset - gutter * (count - 1) as f64) / count as f64).max(0.0); (offset, size) } _ => { let size = if g.size.is_finite() && g.size > 0.0 { g.size } else { 60.0 }; let span = size * count as f64 + gutter * (count - 1) as f64; let start = match g.align { GridAlign::Min => offset, GridAlign::Center => (total - span) / 2.0, _ => total - offset - span, }; (start, size) } }; for i in 0..count { let a = start + i as f64 * (size + gutter); out.push(if cols { Rect::new(a, 0.0, a + size, h) } else { Rect::new(0.0, a, w, a + size) }); } } } out } /// Whether a frame's layout grids apply: they don't show (or snap) on a frame turned off 0°. pub fn grids_shown(doc: &Document, frame: NodeId) -> bool { figcraft_geom::rotation_degrees(doc.world_transform(frame)).abs() < 1e-6 } /// Snap targets of a frame's visible layout grids (page coordinates): column/row edges. pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec, Vec) { grid_snap_lines_in(doc, frame, doc.world_transform(frame)) } /// [`grid_snap_lines`] mapped from the frame's coordinates by `xf` (the frame's world transform /// for page lines). pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec, Vec) { let Some(f) = doc.node(frame).filter(|_| grids_shown(doc, frame)) else { return (vec![], vec![]) }; let mut xs = Vec::new(); let mut ys = Vec::new(); for g in f.grids.iter().filter(|g| g.visible) { for r in grid_lines(g, f.width, f.height) { let a = xf * Point::new(r.x0, r.y0); let b = xf * Point::new(r.x1, r.y1); match g.kind { GridKind::Columns => { xs.push(a.x); xs.push(b.x); } GridKind::Rows => { ys.push(a.y); ys.push(b.y); } GridKind::Grid => { if (r.x0 - r.x1).abs() < 1e-9 { xs.push(a.x); } else { ys.push(a.y); } } } } } (xs, ys) } #[cfg(test)] mod behaviour_tests; #[cfg(test)] mod content_align_tests; #[cfg(test)] mod depth_tests; #[cfg(test)] mod nested_tests; #[cfg(test)] mod tests;