From ec96cae46aef61cfe23ba4e14c9170cec2a71352 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Thu, 29 Oct 2026 14:57:08 +0100 Subject: [PATCH] M2: add auto layout from groups and convert them in place --- ROADMAP.md | 3 +- crates/engine/src/cmd/structure.rs | 11 ++++- crates/engine/src/convert_container_tests.rs | 50 ++++++++++++++++++++ crates/ui-egui/src/panels/layout_panel.rs | 3 +- docs/auto-layout-parity.md | 19 +++++++- 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cf7c7cb6..e9c2e032 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: groups expose Add auto layout and convert to frames in place; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2: the Design sidebar’s Layout header shows Add auto layout (+) for groups as well as frames. `layout.add` converts a single selected group into a frame in place, retaining its id, name and direct children, so auto layout arranges those children instead of treating the whole group as one fixed item inside a new wrapper. This matches the Figma workflow reported by the contributor and the sidebar workflow familiar to users of similar design tools. Sidebar, context menu, shortcut and programmatic callers share the fix; conversion and layout undo together. Regression tests in `convert_container_tests`. Numbers unchanged | | 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 6b203c59..ab7f6544 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -613,7 +613,14 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { let dir = string(p, "direction"); let doc = s.doc()?; let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); - let targets = if all_frames { + let single_group = ids.len() == 1 && ids.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Group)); + let in_place = all_frames || single_group; + let targets = if in_place { + if single_group { + // Layout must arrange the group's children directly. Wrapping the group would + // leave them in a single fixed-size layout item and add an unnecessary layer. + convert(s, &json!({"ids": ids}), "layout.add", true)?; + } ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. @@ -646,7 +653,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(*f)?; n.children = kids; n.layout = Some(al); - if !all_frames || !empty { + if !in_place || !empty { // A frame wrapped around the selection hugs it; an existing frame with no flow // children keeps its size instead of collapsing to its padding. n.child_layout.sizing_h = Sizing::Hug; diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index be338a82..5bc55638 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -29,6 +29,56 @@ fn kind(s: &Session, n: NodeId) -> NodeKind { s.doc().unwrap().node(n).unwrap().kind.clone() } +#[test] +fn adding_auto_layout_converts_a_single_group_and_arranges_its_children_directly() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let group = id(ex(&mut s, "object.group", json!({"ids": [a, b], "name": "Actions"}))); + let siblings = s.doc().unwrap().node(page).unwrap().children.clone(); + let before = [page_box(&s, group), page_box(&s, a), page_box(&s, b)]; + + let result = ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"})); + assert_eq!(result["ids"], json!([group]), "the group's identity is retained"); + let doc = s.doc().unwrap(); + let frame = doc.node(group).unwrap(); + assert!(matches!(frame.kind, NodeKind::Frame)); + assert_eq!(frame.name, "Actions"); + assert_eq!(frame.parent, Some(page)); + assert_eq!(frame.children, vec![a, b], "no intermediate group or wrapper frame"); + assert!(frame.layout.is_some() && frame.fills.is_empty() && !frame.clip); + assert_eq!(doc.node(page).unwrap().children, siblings); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + + // The second child now follows changes to the first through auto layout. + ex(&mut s, "node.set", json!({"id": a, "props": {"width": 60}})); + assert_eq!(page_box(&s, b), [110.0, 20.0, 150.0, 40.0]); + s.undo().unwrap(); + s.undo().unwrap(); + let restored = s.doc().unwrap().node(group).unwrap(); + assert!(matches!(restored.kind, NodeKind::Group) && restored.layout.is_none()); + assert_eq!(restored.children, vec![a, b]); + assert_eq!([page_box(&s, group), page_box(&s, a), page_box(&s, b)], before); + s.redo().unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Frame)); + assert!(s.doc().unwrap().node(group).unwrap().layout.is_some()); +} + +#[test] +fn adding_auto_layout_to_multiple_groups_still_wraps_the_selection() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let (a, b) = (rect(&mut s, page, 10.0, 20.0), rect(&mut s, page, 90.0, 20.0)); + let g1 = id(ex(&mut s, "object.group", json!({"ids": [a]}))); + let g2 = id(ex(&mut s, "object.group", json!({"ids": [b]}))); + let result = ex(&mut s, "layout.add", json!({"ids": [g1, g2]})); + let frame = crate::params::id_of(&result["ids"][0]).unwrap(); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(frame).unwrap().kind, NodeKind::Frame)); + assert_eq!(doc.node(frame).unwrap().children, vec![g1, g2]); + assert!(matches!(kind(&s, g1), NodeKind::Group) && matches!(kind(&s, g2), NodeKind::Group)); +} + #[test] fn a_frame_becomes_a_group_around_its_layers() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs index 34516477..c2938539 100644 --- a/crates/ui-egui/src/panels/layout_panel.rs +++ b/crates/ui-egui/src/panels/layout_panel.rs @@ -26,6 +26,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> { pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); + let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group))); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); let title = if has_al { tr("Auto layout") } else { tr("Layout") }; @@ -35,7 +36,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i)); let btns: Vec<(Icon, &str)> = if in_instance { vec![] - } else if frames && !has_al { + } else if layout_containers && !has_al { vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f..5b5d85a1 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; 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) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; 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 ~98%, @@ -9,6 +9,20 @@ 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. +## Adding auto layout to a group + +With one group selected, Add auto layout converts that group into a frame in place, retaining its +id, name, parent and direct children. The Layout sidebar’s +, context menu, Shift+A and API calls +all dispatch `layout.add`, so they share the conversion and a single undo step restores the group. +The layout settings are inferred from the children, as for an existing frame. + +This follows the Figma behavior reported by the contributor and the sidebar workflow users know from +similar design tools: start with a group and make its children participate in auto layout. +Wrapping the group would instead make the entire group one layout item, leaving its children +outside the new frame’s layout flow and adding an unnecessary level to the layer tree. Converting +the selected container makes later changes to a child’s size reflow its siblings directly. +Selecting several groups still wraps that selection in a shared auto layout frame. + ## Rules from the documentation Status: **pass** (a case in the suite holds), **fixed** (the suite found a divergence, now fixed), @@ -69,7 +83,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | 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 | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | +| Suggest auto layout / "Add auto layout" on a selection | done | the Layout sidebar exposes Add auto layout (+) for groups and frames using the same `layout.add` command as the context menu; a single group becomes a frame in place, keeping its id, name and direct children; adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) | | Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) | | Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) | @@ -77,6 +91,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |