From ee6ca15ccfacd5cef78cc41c5ef9671dd4316a79 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Fri, 30 Oct 2026 11:12:57 +0100 Subject: [PATCH 1/5] M8: join and round layer hierarchy highlights --- crates/ui-egui/src/layers_behaviour_tests.rs | 109 +++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 32 +++++- crates/ui-egui/src/theme.rs | 2 +- docs/ui-parity.md | 5 +- 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 7cf0bf235..0c28ede87 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -51,6 +51,115 @@ fn sorted(mut v: Vec) -> Vec { 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::>() + }; + 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(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 164ec7e4a..244f95805 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -956,15 +956,33 @@ 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: &[NodeId], id: NodeId) -> Option { + doc.ancestors(id).into_iter().rev().find(|a| sel.contains(a)).or_else(|| sel.contains(&id).then_some(id)) +} + +fn hierarchy_bg(doc: &Document, sel: &[NodeId], rows: &[Row], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> { + let root = selection_root(doc, sel, rows.get(i)?.id)?; + // Look beyond the viewport too: scrolling must not round an edge in the + // middle of a selected subtree. Separate selected subtrees keep their corners. + let joins = |neighbor: &Row| selection_root(doc, sel, neighbor.id) == Some(root); + let above = i.checked_sub(1).and_then(|j| rows.get(j)).is_some_and(joins); + let below = rows.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); } } @@ -1070,7 +1088,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // 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); @@ -1087,9 +1105,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(&doc, &sel, &rows, i, 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(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 200dd72bc..cddd89d09 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -493,7 +493,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"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..f30e46409 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (continuous, rounded layer hierarchy selection (M8); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -39,7 +39,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Rows: click, Shift-click range, Cmd/Ctrl-click toggle; the selection follows on the canvas and a canvas selection is revealed | done | `layers_behaviour_tests` | +| Rows: click, Shift-click range, Cmd/Ctrl-click toggle; the selection follows on the canvas and a canvas selection is revealed | done | `layers_behaviour_tests`; selected hierarchies share a continuous background with rounded outer corners, including nested selections | | Expand and collapse: Alt-click opens or closes everything inside; Collapse layers (Alt+L) keeps the way to the selection open | done | Alt-click pinned (the documentation is silent) | | Drag to reorder and into frames, groups and auto layout: insertion line, spring-loaded open, several layers together, Escape cancels | done | several layers dropped together land together (they split apart before); instances and a layer's own children refuse drops | | Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From 901bc6d4fc6a2297289a04d600be22bc05bbc2d5 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Mon, 2 Nov 2026 10:52:52 +0100 Subject: [PATCH 2/5] M8: compute layer selection roots once per visible row --- crates/ui-egui/src/panels/layers.rs | 26 ++++++++++++++++---------- docs/ui-parity.md | 3 ++- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 244f95805..1fc89d1d9 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -957,17 +957,18 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI } /// The outermost selected layer owning a row's continuous hierarchy highlight. -fn selection_root(doc: &Document, sel: &[NodeId], id: NodeId) -> Option { +fn selection_root(doc: &Document, sel: &BTreeSet, id: NodeId) -> Option { + 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(doc: &Document, sel: &[NodeId], rows: &[Row], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> { - let root = selection_root(doc, sel, rows.get(i)?.id)?; - // Look beyond the viewport too: scrolling must not round an edge in the - // middle of a selected subtree. Separate selected subtrees keep their corners. - let joins = |neighbor: &Row| selection_root(doc, sel, neighbor.id) == Some(root); - let above = i.checked_sub(1).and_then(|j| rows.get(j)).is_some_and(joins); - let below = rows.get(i + 1).is_some_and(joins); +fn hierarchy_bg(roots: &[Option], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> { + let root = roots.get(i).copied().flatten()?; + let joins = |neighbor: &Option| *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; } @@ -1083,6 +1084,11 @@ 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. @@ -1091,7 +1097,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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); @@ -1107,7 +1113,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info); 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(&doc, &sel, &rows, i, rr, pitch - ROW); + 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); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f30e46409..57c0c4993 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (continuous, rounded layer hierarchy selection (M8); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (reuse visible layer selection roots (M8); continuous, rounded layer hierarchy selection (M8); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. | | 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | From bb7d568d4962574e4705450321f6b9a9f2613ba5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:13:19 -0500 Subject: [PATCH 3/5] M2: baseline alignment puts a layer without text on the line with its bottom (#1825) In the baseline branch of the flow layout only an item with a text baseline got one; any other layer stayed at the top of the line. A layer without a text baseline now uses its bottom edge, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"): an icon next to a text sits on the text's baseline, a layer reaching below it moves the text down, and a line with no text lines the layers' bottoms up (CSS does the same). Tests: alignment_rules (three cases), baseline_alignment_horizontal, engine baseline_alignment_puts_an_icons_bottom_on_the_text_baseline. Docs: auto-layout-parity row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/cmd/layout.rs | 24 ++++++++++++++++++++++++ crates/layout/src/behaviour_tests.rs | 26 +++++++++++++++++++++++--- crates/layout/src/lib.rs | 8 ++++++-- crates/layout/src/tests.rs | 5 +++-- docs/auto-layout-parity.md | 5 +++-- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6972a6780..fc40cee88 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -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(); diff --git a/crates/layout/src/behaviour_tests.rs b/crates/layout/src/behaviour_tests.rs index bba20c532..50dcc79ad 100644 --- a/crates/layout/src/behaviour_tests.rs +++ b/crates/layout/src/behaviour_tests.rs @@ -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)], }, ]); } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 19de84afb..65049eb01 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, /// 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)); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0af9861b8..fa215b799 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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. diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 3b5466b2c..3f49f3008 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-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` | From ad9894a90fabf079a23aea1afdd7c0dcc8f092b7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:31:52 +0000 Subject: [PATCH 4/5] fix(doc): give render bounds a visit budget so a repeating child list ends --- crates/doc/src/lib.rs | 18 +++++++++++------- crates/doc/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0181ffd21..f3ee25c53 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -760,16 +760,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 { - 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 { + fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell) -> Option { 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)), @@ -778,15 +782,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 { + fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell) -> Option { let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask); let mut out: Option = 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) { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5c8e8e7a..75de3541e 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -582,6 +582,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 From ddb8bfa6652e6644f9cd1ef56687866fdef1b13a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:44:24 +0000 Subject: [PATCH 5/5] fix(format): cap the bytes copied to fill a variable's missing modes --- crates/format/src/rest/variables.rs | 44 +++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 94ca03f6b..ba2467423 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -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, 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 = (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); + } +}