Merge pull request #1546 from Carbocode/feat/group-auto-layout-button

M2: add auto layout from groups in the sidebar and convert them in place
This commit is contained in:
BFlat
2026-10-30 00:12:35 +07:00
committed by GitHub
6 changed files with 252 additions and 17 deletions
+3
View File
@@ -95,3 +95,6 @@ sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
+46 -14
View File
@@ -587,11 +587,14 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
/// Infer direction, gap and padding from visible, non-absolute children, matching the layout flow.
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> =
f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
let boxes: Vec<Rect> = figcraft_layout::flow_children(doc, frame)
.iter()
.filter_map(|c| doc.node(*c))
.map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()))
.collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
@@ -608,12 +611,43 @@ fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
/// Sort unmasked spans by position along the layout axis. Masks stay at their original indices;
/// each visible mask's following content keeps its paint order until the next mask, hidden or not.
fn sort_layout_children(doc: &Document, kids: &mut [NodeId], vertical: bool) {
let mut start = 0;
let mut masked = false;
for end in 0..=kids.len() {
let mask = kids.get(end).and_then(|id| doc.node(*id)).filter(|n| n.is_mask);
if end != kids.len() && mask.is_none() {
continue;
}
if !masked && let Some(span) = kids.get_mut(start..end) {
span.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
}
start = end + 1;
masked = mask.is_some_and(|n| n.visible);
}
}
/// Add inferred auto layout to existing frames or one group converted in place; wrap other
/// selections. Preserve mask boundaries and a converted group's Fill sizing in its parent.
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
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.
@@ -634,23 +668,21 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
sort_layout_children(doc, &mut kids, al.mode == LayoutMode::Vertical);
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
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;
n.child_layout.sizing_v = Sizing::Hug;
if !single_group || n.child_layout.sizing_h != Sizing::Fill {
n.child_layout.sizing_h = Sizing::Hug;
}
if !single_group || n.child_layout.sizing_v != Sizing::Fill {
n.child_layout.sizing_v = Sizing::Hug;
}
}
}
s.set_selection(targets.clone());
@@ -29,6 +29,155 @@ 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 adding_auto_layout_preserves_mask_runs_and_their_rendering() {
let mut s = Session::new();
let page = s.page().unwrap();
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 0.0, 0.0));
let (mask, content) = (rect(&mut s, page, 100.0, 0.0), rect(&mut s, page, 80.0, 0.0));
let (mask2, content2) = (rect(&mut s, page, 200.0, 0.0), rect(&mut s, page, 180.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [right, left, mask, content, mask2, content2]})));
ex(&mut s, "node.set", json!({"ids": [mask, mask2], "props": {"isMask": true}}));
// Absolute layers keep the overlaps, isolating paint order from normal flow placement.
ex(&mut s, "node.set", json!({"ids": [mask, content, mask2, content2], "props": {"absolute": true}}));
let before = figcraft_render::render_node(s.doc().unwrap(), group, 1.0).unwrap();
ex(&mut s, "layout.add", json!({"ids": [group]}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![left, right, mask, content, mask2, content2], "sort the unmasked prefix, retain mask runs");
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![left], vec![right], vec![mask, content], vec![mask2, content2]]);
let after = figcraft_render::render_node(doc, group, 1.0).unwrap();
assert_eq!((after.width(), after.height(), after.data()), (before.width(), before.height(), before.data()));
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(group).unwrap().children, vec![right, left, mask, content, mask2, content2]);
}
#[test]
fn a_hidden_mask_keeps_its_boundary_but_unmasked_children_still_sort() {
let mut s = Session::new();
let page = s.page().unwrap();
let mask = rect(&mut s, page, 100.0, 0.0);
let content = rect(&mut s, page, 80.0, 0.0);
let hidden_mask = rect(&mut s, page, 200.0, 0.0);
let (right, left) = (rect(&mut s, page, 300.0, 0.0), rect(&mut s, page, 250.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [mask, content, hidden_mask, right, left]})));
ex(&mut s, "node.set", json!({"ids": [mask, hidden_mask], "props": {"isMask": true}}));
ex(&mut s, "node.set", json!({"id": hidden_mask, "props": {"visible": false}}));
ex(&mut s, "layout.add", json!({"ids": [group], "direction": "horizontal"}));
let doc = s.doc().unwrap();
let frame = doc.node(group).unwrap();
assert_eq!(frame.children, vec![mask, content, hidden_mask, left, right]);
let runs: Vec<Vec<NodeId>> = doc.paint_runs(frame).iter().map(|r| frame.children[r.clone()].to_vec()).collect();
assert_eq!(runs, vec![vec![mask, content], vec![hidden_mask], vec![left], vec![right]]);
}
#[test]
fn adding_auto_layout_infers_gaps_from_flow_children_only() {
for props in [json!({"visible": false}), json!({"absolute": true})] {
let mut s = Session::new();
let page = s.page().unwrap();
let a = rect(&mut s, page, 0.0, 0.0);
let excluded = rect(&mut s, page, 80.0, 0.0);
let b = rect(&mut s, page, 160.0, 0.0);
let group = id(ex(&mut s, "object.group", json!({"ids": [a, excluded, b]})));
ex(&mut s, "node.set", json!({"id": excluded, "props": props}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let layout = s.doc().unwrap().node(group).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.gap, 120.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
}
#[test]
fn hidden_children_do_not_influence_inferred_direction_or_padding() {
let mut s = Session::new();
let frame = id(ex(&mut s, "frame.create", json!({"width": 300, "height": 200})));
let (a, b) = (rect(&mut s, frame, 10.0, 20.0), rect(&mut s, frame, 90.0, 20.0));
let hidden = rect(&mut s, frame, 0.0, 1000.0);
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
let before = [page_box(&s, a), page_box(&s, b)];
ex(&mut s, "layout.add", json!({"ids": [frame]}));
let layout = s.doc().unwrap().node(frame).unwrap().layout.as_ref().unwrap();
assert_eq!(layout.mode, figcraft_doc::LayoutMode::Horizontal);
assert_eq!(layout.padding, [20.0, 170.0, 160.0, 10.0]);
assert_eq!(layout.gap, 40.0);
assert_eq!([page_box(&s, a), page_box(&s, b)], before);
}
#[test]
fn converting_a_group_preserves_fill_on_each_axis_in_its_parent() {
for (h, v) in [("fill", "fixed"), ("fixed", "fill"), ("fill", "fill")] {
let mut s = Session::new();
let parent = id(ex(&mut s, "frame.create", json!({"width": 500, "height": 300})));
let (a, b) = (rect(&mut s, parent, 0.0, 0.0), rect(&mut s, parent, 80.0, 0.0));
let group = id(ex(&mut s, "object.group", json!({"ids": [a, b]})));
let sibling = rect(&mut s, parent, 200.0, 0.0);
ex(&mut s, "node.set", json!({"id": parent, "props": {"layout": "horizontal", "padding": 0, "gap": 10}}));
ex(&mut s, "node.set", json!({"id": group, "props": {"sizingH": h, "sizingV": v}}));
let before = [page_box(&s, group), page_box(&s, sibling)];
ex(&mut s, "layout.add", json!({"ids": [group]}));
let cl = &s.doc().unwrap().node(group).unwrap().child_layout;
assert_eq!(cl.sizing_h, if h == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!(cl.sizing_v, if v == "fill" { Sizing::Fill } else { Sizing::Hug });
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
s.undo().unwrap();
assert!(matches!(kind(&s, group), NodeKind::Group));
assert_eq!([page_box(&s, group), page_box(&s, sibling)], before);
}
}
#[test]
fn a_frame_becomes_a_group_around_its_layers() {
let mut s = Session::new();
+27
View File
@@ -148,6 +148,33 @@ fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
#[test]
fn layout_add_button_requires_every_selected_container_to_have_no_layout() {
let mut s = Session::new();
let rect = id(s.execute("shape.rectangle", &json!({"width": 40, "height": 20})).unwrap());
let group = id(s.execute("object.group", &json!({"ids": [rect]})).unwrap());
let frame = id(s.execute("frame.create", &json!({"x": 200, "width": 100, "height": 60})).unwrap());
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
for (selection, layout, expected) in [
(vec![group], false, true),
(vec![frame, group], false, true),
(vec![frame, group], true, false),
(vec![frame], true, false),
(vec![], true, false),
] {
if layout {
u.app.session.execute("node.set", &json!({"id": frame, "props": {"layout": "horizontal"}})).unwrap();
}
u.app.session.execute("select.set", &json!({"ids": selection})).unwrap();
u.frame(vec![]);
let output = u.frame_output(vec![]);
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
let has_add = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Add auto layout"));
assert_eq!(has_add, expected, "selection: {selection:?}, frame has layout: {layout}");
}
}
/// Rectangles A (10, 20, 40×30) and B (100, 50, 20×20); A is selected.
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
+5 -1
View File
@@ -23,9 +23,13 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p))
}
/// Show selection sizing and layout controls; offer Add only when every selected container has
/// no auto layout and the selection is editable outside an instance.
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 no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none()));
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 +39,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 && no_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"))]
+22 -2
View File
@@ -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, with mask order, flow-only inference and Fill sizing preserved; 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,24 @@ 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 visible, non-absolute children, matching the layout engine’s
flow. Hidden and absolute children therefore do not distort direction, gap or padding. A converted
group keeps Fill sizing on either axis in its auto layout parent. Sorting by position preserves
mask boundaries and the paint order within masked runs. The sidebar offers Add only when every
selected container has no auto layout, including mixed selections.
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 +87,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 +95,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged |
| 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. |