diff --git a/README.md b/README.md
index bb43d61..3cb648e 100644
--- a/README.md
+++ b/README.md
@@ -78,7 +78,7 @@
Layouts that build themselves
- Auto layout in both directions, with wrap and grid, padding, gaps, alignment, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, and drag handles for padding and spacing right on the canvas. Constraints and layout grids for everything else.
+ Auto layout in both directions, with wrap and grid (fixed, hug and `fr` tracks, cells that span), padding, gaps, alignment including text baselines, Hug / Fill / Fixed sizing, min and max sizes, absolute positioning, and drag handles for padding and spacing right on the canvas. Constraints and layout grids for everything else.
Components that scale
diff --git a/ROADMAP.md b/ROADMAP.md
index 1444dd4..ec722f6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,7 @@ continuous Claude Opus 5.5 agent work (including builds and the CI gate), for **
**4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity
table below and updated as work lands.
-_Last updated: 2026-10-10 (vector edit mode tools)._
+_Last updated: 2026-10-10 (vector edit mode tools, auto layout depth)._
## Where we are
@@ -47,8 +47,11 @@ dimension measured side by side.
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
truncation/max lines, font picker with previews and recents, word/line selection, IME commit.
*Left:* inline IME preedit, variable font axes, visual QA. 8–12 h.
-3. **Auto layout depth**: *landed:* on-canvas padding/gap handles. *Left:* drag-reorder polish, min/max UI, baseline
- alignment, absolute positioning UI, grid tracks (fr/fixed), wrap polish. 15–25 h.
+3. **Auto layout depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid
+ column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text
+ wraps at its max), ignore auto layout (absolute position) with constraints, text baseline
+ alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI. *Left:*
+ grid/wrap-aware drag-reorder and drag-to-cell, nested hug/fill edge cases. 6–10 h.
4. **Components depth**: *landed (engine):* component properties (edit/delete/bind), add variant,
reset override, restore main, instance structure protection. *Left:* properties panel UI, instance
swap UI, nested overrides, slots, local libraries, Assets drag-to-canvas. 14–20 h.
@@ -121,7 +124,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
| Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 |
-| Auto layout & constraints | 9 | 45% | canvas handles, baseline, grid tracks, nested hug/fill edge cases | 15–25 |
+| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
| Text | 8 | 35% | in-text mouse selection, styled ranges UI, lists, OpenType, variable fonts | 20–30 |
| Components & variants | 9 | 40% | properties UI, swap UI, nested overrides, libraries, slots | 20–30 |
| Styles & variables | 5 | 45% | style picker everywhere, variable binding UI, aliasing UI, scoping | 10–16 |
@@ -130,7 +133,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
-Weighted feature depth ≈ **45%**.
+Weighted feature depth ≈ **47%**.
## Milestones
@@ -139,7 +142,7 @@ Weighted feature depth ≈ **45%**.
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) |
| M0.9 CLI + MCP, web build, release packaging, PDF export | in progress |
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~60% |
-| M2 Layout depth | ~45% |
+| M2 Layout depth | ~60% |
| M3 Components & styles depth | ~40% |
| M4 Vector & text depth | ~45% |
| M5 Prototype & present | ~30% |
diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs
index 590baea..dd925be 100644
--- a/crates/doc/src/props.rs
+++ b/crates/doc/src/props.rs
@@ -298,9 +298,13 @@ pub struct AutoLayout {
pub padding: [f64; 4],
pub main_align: MainAlign,
pub cross_align: CrossAlign,
- /// Grid mode: column and row counts.
+ /// Grid mode: column and row counts (rows grow to fit the children).
pub grid_columns: u32,
pub grid_rows: u32,
+ /// Grid mode: column track sizes (missing tracks are `1fr`).
+ pub column_tracks: Vec,
+ /// Grid mode: row track sizes (missing tracks are `1fr`).
+ pub row_tracks: Vec,
/// Include strokes in layout sizes.
pub strokes_included: bool,
/// Last item on top (canvas stacking).
@@ -319,6 +323,8 @@ impl Default for AutoLayout {
cross_align: CrossAlign::Min,
grid_columns: 2,
grid_rows: 2,
+ column_tracks: Vec::new(),
+ row_tracks: Vec::new(),
strokes_included: false,
reverse_z: false,
}
@@ -347,6 +353,34 @@ impl Sizing {
}
}
+/// One grid auto layout track (a column or a row): a fixed size in px, hugging its contents, or
+/// filling a share of the free space (`value` fractional units, like CSS `fr`).
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct GridTrack {
+ pub sizing: Sizing,
+ /// Fixed: the size in px. Fill: the fractional units. Hug: unused.
+ pub value: f64,
+}
+
+impl Default for GridTrack {
+ fn default() -> Self {
+ Self { sizing: Sizing::Fill, value: 1.0 }
+ }
+}
+
+impl GridTrack {
+ pub fn fixed(px: f64) -> Self {
+ Self { sizing: Sizing::Fixed, value: px }
+ }
+ pub fn hug() -> Self {
+ Self { sizing: Sizing::Hug, value: 0.0 }
+ }
+ pub fn fill(fr: f64) -> Self {
+ Self { sizing: Sizing::Fill, value: fr }
+ }
+}
+
/// Layout properties a node has as a child (of an auto layout frame) or as a resizable box.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -359,6 +393,23 @@ pub struct ChildLayout {
pub max_w: Option,
pub min_h: Option,
pub max_h: Option,
+ /// Grid auto layout: anchor column / row (0-based); `None` = placed automatically.
+ pub grid_column: Option,
+ pub grid_row: Option,
+ /// Grid auto layout: how many columns / rows the child spans (0 is read as 1).
+ pub grid_column_span: u32,
+ pub grid_row_span: u32,
+}
+
+impl ChildLayout {
+ /// Column span, at least 1.
+ pub fn column_span(&self) -> u32 {
+ self.grid_column_span.max(1)
+ }
+ /// Row span, at least 1.
+ pub fn row_span(&self) -> u32 {
+ self.grid_row_span.max(1)
+ }
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index f1bd7a4..bf32fe8 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -264,6 +264,19 @@ fn serde_roundtrip_and_repair() {
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
}
+#[test]
+fn auto_layout_loads_files_without_grid_tracks() {
+ // Saved before grid tracks, cells and spans existed.
+ let al: AutoLayout = serde_json::from_str(r#"{"mode":"grid","gap":4,"grid_columns":3,"grid_rows":2}"#).unwrap();
+ assert_eq!((al.grid_columns, al.column_tracks.len(), al.row_tracks.len()), (3, 0, 0));
+ let cl: ChildLayout = serde_json::from_str(r#"{"sizing_h":"fill","absolute":true,"min_w":10}"#).unwrap();
+ assert_eq!((cl.grid_column, cl.grid_row, cl.column_span(), cl.row_span()), (None, None, 1, 1));
+ // Tracks round-trip; a track missing its value reads as the default kind's value.
+ let t: GridTrack = serde_json::from_str(r#"{"sizing":"fixed","value":120}"#).unwrap();
+ assert_eq!(t, GridTrack::fixed(120.0));
+ assert_eq!(serde_json::from_str::("{}").unwrap(), GridTrack::fill(1.0));
+}
+
#[test]
fn node_id_parse() {
assert_eq!("12".parse::().unwrap(), NodeId(12));
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
new file mode 100644
index 0000000..5643ac7
--- /dev/null
+++ b/crates/engine/src/cmd/layout.rs
@@ -0,0 +1,270 @@
+//! Auto layout commands beyond `layout.add` / `layout.remove` (in `structure`) and the layout
+//! keys of `node.set` (gap, padding, alignment, sizing, min/max, absolute, grid cells): grid tracks.
+
+use figcraft_doc::{GridTrack, LayoutMode, Sizing};
+use serde_json::Value;
+
+use super::props::sizing;
+use super::{CommandSpec, ok};
+use crate::params::*;
+use crate::{Result, Session, bad, spec};
+
+pub fn specs() -> Vec {
+ vec![spec!(
+ "layout.setTrack",
+ "Set grid track size",
+ "{ids?, axis: columns|rows, index (0-based), sizing: fixed|hug|fill, value? (fixed: px, default the track's current size; fill: fr, default 1)}",
+ true,
+ set_track
+ )]
+}
+
+fn set_track(s: &mut Session, p: &Value) -> Result {
+ const CMD: &str = "layout.setTrack";
+ let ids = req_targets(CMD, s, p)?;
+ let columns = match string(p, "axis").map(|a| a.to_ascii_lowercase()).as_deref() {
+ Some("columns" | "column" | "col" | "x") => true,
+ Some("rows" | "row" | "y") => false,
+ _ => return Err(bad(CMD, "`axis` must be columns or rows")),
+ };
+ let index = req_num(CMD, p, "index")?;
+ let kind = get(p, "sizing").and_then(sizing).ok_or_else(|| bad(CMD, "`sizing` must be fixed, hug or fill"))?;
+ let value = num(p, "value");
+ let doc = s.doc_mut()?;
+ for id in ids {
+ // Current track sizes (switching to Fixed keeps the size the track has now).
+ let tracks_now = figcraft_layout::grid_geometry(doc, id).map(|g| if columns { g.columns } else { g.rows }).unwrap_or_default();
+ let n = doc.get_mut(id)?;
+ let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else {
+ return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
+ };
+ let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize);
+ if !(0.0..count as f64).contains(&index) {
+ return Err(bad(CMD, format!("`index` must be 0..{count}")));
+ }
+ let i = index as usize;
+ let track = match kind {
+ Sizing::Fixed => {
+ let current = tracks_now.get(i).map(|(a, b)| b - a).unwrap_or(100.0);
+ GridTrack::fixed(value.unwrap_or(current).clamp(0.0, 1e6))
+ }
+ Sizing::Hug => GridTrack::hug(),
+ Sizing::Fill => GridTrack::fill(value.unwrap_or(1.0).clamp(0.0, 1e4)),
+ };
+ let tracks = if columns { &mut l.column_tracks } else { &mut l.row_tracks };
+ if tracks.len() <= i {
+ tracks.resize(i + 1, GridTrack::default());
+ }
+ if let Some(t) = tracks.get_mut(i) {
+ *t = track;
+ }
+ }
+ ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use figcraft_doc::{CrossAlign, Document, GridTrack, LayoutMode, Node, NodeId, Sizing};
+ use serde_json::{Value, json};
+
+ use crate::{EngineError, Session, params};
+
+ fn id(v: &Value) -> NodeId {
+ params::id_of(&v["id"]).unwrap()
+ }
+
+ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
+ }
+
+ fn node(s: &Session, id: NodeId) -> Node {
+ s.doc().unwrap().node(id).unwrap().clone()
+ }
+
+ fn x(s: &Session, id: NodeId) -> (f64, f64) {
+ let c = node(s, id).transform.as_coeffs();
+ (c[4], c[5])
+ }
+
+ /// A horizontal auto layout frame (padding 0, gap 10) with two 40×20 rectangles.
+ fn al_frame(s: &mut Session) -> (NodeId, NodeId, NodeId) {
+ let f = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100, "props": {"layout": "horizontal", "gap": 10}})));
+ let a = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f})));
+ let b = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": f})));
+ (f, a, b)
+ }
+
+ #[test]
+ fn min_max_props_undo_and_order() {
+ let mut s = Session::new();
+ let (f, a, _) = al_frame(&mut s);
+ ex(&mut s, "node.set", json!({"id": f, "props": {"sizingH": "hug"}}));
+ assert_eq!(node(&s, f).width, 90.0);
+ ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": 200}}));
+ assert_eq!(node(&s, f).width, 200.0);
+ // A max below the min pulls the min down with it.
+ ex(&mut s, "node.set", json!({"id": f, "props": {"maxWidth": 150}}));
+ let n = node(&s, f);
+ assert_eq!((n.child_layout.min_w, n.child_layout.max_w, n.width), (Some(150.0), Some(150.0), 150.0));
+ s.undo().unwrap();
+ assert_eq!(node(&s, f).width, 200.0);
+ // null removes the limit.
+ ex(&mut s, "node.set", json!({"id": f, "props": {"minWidth": null, "maxWidth": null}}));
+ assert_eq!(node(&s, f).width, 90.0);
+ // Child limits clamp a fixed child; negatives become 0; junk is refused and rolled back.
+ ex(&mut s, "node.set", json!({"id": a, "props": {"minWidth": 70, "maxHeight": -5}}));
+ let n = node(&s, a);
+ assert_eq!((n.width, n.height, n.child_layout.max_h), (70.0, 0.0, Some(0.0)));
+ let before = s.ds().unwrap().history.undo.len();
+ assert!(s.execute("node.set", &json!({"id": a, "props": {"minWidth": "wide"}})).is_err());
+ assert!(s.execute("node.set", &json!({"id": a, "props": {"maxWidth": [1]}})).is_err());
+ assert_eq!(s.ds().unwrap().history.undo.len(), before);
+ assert_eq!(node(&s, a).child_layout.min_w, Some(70.0));
+ }
+
+ #[test]
+ fn absolute_child_keeps_place_and_follows_constraints() {
+ let mut s = Session::new();
+ let (f, a, b) = al_frame(&mut s);
+ ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": "fill"}}));
+ let at = x(&s, b);
+ ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": true, "constraints": {"horizontal": "right"}}}));
+ let n = node(&s, b);
+ assert!(n.child_layout.absolute);
+ assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "an absolute child can't fill");
+ assert_eq!(x(&s, b), at, "toggling absolute keeps the child where it is");
+ // The flow ignores it: changing the gap moves nothing else.
+ ex(&mut s, "node.set", json!({"id": f, "props": {"paddingLeft": 30}}));
+ assert_eq!(x(&s, a).0, 30.0);
+ assert_eq!(x(&s, b), at);
+ // Resizing the frame applies its constraints (right: keeps its right margin).
+ let right_margin = 300.0 - at.0 - node(&s, b).width;
+ ex(&mut s, "node.set", json!({"id": f, "props": {"width": 400}}));
+ assert!((400.0 - x(&s, b).0 - node(&s, b).width - right_margin).abs() < 1e-9);
+ // Undo the resize, then back into the flow.
+ s.undo().unwrap();
+ assert_eq!(x(&s, b), at);
+ ex(&mut s, "node.set", json!({"id": b, "props": {"absolute": false}}));
+ assert_eq!(x(&s, b).0, 30.0 + 40.0 + 10.0);
+ assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
+ }
+
+ #[test]
+ fn baseline_alignment_lines_up_text() {
+ let mut s = Session::new();
+ let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal"}})));
+ let small = id(&ex(&mut s, "text.create", json!({"text": "Small", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 12}})));
+ let big = id(&ex(&mut s, "text.create", json!({"text": "Big", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
+ ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
+ assert_eq!(node(&s, f).layout.unwrap().cross_align, CrossAlign::Baseline);
+ let base =
+ |s: &Session, t: NodeId| figcraft_text::layout_node(&node(s, t)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
+ // Only meaningful when the text was measured (fonts available).
+ if let (Some(bs), Some(bb)) = (base(&s, small), base(&s, big))
+ && (bb - bs).abs() > 1.0
+ {
+ assert!((x(&s, small).1 + bs - (x(&s, big).1 + bb)).abs() < 1e-6);
+ }
+ // Vertical layouts treat baseline as left.
+ ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "vertical"}}));
+ assert_eq!(x(&s, small).0, x(&s, big).0);
+ }
+
+ #[test]
+ fn grid_tracks_cells_and_undo() {
+ let mut s = Session::new();
+ let f = id(&ex(
+ &mut s,
+ "frame.create",
+ json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid", "gridColumns": 3, "columnGap": 10, "rowGap": 10}}),
+ ));
+ let kids: Vec =
+ (0..4).map(|_| id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f})))).collect();
+ ex(&mut s, "node.set", json!({"id": f, "props": {"columnTracks": [100, "1fr", "auto"]}}));
+ let l = node(&s, f).layout.unwrap();
+ assert_eq!(l.column_tracks, vec![GridTrack::fixed(100.0), GridTrack::fill(1.0), GridTrack::hug()]);
+ // 320 - 20 gaps - 100 - 20 (hug) = 180 for the 1fr column.
+ assert_eq!(x(&s, kids[1]).0, 110.0);
+ assert_eq!(x(&s, kids[2]).0, 300.0);
+ // layout.setTrack: switching to fixed keeps the current size.
+ ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "columns", "index": 1, "sizing": "fixed"}));
+ assert_eq!(node(&s, f).layout.unwrap().column_tracks.get(1).copied(), Some(GridTrack::fixed(180.0)));
+ ex(&mut s, "layout.setTrack", json!({"ids": [f], "axis": "rows", "index": 0, "sizing": "fixed", "value": 50}));
+ assert_eq!(node(&s, f).layout.unwrap().row_tracks, vec![GridTrack::fixed(50.0)]);
+ s.undo().unwrap();
+ assert!(node(&s, f).layout.unwrap().row_tracks.is_empty());
+ // Cells: anchor the last child at column 2, row 1, span the first over two columns.
+ ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": 2, "gridRow": 1}}));
+ ex(&mut s, "node.set", json!({"id": kids[0], "props": {"gridColumnSpan": 2, "sizingH": "fill"}}));
+ assert_eq!(node(&s, kids[0]).width, 100.0 + 10.0 + 180.0);
+ let info = crate::inspect::node_json(s.doc().unwrap(), kids[3], 0);
+ assert_eq!(info["gridCell"]["column"], json!(2));
+ assert_eq!(info["gridCell"]["row"], json!(1));
+ assert_eq!(info["gridCell"]["anchored"], json!(true));
+ ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}}));
+ assert_eq!(node(&s, kids[3]).child_layout.grid_column, None);
+ // Fewer columns drop the extra tracks.
+ ex(&mut s, "node.set", json!({"id": f, "props": {"gridColumns": 2}}));
+ assert_eq!(node(&s, f).layout.unwrap().column_tracks.len(), 2);
+ // Errors roll back.
+ for bad in [
+ json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}),
+ json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}),
+ json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}),
+ json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}),
+ json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}),
+ ] {
+ assert!(s.execute("layout.setTrack", &bad).is_err(), "{bad}");
+ }
+ assert!(s.execute("node.set", &json!({"id": f, "props": {"columnTracks": ["1fr", "wide"]}})).is_err());
+ assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err());
+ }
+
+ #[test]
+ fn layout_props_survive_hostile_values() {
+ let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")];
+ let keys = [
+ "minWidth",
+ "maxWidth",
+ "minHeight",
+ "maxHeight",
+ "absolute",
+ "columnTracks",
+ "rowTracks",
+ "gridColumn",
+ "gridRow",
+ "gridColumnSpan",
+ "gridRowSpan",
+ "gridColumns",
+ "gridRows",
+ "columnGap",
+ "rowGap",
+ "crossAlign",
+ ];
+ for k in keys {
+ for j in &junk {
+ let mut s = Session::new();
+ let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"layout": "grid"}})));
+ let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": f})));
+ for target in [f, r] {
+ if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": target, "props": {k: j}})) {
+ panic!("{cmd} panicked on {k}={j}: {msg}");
+ }
+ }
+ let doc: &Document = s.doc().unwrap();
+ for n in [f, r] {
+ let n = doc.node(n).unwrap();
+ assert!(n.width.is_finite() && n.height.is_finite(), "{k}={j}");
+ }
+ assert!(doc.node(f).unwrap().layout.as_ref().is_some_and(|l| l.mode == LayoutMode::Grid));
+ }
+ }
+ // Tracks as track objects and extreme strings.
+ let t: Vec> =
+ [json!({"sizing": "fill", "value": 2}), json!({"sizing": "fixed"}), json!("120px"), json!("1e400fr"), json!("hug")]
+ .iter()
+ .map(super::super::props::track_of)
+ .collect();
+ assert_eq!(t, vec![Some(GridTrack::fill(2.0)), None, Some(GridTrack::fixed(120.0)), None, Some(GridTrack::hug())]);
+ }
+}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 4f93a73..dcdb4ca 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -6,6 +6,7 @@ pub mod component;
pub mod create;
pub mod edit;
pub mod file;
+pub mod layout;
pub mod page;
pub mod props;
pub mod proto;
@@ -64,6 +65,7 @@ fn registry() -> &'static Registry {
list.extend(edit::specs());
list.extend(arrange::specs());
list.extend(structure::specs());
+ list.extend(layout::specs());
list.extend(file::specs());
list.extend(page::specs());
list.extend(view::specs());
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index 0974064..b5f8d84 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -13,8 +13,10 @@ pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (paren
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], \
strokeCap / startCap / endCap, strokeJoin, dashes [..], cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
-mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline), wrap, gridColumns, gridRows, sizingH / sizingV (fixed|hug|fill), \
-absolute, minWidth, maxWidth, minHeight, maxHeight, constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
+mainAlign (min|center|max|spaceBetween), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap, \
+columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
+gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, sizingH / sizingV (fixed|hug|fill), \
+absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \
fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), pointCount, innerRadius, arcStart, arcSweep, \
@@ -73,7 +75,7 @@ pub(crate) fn enum_of(v: &Value) -> Option {
serde_json::from_value(Value::String(norm)).ok()
}
-fn sizing(v: &Value) -> Option {
+pub(crate) fn sizing(v: &Value) -> Option {
match v.as_str()?.to_ascii_lowercase().as_str() {
"fixed" => Some(Sizing::Fixed),
"hug" | "auto" | "hug contents" => Some(Sizing::Hug),
@@ -114,6 +116,53 @@ fn four(v: &Value) -> Option<[f64; 4]> {
}
}
+const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
+
+/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
+/// or `{sizing, value}`.
+pub fn track_of(v: &Value) -> Option {
+ match v {
+ Value::Number(_) => f64_of(v).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
+ Value::String(s) => {
+ let s = s.trim().to_ascii_lowercase();
+ match s.as_str() {
+ "auto" | "hug" => Some(GridTrack::hug()),
+ "fill" => Some(GridTrack::fill(1.0)),
+ _ => match s.strip_suffix("fr") {
+ Some(fr) => fr.trim().parse::().ok().filter(|f| f.is_finite()).map(|f| GridTrack::fill(f.clamp(0.0, 1e4))),
+ None => f64_of(&Value::String(s)).map(|px| GridTrack::fixed(px.clamp(0.0, 1e6))),
+ },
+ }
+ }
+ Value::Object(o) => {
+ let s = o.get("sizing").or_else(|| o.get("type")).and_then(sizing)?;
+ let val = o.get("value").or_else(|| o.get("size")).and_then(f64_of);
+ Some(match s {
+ Sizing::Fixed => GridTrack::fixed(val?.clamp(0.0, 1e6)),
+ Sizing::Hug => GridTrack::hug(),
+ Sizing::Fill => GridTrack::fill(val.unwrap_or(1.0).clamp(0.0, 1e4)),
+ })
+ }
+ _ => None,
+ }
+}
+
+/// Set one min/max limit (`None` removes it), keeping min ≤ max by moving the other limit.
+pub fn set_limit(cl: &mut ChildLayout, key: &str, val: Option) {
+ let (target, other, is_min) = match key {
+ "minWidth" => (&mut cl.min_w, &mut cl.max_w, true),
+ "maxWidth" => (&mut cl.max_w, &mut cl.min_w, false),
+ "minHeight" => (&mut cl.min_h, &mut cl.max_h, true),
+ _ => (&mut cl.max_h, &mut cl.min_h, false),
+ };
+ *target = val;
+ if let (Some(v), Some(o)) = (val, *other)
+ && (is_min && o < v || !is_min && o > v)
+ {
+ *other = Some(v);
+ }
+}
+
fn text_mut<'a>(cmd: &str, n: &'a mut Node) -> Result<&'a mut TextData> {
let id = n.id;
n.kind.text_mut().ok_or_else(|| bad(cmd, format!("node {id} is not text")))
@@ -260,9 +309,11 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
}
}
- "gap" | "itemSpacing" => ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, "`gap` must be a number"))?,
- "crossGap" | "counterAxisSpacing" => {
- ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, "`crossGap` must be a number"))?
+ "gap" | "itemSpacing" | "columnGap" => {
+ ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
+ }
+ "crossGap" | "counterAxisSpacing" | "rowGap" => {
+ ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
}
"padding" => {
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
@@ -294,8 +345,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
- "gridColumns" => ensure_layout(doc.get_mut(id)?).grid_columns = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
- "gridRows" => ensure_layout(doc.get_mut(id)?).grid_rows = f64_of(v).unwrap_or(2.0).clamp(1.0, 1000.0) as u32,
+ "gridColumns" | "gridRows" => {
+ let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
+ let l = ensure_layout(doc.get_mut(id)?);
+ // Tracks past the new count go away (new tracks are 1fr).
+ let (n, tracks) =
+ if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
+ *n = count;
+ tracks.truncate(count as usize);
+ }
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
"sizingH" | "layoutSizingHorizontal" => {
@@ -306,11 +364,63 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.child_layout.absolute = v.as_bool().unwrap_or(false),
- "minWidth" => doc.get_mut(id)?.child_layout.min_w = f64_of(v),
- "maxWidth" => doc.get_mut(id)?.child_layout.max_w = f64_of(v),
- "minHeight" => doc.get_mut(id)?.child_layout.min_h = f64_of(v),
- "maxHeight" => doc.get_mut(id)?.child_layout.max_h = f64_of(v),
+ "absolute" | "ignoreAutoLayout" => {
+ let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?;
+ let cl = &mut doc.get_mut(id)?.child_layout;
+ cl.absolute = on;
+ // An absolute child can't fill: it keeps its current size.
+ if on {
+ for s in [&mut cl.sizing_h, &mut cl.sizing_v] {
+ if *s == Sizing::Fill {
+ *s = Sizing::Fixed;
+ }
+ }
+ }
+ }
+ "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => {
+ let val = match v {
+ Value::Null => None,
+ other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)),
+ };
+ set_limit(&mut doc.get_mut(id)?.child_layout, k, val);
+ }
+ "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
+ let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
+ let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
+ let l = ensure_layout(doc.get_mut(id)?);
+ if k.contains("olumn") {
+ l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
+ l.column_tracks = tracks;
+ } else {
+ l.grid_rows = (tracks.len() as u32).clamp(1, 1000);
+ l.row_tracks = tracks;
+ }
+ }
+ "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => {
+ let val = match v {
+ Value::Null => None,
+ Value::String(s) if s.eq_ignore_ascii_case("auto") => None,
+ other => {
+ Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0)
+ as u32)
+ }
+ };
+ let cl = &mut doc.get_mut(id)?.child_layout;
+ if k.starts_with("gridColumn") {
+ cl.grid_column = val;
+ } else {
+ cl.grid_row = val;
+ }
+ }
+ "gridColumnSpan" | "gridRowSpan" => {
+ let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
+ let cl = &mut doc.get_mut(id)?.child_layout;
+ if k == "gridColumnSpan" {
+ cl.grid_column_span = span;
+ } else {
+ cl.grid_row_span = span;
+ }
+ }
"constraints" => {
let n = doc.get_mut(id)?;
if let Some(h) = v.get("horizontal") {
@@ -649,7 +759,8 @@ pub fn resize_node(doc: &mut Document, id: NodeId, w: f64, h: f64) -> Result<()>
let kx = if ow > 1e-9 { w / ow } else { 1.0 };
let ky = if oh > 1e-9 { h / oh } else { 1.0 };
figcraft_layout::scale_group_children(doc, id, kx, ky);
- } else if doc.get(id)?.layout.is_none() {
+ } else {
+ // In an auto layout frame this moves only the children that ignore auto layout.
figcraft_layout::apply_constraints(doc, id, ow, oh);
}
Ok(())
diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs
index 2891835..02976c5 100644
--- a/crates/engine/src/inspect.rs
+++ b/crates/engine/src/inspect.rs
@@ -75,10 +75,27 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
}
- if n.child_layout.absolute {
+ let cl = &n.child_layout;
+ if cl.absolute {
o.insert("absolute".into(), json!(true));
}
- if !parent_al && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
+ for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
+ if let Some(v) = v {
+ o.insert(k.into(), json!(v));
+ }
+ }
+ if let Some(cell) = parent_al
+ .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
+ .flatten()
+ .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
+ {
+ o.insert(
+ "gridCell".into(),
+ json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
+ "anchored": cl.grid_column.is_some() || cl.grid_row.is_some()}),
+ );
+ }
+ if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
}
if n.kind.is_frame_like() {
diff --git a/crates/engine/src/measure.rs b/crates/engine/src/measure.rs
index fd51df6..69dabce 100644
--- a/crates/engine/src/measure.rs
+++ b/crates/engine/src/measure.rs
@@ -16,6 +16,19 @@ impl figcraft_layout::Measure for TextMeasure {
}
fn text_size(&self, doc: &Document, id: NodeId) -> Option<(f64, f64)> {
let n = doc.node(id)?;
- figcraft_text::auto_size(n)
+ let NodeKind::Text(t) = &n.kind else { return None };
+ if t.auto_resize == TextAutoResize::WidthAndHeight {
+ return figcraft_text::auto_size(n);
+ }
+ // Natural (unwrapped) size, whatever the resize mode.
+ let mut t = t.clone();
+ t.auto_resize = TextAutoResize::WidthAndHeight;
+ let l = figcraft_text::layout_cached(&t, None, None);
+ Some((l.width.ceil().max(1.0), l.height.max(1.0)))
+ }
+ fn text_baseline(&self, doc: &Document, id: NodeId) -> Option {
+ let n = doc.node(id)?;
+ let (l, dy) = figcraft_text::layout_node(n)?;
+ l.lines.first().map(|line| line.baseline + dy).filter(|b| b.is_finite())
}
}
diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs
index 7024515..c0f46f6 100644
--- a/crates/engine/src/params.rs
+++ b/crates/engine/src/params.rs
@@ -46,7 +46,12 @@ pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result {
}
pub fn boolean(p: &Value, key: &str) -> Option {
- match get(p, key)? {
+ bool_of(get(p, key)?)
+}
+
+/// A lenient boolean: `true`, `1`, `"yes"`, `"on"`…
+pub fn bool_of(v: &Value) -> Option {
+ match v {
Value::Bool(b) => Some(*b),
Value::Number(n) => n.as_f64().map(|f| f != 0.0),
Value::String(s) => match s.to_ascii_lowercase().as_str() {
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 73cab13..0ddb0dd 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -1,9 +1,11 @@
//! FigCraft layout.
//!
//! - [`layout_page`] / [`layout_frame`]: Figma-style auto layout. Children of an auto layout frame
-//! are stacked horizontally or vertically (optionally wrapping) or placed on a grid, with gap,
-//! padding and alignment; each child is Fixed, Hug (its own content) or Fill (shares the free
-//! space). A Hug container sizes to its children. Absolute children are left alone.
+//! 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.
//!
@@ -20,8 +22,13 @@ use figcraft_geom::{Affine, Point, Rect};
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) size of a text node.
+ /// 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.
@@ -36,7 +43,8 @@ impl Measure for NoMeasure {
}
}
-fn clamp_size(v: f64, min: Option, max: Option) -> f64 {
+/// 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);
@@ -47,6 +55,10 @@ fn clamp_size(v: f64, min: Option, max: Option) -> f64 {
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) };
@@ -108,6 +120,88 @@ pub fn layout_up(doc: &mut Document, id: NodeId, m: &dyn Measure) {
}
}
+/// 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()
+}
+
+/// 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 {
+ 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,
@@ -118,6 +212,10 @@ struct Item {
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.
@@ -126,32 +224,28 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
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 (fw, fh) = (f.width, f.height);
- let stroke_pad = if al.strokes_included && f.has_visible_stroke() { f.stroke.weight } else { 0.0 };
- let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 });
- let (pt, pr, pb, pl) = (pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad);
- let kids: Vec = f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect();
+ 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 first.
+ // Text children refresh their natural size, then every child honours its min/max.
for c in &kids {
- if let Some(n) = doc.node(*c)
- && let NodeKind::Text(t) = &n.kind
- && t.auto_resize == TextAutoResize::WidthAndHeight
- && let Some((w, h)) = m.text_size(doc, *c)
- {
- set_size(doc, *c, w, h);
- }
+ fit_hug_text(doc, *c, m);
+ enforce_limits(doc, *c, m);
}
if matches!(al.mode, LayoutMode::Grid) {
- layout_grid(doc, frame, &al, &kids, (pt, pr, pb, pl), m);
+ 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 {
@@ -161,20 +255,23 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
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: sc == Sizing::Fill && !hug_cross,
+ 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 = if al.gap.is_finite() { al.gap } else { 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).
@@ -234,23 +331,29 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
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::
() + gaps;
- let cross: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_cross).map(|it| it.cross).fold(0.0, f64::max);
+ 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 = if al.cross_gap.is_finite() { al.cross_gap } else { 0.0 };
+ 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);
- let fcl = doc.node(frame).map(|n| n.child_layout.clone()).unwrap_or_default();
if hug_main {
let v = content_main + if horizontal { pl + pr } else { pt + pb };
if horizontal {
- nw = clamp_size(v, fcl.min_w, fcl.max_w);
+ nw = v;
} else {
- nh = clamp_size(v, fcl.min_h, fcl.max_h);
+ nh = v;
}
}
if hug_cross {
@@ -259,15 +362,16 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
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 = clamp_size(v, fcl.min_h, fcl.max_h);
+ nh = v;
} else {
- nw = clamp_size(v, fcl.min_w, fcl.max_w);
+ 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 };
- let _ = inner_cross_fixed;
// Position.
let mut cross_pos = if horizontal { pt } else { pl };
@@ -290,7 +394,9 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
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 | CrossAlign::Baseline => 0.0,
+ 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,
};
@@ -305,8 +411,6 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
cross_pos += line_cross + cross_gap;
}
- // Hugging after fill-cross text re-wrapped: heights may have changed; one more pass is cheap.
- let _ = AutoLayout::default();
}
fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure) {
@@ -326,81 +430,303 @@ fn resize_child(doc: &mut Document, id: NodeId, w: f64, h: f64, m: &dyn Measure)
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.layout.is_none() && n.kind.is_frame_like())
+ && doc.node(id).is_some_and(|n| n.kind.is_frame_like())
{
apply_constraints(doc, id, ow, oh);
- } else if doc.node(id).is_some_and(|n| n.layout.is_some()) {
+ }
+ if doc.node(id).is_some_and(|n| n.layout.is_some()) {
layout_frame(doc, id, m);
}
}
}
-fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, kids: &[NodeId], pad: (f64, f64, f64, f64), m: &dyn Measure) {
- let (pt, pr, pb, pl) = pad;
- let Some(f) = doc.node(frame) else { return };
- let cols = al.grid_columns.clamp(1, 1000) as usize;
- let rows_needed = kids.len().div_ceil(cols).max(al.grid_rows.clamp(1, 1000) as usize);
- let (fw, fh) = (f.width, f.height);
- let gx = if al.gap.is_finite() { al.gap.max(0.0) } else { 0.0 };
- let gy = if al.cross_gap.is_finite() { al.cross_gap.max(0.0) } else { 0.0 };
- let hug_w = f.child_layout.sizing_h == Sizing::Hug;
- let hug_h = f.child_layout.sizing_v == Sizing::Hug;
- // Track sizes: equal (1fr) unless hugging, then the max child per track.
- let mut col_w = vec![0.0f64; cols];
- let mut row_h = vec![0.0f64; rows_needed];
- for (i, c) in kids.iter().enumerate() {
- let (w, h) = outer_size(doc, *c);
- if let Some(cw) = col_w.get_mut(i % cols) {
- *cw = cw.max(w);
- }
- if let Some(rh) = row_h.get_mut(i / cols) {
- *rh = rh.max(h);
- }
- }
- let total_w = if hug_w { col_w.iter().sum::() + gx * (cols - 1) as f64 + pl + pr } else { fw };
- let total_h = if hug_h { row_h.iter().sum::() + gy * (rows_needed - 1) as f64 + pt + pb } else { fh };
- set_size(doc, frame, total_w, total_h);
- if !hug_w {
- let cw = ((total_w - pl - pr - gx * (cols - 1) as f64) / cols as f64).max(0.0);
- col_w = vec![cw; cols];
- }
- if !hug_h {
- let rh = ((total_h - pt - pb - gy * (rows_needed - 1) as f64) / rows_needed as f64).max(0.0);
- row_h = vec![rh; rows_needed];
- }
- for (i, c) in kids.iter().enumerate() {
- let (ci, ri) = (i % cols, i / cols);
- let x = pl + col_w.iter().take(ci).sum::() + gx * ci as f64;
- let y = pt + row_h.iter().take(ri).sum::() + gy * ri as f64;
- let (cw, rh) = (col_w.get(ci).copied().unwrap_or(0.0), row_h.get(ri).copied().unwrap_or(0.0));
- let Some(n) = doc.node(*c) else { continue };
- let (fill_w, fill_h) = (n.child_layout.sizing_h == Sizing::Fill, n.child_layout.sizing_v == Sizing::Fill);
- let (w, h) = outer_size(doc, *c);
- let (nw, nh) = (if fill_w { cw } else { w }, if fill_h { rh } else { h });
- if fill_w || fill_h {
- resize_child(doc, *c, nw, nh, m);
- }
- let ox = match al.main_align {
- MainAlign::Center => (cw - nw) / 2.0,
- MainAlign::Max => cw - nw,
- _ => 0.0,
- };
- let oy = match al.cross_align {
- CrossAlign::Center => (rh - nh) / 2.0,
- CrossAlign::Max => rh - nh,
- _ => 0.0,
- };
- place(doc, *c, x + ox, y + oy);
+/// 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))
}
}
-/// After frame `id` (without auto layout) changed size from `(ow, oh)`, move/resize its children
-/// per their constraints (recursively for children frames that resize).
+/// 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. `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 = 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 {
+ let by_content = |i: usize| match track_spec(specs, i).0 {
+ Sizing::Hug => true,
+ Sizing::Fill => hug,
+ Sizing::Fixed => false,
+ };
+ 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),
+ 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 };
- if f.layout.is_some() {
- 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;
@@ -414,6 +740,9 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
}
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);
diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs
index 80de15f..196b375 100644
--- a/crates/layout/src/tests.rs
+++ b/crates/layout/src/tests.rs
@@ -162,3 +162,288 @@ fn hostile_values() {
layout_frame(&mut d, f, &NoMeasure);
let _ = grid_lines(&LayoutGrid { size: 0.0, ..Default::default() }, 1e9, 1e9);
}
+
+/// A fake measurer: each text has a natural (unwrapped) size and a first baseline; wrapping at a
+/// narrower width adds lines of the natural height.
+struct FakeMeasure(std::collections::HashMap);
+
+impl Measure for FakeMeasure {
+ fn text_height(&self, _: &Document, id: NodeId, width: f64) -> Option {
+ let (w, h, _) = self.0.get(&id).copied()?;
+ Some(h * (w / width.max(1.0)).ceil().max(1.0))
+ }
+ fn text_size(&self, _: &Document, id: NodeId) -> Option<(f64, f64)> {
+ self.0.get(&id).map(|(w, h, _)| (*w, *h))
+ }
+ fn text_baseline(&self, _: &Document, id: NodeId) -> Option {
+ self.0.get(&id).map(|t| t.2)
+ }
+}
+
+fn text(d: &mut Document, parent: NodeId, name: &str) -> NodeId {
+ d.create(parent, NodeKind::Text(figcraft_doc::TextData::new(name)), name, 0.0, 0.0, 10.0, 10.0).unwrap()
+}
+
+#[test]
+fn min_max_clamp_fixed_children_and_frame() {
+ let (mut d, f, k) = setup(LayoutMode::Horizontal);
+ d.get_mut(k[0]).unwrap().child_layout.min_w = Some(80.0);
+ d.get_mut(k[1]).unwrap().child_layout.max_h = Some(25.0);
+ d.get_mut(f).unwrap().child_layout.max_w = Some(200.0);
+ layout_frame(&mut d, f, &NoMeasure);
+ assert_eq!(pos(&d, k[0]).2, 80.0);
+ assert_eq!(pos(&d, k[1]), (95.0, 5.0, 30.0, 25.0));
+ // A fixed frame wider than its max width is clamped too.
+ assert_eq!(d.node(f).unwrap().width, 200.0);
+}
+
+#[test]
+fn min_max_on_hug_and_fill() {
+ let (mut d, f, _) = setup(LayoutMode::Horizontal);
+ {
+ let n = d.get_mut(f).unwrap();
+ n.child_layout.sizing_h = Sizing::Hug;
+ n.child_layout.min_w = Some(200.0);
+ }
+ layout_frame(&mut d, f, &NoMeasure);
+ assert_eq!(d.node(f).unwrap().width, 200.0);
+ {
+ let n = d.get_mut(f).unwrap();
+ n.child_layout.min_w = None;
+ n.child_layout.max_w = Some(60.0);
+ }
+ layout_frame(&mut d, f, &NoMeasure);
+ assert_eq!(d.node(f).unwrap().width, 60.0);
+ // A fill child with a min width wider than its share keeps the min.
+ let (mut d, f, k) = setup(LayoutMode::Horizontal);
+ for c in &k {
+ d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill;
+ }
+ d.get_mut(k[0]).unwrap().child_layout.min_w = Some(200.0);
+ layout_frame(&mut d, f, &NoMeasure);
+ assert_eq!(pos(&d, k[0]).2, 200.0);
+ assert_eq!(pos(&d, k[1]).2, 80.0);
+}
+
+#[test]
+fn min_over_max_and_junk_limits() {
+ let (mut d, f, k) = setup(LayoutMode::Vertical);
+ {
+ let cl = &mut d.get_mut(k[0]).unwrap().child_layout;
+ cl.min_w = Some(90.0);
+ cl.max_w = Some(40.0);
+ cl.min_h = Some(f64::NAN);
+ cl.max_h = Some(f64::NEG_INFINITY);
+ }
+ layout_frame(&mut d, f, &NoMeasure);
+ // Min wins over a smaller max; non-finite limits are ignored.
+ assert_eq!(pos(&d, k[0]).2, 90.0);
+ assert_eq!(pos(&d, k[0]).3, 20.0);
+ assert_eq!(clamp_size(-5.0, None, None), 0.0);
+ assert_eq!(clamp_size(f64::NAN, Some(3.0), None), 3.0);
+}
+
+#[test]
+fn hug_text_wraps_at_max_width() {
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(f).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..Default::default() });
+ let t = text(&mut d, f, "Long");
+ d.get_mut(t).unwrap().child_layout.sizing_h = Sizing::Hug;
+ let m = FakeMeasure([(t, (200.0, 20.0, 15.0))].into_iter().collect());
+ layout_frame(&mut d, f, &m);
+ assert_eq!(pos(&d, t).2, 200.0);
+ d.get_mut(t).unwrap().child_layout.max_w = Some(80.0);
+ layout_frame(&mut d, f, &m);
+ let n = d.node(t).unwrap();
+ assert_eq!((n.width, n.height), (80.0, 60.0));
+ assert!(matches!(&n.kind, NodeKind::Text(tx) if tx.auto_resize == TextAutoResize::Height));
+ // Without the max it hugs its natural width again.
+ d.get_mut(t).unwrap().child_layout.max_w = None;
+ layout_frame(&mut d, f, &m);
+ let n = d.node(t).unwrap();
+ assert_eq!((n.width, n.height), (200.0, 20.0));
+ assert!(matches!(&n.kind, NodeKind::Text(tx) if tx.auto_resize == TextAutoResize::WidthAndHeight));
+}
+
+#[test]
+fn absolute_children_follow_constraints() {
+ let (mut d, f, _) = setup(LayoutMode::Horizontal);
+ let abs = d.create(f, NodeKind::Rectangle, "Badge", 280.0, 4.0, 10.0, 10.0).unwrap();
+ {
+ let n = d.get_mut(abs).unwrap();
+ n.child_layout.absolute = true;
+ n.constraints = figcraft_doc::Constraints { horizontal: HConstraint::Right, vertical: VConstraint::Top };
+ }
+ d.get_mut(f).unwrap().child_layout.sizing_h = Sizing::Hug;
+ layout_frame(&mut d, f, &NoMeasure);
+ // Hugging shrank the frame 300 → 100: the badge keeps its distance from the right edge.
+ assert_eq!(d.node(f).unwrap().width, 100.0);
+ assert_eq!(pos(&d, abs), (80.0, 4.0, 10.0, 10.0));
+ assert!(!flow_children(&d, f).contains(&abs));
+ // A plain resize of an auto layout frame moves only absolute children by their constraints.
+ let first = pos(&d, d.node(f).unwrap().children[0]);
+ set_size(&mut d, f, 150.0, 100.0);
+ apply_constraints(&mut d, f, 100.0, 100.0);
+ assert_eq!(pos(&d, abs).0, 130.0);
+ assert_eq!(pos(&d, d.node(f).unwrap().children[0]), first);
+}
+
+#[test]
+fn baseline_alignment_horizontal() {
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(f).unwrap().layout = Some(AutoLayout { padding: [5.0; 4], gap: 0.0, cross_align: CrossAlign::Baseline, ..Default::default() });
+ d.get_mut(f).unwrap().child_layout.sizing_v = Sizing::Hug;
+ let small = text(&mut d, f, "small");
+ let big = text(&mut d, f, "big");
+ let rect = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 20.0).unwrap();
+ let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0))].into_iter().collect());
+ layout_frame(&mut d, f, &m);
+ // Baselines line up at 5 + 30; the rectangle (no baseline) sits at the top.
+ assert_eq!(pos(&d, small).1, 25.0);
+ assert_eq!(pos(&d, big).1, 5.0);
+ assert_eq!(pos(&d, rect).1, 5.0);
+ // Hug height covers the shifted small text: max(20 + 14, 40) + padding.
+ assert_eq!(d.node(f).unwrap().height, 50.0);
+ // Nested auto layout frames align on their first child's baseline.
+ let outer = d.create(p, NodeKind::Frame, "O", 0.0, 0.0, 400.0, 100.0).unwrap();
+ d.get_mut(outer).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], gap: 0.0, cross_align: CrossAlign::Baseline, ..Default::default() });
+ d.reparent(f, outer, None).unwrap();
+ let t2 = text(&mut d, outer, "t2");
+ let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0)), (t2, (20.0, 60.0, 50.0))].into_iter().collect());
+ layout_page(&mut d, p, &m);
+ // Frame baseline = 5 + 30 = 35; t2's is 50 → the frame moves down 15.
+ assert_eq!(pos(&d, f).1, 15.0);
+ assert_eq!(pos(&d, t2).1, 0.0);
+ // Vertical layouts read baseline as left.
+ d.get_mut(outer).unwrap().layout.as_mut().unwrap().mode = LayoutMode::Vertical;
+ layout_frame(&mut d, outer, &m);
+ assert_eq!(pos(&d, f).0, 0.0);
+ assert_eq!(pos(&d, t2).0, 0.0);
+}
+
+fn grid_frame(cols: u32, w: f64, h: f64) -> (Document, NodeId) {
+ let mut d = Document::new("t");
+ let p = d.first_page().unwrap();
+ let f = d.create(p, NodeKind::Frame, "G", 0.0, 0.0, w, h).unwrap();
+ d.get_mut(f).unwrap().layout = Some(AutoLayout {
+ mode: LayoutMode::Grid,
+ grid_columns: cols,
+ grid_rows: 1,
+ gap: 10.0,
+ cross_gap: 10.0,
+ padding: [0.0; 4],
+ ..Default::default()
+ });
+ (d, f)
+}
+
+#[test]
+fn grid_tracks_fixed_hug_fr() {
+ use figcraft_doc::GridTrack;
+ let (mut d, f) = grid_frame(4, 400.0, 100.0);
+ d.get_mut(f).unwrap().layout.as_mut().unwrap().column_tracks =
+ vec![GridTrack::fixed(50.0), GridTrack::hug(), GridTrack::fill(1.0), GridTrack::fill(2.0)];
+ let ids: Vec = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 30.0 + i as f64, 20.0).unwrap()).collect();
+ for c in &ids {
+ d.get_mut(*c).unwrap().child_layout.sizing_h = Sizing::Fill;
+ }
+ // The hug column fits its (fixed) child.
+ d.get_mut(ids[1]).unwrap().child_layout.sizing_h = Sizing::Fixed;
+ layout_frame(&mut d, f, &NoMeasure);
+ let g = grid_geometry(&d, f).unwrap();
+ // 400 - 3 gaps (30) - 50 - 31 = 289 free → 1fr 96.33, 2fr 192.67.
+ assert_eq!(g.columns[0], (0.0, 50.0));
+ assert_eq!(g.columns[1], (60.0, 91.0));
+ assert!((g.columns[2].1 - g.columns[2].0 - 289.0 / 3.0).abs() < 1e-9);
+ assert!((g.columns[3].1 - 400.0).abs() < 1e-9);
+ assert_eq!(pos(&d, ids[0]).2, 50.0);
+ assert!((pos(&d, ids[3]).2 - 289.0 * 2.0 / 3.0).abs() < 1e-9);
+ // The only row fills the height.
+ assert_eq!(g.rows, vec![(0.0, 100.0)]);
+}
+
+#[test]
+fn grid_cells_spans_and_anchors() {
+ let (mut d, f) = grid_frame(3, 320.0, 200.0);
+ let ids: Vec = (0..4).map(|i| d.create(f, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect();
+ // R0 spans two columns and fills them; R3 is anchored at column 0, row 2.
+ {
+ let cl = &mut d.get_mut(ids[0]).unwrap().child_layout;
+ cl.grid_column_span = 2;
+ cl.sizing_h = Sizing::Fill;
+ }
+ {
+ let cl = &mut d.get_mut(ids[3]).unwrap().child_layout;
+ cl.grid_column = Some(0);
+ cl.grid_row = Some(2);
+ }
+ layout_frame(&mut d, f, &NoMeasure);
+ let g = grid_geometry(&d, f).unwrap();
+ let cell = |id: NodeId| g.cells.iter().find(|c| c.id == id).copied().unwrap();
+ assert_eq!((cell(ids[0]).column, cell(ids[0]).row, cell(ids[0]).column_span), (0, 0, 2));
+ assert_eq!((cell(ids[1]).column, cell(ids[1]).row), (2, 0));
+ assert_eq!((cell(ids[2]).column, cell(ids[2]).row), (0, 1));
+ assert_eq!((cell(ids[3]).column, cell(ids[3]).row), (0, 2));
+ assert_eq!(g.rows.len(), 3);
+ // Columns are 100 wide: the spanning child covers 100 + 10 + 100.
+ assert_eq!(pos(&d, ids[0]).2, 210.0);
+ assert_eq!(pos(&d, ids[1]).0, 220.0);
+ assert_eq!(pos(&d, ids[3]).1, 140.0);
+}
+
+#[test]
+fn grid_hug_with_span() {
+ use figcraft_doc::GridTrack;
+ let (mut d, f) = grid_frame(2, 10.0, 10.0);
+ {
+ let n = d.get_mut(f).unwrap();
+ n.child_layout.sizing_h = Sizing::Hug;
+ n.child_layout.sizing_v = Sizing::Hug;
+ n.layout.as_mut().unwrap().column_tracks = vec![GridTrack::hug(), GridTrack::fixed(20.0)];
+ }
+ d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 40.0, 10.0).unwrap();
+ let wide = d.create(f, NodeKind::Rectangle, "W", 0.0, 0.0, 100.0, 30.0).unwrap();
+ d.get_mut(wide).unwrap().child_layout.grid_column_span = 2;
+ layout_frame(&mut d, f, &NoMeasure);
+ // Column 0 grows so the span fits: 100 - 10 gap - 20 fixed = 70.
+ let n = d.node(f).unwrap();
+ assert_eq!((n.width, n.height), (100.0, 10.0 + 10.0 + 30.0));
+ assert_eq!(pos(&d, wide).1, 20.0);
+}
+
+#[test]
+fn grid_hostile_values() {
+ use figcraft_doc::GridTrack;
+ let (mut d, f) = grid_frame(0, f64::NAN, -5.0);
+ {
+ let l = d.get_mut(f).unwrap().layout.as_mut().unwrap();
+ l.column_tracks = vec![GridTrack { sizing: Sizing::Fixed, value: f64::NAN }, GridTrack::fill(f64::INFINITY), GridTrack::fill(-3.0)];
+ l.row_tracks = vec![GridTrack::fixed(1e300)];
+ l.grid_rows = u32::MAX;
+ }
+ let r = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
+ {
+ let cl = &mut d.get_mut(r).unwrap().child_layout;
+ cl.grid_column = Some(u32::MAX);
+ cl.grid_row = Some(u32::MAX);
+ cl.grid_column_span = u32::MAX;
+ cl.grid_row_span = u32::MAX;
+ cl.sizing_h = Sizing::Fill;
+ }
+ for i in 0..20 {
+ let c = d.create(f, NodeKind::Rectangle, &format!("K{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(c).unwrap().child_layout.grid_column_span = 3;
+ }
+ layout_frame(&mut d, f, &NoMeasure);
+ let g = grid_geometry(&d, f).unwrap();
+ assert!(g.columns.len() == 1 && g.rows.len() <= 1000);
+ assert!(g.width.is_finite() && g.height.is_finite());
+ for id in d.node(f).unwrap().children.clone() {
+ let (x, y, w, h) = pos(&d, id);
+ assert!(x.is_finite() && y.is_finite() && w.is_finite() && h.is_finite());
+ }
+}
diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs
index d2d756d..3c26253 100644
--- a/crates/ui-egui/src/handles.rs
+++ b/crates/ui-egui/src/handles.rs
@@ -1,6 +1,7 @@
//! On-canvas editing handles that sit above the pointer tools: gradient handles (while a
//! gradient fill is open in the colour picker), image crop handles (while a Crop-mode image
-//! fill is open) and auto layout padding/gap handles.
+//! fill is open) and auto layout padding/gap handles (item gaps, wrapped row gaps, grid column
+//! and row gaps, with the grid's tracks outlined).
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2};
use figcraft_color::{ImagePaint, ImageScaleMode, Paint, PaintKind};
@@ -17,34 +18,13 @@ const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
#[derive(Clone, Debug)]
enum Drag {
/// Gradient handle 0 = from, 1 = to, 2 = width.
- Gradient {
- id: NodeId,
- fills: bool,
- index: usize,
- which: usize,
- },
+ Gradient { id: NodeId, fills: bool, index: usize, which: usize },
/// Padding side 0 top, 1 right, 2 bottom, 3 left.
- Padding {
- id: NodeId,
- side: usize,
- start: Pos2,
- value: f64,
- },
- Gap {
- id: NodeId,
- start: Pos2,
- value: f64,
- vertical: bool,
- },
+ Padding { id: NodeId, side: usize, start: Pos2, value: f64 },
+ /// A gap band: `key` is the prop it edits (`gap` or `crossGap`), `vertical` the drag axis.
+ Gap { id: NodeId, start: Pos2, value: f64, vertical: bool, key: &'static str },
/// Image crop: corner 0..4 (tl, tr, br, bl) scales about the opposite corner, 4 moves.
- Crop {
- id: NodeId,
- fills: bool,
- index: usize,
- which: usize,
- start: (f64, f64),
- rect: (f64, f64, f64, f64),
- },
+ Crop { id: NodeId, fills: bool, index: usize, which: usize, start: (f64, f64), rect: (f64, f64, f64, f64) },
}
/// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box).
@@ -111,9 +91,25 @@ fn screen_to_unit(app: &FigApp, id: NodeId, s: Pos2) -> Option<(f64, f64)> {
Some((l.x / n.width.max(1e-9), l.y / n.height.max(1e-9)))
}
-/// Auto layout zones of a single selected AL frame, in screen space: padding bands and gaps.
-/// (frame, padding bands (side, rect, value), gap rects, gap, vertical)
-type Zones = (NodeId, Vec<(usize, Rect, f64)>, Vec, f64, bool);
+/// A gap band between auto layout items (screen space): the prop it edits, its value and the
+/// drag axis.
+#[derive(Clone, Copy)]
+struct GapZone {
+ rect: Rect,
+ key: &'static str,
+ value: f64,
+ vertical: bool,
+}
+
+/// Auto layout zones of a single selected AL frame, in screen space.
+struct Zones {
+ id: NodeId,
+ /// Padding bands: (side, rect, value).
+ bands: Vec<(usize, Rect, f64)>,
+ gaps: Vec,
+ /// Grid frames: the track boundaries to outline (screen-space segments).
+ tracks: Vec<[Pos2; 2]>,
+}
fn al_zones(app: &FigApp) -> Option {
let ds = app.session.ds().ok()?;
@@ -133,30 +129,73 @@ fn al_zones(app: &FigApp) -> Option {
let p = w * Point::new(x, y);
app.to_screen(p.x, p.y)
};
+ let band = |x0: f64, y0: f64, x1: f64, y1: f64| Rect::from_two_pos(to(x0, y0), to(x1, y1));
let [pt, pr, pb, pl] = al.padding;
let (fw, fh) = (n.width, n.height);
let bands = vec![
- (0, Rect::from_two_pos(to(0.0, 0.0), to(fw, pt.max(4.0))), pt),
- (1, Rect::from_two_pos(to(fw - pr.max(4.0), 0.0), to(fw, fh)), pr),
- (2, Rect::from_two_pos(to(0.0, fh - pb.max(4.0)), to(fw, fh)), pb),
- (3, Rect::from_two_pos(to(0.0, 0.0), to(pl.max(4.0), fh)), pl),
+ (0, band(0.0, 0.0, fw, pt.max(4.0)), pt),
+ (1, band(fw - pr.max(4.0), 0.0, fw, fh), pr),
+ (2, band(0.0, fh - pb.max(4.0), fw, fh), pb),
+ (3, band(0.0, 0.0, pl.max(4.0), fh), pl),
];
- let vertical = al.mode == LayoutMode::Vertical;
+ let main = |rect: Rect, vertical: bool| GapZone { rect, key: "gap", value: al.gap, vertical };
+ let cross = |rect: Rect, vertical: bool| GapZone { rect, key: "crossGap", value: al.cross_gap, vertical };
let mut gaps = Vec::new();
- if al.mode != LayoutMode::Grid && !al.wrap {
- let kids: Vec<_> = n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible && !c.child_layout.absolute).collect();
- for pair in kids.windows(2) {
- let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
- let ra = figcraft_geom::transform_rect_bbox(a.transform, a.local_rect());
- let rb = figcraft_geom::transform_rect_bbox(b.transform, b.local_rect());
- let r =
- if vertical { Rect::from_two_pos(to(pl, ra.y1), to(fw - pr, rb.y0)) } else { Rect::from_two_pos(to(ra.x1, pt), to(rb.x0, fh - pb)) };
- if r.width() > 0.5 && r.height() > 0.5 {
- gaps.push(r);
+ let mut tracks = Vec::new();
+ if let Some(g) = figcraft_layout::grid_geometry(doc, id) {
+ // Grid: bands between columns (column gap) and between rows (row gap), and the tracks.
+ let (top, bottom) = (g.rows.first().map(|r| r.0).unwrap_or(pt), g.rows.last().map(|r| r.1).unwrap_or(fh - pb));
+ let (left, right) = (g.columns.first().map(|c| c.0).unwrap_or(pl), g.columns.last().map(|c| c.1).unwrap_or(fw - pr));
+ for pair in g.columns.windows(2) {
+ if let [a, b] = pair {
+ gaps.push(main(band(a.1, top, b.0, bottom), false));
+ }
+ }
+ for pair in g.rows.windows(2) {
+ if let [a, b] = pair {
+ gaps.push(cross(band(left, a.1, right, b.0), true));
+ }
+ }
+ for c in &g.columns {
+ tracks.push([to(c.0, top), to(c.0, bottom)]);
+ tracks.push([to(c.1, top), to(c.1, bottom)]);
+ }
+ for r in &g.rows {
+ tracks.push([to(left, r.0), to(right, r.0)]);
+ tracks.push([to(left, r.1), to(right, r.1)]);
+ }
+ } else {
+ let vertical = al.mode == LayoutMode::Vertical;
+ let boxes: Vec = figcraft_layout::flow_children(doc, id)
+ .into_iter()
+ .filter_map(|c| doc.node(c))
+ .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))
+ .collect();
+ // Lines: a wrapped item starts left of the previous one.
+ let mut lines: Vec> = Vec::new();
+ for b in boxes {
+ match lines.last_mut() {
+ Some(line) if vertical || !al.wrap || line.last().is_some_and(|a| b.x0 >= a.x1 - 1e-6) => line.push(b),
+ _ => lines.push(vec![b]),
+ }
+ }
+ let span = |line: &[figcraft_geom::Rect]| line.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), r| (a.min(r.y0), b.max(r.y1)));
+ for line in &lines {
+ let (y0, y1) = if al.wrap { span(line) } else { (pt, fh - pb) };
+ for pair in line.windows(2) {
+ if let [ra, rb] = pair {
+ gaps.push(main(if vertical { band(pl, ra.y1, fw - pr, rb.y0) } else { band(ra.x1, y0, rb.x0, y1) }, vertical));
+ }
+ }
+ }
+ for pair in lines.windows(2) {
+ if let [a, b] = pair {
+ gaps.push(cross(band(pl, span(a).1, fw - pr, span(b).0), true));
}
}
}
- Some((id, bands, gaps, al.gap, vertical))
+ gaps.retain(|g| g.rect.width() > 0.5 && g.rect.height() > 0.5);
+ Some(Zones { id, bands, gaps, tracks })
}
/// Draw the handles.
@@ -203,12 +242,15 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
if !tool_move && dragging.is_none() {
return;
}
- let Some((_, bands, gaps, gap, _)) = al_zones(app) else { return };
+ let Some(z) = al_zones(app) else { return };
let active_side = match &dragging {
Some(Drag::Padding { side, .. }) => Some(*side),
_ => None,
};
- for (side, r, v) in &bands {
+ for seg in &z.tracks {
+ painter.add(Shape::dashed_line(seg, Stroke::new(1.0, PINK.gamma_multiply(0.6)), 3.0, 3.0));
+ }
+ for (side, r, v) in &z.bands {
let hot = hover.is_some_and(|h| r.contains(h)) || active_side == Some(*side);
if hot {
painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.25));
@@ -219,14 +261,18 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
}
}
- let gap_drag = matches!(dragging, Some(Drag::Gap { .. }));
- for r in &gaps {
- let hot = hover.is_some_and(|h| r.contains(h)) || gap_drag;
+ let drag_key = match &dragging {
+ Some(Drag::Gap { key, .. }) => Some(*key),
+ _ => None,
+ };
+ for g in &z.gaps {
+ let r = g.rect;
+ let hot = hover.is_some_and(|h| r.contains(h)) || drag_key == Some(g.key);
if hot {
- painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.3));
+ painter.rect_filled(r, CornerRadius::ZERO, PINK.gamma_multiply(0.3));
let c = r.center();
painter.line_segment([pos2(c.x - 3.0, c.y), pos2(c.x + 3.0, c.y)], Stroke::new(1.0, PINK));
- let galley = painter.layout_no_wrap(crate::canvas::fmt(gap), semibold(10.0), Color32::WHITE);
+ let galley = painter.layout_no_wrap(crate::canvas::fmt(g.value), semibold(10.0), Color32::WHITE);
let lr = Rect::from_center_size(c, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(lr, CornerRadius::same(3), PINK);
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
@@ -303,7 +349,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
if app.session.tool.tool != figcraft_engine::ToolId::Move || app.session.tool.busy() {
return false;
}
- let Some((id, bands, gaps, gap, vertical)) = al_zones(app) else { return false };
+ let Some(Zones { id, bands, gaps, .. }) = al_zones(app) else { return false };
for (side, r, v) in bands {
if r.contains(pos) {
ctx.set_cursor_icon(if side % 2 == 0 { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
@@ -315,12 +361,12 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
return false;
}
}
- for r in gaps {
- if r.contains(pos) {
- ctx.set_cursor_icon(if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
+ for g in gaps {
+ if g.rect.contains(pos) {
+ ctx.set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
if pressed {
let _ = app.session.begin_interaction("Gap");
- ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gap { id, start: pos, value: gap, vertical }));
+ ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gap { id, start: pos, value: g.value, vertical: g.vertical, key: g.key }));
return true;
}
return false;
@@ -396,10 +442,10 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
let cmd = if *fills { "fill.set" } else { "stroke.set" };
let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint}));
}
- Drag::Gap { id, start, value, vertical } => {
+ Drag::Gap { id, start, value, vertical, key } => {
let d = f64::from(if *vertical { pos.y - start.y } else { pos.x - start.x }) / zoom;
let v = (value + d).round().max(0.0);
- let _ = app.session.execute("node.set", &json!({"ids": [id], "props": {"gap": v}}));
+ let _ = app.session.execute("node.set", &json!({"ids": [id], "props": { *key: v }}));
}
}
}
diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs
index 4c23b47..fc8c2ad 100644
--- a/crates/ui-egui/src/icons.rs
+++ b/crates/ui-egui/src/icons.rs
@@ -116,6 +116,8 @@ pub enum Icon {
Prototype,
Section2,
Logo,
+ /// Ignore auto layout: a layer pinned at an offset inside its frame.
+ AbsolutePosition,
}
struct P<'a> {
@@ -647,6 +649,13 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
p.poly(&[(6.0, 10.0), (9.0, 6.0), (12.5, 4.0)], false);
p.poly(&[(10.0, 3.0), (12.8, 3.8), (12.0, 6.6)], false);
}
+ AbsolutePosition => {
+ // The frame, a small layer near its top-right corner, and the two offsets pinning it.
+ p.rect(2.5, 2.5, 13.5, 13.5, 1.5);
+ p.fill_rect(7.0, 6.0, 10.5, 9.5, 0.5);
+ p.line(10.5, 7.75, 13.5, 7.75);
+ p.line(8.75, 2.5, 8.75, 6.0);
+ }
Logo => {
// FigCraft's own mark: three stacked rounded tiles forming an "F" shape.
p.fill_rect(3.0, 2.0, 13.0, 5.5, 1.75);
diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs
new file mode 100644
index 0000000..74fece6
--- /dev/null
+++ b/crates/ui-egui/src/layout_panel.rs
@@ -0,0 +1,433 @@
+//! The Design panel's layout section: auto layout direction, W/H with their sizing (Fixed / Hug /
+//! Fill) and min/max limits, alignment (including text baselines), gaps, padding, grid tracks,
+//! a grid child's cell and spans, and clip content. Everything goes through `node.set` props or
+//! `layout.*` commands.
+
+use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
+use figcraft_doc::*;
+use serde_json::json;
+
+use crate::FigApp;
+use crate::icons::{self, Icon};
+use crate::right::{block, common, menu_item, row};
+use crate::theme::{ACCENT, Tokens, regular};
+use crate::widgets::{self, FIELD_H, Lead};
+
+/// Most tracks listed in the panel (the rest are still laid out; edit them through commands).
+const MAX_TRACK_ROWS: usize = 24;
+
+fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
+ doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p))
+}
+
+pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+ let ids = sel.to_vec();
+ let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
+ let al = common(doc, sel, |n| n.layout.clone());
+ let has_al = matches!(al, Some(Some(_)));
+ let title = if has_al { "Auto layout" } else { "Layout" };
+ let btns: Vec<(Icon, &str)> = if frames && !has_al {
+ vec![(Icon::Plus, "Add auto layout ⇧A")]
+ } else if has_al {
+ vec![(Icon::Minus, "Remove auto layout ⌥⇧A")]
+ } else {
+ vec![]
+ };
+ if widgets::section_header(ui, title, &btns, false).is_some() {
+ app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids}));
+ }
+ if let Some(Some(l)) = &al {
+ direction(app, ui, &ids, l);
+ }
+ dimensions(app, ui, doc, sel, has_al);
+ if let Some(Some(l)) = &al {
+ alignment(app, ui, &ids, l);
+ spacing(app, ui, &ids, l);
+ if l.mode == LayoutMode::Grid && sel.len() == 1 {
+ tracks(app, ui, doc, &ids);
+ }
+ }
+ let grid_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid));
+ if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) {
+ grid_cell(app, ui, doc, sel);
+ }
+ if frames {
+ let clip = common(doc, sel, |n| n.clip);
+ row(ui, 32.0, |ui| {
+ if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") {
+ app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)}));
+ }
+ });
+ }
+}
+
+fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
+ row(ui, 32.0, |ui| {
+ let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")];
+ let active = match (l.mode, l.wrap) {
+ (LayoutMode::Vertical, _) => 0,
+ (LayoutMode::Horizontal, false) => 1,
+ (LayoutMode::Horizontal, true) => 2,
+ (LayoutMode::Grid, _) => 3,
+ };
+ if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) {
+ let p = match i {
+ 0 => json!({"layout": "vertical", "wrap": false}),
+ 1 => json!({"layout": "horizontal", "wrap": false}),
+ 2 => json!({"layout": "horizontal", "wrap": true}),
+ _ => json!({"layout": "grid"}),
+ };
+ app.set_props(ids, p);
+ }
+ });
+}
+
+/// One axis' sizing, min/max keys and labels.
+struct Axis {
+ sizing: &'static str,
+ min: &'static str,
+ max: &'static str,
+ noun: &'static str,
+ letter: &'static str,
+}
+
+const AXES: [Axis; 2] = [
+ Axis { sizing: "sizingH", min: "minWidth", max: "maxWidth", noun: "width", letter: "W" },
+ Axis { sizing: "sizingV", min: "minHeight", max: "maxHeight", noun: "height", letter: "H" },
+];
+
+/// A node's min (`is_min`) or max limit on one axis.
+fn limit(n: &Node, horizontal: bool, is_min: bool) -> Option {
+ let cl = &n.child_layout;
+ match (horizontal, is_min) {
+ (true, true) => cl.min_w,
+ (true, false) => cl.max_w,
+ (false, true) => cl.min_h,
+ (false, false) => cl.max_h,
+ }
+}
+
+fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool) {
+ let ids = sel.to_vec();
+ let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0);
+ let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0);
+ let col = widgets::col_width(ui.available_width());
+ widgets::sublabels(ui, "Dimensions", None);
+ row(ui, 32.0, |ui| {
+ let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0);
+ app.field(&ids, "width", e, |v| json!(v.max(0.0)));
+ let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0);
+ app.field(&ids, "height", e, |v| json!(v.max(0.0)));
+ let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false);
+ if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() {
+ app.set_props(&ids, json!({"constrainProportions": !locked}));
+ }
+ });
+ let parent_al = sel.iter().all(|i| parent_of(doc, *i).is_some_and(|p| p.layout.is_some()));
+ let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_))));
+ if !(has_al || parent_al || is_text) {
+ return;
+ }
+ // Min/max apply to auto layout frames and their children; absolute children neither fill nor limit.
+ let can_fill = parent_al && sel.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute));
+ let can_limit = has_al || can_fill;
+ let first = sel.first().and_then(|i| doc.node(*i));
+ row(ui, 32.0, |ui| {
+ for (ax, axis) in AXES.iter().enumerate() {
+ let horizontal = ax == 0;
+ let cur = common(doc, sel, |n| if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v });
+ let any_limit =
+ sel.iter().filter_map(|i| doc.node(*i)).any(|n| limit(n, horizontal, true).is_some() || limit(n, horizontal, false).is_some());
+ let label = cur.map(|s| s.label()).unwrap_or("Mixed");
+ let icon = match cur {
+ Some(Sizing::Hug) => Icon::Hug,
+ Some(Sizing::Fill) => Icon::FillContainer,
+ _ => Icon::Fixed,
+ };
+ let resp = widgets::dropdown(ui, label, col, Some(icon));
+ if any_limit {
+ // Limits set: tick marks on the sizing icon's ends.
+ let c = pos2(resp.rect.min.x + 14.0, resp.rect.center().y);
+ let tint = Tokens::get(ui.ctx()).icon_secondary;
+ for dx in [-8.5, 8.5] {
+ ui.painter().line_segment([c + vec2(dx, -4.0), c + vec2(dx, 4.0)], Stroke::new(1.0, tint));
+ }
+ }
+ let current = first.map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0);
+ let ids2 = ids.clone();
+ egui::Popup::menu(&resp).width(200.0).show(|ui| {
+ let mut opts = vec![("Fixed", "fixed")];
+ if has_al || is_text {
+ opts.push(("Hug contents", "hug"));
+ }
+ if can_fill {
+ opts.push(("Fill container", "fill"));
+ }
+ for (l, v) in opts {
+ if menu_item(ui, l, "") {
+ app.set_props(&ids2, json!({axis.sizing: v}));
+ ui.close();
+ }
+ }
+ if can_limit {
+ ui.separator();
+ let (min, max) = (first.and_then(|n| limit(n, horizontal, true)), first.and_then(|n| limit(n, horizontal, false)));
+ if min.is_none() && menu_item(ui, &format!("Add min {}", axis.noun), "") {
+ app.set_props(&ids2, json!({axis.min: current}));
+ ui.close();
+ }
+ if max.is_none() && menu_item(ui, &format!("Add max {}", axis.noun), "") {
+ app.set_props(&ids2, json!({axis.max: current}));
+ ui.close();
+ }
+ if any_limit && menu_item(ui, "Remove min and max", "") {
+ app.set_props(&ids2, json!({axis.min: null, axis.max: null}));
+ ui.close();
+ }
+ }
+ });
+ }
+ });
+ if can_limit {
+ for (ax, axis) in AXES.iter().enumerate() {
+ limit_row(app, ui, doc, sel, ax == 0, axis, col);
+ }
+ }
+}
+
+/// The min/max fields of one axis (shown once either is set): min in the first column, max in the
+/// second, an empty slot offers to add the missing one; the button removes both.
+fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], horizontal: bool, axis: &Axis, col: f32) {
+ let nodes: Vec<&Node> = sel.iter().filter_map(|i| doc.node(*i)).collect();
+ let has = |is_min: bool| nodes.iter().any(|n| limit(n, horizontal, is_min).is_some());
+ if !has(true) && !has(false) {
+ return;
+ }
+ let ids = sel.to_vec();
+ let current = nodes.first().map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0);
+ row(ui, 32.0, |ui| {
+ for (is_min, key) in [(true, axis.min), (false, axis.max)] {
+ let lead = format!("{} {}", if is_min { "Min" } else { "Max" }, axis.letter);
+ if has(is_min) {
+ let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten();
+ let e = widgets::number_field(ui, Id::new(("lay_limit", key)), Lead::Text(&lead), v, "", col, 1.0);
+ app.field(&ids, key, e, |v| json!(v.max(0.0)));
+ } else if add_slot(ui, &lead, col).clicked() {
+ app.set_props(&ids, json!({key: current}));
+ }
+ }
+ if icons::button(ui, Icon::Minus, 24.0, false, &format!("Remove min and max {}", axis.noun)).clicked() {
+ app.set_props(&ids, json!({axis.min: null, axis.max: null}));
+ }
+ });
+}
+
+/// A quiet placeholder button the size of a field ("+ Max W").
+fn add_slot(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
+ let t = Tokens::get(ui.ctx());
+ let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click());
+ let resp = resp.on_hover_text(format!("Add {}", label.to_lowercase()));
+ let border = if resp.hovered() { t.field_hover_border } else { t.border };
+ ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, border), StrokeKind::Inside);
+ icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), Icon::Plus, t.icon_secondary);
+ ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary);
+ resp
+}
+
+fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
+ let t = Tokens::get(ui.ctx());
+ let grid_mode = l.mode == LayoutMode::Grid;
+ block(ui, 76.0, 6.0, |ui| {
+ let (grid, _) = ui.allocate_exact_size(vec2(72.0, 64.0), Sense::hover());
+ ui.painter().rect_filled(grid, CornerRadius::same(5), t.field);
+ let horizontal = l.mode != LayoutMode::Vertical;
+ let cur_main = match l.main_align {
+ MainAlign::Min => 0,
+ MainAlign::Center => 1,
+ MainAlign::Max => 2,
+ MainAlign::SpaceBetween => 9,
+ };
+ let cur_cross = match l.cross_align {
+ CrossAlign::Min | CrossAlign::Baseline => 0,
+ CrossAlign::Center => 1,
+ CrossAlign::Max => 2,
+ };
+ for yi in 0..3 {
+ for xi in 0..3 {
+ let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0);
+ let r = Rect::from_center_size(c, vec2(22.0, 20.0));
+ let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click());
+ let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) };
+ let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i;
+ if on {
+ ui.painter().rect_filled(
+ Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })),
+ CornerRadius::same(1),
+ ACCENT,
+ );
+ } else {
+ ui.painter().circle_filled(c, if resp.hovered() { 2.5 } else { 1.5 }, if resp.hovered() { ACCENT } else { t.text_tertiary });
+ }
+ if resp.clicked() {
+ let m = ["min", "center", "max"][main_i];
+ let cr = ["min", "center", "max"][cross_i];
+ let main = if l.main_align == MainAlign::SpaceBetween && !grid_mode { "spaceBetween" } else { m };
+ app.set_props(ids, json!({"mainAlign": main, "crossAlign": cr}));
+ }
+ }
+ }
+ ui.vertical(|ui| {
+ if grid_mode {
+ // Grid: gap between columns and between rows.
+ let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), Some(l.gap), "", 96.0, 1.0);
+ app.field(ids, "columnGap", e, |v| json!(v.max(0.0)));
+ ui.add_space(8.0);
+ let e = widgets::number_field(ui, Id::new("al_rowgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", 96.0, 1.0);
+ app.field(ids, "rowGap", e, |v| json!(v.max(0.0)));
+ return;
+ }
+ let auto = l.main_align == MainAlign::SpaceBetween;
+ let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap };
+ let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(gap_icon), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0);
+ app.field(ids, "gap", e, |v| json!(v));
+ ui.add_space(8.0);
+ let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None);
+ egui::Popup::menu(&resp).show(|ui| {
+ if menu_item(ui, "Fixed", "") {
+ app.set_props(ids, json!({"mainAlign": "min"}));
+ ui.close();
+ }
+ if menu_item(ui, "Auto (space between)", "") {
+ app.set_props(ids, json!({"mainAlign": "spaceBetween"}));
+ ui.close();
+ }
+ });
+ });
+ });
+ // Text baselines: horizontal stacks only.
+ if l.mode == LayoutMode::Horizontal {
+ let on = l.cross_align == CrossAlign::Baseline;
+ row(ui, 28.0, |ui| {
+ if widgets::checkbox(ui, on, "Align text baselines") {
+ app.set_props(ids, json!({"crossAlign": if on { "min" } else { "baseline" }}));
+ }
+ });
+ }
+}
+
+fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
+ let col = widgets::col_width(ui.available_width());
+ let [pt, pr, pb, pl] = l.padding;
+ row(ui, 32.0, |ui| {
+ let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), (pl == pr).then_some(pl), "", col, 1.0);
+ app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
+ let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), (pt == pb).then_some(pt), "", col, 1.0);
+ app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
+ });
+ if l.mode == LayoutMode::Horizontal && l.wrap {
+ // Wrapped rows: the gap between rows.
+ widgets::sublabels(ui, "Row gap", None);
+ row(ui, 32.0, |ui| {
+ let e = widgets::number_field(ui, Id::new("al_crossgap"), Lead::Icon(Icon::DistributeV), Some(l.cross_gap), "", col, 1.0);
+ app.field(ids, "crossGap", e, |v| json!(v.max(0.0)));
+ });
+ }
+ if l.mode == LayoutMode::Grid {
+ widgets::sublabels(ui, "Columns", Some("Rows"));
+ row(ui, 32.0, |ui| {
+ let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0);
+ app.field(ids, "gridColumns", e, |v| json!(v.max(1.0)));
+ let e = widgets::number_field(ui, Id::new("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0);
+ app.field(ids, "gridRows", e, |v| json!(v.max(1.0)));
+ });
+ }
+}
+
+/// Track sizes of a single grid frame: one row per column / row with its size and sizing.
+fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) {
+ let Some(id) = ids.first().copied() else { return };
+ let Some(l) = doc.node(id).and_then(|n| n.layout.clone()) else { return };
+ let Some(g) = figcraft_layout::grid_geometry(doc, id) else { return };
+ let col = widgets::col_width(ui.available_width());
+ for (axis, specs, ranges) in [("columns", &l.column_tracks, &g.columns), ("rows", &l.row_tracks, &g.rows)] {
+ widgets::sublabels(ui, if axis == "columns" { "Column sizes" } else { "Row sizes" }, None);
+ for (i, (a, b)) in ranges.iter().enumerate().take(MAX_TRACK_ROWS) {
+ let track = specs.get(i).copied().unwrap_or_default();
+ let size = ((b - a) * 100.0).round() / 100.0;
+ let base = json!({"ids": [id], "axis": axis, "index": i});
+ let with = |sizing: &str, value: Option| {
+ let mut p = base.clone();
+ p["sizing"] = json!(sizing);
+ if let Some(v) = value {
+ p["value"] = json!(v);
+ }
+ p
+ };
+ row(ui, 32.0, |ui| {
+ let label = (i + 1).to_string();
+ // Fill tracks edit their fr; fixed and hug tracks show (and set) a px size.
+ let (value, suffix) = if track.sizing == Sizing::Fill { (track.value, "fr") } else { (size, "") };
+ let e = widgets::number_field(
+ ui,
+ Id::new(("al_track", axis, i)),
+ Lead::Text(&label),
+ Some(value),
+ suffix,
+ col,
+ if suffix.is_empty() { 1.0 } else { 0.1 },
+ );
+ let fill = track.sizing == Sizing::Fill;
+ app.field_run(e, "layout.setTrack", |v| with(if fill { "fill" } else { "fixed" }, Some(v.max(0.0))));
+ let (label, icon) = match track.sizing {
+ Sizing::Fixed => ("Fixed", Icon::Fixed),
+ Sizing::Hug => ("Hug", Icon::Hug),
+ Sizing::Fill => ("Fill", Icon::FillContainer),
+ };
+ let resp = widgets::dropdown(ui, label, col, Some(icon));
+ egui::Popup::menu(&resp).show(|ui| {
+ for (l, v) in [("Fixed", "fixed"), ("Hug contents", "hug"), ("Fill (fr)", "fill")] {
+ if menu_item(ui, l, "") {
+ app.run("layout.setTrack", with(v, None));
+ ui.close();
+ }
+ }
+ });
+ });
+ }
+ }
+}
+
+/// A grid child's cell (single selection: column and row, 1-based; auto placement resets them)
+/// and its spans.
+fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
+ let ids = sel.to_vec();
+ let col = widgets::col_width(ui.available_width());
+ if let [id] = sel
+ && let Some(cell) =
+ parent_of(doc, *id).and_then(|p| figcraft_layout::grid_geometry(doc, p.id)).and_then(|g| g.cells.into_iter().find(|c| c.id == *id))
+ {
+ let anchored = doc.node(*id).is_some_and(|n| n.child_layout.grid_column.is_some() || n.child_layout.grid_row.is_some());
+ widgets::sublabels(ui, "Column", Some("Row"));
+ row(ui, 32.0, |ui| {
+ // Moving on one axis pins the other where it is, so the child doesn't jump.
+ let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}});
+ let (c0, r0) = ((cell.column + 1) as f64, (cell.row + 1) as f64);
+ let e = widgets::number_field(ui, Id::new("al_cell_col"), Lead::None, Some(c0), "", col, 1.0);
+ app.field_run(e, "node.set", |v| at(v, r0));
+ let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0);
+ app.field_run(e, "node.set", |v| at(c0, v));
+ if anchored && icons::button(ui, Icon::Reset, 24.0, false, "Place automatically").clicked() {
+ app.set_props(&ids, json!({"gridColumn": null, "gridRow": null}));
+ }
+ });
+ }
+ let cs = common(doc, sel, |n| f64::from(n.child_layout.column_span()));
+ let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span()));
+ widgets::sublabels(ui, "Column span", Some("Row span"));
+ row(ui, 32.0, |ui| {
+ let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0);
+ app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0)));
+ let e = widgets::number_field(ui, Id::new("al_span_r"), Lead::None, rs, "", col, 1.0);
+ app.field(&ids, "gridRowSpan", e, |v| json!(v.max(1.0)));
+ });
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c94e5ef..1aa9947 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -16,6 +16,7 @@ pub mod handles;
pub mod home;
pub mod icons;
pub mod layers;
+pub mod layout_panel;
pub mod menus;
pub mod present;
pub mod proto_canvas;
diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs
index 7ba5fc1..8a95810 100644
--- a/crates/ui-egui/src/right.rs
+++ b/crates/ui-egui/src/right.rs
@@ -28,11 +28,21 @@ impl FigApp {
/// Apply a field edit: scrubs are live previews committed as one step.
pub fn field(&mut self, ids: &[NodeId], key: &str, edit: Option, map: impl Fn(f64) -> Value) {
+ if ids.is_empty() {
+ return;
+ }
+ self.field_run(edit, "node.set", |v| json!({"ids": ids, "props": { key: map(v) }}));
+ }
+
+ /// Apply a field edit through any command: `params` builds the command's params from the value.
+ pub fn field_run(&mut self, edit: Option, cmd: &str, params: impl Fn(f64) -> Value) {
match edit {
- Some(FieldEdit::Set(v)) => self.set_props(ids, json!({ key: map(v) })),
+ Some(FieldEdit::Set(v)) => {
+ self.run(cmd, params(v));
+ }
Some(FieldEdit::Scrub(v, done)) => {
let _ = self.session.begin_interaction("Edit");
- let _ = self.session.execute("node.set", &json!({"ids": ids, "props": { key: map(v) }}));
+ let _ = self.session.execute(cmd, ¶ms(v));
if done {
let _ = self.session.commit_interaction();
}
@@ -165,11 +175,16 @@ pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
}
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
+ block(ui, h, (h - FIELD_H) / 2.0, add);
+}
+
+/// A panel row `h` tall whose content is inset `inset_y` from its top and bottom (for content
+/// taller than one field).
+pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) {
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
- let mut child = ui.new_child(
- egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, (h - FIELD_H) / 2.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),
- );
+ let mut child =
+ ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing = vec2(8.0, 0.0);
add(&mut child);
}
@@ -190,7 +205,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
None => position(app, ui, &doc, &sel),
}
widgets::separator(ui);
- layout(app, ui, &doc, &sel);
+ crate::layout_panel::layout_section(app, ui, &doc, &sel);
widgets::separator(ui);
appearance(app, ui, &doc, &sel);
widgets::separator(ui);
@@ -399,12 +414,23 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0);
let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0);
let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0);
+ let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
+ let absolute = common(doc, sel, |n| n.child_layout.absolute);
widgets::sublabels(ui, "Position", None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0);
app.field(&ids, "x", e, |v| json!(v));
let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0);
app.field(&ids, "y", e, |v| json!(v));
+ // Children of auto layout frames can ignore the layout (absolute position).
+ if parent_al {
+ let on = absolute.unwrap_or(false);
+ if icons::button(ui, Icon::AbsolutePosition, 24.0, on, if on { "Use auto layout" } else { "Ignore auto layout (absolute position)" })
+ .clicked()
+ {
+ app.set_props(&ids, json!({"absolute": !on}));
+ }
+ }
});
widgets::sublabels(ui, "Rotation", None);
row(ui, 32.0, |ui| {
@@ -415,10 +441,12 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids}));
}
});
- // Constraints (children of non-auto-layout frames).
- let in_frame = sel
- .iter()
- .all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && p.layout.is_none()));
+ // Constraints (children of frames without auto layout, or ignoring it).
+ let in_frame = sel.iter().all(|i| {
+ doc.node(*i).is_some_and(|n| {
+ n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))
+ })
+ });
if in_frame {
let c = common(doc, sel, |n| n.constraints);
widgets::sublabels(ui, "Constraints", None);
@@ -456,191 +484,6 @@ fn pretty(s: &str) -> String {
}
}
-fn layout(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
- let t = Tokens::get(ui.ctx());
- let ids = sel.to_vec();
- let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
- let al = common(doc, sel, |n| n.layout.clone());
- let has_al = matches!(al, Some(Some(_)));
- let title = if has_al { "Auto layout" } else { "Layout" };
- let btns: Vec<(Icon, &str)> = if frames && !has_al {
- vec![(Icon::Plus, "Add auto layout ⇧A")]
- } else if has_al {
- vec![(Icon::Minus, "Remove auto layout ⌥⇧A")]
- } else {
- vec![]
- };
- if let Some(_b) = widgets::section_header(ui, title, &btns, false) {
- app.run(if has_al { "layout.remove" } else { "layout.add" }, json!({"ids": ids}));
- }
- if let Some(Some(l)) = &al {
- // Direction.
- row(ui, 32.0, |ui| {
- let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")];
- let active = match (l.mode, l.wrap) {
- (LayoutMode::Vertical, _) => 0,
- (LayoutMode::Horizontal, false) => 1,
- (LayoutMode::Horizontal, true) => 2,
- (LayoutMode::Grid, _) => 3,
- };
- if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) {
- let p = match i {
- 0 => json!({"layout": "vertical", "wrap": false}),
- 1 => json!({"layout": "horizontal", "wrap": false}),
- 2 => json!({"layout": "horizontal", "wrap": true}),
- _ => json!({"layout": "grid"}),
- };
- app.set_props(&ids, p);
- }
- });
- }
- // W / H with the proportion lock.
- let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0);
- let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0);
- let col = widgets::col_width(ui.available_width());
- let half = col;
- widgets::sublabels(ui, "Dimensions", None);
- row(ui, 32.0, |ui| {
- let e = widgets::number_field(ui, Id::new("lay_w"), Lead::Text("W"), w, "", col, 1.0);
- app.field(&ids, "width", e, |v| json!(v.max(0.0)));
- let e = widgets::number_field(ui, Id::new("lay_h"), Lead::Text("H"), h, "", col, 1.0);
- app.field(&ids, "height", e, |v| json!(v.max(0.0)));
- let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false);
- if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() {
- app.set_props(&ids, json!({"constrainProportions": !locked}));
- }
- });
- let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
- let is_text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_))));
- if has_al || parent_al || is_text {
- let sh = common(doc, sel, |n| n.child_layout.sizing_h);
- let sv = common(doc, sel, |n| n.child_layout.sizing_v);
- row(ui, 32.0, |ui| {
- for (cur, key) in [(sh, "sizingH"), (sv, "sizingV")] {
- let label = cur.map(|s| s.label()).unwrap_or("Mixed");
- let resp = widgets::dropdown(
- ui,
- label,
- half,
- Some(match cur {
- Some(Sizing::Hug) => Icon::Hug,
- Some(Sizing::Fill) => Icon::FillContainer,
- _ => Icon::Fixed,
- }),
- );
- let ids2 = ids.clone();
- egui::Popup::menu(&resp).show(|ui| {
- let mut opts = vec![("Fixed", "fixed")];
- if has_al || is_text {
- opts.push(("Hug contents", "hug"));
- }
- if parent_al {
- opts.push(("Fill container", "fill"));
- }
- for (l, v) in opts {
- if menu_item(ui, l, "") {
- app.set_props(&ids2, json!({key: v}));
- ui.close();
- }
- }
- });
- }
- });
- }
- if let Some(Some(l)) = &al {
- // Alignment grid + gap.
- row(ui, 72.0, |ui| {
- let (grid, _) = ui.allocate_exact_size(vec2(72.0, 64.0), Sense::hover());
- ui.painter().rect_filled(grid, CornerRadius::same(5), t.field);
- let horizontal = l.mode != LayoutMode::Vertical;
- for yi in 0..3 {
- for xi in 0..3 {
- let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0);
- let r = Rect::from_center_size(c, vec2(22.0, 20.0));
- let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click());
- let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) };
- let cur_main = match l.main_align {
- MainAlign::Min => 0,
- MainAlign::Center => 1,
- MainAlign::Max => 2,
- MainAlign::SpaceBetween => 9,
- };
- let cur_cross = match l.cross_align {
- CrossAlign::Min | CrossAlign::Baseline => 0,
- CrossAlign::Center => 1,
- CrossAlign::Max => 2,
- };
- let on = (cur_main == main_i || cur_main == 9) && cur_cross == cross_i;
- if on {
- ui.painter().rect_filled(
- Rect::from_center_size(c, vec2(if horizontal { 4.0 } else { 12.0 }, if horizontal { 12.0 } else { 4.0 })),
- CornerRadius::same(1),
- ACCENT,
- );
- } else {
- ui.painter().circle_filled(c, if resp.hovered() { 2.5 } else { 1.5 }, if resp.hovered() { ACCENT } else { t.text_tertiary });
- }
- if resp.clicked() {
- let m = ["min", "center", "max"][main_i];
- let cr = ["min", "center", "max"][cross_i];
- let main = if l.main_align == MainAlign::SpaceBetween { "spaceBetween" } else { m };
- app.set_props(&ids, json!({"mainAlign": main, "crossAlign": cr}));
- }
- }
- }
- ui.vertical(|ui| {
- let auto = l.main_align == MainAlign::SpaceBetween;
- let e = widgets::number_field(ui, Id::new("al_gap"), Lead::Icon(Icon::Gap), if auto { None } else { Some(l.gap) }, "", 96.0, 1.0);
- app.field(&ids, "gap", e, |v| json!(v));
- ui.add_space(8.0);
- let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None);
- egui::Popup::menu(&resp).show(|ui| {
- if menu_item(ui, "Fixed", "") {
- app.set_props(&ids, json!({"mainAlign": "min"}));
- ui.close();
- }
- if menu_item(ui, "Auto (space between)", "") {
- app.set_props(&ids, json!({"mainAlign": "spaceBetween"}));
- ui.close();
- }
- });
- });
- });
- // Padding.
- let [pt, pr, pb, pl] = l.padding;
- row(ui, 32.0, |ui| {
- let e = widgets::number_field(ui, Id::new("al_padh"), Lead::Icon(Icon::PaddingH), if pl == pr { Some(pl) } else { None }, "", half, 1.0);
- app.field(&ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
- let e = widgets::number_field(ui, Id::new("al_padv"), Lead::Icon(Icon::PaddingV), if pt == pb { Some(pt) } else { None }, "", half, 1.0);
- app.field(&ids, "paddingVertical", e, |v| json!(v.max(0.0)));
- });
- if l.mode == LayoutMode::Grid {
- row(ui, 32.0, |ui| {
- let e = widgets::number_field(ui, Id::new("al_cols"), Lead::Text("Cols"), Some(f64::from(l.grid_columns)), "", half, 1.0);
- app.field(&ids, "gridColumns", e, |v| json!(v.max(1.0)));
- let e = widgets::number_field(ui, Id::new("al_rows"), Lead::Text("Rows"), Some(f64::from(l.grid_rows)), "", half, 1.0);
- app.field(&ids, "gridRows", e, |v| json!(v.max(1.0)));
- });
- }
- }
- if frames {
- let clip = common(doc, sel, |n| n.clip);
- row(ui, 32.0, |ui| {
- if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") {
- app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)}));
- }
- });
- }
- if parent_al {
- let abs = common(doc, sel, |n| n.child_layout.absolute);
- row(ui, 32.0, |ui| {
- if widgets::checkbox(ui, abs.unwrap_or(false), "Ignore auto layout (absolute position)") {
- app.set_props(&ids, json!({"absolute": !abs.unwrap_or(false)}));
- }
- });
- }
-}
-
fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let visible = common(doc, sel, |n| n.visible).unwrap_or(true);
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 7221aea..20fbfe7 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -52,8 +52,11 @@ images, the text being edited and the undo history.
One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation,
absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius,
-layout, sizing, constraints, clip, text properties, component/instance links, overrides,
-boundVariables, interactions, exports.
+layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxWidth` /
+`minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute
+children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
+component/instance links, overrides, boundVariables, interactions, exports. The auto layout props
+that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
### `ui.inspect {}`
diff --git a/docs/mcp.md b/docs/mcp.md
index 67ffa26..b10675f 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -58,7 +58,19 @@ Or in a project's `.mcp.json`:
no parent, so they are page coordinates then). `inspect_document` reports parent-relative `x`/`y`
plus `absoluteBounds` in page coordinates.
- Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`,
- `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y.
+ `mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props:
+ - `minWidth` / `maxWidth` / `minHeight` / `maxHeight` (px, `null` removes) bound Hug, Fill and
+ Fixed sizes; a hugging text wider than its max width wraps.
+ - `absolute: true` takes a child out of the flow (it stays where it is and follows its
+ `constraints` when the frame resizes; Fill becomes Fixed).
+ - `crossAlign: "baseline"` lines up the first text baselines in horizontal layouts (other layers
+ align to the top; nested auto layout frames use their first child's baseline).
+ - `wrap` with `crossGap` (alias `rowGap`) for the gap between wrapped rows.
+ - Grid (`layout: "grid"`): `gridColumns`, `gridRows` (minimum; rows grow), `columnGap` /
+ `rowGap`, track sizes `columnTracks` / `rowTracks` as `[120, "1fr", "auto"]` (px fixed, `fr`
+ fill share, `auto` hug) or the `layout.setTrack {axis, index, sizing, value?}` command; on a
+ child, `gridColumn` / `gridRow` (0-based anchor cell, `null` = placed automatically) and
+ `gridColumnSpan` / `gridRowSpan`. `inspect` reports a grid child's resolved `gridCell`.
- **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given,
else on the selection. New layers become the selection.
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP
diff --git a/docs/parity.md b/docs/parity.md
index aba686e..0b044db 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -1,6 +1,6 @@
# Parity report
-Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (327 commands in the engine).
+Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (328 commands in the engine).
**193 / 199 menu items (97.0%)**