mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
- Min/max width/height: the W/H sizing menus add min/max (at the current size) and remove them;
limit rows edit or add the missing bound. The layout clamps Fixed, Hug and Fill children and
the frame itself; a hugging text wider than its max width wraps at the max. min > max is kept
ordered by node.set (the other bound follows); in the layout min wins.
- Ignore auto layout (absolute position): a toggle in the Position section; the child stays
where it is, can't Fill, shows constraints and follows them when the auto layout frame resizes
(by hugging, filling or a direct resize).
- Text baseline alignment for horizontal layouts ("Align text baselines"; crossAlign baseline):
first-line baselines line up, other layers align to the top, nested auto layout frames use
their first child's baseline. Measure gains text_baseline.
- Grid auto layout: column/row tracks (fixed px, hug, fr fill) via columnTracks / rowTracks or
layout.setTrack, cells anchored by gridColumn / gridRow with gridColumnSpan / gridRowSpan,
auto placement in reading order, rows grow to fit, hugging grids size to their tracks.
Panel: column/row gap, track size rows, a child's cell and spans. Old files load unchanged
(serde defaults; missing tracks are 1fr).
- Canvas handles: wrapped-row gaps and grid column/row gaps are draggable; a selected grid's
tracks are outlined.
- The layout section moved from right.rs to layout_panel.rs; inspect reports min/max and a
grid child's resolved gridCell; docs list the new props.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
911 lines
38 KiB
Rust
911 lines
38 KiB
Rust
//! 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.
|
|
//! - [`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, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize,
|
|
VConstraint,
|
|
};
|
|
use figcraft_geom::{Affine, Point, Rect};
|
|
|
|
/// 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<f64>;
|
|
/// 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<f64> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// A measurer that never changes text sizes.
|
|
pub struct NoMeasure;
|
|
|
|
impl Measure for NoMeasure {
|
|
fn text_height(&self, _: &Document, _: NodeId, _: f64) -> Option<f64> {
|
|
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<f64>, max: Option<f64>) -> 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])
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
if let NodeKind::Vector { network } = &mut n.kind {
|
|
let sx = if ow > 1e-9 { w / ow } else { 1.0 };
|
|
let sy = if oh > 1e-9 { h / oh } else { 1.0 };
|
|
network.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<NodeId> {
|
|
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()
|
|
}
|
|
|
|
/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the stroke weight
|
|
/// when strokes are included in the layout.
|
|
fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
|
|
let stroke_pad = if al.strokes_included && f.has_visible_stroke() { finite_or_zero(f.stroke.weight).max(0.0) } else { 0.0 };
|
|
let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 });
|
|
(pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad)
|
|
}
|
|
|
|
/// 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<f64> {
|
|
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<f64>,
|
|
max_main: Option<f64>,
|
|
min_cross: Option<f64>,
|
|
max_cross: Option<f64>,
|
|
/// Baseline alignment: distance from the item's top to its first baseline.
|
|
baseline: Option<f64>,
|
|
/// 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(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.
|
|
let fcl = f.child_layout.clone();
|
|
let (fw, fh) = (clamp_size(ow, fcl.min_w, fcl.max_w), clamp_size(oh, fcl.min_h, fcl.max_h));
|
|
let (pt, pr, pb, pl) = insets(f, &al);
|
|
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 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<Item> = 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) };
|
|
let fill_cross = sc == Sizing::Fill && !hug_cross;
|
|
items.push(Item {
|
|
id: *c,
|
|
main: if horizontal { w } else { h },
|
|
cross: if horizontal { h } else { w },
|
|
fill_main: sm == Sizing::Fill && !hug_main,
|
|
fill_cross,
|
|
min_main,
|
|
max_main,
|
|
min_cross,
|
|
max_cross,
|
|
baseline: if baseline && !fill_cross { item_baseline(doc, *c, m, 0) } else { None },
|
|
base_off: 0.0,
|
|
});
|
|
}
|
|
|
|
let gap = finite_or_zero(al.gap);
|
|
let between = al.main_align == MainAlign::SpaceBetween;
|
|
|
|
// Split into lines (wrap only on the main axis of a horizontal layout, like Figma).
|
|
let mut lines: Vec<Vec<usize>> = Vec::new();
|
|
if al.wrap && !hug_main {
|
|
let mut cur: Vec<usize> = 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());
|
|
}
|
|
|
|
// Resolve main sizes per line.
|
|
let mut line_extents: Vec<(f64, f64)> = Vec::new(); // (main used, cross size)
|
|
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<usize> = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
|
|
if !fills.is_empty() {
|
|
let mut free = (inner_main_fixed - fixed - if between { 0.0 } else { gaps }).max(0.0);
|
|
let mut open: Vec<usize> = fills.clone();
|
|
// Distribute, honouring min/max (a few rounds).
|
|
for _ in 0..4 {
|
|
if open.is_empty() {
|
|
break;
|
|
}
|
|
let share = free / open.len() as f64;
|
|
let mut still = Vec::new();
|
|
let mut used = 0.0;
|
|
for i in &open {
|
|
let Some(it) = items.get_mut(*i) else { continue };
|
|
let v = clamp_size(share, it.min_main, it.max_main);
|
|
it.main = v;
|
|
if (v - share).abs() > 1e-9 {
|
|
used += v;
|
|
} else {
|
|
still.push(*i);
|
|
}
|
|
}
|
|
if still.len() == open.len() {
|
|
break;
|
|
}
|
|
free = (free - used).max(0.0);
|
|
open = still;
|
|
}
|
|
}
|
|
// 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 main_used: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum::<f64>() + gaps;
|
|
let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);
|
|
line_extents.push((main_used, cross));
|
|
}
|
|
|
|
// Container 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::<f64>() + 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 {
|
|
// Fill-cross children inside a hugging container use their own size.
|
|
let max_fill_cross = items.iter().filter(|it| it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
|
|
let cc = if lines.len() <= 1 { content_cross.max(max_fill_cross) } else { content_cross };
|
|
let v = cc + 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), clamp_size(nh, fcl.min_h, fcl.max_h));
|
|
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.
|
|
let mut cross_pos = if horizontal { pt } else { pl };
|
|
let single = lines.len() == 1;
|
|
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 n_items = line.len();
|
|
let (mut pos, step_gap) = match al.main_align {
|
|
MainAlign::Min => (0.0, gap),
|
|
MainAlign::Center => ((inner_main - used) / 2.0, gap),
|
|
MainAlign::Max => (inner_main - used, gap),
|
|
MainAlign::SpaceBetween => {
|
|
let total: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum();
|
|
if n_items > 1 { (0.0, (inner_main - total) / (n_items - 1) as f64) } else { ((inner_main - total) / 2.0, 0.0) }
|
|
}
|
|
};
|
|
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,
|
|
};
|
|
let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
|
|
// Resize only when the layout decides the size (fill, or text that must re-wrap).
|
|
if it.fill_main || it.fill_cross {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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<GridCell>,
|
|
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<GridCell>, 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<bool>> = Vec::new();
|
|
let free = |occ: &Vec<Vec<bool>>, 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<Vec<bool>>, 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<Option<GridCell>> = 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<GridCell> = 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. `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<f64>) -> Vec<f64> {
|
|
let mut out: Vec<f64> = Vec::with_capacity(content.len());
|
|
let mut total_fr = 0.0;
|
|
let mut used = 0.0;
|
|
for (i, c) in content.iter().enumerate() {
|
|
let size = match track_spec(specs, i) {
|
|
(Sizing::Fixed, v) => v,
|
|
(Sizing::Fill, v) if avail.is_some() => {
|
|
total_fr += v;
|
|
0.0
|
|
}
|
|
_ => *c,
|
|
};
|
|
used += size;
|
|
out.push(size);
|
|
}
|
|
if let Some(a) = avail
|
|
&& total_fr > 1e-12
|
|
{
|
|
let free = (a - used).max(0.0);
|
|
for (i, s) in out.iter_mut().enumerate() {
|
|
if let (Sizing::Fill, v) = track_spec(specs, i) {
|
|
*s = free * v / total_fr;
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content too):
|
|
/// the largest single-span child; 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<f64> {
|
|
let by_content = |i: usize| match track_spec(specs, i).0 {
|
|
Sizing::Hug => true,
|
|
Sizing::Fill => hug,
|
|
Sizing::Fixed => false,
|
|
};
|
|
let mut content: Vec<f64> = (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::<f64>() + gap * (len - 1) as f64;
|
|
let grow: Vec<usize> = (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<GridGeometry> {
|
|
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::<f64>() + gaps_w + pl + pr } else { f.width };
|
|
let height = if hug_h { row_h.iter().sum::<f64>() + 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),
|
|
height: clamp_size(height, cl.min_h, cl.max_h),
|
|
})
|
|
}
|
|
|
|
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 (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug);
|
|
let Some(g) = grid_geometry(doc, frame) else { return };
|
|
set_size(doc, frame, g.width, g.height);
|
|
apply_constraints(doc, frame, fw, fh);
|
|
// A fixed frame resized by its own min/max: tracks follow the clamped size.
|
|
let g = if (g.width - fw).abs() > 1e-9 && !hug_w || (g.height - fh).abs() > 1e-9 && !hug_h { grid_geometry(doc, frame).unwrap_or(g) } else { g };
|
|
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 = match al.main_align {
|
|
MainAlign::Center => (cell.width() - nw) / 2.0,
|
|
MainAlign::Max => cell.width() - nw,
|
|
_ => 0.0,
|
|
};
|
|
let oy = match al.cross_align {
|
|
CrossAlign::Center => (cell.height() - nh) / 2.0,
|
|
CrossAlign::Max => cell.height() - nh,
|
|
_ => 0.0,
|
|
};
|
|
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(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 (axis-aligned children exactly; rotated approximately).
|
|
let kw = if bw > 1e-9 { (x1 - x0) / bw } else { 1.0 };
|
|
let kh = 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).
|
|
pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) {
|
|
let Some(n) = doc.node(id) else { return };
|
|
for c in n.children.clone() {
|
|
let Some(cn) = doc.node(c) else { continue };
|
|
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
|
|
let (w, h) = (cn.width, cn.height);
|
|
set_size(doc, c, w * kx, h * ky);
|
|
place(doc, c, b.x0 * kx, b.y0 * ky);
|
|
if doc.node(c).is_some_and(|n| n.kind.bounds_follow_children()) {
|
|
scale_group_children(doc, c, kx, ky);
|
|
} 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<Rect> {
|
|
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 count = g.count.clamp(1, 500) as usize;
|
|
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 (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
|
|
}
|
|
|
|
/// Snap targets of a frame's visible layout grids (page coordinates): column/row edges.
|
|
pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
|
|
let Some(f) = doc.node(frame) else { return (vec![], vec![]) };
|
|
let xf = doc.world_transform(frame);
|
|
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 tests;
|