mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
M2: auto layout behaviour suite and fixes (#130)
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
This commit is contained in:
+7
-6
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -985,6 +985,30 @@ impl Document {
|
||||
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64, result: geometry::BoolResult) -> Option<NodeId> {
|
||||
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<std::ops::Range<usize>> {
|
||||
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<NodeId, NodeId>) {
|
||||
|
||||
@@ -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<Item = NodeId> + '_ {
|
||||
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
|
||||
|
||||
+32
-4
@@ -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<GridTrack>,
|
||||
/// Grid mode: row track sizes (missing tracks are `1fr`).
|
||||
pub row_tracks: Vec<GridTrack>,
|
||||
/// 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<CellAlign>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub grid_align_v: Option<CellAlign>,
|
||||
}
|
||||
|
||||
impl ChildLayout {
|
||||
|
||||
@@ -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<NodeId> = (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<_>>(), 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<NodeId> = (0..3).map(|_| rect(&mut s, f, 20.0, 40.0, "#FF0000")).collect();
|
||||
assert_eq!(k.iter().map(|c| pos(&s, *c)).collect::<Vec<_>>(), 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<NodeId> = (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]);
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<bool> {
|
||||
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)))
|
||||
|
||||
@@ -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<String, Valu
|
||||
l.padding[2] = val;
|
||||
}
|
||||
"mainAlign" | "primaryAxisAlignItems" => {
|
||||
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<String, Valu
|
||||
cl.grid_row = val;
|
||||
}
|
||||
}
|
||||
"gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => {
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+21
-10
@@ -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<NodeId> = 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<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
/// `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<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = 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<Vec<(usize, figcraft_ge
|
||||
rows
|
||||
}
|
||||
|
||||
/// The slot in a wrapping row layout: the visual row under the pointer, then the position in it.
|
||||
fn wrap_slot(doc: &Document, kids: &[NodeId], p: Point) -> (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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -1179,6 +1179,8 @@ fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet<No
|
||||
#[cfg(test)]
|
||||
mod a11y_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod al_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod al_drag_tests;
|
||||
#[cfg(test)]
|
||||
mod al_keys_tests;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind};
|
||||
use figcraft_doc::geom::Affine;
|
||||
use figcraft_doc::{
|
||||
AutoLayout, Blur, BlurType, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass, GridAlign,
|
||||
GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps,
|
||||
Texture, VConstraint,
|
||||
AutoLayout, Blur, BlurType, CellAlign, ChildLayout, Constraints, CrossAlign, Effect, EffectKind, ExportFormat, ExportSetting, ExportSize, Glass,
|
||||
GridAlign, GridKind, HConstraint, LayoutGrid, LayoutMode, MainAlign, Noise, NoiseMode, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin,
|
||||
StrokeProps, Texture, VConstraint,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -399,6 +399,8 @@ pub(super) fn auto_layout(v: &Value) -> Option<AutoLayout> {
|
||||
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<LayoutMode>, 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f64>, Option<f64>),
|
||||
max: (Option<f64>, Option<f64>),
|
||||
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<f64>, Option<f64>),
|
||||
max: (Option<f64>, Option<f64>),
|
||||
text: Option<(f64, f64, f64)>,
|
||||
absolute: bool,
|
||||
at: (f64, f64),
|
||||
constraints: (HConstraint, VConstraint),
|
||||
cell: Option<(u32, u32)>,
|
||||
span: (u32, u32),
|
||||
align: (Option<CellAlign>, Option<CellAlign>),
|
||||
}
|
||||
|
||||
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<NodeId>, 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::<Vec<_>>(), 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<_>>(), 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));
|
||||
}
|
||||
+100
-70
@@ -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<usize>> = Vec::new();
|
||||
if wraps {
|
||||
let mut cur: Vec<usize> = 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<usize> = 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<f64>) -> Vec<f64> {
|
||||
let mut out: Vec<f64> = Vec::with_capacity(content.len());
|
||||
let mut total_fr = 0.0;
|
||||
let mut used = 0.0;
|
||||
for (i, c) in content.iter().enumerate() {
|
||||
let size = match track_spec(specs, i) {
|
||||
let mut out: Vec<f64> = (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<usize>, Vec<usize>) = (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<usize>, Vec<usize>) = 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::<f64>();
|
||||
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<f64> {
|
||||
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<f64> = (0..n).map(|i| if let (Sizing::Fixed, v) = track_spec(specs, i) { v } else { 0.0 }).collect();
|
||||
let span = |c: &GridCell| if columns { (c.column, c.column_span) } else { (c.row, c.row_span) };
|
||||
for (c, s) in cells.iter().zip(sizes) {
|
||||
@@ -764,8 +782,8 @@ pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option<GridGeometry> {
|
||||
columns: track_ranges(pl, &col_w, gx),
|
||||
rows: track_ranges(pt, &row_h, gy),
|
||||
cells,
|
||||
width: clamp_size(width, cl.min_w, cl.max_w),
|
||||
height: clamp_size(height, cl.min_h, cl.max_h),
|
||||
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<f64
|
||||
(xs, ys)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -263,6 +263,7 @@ fn padding_and_included_strokes() {
|
||||
n.stroke.weight = 5.0;
|
||||
}
|
||||
layout_mut(&mut d, f).padding = [1.0, 2.0, 3.0, 4.0];
|
||||
layout_mut(&mut d, f).strokes_included = false;
|
||||
let a = rect(&mut d, f, 50.0, 20.0);
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!(size(&d, f), (56.0, 24.0));
|
||||
|
||||
@@ -162,7 +162,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
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"],
|
||||
|
||||
+5
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-14
@@ -390,9 +390,8 @@ impl W<'_> {
|
||||
let _ = write!(self.body, "<g clip-path=\"url(#{cid})\">");
|
||||
}
|
||||
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 id=\"{mid}\" maskUnits=\"userSpaceOnUse\"{alpha}>{mask_body}</mask>");
|
||||
let _ = write!(self.body, "<g mask=\"url(#{mid})\">");
|
||||
}
|
||||
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("</g>");
|
||||
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("</g>");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -223,32 +223,38 @@ fn al_zones(app: &FigApp) -> Option<Zones> {
|
||||
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_geom::Rect> = figcraft_layout::flow_children(doc, id)
|
||||
.into_iter()
|
||||
.filter_map(|c| doc.node(c))
|
||||
.map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))
|
||||
.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<figcraft_geom::Rect>> = Vec::new();
|
||||
for b in boxes {
|
||||
match lines.last_mut() {
|
||||
Some(line) if vertical || !al.wrap || line.last().is_some_and(|a| b.x0 >= a.x1 - 1e-6) => line.push(b),
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+59
-12
@@ -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 |
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user