Merge main into #1855

This commit is contained in:
bflatastic
2026-11-07 09:51:12 +07:00
12 changed files with 272 additions and 31 deletions
+11 -7
View File
@@ -767,16 +767,20 @@ impl Document {
/// children only within their mask). A group, or an unclipped frame past its box, is what its
/// children show grown by its effects, which apply to all of it.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
// A tree visits each node once; a child list deserialized with repeats (a graph) is cut off
// after twice the document's node count rather than walked in full.
self.render_bounds_at(id, 0, &std::cell::Cell::new(self.nodes.len().saturating_mul(2).saturating_add(8)))
}
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
let n = self.node(id)?;
let own = n.own_paint_bounds(self.world_transform(id));
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
let visit = left.get();
left.set(visit.saturating_sub(1));
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH || visit == 0 {
return Some(own);
}
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
Some(match (self.children_render_bounds(n, depth + 1, left), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
// An unclipped frame's effects apply to its children too, past its box included.
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
@@ -785,15 +789,15 @@ impl Document {
}
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
let mut out: Option<Rect> = None;
for run in self.paint_runs(n) {
let ids = n.children.get(run).unwrap_or(&[]);
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth, left)).reduce(|a, b| a.union(b));
let r = match mask {
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
Some(m) => r.zip(self.render_bounds_at(m, depth, left)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
None => r,
};
out = match (out, r) {
+16
View File
@@ -596,6 +596,22 @@ fn is_ancestor_holds_for_very_deep_chains() {
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn render_bounds_of_a_doubled_child_list_end_at_once() {
// A file can list a child twice at every level: 2^40 visits if each is walked.
let mut d = Document::new("x");
let mut parent = page(&d);
let mut groups = Vec::new();
for _ in 0..40 {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
groups.push(parent);
}
for w in groups.windows(2) {
d.node_mut(w[0]).unwrap().children.push(w[1]);
}
assert!(d.world_render_bounds(groups[0]).is_some());
}
#[test]
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
+24
View File
@@ -215,6 +215,30 @@ mod tests {
assert_eq!(x(&s, small).0, x(&s, big).0);
}
/// "for baseline alignment, the bottoms of the icon and the word home are aligned on the red
/// line" (Use the horizontal and vertical flows in auto layout, the figure's caption; #1825): a
/// layer without text sits on the text's baseline with its bottom edge, not at the top.
#[test]
fn baseline_alignment_puts_an_icons_bottom_on_the_text_baseline() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal", "gap": 8}})));
let icon = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "parent": f})));
let home = id(&ex(&mut s, "text.create", json!({"text": "Home", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
let base = figcraft_text::layout_node(&node(&s, home)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
// Only meaningful when the text was measured (fonts available) and its baseline is below
// the icon's height.
if let Some(b) = base
&& b > 16.0
{
let bottom = x(&s, icon).1 + 16.0;
assert!((bottom - (x(&s, home).1 + b)).abs() < 1e-6, "icon bottom {bottom}, text baseline {}", x(&s, home).1 + b);
}
// Top alignment puts it back at the top.
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "min"}}));
assert_eq!(x(&s, icon).1, x(&s, home).1);
}
#[test]
fn grid_tracks_cells_and_undo() {
let mut s = Session::new();
+38 -6
View File
@@ -20,6 +20,9 @@ const MAX_VARIABLES: usize = 100_000;
const MAX_MODES: usize = 1_000;
/// Longest string variable value kept (characters).
const MAX_STRING: usize = 10_000;
/// The copies of fallback values that fill missing modes, in bytes, for one import: variables, modes
/// and string length are each capped, but not their product. Past it, modes stay empty.
const MAX_FILLED_BYTES: usize = 64 << 20;
/// The object holding `variableCollections` and `variables`: a local variables response's `meta`,
/// or `v` itself.
@@ -287,14 +290,10 @@ impl Ctx<'_> {
if dropped {
self.warn("variable aliases to variables that aren't in the variables JSON (or of another type) were skipped");
}
let mut budget = MAX_FILLED_BYTES;
for v in found.iter_mut() {
let Some(modes) = self.var_modes.get(&v.collection) else { continue };
let fallback = modes.first().and_then(|m| v.values.get(m)).or_else(|| v.values.values().next()).cloned();
if let Some(f) = fallback {
for m in modes {
v.values.entry(m.clone()).or_insert_with(|| f.clone());
}
}
fill_modes(&mut v.values, modes, &mut budget);
}
}
@@ -521,3 +520,36 @@ fn field_type(field: &str) -> VariableType {
_ => VariableType::Number,
}
}
/// Give each of `modes` without a value a copy of the first mode's (else any) value, while the
/// copies' bytes fit in `budget`.
fn fill_modes(values: &mut BTreeMap<String, VariableValue>, modes: &[String], budget: &mut usize) {
let Some(f) = modes.first().and_then(|m| values.get(m)).or_else(|| values.values().next()).cloned() else { return };
let cost =
64 + modes.iter().map(String::len).max().unwrap_or(0) + if let VariableValue::String(t) | VariableValue::Alias(t) = &f { t.len() } else { 0 };
for m in modes {
if values.contains_key(m) {
continue;
}
let Some(left) = budget.checked_sub(cost) else { return };
*budget = left;
values.insert(m.clone(), f.clone());
}
}
#[cfg(test)]
mod fill_tests {
use super::*;
#[test]
fn missing_modes_are_filled_only_within_the_budget() {
let modes: Vec<String> = (0..10).map(|i| format!("m{i}")).collect();
let mut v = BTreeMap::from([("m0".to_string(), VariableValue::String("x".repeat(100)))]);
let mut budget = 1_000;
fill_modes(&mut v, &modes, &mut budget);
assert!(v.len() > 1 && v.len() < 10, "{}", v.len());
let mut v = BTreeMap::from([("m0".to_string(), VariableValue::Number(1.0))]);
fill_modes(&mut v, &modes, &mut { MAX_FILLED_BYTES });
assert_eq!(v.len(), 10);
}
}
+23 -3
View File
@@ -535,12 +535,32 @@ fn alignment_rules() {
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",
// "for baseline alignment, the bottoms of the icon and the word home are aligned on
// the red line" (the figure's caption): a layer without text sits on the baseline with
// its bottom edge. Baseline 20, icon 10 high: the icon moves down 10 (#1825).
rule: "baseline alignment: a layer without text puts its bottom on the baseline",
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)],
rects: &[(0.0, 10.0, 10.0, 10.0), (10.0, 0.0, 60.0, 28.0)],
},
Case {
// The icon (30 high) reaches below the text's baseline (20): the line's baseline is the
// icon's bottom, so the text moves down 10 and the frame hugs 10 + 28.
rule: "baseline alignment: a layer taller than the baseline moves the text down",
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
kids: &[kid(10.0, 30.0), Kid { text: Some((60.0, 28.0, 20.0)), sizing: (Hug, Hug), ..KID }],
want: (70.0, 38.0),
rects: &[(0.0, 0.0, 10.0, 30.0), (10.0, 10.0, 60.0, 28.0)],
},
Case {
// Docs silent on a line with no text at all: every layer's baseline is its bottom
// edge (as the icon's), so the bottoms line up, as they do under CSS baseline alignment.
rule: "baseline alignment without text: the layers' bottoms line up",
frame: Frame { sizing: (Hug, Hug), cross: CrossAlign::Baseline, ..FRAME },
kids: &[kid(10.0, 10.0), kid(10.0, 30.0)],
want: (20.0, 30.0),
rects: &[(0.0, 20.0, 10.0, 10.0), (10.0, 0.0, 10.0, 30.0)],
},
]);
}
+6 -2
View File
@@ -332,7 +332,8 @@ struct Item {
/// inside stroke): fill children share the free space by their content area, border-box
/// fashion, so these come on top of each one's share.
inset_main: f64,
/// Baseline alignment: distance from the item's top to its first baseline.
/// Baseline alignment: distance from the item's top to its first text baseline, or to its
/// bottom edge when it has no text (an icon next to a text sits on the line with its bottom).
baseline: Option<f64>,
/// Baseline alignment: offset from the line's top (computed per line).
base_off: f64,
@@ -555,8 +556,11 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
it.cross = if horizontal { h } else { w };
}
if baseline {
// A layer without a text baseline (an icon, a shape, a frame holding no text) sits on the
// line with its bottom edge: "the bottoms of the icon and the word home are aligned on the
// red line" (Use the horizontal and vertical flows in auto layout; #1825).
for it in items.iter_mut().filter(|it| !it.fill_cross) {
it.baseline = item_baseline(doc, it.id, m, 0);
it.baseline = item_baseline(doc, it.id, m, 0).or(Some(it.cross));
}
}
+3 -2
View File
@@ -302,10 +302,11 @@ fn baseline_alignment_horizontal() {
let rect = d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 20.0).unwrap();
let m = FakeMeasure([(small, (30.0, 14.0, 10.0)), (big, (60.0, 40.0, 30.0))].into_iter().collect());
layout_frame(&mut d, f, &m);
// Baselines line up at 5 + 30; the rectangle (no baseline) sits at the top.
// Baselines line up at 5 + 30; the rectangle (no text baseline) sits on it with its bottom
// edge: 35 - 20 (#1825).
assert_eq!(pos(&d, small).1, 25.0);
assert_eq!(pos(&d, big).1, 5.0);
assert_eq!(pos(&d, rect).1, 5.0);
assert_eq!(pos(&d, rect).1, 15.0);
// Hug height covers the shifted small text: max(20 + 14, 40) + padding.
assert_eq!(d.node(f).unwrap().height, 50.0);
// Nested auto layout frames align on their first child's baseline.
@@ -51,6 +51,115 @@ fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
v
}
#[test]
fn selected_hierarchies_have_continuous_rounded_backgrounds() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Selected frame"})).unwrap());
let child = rect(&mut s, "child", 0.0, Some(frame));
let group = id(s.execute("object.group", &json!({"ids": [child]})).unwrap());
rect(&mut s, "sibling", 30.0, Some(frame));
let other = id(s.execute("frame.create", &json!({"name": "Other frame"})).unwrap());
rect(&mut s, "other child", 0.0, Some(other));
s.execute("select.set", &json!({"ids": [frame, group, other]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.expanded.extend([frame, group, other]);
let selected_rows = [frame, group, other].map(|id| u.row_at(id));
let out = u.frame_output(vec![Event::PointerGone]);
let t = crate::theme::Tokens::get(&u.ctx);
for pos in selected_rows {
let rect_at = |fill| {
out.shapes.iter().enumerate().find_map(|(i, s)| match &s.shape {
egui::Shape::Rect(r) if r.fill == fill && r.rect.contains(pos) => Some(i),
_ => None,
})
};
let background = rect_at(t.selected_row_child).expect("the selected row belongs to the hierarchy background");
let highlight = rect_at(t.selected_row).expect("each selected frame or group has its own highlight");
assert!(highlight > background, "the selected highlight is painted above the hierarchy background");
}
let backgrounds: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
_ => None,
})
.collect();
// Other frame + child, then selected frame + sibling + selected group + child.
assert_eq!(backgrounds.len(), 6);
for band in [&backgrounds[..2], &backgrounds[2..]] {
assert_eq!(band.first().unwrap().corner_radius.nw, 5);
assert_eq!(band.last().unwrap().corner_radius.sw, 5);
for pair in band.windows(2) {
assert_eq!(pair[0].rect.bottom(), pair[1].rect.top(), "the highlight fills the inter-row spacing");
assert_eq!(pair[0].corner_radius.sw, 0, "no rounded seams inside the hierarchy");
assert_eq!(pair[1].corner_radius.nw, 0, "a selected nested group stays in the same block");
}
}
assert!(backgrounds[1].rect.bottom() < backgrounds[2].rect.top(), "separate selected hierarchies stay separate");
// A collapsed frame and a filtered leaf each have all four corners rounded.
u.app.ui.expanded.clear();
let out = u.frame_output(vec![]);
let collapsed: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
_ => None,
})
.collect();
assert_eq!(collapsed.len(), 2);
assert!(collapsed.iter().all(|r| r.corner_radius == egui::CornerRadius::same(5)));
u.app.ui.layer_filter = "child".into();
let out = u.frame_output(vec![]);
assert!(out.shapes.iter().any(|s| matches!(&s.shape,
egui::Shape::Rect(r) if r.fill == t.selected_row_child && r.corner_radius.sw == 5
)));
}
#[test]
fn selected_hierarchy_keeps_square_seams_when_scrolled() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Long hierarchy"})).unwrap());
for i in 0..70 {
rect(&mut s, &format!("Child {i}"), 0.0, Some(frame));
}
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.expanded.insert(frame);
let fill = crate::theme::Tokens::get(&u.ctx).selected_row_child;
let corners = |out: &egui::FullOutput| {
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == fill && r.rect.intersects(s.clip_rect) => Some(r.corner_radius),
_ => None,
})
.collect::<Vec<_>>()
};
let wheel = |y| Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, y),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
};
u.frame(vec![Event::PointerMoved(egui::pos2(150.0, 500.0)), wheel(-700.0)]);
for _ in 0..30 {
u.frame(vec![]);
}
let middle = corners(&u.frame_output(vec![]));
assert!(!middle.is_empty());
assert!(middle.iter().all(|c| *c == egui::CornerRadius::ZERO), "viewport edges inside a subtree are square seams");
u.frame(vec![wheel(-10000.0)]);
for _ in 0..30 {
u.frame(vec![]);
}
let end = corners(&u.frame_output(vec![]));
assert_eq!(end.first().unwrap().nw, 0, "the hierarchy starts above the viewport");
assert_eq!(end.last().unwrap().sw, 5, "the hierarchy's real end stays rounded");
}
#[test]
fn clicking_rows_selects_by_the_documented_rules() {
let (mut u, [a, b, c, d]) = four();
+34 -6
View File
@@ -962,15 +962,34 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI
reveal
}
/// The outermost selected layer owning a row's continuous hierarchy highlight.
fn selection_root(doc: &Document, sel: &BTreeSet<NodeId>, id: NodeId) -> Option<NodeId> {
if sel.is_empty() {
return None;
}
doc.ancestors(id).into_iter().rev().find(|a| sel.contains(a)).or_else(|| sel.contains(&id).then_some(id))
}
fn hierarchy_bg(roots: &[Option<NodeId>], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> {
let root = roots.get(i).copied().flatten()?;
let joins = |neighbor: &Option<NodeId>| *neighbor == Some(root);
let above = i.checked_sub(1).and_then(|j| roots.get(j)).is_some_and(joins);
let below = roots.get(i + 1).is_some_and(joins);
if below {
rr.max.y += gap;
}
let top = if above { 0 } else { 5 };
let bottom = if below { 0 } else { 5 };
Some((rr, CornerRadius { nw: top, ne: top, sw: bottom, se: bottom }))
}
/// A row's background: selected, inside a selected layer, or outlined when hovered.
/// `state` is [selected, in_selected, component].
fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
let [selected, in_selected, is_comp] = state;
if selected {
ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
} else if in_selected {
ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
} else if hovered {
} else if !in_selected && hovered {
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
}
}
@@ -1071,15 +1090,20 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
drag_autoscroll(ui, dragging.is_some());
let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len());
let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len());
// Compute each visible row's root once, plus one neighbor outside each end
// so scrolling never rounds a seam in the middle of a selected subtree.
let selected_ids: BTreeSet<_> = sel.iter().copied().collect();
let roots_start = first.saturating_sub(1);
let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect();
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band));
// Stable widget ids for a row wherever the list is scrolled.
band_ui.skip_ahead_auto_ids(first);
let ui = &mut band_ui;
for row in rows.iter().take(last).skip(first) {
for (i, row) in rows.iter().enumerate().take(last).skip(first) {
let Some(n) = doc.node(row.id) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
let selected = sel.contains(&row.id);
let selected = selected_ids.contains(&row.id);
a11y::layer_row(&mut app.ui.nav, &resp);
if sel.last() == Some(&row.id) {
a11y::prefer(&mut app.ui.nav, Region::Layers, resp.id);
@@ -1093,9 +1117,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
locked: n.locked,
};
a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info);
let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a));
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let rr = r.shrink2(vec2(8.0, 0.0));
let bg = hierarchy_bg(&roots, i - roots_start, rr, pitch - ROW);
let in_selected = !selected && bg.is_some();
if let Some((rect, corners)) = bg {
ui.painter().rect_filled(rect, corners, t.selected_row_child);
}
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
let over = resp.contains_pointer();
+1 -1
View File
@@ -494,7 +494,7 @@ mod tests {
let theme = if t.dark { "dark" } else { "light" };
// Disabled text (a disabled button's label, placeholders) on panels, fields and
// selected rows, still dimmer than secondary text.
for bg in [t.panel, t.field, t.hover, t.selected_row] {
for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child] {
let ratio = c(t.text_tertiary).contrast(c(bg));
assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary);
assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text");
+3 -2
View File
@@ -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 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)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); 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%,
@@ -60,7 +60,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| 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 |
| Baseline alignment of a layer without text (an icon next to text) | **fixed** | its bottom sits on the baseline, as the figure's caption says ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line (#1825) |
| 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 as a block: packed at the start, centre or end the cross alignment names; stretched when every child fills across the flow; or the free space between them (one line or no free space: like packed) | **added** | `content_align_tests` (13 cases), `al_content_align_tests`; `contentAlign`, the Row/Column gap mode in the Design panel, REST and `.fig` both ways, `align-content` in CSS |
@@ -100,6 +100,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
| 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` |
+4 -2
View File
File diff suppressed because one or more lines are too long