diff --git a/ROADMAP.md b/ROADMAP.md index ee4d077f..3838fc81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~195–355 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,14 +19,14 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~195–355 h** one agent | estimated | -| Remaining to full parity | **~348–672 h** one agent (~92–160 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~345–666 h** one agent (~92–160 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~330–635 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~327–629 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~162–304 h | core editor depth incl. `.fig` import (80–157), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 63–121 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 67%) | 60–115 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -80,7 +80,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Strokes | 90% | 72% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | -| Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) | +| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | | Text | 85% | 64% | 7–13 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | | Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) | @@ -190,6 +190,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | | 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 4ecae60e..9bd4a704 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -139,8 +139,8 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result if in_result == Some(false) { return None; } - for c in n.children.iter().rev() { - if let Some(h) = hit_rec(doc, *c, pt, tol, depth + 1, result) { + for c in n.paint_order().rev() { + if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result) { return Some(h); } } @@ -172,8 +172,8 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool } return; } - for c in n.children.iter().rev() { - rec(doc, *c, pt, tol, depth + 1, result, out); + for c in n.paint_order().rev() { + rec(doc, c, pt, tol, depth + 1, result, out); } if out.len() > at || hits_node_itself(doc, id, n, pt, tol) { out.insert(at, (id, depth)); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5e8b127f..56bcec8e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -985,6 +985,30 @@ impl Document { pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64, result: geometry::BoolResult) -> Option { geometry::hit_leaf(self, page, pt, tolerance, result) } + + /// `n`'s children as paint runs (index ranges into `n.children`), bottom to top: a visible + /// mask with the layers above it up to the next mask, or a single layer. An auto layout frame + /// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]). + pub fn paint_runs(&self, n: &Node) -> Vec> { + let kids = &n.children; + let node = |i: usize| kids.get(i).and_then(|k| self.node(*k)); + let mut runs = Vec::new(); + let mut i = 0; + while i < kids.len() { + let mut j = i + 1; + if node(i).is_some_and(|k| k.is_mask && k.visible) { + while j < kids.len() && !node(j).is_some_and(|k| k.is_mask) { + j += 1; + } + } + runs.push(i..j); + i = j; + } + if n.layout.as_ref().is_some_and(|l| l.reverse_z) { + runs.reverse(); + } + runs + } } fn remap_action(a: &mut proto::Action, map: &BTreeMap) { diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 3cc92f27..fa307410 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -676,6 +676,14 @@ impl Node { } } + /// Children in paint order, bottom to top: the child list, reversed in an auto layout frame + /// whose first child paints on top (canvas stacking; the layer order stays as it is). + pub fn paint_order(&self) -> impl DoubleEndedIterator + '_ { + let rev = self.layout.as_ref().is_some_and(|l| l.reverse_z); + let n = self.children.len(); + (0..n).filter_map(move |i| self.children.get(if rev { n - 1 - i } else { i }).copied()) + } + /// Whether painted strokes are present. pub fn has_visible_stroke(&self) -> bool { self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0 diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a08f614b..c4ae6912 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -955,7 +955,9 @@ pub enum LayoutMode { Grid, } -/// Alignment along the main axis. +/// Alignment along the main axis. The `Space*` modes are the auto gaps: the free space goes +/// between the children only (between), equally between them and at the edges (evenly), or +/// between them with half as much at each edge (around). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum MainAlign { @@ -964,6 +966,26 @@ pub enum MainAlign { Center, Max, SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +impl MainAlign { + /// An auto gap (the gap comes from the free space, not the gap value). + pub fn is_auto_gap(self) -> bool { + matches!(self, MainAlign::SpaceBetween | MainAlign::SpaceAround | MainAlign::SpaceEvenly) + } +} + +/// A grid auto layout child's alignment in its cell on one axis (`None` where it is used: the +/// frame's alignment). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum CellAlign { + #[default] + Min, + Center, + Max, } /// Alignment across the main axis. @@ -998,9 +1020,9 @@ pub struct AutoLayout { pub column_tracks: Vec, /// Grid mode: row track sizes (missing tracks are `1fr`). pub row_tracks: Vec, - /// Include strokes in layout sizes. + /// The frame's own inside strokes take up space in the layout (like padding). pub strokes_included: bool, - /// Last item on top (canvas stacking). + /// Canvas stacking: the first child paints on top (default: the last one does). pub reverse_z: bool, } @@ -1018,7 +1040,8 @@ impl Default for AutoLayout { grid_rows: 2, column_tracks: Vec::new(), row_tracks: Vec::new(), - strokes_included: false, + // New auto layout includes inside strokes. + strokes_included: true, reverse_z: false, } } @@ -1092,6 +1115,11 @@ pub struct ChildLayout { /// 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, + /// Grid auto layout: alignment in the cell, horizontally / vertically (`None`: the frame's). + #[serde(skip_serializing_if = "Option::is_none")] + pub grid_align_h: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub grid_align_v: Option, } impl ChildLayout { diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs new file mode 100644 index 00000000..5d34d586 --- /dev/null +++ b/crates/engine/src/al_behaviour_tests.rs @@ -0,0 +1,206 @@ +//! Auto layout behaviour through commands (the rules of `figcraft_layout`'s behaviour suite that +//! involve edits): what sizing a layer may take, how a hugging frame follows its children, the +//! settings' defaults, canvas stacking in renders and exports, and one undo step per edit. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn rect(s: &mut Session, parent: NodeId, w: f64, h: f64, fill: &str) -> NodeId { + id(s.execute("shape.rectangle", &json!({"parent": parent, "x": 0, "y": 0, "width": w, "height": h, "props": {"fill": fill}})).unwrap()) +} + +/// A frame with auto layout (no padding, no gap) and the given extra props. +fn al_frame(s: &mut Session, props: Value) -> NodeId { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + let mut all = json!({"layout": "horizontal", "padding": 0, "gap": 0, "fills": []}); + if let (Some(all), Value::Object(extra)) = (all.as_object_mut(), props) { + all.extend(extra); + } + set(s, f, all); + f +} + +fn set(s: &mut Session, node: NodeId, props: Value) { + s.execute("node.set", &json!({"id": node, "props": props})).unwrap(); +} + +fn size(s: &Session, node: NodeId) -> (f64, f64) { + let n = s.doc().unwrap().node(node).unwrap(); + (n.width, n.height) +} + +fn pos(s: &Session, node: NodeId) -> (f64, f64) { + let c = s.doc().unwrap().node(node).unwrap().transform.as_coeffs(); + (c[4], c[5]) +} + +fn sizing(s: &Session, node: NodeId) -> (Sizing, Sizing) { + let cl = &s.doc().unwrap().node(node).unwrap().child_layout; + (cl.sizing_h, cl.sizing_v) +} + +/// The RGBA of `node` rendered at 1x, at (`x`, `y`) of its box. +fn pixel(s: &Session, node: NodeId, x: u32, y: u32) -> [u8; 4] { + let pix = figcraft_render::render_node(s.doc().unwrap(), node, 1.0).unwrap(); + let i = ((y * pix.width() + x) * 4) as usize; + let d = pix.data(); + [d[i], d[i + 1], d[i + 2], d[i + 3]] +} + +#[test] +fn fill_on_a_child_turns_the_hugging_parent_fixed() { + // Documented: a child set to Fill container makes its parent stop hugging that axis. + let mut s = Session::new(); + let f = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug"})); + let a = rect(&mut s, f, 50.0, 20.0, "#FF0000"); + rect(&mut s, f, 30.0, 40.0, "#0000FF"); + assert_eq!(size(&s, f), (80.0, 40.0)); + set(&mut s, a, json!({"sizingH": "fill"})); + assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug)); + assert_eq!(sizing(&s, a).0, Sizing::Fill); + // Fixed at the size it had: the fill child takes what the other child leaves. + assert_eq!(size(&s, f), (80.0, 40.0)); + assert_eq!(size(&s, a), (50.0, 20.0)); + s.undo().unwrap(); + assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug)); +} + +#[test] +fn hug_and_fill_only_where_they_apply() { + // Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout, + // never to a top-level frame. + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let r = rect(&mut s, f, 50.0, 20.0, "#FF0000"); + assert!(s.execute("node.set", &json!({"id": r, "props": {"sizingH": "hug"}})).is_err()); + assert!(s.execute("node.set", &json!({"id": f, "props": {"sizingH": "fill"}})).is_err()); + assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Fixed)); +} + +#[test] +fn a_hugging_frame_follows_its_children_through_nested_levels() { + // Documented: hug resizes the frame from its children; nested frames keep their own padding + // and gap. + let mut s = Session::new(); + let outer = al_frame(&mut s, json!({"layout": "vertical", "sizingH": "hug", "sizingV": "hug", "padding": 10, "gap": 5})); + let inner = id(s.execute("frame.create", &json!({"parent": outer, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + set(&mut s, inner, json!({"layout": "horizontal", "padding": 4, "gap": 2, "sizingH": "hug", "sizingV": "hug", "fills": []})); + let a = rect(&mut s, inner, 30.0, 20.0, "#FF0000"); + rect(&mut s, inner, 30.0, 10.0, "#0000FF"); + rect(&mut s, outer, 20.0, 20.0, "#00FF00"); + // Inner: 4 + 30 + 2 + 30 + 4 = 70 by 4 + 20 + 4 = 28; outer: 10 + 70 + 10 by 10 + 28 + 5 + 20 + 10. + assert_eq!(size(&s, inner), (70.0, 28.0)); + assert_eq!(size(&s, outer), (90.0, 73.0)); + // A child grows: both levels follow in the same edit, and one undo puts it all back. + set(&mut s, a, json!({"width": 60, "height": 40})); + assert_eq!(size(&s, inner), (100.0, 48.0)); + assert_eq!(size(&s, outer), (120.0, 93.0)); + s.undo().unwrap(); + assert_eq!(size(&s, outer), (90.0, 73.0)); +} + +#[test] +fn new_auto_layout_includes_inside_strokes() { + // Documented: by default inside strokes are included in auto layout sizes. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + set(&mut s, f, json!({"stroke": "#000000", "strokeWeight": 3, "strokeAlign": "inside"})); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().strokes_included); + set(&mut s, f, json!({"padding": 0, "sizingH": "hug", "sizingV": "hug"})); + let r = rect(&mut s, f, 40.0, 20.0, "#FF0000"); + assert_eq!(size(&s, f), (46.0, 26.0)); + assert_eq!(pos(&s, r), (3.0, 3.0)); + // Excluded: the stroke takes no space (one undo step brings it back). + set(&mut s, f, json!({"strokesIncluded": false})); + assert_eq!(size(&s, f), (40.0, 20.0)); + s.undo().unwrap(); + assert_eq!(size(&s, f), (46.0, 26.0)); +} + +#[test] +fn auto_gaps_and_cell_alignment_through_node_set() { + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let k: Vec = (0..3).map(|_| rect(&mut s, f, 20.0, 20.0, "#FF0000")).collect(); + // 200 - 60 = 140: evenly 35 everywhere; around 46.67 between, half at the edges. + set(&mut s, f, json!({"mainAlign": "spaceEvenly"})); + assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().main_align, MainAlign::SpaceEvenly); + assert_eq!(k.iter().map(|c| pos(&s, *c).0).collect::>(), vec![35.0, 90.0, 145.0]); + set(&mut s, f, json!({"primaryAxisAlignItems": "SPACE_AROUND"})); + assert!((pos(&s, k[0]).0 - 70.0 / 3.0).abs() < 1e-9); + assert!(s.execute("node.set", &json!({"id": f, "props": {"mainAlign": "spaceSideways"}})).is_err()); + // A grid child aligns in its own cell; auto goes back to the frame's alignment. + set(&mut s, f, json!({"layout": "grid", "columnTracks": ["1fr", "1fr", "1fr"], "rowTracks": ["1fr"], "mainAlign": "min"})); + set(&mut s, k[1], json!({"gridAlignH": "max", "gridChildVerticalAlign": "CENTER"})); + let cl = s.doc().unwrap().node(k[1]).unwrap().child_layout.clone(); + assert_eq!((cl.grid_align_h, cl.grid_align_v), (Some(CellAlign::Max), Some(CellAlign::Center))); + // Columns of 200 / 3 (the second ends at 133.33) in one 100 px row. + let (x, y) = pos(&s, k[1]); + assert!((x - (400.0 / 3.0 - 20.0)).abs() < 1e-9 && (y - 40.0).abs() < 1e-9, "{x} {y}"); + set(&mut s, k[1], json!({"gridAlignH": "auto", "gridAlignV": null})); + assert!((pos(&s, k[1]).0 - 200.0 / 3.0).abs() < 1e-9); + assert!(s.execute("node.set", &json!({"id": k[1], "props": {"gridAlignH": "baseline"}})).is_err()); +} + +#[test] +fn vertical_wrap_through_node_set() { + // Documented: a vertical flow wraps into a new column when it reaches the frame's height. + let mut s = Session::new(); + let f = al_frame(&mut s, json!({"layout": "vertical", "wrap": true, "gap": 10, "crossGap": 5})); + let k: Vec = (0..3).map(|_| rect(&mut s, f, 20.0, 40.0, "#FF0000")).collect(); + assert_eq!(k.iter().map(|c| pos(&s, *c)).collect::>(), vec![(0.0, 0.0), (0.0, 50.0), (25.0, 0.0)]); +} + +#[test] +fn vertical_wrap_columns_take_arrows_and_drops() { + // 40 px tall items, gap 10, 160 px tall: three per column, columns 30 px apart. + let mut s = Session::new(); + let f = al_frame(&mut s, json!({"layout": "vertical", "wrap": true, "gap": 10, "crossGap": 10, "height": 160})); + let k: Vec = (0..6).map(|_| rect(&mut s, f, 20.0, 40.0, "#FF0000")).collect(); + let kids = |s: &Session| s.doc().unwrap().node(f).unwrap().children.clone(); + assert_eq!(pos(&s, k[3]), (30.0, 0.0), "second column"); + // Right: the second item moves into the second column, beside where it was. + s.execute("transform.nudge", &json!({"ids": [k[1]], "dx": 1, "dy": 0})).unwrap(); + assert_eq!(kids(&s), vec![k[0], k[2], k[3], k[4], k[1], k[5]]); + assert_eq!(pos(&s, k[1]), (30.0, 50.0)); + // Down still steps along the flow. + s.execute("transform.nudge", &json!({"ids": [k[1]], "dx": 0, "dy": 1})).unwrap(); + assert_eq!(kids(&s), vec![k[0], k[2], k[3], k[4], k[5], k[1]]); + // A drop between two items of a column: before the lower one, a horizontal insertion line. + let (index, line) = crate::drop::auto_layout_slot(s.doc().unwrap(), f, figcraft_geom::Point::new(35.0, 45.0), &[]).unwrap(); + assert_eq!(index, 4); + assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0])); +} + +#[test] +fn canvas_stacking_paints_and_exports_the_first_child_on_top() { + // Documented: the last child is on top by default; "first on top" flips which one is drawn + // over the other, and the layer order stays the same. + let mut s = Session::new(); + let f = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "gap": -20})); + let a = rect(&mut s, f, 50.0, 20.0, "#FF0000"); + let b = rect(&mut s, f, 50.0, 20.0, "#0000FF"); + let order = s.doc().unwrap().node(f).unwrap().children.clone(); + let svg = |s: &Session| figcraft_svg::export_svg(s.doc().unwrap(), &[f], &figcraft_svg::SvgOptions::default()).unwrap().to_lowercase(); + let red_first = |svg: &str| svg.find("ff0000").unwrap() < svg.find("0000ff").unwrap(); + // The overlap is x 30..50. + assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); + assert!(red_first(&svg(&s))); + set(&mut s, f, json!({"reverseZ": true})); + assert_eq!(pixel(&s, f, 40, 10), [255, 0, 0, 255]); + assert!(!red_first(&svg(&s))); + assert!(figcraft_pdf::export_nodes_pdf(s.doc().unwrap(), &[f]).is_ok()); + // Places and layers stay; one undo step puts the last child back on top. + assert_eq!(s.doc().unwrap().node(f).unwrap().children, order); + assert_eq!((pos(&s, a), pos(&s, b)), ((0.0, 0.0), (30.0, 0.0))); + s.undo().unwrap(); + assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); +} diff --git a/crates/engine/src/al_keys.rs b/crates/engine/src/al_keys.rs index f4e01d3a..8c728d57 100644 --- a/crates/engine/src/al_keys.rs +++ b/crates/engine/src/al_keys.rs @@ -24,7 +24,7 @@ use figcraft_layout::{GridCell, flow_children, grid_place}; use crate::Result; use crate::components::structure_locked; -use crate::drop::{auto_layout_slot, wrap_rows}; +use crate::drop::{auto_layout_slot, flip, wrap_rows}; /// One arrow key: an axis and a direction (`forward`: right or down). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -71,8 +71,8 @@ pub fn arrow_move(doc: &mut Document, parent: NodeId, selected: &[NodeId], dir: let along = (mode == LayoutMode::Horizontal) == dir.horizontal; if along { step_in_flow(doc, parent, &flow, &block, dir.forward)? - } else if layout.wrap && mode == LayoutMode::Horizontal { - wrap_row_move(doc, parent, &flow, &block, dir.forward)? + } else if layout.wrap { + wrap_row_move(doc, parent, &flow, &block, dir.forward, mode == LayoutMode::Vertical)? } else { false } @@ -119,17 +119,18 @@ fn step_in_flow(doc: &mut Document, parent: NodeId, flow: &[NodeId], block: &[No Ok(true) } -/// Up/Down in a wrapping row layout: into the previous/next visual row, next to the child nearest -/// the selection's centre (before it going up, after it going down, so the selection lands under -/// or over where it was once the rows reflow). Returns whether anything moved. -fn wrap_row_move(doc: &mut Document, parent: NodeId, flow: &[NodeId], block: &[NodeId], forward: bool) -> Result { - let rows = wrap_rows(doc, flow); +/// Up/Down in a wrapping row layout (Left/Right in a wrapping column layout, `vertical`): into the +/// previous/next visual row, next to the child nearest the selection's centre (before it going +/// back, after it going forward, so the selection lands beside where it was once the rows reflow). +/// Returns whether anything moved. +fn wrap_row_move(doc: &mut Document, parent: NodeId, flow: &[NodeId], block: &[NodeId], forward: bool, vertical: bool) -> Result { + let rows = wrap_rows(doc, flow, vertical); let lead = if forward { block.last() } else { block.first() }; let Some(lead_i) = lead.and_then(|l| flow.iter().position(|c| c == l)) else { return Ok(false) }; let Some(row) = rows.iter().position(|r| r.iter().any(|(i, _)| *i == lead_i)) else { return Ok(false) }; let target_row = if forward { row.checked_add(1) } else { row.checked_sub(1) }; let Some(target) = target_row.and_then(|r| rows.get(r)) else { return Ok(false) }; - let Some(centre) = doc.bounds_of(block).map(|b| b.center().x) else { return Ok(false) }; + let Some(centre) = doc.bounds_of(block).map(|b| flip(b, vertical).center().x) else { return Ok(false) }; let nearest = target .iter() .filter(|(i, _)| flow.get(*i).is_some_and(|c| !block.contains(c))) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 63429c88..137daa64 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,9 +13,9 @@ 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 [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), 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: first text baselines, horizontal layouts), wrap, \ +mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ 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), \ +gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), 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, \ @@ -438,7 +438,8 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { - ensure_layout(doc.get_mut(id)?).main_align = enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween"))? + ensure_layout(doc.get_mut(id)?).main_align = + enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? } "crossAlign" | "counterAxisAlignItems" => { ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? @@ -511,6 +512,19 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.contains('H') { + cl.grid_align_h = val; + } else { + cl.grid_align_v = 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; diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0192ab7b..cfb11f22 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -281,6 +281,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo MainAlign::Center => "center", MainAlign::Max => "flex-end", MainAlign::SpaceBetween => "space-between", + MainAlign::SpaceAround => "space-around", + MainAlign::SpaceEvenly => "space-evenly", }; put("justify-content", justify.into()); let align = match l.cross_align { diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index c686e705..8513ac80 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -382,7 +382,8 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section { let dir = match (l.mode, l.wrap) { (LayoutMode::Horizontal, true) => "Horizontal, wrap", (LayoutMode::Horizontal, false) => "Horizontal", - (LayoutMode::Vertical, _) => "Vertical", + (LayoutMode::Vertical, true) => "Vertical, wrap", + (LayoutMode::Vertical, false) => "Vertical", (LayoutMode::Grid, _) => "Grid", }; let mut rows = vec![prop("Direction", dir)]; @@ -390,10 +391,16 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section { rows.push(prop("Grid", format!("{} × {}", l.grid_columns, l.grid_rows))); rows.push(prop("Gap", format!("{} {}", num(l.gap), num(l.cross_gap)))); } else { - let gap = if l.main_align == MainAlign::SpaceBetween { "Auto (space between)".to_string() } else { num(l.gap) }; + let gap = match l.main_align { + MainAlign::SpaceBetween => "Auto (space between)".to_string(), + MainAlign::SpaceAround => "Auto (space around)".to_string(), + MainAlign::SpaceEvenly => "Auto (space evenly)".to_string(), + _ => num(l.gap), + }; rows.push(prop("Gap", gap).token(variable(doc, n, "gap"))); if l.wrap { - rows.push(prop("Row gap", num(l.cross_gap)).token(variable(doc, n, "crossGap"))); + let label = if l.mode == LayoutMode::Vertical { "Column gap" } else { "Row gap" }; + rows.push(prop(label, num(l.cross_gap)).token(variable(doc, n, "crossGap"))); } } let [t, r, b, lft] = l.padding; @@ -411,6 +418,8 @@ fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section { MainAlign::Center => "center", MainAlign::Max => "end", MainAlign::SpaceBetween => "space between", + MainAlign::SpaceAround => "space around", + MainAlign::SpaceEvenly => "space evenly", }; let cross = match l.cross_align { CrossAlign::Min => "start", diff --git a/crates/engine/src/drop.rs b/crates/engine/src/drop.rs index f8533a91..15ea1364 100644 --- a/crates/engine/src/drop.rs +++ b/crates/engine/src/drop.rs @@ -30,8 +30,8 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod // A slot before the k-th placed child (k = its count: after the last) as an index in `kids`. let index = |k: usize| flow.get(k).map_or(kids.len(), |(i, _)| *i); let flow_ids: Vec = flow.iter().map(|(_, c)| *c).collect(); - if layout.wrap && !vertical && layout.mode != LayoutMode::Grid { - let (k, line) = wrap_slot(doc, &flow_ids, p); + if layout.wrap && layout.mode != LayoutMode::Grid { + let (k, line) = wrap_slot(doc, &flow_ids, p, vertical); return Some((index(k), line)); } let pos = if vertical { p.y } else { p.x }; @@ -53,12 +53,18 @@ pub fn line_before(b: figcraft_geom::Rect, vertical: bool, off: f64) -> [f64; 4] if vertical { [b.x0, b.y0 - off, b.x1, b.y0 - off] } else { [b.x0 - off, b.y0, b.x0 - off, b.y1] } } -/// The visual rows of a wrapping row layout's placed children `kids`, each child as (its index in -/// `kids`, its page bounds). Children wrap in flow order, so a new row starts where a child sits -/// left of the one before. -pub fn wrap_rows(doc: &Document, kids: &[NodeId]) -> Vec> { +/// `r` with x and y swapped when `vertical`: a vertical wrap (columns) is a horizontal one (rows) +/// turned on its side. +pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect { + if vertical { figcraft_geom::Rect::new(r.y0, r.x0, r.y1, r.x1) } else { r } +} + +/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each +/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children +/// wrap in flow order, so a new row starts where a child sits before the one before. +pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec> { let mut rows: Vec> = Vec::new(); - for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, b))) { + for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) { match rows.last_mut() { Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)), _ => rows.push(vec![(i, b)]), @@ -67,9 +73,14 @@ pub fn wrap_rows(doc: &Document, kids: &[NodeId]) -> Vec (usize, Option<[f64; 4]>) { - let rows = wrap_rows(doc, kids); +/// The slot in a wrapping layout: the visual row (column) under the pointer, then the position in it. +fn wrap_slot(doc: &Document, kids: &[NodeId], p: Point, vertical: bool) -> (usize, Option<[f64; 4]>) { + let (k, line) = wrap_slot_rows(&wrap_rows(doc, kids, vertical), if vertical { Point::new(p.y, p.x) } else { p }); + (k, line.map(|[x0, y0, x1, y1]| if vertical { [y0, x0, y1, x1] } else { [x0, y0, x1, y1] })) +} + +/// [`wrap_slot`] in rows that run along x. +fn wrap_slot_rows(rows: &[Vec<(usize, figcraft_geom::Rect)>], p: Point) -> (usize, Option<[f64; 4]>) { let span = |row: &[(usize, figcraft_geom::Rect)]| row.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, b)| (lo.min(b.y0), hi.max(b.y1))); // The row containing the pointer, else the nearest one. diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index b00ac73e..b1c02700 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -99,7 +99,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { 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()}), + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), ); } if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1a214df3..5221f472 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1179,6 +1179,8 @@ fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet Option { Some("CENTER") => MainAlign::Center, Some("MAX") => MainAlign::Max, Some("SPACE_BETWEEN") => MainAlign::SpaceBetween, + Some("SPACE_AROUND") => MainAlign::SpaceAround, + Some("SPACE_EVENLY") => MainAlign::SpaceEvenly, _ => MainAlign::Min, }, cross_align: match text(v, "counterAxisAlignItems") { @@ -460,6 +462,14 @@ pub(super) fn child_layout(v: &Value, own: Option, parent_mode: Opti cl.grid_row = index("gridRowAnchorIndex"); cl.grid_column_span = index("gridColumnSpan").unwrap_or(0); cl.grid_row_span = index("gridRowSpan").unwrap_or(0); + let align = |k: &str| match text(v, k) { + Some("MIN") => Some(CellAlign::Min), + Some("CENTER") => Some(CellAlign::Center), + Some("MAX") => Some(CellAlign::Max), + _ => None, + }; + cl.grid_align_h = align("gridChildHorizontalAlign"); + cl.grid_align_v = align("gridChildVerticalAlign"); cl } diff --git a/crates/layout/src/behaviour_tests.rs b/crates/layout/src/behaviour_tests.rs new file mode 100644 index 00000000..36f5454f --- /dev/null +++ b/crates/layout/src/behaviour_tests.rs @@ -0,0 +1,789 @@ +//! Auto layout behaviour suite: each case is a rule from the public documentation of auto layout +//! (hug / fill / fixed, min/max, padding, strokes, gaps, auto gaps, alignment, baselines, wrap, +//! absolute position and constraints, text, grid tracks and cells, canvas stacking) with the sizes +//! and positions the rule implies. The comment on each case is the one-line derivation. Where the +//! documentation is silent the case says so and pins the current behaviour instead. + +use super::tests::FakeMeasure; +use super::*; +use figcraft_doc::color::{Color, Paint}; +use figcraft_doc::{Constraints, GridTrack, Node, TextData}; +use std::collections::HashMap; + +use CellAlign as CA; +use LayoutMode::{Grid, Horizontal as H, Vertical as V}; +use Sizing::{Fill, Fixed, Hug}; + +/// An auto layout frame: everything a case can set (`FRAME` holds the defaults). +#[derive(Clone, Copy)] +struct Frame<'a> { + mode: LayoutMode, + wrap: bool, + size: (f64, f64), + sizing: (Sizing, Sizing), + pad: [f64; 4], + gap: f64, + cross_gap: f64, + main: MainAlign, + cross: CrossAlign, + /// A visible stroke: weight, alignment and optional per-side weights. + stroke: Option<(f64, StrokeAlign, Option<[f64; 4]>)>, + strokes_included: bool, + min: (Option, Option), + max: (Option, Option), + columns: &'a [GridTrack], + rows: &'a [GridTrack], +} + +const FRAME: Frame<'static> = Frame { + mode: H, + wrap: false, + size: (100.0, 100.0), + sizing: (Fixed, Fixed), + pad: [0.0; 4], + gap: 0.0, + cross_gap: 0.0, + main: MainAlign::Min, + cross: CrossAlign::Min, + stroke: None, + strokes_included: true, + min: (None, None), + max: (None, None), + columns: &[], + rows: &[], +}; + +/// A child: a rectangle, or a text (natural width, height and first baseline) when `text` is set. +#[derive(Clone, Copy)] +struct Kid { + size: (f64, f64), + sizing: (Sizing, Sizing), + min: (Option, Option), + max: (Option, Option), + text: Option<(f64, f64, f64)>, + absolute: bool, + at: (f64, f64), + constraints: (HConstraint, VConstraint), + cell: Option<(u32, u32)>, + span: (u32, u32), + align: (Option, Option), +} + +const KID: Kid = Kid { + size: (20.0, 20.0), + sizing: (Fixed, Fixed), + min: (None, None), + max: (None, None), + text: None, + absolute: false, + at: (0.0, 0.0), + constraints: (HConstraint::Left, VConstraint::Top), + cell: None, + span: (1, 1), + align: (None, None), +}; + +const fn kid(w: f64, h: f64) -> Kid { + Kid { size: (w, h), ..KID } +} + +const fn fixed(v: f64) -> GridTrack { + GridTrack { sizing: Fixed, value: v } +} + +const fn fr(v: f64) -> GridTrack { + GridTrack { sizing: Fill, value: v } +} + +const HUG: GridTrack = GridTrack { sizing: Hug, value: 0.0 }; + +struct Case<'a> { + rule: &'static str, + frame: Frame<'a>, + kids: &'a [Kid], + /// Expected frame size and each child's `(x, y, w, h)` in the frame. + want: (f64, f64), + rects: &'a [(f64, f64, f64, f64)], +} + +fn build(c: &Case<'_>) -> (Document, NodeId, Vec, FakeMeasure) { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let f = c.frame; + let id = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, f.size.0, f.size.1).unwrap(); + { + let n = d.get_mut(id).unwrap(); + n.layout = Some(AutoLayout { + mode: f.mode, + wrap: f.wrap, + gap: f.gap, + cross_gap: f.cross_gap, + padding: f.pad, + main_align: f.main, + cross_align: f.cross, + grid_columns: f.columns.len().max(1) as u32, + grid_rows: f.rows.len().max(1) as u32, + column_tracks: f.columns.to_vec(), + row_tracks: f.rows.to_vec(), + strokes_included: f.strokes_included, + reverse_z: false, + }); + (n.child_layout.sizing_h, n.child_layout.sizing_v) = f.sizing; + (n.child_layout.min_w, n.child_layout.min_h) = f.min; + (n.child_layout.max_w, n.child_layout.max_h) = f.max; + if let Some((w, align, sides)) = f.stroke { + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = w; + n.stroke.align = align; + n.stroke.sides = sides; + } + } + let mut texts = HashMap::new(); + let mut ids = Vec::new(); + for k in c.kids { + let kind = if k.text.is_some() { NodeKind::Text(TextData::new("T")) } else { NodeKind::Rectangle }; + let kid = d.create(id, kind, "K", k.at.0, k.at.1, k.size.0, k.size.1).unwrap(); + let n = d.get_mut(kid).unwrap(); + if let (Some(t), NodeKind::Text(td)) = (k.text, &mut n.kind) { + // Auto width hugs both axes; filling the width wraps (auto height). + td.auto_resize = if k.sizing.0 == Fill { TextAutoResize::Height } else { TextAutoResize::WidthAndHeight }; + texts.insert(kid, t); + } + let cl = &mut n.child_layout; + (cl.sizing_h, cl.sizing_v) = k.sizing; + (cl.min_w, cl.min_h) = k.min; + (cl.max_w, cl.max_h) = k.max; + cl.absolute = k.absolute; + (cl.grid_column, cl.grid_row) = (k.cell.map(|c| c.0), k.cell.map(|c| c.1)); + (cl.grid_column_span, cl.grid_row_span) = k.span; + (cl.grid_align_h, cl.grid_align_v) = k.align; + n.constraints = Constraints { horizontal: k.constraints.0, vertical: k.constraints.1 }; + ids.push(kid); + } + (d, id, ids, FakeMeasure(texts)) +} + +fn rect_of(d: &Document, id: NodeId) -> (f64, f64, f64, f64) { + let n: &Node = d.node(id).unwrap(); + let c = n.transform.as_coeffs(); + (c[4], c[5], n.width, n.height) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn check(cases: &[Case<'_>]) { + let mut failures = Vec::new(); + for c in cases { + let (mut d, f, ids, m) = build(c); + layout_frame(&mut d, f, &m); + let n = d.node(f).unwrap(); + let got: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect(); + let size_ok = close(n.width, c.want.0) && close(n.height, c.want.1); + let rects_ok = got.len() == c.rects.len() + && got.iter().zip(c.rects).all(|(g, w)| close(g.0, w.0) && close(g.1, w.1) && close(g.2, w.2) && close(g.3, w.3)); + if !size_ok || !rects_ok { + failures.push(format!("{}\n frame {:?} want {:?}\n kids {:?}\n want {:?}", c.rule, (n.width, n.height), c.want, got, c.rects)); + } + // Laying out again changes nothing (one pass settles). + let before: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect(); + layout_frame(&mut d, f, &m); + let after: Vec<_> = ids.iter().map(|k| rect_of(&d, *k)).collect(); + if before != after { + failures.push(format!("{}: a second pass moved children {before:?} -> {after:?}", c.rule)); + } + } + assert!(failures.is_empty(), "{} rule(s) diverge:\n{}", failures.len(), failures.join("\n")); +} + +#[test] +fn sizing_rules() { + check(&[ + Case { + rule: "hug: a 40 px text plus 10 px left and right padding makes a 60 px frame", + // 10 + 40 + 10 = 60 wide; 20 tall (no vertical padding). + frame: Frame { sizing: (Hug, Hug), pad: [0.0, 10.0, 0.0, 10.0], ..FRAME }, + kids: &[Kid { text: Some((40.0, 20.0, 15.0)), sizing: (Hug, Hug), ..KID }], + want: (60.0, 20.0), + rects: &[(10.0, 0.0, 40.0, 20.0)], + }, + Case { + rule: "fixed: the frame keeps its size whatever its children need", + // 3 × 50 = 150 overflows the fixed 100 px frame; it stays 100 × 50. + frame: Frame { size: (100.0, 50.0), ..FRAME }, + kids: &[kid(50.0, 20.0), kid(50.0, 20.0), kid(50.0, 20.0)], + want: (100.0, 50.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 50.0, 20.0), (100.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "fill: children share the space left after padding, gaps and fixed siblings", + // 300 - 2×10 padding - 60 fixed - 2×20 gaps = 180 → 90 each. + frame: Frame { size: (300.0, 40.0), pad: [0.0, 10.0, 0.0, 10.0], gap: 20.0, ..FRAME }, + kids: &[kid(60.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }], + want: (300.0, 40.0), + rects: &[(10.0, 0.0, 60.0, 20.0), (90.0, 0.0, 90.0, 20.0), (200.0, 0.0, 90.0, 20.0)], + }, + Case { + rule: "fill across the flow stretches to the frame's inner height", + // 100 - 2×10 padding = 80. + frame: Frame { size: (100.0, 100.0), pad: [10.0; 4], ..FRAME }, + kids: &[Kid { sizing: (Fixed, Fill), ..KID }], + want: (100.0, 100.0), + rects: &[(10.0, 10.0, 20.0, 80.0)], + }, + Case { + rule: "fill on an axis the frame hugs has nothing to fill: the child keeps its size", + // A hugging frame has no free space; it hugs 50 + 30. + frame: Frame { sizing: (Hug, Hug), ..FRAME }, + kids: &[kid(50.0, 20.0), Kid { size: (30.0, 40.0), sizing: (Fill, Fill), ..KID }], + want: (80.0, 40.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 30.0, 40.0)], + }, + Case { + rule: "min width: a fixed child is never narrower than its minimum", + frame: Frame { sizing: (Hug, Hug), ..FRAME }, + kids: &[Kid { size: (50.0, 20.0), min: (Some(80.0), None), ..KID }], + want: (80.0, 20.0), + rects: &[(0.0, 0.0, 80.0, 20.0)], + }, + Case { + rule: "max width on a fill child: it stops at its max and the other fill child takes the rest", + // 200 shared: one stops at 50, the other fills the remaining 150. + frame: Frame { size: (200.0, 20.0), ..FRAME }, + kids: &[Kid { sizing: (Fill, Fixed), max: (Some(50.0), None), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }], + want: (200.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (50.0, 0.0, 150.0, 20.0)], + }, + Case { + rule: "max width on a hugging frame: it hugs up to its max", + // Content 300 wide, max 200. + frame: Frame { sizing: (Hug, Hug), max: (Some(200.0), None), ..FRAME }, + kids: &[kid(300.0, 20.0)], + want: (200.0, 20.0), + rects: &[(0.0, 0.0, 300.0, 20.0)], + }, + Case { + rule: "min height on a hugging frame: it hugs, but never below its min", + frame: Frame { sizing: (Hug, Hug), min: (None, Some(60.0)), ..FRAME }, + kids: &[kid(30.0, 20.0)], + want: (30.0, 60.0), + rects: &[(0.0, 0.0, 30.0, 20.0)], + }, + Case { + rule: "a hugging text wider than its max width wraps at the max", + // 150 px of text at max 100 wraps to 2 lines of 20. + frame: Frame { sizing: (Hug, Hug), ..FRAME }, + kids: &[Kid { text: Some((150.0, 20.0, 15.0)), sizing: (Hug, Hug), max: (Some(100.0), None), ..KID }], + want: (100.0, 40.0), + rects: &[(0.0, 0.0, 100.0, 40.0)], + }, + Case { + rule: "a text filling the width wraps and its hugging frame grows to its new height", + // 150 px of text in an 80 px inner width (100 - 2×10) needs 2 lines: 40 + 20 padding. + frame: Frame { size: (100.0, 10.0), sizing: (Fixed, Hug), pad: [10.0; 4], ..FRAME }, + kids: &[Kid { text: Some((150.0, 20.0, 15.0)), sizing: (Fill, Hug), ..KID }], + want: (100.0, 60.0), + rects: &[(10.0, 10.0, 80.0, 40.0)], + }, + ]); +} + +#[test] +fn padding_and_stroke_rules() { + let inside = Some((4.0, StrokeAlign::Inside, None)); + check(&[ + Case { + rule: "padding: space between the frame's edge and its children, per side", + // 4 + 50 + 2 wide, 1 + 20 + 3 tall; the child sits at (left, top). + frame: Frame { sizing: (Hug, Hug), pad: [1.0, 2.0, 3.0, 4.0], ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (56.0, 24.0), + rects: &[(4.0, 1.0, 50.0, 20.0)], + }, + Case { + rule: "a fixed frame is never smaller than its padding: it sizes up to fit", + // 10 px fixed, 20 px padding each side → 40. + frame: Frame { size: (10.0, 10.0), pad: [20.0; 4], ..FRAME }, + kids: &[], + want: (40.0, 40.0), + rects: &[], + }, + Case { + rule: "the padding floor wins over a max size", + // max width 10 < 20 + 20 padding → 40. + frame: Frame { size: (100.0, 50.0), pad: [0.0, 20.0, 0.0, 20.0], max: (Some(10.0), None), ..FRAME }, + kids: &[], + want: (40.0, 50.0), + rects: &[], + }, + Case { + rule: "an empty hugging frame is its padding plus its included inside stroke", + // 8 + 2 on each side → 20 × 20. + frame: Frame { sizing: (Hug, Hug), pad: [8.0; 4], stroke: Some((2.0, StrokeAlign::Inside, None)), ..FRAME }, + kids: &[], + want: (20.0, 20.0), + rects: &[], + }, + Case { + rule: "strokes included: the frame's inside stroke takes up space like padding", + // 4 + 50 + 4 by 4 + 20 + 4. + frame: Frame { sizing: (Hug, Hug), stroke: inside, ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (58.0, 28.0), + rects: &[(4.0, 4.0, 50.0, 20.0)], + }, + Case { + rule: "strokes excluded: the stroke takes no space", + frame: Frame { sizing: (Hug, Hug), stroke: inside, strokes_included: false, ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "center strokes are never counted, even when strokes are included", + frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Center, None)), ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "outside strokes are never counted, even when strokes are included", + frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Outside, None)), ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0)], + }, + Case { + // Docs silent on per-side weights: each side counts its own inside weight. + rule: "individual inside stroke weights count per side", + // 4 + 50 + 2 by 1 + 20 + 3. + frame: Frame { sizing: (Hug, Hug), stroke: Some((4.0, StrokeAlign::Inside, Some([1.0, 2.0, 3.0, 4.0]))), ..FRAME }, + kids: &[kid(50.0, 20.0)], + want: (56.0, 24.0), + rects: &[(4.0, 1.0, 50.0, 20.0)], + }, + Case { + rule: "fill children share the space inside padding and the included stroke", + // 100 - 2×(10 + 5) = 70 by 50 - 30 = 20. + frame: Frame { size: (100.0, 50.0), pad: [10.0; 4], stroke: Some((5.0, StrokeAlign::Inside, None)), ..FRAME }, + kids: &[Kid { sizing: (Fill, Fill), ..KID }], + want: (100.0, 50.0), + rects: &[(15.0, 15.0, 70.0, 20.0)], + }, + ]); +} + +#[test] +fn gap_rules() { + let three = &[kid(20.0, 20.0), kid(20.0, 20.0), kid(20.0, 20.0)]; + check(&[ + Case { + rule: "gap: a fixed distance between children; a hugging frame includes it", + // 20 + 10 + 20 + 10 + 20 = 80. + frame: Frame { sizing: (Hug, Hug), gap: 10.0, ..FRAME }, + kids: three, + want: (80.0, 20.0), + rects: &[(0.0, 0.0, 20.0, 20.0), (30.0, 0.0, 20.0, 20.0), (60.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "negative gap: children overlap by the gap", + // 50 - 10 = 40 per step; hug 50 + 40 = 90. + frame: Frame { sizing: (Hug, Hug), gap: -10.0, ..FRAME }, + kids: &[kid(50.0, 20.0), kid(50.0, 20.0)], + want: (90.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (40.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "vertical gap: the same along y", + frame: Frame { mode: V, sizing: (Hug, Hug), gap: 5.0, ..FRAME }, + kids: &[kid(20.0, 30.0), kid(40.0, 10.0)], + want: (40.0, 45.0), + rects: &[(0.0, 0.0, 20.0, 30.0), (0.0, 35.0, 40.0, 10.0)], + }, + Case { + rule: "auto gap between: as far apart as possible, no space at the edges", + // 200 - 60 = 140 over 2 gaps → 70. + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, gap: 10.0, ..FRAME }, + kids: three, + want: (200.0, 20.0), + rects: &[(0.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (180.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "auto gap evenly: equal space in every gap and at both edges (1:1)", + // 140 over 4 spaces → 35. + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME }, + kids: three, + want: (200.0, 20.0), + rects: &[(35.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (145.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "auto gap around: gaps twice the edge space (2:1)", + // 140 = 2e + 2×(2e) → e = 23.33, gap 46.67. + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceAround, ..FRAME }, + kids: three, + want: (200.0, 20.0), + rects: &[(70.0 / 3.0, 0.0, 20.0, 20.0), (90.0, 0.0, 20.0, 20.0), (470.0 / 3.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "auto gap stops at 0 when the children don't fit (no overlap)", + // 30 + 30 > 50: the gap is 0, not -10. + frame: Frame { size: (50.0, 20.0), main: MainAlign::SpaceBetween, ..FRAME }, + kids: &[kid(30.0, 20.0), kid(30.0, 20.0)], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 30.0, 20.0), (30.0, 0.0, 30.0, 20.0)], + }, + Case { + rule: "auto gap evenly stops at 0 too (children start at the edge)", + frame: Frame { size: (50.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME }, + kids: &[kid(30.0, 20.0), kid(30.0, 20.0)], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 30.0, 20.0), (30.0, 0.0, 30.0, 20.0)], + }, + Case { + rule: "a single child of auto gap between aligns to the start (left)", + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, ..FRAME }, + kids: &[kid(20.0, 20.0)], + want: (200.0, 20.0), + rects: &[(0.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "a single child of auto gap evenly has equal space on both sides", + // (200 - 20) / 2 = 90. + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceEvenly, ..FRAME }, + kids: &[kid(20.0, 20.0)], + want: (200.0, 20.0), + rects: &[(90.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "auto gap with fill children: they take the free space, leaving no gap", + // Fill takes 200 - 20 - 20; between has nothing left to share. + frame: Frame { size: (200.0, 20.0), main: MainAlign::SpaceBetween, gap: 10.0, ..FRAME }, + kids: &[kid(20.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }, kid(20.0, 20.0)], + want: (200.0, 20.0), + rects: &[(0.0, 0.0, 20.0, 20.0), (20.0, 0.0, 160.0, 20.0), (180.0, 0.0, 20.0, 20.0)], + }, + ]); +} + +#[test] +fn alignment_rules() { + let one = &[kid(50.0, 20.0)]; + check(&[ + Case { + rule: "align top left (the default)", + frame: Frame { size: (200.0, 100.0), ..FRAME }, + kids: one, + want: (200.0, 100.0), + rects: &[(0.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "align center: centered on both axes", + // ((200 - 50) / 2, (100 - 20) / 2). + frame: Frame { size: (200.0, 100.0), main: MainAlign::Center, cross: CrossAlign::Center, ..FRAME }, + kids: one, + want: (200.0, 100.0), + rects: &[(75.0, 40.0, 50.0, 20.0)], + }, + Case { + rule: "align bottom right", + frame: Frame { size: (200.0, 100.0), main: MainAlign::Max, cross: CrossAlign::Max, ..FRAME }, + kids: one, + want: (200.0, 100.0), + rects: &[(150.0, 80.0, 50.0, 20.0)], + }, + Case { + rule: "vertical flow: main alignment is vertical, cross alignment horizontal", + // Main max → y = 100 - 20; cross center → x = 75. + frame: Frame { mode: V, size: (200.0, 100.0), main: MainAlign::Max, cross: CrossAlign::Center, ..FRAME }, + kids: one, + want: (200.0, 100.0), + rects: &[(75.0, 80.0, 50.0, 20.0)], + }, + Case { + rule: "alignment works inside the padding", + // Center of the inner 180 × 80 box at (10, 10). + frame: Frame { size: (200.0, 100.0), pad: [10.0; 4], main: MainAlign::Center, cross: CrossAlign::Center, ..FRAME }, + kids: one, + want: (200.0, 100.0), + rects: &[(75.0, 40.0, 50.0, 20.0)], + }, + Case { + rule: "a hugging frame has no extra space: alignment changes nothing visible", + frame: Frame { sizing: (Hug, Hug), main: MainAlign::Max, cross: CrossAlign::Max, ..FRAME }, + kids: one, + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0)], + }, + Case { + rule: "auto gap keeps the cross alignment (top / center / bottom)", + frame: Frame { size: (200.0, 100.0), main: MainAlign::SpaceBetween, cross: CrossAlign::Center, ..FRAME }, + kids: &[kid(20.0, 20.0), kid(20.0, 40.0)], + want: (200.0, 100.0), + rects: &[(0.0, 40.0, 20.0, 20.0), (180.0, 30.0, 20.0, 40.0)], + }, + Case { + rule: "text baselines: texts of different sizes sit on one baseline", + // Baselines 10 and 20: the first moves down 10; the frame hugs max(10 + 14, 28). + frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, gap: 5.0, ..FRAME }, + kids: &[ + Kid { text: Some((30.0, 14.0, 10.0)), sizing: (Hug, Hug), ..KID }, + Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }, + ], + want: (95.0, 28.0), + rects: &[(0.0, 10.0, 30.0, 14.0), (35.0, 0.0, 60.0, 28.0)], + }, + Case { + // Docs silent on layers without text: they keep to the top of the line. + rule: "baseline alignment: a layer without text sits at the top", + frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME }, + kids: &[kid(10.0, 10.0), Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }], + want: (70.0, 28.0), + rects: &[(0.0, 0.0, 10.0, 10.0), (10.0, 0.0, 60.0, 28.0)], + }, + ]); +} + +#[test] +fn wrap_rules() { + let three = &[kid(40.0, 20.0), kid(40.0, 20.0), kid(40.0, 20.0)]; + check(&[ + Case { + rule: "horizontal wrap: left to right, a new row when the width runs out", + // 40 + 10 + 40 = 90 fits in 100; the third wraps to y = 20 + 5. + frame: Frame { wrap: true, size: (100.0, 10.0), sizing: (Fixed, Hug), gap: 10.0, cross_gap: 5.0, ..FRAME }, + kids: three, + want: (100.0, 45.0), + rects: &[(0.0, 0.0, 40.0, 20.0), (50.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)], + }, + Case { + rule: "vertical wrap: top to bottom, a new column when the height runs out", + // 40 + 10 + 40 = 90 fits in 100; the third starts a column at x = 20 + 5. + frame: Frame { mode: V, wrap: true, size: (10.0, 100.0), sizing: (Hug, Fixed), gap: 10.0, cross_gap: 5.0, ..FRAME }, + kids: &[kid(20.0, 40.0), kid(20.0, 40.0), kid(20.0, 40.0)], + want: (45.0, 100.0), + rects: &[(0.0, 0.0, 20.0, 40.0), (0.0, 50.0, 20.0, 40.0), (25.0, 0.0, 20.0, 40.0)], + }, + Case { + rule: "vertical wrap is not masonry: a column fills to the height before the next starts", + // 30 + 30 + 30 = 90 ≤ 100 stay in column 1 even though column 2 is empty. + frame: Frame { mode: V, wrap: true, size: (100.0, 100.0), ..FRAME }, + kids: &[kid(20.0, 30.0), kid(20.0, 30.0), kid(20.0, 30.0), kid(20.0, 30.0)], + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 20.0, 30.0), (0.0, 30.0, 20.0, 30.0), (0.0, 60.0, 20.0, 30.0), (20.0, 0.0, 20.0, 30.0)], + }, + Case { + rule: "wrap: auto gap spreads each row on its own", + // Row 1: 100 - 80 between 2 → gap 20; row 2: a single child at the start. + frame: Frame { wrap: true, size: (100.0, 100.0), main: MainAlign::SpaceBetween, gap: 10.0, cross_gap: 5.0, ..FRAME }, + kids: three, + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 40.0, 20.0), (60.0, 0.0, 40.0, 20.0), (0.0, 25.0, 40.0, 20.0)], + }, + Case { + rule: "wrap: cross alignment within each row", + // Row heights 40 and 20; the short child centers in row 1: (40 - 20) / 2. + frame: Frame { wrap: true, size: (100.0, 10.0), sizing: (Fixed, Hug), gap: 10.0, cross: CrossAlign::Center, ..FRAME }, + kids: &[kid(40.0, 20.0), kid(40.0, 40.0), kid(40.0, 20.0)], + want: (100.0, 60.0), + rects: &[(0.0, 10.0, 40.0, 20.0), (50.0, 0.0, 40.0, 40.0), (0.0, 40.0, 40.0, 20.0)], + }, + ]); +} + +#[test] +fn absolute_position_rules() { + check(&[ + Case { + rule: "ignore auto layout: the child keeps its place and the frame hugs only the flow", + frame: Frame { sizing: (Hug, Hug), ..FRAME }, + kids: &[kid(50.0, 20.0), Kid { size: (200.0, 200.0), absolute: true, at: (5.0, 5.0), ..KID }], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (5.0, 5.0, 200.0, 200.0)], + }, + Case { + rule: "absolute children follow their constraints when the frame resizes (right / bottom)", + // The frame hugs from 100 × 100 to 50 × 20: right and bottom offsets of 10 stay. + frame: Frame { sizing: (Hug, Hug), ..FRAME }, + kids: &[ + kid(50.0, 20.0), + Kid { size: (10.0, 10.0), absolute: true, at: (80.0, 80.0), constraints: (HConstraint::Right, VConstraint::Bottom), ..KID }, + ], + want: (50.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (30.0, 0.0, 10.0, 10.0)], + }, + Case { + rule: "absolute children follow their constraints (left and right / center)", + // 200 → 100 wide: left and right keeps 10 px each side (width 80); center moves by -50. + frame: Frame { size: (200.0, 100.0), sizing: (Hug, Fixed), ..FRAME }, + kids: &[ + kid(100.0, 20.0), + Kid { size: (180.0, 10.0), absolute: true, at: (10.0, 50.0), constraints: (HConstraint::LeftRight, VConstraint::Top), ..KID }, + Kid { size: (20.0, 10.0), absolute: true, at: (90.0, 0.0), constraints: (HConstraint::Center, VConstraint::Top), ..KID }, + ], + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 100.0, 20.0), (10.0, 50.0, 80.0, 10.0), (40.0, 0.0, 20.0, 10.0)], + }, + ]); +} + +#[test] +fn grid_rules() { + check(&[ + Case { + rule: "fill tracks share the space by fr: 1fr and 2fr take a third and two thirds", + frame: Frame { mode: Grid, size: (300.0, 50.0), columns: &[fr(1.0), fr(2.0)], ..FRAME }, + kids: &[Kid { sizing: (Fill, Fill), ..KID }, Kid { sizing: (Fill, Fill), ..KID }], + want: (300.0, 50.0), + rects: &[(0.0, 0.0, 100.0, 50.0), (100.0, 0.0, 200.0, 50.0)], + }, + Case { + rule: "fixed tracks keep their size; fill tracks share the rest after gaps and padding", + // 300 - 2×10 padding - 10 gap - 100 = 170. + frame: Frame { mode: Grid, size: (300.0, 50.0), pad: [0.0, 10.0, 0.0, 10.0], gap: 10.0, columns: &[fixed(100.0), fr(1.0)], ..FRAME }, + kids: &[Kid { sizing: (Fill, Fixed), ..KID }, Kid { sizing: (Fill, Fixed), ..KID }], + want: (300.0, 50.0), + rects: &[(10.0, 0.0, 100.0, 20.0), (120.0, 0.0, 170.0, 20.0)], + }, + Case { + rule: "hug tracks keep the smallest size around their children", + frame: Frame { mode: Grid, size: (300.0, 50.0), columns: &[HUG, fr(1.0)], ..FRAME }, + kids: &[kid(60.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }], + want: (300.0, 50.0), + rects: &[(0.0, 0.0, 60.0, 20.0), (60.0, 0.0, 240.0, 20.0)], + }, + Case { + rule: "fr tracks behave like the CSS fr unit: a fixed child taller than its share keeps its row", + // Share 50 < 80: row 1 keeps 80, row 2 gets the remaining 20. + frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME }, + kids: &[kid(20.0, 80.0), Kid { sizing: (Fixed, Fill), ..KID }], + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 20.0)], + }, + Case { + rule: "fr tracks: children bigger than every share keep their sizes (the grid overflows)", + frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME }, + kids: &[kid(20.0, 80.0), kid(20.0, 70.0)], + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 70.0)], + }, + Case { + rule: "fr columns: a fixed child wider than its share keeps its column", + // Share 100 < 150: column 1 keeps 150; column 2 (2fr alone) gets the other 150. + frame: Frame { mode: Grid, size: (300.0, 20.0), columns: &[fr(1.0), fr(2.0)], ..FRAME }, + kids: &[kid(150.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }], + want: (300.0, 20.0), + rects: &[(0.0, 0.0, 150.0, 20.0), (150.0, 0.0, 150.0, 20.0)], + }, + Case { + rule: "placement: left to right, top to bottom; rows grow to fit the children", + // 2 columns of 50, 3 children → 2 rows of 50 (fill rows share 100). + frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0), fr(1.0)], rows: &[fr(1.0)], ..FRAME }, + kids: &[kid(10.0, 10.0), kid(10.0, 10.0), kid(10.0, 10.0)], + want: (100.0, 100.0), + rects: &[(0.0, 0.0, 10.0, 10.0), (50.0, 0.0, 10.0, 10.0), (0.0, 50.0, 10.0, 10.0)], + }, + Case { + rule: "gap between columns and between rows", + frame: Frame { + mode: Grid, + size: (110.0, 105.0), + gap: 10.0, + cross_gap: 5.0, + columns: &[fr(1.0), fr(1.0)], + rows: &[fr(1.0), fr(1.0)], + ..FRAME + }, + kids: &[kid(10.0, 10.0), kid(10.0, 10.0), kid(10.0, 10.0)], + want: (110.0, 105.0), + rects: &[(0.0, 0.0, 10.0, 10.0), (60.0, 0.0, 10.0, 10.0), (0.0, 55.0, 10.0, 10.0)], + }, + Case { + rule: "a child spanning two columns fills both and the gap between them", + frame: Frame { mode: Grid, size: (110.0, 20.0), gap: 10.0, columns: &[fr(1.0), fr(1.0)], ..FRAME }, + kids: &[Kid { sizing: (Fill, Fill), span: (2, 1), ..KID }], + want: (110.0, 20.0), + rects: &[(0.0, 0.0, 110.0, 20.0)], + }, + Case { + rule: "a child anchored to a cell sits there; the rest flow around it", + frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0), fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME }, + kids: &[kid(10.0, 10.0), Kid { cell: Some((0, 0)), ..KID }], + want: (100.0, 100.0), + rects: &[(50.0, 0.0, 10.0, 10.0), (0.0, 0.0, 20.0, 20.0)], + }, + Case { + rule: "children align in their cells (the frame's alignment)", + // Cells 50 × 50: centered → (15, 15) and (65, 15). + frame: Frame { + mode: Grid, + size: (100.0, 50.0), + main: MainAlign::Center, + cross: CrossAlign::Center, + columns: &[fr(1.0), fr(1.0)], + ..FRAME + }, + kids: &[kid(20.0, 20.0), kid(20.0, 20.0)], + want: (100.0, 50.0), + rects: &[(15.0, 15.0, 20.0, 20.0), (65.0, 15.0, 20.0, 20.0)], + }, + Case { + rule: "a child aligns independently in its cell (its own alignment wins)", + // The second child: right and bottom of its cell → (100 - 20, 50 - 20). + frame: Frame { + mode: Grid, + size: (100.0, 50.0), + main: MainAlign::Center, + cross: CrossAlign::Center, + columns: &[fr(1.0), fr(1.0)], + ..FRAME + }, + kids: &[kid(20.0, 20.0), Kid { align: (Some(CA::Max), Some(CA::Max)), ..KID }], + want: (100.0, 50.0), + rects: &[(15.0, 15.0, 20.0, 20.0), (80.0, 30.0, 20.0, 20.0)], + }, + Case { + rule: "a hugging grid keeps the smallest size around its tracks (fill tracks hug too)", + // Columns 30 and 40 + gap 10 = 80; one row of 20. + frame: Frame { mode: Grid, sizing: (Hug, Hug), gap: 10.0, columns: &[fr(1.0), fr(1.0)], ..FRAME }, + kids: &[kid(30.0, 20.0), kid(40.0, 10.0)], + want: (80.0, 20.0), + rects: &[(0.0, 0.0, 30.0, 20.0), (40.0, 0.0, 40.0, 10.0)], + }, + Case { + rule: "padding: the space between the frame's edge and its cells", + frame: Frame { mode: Grid, sizing: (Hug, Hug), pad: [1.0, 2.0, 3.0, 4.0], columns: &[HUG], ..FRAME }, + kids: &[kid(30.0, 20.0)], + want: (36.0, 24.0), + rects: &[(4.0, 1.0, 30.0, 20.0)], + }, + ]); +} + +/// Canvas stacking changes which child is drawn (and hit) on top, never the places or the layer +/// order. +#[test] +fn canvas_stacking_rule() { + let case = Case { + rule: "negative gap stack", + frame: Frame { sizing: (Hug, Hug), gap: -20.0, ..FRAME }, + kids: &[kid(50.0, 20.0), kid(50.0, 20.0)], + want: (80.0, 20.0), + rects: &[(0.0, 0.0, 50.0, 20.0), (30.0, 0.0, 50.0, 20.0)], + }; + let (mut d, f, ids, m) = build(&case); + layout_frame(&mut d, f, &m); + let page = d.first_page().unwrap(); + let overlap = figcraft_geom::Point::new(40.0, 10.0); + let hit = |d: &Document| d.hit_test(page, overlap, 0.0, figcraft_doc::geometry::BoolResult::default()); + // Last on top by default: the right-most child. + assert_eq!(hit(&d), Some(ids[1])); + assert_eq!(d.node(f).unwrap().paint_order().collect::>(), ids); + d.get_mut(f).unwrap().layout.as_mut().unwrap().reverse_z = true; + layout_frame(&mut d, f, &m); + // First on top: the left-most child is drawn and hit on top; places and layers stay. + assert_eq!(hit(&d), Some(ids[0])); + assert_eq!(d.node(f).unwrap().paint_order().collect::>(), vec![ids[1], ids[0]]); + assert_eq!(d.node(f).unwrap().children, ids); + assert_eq!(rect_of(&d, ids[1]), (30.0, 0.0, 50.0, 20.0)); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 17212352..4d99b479 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -15,8 +15,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use figcraft_doc::{ - AutoLayout, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, TextAutoResize, - VConstraint, + AutoLayout, CellAlign, CrossAlign, Document, GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, NodeId, NodeKind, Sizing, + StrokeAlign, TextAutoResize, VConstraint, }; use figcraft_geom::{Affine, Point, Rect}; @@ -177,12 +177,39 @@ fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool { true } -/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the stroke weight -/// when strokes are included in the layout. +/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the frame's own +/// inside stroke (per side) when strokes are included in the layout; center and outside strokes +/// never take up space. fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) { - let 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) + let included = al.strokes_included && f.has_visible_stroke() && f.stroke.align == StrokeAlign::Inside; + let stroke = if included { f.stroke.sides.unwrap_or([f.stroke.weight; 4]) } else { [0.0; 4] }; + let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).max(0.0)); + let [st, sr, sb, sl] = stroke.map(|w| finite_or_zero(w).max(0.0)); + (pt + st, pr + sr, pb + sb, pl + sl) +} + +/// Where the first child starts and the step between children along the main axis, from the +/// free space (`inner` minus the children's total size). Auto gaps share the free space and never +/// go below 0; a single child of "space between" sits at the start. +fn main_offsets(align: MainAlign, inner: f64, total: f64, n: usize, gap: f64) -> (f64, f64) { + let gaps = gap * n.saturating_sub(1) as f64; + let free = inner - total; + let k = n.max(1) as f64; + match align { + MainAlign::Min => (0.0, gap), + MainAlign::Center => ((free - gaps) / 2.0, gap), + MainAlign::Max => (free - gaps, gap), + MainAlign::SpaceBetween => (0.0, if n > 1 { (free / (n - 1) as f64).max(0.0) } else { 0.0 }), + // Gaps are twice the edge space: n gaps of 2e fill the free space. + MainAlign::SpaceAround => { + let g = (free / k).max(0.0); + (g / 2.0, g) + } + MainAlign::SpaceEvenly => { + let g = (free / (k + 1.0)).max(0.0); + (g, g) + } + } } /// Text that hugs its width: refresh its natural size, honouring min/max width by wrapping (a @@ -276,10 +303,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let horizontal = matches!(al.mode, LayoutMode::Horizontal); let (hug_w, hug_h) = (f.child_layout.sizing_h == Sizing::Hug, f.child_layout.sizing_v == Sizing::Hug); let (ow, oh) = (f.width, f.height); - // The frame's own min/max apply whatever its sizing. + // The frame's own min/max apply whatever its sizing; it is never smaller than its padding. 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 (fw, fh) = (clamp_size(ow, fcl.min_w, fcl.max_w).max(pl + pr), clamp_size(oh, fcl.min_h, fcl.max_h).max(pt + pb)); let kids = flow_children(doc, frame); // Text children refresh their natural size, then every child honours its min/max. @@ -324,7 +351,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } let gap = finite_or_zero(al.gap); - let between = al.main_align == MainAlign::SpaceBetween; + let auto_gap = al.main_align.is_auto_gap(); let wraps = al.wrap && !hug_main; // Fill across a single line: the size is the frame's inner cross size, known up front. Resize @@ -339,7 +366,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { } } - // Split into lines (wrap only on the main axis of a horizontal layout, like Figma). + // Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one). let mut lines: Vec> = Vec::new(); if wraps { let mut cur: Vec = Vec::new(); @@ -368,7 +395,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { let gaps = gap * line.len().saturating_sub(1) as f64; let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect(); if !fills.is_empty() { - distribute(&mut items, fills, inner_main_fixed - fixed - if between { 0.0 } else { gaps }); + distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps }); } } // Fill children take their main size now, so their cross size (wrapped text, a hugging frame) @@ -426,7 +453,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { nw = v; } } - let (nw, nh) = (clamp_size(nw, fcl.min_w, fcl.max_w), clamp_size(nh, fcl.min_h, fcl.max_h)); + let (nw, nh) = (clamp_size(nw, fcl.min_w, fcl.max_w).max(pl + pr), clamp_size(nh, fcl.min_h, fcl.max_h).max(pt + pb)); set_size(doc, frame, nw, nh); apply_constraints(doc, frame, ow, oh); let inner_main = if horizontal { nw - pl - pr } else { nh - pt - pb }; @@ -438,17 +465,8 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { for (li, line) in lines.iter().enumerate() { let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0)); let line_cross = if single { inner_cross } else { line_cross }; - let n_items = line.len(); - let (mut pos, step_gap) = match al.main_align { - MainAlign::Min => (0.0, gap), - MainAlign::Center => ((inner_main - used) / 2.0, gap), - MainAlign::Max => (inner_main - used, gap), - // A single child sits at the start. - MainAlign::SpaceBetween => { - let total: f64 = line.iter().filter_map(|i| items.get(*i)).map(|it| it.main).sum(); - (0.0, if n_items > 1 { (inner_main - total) / (n_items - 1) as f64 } else { 0.0 }) - } - }; + let total = used - gap * line.len().saturating_sub(1) as f64; + let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap); pos += if horizontal { pl } else { pt }; for i in line { let Some(it) = items.get(*i) else { continue }; @@ -644,46 +662,46 @@ fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) { } /// 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). +/// share what is left by their fractional units like the CSS `fr` unit (a track never gets less +/// than its content: one whose share is too small keeps its content size and the others share the +/// rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too). fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option) -> Vec { - let mut out: Vec = 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) { + let mut out: Vec = (0..content.len()) + .map(|i| 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; + _ => content.get(i).copied().unwrap_or(0.0), + }) + .collect(); + let Some(a) = avail else { return out }; + let fr = |i: usize| track_spec(specs, i).1; + let base = |out: &[f64], i: usize| out.get(i).copied().unwrap_or(0.0); + let (mut flex, fixed): (Vec, Vec) = (0..out.len()).partition(|i| track_spec(specs, *i).0 == Sizing::Fill); + let mut used: f64 = fixed.iter().map(|i| base(&out, *i)).sum(); + // Each round either sizes every flexible track or freezes at least one at its content size. + while !flex.is_empty() { + let total: f64 = flex.iter().map(|i| fr(*i)).sum(); + let unit = if total > 1e-12 { (a - used).max(0.0) / total } else { 0.0 }; + let (open, frozen): (Vec, Vec) = flex.iter().partition(|i| unit * fr(**i) >= base(&out, **i)); + if frozen.is_empty() { + for i in open { + if let Some(s) = out.get_mut(i) { + *s = unit * fr(i); + } } + break; } + used += frozen.iter().map(|i| base(&out, *i)).sum::(); + flex = open; } out } -/// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content 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). +/// Content size per track (`hug`: the frame hugs this axis, so fill tracks size to content): the +/// largest single-span child (for a fill track, the least it can shrink to); a spanning child that +/// needs more than its tracks give it grows the content-sized tracks it spans (unless a flexible +/// track can absorb it). fn track_content(cells: &[GridCell], sizes: &[f64], specs: &[figcraft_doc::GridTrack], n: usize, gap: f64, columns: bool, hug: bool) -> Vec { - let by_content = |i: usize| match track_spec(specs, i).0 { - Sizing::Hug => true, - Sizing::Fill => hug, - Sizing::Fixed => false, - }; + let by_content = |i: usize| track_spec(specs, i).0 != Sizing::Fixed; let mut content: Vec = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect(); let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) }; for (c, s) in cells.iter().zip(sizes) { @@ -764,8 +782,8 @@ pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option { 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), + width: clamp_size(width, cl.min_w, cl.max_w).max(pl + pr), + height: clamp_size(height, cl.min_h, cl.max_h).max(pt + pb), }) } @@ -774,9 +792,11 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu let (fw, fh) = (f.width, f.height); let fcl = f.child_layout.clone(); let (hug_w, hug_h) = (fcl.sizing_h == Sizing::Hug, fcl.sizing_v == Sizing::Hug); - // A fixed axis takes its own min/max first, so the tracks see the final size. - let w = if hug_w { fw } else { clamp_size(fw, fcl.min_w, fcl.max_w) }; - let h = if hug_h { fh } else { clamp_size(fh, fcl.min_h, fcl.max_h) }; + // A fixed axis takes its own min/max (and the padding floor) first, so the tracks see the + // final size. + let (pt, pr, pb, pl) = insets(f, al); + let w = if hug_w { fw } else { clamp_size(fw, fcl.min_w, fcl.max_w).max(pl + pr) }; + let h = if hug_h { fh } else { clamp_size(fh, fcl.min_h, fcl.max_h).max(pt + pb) }; set_size(doc, frame, w, h); let Some(g) = grid_geometry(doc, frame) else { return }; // Fill-width children take their column width before the rows are sized, so text that wraps @@ -795,6 +815,22 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu let g = if widened { grid_geometry(doc, frame).unwrap_or(g) } else { g }; set_size(doc, frame, g.width, g.height); apply_constraints(doc, frame, fw, fh); + // The frame's alignment in each cell; a child's own alignment wins. + let frame_h = match al.main_align { + MainAlign::Center => CellAlign::Center, + MainAlign::Max => CellAlign::Max, + _ => CellAlign::Min, + }; + let frame_v = match al.cross_align { + CrossAlign::Center => CellAlign::Center, + CrossAlign::Max => CellAlign::Max, + _ => CellAlign::Min, + }; + let offset = |a: CellAlign, free: f64| match a { + CellAlign::Min => 0.0, + CellAlign::Center => free / 2.0, + CellAlign::Max => free, + }; for c in &g.cells { let cell = g.cell_rect(c); let Some(n) = doc.node(c.id) else { continue }; @@ -808,16 +844,8 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu 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, - }; + let ox = offset(cl.grid_align_h.unwrap_or(frame_h), cell.width() - nw); + let oy = offset(cl.grid_align_v.unwrap_or(frame_v), cell.height() - nh); place(doc, c.id, cell.x0 + ox, cell.y0 + oy); } } @@ -1012,6 +1040,8 @@ pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec Vec { tool( "set_properties", "Set properties", - "Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween), crossAlign, wrap, sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.", + "Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (parent-relative), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.", obj( json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}), &["props"], diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 9e6eef51..c5af308f 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -342,21 +342,14 @@ impl<'a> Exporter<'a> { fn children(&mut self, s: &mut Surface, n: &Node, xf: Affine, depth: usize) { let doc = self.doc; let kids = &n.children; - let mut i = 0; - while i < kids.len() { - let Some(&c) = kids.get(i) else { break }; + for run in doc.paint_runs(n) { + let Some(&c) = kids.get(run.start) else { continue }; if doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) { // Everything above the mask (until the next mask) shows through its alpha. - let mut j = i + 1; - while kids.get(j).is_some_and(|k| !doc.node(*k).is_some_and(|kn| kn.is_mask)) { - j += 1; - } - self.masked(s, c, kids.get(i + 1..j).unwrap_or(&[]), xf, depth); - i = j; - continue; + self.masked(s, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth); + } else { + self.node(s, c, xf, depth + 1, false); } - self.node(s, c, xf, depth + 1, false); - i += 1; } } diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs index 42c915f8..d0be9559 100644 --- a/crates/render/src/backdrop.rs +++ b/crates/render/src/backdrop.rs @@ -129,7 +129,7 @@ pub fn hide_above(doc: &mut Document, id: NodeId, roots: &[NodeId]) { } let Some(parent) = doc.node(cur).and_then(|n| n.parent) else { break }; if let Some(p) = doc.node(parent) { - hide.extend(p.children.iter().skip_while(|c| **c != cur).skip(1)); + hide.extend(p.paint_order().skip_while(|c| *c != cur).skip(1)); } if let Some(pn) = doc.node_mut(parent) { pn.strokes.clear(); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 3a196a08..a9f63046 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -444,8 +444,8 @@ fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None); } if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() { - for c in &n.children { - draw_node(ctx, pix, *c, xf, depth + 1); + for c in n.paint_order() { + draw_node(ctx, pix, c, xf, depth + 1); } } } @@ -544,22 +544,14 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { let kids = &n.children; - let mut i = 0; - while i < kids.len() { - let Some(&c) = kids.get(i) else { break }; - let is_mask = ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible); - if is_mask { + for run in ctx.doc.paint_runs(n) { + let Some(&c) = kids.get(run.start) else { continue }; + if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) { // Everything above the mask (until the next mask) is clipped by its alpha. - let mut j = i + 1; - while kids.get(j).is_some_and(|k| !ctx.doc.node(*k).is_some_and(|kn| kn.is_mask)) { - j += 1; - } - draw_masked(ctx, pix, c, kids.get(i + 1..j).unwrap_or(&[]), xf, depth); - i = j; - continue; + draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth); + } else { + draw_node(ctx, pix, c, xf, depth + 1); } - draw_node(ctx, pix, c, xf, depth + 1); - i += 1; } } diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index 8738a4d6..d92a2121 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -200,7 +200,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op } else { clip }; - for c in n.children.iter().filter_map(|c| ctx.doc.node(*c)) { + for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) { splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1); } } diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index c9cbe482..49335280 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -390,9 +390,8 @@ impl W<'_> { let _ = write!(self.body, ""); } let kids = n.children.clone(); - let mut i = 0; - while i < kids.len() { - let Some(&c) = kids.get(i) else { break }; + for run in self.doc.paint_runs(n) { + let Some(&c) = kids.get(run.start) else { continue }; if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) { let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default(); if kind == figcraft_doc::MaskType::Vector { @@ -421,20 +420,13 @@ impl W<'_> { let _ = write!(self.defs, "{mask_body}"); let _ = write!(self.body, ""); } - let mut j = i + 1; - while let Some(&k) = kids.get(j) { - if self.doc.node(k).is_some_and(|kn| kn.is_mask) { - break; - } - self.node(k, xf, depth + 1); - j += 1; + for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) { + self.node(*k, xf, depth + 1); } self.body.push_str(""); - i = j; - continue; + } else { + self.node(c, xf, depth + 1); } - self.node(c, xf, depth + 1); - i += 1; } if clip && outline.is_some() { self.body.push_str(""); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 36db4abf..c9829e67 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -106,7 +106,9 @@ name = "English" "As typed" = "As typed" "Assets" = "Assets" "Auto" = "Auto" +"Auto (space around)" = "Auto (space around)" "Auto (space between)" = "Auto (space between)" +"Auto (space evenly)" = "Auto (space evenly)" "Auto ({mode})" = "Auto ({mode})" "Auto gap" = "Auto gap" "Auto height" = "Auto height" @@ -167,6 +169,7 @@ name = "English" "Can't read this library: {e}" = "Can't read this library: {e}" "Cancel" = "Cancel" "Canvas" = "Canvas" +"Canvas stacking" = "Canvas stacking" "Case" = "Case" "Case-sensitive forms" = "Case-sensitive forms" "Categories" = "Categories" @@ -380,6 +383,8 @@ name = "English" "Every top-level frame already starts a flow" = "Every top-level frame already starts a flow" "Exact opacity (4 then 5: 45%)" = "Exact opacity (4 then 5: 45%)" "Exclude" = "Exclude" +"Excluded" = "Excluded" +"Excluded from layout" = "Excluded from layout" "Excludes" = "Excludes" "Exclusion" = "Exclusion" "Exit focus view (Esc)" = "Exit focus view (Esc)" @@ -417,6 +422,7 @@ name = "English" "Find layers" = "Find layers" "First" = "First" "First commit" = "First commit" +"First on top" = "First on top" "Fit" = "Fit" "Fit device to window" = "Fit device to window" "Fit to contents" = "Fit to contents" @@ -451,6 +457,7 @@ name = "English" "From the library \"{name}\": read-only" = "From the library \"{name}\": read-only" "Frost" = "Frost" "Gap" = "Gap" +"Gap between columns" = "Gap between columns" "Gap between items" = "Gap between items" "Gap between rows" = "Gap between rows" "Glass" = "Glass" @@ -487,6 +494,7 @@ name = "English" "Hold ⌥Z" = "Hold ⌥Z" "Home" = "Home" "Horizontal" = "Horizontal" +"Horizontal align" = "Horizontal align" "Horizontal hex" = "Horizontal hex" "Horizontal padding" = "Horizontal padding" "Horizontal spacing" = "Horizontal spacing" @@ -509,6 +517,8 @@ name = "English" "Imported {n} new variables, updated {updated}" = { one = "Imported {n} new variable, updated {updated}", other = "Imported {n} new variables, updated {updated}" } "Import…" = "Import…" "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." +"Included" = "Included" +"Included in layout" = "Included in layout" "Individual corners" = "Individual corners" "Individual strokes" = "Individual strokes" "Ink" = "Ink" @@ -551,6 +561,7 @@ name = "English" "Last" = "Last" "Last change: {note}" = "Last change: {note}" "Last commit" = "Last commit" +"Last on top" = "Last on top" "Layer blur" = "Layer blur" "Layer name" = "Layer name" "Layers" = "Layers" @@ -1014,7 +1025,10 @@ name = "English" "Sort" = "Sort" "Sort by date" = "Sort by date" "Sort by page" = "Sort by page" +"Space around" = "Space around" +"Space between" = "Space between" "Space drag" = "Space drag" +"Space evenly" = "Space evenly" "Space+drag" = "Space+drag" "Spacing" = "Spacing" "Splay" = "Splay" @@ -1158,6 +1172,7 @@ name = "English" "Version {version}" = "Version {version}" "Version {v} · {components} · {styles} · {variables}" = "Version {v} · {components} · {styles} · {variables}" "Vertical" = "Vertical" +"Vertical align" = "Vertical align" "Vertical hex" = "Vertical hex" "Vertical padding" = "Vertical padding" "Vertical spacing" = "Vertical spacing" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 74e798fd..c34da149 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -107,7 +107,9 @@ status = "draft — needs native review" "As typed" = "Como se escribió" "Assets" = "Recursos" "Auto" = "Auto" +"Auto (space around)" = "Auto (espacio alrededor)" "Auto (space between)" = "Auto (espacio entre)" +"Auto (space evenly)" = "Auto (espacio uniforme)" "Auto ({mode})" = "Auto ({mode})" "Auto gap" = "Espacio auto" "Auto height" = "Alto automático" @@ -168,6 +170,7 @@ status = "draft — needs native review" "Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}" "Cancel" = "Cancelar" "Canvas" = "Lienzo" +"Canvas stacking" = "Apilado en el lienzo" "Case" = "Mayúsculas y minúsculas" "Case-sensitive forms" = "Formas sensibles a mayúsculas" "Categories" = "Categorías" @@ -381,6 +384,8 @@ status = "draft — needs native review" "Every top-level frame already starts a flow" = "Todos los marcos de nivel superior ya inician un flujo" "Exact opacity (4 then 5: 45%)" = "Opacidad exacta (4 y luego 5: 45 %)" "Exclude" = "Excluir" +"Excluded" = "Excluidos" +"Excluded from layout" = "Excluidos del diseño" "Excludes" = "Exclusiones" "Exclusion" = "Exclusión" "Exit focus view (Esc)" = "Salir de la vista de enfoque (Esc)" @@ -418,6 +423,7 @@ status = "draft — needs native review" "Find layers" = "Buscar capas" "First" = "Primero" "First commit" = "Primer commit" +"First on top" = "Primero encima" "Fit" = "Ajustar" "Fit device to window" = "Ajustar dispositivo a la ventana" "Fit to contents" = "Ajustar al contenido" @@ -452,6 +458,7 @@ status = "draft — needs native review" "From the library \"{name}\": read-only" = "De la biblioteca \"{name}\": solo lectura" "Frost" = "Escarcha" "Gap" = "Espacio" +"Gap between columns" = "Espacio entre columnas" "Gap between items" = "Espacio entre elementos" "Gap between rows" = "Espacio entre filas" "Glass" = "Vidrio" @@ -488,6 +495,7 @@ status = "draft — needs native review" "Hold ⌥Z" = "Mantén ⌥Z" "Home" = "Inicio" "Horizontal" = "Horizontal" +"Horizontal align" = "Alineación horizontal" "Horizontal hex" = "Hexágono horizontal" "Horizontal padding" = "Margen interno horizontal" "Horizontal spacing" = "Espaciado horizontal" @@ -510,6 +518,8 @@ status = "draft — needs native review" "Imported {n} new variables, updated {updated}" = { one = "Variables importadas: {n} nueva; actualizadas: {updated}", other = "Variables importadas: {n} nuevas; actualizadas: {updated}" } "Import…" = "Importar…" "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "En el navegador puedes unirte a sesiones alojadas por la app de escritorio y a archivos de un servidor FigCraft. Una página abierta por https solo llega a otros equipos mediante enlaces wss:// (un servidor con TLS)." +"Included" = "Incluidos" +"Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" @@ -552,6 +562,7 @@ status = "draft — needs native review" "Last" = "Último" "Last change: {note}" = "Último cambio: {note}" "Last commit" = "Último commit" +"Last on top" = "Último encima" "Layer blur" = "Desenfoque de capa" "Layer name" = "Nombre de capa" "Layers" = "Capas" @@ -1015,7 +1026,10 @@ status = "draft — needs native review" "Sort" = "Ordenar" "Sort by date" = "Ordenar por fecha" "Sort by page" = "Ordenar por página" +"Space around" = "Espacio alrededor" +"Space between" = "Espacio entre" "Space drag" = "Espacio y arrastrar" +"Space evenly" = "Espacio uniforme" "Space+drag" = "Espacio+arrastrar" "Spacing" = "Espaciado" "Splay" = "Apertura" @@ -1159,6 +1173,7 @@ status = "draft — needs native review" "Version {version}" = "Versión {version}" "Version {v} · {components} · {styles} · {variables}" = "Versión {v} · {components} · {styles} · {variables}" "Vertical" = "Vertical" +"Vertical align" = "Alineación vertical" "Vertical hex" = "Hexágono vertical" "Vertical padding" = "Margen interno vertical" "Vertical spacing" = "Espaciado vertical" diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index 69f42064..b4be04c2 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -223,32 +223,38 @@ fn al_zones(app: &FigApp) -> Option { tracks.push([to(left, r.1), to(right, r.1)]); } } else { + // Worked out along x: a vertical flow is a horizontal one turned on its side (`flip` + // swaps the axes, `fband` swaps them back). let vertical = al.mode == LayoutMode::Vertical; + let flip = |r: figcraft_geom::Rect| if vertical { figcraft_geom::Rect::new(r.y0, r.x0, r.y1, r.x1) } else { r }; + let fband = |x0: f64, y0: f64, x1: f64, y1: f64| if vertical { band(y0, x0, y1, x1) } else { band(x0, y0, x1, y1) }; + // Main-axis length and padding, cross-axis length and padding. + let (mw, mp0, mp1, cw, cp0, cp1) = if vertical { (fh, pt, pb, fw, pl, pr) } else { (fw, pl, pr, fh, pt, pb) }; let boxes: Vec = figcraft_layout::flow_children(doc, id) .into_iter() .filter_map(|c| doc.node(c)) - .map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())) + .map(|c| flip(figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))) .collect(); - // Lines: a wrapped item starts left of the previous one. + // Lines: a wrapped item starts before the end of the previous one. let mut lines: Vec> = Vec::new(); for b in boxes { match lines.last_mut() { - Some(line) if vertical || !al.wrap || line.last().is_some_and(|a| b.x0 >= a.x1 - 1e-6) => line.push(b), + Some(line) if !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) }; + let (y0, y1) = if al.wrap { span(line) } else { (cp0, cw - cp1) }; 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)); + gaps.push(main(fband(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)); + gaps.push(cross(fband(mp0, span(a).1, mw - mp1, span(b).0), !vertical)); } } } diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 18721c01..ae43e180 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -1,7 +1,8 @@ -//! 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. +//! The Design panel's layout section: auto layout direction and wrap, W/H with their sizing (Fixed / +//! Hug / Fill) and min/max limits, alignment (including text baselines), fixed or auto gaps, +//! padding, strokes in layout and canvas stacking, grid tracks, a grid child's cell, spans and +//! alignment in its cell, 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::*; @@ -45,6 +46,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if let Some(Some(l)) = &al { alignment(app, ui, &ids, l); spacing(app, ui, &ids, l); + advanced(app, ui, &ids, l); if l.mode == LayoutMode::Grid && sel.len() == 1 { tracks(app, ui, doc, &ids); } @@ -65,22 +67,23 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { row(ui, 32.0, |ui| { - let items = - [(Icon::LayoutV, tr("Vertical")), (Icon::LayoutH, tr("Horizontal")), (Icon::LayoutWrap, tr("Wrap")), (Icon::LayoutGrid, tr("Grid"))]; - let active = match (l.mode, l.wrap) { - (LayoutMode::Vertical, _) => 0, - (LayoutMode::Horizontal, false) => 1, - (LayoutMode::Horizontal, true) => 2, - (LayoutMode::Grid, _) => 3, + let items = [(Icon::LayoutV, tr("Vertical")), (Icon::LayoutH, tr("Horizontal")), (Icon::LayoutGrid, tr("Grid"))]; + let active = match l.mode { + LayoutMode::Vertical => 0, + LayoutMode::Horizontal => 1, + LayoutMode::Grid => 2, }; 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); + // Wrap carries over between the flows: rows of a horizontal one, columns of a vertical one. + let mode = ["vertical", "horizontal", "grid"].get(i).copied().unwrap_or("grid"); + app.set_props(ids, json!({"layout": mode})); + } + if l.mode != LayoutMode::Grid { + let resp = icons::button(ui, Icon::LayoutWrap, 24.0, l.wrap, tr("Wrap")); + crate::a11y::toggle(&resp, tr("Wrap"), l.wrap); + if resp.clicked() { + app.set_props(ids, json!({"wrap": !l.wrap})); + } } }); } @@ -271,7 +274,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { MainAlign::Min => 0, MainAlign::Center => 1, MainAlign::Max => 2, - MainAlign::SpaceBetween => 9, + MainAlign::SpaceBetween | MainAlign::SpaceAround | MainAlign::SpaceEvenly => 9, }; let cur_cross = match l.cross_align { CrossAlign::Min | CrossAlign::Baseline => 0, @@ -297,7 +300,8 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { 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 }; + // An auto gap keeps its mode: the cells pick the cross alignment. + let main = if l.main_align.is_auto_gap() && !grid_mode { json!(l.main_align) } else { json!(m) }; app.set_props(ids, json!({"mainAlign": main, "crossAlign": cr})); } } @@ -332,7 +336,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { app.field(ids, "rowGap", e, |v| json!(v.max(0.0))); return; } - let auto = l.main_align == MainAlign::SpaceBetween; + let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; let e = crate::variable_ui::number_field( app, @@ -347,15 +351,26 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); - let resp = widgets::dropdown(ui, if auto { tr("Auto gap") } else { tr("Fixed gap") }, 96.0, None); + let label = match l.main_align { + MainAlign::SpaceBetween => tr("Space between"), + MainAlign::SpaceAround => tr("Space around"), + MainAlign::SpaceEvenly => tr("Space evenly"), + _ => tr("Fixed gap"), + }; + let resp = widgets::dropdown(ui, label, 96.0, None); + crate::a11y::combo(&resp, tr("Auto gap"), label, crate::a11y::menu_open(&resp)); menu_popup(&resp).show(|ui| { - if menu_item(ui, tr("Fixed"), "") { - app.set_props(ids, json!({"mainAlign": "min"})); - ui.close(); - } - if menu_item(ui, tr("Auto (space between)"), "") { - app.set_props(ids, json!({"mainAlign": "spaceBetween"})); - ui.close(); + let modes = [ + (tr("Fixed"), "min"), + (tr("Auto (space between)"), "spaceBetween"), + (tr("Auto (space evenly)"), "spaceEvenly"), + (tr("Auto (space around)"), "spaceAround"), + ]; + for (l, v) in modes { + if menu_item(ui, l, "") { + app.set_props(ids, json!({"mainAlign": v})); + ui.close(); + } } }); }); @@ -400,21 +415,14 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ); 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, tr("Row gap"), None); + if l.mode != LayoutMode::Grid && l.wrap { + // Wrapped rows (horizontal) or columns (vertical): the gap between them. + let vertical = l.mode == LayoutMode::Vertical; + widgets::sublabels(ui, if vertical { tr("Column gap") } else { tr("Row gap") }, None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "crossGap", - Id::new("al_crossgap"), - Lead::Icon(Icon::DistributeV, tr("Gap between rows")), - Some(l.cross_gap), - "", - col, - ); + let (icon, tip) = if vertical { (Icon::Gap, tr("Gap between columns")) } else { (Icon::DistributeV, tr("Gap between rows")) }; + let e = + crate::variable_ui::number_field(app, ui, ids, "crossGap", Id::new("al_crossgap"), Lead::Icon(icon, tip), Some(l.cross_gap), "", col); app.field(ids, "crossGap", e, |v| json!(v.max(0.0))); }); } @@ -429,6 +437,43 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// Whether the frame's inside stroke takes up space, and which child paints on top where children +/// overlap (canvas stacking; the layer order stays). +fn advanced(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { + let col = widgets::col_width(ui.available_width()); + widgets::sublabels(ui, tr("Strokes"), Some(tr("Canvas stacking"))); + row(ui, 32.0, |ui| { + // (value, menu item, the field's shorter label) + let menus = [ + ( + "strokesIncluded", + tr("Strokes"), + l.strokes_included, + [(true, tr("Included in layout"), tr("Included")), (false, tr("Excluded from layout"), tr("Excluded"))], + ), + ( + "reverseZ", + tr("Canvas stacking"), + l.reverse_z, + [(false, tr("Last on top"), tr("Last on top")), (true, tr("First on top"), tr("First on top"))], + ), + ]; + for (key, name, cur, options) in menus { + let label = options.iter().find(|o| o.0 == cur).map_or("", |o| o.2); + let resp = widgets::dropdown(ui, label, col, None); + crate::a11y::combo(&resp, name, label, crate::a11y::menu_open(&resp)); + menu_popup(&resp).show(|ui| { + for (v, l, _) in options { + if menu_item(ui, l, "") { + app.set_props(ids, json!({key: v})); + ui.close(); + } + } + }); + } + }); +} + /// 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 }; @@ -516,4 +561,26 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { 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))); }); + // Alignment in the cell; Auto follows the frame's alignment. + widgets::sublabels(ui, tr("Horizontal align"), Some(tr("Vertical align"))); + row(ui, 32.0, |ui| { + let axes = [ + ("gridAlignH", tr("Horizontal align"), common(doc, sel, |n| n.child_layout.grid_align_h), [tr("Left"), tr("Center"), tr("Right")]), + ("gridAlignV", tr("Vertical align"), common(doc, sel, |n| n.child_layout.grid_align_v), [tr("Top"), tr("Center"), tr("Bottom")]), + ]; + for (key, name, cur, [min, center, max]) in axes { + let options = [(None, tr("Auto")), (Some(CellAlign::Min), min), (Some(CellAlign::Center), center), (Some(CellAlign::Max), max)]; + let label = cur.map_or(tr("Mixed"), |c| options.iter().find(|(v, _)| *v == c).map_or("", |(_, l)| *l)); + let resp = widgets::dropdown(ui, label, col, None); + crate::a11y::combo(&resp, name, label, crate::a11y::menu_open(&resp)); + menu_popup(&resp).show(|ui| { + for (v, l) in options { + if menu_item(ui, l, "") { + app.set_props(&ids, json!({key: v})); + ui.close(); + } + } + }); + } + }); } diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 56a5ad3a..f98e612f 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,26 +1,72 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the ROADMAP's auto layout items and `crates/layout`) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a -hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~95%, -ready ~68% (estimated)**: everything Figma documents exists, checked against the documented rules -with synthetic samples; nothing yet against real Figma files. Remaining: 6–12 h. +hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, +ready ~74% (estimated)**: every rule of the public documentation is a test case with the sizes and +positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and +`crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real +Figma files. Remaining: 3–6 h. + +## Rules from the documentation + +Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed), +**added** (the setting was missing and now exists as a `node.set` prop and a Design panel control), +**silent** (the documentation doesn't say; the case pins the current behaviour). + +| Rule | Status | Evidence / notes | +|---|---|---| +| Hug: the frame sizes to its children plus padding (the 40 px text + 10 px padding = 60 px example) | pass | `sizing_rules` | +| Fixed: the frame keeps its size whatever its children need | pass | `sizing_rules` | +| Fill: children share the space left after padding, gaps and fixed siblings; across the flow they stretch | pass | `sizing_rules` | +| Fill on a child turns its hugging parent Fixed on that axis | pass | `al_behaviour_tests::fill_on_a_child_turns_the_hugging_parent_fixed` | +| Hug only on auto layout frames (and text); Fill only in an auto layout flow, never on a top-level frame | pass | `al_behaviour_tests::hug_and_fill_only_where_they_apply` | +| Min/max width and height bound Hug, Fill and Fixed sizes, on frames and children | pass | `sizing_rules`; a fill child at its max leaves the rest to the other fill children (silent on redistribution: CSS flexbox) | +| A hugging text wider than its max width wraps at the max | pass | `sizing_rules` | +| Text filling a width wraps and its hugging frame grows with it | pass | `sizing_rules` | +| A hugging frame follows a child that grows, through nested levels, in one edit and one undo step | pass | `al_behaviour_tests::a_hugging_frame_follows_its_children_through_nested_levels` | +| Padding per side (uniform, vertical/horizontal, individual) | pass | `padding_and_stroke_rules` | +| A frame is never smaller than its padding (plus an included inside stroke), even when Fixed, hugging or given a smaller max | **fixed** | was free to shrink below its padding | +| Strokes included: only the frame's own **inside** stroke takes up space; center and outside strokes never do | **fixed** | every stroke alignment counted before; per-side inside weights count per side (silent on individual weights) | +| New auto layout includes inside strokes by default | **fixed** | the default was excluded | +| Strokes included / excluded setting in the Design panel | **added** | `strokesIncluded` existed as a prop only | +| Gap: fixed distance; negative gaps overlap | pass | `gap_rules` | +| Auto gap between: no space at the edges; a single child at the start | pass | `gap_rules` | +| Auto gap evenly (1:1 with the edges) and around (gaps twice the edges) | **added** | `mainAlign: spaceEvenly / spaceAround`, Design panel gap menu | +| An auto gap stops at 0 when children don't fit (no overlap) | **fixed** | went negative before | +| Alignment 3×3; with an auto gap only the cross alignment; a hugging frame shows no difference | pass | `alignment_rules` | +| Text baseline alignment, texts of different sizes | pass | `alignment_rules` | +| Baseline alignment of a layer without text (an icon next to text) | silent | it keeps to the top of the line | +| Horizontal wrap: a new row when the width runs out; gap between rows | pass | `wrap_rules` | +| Vertical wrap: a new column when the height runs out; not masonry; gap between columns | **added** | layout, Design panel wrap toggle, column gap field, on-canvas gap handles, drag reorder and arrow keys by column | +| Wrapped rows/columns align their contents (space between rows) | silent | the help centre doesn't describe it; not implemented (the file format has a setting for it) | +| Ignore auto layout: out of the flow, kept in the frame; constraints apply only then | pass | `absolute_position_rules` | +| Canvas stacking: last on top by default, first on top on request; the layer order stays | **fixed** + **added** | `reverseZ` was stored but never drawn: now the canvas, PNG, SVG and PDF exports and hit testing paint in that order; Design panel control | +| Grid: `fr` tracks share by fraction; fixed tracks keep their size; hug tracks fit their content | pass | `grid_rules` | +| Grid `fr` tracks behave like the CSS `fr` unit: a track never gets less than its content | **fixed** | was `minmax(0, 1fr)`: a fixed-size child taller than its share overflowed into the next row | +| Grid placement left to right, top to bottom; rows grow; anchors; spans; gaps; padding | pass | `grid_rules` | +| Grid children align independently in their cells | **added** | `gridAlignH` / `gridAlignV` (Auto follows the frame), Design panel cell alignment | +| A hugging grid with `fr` tracks | silent | the file format calls it invalid; `fr` tracks hug their content | +| A hugging frame with an auto gap | silent | it hugs with the gap value, then shares the (no) extra space | +| One layout pass settles every case | pass | every case is laid out twice and must not move | + +## Features | Feature | Status | Evidence / notes | |---|---|---| -| Horizontal, vertical, wrap | done | `crates/layout` | -| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps | done | `fr` rows of a fixed-height grid behave like `minmax(0, 1fr)` around fixed-size children; Figma's exact rule unconfirmed | -| Gap, auto gap, padding (per side), alignment 3×3, space between | done | | +| Horizontal, vertical, wrap (both directions) | done | `crates/layout` | +| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` | +| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | | | Text baseline alignment | done | | -| Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs` | +| Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` | | Min/max width and height, redistribution | done | hugging text wraps at its max | | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | -| Canvas stacking order (first on top / last on top) | done | | -| On-canvas padding and gap handles, incl. wrap row gap and grid gaps | done | | -| Drag to reorder (wrap-aware, by row), into grid cells, across nested frames | done | `al_drag_tests.rs` | -| Arrow keys move children one place (flows, rows, grid cells, into neighbours) | done | `al_keys_tests.rs` | +| Strokes included in layout (inside strokes), canvas stacking | done | | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | | +| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | +| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | | Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | | | Suggest auto layout / "Add auto layout" on a selection | done | | @@ -31,4 +77,5 @@ with synthetic samples; nothing yet against real Figma files. Remaining: 6–12 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Behaviour suite from the public documentation (64 layout cases, 8 command tests). Fixed: padding floor, inside-only included strokes (and included by default), auto gaps stop at 0, grid `fr` like CSS `fr`, canvas stacking actually drawn and hit. Added: auto gaps evenly/around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Breadth 95% → 98%, ready 68% → 74%, remaining 6–12 h → 3–6 h | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/mcp.md b/docs/mcp.md index e4709f5a..eb79d4d1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -68,14 +68,28 @@ Or in a project's `.mcp.json`: 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). + - `mainAlign` takes `min`, `center`, `max` or an auto gap: `spaceBetween` (no space at the + edges), `spaceEvenly` (the same space in every gap and at the edges) or `spaceAround` (gaps + twice the edge space). An auto gap never goes below 0. + - `padding` never lets a frame get smaller than it: a fixed or hugging frame (or one with a + smaller max size) sizes up to its padding. + - `strokesIncluded` (default `true` for new auto layout): the frame's own inside stroke takes up + space like padding (per side with `strokeSides`); center and outside strokes never do. + - `reverseZ` (canvas stacking): `true` paints and hits the first child on top, `false` (default) + the last. The layer order doesn't change. - `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. + - `wrap` with `crossGap` (alias `rowGap`) for the gap between wrapped rows (horizontal) or + columns (vertical layouts wrap top to bottom into new columns). - 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`. + `gridColumnSpan` / `gridRowSpan`, and `gridAlignH` / `gridAlignV` (`min`, `center`, `max`; + `auto` or `null` uses the frame's alignment) to align it in its cell. `fr` tracks behave like + the CSS `fr` unit: a track never gets less than its content (a fixed-size child bigger than + its share keeps it; the other `fr` tracks share the rest). `inspect` reports a grid child's + resolved `gridCell`. - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. - Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3aa39de9..2917bf52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | | Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) | | Remaining to beta | **~195–355 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~348–672 h** one agent (~92–160 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~345–666 h** one agent (~92–160 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~330–635 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | -| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Full target (ready for real work) | ~47% | ~327–629 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Mainstream practitioner | ~43% | ~162–304 h | core editor depth incl. `.fig` import (80–157), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour @@ -102,7 +102,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 6 | Strokes | 3 | 90% | 72% | Stroke styles (brush strokes landed, see 20) | 1–3 | | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | -| 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 | +| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 85% | 64% | Spell check, CJK text QA, real-file text metrics (text on a path landed) | 7–13 | | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | | 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **190–386** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 85–167 | +| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **187–380** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 82–161 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 63–121 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 60–115 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~47%** | **~348–672** | | | +| **Ready for real work** | 100 | **~47%** | **~345–666** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | | 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | | 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h |