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..eeb833e42 --- /dev/null +++ b/crates/ui-egui/src/layers_wide_names_tests.rs @@ -0,0 +1,161 @@ +//! 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); +} + +/// 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/lib.rs b/crates/ui-egui/src/lib.rs index 62e37dc78..bb92d5296 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -146,6 +146,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 9db48dd49..4901851dd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -492,6 +492,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 { @@ -504,15 +506,71 @@ 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). 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; + 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(); + 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 = 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 } /// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase). @@ -1030,6 +1088,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. @@ -1071,13 +1133,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); @@ -1095,14 +1161,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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 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 (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 (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag()); let selected = selected_ids.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -1158,12 +1224,11 @@ 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 max_w = (rr.max.x - 56.0 - name_x).max(20.0); + 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`: 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); @@ -1187,7 +1252,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)) @@ -1196,6 +1264,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); @@ -1211,7 +1280,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 109dbaf81..f23330ede 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -173,6 +173,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 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 | @@ -187,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 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-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-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |