mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
M2: a child resized to the full available space of its auto layout frame becomes Fill container (#1859)
* M2: a child resized to the full available space of its auto layout frame becomes Fill container Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Auto layout: a child becomes Fill for the full space only across the flow or alone along it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Auto layout: the full available space is what layout leaves (padding and an included inside stroke), judged after a new path's size Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * M2: a rotated child resized to the frame's room stays Fixed (review) CodeRabbit on #1859: a rotated layer's local size isn't the room layout gives it, so Fill would squash its geometry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
9f09b7163c
commit
f9d507f7b0
@@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() {
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_resized_to_the_full_available_space_becomes_fill() {
|
||||
// Documented: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
// they are manually resized to the full available space of the parent frame."
|
||||
let mut s = Session::new();
|
||||
let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed
|
||||
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
|
||||
// Along the flow, alone in the frame: its width less the padding (200 - 20).
|
||||
s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, a), (180.0, 20.0));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit");
|
||||
// Across the flow, beside a sibling too: the frame's height less its padding.
|
||||
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
|
||||
set(&mut s, b, json!({"height": 80}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill));
|
||||
assert_eq!(size(&s, b), (50.0, 80.0));
|
||||
// Short of it: fixed, as before.
|
||||
set(&mut s, b, json!({"height": 70}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// Along the flow beside a sibling, what is left is not "the full available space": a width
|
||||
// that happens to match it (180 - 10 - 50) stays fixed.
|
||||
s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap();
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// A hugging frame has no full space to offer: the child stays fixed.
|
||||
let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10}));
|
||||
let c = rect(&mut s, g, 50.0, 20.0, "#00FF00");
|
||||
set(&mut s, c, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed));
|
||||
// An inside stroke the layout includes takes room like the padding: the full space is what
|
||||
// layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone.
|
||||
let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"}));
|
||||
let d = rect(&mut s, h, 50.0, 20.0, "#FF0000");
|
||||
set(&mut s, d, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves");
|
||||
assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed");
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, d), (172.0, 20.0));
|
||||
// Excluded from the layout, the stroke takes no room: the frame less its padding again.
|
||||
set(&mut s, h, json!({"strokesIncluded": false}));
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hug_and_fill_only_where_they_apply() {
|
||||
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
|
||||
@@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// The axis is judged by the size the path is given, not the one before it: the frame's full
|
||||
// height (100) given with a path fills; a smaller height given with the next path is fixed,
|
||||
// though the layer filled the frame until then.
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fill);
|
||||
assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// Rotated, the layer's local size isn't the room layout gives it: a height matching the
|
||||
// frame's stays fixed rather than have Fill squash the path.
|
||||
set(&mut s, v, json!({"rotation": 90}));
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
|
||||
@@ -569,12 +569,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
let after = n.transform * Point::new(w * ax, h * ay);
|
||||
n.transform = Affine::translate(before - after) * n.transform;
|
||||
if num(p, "width").is_some() {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
if num(p, "height").is_some() {
|
||||
t.fix_size(num(p, "width").is_some(), true);
|
||||
@@ -582,6 +576,13 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
}
|
||||
// A side given is fixed, or fills its auto layout parent when it is the full space there.
|
||||
if num(p, "width").is_some() {
|
||||
super::props::fix_axis(doc, i, true)?;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
super::props::fix_axis(doc, i, false)?;
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
|
||||
@@ -437,17 +437,55 @@ fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()>
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
/// into W or H; sized to the full space its auto layout parent has for it on that axis, the layer
|
||||
/// fills instead: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
/// they are manually resized to the full available space of the parent frame" (Guide to auto
|
||||
/// layout).
|
||||
pub(crate) fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
let sizing = if fills_available_space(doc, id, is_w) { Sizing::Fill } else { Sizing::Fixed };
|
||||
let n = doc.get_mut(id)?;
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_h = sizing;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = sizing;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `id`'s width (`is_w`) or height is the full space its auto layout parent has for it on
|
||||
/// that axis: the frame's size less its padding and an inside stroke included in the layout (the
|
||||
/// insets layout itself takes). Across the flow that is every child's room;
|
||||
/// along the flow only a lone child has it (beside siblings what is left depends on the gaps, the
|
||||
/// alignment and overflow, so a size that happens to match it is not "the full available
|
||||
/// space"). Never in a hugging or wrapping frame or a grid, or for a layer that ignores the layout.
|
||||
fn fills_available_space(doc: &Document, id: NodeId, is_w: bool) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
let Some(parent) = n.parent.and_then(|p| doc.node(p)) else { return false };
|
||||
let Some(l) = &parent.layout else { return false };
|
||||
if n.child_layout.absolute || l.wrap || l.mode == LayoutMode::Grid {
|
||||
return false;
|
||||
}
|
||||
// A rotated layer's local size isn't the size layout gives it room for (its rotated bounds
|
||||
// are): it stays Fixed rather than have Fill squash its geometry.
|
||||
let [_, b, c, ..] = n.transform.as_coeffs();
|
||||
if b.abs() > 1e-9 || c.abs() > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let hugs = if is_w { parent.child_layout.sizing_h } else { parent.child_layout.sizing_v } == Sizing::Hug;
|
||||
if hugs {
|
||||
return false;
|
||||
}
|
||||
if (l.mode == LayoutMode::Horizontal) == is_w
|
||||
&& parent.children.iter().filter(|c| **c != id).filter_map(|c| doc.node(*c)).any(|c| c.visible && !c.child_layout.absolute)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (pt, pr, pb, pl) = figcraft_layout::insets(parent, l);
|
||||
let available = if is_w { parent.width - pl - pr } else { parent.height - pt - pb };
|
||||
let size = if is_w { n.width } else { n.height };
|
||||
available > 0.0 && (size - available).abs() < 0.01
|
||||
}
|
||||
|
||||
/// The `geometry` properties of `apply`; `false` when `k` is not one of them.
|
||||
fn apply_geometry(
|
||||
cmd: &str,
|
||||
@@ -459,10 +497,17 @@ fn apply_geometry(
|
||||
path_offset: &mut (f64, f64),
|
||||
) -> Result<bool> {
|
||||
match k {
|
||||
"path" => *path_offset = set_path(cmd, doc, id, v, props)?,
|
||||
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
|
||||
// its auto layout axis.
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
|
||||
"path" => {
|
||||
*path_offset = set_path(cmd, doc, id, v, props)?;
|
||||
// A size given beside the path is its scale (`set_path`), and still fixes or fills
|
||||
// its auto layout axis, judged once the path has given the layer that size.
|
||||
for (long, short, is_w) in [("width", "w", true), ("height", "h", false)] {
|
||||
if props.contains_key(long) || props.contains_key(short) {
|
||||
fix_axis(doc, id, is_w)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
|
||||
|
||||
@@ -216,8 +216,9 @@ fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool {
|
||||
|
||||
/// 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) {
|
||||
/// never take up space. The frame's size less these is its children's room, the "full available
|
||||
/// space" a child resized to it fills.
|
||||
pub fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
|
||||
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));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Auto layout and constraints parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** patch (fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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%,
|
||||
@@ -40,6 +40,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
| Fill: children share the space left after padding, gaps and fixed siblings; across the flow they stretch | pass | `sizing_rules` |
|
||||
| Fill children share by their content area (border-box): a child with more padding or a thicker included inside stroke takes a little more, so the content areas come out the same | **fixed** | "Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will take up slightly more of the available width or height, so that its inner content area matches its sibling's" (Use auto layout with CSS Flexbox in mind); the split went by outer size before. Min and max still clamp the outer size; a child's own insets are its padding and its included inside stroke (`nested_tests::fill_children_share_by_content_area`; #1824) |
|
||||
| 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` |
|
||||
| A child resized to the full available space becomes Fill container on that axis | **fixed** | "Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame": a width or height given by a resize, the W/H fields or a canvas handle that equals the frame's size less its padding sets Fill there: across the flow for any child, along the flow for a lone child (beside siblings what is left depends on the gaps, the alignment and overflow, so it is left alone); not in a hugging or wrapping frame or a grid. It stayed Fixed before (`al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill`) |
|
||||
| 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` |
|
||||
| Double-click an edge of an auto layout frame's box: Hug contents on that axis; Alt+double-click an edge of a child's box: Fill container | **fixed** | "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents."; "Hold ⌥ Option or Alt and double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Fill container." A vertical edge sizes the width, a horizontal one the height; any other layer keeps the double-click's old meaning (#1838). `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills` |
|
||||
| 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) |
|
||||
@@ -101,6 +102,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
|---|---|---|
|
||||
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
|
||||
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` |
|
||||
| 2026-11-01 | patch | M2: a text layer keeps a max height or a number of max lines, not both ("Text layers cannot have both a max height and a set number of max lines", Guide to auto layout): `node.set {maxHeight}` on a text sets its max lines to Auto, and `node.set {maxLines}` or `text.maxLines` with a number removes its max height. Found by a documentation audit of this page. Test: `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both`. Numbers unchanged |
|
||||
| 2026-11-01 | minor | M2: Remove all auto layout ("To remove auto layout from a frame and all of its nested objects at once, right-click on the frame and go to More layout options > Remove all auto layout", Toggle on auto layout in designs): `layout.removeAll {ids?}` strips the frames and every frame nested in them in one undo step, leaving instances and their layers alone; in the canvas context menu beside Add auto layout and in the Actions palette. Found by a documentation audit of this page. Test: `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user