M2: auto layout depth: min/max UI, ignore auto layout toggle, text baseline alignment, grid tracks and cells (#3)

- Min/max width/height: the W/H sizing menus add min/max (at the current size) and remove them;
  limit rows edit or add the missing bound. The layout clamps Fixed, Hug and Fill children and
  the frame itself; a hugging text wider than its max width wraps at the max. min > max is kept
  ordered by node.set (the other bound follows); in the layout min wins.
- Ignore auto layout (absolute position): a toggle in the Position section; the child stays
  where it is, can't Fill, shows constraints and follows them when the auto layout frame resizes
  (by hugging, filling or a direct resize).
- Text baseline alignment for horizontal layouts ("Align text baselines"; crossAlign baseline):
  first-line baselines line up, other layers align to the top, nested auto layout frames use
  their first child's baseline. Measure gains text_baseline.
- Grid auto layout: column/row tracks (fixed px, hug, fr fill) via columnTracks / rowTracks or
  layout.setTrack, cells anchored by gridColumn / gridRow with gridColumnSpan / gridRowSpan,
  auto placement in reading order, rows grow to fit, hugging grids size to their tracks.
  Panel: column/row gap, track size rows, a child's cell and spans. Old files load unchanged
  (serde defaults; missing tracks are 1fr).
- Canvas handles: wrapped-row gaps and grid column/row gaps are draggable; a selected grid's
  tracks are outlined.
- The layout section moved from right.rs to layout_panel.rs; inspect reports min/max and a
  grid child's resolved gridCell; docs list the new props.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 02:23:43 +07:00 committed by GitHub
parent 94e689fad6
commit 4286a3b74b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1827 additions and 381 deletions

View File

@ -78,7 +78,7 @@
<tr>
<td width="50%" valign="top">
<h3>Layouts that build themselves</h3>
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.
</td>
<td width="50%" valign="top">
<h3>Components that scale</h3>

View File

@ -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% |

View File

@ -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<GridTrack>,
/// Grid mode: row track sizes (missing tracks are `1fr`).
pub row_tracks: Vec<GridTrack>,
/// 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<f64>,
pub min_h: Option<f64>,
pub max_h: Option<f64>,
/// Grid auto layout: anchor column / row (0-based); `None` = placed automatically.
pub grid_column: Option<u32>,
pub grid_row: Option<u32>,
/// 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)]

View File

@ -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::<GridTrack>("{}").unwrap(), GridTrack::fill(1.0));
}
#[test]
fn node_id_parse() {
assert_eq!("12".parse::<NodeId>().unwrap(), NodeId(12));

View File

@ -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<CommandSpec> {
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<Value> {
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<NodeId> =
(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<Option<GridTrack>> =
[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())]);
}
}

View File

@ -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());

View File

@ -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<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
serde_json::from_value(Value::String(norm)).ok()
}
fn sizing(v: &Value) -> Option<Sizing> {
pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
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<GridTrack> {
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::<f64>().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<f64>) {
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<String, Valu
_ => 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<String, Valu
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
}
"wrap" => 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<String, Valu
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
set_sizing(cmd, doc, id, s, false)?;
}
"absolute" => 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::<Option<Vec<_>>>().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(())

View File

@ -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() {

View File

@ -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<f64> {
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())
}
}

View File

@ -46,7 +46,12 @@ pub fn req_string(cmd: &str, p: &Value, key: &str) -> Result<String> {
}
pub fn boolean(p: &Value, key: &str) -> Option<bool> {
match get(p, key)? {
bool_of(get(p, key)?)
}
/// A lenient boolean: `true`, `1`, `"yes"`, `"on"`…
pub fn bool_of(v: &Value) -> Option<bool> {
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() {

View File

@ -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<f64>;
/// 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<f64> {
None
}
}
/// A measurer that never changes text sizes.
@ -36,7 +43,8 @@ impl Measure for NoMeasure {
}
}
fn clamp_size(v: f64, min: Option<f64>, max: Option<f64>) -> 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<f64>, max: Option<f64>) -> f64 {
let mut v = if v.is_finite() { v.max(0.0) } else { 0.0 };
if let Some(m) = max.filter(|m| m.is_finite()) {
v = v.min(m);
@ -47,6 +55,10 @@ fn clamp_size(v: f64, min: Option<f64>, max: Option<f64>) -> 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<NodeId> {
let Some(f) = doc.node(frame) else { return Vec::new() };
f.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.child_layout.absolute)).collect()
}
/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the stroke weight
/// when strokes are included in the layout.
fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
let stroke_pad = if al.strokes_included && f.has_visible_stroke() { finite_or_zero(f.stroke.weight).max(0.0) } else { 0.0 };
let [pt, pr, pb, pl] = al.padding.map(|p| if p.is_finite() { p.max(0.0) } else { 0.0 });
(pt + stroke_pad, pr + stroke_pad, pb + stroke_pad, pl + stroke_pad)
}
/// Text that hugs its width: refresh its natural size, honouring min/max width by wrapping (a
/// hugging text wider than its max width wraps at the max; narrower than its min width gets the min).
fn fit_hug_text(doc: &mut Document, id: NodeId, m: &dyn Measure) {
let Some(n) = doc.node(id) else { return };
let NodeKind::Text(t) = &n.kind else { return };
let cl = &n.child_layout;
let auto_width = t.auto_resize == TextAutoResize::WidthAndHeight;
// A hugging text that wrapped at its max width earlier is auto height now.
let wrapped_hug = cl.sizing_h == Sizing::Hug && t.auto_resize == TextAutoResize::Height;
if !auto_width && !wrapped_hug {
return;
}
let (min_w, max_w) = (cl.min_w, cl.max_w);
let Some((nw, nh)) = m.text_size(doc, id) else { return };
let w = clamp_size(nw, min_w, max_w);
let limited = (w - nw).abs() > 1e-6;
let h = if limited { m.text_height(doc, id, w).unwrap_or(nh) } else { nh };
if let Some(n) = doc.node_mut(id)
&& let NodeKind::Text(t) = &mut n.kind
{
let mode = if limited { TextAutoResize::Height } else { TextAutoResize::WidthAndHeight };
if t.auto_resize != mode {
t.auto_resize = mode;
}
}
set_size(doc, id, w, h);
}
/// Clamp an in-flow child's own size to its min/max (fill axes are clamped when distributed).
fn enforce_limits(doc: &mut Document, id: NodeId, m: &dyn Measure) {
let Some(n) = doc.node(id) else { return };
let cl = &n.child_layout;
if cl.min_w.is_none() && cl.max_w.is_none() && cl.min_h.is_none() && cl.max_h.is_none() {
return;
}
let (w, h) = (n.width, n.height);
let (nw, nh) = (clamp_size(w, cl.min_w, cl.max_w), clamp_size(h, cl.min_h, cl.max_h));
if (nw - w).abs() < 1e-9 && (nh - h).abs() < 1e-9 {
return;
}
if matches!(n.kind, NodeKind::Text(_)) {
// Keep the text's resize mode (hugging width was handled by `fit_hug_text`).
set_size(doc, id, nw, nh);
} else {
resize_child(doc, id, nw, nh, m);
}
}
/// Distance from the top of a child's box to its first text baseline: text measures it, an auto
/// layout frame uses its first placed child's baseline. `None` for other layers and rotated ones.
fn item_baseline(doc: &Document, id: NodeId, m: &dyn Measure, depth: usize) -> Option<f64> {
let n = doc.node(id)?;
let c = n.transform.as_coeffs();
if c[1].abs() > 1e-9 || c[2].abs() > 1e-9 || c[0] < 0.0 || c[3] < 0.0 {
return None;
}
match &n.kind {
NodeKind::Text(_) => m.text_baseline(doc, id).filter(|b| b.is_finite()),
_ if n.layout.is_some() && n.kind.is_frame_like() && depth < 32 => flow_children(doc, id).into_iter().find_map(|k| {
let b = item_baseline(doc, k, m, depth + 1)?;
let kn = doc.node(k)?;
let top = figcraft_geom::transform_rect_bbox(kn.transform, kn.local_rect()).y0;
Some(top + b)
}),
_ => None,
}
}
struct Item {
id: NodeId,
main: f64,
@ -118,6 +212,10 @@ struct Item {
max_main: Option<f64>,
min_cross: Option<f64>,
max_cross: Option<f64>,
/// Baseline alignment: distance from the item's top to its first baseline.
baseline: Option<f64>,
/// Baseline alignment: offset from the line's top (computed per line).
base_off: f64,
}
/// Lay out one auto layout frame's children and size the frame if it hugs.
@ -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<NodeId> = 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<Item> = 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::<f64>() + 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::<f64>() + 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::<f64>() + gx * (cols - 1) as f64 + pl + pr } else { fw };
let total_h = if hug_h { row_h.iter().sum::<f64>() + 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::<f64>() + gx * ci as f64;
let y = pt + row_h.iter().take(ri).sum::<f64>() + 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<GridCell>,
pub width: f64,
pub height: f64,
}
impl GridGeometry {
/// The frame-local rectangle a cell (with its spans) covers.
pub fn cell_rect(&self, c: &GridCell) -> Rect {
let x0 = self.columns.get(c.column).map(|t| t.0).unwrap_or(0.0);
let x1 = self.columns.get(c.column + c.column_span - 1).map(|t| t.1).unwrap_or(x0);
let y0 = self.rows.get(c.row).map(|t| t.0).unwrap_or(0.0);
let y1 = self.rows.get(c.row + c.row_span - 1).map(|t| t.1).unwrap_or(y0);
Rect::new(x0, y0, x1.max(x0), y1.max(y0))
}
}
/// 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<GridCell>, usize) {
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
let mut occ: Vec<Vec<bool>> = Vec::new();
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
};
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
while occ.len() < r + rs {
occ.push(vec![false; cols]);
}
for row in occ.iter_mut().skip(r).take(rs) {
for cell in row.iter_mut().skip(c).take(cs) {
*cell = true;
}
}
};
let spans = |id: NodeId| {
let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default();
let cs = (cl.column_span() as usize).min(cols);
let rs = (cl.row_span() as usize).min(max_rows);
(cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs)
};
let mut placed: Vec<Option<GridCell>> = vec![None; kids.len()];
// 1. Fully anchored children.
for (i, id) in kids.iter().enumerate() {
if let (Some(c), Some(r), cs, rs) = spans(*id) {
mark(&mut occ, c, r, cs, rs);
if let Some(slot) = placed.get_mut(i) {
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
}
}
// 2. The rest, in order (a cursor keeps auto-placed children in reading order).
let mut cursor = (0usize, 0usize); // (row, column)
for (i, id) in kids.iter().enumerate() {
if placed.get(i).is_some_and(Option::is_some) {
continue;
}
let (col, row, cs, rs) = spans(*id);
let last_row = max_rows - rs;
let (c, r) = match (col, row) {
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
(None, None) => {
let mut found = None;
let (mut r, mut c) = cursor;
while r <= last_row {
if c + cs <= cols && free(&occ, c, r, cs, rs) {
found = Some((c, r));
break;
}
c += 1;
if c + cs > cols {
c = 0;
r += 1;
}
}
let (c, r) = found.unwrap_or((0, last_row));
cursor = (r, c + cs);
(c, r)
}
};
mark(&mut occ, c, r, cs, rs);
if let Some(slot) = placed.get_mut(i) {
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
}
let cells: Vec<GridCell> = placed.into_iter().flatten().collect();
let rows = cells.iter().map(|c| c.row + c.row_span).max().unwrap_or(0).max((al.grid_rows as usize).clamp(1, MAX_TRACKS));
(cells, rows)
}
/// Track `i`'s sizing and sanitised value (missing tracks are `1fr`).
fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) {
let t = specs.get(i).copied().unwrap_or_default();
let v = if t.value.is_finite() { t.value.clamp(0.0, 1e6) } else { 0.0 };
(t.sizing, v)
}
/// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks
/// share what is left by their fractional units. `avail` is None when the frame hugs that axis
/// (fill tracks then hug their content too).
fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option<f64>) -> Vec<f64> {
let mut out: Vec<f64> = Vec::with_capacity(content.len());
let mut total_fr = 0.0;
let mut used = 0.0;
for (i, c) in content.iter().enumerate() {
let size = match track_spec(specs, i) {
(Sizing::Fixed, v) => v,
(Sizing::Fill, v) if avail.is_some() => {
total_fr += v;
0.0
}
_ => *c,
};
used += size;
out.push(size);
}
if let Some(a) = avail
&& total_fr > 1e-12
{
let free = (a - used).max(0.0);
for (i, s) in out.iter_mut().enumerate() {
if let (Sizing::Fill, v) = track_spec(specs, i) {
*s = free * v / total_fr;
}
}
}
out
}
/// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content too):
/// the largest single-span child; a spanning child that needs more than its tracks give it grows
/// the content-sized tracks it spans (unless a flexible track can absorb it).
fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridTrack], n: usize, gap: f64, columns: bool, hug: bool) -> Vec<f64> {
let by_content = |i: usize| match track_spec(specs, i).0 {
Sizing::Hug => true,
Sizing::Fill => hug,
Sizing::Fixed => false,
};
let mut content: Vec<f64> = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect();
let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) };
for (c, s) in cells.iter().zip(sizes) {
let (start, len) = span(c);
if len == 1
&& by_content(start)
&& let Some(t) = content.get_mut(start)
{
*t = t.max(*s);
}
}
for (c, s) in cells.iter().zip(sizes) {
let (start, len) = span(c);
if len < 2 || (!hug && (start..start + len).any(|i| track_spec(specs, i).0 == Sizing::Fill)) {
continue;
}
let have: f64 = content.iter().skip(start).take(len).sum::<f64>() + gap * (len - 1) as f64;
let grow: Vec<usize> = (start..start + len).filter(|i| by_content(*i)).collect();
if *s > have && !grow.is_empty() {
let extra = (*s - have) / grow.len() as f64;
for i in grow {
if let Some(t) = content.get_mut(i) {
*t += extra;
}
}
}
}
content
}
fn track_ranges(start: f64, sizes: &[f64], gap: f64) -> Vec<(f64, f64)> {
let mut x = start;
sizes
.iter()
.map(|s| {
let r = (x, x + s);
x += s + gap;
r
})
.collect()
}
/// Resolve a grid auto layout frame's tracks and cells from its children's current sizes.
pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option<GridGeometry> {
let f = doc.node(frame)?;
let al = f.layout.as_ref()?;
if al.mode != LayoutMode::Grid {
return None;
}
let (pt, pr, pb, pl) = insets(f, al);
let kids = flow_children(doc, frame);
let (cells, rows) = grid_place(doc, al, &kids);
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
let gx = finite_or_zero(al.gap).max(0.0);
let gy = finite_or_zero(al.cross_gap).max(0.0);
let hug_w = f.child_layout.sizing_h == Sizing::Hug;
let hug_h = f.child_layout.sizing_v == Sizing::Hug;
// What each child asks of its tracks: its size, or (filling a non-hugging axis) its minimum.
let (mut ws, mut hs) = (Vec::with_capacity(cells.len()), Vec::with_capacity(cells.len()));
for c in &cells {
let (w, h) = outer_size(doc, c.id);
let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default();
ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w });
hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h });
}
let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w);
let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h);
let gaps_w = gx * cols.saturating_sub(1) as f64;
let gaps_h = gy * rows.saturating_sub(1) as f64;
let avail_w = (!hug_w).then_some(f.width - pl - pr - gaps_w);
let avail_h = (!hug_h).then_some(f.height - pt - pb - gaps_h);
let col_w = resolve_tracks(&al.column_tracks, &col_content, avail_w);
let row_h = resolve_tracks(&al.row_tracks, &row_content, avail_h);
let cl = &f.child_layout;
let width = if hug_w { col_w.iter().sum::<f64>() + gaps_w + pl + pr } else { f.width };
let height = if hug_h { row_h.iter().sum::<f64>() + gaps_h + pt + pb } else { f.height };
Some(GridGeometry {
columns: track_ranges(pl, &col_w, gx),
rows: track_ranges(pt, &row_h, gy),
cells,
width: clamp_size(width, cl.min_w, cl.max_w),
height: clamp_size(height, cl.min_h, cl.max_h),
})
}
fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measure) {
let Some(f) = doc.node(frame) else { return };
let (fw, fh) = (f.width, f.height);
let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug);
let Some(g) = grid_geometry(doc, frame) else { return };
set_size(doc, frame, g.width, g.height);
apply_constraints(doc, frame, fw, fh);
// A fixed frame resized by its own min/max: tracks follow the clamped size.
let g = if (g.width - fw).abs() > 1e-9 && !hug_w || (g.height - fh).abs() > 1e-9 && !hug_h { grid_geometry(doc, frame).unwrap_or(g) } else { g };
for c in &g.cells {
let cell = g.cell_rect(c);
let Some(n) = doc.node(c.id) else { continue };
let cl = n.child_layout.clone();
let fill_w = cl.sizing_h == Sizing::Fill && !hug_w;
let fill_h = cl.sizing_v == Sizing::Fill && !hug_h;
let (w, h) = outer_size(doc, c.id);
let nw = if fill_w { clamp_size(cell.width(), cl.min_w, cl.max_w) } else { w };
let nh = if fill_h { clamp_size(cell.height(), cl.min_h, cl.max_h) } else { h };
if fill_w || fill_h {
resize_child(doc, c.id, nw, nh, m);
}
let (nw, nh) = outer_size(doc, c.id);
let ox = match al.main_align {
MainAlign::Center => (cell.width() - nw) / 2.0,
MainAlign::Max => cell.width() - nw,
_ => 0.0,
};
let oy = match al.cross_align {
CrossAlign::Center => (cell.height() - nh) / 2.0,
CrossAlign::Max => cell.height() - nh,
_ => 0.0,
};
place(doc, c.id, cell.x0 + ox, cell.y0 + oy);
}
}
/// After frame `id` changed size from `(ow, oh)`, move/resize its children per their constraints
/// (recursively for children frames that resize). In an auto layout frame only the children that
/// ignore auto layout (absolute position) follow constraints; the layout places the others.
pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) {
let Some(f) = doc.node(id) else { return };
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);

View File

@ -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<NodeId, (f64, f64, f64)>);
impl Measure for FakeMeasure {
fn text_height(&self, _: &Document, id: NodeId, width: f64) -> Option<f64> {
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<f64> {
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<NodeId> = (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<NodeId> = (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());
}
}

View File

@ -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<Rect>, 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<GapZone>,
/// Grid frames: the track boundaries to outline (screen-space segments).
tracks: Vec<[Pos2; 2]>,
}
fn al_zones(app: &FigApp) -> Option<Zones> {
let ds = app.session.ds().ok()?;
@ -133,30 +129,73 @@ fn al_zones(app: &FigApp) -> Option<Zones> {
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_geom::Rect> = 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<figcraft_geom::Rect>> = 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 }}));
}
}
}

View File

@ -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);

View File

@ -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<f64> {
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<f64>| {
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)));
});
}

View File

@ -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;

View File

@ -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<FieldEdit>, 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<FieldEdit>, 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, &params(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);

View File

@ -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 {}`

View File

@ -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

View File

@ -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%)**