From 14431850ca4c532c0676b1cb0eb23dfafe2e101e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:34:08 -0500 Subject: [PATCH 1/2] M1: Layers panel as wide as its widest name, scrolling sideways (#1904) The Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so an imported component set's variants all read "Size=small, Type=prim..." and a deeply nested name lost its room to the indent. The row cache now also keeps the width the widest row needs (a few names measured per rebuild, picked by a bound on their width), the list takes that width and scrolls both ways, revealing a layer scrolls up or down only, and a name past 1200 pt still elides (#1650). Tests: layers_wide_names_tests. Docs: ui-parity Layers panel row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/layers_wide_names_tests.rs | 115 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/layers.rs | 104 +++++++++++++--- docs/ui-parity.md | 4 +- 4 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/layers_wide_names_tests.rs diff --git a/crates/ui-egui/src/layers_wide_names_tests.rs b/crates/ui-egui/src/layers_wide_names_tests.rs new file mode 100644 index 000000000..2a4ae8999 --- /dev/null +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -0,0 +1,115 @@ +//! Long and deeply nested names in the Layers panel (#1904), drawn headless: the list is as wide +//! as its widest name needs and scrolls sideways, so a name reads in full without widening the +//! sidebar, as the reference app's Layers panel does with its horizontal scrollbar; names that +//! fit leave the list as wide as the panel; a name past the width cap (#1650) still elides. + +use egui::{Event, MouseWheelUnit, Pos2, Shape, TouchPhase, Vec2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle named `name` on the page, or inside `parent`. +fn named(s: &mut Session, name: &str, parent: Option) -> NodeId { + let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "props": {"name": name}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +/// The painted text starting with `prefix`: (whether it was elided, its position, its size). +fn painted(u: &mut Ui, prefix: &str) -> (bool, Pos2, Vec2) { + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text().starts_with(prefix) => Some((t.galley.elided, t.pos, t.galley.size())), + _ => None, + }) + .unwrap_or_else(|| panic!("no text starting {prefix:?}")) +} + +/// Turn the wheel sideways by `dx` points over `at`, and let the scroll settle. +fn wheel_sideways(u: &mut Ui, at: Pos2, dx: f32) { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(-dx, 0.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(at), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } +} + +const LONG: &str = "Size=small, Type=primary, State=default, Icon=leading, Width=full"; + +#[test] +fn a_long_name_is_painted_in_full_and_the_list_scrolls_sideways() { + let mut s = Session::new(); + let long = named(&mut s, LONG, None); + named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Size=small"); + assert!(!elided, "the whole name, not an elided one"); + // The panel is 240 pt wide: the name runs past it, into the space a sideways scroll shows. + assert!(p.x + size.x > 320.0, "the name runs past the panel: {} + {}", p.x, size.x); + let at = u.row_at(long); + wheel_sideways(&mut u, at, 120.0); + let (_, scrolled, _) = painted(&mut u, "Size=small"); + assert!(scrolled.x <= p.x - 100.0, "the list scrolled sideways: {} -> {}", p.x, scrolled.x); + // Short names move with it (one list), not on their own. + let (_, short, _) = painted(&mut u, "Short"); + assert!(short.x < 100.0, "the short name moved with the list: {}", short.x); +} + +#[test] +fn a_deeply_nested_name_gets_its_indent_too() { + let mut s = Session::new(); + // A name that fits at the top level, under five frames: the indent pushes it past the panel. + let mut parent = None; + for i in 0..5 { + let f = id(s + .execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": parent, "props": {"name": format!("Frame {i}")}})) + .unwrap()); + parent = Some(f); + } + let leaf = named(&mut s, "Component set original (não usar)", parent); + s.execute("select.set", &json!({"ids": [leaf]})).unwrap(); + let mut u = Ui::new(s); + let (elided, p, size) = painted(&mut u, "Component set original"); + assert!(!elided, "the nested name reads in full"); + assert!(p.x + size.x > 320.0, "it runs past the panel: {} + {}", p.x, size.x); +} + +#[test] +fn names_that_fit_leave_the_list_as_wide_as_the_panel() { + let mut s = Session::new(); + let short = named(&mut s, "Short", None); + let mut u = Ui::new(s); + let (elided, p, _) = painted(&mut u, "Short"); + assert!(!elided); + let at = u.row_at(short); + wheel_sideways(&mut u, at, 120.0); + let (_, after, _) = painted(&mut u, "Short"); + assert_eq!(after.x, p.x, "nothing to scroll to sideways"); +} + +#[test] +fn a_name_past_the_room_cap_still_elides() { + let mut s = Session::new(); + let name = "Layer ".repeat(500); + named(&mut s, &name, None); + let mut u = Ui::new(s); + let (elided, _, size) = painted(&mut u, "Layer Layer"); + assert!(elided, "a 3000-character name elides"); + assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..371a72215 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] +mod layers_wide_names_tests; +#[cfg(test)] mod library_beacon_tests; #[cfg(test)] mod live_chat_tests; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 164ec7e4a..a34b43e77 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -486,6 +486,8 @@ struct RowCache { expanded: u64, filter: String, rows: Arc>, + /// The width the widest row needs to show its whole name ([`widest_row`]). + width: f32, } fn hash_expanded(expanded: &BTreeSet) -> u64 { @@ -498,15 +500,76 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { /// The rows to list: the expanded tree, or the layers matching `filter` (lowercase). pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; + let id = Id::unique("layer_rows"); + let fresh = ui + .data(|d| d.get_temp::(id)) + .filter(|c| c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter); + if let Some(c) = fresh { + return c.rows; + } + let rows = Arc::new(listed_rows(doc, page, expanded, filter)); + // Outside the context's data lock: laying out the names takes the context too. + let width = widest_row(ui, doc, page, &rows); ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); - let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; - if !fresh { - let rows = listed_rows(doc, page, expanded, filter); - *c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) }; + d.insert_temp(id, RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: rows.clone(), width }) + }); + rows +} + +/// The width the rows [`rows_for`] last listed need to show their names in full, from the +/// panel's left edge (0 before any were listed). +pub(crate) fn list_width(ui: &Ui) -> f32 { + ui.data(|d| d.get_temp::(Id::unique("layer_rows"))).map_or(0.0, |c| c.width) +} + +/// A name's room on a row stops growing here: a name of thousands of characters (#1650) elides +/// past it instead of making the list that wide. +pub(crate) const NAME_ROOM: f32 = 1200.0; + +/// A row's name: the layer's, or its kind's label when it has none. +fn row_name(n: &figcraft_doc::Node) -> &str { + if n.name.is_empty() { tr(n.kind.label()) } else { &n.name } +} + +/// The font a row's name is drawn in: top-level frames and sections stand out. +fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::FontId { + let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); + if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) } +} + +/// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the +/// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to +/// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls +/// sideways when a name doesn't fit the panel (#1904). Only the rows whose name could be the +/// widest are laid out: a name's bytes at the widest glyph bound its width, and the few rows with +/// the largest bounds are measured, largest first, until the bound falls under the widest found; +/// a 10 000-row list lays out a handful of names. +fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { + // What sits left of the name (the row's margin, the indent's start, the triangle and icon) + // and right of it (the eye and padlock, the margin): see the row loop in `layers`. + const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0; + // No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width. + const GLYPH: f32 = 12.0; + // The rows laid out at most: enough that the widest is among them whatever the letters. + const MEASURED: usize = 48; + let indent = |r: &Row| r.depth as f32 * 16.0; + let mut bounds: Vec<(f32, usize)> = + rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect(); + if bounds.len() > MEASURED { + bounds.select_nth_unstable_by(MEASURED, |a, b| b.0.total_cmp(&a.0)); + bounds.truncate(MEASURED); + } + bounds.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut widest = 0.0f32; + for (bound, i) in bounds { + if bound <= widest { + break; } - c.rows.clone() - }) + let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue }; + let w = ui.painter().layout_no_wrap(row_name(n).to_string(), name_font(doc, page, n), Color32::WHITE).size().x; + widest = widest.max(indent(r) + w.min(NAME_ROOM)); + } + widest + AROUND } /// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase). @@ -1005,6 +1068,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let reveal = reveal_selection(app, &doc, page, &sel); let filter = app.ui.layer_filter.trim().to_lowercase(); let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter); + // The rows are as wide as the panel, or as the widest name needs: the list then scrolls + // sideways too, so long and deeply nested names read in full without widening the sidebar, + // as the reference app's Layers panel does with its horizontal scrollbar (#1904). + let row_w = w.max(list_width(ui)); let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; // A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and // go on to the next / previous row. @@ -1046,13 +1113,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // The drag is over a row that takes no drop: the not-allowed cursor shows. let mut refused = false; // Only the rows in view are laid out and drawn; the rest is empty scroll space. - egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + egui::ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let pitch = ROW + ui.spacing().item_spacing.y; let top = ui.max_rect().top(); + let left = ui.max_rect().left(); let total = rows.len() as f32 * pitch; + let row_w = row_w.max(viewport.width()); ui.set_height(total.max(viewport.height())); + ui.set_width(row_w); if let Some(i) = scroll_to { - ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None); + // The row at the width in view: revealing a layer scrolls up or down, not sideways. + ui.scroll_to_rect(Rect::from_min_size(pos2(left + viewport.min.x, top + i as f32 * pitch), vec2(viewport.width(), ROW)), None); } if rows.is_empty() { ui.add_space(8.0); @@ -1065,14 +1136,14 @@ 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()); - let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); + let band = Rect::from_x_y_ranges(left..=(left + row_w), (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) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag()); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -1124,11 +1195,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } else { let col = row_color(is_comp, shown, t.text, t.text_tertiary); - let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); - let font = - if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }; - let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() }; - // Room left of the lock and eye buttons. + let font = name_font(&doc, page, n); + let name = row_name(n).to_string(); + // Room left of the lock and eye buttons (the row is wide enough for the whole + // name, up to `NAME_ROOM`). let max_w = (rr.max.x - 56.0 - name_x).max(20.0); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } @@ -1177,7 +1247,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i)) && let Some(n) = doc.node(id) { - let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW)); + let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(row_w, ROW)); if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) { rename_done = Some((id, new)); } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..71d32a14b 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-03 · **Change:** minor (the Layers panel is as wide as its widest name needs and scrolls sideways, so long and deeply nested names read in full (#1904); 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: @@ -170,6 +170,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | +| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache measures the few names that could be the widest, so a 10 000-row list lays out a handful) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. 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 4ad80fa7bee3bfc6b3a5cd1ce99de69d347808c2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:57:59 -0500 Subject: [PATCH 2/2] M1: Layers panel width: measure every candidate as painted, cap painting, no drops past the list (#1904) Review: the widest name is found by laying rows out in descending order of a bound on their width until the bound falls under the widest found, with no cut at 48 rows; each name is laid out as it is painted (one line, cut at NAME_ROOM); the painted room is capped at NAME_ROOM too; and a drag over a wide row counts only inside the list's clip rect, so a layer released over the canvas drops nowhere. Tests: a_wide_name_among_many_long_narrow_ones_is_still_measured, a_name_with_line_breaks_is_measured_as_painted, a_drag_released_over_the_canvas_drops_nowhere. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/layers_wide_names_tests.rs | 46 +++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 29 ++++++------ docs/ui-parity.md | 4 +- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/layers_wide_names_tests.rs b/crates/ui-egui/src/layers_wide_names_tests.rs index 2a4ae8999..eeb833e42 100644 --- a/crates/ui-egui/src/layers_wide_names_tests.rs +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -113,3 +113,49 @@ fn a_name_past_the_room_cap_still_elides() { assert!(elided, "a 3000-character name elides"); assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x); } + +/// Review: the widest name isn't always the longest in bytes. Fifty rows of narrow letters outrank +/// one row of wide ones by the bound; it is laid out all the same, so it reads in full. +#[test] +fn a_wide_name_among_many_long_narrow_ones_is_still_measured() { + let mut s = Session::new(); + for _ in 0..50 { + named(&mut s, &"i".repeat(100), None); + } + named(&mut s, &"W".repeat(70), None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "WWWW"); + assert!(!elided, "the wide name reads in full"); +} + +/// Review: a name is laid out as it is painted, on one line, so line breaks in it don't make it +/// measure narrower than it paints. +#[test] +fn a_name_with_line_breaks_is_measured_as_painted() { + let mut s = Session::new(); + named(&mut s, "Size=small,\nType=primary,\nState=default,\nIcon=leading,\nWidth=full", None); + let mut u = Ui::new(s); + let (elided, ..) = painted(&mut u, "Size=small,"); + assert!(!elided, "the whole name on one line"); +} + +/// Review: a wide row runs on under the canvas; a dragged layer released there is no drop. +#[test] +fn a_drag_released_over_the_canvas_drops_nowhere() { + let mut s = Session::new(); + let wide = named(&mut s, &"Size=small, Type=primary, ".repeat(6), None); + let short = named(&mut s, "Short", None); + let page = s.page().unwrap(); + let order = s.doc().unwrap().node(page).unwrap().children.clone(); + let mut u = Ui::new(s); + let from = u.row_at(short); + let level = u.row_at(wide).y; + // Over the canvas, level with the wide row (which runs on under it). + let over_canvas = Pos2::new(800.0, level); + u.drag_to(from, over_canvas); + u.button(over_canvas, false); + for _ in 0..5 { + u.frame(vec![]); + } + assert_eq!(u.app.session.doc().unwrap().node(page).unwrap().children, order, "no reorder"); +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index a34b43e77..54682c683 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -540,25 +540,20 @@ fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::Font /// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the /// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to /// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls -/// sideways when a name doesn't fit the panel (#1904). Only the rows whose name could be the -/// widest are laid out: a name's bytes at the widest glyph bound its width, and the few rows with -/// the largest bounds are measured, largest first, until the bound falls under the widest found; -/// a 10 000-row list lays out a handful of names. +/// sideways when a name doesn't fit the panel (#1904). A name's bytes at the widest glyph bound +/// its width, and the rows are laid out in descending order of that bound until the bound falls +/// under the widest found, so no name that could be the widest is skipped: a list with one long +/// name lays out a handful, a list of names all alike lays them all out, through egui's galley +/// cache. A name is laid out as it is painted: on one line, cut at [`NAME_ROOM`]. fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { // What sits left of the name (the row's margin, the indent's start, the triangle and icon) // and right of it (the eye and padlock, the margin): see the row loop in `layers`. const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0; // No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width. const GLYPH: f32 = 12.0; - // The rows laid out at most: enough that the widest is among them whatever the letters. - const MEASURED: usize = 48; let indent = |r: &Row| r.depth as f32 * 16.0; let mut bounds: Vec<(f32, usize)> = rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect(); - if bounds.len() > MEASURED { - bounds.select_nth_unstable_by(MEASURED, |a, b| b.0.total_cmp(&a.0)); - bounds.truncate(MEASURED); - } bounds.sort_by(|a, b| b.0.total_cmp(&a.0)); let mut widest = 0.0f32; for (bound, i) in bounds { @@ -566,8 +561,8 @@ fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 { break; } let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue }; - let w = ui.painter().layout_no_wrap(row_name(n).to_string(), name_font(doc, page, n), Color32::WHITE).size().x; - widest = widest.max(indent(r) + w.min(NAME_ROOM)); + let w = crate::bidi::name_galley(ui.painter(), row_name(n), name_font(doc, page, n), Color32::WHITE, NAME_ROOM).size().x; + widest = widest.max(indent(r) + w); } widest + AROUND } @@ -1198,8 +1193,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let font = name_font(&doc, page, n); let name = row_name(n).to_string(); // Room left of the lock and eye buttons (the row is wide enough for the whole - // name, up to `NAME_ROOM`). - let max_w = (rr.max.x - 56.0 - name_x).max(20.0); + // name, up to `NAME_ROOM`: a row widened by a deeper name gives no more). + let max_w = (rr.max.x - 56.0 - name_x).clamp(20.0, NAME_ROOM); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); } let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep); @@ -1223,7 +1218,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } - let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); + // In the row and in the list's view: a wide row runs on under the canvas (#1904), and a + // drag released there lands nowhere. + let clip = ui.clip_rect(); + let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p) && clip.contains(p)); // What is inside the dragged layers, and inside an instance, takes no drop. refused |= over && (doc.ancestors(row.id).iter().any(|a| moving.contains(a)) @@ -1232,6 +1230,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { && !blocked(row.id) && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && r.contains(p) + && clip.contains(p) { let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id); let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 71d32a14b..facf91b39 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -170,7 +170,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet | | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | -| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | +| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a drag released over the canvas drops nowhere; a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) | | Reduced motion | done | system setting or preference | | WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -179,7 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache measures the few names that could be the widest, so a 10 000-row list lays out a handful) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. | +| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. 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` |