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 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-03 09:34:08 -05:00
co-authored by Claude Fable 5.1
parent 35684eaa02
commit 14431850ca
4 changed files with 207 additions and 18 deletions
@@ -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>) -> 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);
}
+2
View File
@@ -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;
+87 -17
View File
@@ -486,6 +486,8 @@ struct RowCache {
expanded: u64,
filter: String,
rows: Arc<Vec<Row>>,
/// The width the widest row needs to show its whole name ([`widest_row`]).
width: f32,
}
fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
@@ -498,15 +500,76 @@ fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
/// The rows to list: the expanded tree, or the layers matching `filter` (lowercase).
pub(crate) fn rows_for(ui: &Ui, doc: &Arc<Document>, page: NodeId, expanded: &BTreeSet<NodeId>, filter: &str) -> Arc<Vec<Row>> {
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::<RowCache>(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::<RowCache>(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::<RowCache>(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));
}
+3 -1
View File
File diff suppressed because one or more lines are too long