mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 00:11:10 +00:00
Merge pull request #1912 from TrevorBrown-dev/feat/layers-horizontal-scroll
Layers panel: the list is as wide as its widest name and scrolls sideways (#1904)
This commit is contained in:
4 files changed
+253
-19
No files matched your search
@@ -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>) -> 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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -492,6 +492,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 {
|
||||
@@ -504,15 +506,71 @@ 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). 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));
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user