mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 01:21:17 +00:00
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:
co-authored by
Claude Fable 5.1
parent
35684eaa02
commit
14431850ca
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user