From cfd6bf5273f1e645a864e387a8b6f1efb254e544 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 16:18:06 -0500 Subject: [PATCH 1/5] M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma Measured in Figma's web app on 2026-11-12 (top-level rectangles): a row, a column and a 2 x 3 grid of unequal sizes, eight runs on the gap, and the cases where Tidy up is greyed out (fewer than three layers, overlapping layers). Figma keeps the order, the sizes and the first layer, evens one gap per axis (the most common existing gap, else the mean taken down to a whole number) and tidies a grid by columns. Choices, not measured: the smaller of two equally common gaps, a mean ending in .5 going down, gaps a pixel apart differing, a diagonal being a row, a grid with an empty place, a grid always read by columns, one overlapping pair making it unavailable, auto layout flow children making it unavailable, the 2,000 layer cap, rotated layers by their box, spacing with two layers, and no rounding with Snap to pixel grid off. A selection that is none of a row, a column or a grid keeps the earlier rule unchanged. The Design panel's Tidy up row is greyed out with a tip when unavailable; the command then moves nothing and adds no undo step. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.2 briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/cmd/arrange.rs | 60 ++---- crates/engine/src/lib.rs | 3 + crates/engine/src/selection.rs | 1 + crates/engine/src/selection/tidy.rs | 260 ++++++++++++++++++++++++++ crates/engine/src/tidy_tests.rs | 279 ++++++++++++++++++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/right.rs | 20 +- crates/ui-egui/src/tidy_tests.rs | 30 +++ docs/ui-parity.md | 4 +- 10 files changed, 610 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/selection/tidy.rs create mode 100644 crates/engine/src/tidy_tests.rs create mode 100644 crates/ui-egui/src/tidy_tests.rs diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 88146ea95..43bf7efc5 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -223,53 +223,25 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result { ok() } -/// Tidy up: snap the selection into a grid of rows/columns with even spacing. +/// Tidy up: even the gaps of a row, a column or a grid, keeping the order and sizes (see +/// `selection::tidy`). Nothing moves, and no undo step is added, when it is unavailable. fn tidy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("arrange.tidy", s, p)?; + let spacing = num(p, "spacing"); + if spacing.is_some_and(|v| !v.is_finite() || v.abs() > 1_000_000.0) { + return Err(bad("arrange.tidy", "spacing out of range")); + } + let snap = s.ds()?.view.snap_pixel; + let moves = crate::tidy::tidy_moves(s.doc()?, &ids, spacing, snap); + if moves.is_empty() { + return ok(); + } + if moves.iter().any(|(_, dx, dy)| !dx.is_finite() || !dy.is_finite()) { + return Err(bad("arrange.tidy", "non-finite result")); + } let doc = s.doc_mut()?; - let ids = roots(doc, &ids); - let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect(); - if items.len() < 2 { - return ok(); - } - // Group into rows by vertical centre overlap. - items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); - let mut rows: Vec> = Vec::new(); - for it in items { - match rows.last_mut() { - Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it), - _ => rows.push(vec![it]), - } - } - let all: Vec = rows.iter().flatten().map(|(_, b)| *b).collect(); - let (Some(origin), maxw, maxh) = ( - all.iter().copied().reduce(|a, b| a.union(b)), - all.iter().map(Rect::width).fold(0.0, f64::max), - all.iter().map(Rect::height).fold(0.0, f64::max), - ) else { - return ok(); - }; - let gap = num(p, "spacing").unwrap_or_else(|| { - // Keep the average existing horizontal gap (min 10). - 10f64.max( - rows.iter() - .filter(|r| r.len() > 1) - .map(|r| { - (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) - - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) - - r.iter().map(|(_, b)| b.width()).sum::()) - / (r.len() - 1) as f64 - }) - .fold(0.0, f64::max), - ) - }); - for (ri, row) in rows.iter_mut().enumerate() { - row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); - for (ci, (id, b)) in row.iter().enumerate() { - let x = origin.x0 + ci as f64 * (maxw + gap); - let y = origin.y0 + ri as f64 * (maxh + gap); - move_world(doc, *id, x - b.x0, y - b.y0)?; - } + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; } ok() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 39ead2e82..fc8ab8c4d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -77,6 +77,7 @@ pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; pub use selection::selection_spacing; +pub use selection::tidy; pub use sharing::agent; pub use sharing::background; pub use sharing::branches; @@ -609,6 +610,8 @@ mod textfit_tests; #[cfg(test)] mod theme_pages_tests; #[cfg(test)] +mod tidy_tests; +#[cfg(test)] mod tiff_tests; #[cfg(test)] mod transform_behaviour_tests; diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index 08ab6456f..fa901b563 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -2,3 +2,4 @@ pub mod rename; pub mod selection_colors; pub mod selection_reorder; pub mod selection_spacing; +pub mod tidy; diff --git a/crates/engine/src/selection/tidy.rs b/crates/engine/src/selection/tidy.rs new file mode 100644 index 000000000..a802aeccc --- /dev/null +++ b/crates/engine/src/selection/tidy.rs @@ -0,0 +1,260 @@ +//! Tidy up: the moves that even a selection's gaps the way Figma's web app does. +//! +//! Measured in Figma's web app on 2026-11-12 (top-level rectangles): a row, a column and a 2 x 3 +//! grid of unequal sizes, and eight runs on the gap. Figma keeps the order and the sizes of every +//! layer and keeps the first layer where it is. A row evens its horizontal gaps and keeps every Y, +//! a column the mirror image. The one gap is the most common existing gap, else the mean of the +//! gaps taken down to a whole number. A grid is tidied by columns: each column is as wide as its +//! widest layer, centred where its layers were, the columns are evened, every layer is centred in +//! its column and every column is stacked from the selection's top. Tidy up is greyed out for fewer +//! than three layers and for layers that overlap. +//! +//! Not measured, FigCraft's choices: the smaller of two equally common gaps; a mean ending in .5 +//! going down; gaps a pixel apart being different gaps; a diagonal being a row; a grid with an +//! empty place; a grid always read by columns; one overlapping pair among other layers making it +//! unavailable; layers in an auto layout flow making it unavailable; the 2,000 layer cap; rotated +//! layers counting by their box; an explicit `spacing` taking two layers; no rounding in a grid +//! with Snap to pixel grid off. A selection that is none of a row, a column or a grid keeps the +//! earlier rule (the cells of the largest layer). + +use std::collections::BTreeSet; + +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::Rect; + +use crate::cmd::edit::roots; + +const EPS: f64 = 0.01; +const MAX_LAYERS: usize = 2000; + +type Item = (NodeId, Rect); +type Move = (NodeId, f64, f64); + +/// Whether Tidy up is offered for `ids`: three to 2,000 layers with boxes, none in an auto layout +/// flow, none overlapping another. +pub fn can_tidy(doc: &Document, ids: &[NodeId]) -> bool { + available(doc, ids, 3).is_some() +} + +/// The page-space moves that tidy `ids` (empty when unavailable or when nothing would move). +/// `spacing` sets the gap and lets two layers through; `snap` rounds a grid's column positions to +/// whole pixels. +pub(crate) fn tidy_moves(doc: &Document, ids: &[NodeId], spacing: Option, snap: bool) -> Vec { + let min = if spacing.is_some() { 2 } else { 3 }; + let Some(items) = available(doc, ids, min) else { return Vec::new() }; + let cols = groups(&items, false); + let rows = groups(&items, true); + let moves = if cols.len() == items.len() { + line_moves(&items, false, spacing) + } else if rows.len() == items.len() { + line_moves(&items, true, spacing) + } else if is_grid(&cols, &rows) { + grid_moves(&cols, spacing, snap) + } else { + irregular_moves(items, spacing) + }; + moves.into_iter().filter(|(_, dx, dy)| !(dx.is_finite() && dy.is_finite()) || dx.abs() > 1e-9 || dy.abs() > 1e-9).collect() +} + +fn available(doc: &Document, ids: &[NodeId], min: usize) -> Option> { + let mut seen = BTreeSet::new(); + let ids: Vec = roots(doc, ids).into_iter().filter(|i| seen.insert(*i)).collect(); + if ids.len() < min || ids.len() > MAX_LAYERS { + return None; + } + let mut items = Vec::with_capacity(ids.len()); + for id in ids { + let n = doc.node(id)?; + if n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && !n.child_layout.absolute) { + return None; + } + let b = doc.world_bounds(id)?; + if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) { + return None; + } + items.push((id, b)); + } + let overlapping = items.iter().enumerate().any(|(i, a)| items.iter().skip(i + 1).any(|b| overlap(a.1, b.1))); + if overlapping { None } else { Some(items) } +} + +/// Two boxes overlap when they share more than `EPS` on both axes; touching edges do not count. +fn overlap(a: Rect, b: Rect) -> bool { + a.x1.min(b.x1) - a.x0.max(b.x0) > EPS && a.y1.min(b.y1) - a.y0.max(b.y0) > EPS +} + +fn span(b: Rect, vertical: bool) -> (f64, f64) { + if vertical { (b.y0, b.y1) } else { (b.x0, b.x1) } +} + +/// Columns (`vertical` false) or rows: the layers whose ranges on the axis overlap, transitively, +/// in order along the axis. +fn groups(items: &[Item], vertical: bool) -> Vec> { + let mut sorted = items.to_vec(); + sorted.sort_by(|a, b| span(a.1, vertical).0.total_cmp(&span(b.1, vertical).0)); + let mut out: Vec> = Vec::new(); + let mut end = f64::MIN; + for it in sorted { + let (lo, hi) = span(it.1, vertical); + match out.last_mut() { + Some(group) if lo < end - EPS => { + group.push(it); + end = end.max(hi); + } + _ => { + out.push(vec![it]); + end = hi; + } + } + } + out +} + +/// No two layers share both a column and a row. +fn is_grid(cols: &[Vec], rows: &[Vec]) -> bool { + cols.iter().all(|c| rows.iter().all(|r| c.iter().filter(|a| r.iter().any(|b| b.0 == a.0)).count() <= 1)) +} + +/// The one gap an axis is evened to: `spacing`, a single gap, the most common gap, else the mean +/// taken down to a whole number. +fn even_gap(gaps: &[f64], spacing: Option) -> f64 { + if let Some(s) = spacing { + return s; + } + if let [only] = gaps { + return *only; + } + let count = |g: f64| gaps.iter().filter(|o| (**o - g).abs() <= EPS).count(); + let best = gaps.iter().copied().map(|g| (count(g), g)).reduce(|a, b| if b.0 > a.0 || (b.0 == a.0 && b.1 < a.1) { b } else { a }); + match best { + Some((n, g)) if n >= 2 => g, + _ if gaps.is_empty() => 0.0, + _ => (gaps.iter().sum::() / gaps.len() as f64 + 1e-9).floor(), + } +} + +/// A row (`vertical` false) or a column: the first layer stays, each next one follows the last at +/// the even gap, the other axis is untouched. +fn line_moves(items: &[Item], vertical: bool, spacing: Option) -> Vec { + let mut line = items.to_vec(); + line.sort_by(|a, b| span(a.1, vertical).0.total_cmp(&span(b.1, vertical).0)); + let gaps: Vec = line + .windows(2) + .filter_map(|w| match w { + [a, b] => Some(span(b.1, vertical).0 - span(a.1, vertical).1), + _ => None, + }) + .collect(); + let gap = even_gap(&gaps, spacing); + let mut pos = line.first().map_or(0.0, |i| span(i.1, vertical).0); + let mut out = Vec::with_capacity(line.len()); + for (id, b) in line { + let (lo, hi) = span(b, vertical); + let d = pos - lo; + pos += (hi - lo) + gap; + out.push(if vertical { (id, 0.0, d) } else { (id, d, 0.0) }); + } + out +} + +fn snap_to(v: f64, snap: bool) -> f64 { + if snap { (v + 0.5).floor() } else { v } +} + +/// A column box: where it starts and how wide it is (its widest layer, around the middle of the +/// column's X range). +fn column_box(col: &[Item]) -> (f64, f64) { + let lo = col.iter().map(|i| i.1.x0).fold(f64::MAX, f64::min); + let hi = col.iter().map(|i| i.1.x1).fold(f64::MIN, f64::max); + let w = col.iter().map(|i| i.1.width()).fold(0.0, f64::max); + ((lo + hi) / 2.0 - w / 2.0, w) +} + +/// Vertical gaps inside every column, pooled. +fn column_gaps(cols: &[Vec]) -> Vec { + let mut out = Vec::new(); + for col in cols { + let mut c = col.clone(); + c.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); + out.extend(c.windows(2).filter_map(|w| match w { + [a, b] => Some(b.1.y0 - a.1.y1), + _ => None, + })); + } + out +} + +fn grid_moves(cols: &[Vec], spacing: Option, snap: bool) -> Vec { + let boxes: Vec<(f64, f64)> = cols.iter().map(|c| column_box(c)).collect(); + let across: Vec = boxes + .windows(2) + .filter_map(|w| match w { + [a, b] => Some(b.0 - (a.0 + a.1)), + _ => None, + }) + .collect(); + let gx = even_gap(&across, spacing); + let gy = even_gap(&column_gaps(cols), spacing); + let top = cols.iter().flatten().map(|i| i.1.y0).fold(f64::MAX, f64::min); + let mut left = boxes.first().map_or(0.0, |b| snap_to(b.0, snap)); + let mut out = Vec::new(); + for (col, (_, w)) in cols.iter().zip(&boxes) { + let mut c = col.clone(); + c.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); + let mut y = top; + for (id, b) in c { + let x = snap_to(left + (w - b.width()) / 2.0, snap); + out.push((id, x - b.x0, y - b.y0)); + y += b.height() + gy; + } + left += w + gx; + } + out +} + +/// The earlier rule, unchanged: rows by overlapping vertical centres on a grid whose every cell is +/// the largest layer's size, from the union's top left, at the average horizontal gap (at least 10). +fn irregular_moves(mut items: Vec, spacing: Option) -> Vec { + items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); + let mut rows: Vec> = Vec::new(); + for it in items { + match rows.last_mut() { + Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it), + _ => rows.push(vec![it]), + } + } + let all: Vec = rows.iter().flatten().map(|(_, b)| *b).collect(); + let (Some(origin), maxw, maxh) = ( + all.iter().copied().reduce(|a, b| a.union(b)), + all.iter().map(Rect::width).fold(0.0, f64::max), + all.iter().map(Rect::height).fold(0.0, f64::max), + ) else { + return Vec::new(); + }; + let gap = spacing.unwrap_or_else(|| average_gap(&rows)); + let mut out = Vec::new(); + for (ri, row) in rows.iter_mut().enumerate() { + row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + for (ci, (id, b)) in row.iter().enumerate() { + let x = origin.x0 + ci as f64 * (maxw + gap); + let y = origin.y0 + ri as f64 * (maxh + gap); + out.push((*id, x - b.x0, y - b.y0)); + } + } + out +} + +/// The average existing horizontal gap of the widest row, at least 10. +fn average_gap(rows: &[Vec]) -> f64 { + 10f64.max( + rows.iter() + .filter(|r| r.len() > 1) + .map(|r| { + (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) + - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) + - r.iter().map(|(_, b)| b.width()).sum::()) + / (r.len() - 1) as f64 + }) + .fold(0.0, f64::max), + ) +} diff --git a/crates/engine/src/tidy_tests.rs b/crates/engine/src/tidy_tests.rs new file mode 100644 index 000000000..41eff8d2c --- /dev/null +++ b/crates/engine/src/tidy_tests.rs @@ -0,0 +1,279 @@ +//! Tidy up (`arrange.tidy`) against what was measured in Figma's web app on 2026-11-12: a row, a +//! column and a 2 x 3 grid of unequal sizes, eight runs on the gap, and the cases where the menu +//! row is greyed out. Figma keeps the order and sizes, evens one gap per axis and keeps the first +//! layer where it is. Tests whose message says "not measured in Figma" pin FigCraft's own choice. + +use crate::Session; +use figcraft_doc::NodeId; +use serde_json::json; + +fn make(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} +fn xy(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} +fn size(s: &Session, id: NodeId) -> (f64, f64) { + let n = s.doc().unwrap().node(id).unwrap(); + (n.width, n.height) +} +fn tidy(s: &mut Session, ids: &[NodeId]) { + s.execute("arrange.tidy", &json!({"ids": ids})).unwrap(); +} +fn can(s: &Session, ids: &[NodeId]) -> bool { + crate::tidy::can_tidy(s.doc().unwrap(), ids) +} +fn all_xy(s: &Session, ids: &[NodeId]) -> Vec<(f64, f64)> { + ids.iter().map(|i| xy(s, *i)).collect() +} +/// Squares of 50 at Y 0 with the given X values. +fn squares(s: &mut Session, xs: &[f64]) -> Vec { + xs.iter().map(|x| make(s, *x, 0.0, 50.0, 50.0)).collect() +} +fn xs_of(s: &Session, ids: &[NodeId]) -> Vec { + ids.iter().map(|i| xy(s, *i).0).collect() +} + +fn row(s: &mut Session, xs: [f64; 4]) -> Vec { + vec![make(s, xs[0], 0., 50., 30.), make(s, xs[1], 10., 70., 50.), make(s, xs[2], -5., 40., 40.), make(s, xs[3], 8., 60., 20.)] +} +fn grid(s: &mut Session) -> Vec { + vec![ + make(s, 0., 0., 50., 30.), + make(s, 80., 5., 70., 50.), + make(s, 190., -3., 40., 40.), + make(s, 2., 90., 60., 20.), + make(s, 85., 95., 40., 50.), + make(s, 200., 92., 55., 35.), + ] +} + +#[test] +fn a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps() { + let mut s = Session::new(); + let ids = row(&mut s, [0., 80., 190., 260.]); + let before = s.undo_depth(); + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (80., 10.), (180., -5.), (250., 8.)]); + assert_eq!(size(&s, ids[1]), (70., 50.)); + assert_eq!(size(&s, ids[3]), (60., 20.)); + assert_eq!(s.undo_depth().0, before.0 + 1); + s.undo().unwrap(); + assert_eq!(xy(&s, ids[3]), (260., 8.)); +} + +#[test] +fn gaps_that_all_differ_take_their_mean_taken_down() { + let mut s = Session::new(); + let ids = row(&mut s, [0., 130., 220., 305.]); + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (98., 10.), (216., -5.), (304., 8.)]); + // Gaps 30, 40, 21 (the third layer 49 wide, 50 tall). + let mut s = Session::new(); + let ids = + vec![make(&mut s, 0., 0., 50., 30.), make(&mut s, 80., 10., 70., 50.), make(&mut s, 190., -5., 49., 50.), make(&mut s, 260., 8., 60., 20.)]; + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 80., 180., 259.]); +} + +#[test] +fn a_column_keeps_x_and_evens_the_vertical_gaps() { + let mut s = Session::new(); + let ids = + vec![make(&mut s, 0., 0., 50., 30.), make(&mut s, 8., 60., 70., 50.), make(&mut s, -5., 150., 40., 40.), make(&mut s, 6., 220., 60., 20.)]; + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (8., 60.), (-5., 140.), (6., 210.)]); +} + +#[test] +fn the_gap_is_the_most_common_one_else_the_mean() { + // (X before, widths, X after): cases 1, 2, 3, 4, 5, 7 and 8 of the second run. + let cases: [(&[f64], &[f64], &[f64]); 7] = [ + (&[0., 60., 160.], &[50., 50., 50.], &[0., 80., 160.]), + (&[0., 100., 160.], &[50., 50., 50.], &[0., 80., 160.]), + (&[0., 70., 140.], &[50., 50., 50.], &[0., 70., 140.]), + (&[0., 50., 160.], &[50., 50., 50.], &[0., 80., 160.]), + (&[0., 60., 120., 240.], &[50., 50., 50., 50.], &[0., 60., 120., 180.]), + (&[0., 70., 210.], &[50., 100., 30.], &[0., 80., 210.]), + (&[0., 60., 120., 220., 360.], &[50., 50., 50., 50., 50.], &[0., 60., 120., 180., 240.]), + ]; + for (n, (before, widths, after)) in cases.iter().enumerate() { + let mut s = Session::new(); + let ids: Vec = before.iter().zip(widths.iter()).map(|(x, w)| make(&mut s, *x, 0., *w, 50.)).collect(); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), after.to_vec(), "case {n}"); + assert!(xy(&s, ids[0]).1 == 0.0 && xy(&s, ids[1]).1 == 0.0, "case {n}: Y kept"); + } +} + +#[test] +fn a_grid_is_tidied_by_columns() { + let mut s = Session::new(); + let ids = grid(&mut s); + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(6., -3.), (93., -3.), (203., -3.), (1., 78.), (108., 98.), (195., 88.)]); + assert_eq!(size(&s, ids[4]), (40., 50.)); +} + +#[test] +fn two_layers_and_overlapping_layers_are_left_alone() { + let mut s = Session::new(); + let two = squares(&mut s, &[0., 87.]); + let before = (all_xy(&s, &two), s.undo_depth()); + tidy(&mut s, &two); + assert_eq!((all_xy(&s, &two), s.undo_depth()), before, "two layers"); + assert!(!can(&s, &two)); + + let mut s = Session::new(); + let tri = vec![make(&mut s, 100., 460., 50., 30.), make(&mut s, 140., 430., 80., 60.), make(&mut s, 130., 400., 30., 90.)]; + let before = (all_xy(&s, &tri), s.undo_depth()); + tidy(&mut s, &tri); + assert_eq!((all_xy(&s, &tri), s.undo_depth()), before, "overlapping triple"); + assert!(!can(&s, &tri)); +} + +#[test] +fn a_tidy_that_moves_nothing_adds_no_undo_step() { + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 70., 140.]); + let before = s.undo_depth(); + tidy(&mut s, &ids); + assert_eq!(s.undo_depth(), before); + assert_eq!(xs_of(&s, &ids), vec![0., 70., 140.]); + assert!(can(&s, &ids)); +} + +#[test] +fn two_equally_common_gaps_give_the_smaller() { + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 60., 120., 220., 320.]); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 60., 120., 180., 240.], "not measured in Figma: a tie takes the smaller gap"); +} + +#[test] +fn a_mean_is_taken_down_not_rounded() { + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 60., 135.]); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 67., 134.], "not measured in Figma: a mean of 17.5 goes down"); + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 60., 135., 215.]); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 71., 142., 213.], "not measured in Figma: a mean of 21.67 goes down"); +} + +#[test] +fn layers_on_a_diagonal_are_a_row() { + let mut s = Session::new(); + let ids = vec![make(&mut s, 0., 0., 50., 50.), make(&mut s, 60., 70., 50., 50.), make(&mut s, 140., 150., 50., 50.)]; + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (70., 70.), (140., 150.)], "not measured in Figma: a diagonal is a row"); +} + +#[test] +fn a_grid_with_an_empty_place_stacks_each_column() { + let mut s = Session::new(); + let ids = vec![ + make(&mut s, 0., 0., 50., 50.), + make(&mut s, 70., 0., 50., 50.), + make(&mut s, 150., 0., 50., 50.), + make(&mut s, 0., 80., 50., 50.), + make(&mut s, 72., 90., 50., 50.), + ]; + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (75., 0.), (150., 0.), (0., 85.), (75., 85.)], "not measured in Figma: an empty place in a grid"); +} + +#[test] +fn one_overlapping_pair_makes_tidy_up_unavailable() { + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 60., 105., 170.]); + let before = (xs_of(&s, &ids), s.undo_depth()); + tidy(&mut s, &ids); + assert_eq!((xs_of(&s, &ids), s.undo_depth()), before, "not measured in Figma: one overlapping pair"); + assert!(!can(&s, &ids), "not measured in Figma: one overlapping pair"); +} + +#[test] +fn layers_in_an_auto_layout_flow_are_left_alone() { + let mut s = Session::new(); + let f = NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal"}})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let ids: Vec = (0..3) + .map(|_| NodeId(s.execute("shape.rectangle", &json!({"width": 50, "height": 50, "parent": f})).unwrap()["id"].as_u64().unwrap())) + .collect(); + let before = (all_xy(&s, &ids), s.undo_depth()); + tidy(&mut s, &ids); + assert_eq!((all_xy(&s, &ids), s.undo_depth()), before, "not measured in Figma: auto layout flow children"); + assert!(!can(&s, &ids), "not measured in Figma: auto layout flow children"); + for id in &ids { + s.execute("node.set", &json!({"id": id, "props": {"absolute": true}})).unwrap(); + } + for (id, x) in ids.iter().zip([0., 60., 160.]) { + s.execute("node.set", &json!({"id": id, "props": {"x": x, "y": 0}})).unwrap(); + } + assert_eq!(xs_of(&s, &ids), vec![0., 60., 160.]); + assert!(can(&s, &ids)); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 80., 160.]); +} + +#[test] +fn an_irregular_selection_keeps_the_earlier_rule() { + let mut s = Session::new(); + let ids = vec![make(&mut s, 0., 0., 200., 30.), make(&mut s, 0., 50., 90., 50.), make(&mut s, 110., 50., 90., 50.)]; + tidy(&mut s, &ids); + assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (0., 70.), (220., 70.)]); +} + +#[test] +fn spacing_sets_the_gap_and_takes_two_layers() { + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 60., 160.]); + s.execute("arrange.tidy", &json!({"ids": ids, "spacing": 12})).unwrap(); + assert_eq!(xs_of(&s, &ids), vec![0., 62., 124.]); + let mut s = Session::new(); + let two = squares(&mut s, &[0., 87.]); + s.execute("arrange.tidy", &json!({"ids": two, "spacing": 12})).unwrap(); + assert_eq!(xs_of(&s, &two), vec![0., 62.]); + assert!(!can(&s, &two)); +} + +#[test] +fn without_pixel_snapping_a_centred_layer_keeps_its_half_pixel() { + let mut s = Session::new(); + s.execute("view.snapPixel", &json!({"on": false})).unwrap(); + let ids = grid(&mut s); + tidy(&mut s, &ids); + assert_eq!( + all_xy(&s, &ids), + vec![(6., -3.), (93., -3.), (202.5, -3.), (1., 78.), (108., 98.), (195., 88.)], + "not measured in Figma: no rounding with Snap to pixel grid off" + ); +} + +#[test] +fn a_rotated_layer_is_tidied_by_its_box() { + let mut s = Session::new(); + let a = make(&mut s, 0., 0., 50., 50.); + let m = make(&mut s, 80., 0., 40., 40.); + let c = make(&mut s, 200., 0., 50., 50.); + s.execute("node.set", &json!({"id": m, "props": {"rotation": 30}})).unwrap(); + let boxes = |s: &Session| -> Vec { [a, m, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap()).collect() }; + let b = boxes(&s); + let (g1, g2) = (b[1].x0 - b[0].x1, b[2].x0 - b[1].x1); + let want = ((g1 + g2) / 2.0).floor(); + let rot = figcraft_geom::rotation_degrees(s.doc().unwrap().node(m).unwrap().transform); + tidy(&mut s, &[a, m, c]); + let b = boxes(&s); + assert_eq!(b[0].x0, 0.0); + assert!(((b[1].x0 - b[0].x1) - want).abs() < 1e-6); + assert!(((b[2].x0 - b[1].x1) - want).abs() < 1e-6); + let after = figcraft_geom::rotation_degrees(s.doc().unwrap().node(m).unwrap().transform); + assert!((rot - after).abs() < 1e-9, "rotation unchanged"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 71e6ff010..91b215f21 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1622,6 +1622,7 @@ name = "English" "This version can't be read." = "This version can't be read." "This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" +"Tidy up needs three or more layers that don't overlap" = "Tidy up needs three or more layers that don't overlap" "Tile" = "Tile" "Tile type" = "Tile type" "Timer" = "Timer" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01cfe8e88..3860da3b6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -274,6 +274,8 @@ mod text_link_tests; #[cfg(test)] mod text_mixed_fill_tests; #[cfg(test)] +mod tidy_tests; +#[cfg(test)] mod titlebar_tests; #[cfg(test)] mod ui_params_tests; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3c64f665c..4a230c07c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -654,6 +654,11 @@ pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc, sel: &[N }) } +/// Whether the Design panel offers Tidy up for `sel` (three or more layers that don't overlap). +pub(crate) fn tidy_available(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> bool { + crate::selection_cache::cached(ctx, "tidy", doc, sel, &[], || figcraft_engine::tidy::can_tidy(doc, sel)) +} + /// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the /// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { @@ -695,12 +700,17 @@ fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col if sel.len() > 2 { let more = icons::button(ui, Icon::DistributeH, 24.0, false, tr("Distribute and tidy")); menu_popup(&more).show(|ui| { - for (l, c) in [ - (tr("Distribute horizontal spacing"), "distribute.horizontal"), - (tr("Distribute vertical spacing"), "distribute.vertical"), - (tr("Tidy up"), "arrange.tidy"), + let can_tidy = app.session.ds().is_ok_and(|ds| tidy_available(ui.ctx(), &ds.doc, &ids)); + for (l, c, on) in [ + (tr("Distribute horizontal spacing"), "distribute.horizontal", true), + (tr("Distribute vertical spacing"), "distribute.vertical", true), + (tr("Tidy up"), "arrange.tidy", can_tidy), ] { - if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { + let item = ui.add_enabled_ui(on, |ui| menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or(""))); + if !on { + item.response.on_disabled_hover_text(tr("Tidy up needs three or more layers that don't overlap")); + } + if item.inner { app.run(c, json!({"ids": ids})); ui.close(); } diff --git a/crates/ui-egui/src/tidy_tests.rs b/crates/ui-egui/src/tidy_tests.rs new file mode 100644 index 000000000..971b07dac --- /dev/null +++ b/crates/ui-egui/src/tidy_tests.rs @@ -0,0 +1,30 @@ +//! The Design panel's Tidy up row follows the engine's rule (measured in Figma's web app on +//! 2026-11-12): offered for three or more layers that don't overlap, greyed out otherwise. + +use egui::Context; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn available(layers: &[(f64, f64, f64, f64)]) -> bool { + let mut s = Session::new(); + let ids: Vec = layers.iter().map(|(x, y, w, h)| rect(&mut s, *x, *y, *w, *h)).collect(); + s.execute("select.set", &json!({"ids": ids})).unwrap(); + let app = FigApp::new(s, Box::new(NoServices)); + let ctx = Context::default(); + let doc = app.session.ds().unwrap().doc.clone(); + crate::right::tidy_available(&ctx, &doc, &ids) +} + +#[test] +fn tidy_up_is_off_in_the_panel_for_overlapping_layers() { + assert!(available(&[(0., 0., 50., 50.), (80., 0., 50., 50.), (160., 0., 50., 50.)]), "three squares in a row"); + assert!(!available(&[(100., 460., 50., 30.), (140., 430., 80., 60.), (130., 400., 30., 90.)]), "the overlapping triple"); + assert!(!available(&[(0., 0., 50., 50.), (87., 0., 50., 50.)]), "two squares"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1911dbbcd..b37f7ad15 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-10 · **Change:** minor (the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, and is greyed out for fewer than three layers and for overlapping layers (M8); the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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: @@ -83,6 +83,7 @@ interaction feel not yet compared. | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Edit ▸ Set default properties: new layers take the selected layer's properties | done | from a shape (any layer but text): new rectangles, ellipses, polygons and stars drawn or clicked with their tools take its fills, strokes and their settings, effects, opacity, blend and corners; from a text layer: new text from the Text tool takes its fills and font (#2028). Silent in the help center on frames, lines and arrows: they keep their own look, as do layers made by commands; the defaults last for the session | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | +| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes, eight runs on the gap): the order, the sizes and the other axis are kept and the first layer stays. A row evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap, else the mean taken down to a whole number (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; the grid's 60, 40, 55 gives 51). A grid is tidied by columns: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top. Unavailable for fewer than three layers and for overlapping layers, as measured: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: the smaller of two equally common gaps; a mean ending in .5 going down; gaps a pixel apart being different gaps; a diagonal being a row; a grid with an empty place (each column is still stacked); a grid always read by columns; one overlapping pair among other layers making it unavailable; layers in an auto layout flow making it unavailable (the frame places them); the 2,000 layer cap; rotated layers counting by their box; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. A selection that is none of a row, a column or a grid keeps the earlier rule. Tests: tidy_tests | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also offers enabled libraries' published color styles and Create style (`selection.createColorStyle`); no library variables and no new variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance; a layer without children shows it only with two paint groups, such as a red fill and a blue stroke; one paint group, however many layers share it, hides it), counting the selected layers' own fills and strokes and their descendants' (measured in Figma's web app, 2026-11-09, #2188, on frames, groups and rectangles only; the rule for components and instances is not measured), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | @@ -217,6 +218,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, else the mean taken down) and keeps the other axis, a grid is tidied by columns, and fewer than three layers or overlapping layers grey the row out and move nothing. Measured: those rules and the greyed-out cases; FigCraft's choices: ties, a mean ending in .5, a diagonal, a grid with an empty place, one overlapping pair, auto layout flow children, the 2,000 layer cap, rotated layers, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_column_keeps_x_and_evens_the_vertical_gaps, the_gap_is_the_most_common_one_else_the_mean, a_grid_is_tidied_by_columns, two_layers_and_overlapping_layers_are_left_alone, a_tidy_that_moves_nothing_adds_no_undo_step, an_irregular_selection_keeps_the_earlier_rule and the choices). | | 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` | | 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) | | 2026-11-09 | patch | M3, #2189: the eyedropper picks the colour the canvas shows. It sampled a 1x1 render without the canvas's magnified-image pixels, so on an image magnified past 2x it blended a 1 px line with its neighbours (#3D444D picked as #343B44 beside #151B23), and the pixel it sampled began at the pointer instead of being centred on it. It now matches the canvas when zoomed in; zoomed out it still samples the full-resolution image, not the smaller copy the canvas may show. Test: `eyedropper_image_tests` | From d3f7ae902924701ff3e2ce240b8e51dfddc2888b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 12 Nov 2026 16:32:48 -0500 Subject: [PATCH 2/5] M8: Tidy up rounds the gap, tidies overlapping rows and grids along their longer axis, as measured Third Figma run (web app, 2026-11-12): the mean gap rounds half up (X 0, 60, 135, 215 gives 0, 72, 144, 216; 0, 60, 135 gives 0, 68, 136); a tie between repeated gaps takes the smaller; a row is one row group of layers and may overlap along X, a negative gap counting in the mean (X 0, 60, 105, 170 gives 0, 57, 114, 171); a grid is packed along the axis with more groups (2 x 3 by centred columns, 3 x 2 by centred rows); a diagonal, an irregular layout and layers that all overlap are unavailable. The earlier cell rule is removed. Choices, not measured: a 2 x 2 grid as columns, auto layout flow children unavailable, the 2,000 layer cap, rotated layers by their box, spacing with two layers, no rounding with Snap to pixel grid off. Grid positions along a stack can differ by a pixel from Figma's (it read 52 and 51 for the same mean 51.67). Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.2 briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/selection/tidy.rs | 212 +++++++++++++--------------- crates/engine/src/tidy_tests.rs | 71 +++++++--- docs/ui-parity.md | 6 +- 3 files changed, 148 insertions(+), 141 deletions(-) diff --git a/crates/engine/src/selection/tidy.rs b/crates/engine/src/selection/tidy.rs index a802aeccc..2450c0dcb 100644 --- a/crates/engine/src/selection/tidy.rs +++ b/crates/engine/src/selection/tidy.rs @@ -1,21 +1,24 @@ //! Tidy up: the moves that even a selection's gaps the way Figma's web app does. //! -//! Measured in Figma's web app on 2026-11-12 (top-level rectangles): a row, a column and a 2 x 3 -//! grid of unequal sizes, and eight runs on the gap. Figma keeps the order and the sizes of every -//! layer and keeps the first layer where it is. A row evens its horizontal gaps and keeps every Y, -//! a column the mirror image. The one gap is the most common existing gap, else the mean of the -//! gaps taken down to a whole number. A grid is tidied by columns: each column is as wide as its -//! widest layer, centred where its layers were, the columns are evened, every layer is centred in -//! its column and every column is stacked from the selection's top. Tidy up is greyed out for fewer -//! than three layers and for layers that overlap. +//! Measured in Figma's web app on 2026-11-12 (top-level rectangles): a row, a column, a 2 x 3 grid +//! and its transpose, and many runs on the gap. Figma keeps the order and the sizes of every layer +//! and keeps the first layer where it is. Rows are the groups of layers whose Y ranges overlap +//! (transitively), columns the groups whose X ranges overlap. A row is one row group of layers +//! side by side: its horizontal gaps are evened and every Y kept; a column is the mirror image. +//! Layers of a row may overlap along X: a negative gap counts in the mean. The one gap is the most +//! common existing gap (the smaller of two equally common ones), else the mean rounded to a whole +//! number, half up. A grid (two or more rows and two or more columns, one layer in every cell) is +//! packed along the axis that has more groups: for 2 rows by 3 columns each column is as wide as +//! its widest layer, centred where its layers were, the columns are evened, every layer is +//! centred in its column and every column is stacked from the selection's top; 3 rows by 2 +//! columns is the mirror image. Tidy up is unavailable for fewer than three layers, for layers all +//! in one row and one column (overlapping each other), for a diagonal and for an irregular layout. //! -//! Not measured, FigCraft's choices: the smaller of two equally common gaps; a mean ending in .5 -//! going down; gaps a pixel apart being different gaps; a diagonal being a row; a grid with an -//! empty place; a grid always read by columns; one overlapping pair among other layers making it -//! unavailable; layers in an auto layout flow making it unavailable; the 2,000 layer cap; rotated -//! layers counting by their box; an explicit `spacing` taking two layers; no rounding in a grid -//! with Snap to pixel grid off. A selection that is none of a row, a column or a grid keeps the -//! earlier rule (the cells of the largest layer). +//! Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow +//! making it unavailable (the frame places them, and the measurement could not tell this from an +//! irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel +//! apart being different gaps; an explicit `spacing` taking two layers; no rounding in a grid with +//! Snap to pixel grid off. use std::collections::BTreeSet; @@ -30,33 +33,34 @@ const MAX_LAYERS: usize = 2000; type Item = (NodeId, Rect); type Move = (NodeId, f64, f64); +/// What a selection is: a row or a column (`vertical`), or a grid packed in units along an axis +/// (`vertical`: the units are rows). +#[derive(Clone, Copy)] +enum Shape { + Line { vertical: bool }, + Grid { vertical: bool }, +} + /// Whether Tidy up is offered for `ids`: three to 2,000 layers with boxes, none in an auto layout -/// flow, none overlapping another. +/// flow, forming a row, a column or a grid. pub fn can_tidy(doc: &Document, ids: &[NodeId]) -> bool { available(doc, ids, 3).is_some() } /// The page-space moves that tidy `ids` (empty when unavailable or when nothing would move). -/// `spacing` sets the gap and lets two layers through; `snap` rounds a grid's column positions to -/// whole pixels. +/// `spacing` sets the gap and lets two layers through; `snap` rounds a grid's positions across its +/// packing axis to whole pixels. pub(crate) fn tidy_moves(doc: &Document, ids: &[NodeId], spacing: Option, snap: bool) -> Vec { let min = if spacing.is_some() { 2 } else { 3 }; - let Some(items) = available(doc, ids, min) else { return Vec::new() }; - let cols = groups(&items, false); - let rows = groups(&items, true); - let moves = if cols.len() == items.len() { - line_moves(&items, false, spacing) - } else if rows.len() == items.len() { - line_moves(&items, true, spacing) - } else if is_grid(&cols, &rows) { - grid_moves(&cols, spacing, snap) - } else { - irregular_moves(items, spacing) + let Some((items, shape)) = available(doc, ids, min) else { return Vec::new() }; + let moves = match shape { + Shape::Line { vertical } => line_moves(&items, vertical, spacing), + Shape::Grid { vertical } => grid_moves(&groups(&items, vertical), vertical, spacing, snap), }; moves.into_iter().filter(|(_, dx, dy)| !(dx.is_finite() && dy.is_finite()) || dx.abs() > 1e-9 || dy.abs() > 1e-9).collect() } -fn available(doc: &Document, ids: &[NodeId], min: usize) -> Option> { +fn available(doc: &Document, ids: &[NodeId], min: usize) -> Option<(Vec, Shape)> { let mut seen = BTreeSet::new(); let ids: Vec = roots(doc, ids).into_iter().filter(|i| seen.insert(*i)).collect(); if ids.len() < min || ids.len() > MAX_LAYERS { @@ -74,13 +78,30 @@ fn available(doc: &Document, ids: &[NodeId], min: usize) -> Option> { } items.push((id, b)); } - let overlapping = items.iter().enumerate().any(|(i, a)| items.iter().skip(i + 1).any(|b| overlap(a.1, b.1))); - if overlapping { None } else { Some(items) } + let shape = classify(&items)?; + Some((items, shape)) } -/// Two boxes overlap when they share more than `EPS` on both axes; touching edges do not count. -fn overlap(a: Rect, b: Rect) -> bool { - a.x1.min(b.x1) - a.x0.max(b.x0) > EPS && a.y1.min(b.y1) - a.y0.max(b.y0) > EPS +/// A row: one row group over two or more column groups (layers sharing a column group overlap). +/// A column: the mirror image. A grid: two or +/// more of each, one layer in every cell, packed along the axis with more groups (columns on a tie). +fn classify(items: &[Item]) -> Option { + let cols = groups(items, false); + let rows = groups(items, true); + match (cols.len(), rows.len()) { + (c, 1) if c >= 2 && cols.iter().all(|g| overlapping(g)) => Some(Shape::Line { vertical: false }), + (1, r) if r >= 2 && rows.iter().all(|g| overlapping(g)) => Some(Shape::Line { vertical: true }), + (c, r) if c >= 2 && r >= 2 && one_per_cell(&cols, &rows) => Some(Shape::Grid { vertical: r > c }), + _ => None, + } +} + +/// Layers that share a group along a row's or column's own axis must overlap each other (50 px +/// squares at X 0, 60, 105, 170: the middle two do), not merely sit in the same band: a header +/// above two layers side by side is irregular. +fn overlapping(group: &[Item]) -> bool { + let both = |a: Rect, b: Rect| a.x1.min(b.x1) - a.x0.max(b.x0) > EPS && a.y1.min(b.y1) - a.y0.max(b.y0) > EPS; + group.iter().enumerate().all(|(i, a)| group.iter().skip(i + 1).all(|b| both(a.1, b.1))) } fn span(b: Rect, vertical: bool) -> (f64, f64) { @@ -110,13 +131,13 @@ fn groups(items: &[Item], vertical: bool) -> Vec> { out } -/// No two layers share both a column and a row. -fn is_grid(cols: &[Vec], rows: &[Vec]) -> bool { - cols.iter().all(|c| rows.iter().all(|r| c.iter().filter(|a| r.iter().any(|b| b.0 == a.0)).count() <= 1)) +/// Every (column, row) cell holds exactly one layer. +fn one_per_cell(cols: &[Vec], rows: &[Vec]) -> bool { + cols.iter().all(|c| rows.iter().all(|r| c.iter().filter(|a| r.iter().any(|b| b.0 == a.0)).count() == 1)) } -/// The one gap an axis is evened to: `spacing`, a single gap, the most common gap, else the mean -/// taken down to a whole number. +/// The one gap an axis is evened to: `spacing`, a single gap, the most common gap (the smaller of +/// two equally common ones), else the mean rounded to a whole number, half up. fn even_gap(gaps: &[f64], spacing: Option) -> f64 { if let Some(s) = spacing { return s; @@ -129,7 +150,7 @@ fn even_gap(gaps: &[f64], spacing: Option) -> f64 { match best { Some((n, g)) if n >= 2 => g, _ if gaps.is_empty() => 0.0, - _ => (gaps.iter().sum::() / gaps.len() as f64 + 1e-9).floor(), + _ => (gaps.iter().sum::() / gaps.len() as f64 + 0.5 + 1e-9).floor(), } } @@ -161,31 +182,34 @@ fn snap_to(v: f64, snap: bool) -> f64 { if snap { (v + 0.5).floor() } else { v } } -/// A column box: where it starts and how wide it is (its widest layer, around the middle of the -/// column's X range). -fn column_box(col: &[Item]) -> (f64, f64) { - let lo = col.iter().map(|i| i.1.x0).fold(f64::MAX, f64::min); - let hi = col.iter().map(|i| i.1.x1).fold(f64::MIN, f64::max); - let w = col.iter().map(|i| i.1.width()).fold(0.0, f64::max); +/// A unit's box on the packing axis: where it starts and how wide it is (its widest layer, around +/// the middle of the unit's range). +fn unit_box(unit: &[Item], vertical: bool) -> (f64, f64) { + let lo = unit.iter().map(|i| span(i.1, vertical).0).fold(f64::MAX, f64::min); + let hi = unit.iter().map(|i| span(i.1, vertical).1).fold(f64::MIN, f64::max); + let w = unit.iter().map(|i| span(i.1, vertical).1 - span(i.1, vertical).0).fold(0.0, f64::max); ((lo + hi) / 2.0 - w / 2.0, w) } -/// Vertical gaps inside every column, pooled. -fn column_gaps(cols: &[Vec]) -> Vec { +/// The gaps inside every unit along the other axis, pooled. +fn unit_gaps(units: &[Vec], vertical: bool) -> Vec { let mut out = Vec::new(); - for col in cols { - let mut c = col.clone(); - c.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); - out.extend(c.windows(2).filter_map(|w| match w { - [a, b] => Some(b.1.y0 - a.1.y1), + for unit in units { + let mut u = unit.clone(); + u.sort_by(|a, b| span(a.1, !vertical).0.total_cmp(&span(b.1, !vertical).0)); + out.extend(u.windows(2).filter_map(|w| match w { + [a, b] => Some(span(b.1, !vertical).0 - span(a.1, !vertical).1), _ => None, })); } out } -fn grid_moves(cols: &[Vec], spacing: Option, snap: bool) -> Vec { - let boxes: Vec<(f64, f64)> = cols.iter().map(|c| column_box(c)).collect(); +/// A grid packed in `units` (columns, or rows when `vertical`): the units are evened around where +/// they were, every layer is centred in its unit and each unit is laid out from the selection's +/// start on the other axis. +fn grid_moves(units: &[Vec], vertical: bool, spacing: Option, snap: bool) -> Vec { + let boxes: Vec<(f64, f64)> = units.iter().map(|u| unit_box(u, vertical)).collect(); let across: Vec = boxes .windows(2) .filter_map(|w| match w { @@ -193,68 +217,24 @@ fn grid_moves(cols: &[Vec], spacing: Option, snap: bool) -> Vec _ => None, }) .collect(); - let gx = even_gap(&across, spacing); - let gy = even_gap(&column_gaps(cols), spacing); - let top = cols.iter().flatten().map(|i| i.1.y0).fold(f64::MAX, f64::min); - let mut left = boxes.first().map_or(0.0, |b| snap_to(b.0, snap)); + let g_units = even_gap(&across, spacing); + let g_within = even_gap(&unit_gaps(units, vertical), spacing); + let start = units.iter().flatten().map(|i| span(i.1, !vertical).0).fold(f64::MAX, f64::min); + let mut at = boxes.first().map_or(0.0, |b| snap_to(b.0, snap)); let mut out = Vec::new(); - for (col, (_, w)) in cols.iter().zip(&boxes) { - let mut c = col.clone(); - c.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); - let mut y = top; - for (id, b) in c { - let x = snap_to(left + (w - b.width()) / 2.0, snap); - out.push((id, x - b.x0, y - b.y0)); - y += b.height() + gy; + for (unit, (_, w)) in units.iter().zip(&boxes) { + let mut u = unit.clone(); + u.sort_by(|a, b| span(a.1, !vertical).0.total_cmp(&span(b.1, !vertical).0)); + let mut pos = start; + for (id, b) in u { + let (lo, hi) = span(b, vertical); + let (lo2, hi2) = span(b, !vertical); + let d_main = snap_to(at + (w - (hi - lo)) / 2.0, snap) - lo; + let d_cross = pos - lo2; + pos += (hi2 - lo2) + g_within; + out.push(if vertical { (id, d_cross, d_main) } else { (id, d_main, d_cross) }); } - left += w + gx; + at += w + g_units; } out } - -/// The earlier rule, unchanged: rows by overlapping vertical centres on a grid whose every cell is -/// the largest layer's size, from the union's top left, at the average horizontal gap (at least 10). -fn irregular_moves(mut items: Vec, spacing: Option) -> Vec { - items.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0)); - let mut rows: Vec> = Vec::new(); - for it in items { - match rows.last_mut() { - Some(row) if row.iter().any(|(_, b)| it.1.center().y < b.y1 && it.1.center().y > b.y0) => row.push(it), - _ => rows.push(vec![it]), - } - } - let all: Vec = rows.iter().flatten().map(|(_, b)| *b).collect(); - let (Some(origin), maxw, maxh) = ( - all.iter().copied().reduce(|a, b| a.union(b)), - all.iter().map(Rect::width).fold(0.0, f64::max), - all.iter().map(Rect::height).fold(0.0, f64::max), - ) else { - return Vec::new(); - }; - let gap = spacing.unwrap_or_else(|| average_gap(&rows)); - let mut out = Vec::new(); - for (ri, row) in rows.iter_mut().enumerate() { - row.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); - for (ci, (id, b)) in row.iter().enumerate() { - let x = origin.x0 + ci as f64 * (maxw + gap); - let y = origin.y0 + ri as f64 * (maxh + gap); - out.push((*id, x - b.x0, y - b.y0)); - } - } - out -} - -/// The average existing horizontal gap of the widest row, at least 10. -fn average_gap(rows: &[Vec]) -> f64 { - 10f64.max( - rows.iter() - .filter(|r| r.len() > 1) - .map(|r| { - (r.iter().map(|(_, b)| b.x1).fold(f64::MIN, f64::max) - - r.iter().map(|(_, b)| b.x0).fold(f64::MAX, f64::min) - - r.iter().map(|(_, b)| b.width()).sum::()) - / (r.len() - 1) as f64 - }) - .fold(0.0, f64::max), - ) -} diff --git a/crates/engine/src/tidy_tests.rs b/crates/engine/src/tidy_tests.rs index 41eff8d2c..79a349f61 100644 --- a/crates/engine/src/tidy_tests.rs +++ b/crates/engine/src/tidy_tests.rs @@ -107,17 +107,41 @@ fn the_gap_is_the_most_common_one_else_the_mean() { } } +/// Within a pixel: Figma read 52 for the vertical gap in one column and 51 in two (the mean of 60, +/// 40, 55 is 51.67), probably from pixel snapping, so a layer's place along a stack may differ by one. +fn near(got: &[(f64, f64)], want: &[(f64, f64)]) { + assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(want) { + assert!((g.0 - w.0).abs() <= 1.0 && (g.1 - w.1).abs() <= 1.0, "{got:?} vs {want:?}"); + } +} + #[test] fn a_grid_is_tidied_by_columns() { let mut s = Session::new(); let ids = grid(&mut s); tidy(&mut s, &ids); - assert_eq!(all_xy(&s, &ids), vec![(6., -3.), (93., -3.), (203., -3.), (1., 78.), (108., 98.), (195., 88.)]); + near(&all_xy(&s, &ids), &[(6., -3.), (93., -3.), (203., -3.), (1., 79.), (108., 98.), (195., 88.)]); assert_eq!(size(&s, ids[4]), (40., 50.)); } #[test] -fn two_layers_and_overlapping_layers_are_left_alone() { +fn a_transposed_grid_is_tidied_by_rows() { + let mut s = Session::new(); + let ids = vec![ + make(&mut s, 0., 0., 30., 50.), + make(&mut s, 5., 80., 50., 70.), + make(&mut s, -3., 190., 40., 40.), + make(&mut s, 90., 2., 20., 60.), + make(&mut s, 95., 85., 50., 40.), + make(&mut s, 92., 200., 35., 55.), + ]; + tidy(&mut s, &ids); + near(&all_xy(&s, &ids), &[(-3., 6.), (-3., 93.), (-3., 203.), (78., 1.), (98., 108.), (88., 195.)]); +} + +#[test] +fn two_layers_and_a_selection_in_one_cell_are_left_alone() { let mut s = Session::new(); let two = squares(&mut s, &[0., 87.]); let before = (all_xy(&s, &two), s.undo_depth()); @@ -149,31 +173,33 @@ fn two_equally_common_gaps_give_the_smaller() { let mut s = Session::new(); let ids = squares(&mut s, &[0., 60., 120., 220., 320.]); tidy(&mut s, &ids); - assert_eq!(xs_of(&s, &ids), vec![0., 60., 120., 180., 240.], "not measured in Figma: a tie takes the smaller gap"); + assert_eq!(xs_of(&s, &ids), vec![0., 60., 120., 180., 240.]); } #[test] -fn a_mean_is_taken_down_not_rounded() { +fn a_mean_is_rounded_half_up() { let mut s = Session::new(); let ids = squares(&mut s, &[0., 60., 135.]); tidy(&mut s, &ids); - assert_eq!(xs_of(&s, &ids), vec![0., 67., 134.], "not measured in Figma: a mean of 17.5 goes down"); + assert_eq!(xs_of(&s, &ids), vec![0., 68., 136.], "a mean of 17.5 goes up"); let mut s = Session::new(); let ids = squares(&mut s, &[0., 60., 135., 215.]); tidy(&mut s, &ids); - assert_eq!(xs_of(&s, &ids), vec![0., 71., 142., 213.], "not measured in Figma: a mean of 21.67 goes down"); + assert_eq!(xs_of(&s, &ids), vec![0., 72., 144., 216.], "a mean of 21.67 goes to 22"); } #[test] -fn layers_on_a_diagonal_are_a_row() { +fn layers_on_a_diagonal_are_unavailable() { let mut s = Session::new(); let ids = vec![make(&mut s, 0., 0., 50., 50.), make(&mut s, 60., 70., 50., 50.), make(&mut s, 140., 150., 50., 50.)]; + let before = (all_xy(&s, &ids), s.undo_depth()); tidy(&mut s, &ids); - assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (70., 70.), (140., 150.)], "not measured in Figma: a diagonal is a row"); + assert_eq!((all_xy(&s, &ids), s.undo_depth()), before); + assert!(!can(&s, &ids)); } #[test] -fn a_grid_with_an_empty_place_stacks_each_column() { +fn a_grid_with_an_empty_place_is_unavailable() { let mut s = Session::new(); let ids = vec![ make(&mut s, 0., 0., 50., 50.), @@ -182,18 +208,19 @@ fn a_grid_with_an_empty_place_stacks_each_column() { make(&mut s, 0., 80., 50., 50.), make(&mut s, 72., 90., 50., 50.), ]; + let before = (all_xy(&s, &ids), s.undo_depth()); tidy(&mut s, &ids); - assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (75., 0.), (150., 0.), (0., 85.), (75., 85.)], "not measured in Figma: an empty place in a grid"); + assert_eq!((all_xy(&s, &ids), s.undo_depth()), before, "not measured in Figma: an empty place in a grid"); + assert!(!can(&s, &ids), "not measured in Figma: an empty place in a grid"); } #[test] -fn one_overlapping_pair_makes_tidy_up_unavailable() { +fn an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts() { let mut s = Session::new(); let ids = squares(&mut s, &[0., 60., 105., 170.]); - let before = (xs_of(&s, &ids), s.undo_depth()); + assert!(can(&s, &ids)); tidy(&mut s, &ids); - assert_eq!((xs_of(&s, &ids), s.undo_depth()), before, "not measured in Figma: one overlapping pair"); - assert!(!can(&s, &ids), "not measured in Figma: one overlapping pair"); + assert_eq!(xs_of(&s, &ids), vec![0., 57., 114., 171.], "gaps 10, -5, 15 have the mean 6.67, which gives 7"); } #[test] @@ -224,11 +251,13 @@ fn layers_in_an_auto_layout_flow_are_left_alone() { } #[test] -fn an_irregular_selection_keeps_the_earlier_rule() { +fn an_irregular_selection_is_unavailable() { let mut s = Session::new(); let ids = vec![make(&mut s, 0., 0., 200., 30.), make(&mut s, 0., 50., 90., 50.), make(&mut s, 110., 50., 90., 50.)]; + let before = (all_xy(&s, &ids), s.undo_depth()); tidy(&mut s, &ids); - assert_eq!(all_xy(&s, &ids), vec![(0., 0.), (0., 70.), (220., 70.)]); + assert_eq!((all_xy(&s, &ids), s.undo_depth()), before); + assert!(!can(&s, &ids)); } #[test] @@ -250,11 +279,9 @@ fn without_pixel_snapping_a_centred_layer_keeps_its_half_pixel() { s.execute("view.snapPixel", &json!({"on": false})).unwrap(); let ids = grid(&mut s); tidy(&mut s, &ids); - assert_eq!( - all_xy(&s, &ids), - vec![(6., -3.), (93., -3.), (202.5, -3.), (1., 78.), (108., 98.), (195., 88.)], - "not measured in Figma: no rounding with Snap to pixel grid off" - ); + let got = all_xy(&s, &ids); + assert_eq!(got.get(2).map(|p| p.0), Some(202.5), "not measured in Figma: no rounding with Snap to pixel grid off"); + near(&got, &[(6., -3.), (93., -3.), (202.5, -3.), (1., 79.), (108., 98.), (195., 88.)]); } #[test] @@ -267,7 +294,7 @@ fn a_rotated_layer_is_tidied_by_its_box() { let boxes = |s: &Session| -> Vec { [a, m, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap()).collect() }; let b = boxes(&s); let (g1, g2) = (b[1].x0 - b[0].x1, b[2].x0 - b[1].x1); - let want = ((g1 + g2) / 2.0).floor(); + let want = ((g1 + g2) / 2.0 + 0.5).floor(); let rot = figcraft_geom::rotation_degrees(s.doc().unwrap().node(m).unwrap().transform); tidy(&mut s, &[a, m, c]); let b = boxes(&s); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b37f7ad15..4c40b553a 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-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, and is greyed out for fewer than three layers and for overlapping layers (M8); the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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: @@ -83,7 +83,7 @@ interaction feel not yet compared. | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Edit ▸ Set default properties: new layers take the selected layer's properties | done | from a shape (any layer but text): new rectangles, ellipses, polygons and stars drawn or clicked with their tools take its fills, strokes and their settings, effects, opacity, blend and corners; from a text layer: new text from the Text tool takes its fills and font (#2028). Silent in the help center on frames, lines and arrows: they keep their own look, as do layers made by commands; the defaults last for the session | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | -| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes, eight runs on the gap): the order, the sizes and the other axis are kept and the first layer stays. A row evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap, else the mean taken down to a whole number (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; the grid's 60, 40, 55 gives 51). A grid is tidied by columns: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top. Unavailable for fewer than three layers and for overlapping layers, as measured: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: the smaller of two equally common gaps; a mean ending in .5 going down; gaps a pixel apart being different gaps; a diagonal being a row; a grid with an empty place (each column is still stacked); a grid always read by columns; one overlapping pair among other layers making it unavailable; layers in an auto layout flow making it unavailable (the frame places them); the 2,000 layer cap; rotated layers counting by their box; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. A selection that is none of a row, a column or a grid keeps the earlier rule. Tests: tidy_tests | +| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes and its transpose, and many runs on the gap, the unavailable cases among them): the order, the sizes and the other axis are kept and the first layer stays. A row (one group of layers whose Y ranges overlap, side by side) evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap (the smaller of two equally common ones), else the mean rounded to a whole number, half up (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; 10, 25 gives 18; 10, 25, 30 gives 22). Layers of a row may overlap along X: the negative gap counts in the mean (50 px squares at X 0, 60, 105, 170, gaps 10, -5, 15, give X 0, 57, 114, 171). A grid (two or more rows and columns, one layer in every cell) is packed along the axis with more groups: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top; a grid with more rows than columns is the mirror image (centred rows, laid out left to right). The vertical gap reads 51 or 52 in Figma's own runs (mean 51.67), so FigCraft's positions along a stack can differ by a pixel there. Unavailable, as measured, for fewer than three layers, for layers that all overlap each other (one row and one column), for a diagonal and for an irregular layout: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow being unavailable (the frame places them; the measurement could not tell this from an irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel apart being different gaps; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. Tests: tidy_tests | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also offers enabled libraries' published color styles and Create style (`selection.createColorStyle`); no library variables and no new variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance; a layer without children shows it only with two paint groups, such as a red fill and a blue stroke; one paint group, however many layers share it, hides it), counting the selected layers' own fills and strokes and their descendants' (measured in Figma's web app, 2026-11-09, #2188, on frames, groups and rectangles only; the rule for components and instances is not measured), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | @@ -218,7 +218,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, else the mean taken down) and keeps the other axis, a grid is tidied by columns, and fewer than three layers or overlapping layers grey the row out and move nothing. Measured: those rules and the greyed-out cases; FigCraft's choices: ties, a mean ending in .5, a diagonal, a grid with an empty place, one overlapping pair, auto layout flow children, the 2,000 layer cap, rotated layers, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_column_keeps_x_and_evens_the_vertical_gaps, the_gap_is_the_most_common_one_else_the_mean, a_grid_is_tidied_by_columns, two_layers_and_overlapping_layers_are_left_alone, a_tidy_that_moves_nothing_adds_no_undo_step, an_irregular_selection_keeps_the_earlier_rule and the choices). | +| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X, and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). | | 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` | | 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) | | 2026-11-09 | patch | M3, #2189: the eyedropper picks the colour the canvas shows. It sampled a 1x1 render without the canvas's magnified-image pixels, so on an image magnified past 2x it blended a 1 px line with its neighbours (#3D444D picked as #343B44 beside #151B23), and the pixel it sampled began at the pointer instead of being centred on it. It now matches the canvas when zoomed in; zoomed out it still samples the full-resolution image, not the smaller copy the canvas may show. Test: `eyedropper_image_tests` | From a7a9e627215c5185c1e26e63c955c09014a576a1 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 13 Nov 2026 00:00:02 -0500 Subject: [PATCH 3/5] M8: Tidy up takes a row whose layers overlap in a chain, and raises a negative gap so no layer reaches the one two before it, as in Figma Measured in Figma's web app on 2026-11-13. Co-Authored-By below. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 measured and wrote at medium --- crates/engine/src/selection/tidy.rs | 46 ++++++++++++++++++------ crates/engine/src/tidy_tests.rs | 54 +++++++++++++++++++++++++++++ crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/src/panels/right.rs | 6 ++-- crates/ui-egui/src/tidy_tests.rs | 2 +- docs/ui-parity.md | 4 +-- 6 files changed, 98 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/selection/tidy.rs b/crates/engine/src/selection/tidy.rs index 2450c0dcb..e5b9ada25 100644 --- a/crates/engine/src/selection/tidy.rs +++ b/crates/engine/src/selection/tidy.rs @@ -5,7 +5,7 @@ //! and keeps the first layer where it is. Rows are the groups of layers whose Y ranges overlap //! (transitively), columns the groups whose X ranges overlap. A row is one row group of layers //! side by side: its horizontal gaps are evened and every Y kept; a column is the mirror image. -//! Layers of a row may overlap along X: a negative gap counts in the mean. The one gap is the most +//! Layers of a row may overlap along X, also in a chain: a negative gap counts in the mean. The one gap is the most //! common existing gap (the smaller of two equally common ones), else the mean rounded to a whole //! number, half up. A grid (two or more rows and two or more columns, one layer in every cell) is //! packed along the axis that has more groups: for 2 rows by 3 columns each column is as wide as @@ -89,19 +89,25 @@ fn classify(items: &[Item]) -> Option { let cols = groups(items, false); let rows = groups(items, true); match (cols.len(), rows.len()) { - (c, 1) if c >= 2 && cols.iter().all(|g| overlapping(g)) => Some(Shape::Line { vertical: false }), - (1, r) if r >= 2 && rows.iter().all(|g| overlapping(g)) => Some(Shape::Line { vertical: true }), + (c, 1) if c >= 2 && cols.iter().all(|g| overlapping(g, false)) => Some(Shape::Line { vertical: false }), + (1, r) if r >= 2 && rows.iter().all(|g| overlapping(g, true)) => Some(Shape::Line { vertical: true }), (c, r) if c >= 2 && r >= 2 && one_per_cell(&cols, &rows) => Some(Shape::Grid { vertical: r > c }), _ => None, } } -/// Layers that share a group along a row's or column's own axis must overlap each other (50 px -/// squares at X 0, 60, 105, 170: the middle two do), not merely sit in the same band: a header -/// above two layers side by side is irregular. -fn overlapping(group: &[Item]) -> bool { - let both = |a: Rect, b: Rect| a.x1.min(b.x1) - a.x0.max(b.x0) > EPS && a.y1.min(b.y1) - a.y0.max(b.y0) > EPS; - group.iter().enumerate().all(|(i, a)| group.iter().skip(i + 1).all(|b| both(a.1, b.1))) +/// Layers that share a group along a row's (`vertical` false) or column's own axis must overlap +/// each other pairwise on the OTHER axis, not merely sit in the same band: a header above two +/// layers side by side is irregular (a row's column group of the header and one of the pair has Y +/// ranges that don't overlap for the pair). The own axis is already covered by `groups`, which +/// chains transitively, so 50 px squares at X 0, 40, 80, 150 are a row although the first and +/// third don't touch. +fn overlapping(group: &[Item], vertical: bool) -> bool { + let across = |a: Rect, b: Rect| { + let (a, b) = (span(a, !vertical), span(b, !vertical)); + a.1.min(b.1) - a.0.max(b.0) > EPS + }; + group.iter().enumerate().all(|(i, a)| group.iter().skip(i + 1).all(|b| across(a.1, b.1))) } fn span(b: Rect, vertical: bool) -> (f64, f64) { @@ -154,6 +160,22 @@ fn even_gap(gaps: &[f64], spacing: Option) -> f64 { } } +/// The even gap of a line of three or more layers, from its derived gap `gap`: raised so that +/// every layer starts strictly after the end of the layer two places before it. With one gap `g`, +/// layer `i + 2` starts at `end(i) + w(i + 1) + 2g`, so `g > -w(i + 1) / 2` for every interior +/// layer, in whole pixels `floor(-w_min / 2) + 1` for the narrowest interior size `w_min`. Measured +/// in Figma's web app on 2026-11-13: interior widths of 10, 20 and 30 among 100s gave -4, -9 and +/// -14, and widths of 100 left the most common gap, -40, alone. Only those three widths were +/// measured; other widths follow the same formula. +fn raise_negative_gap(gap: f64, widths: &[f64]) -> f64 { + let interior = widths.get(1..widths.len().saturating_sub(1)).unwrap_or(&[]); + let w_min = interior.iter().copied().fold(f64::INFINITY, f64::min); + if widths.len() < 3 || !w_min.is_finite() { + return gap; + } + gap.max((-w_min / 2.0).floor() + 1.0) +} + /// A row (`vertical` false) or a column: the first layer stays, each next one follows the last at /// the even gap, the other axis is untouched. fn line_moves(items: &[Item], vertical: bool, spacing: Option) -> Vec { @@ -166,7 +188,11 @@ fn line_moves(items: &[Item], vertical: bool, spacing: Option) -> Vec _ => None, }) .collect(); - let gap = even_gap(&gaps, spacing); + let widths: Vec = line.iter().map(|i| span(i.1, vertical).1 - span(i.1, vertical).0).collect(); + let gap = match spacing { + Some(s) => s, + None => raise_negative_gap(even_gap(&gaps, None), &widths), + }; let mut pos = line.first().map_or(0.0, |i| span(i.1, vertical).0); let mut out = Vec::with_capacity(line.len()); for (id, b) in line { diff --git a/crates/engine/src/tidy_tests.rs b/crates/engine/src/tidy_tests.rs index 79a349f61..69c52e310 100644 --- a/crates/engine/src/tidy_tests.rs +++ b/crates/engine/src/tidy_tests.rs @@ -304,3 +304,57 @@ fn a_rotated_layer_is_tidied_by_its_box() { let after = figcraft_geom::rotation_degrees(s.doc().unwrap().node(m).unwrap().transform); assert!((rot - after).abs() < 1e-9, "rotation unchanged"); } + +/// Rectangles at Y 0, H 50 with the given (X, W) pairs. +fn bars(s: &mut Session, xw: &[(f64, f64)]) -> Vec { + xw.iter().map(|(x, w)| make(s, *x, 0.0, *w, 50.0)).collect() +} + +#[test] +fn a_row_whose_layers_overlap_in_a_chain_is_tidied_at_its_most_common_gap() { + // Measured in Figma's web app on 2026-11-13: gaps -10, -10, 20 give X 0, 40, 80, 120. + let mut s = Session::new(); + let ids = squares(&mut s, &[0., 40., 80., 150.]); + assert!(can(&s, &ids), "the first and third layers don't overlap, the chain still counts as a row"); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 40., 80., 120.]); +} + +#[test] +fn a_negative_gap_is_raised_so_no_layer_reaches_the_one_two_before() { + // Measured in Figma's web app on 2026-11-13, X before 0, 80, 200, 260, 400, 460. + let cases: [([f64; 6], [f64; 6]); 4] = [ + ([100., 10., 100., 100., 100., 100.], [0., 96., 102., 198., 294., 390.]), + ([100., 20., 100., 100., 100., 100.], [0., 91., 102., 193., 284., 375.]), + ([100., 30., 100., 100., 100., 100.], [0., 86., 102., 188., 274., 360.]), + ([100., 100., 100., 10., 100., 100.], [0., 96., 192., 288., 294., 390.]), + ]; + for (widths, want) in cases { + let mut s = Session::new(); + let xs = [0., 80., 200., 260., 400., 460.]; + let pairs: Vec<(f64, f64)> = xs.iter().copied().zip(widths).collect(); + let ids = bars(&mut s, &pairs); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), want.to_vec(), "widths {widths:?}"); + } +} + +#[test] +fn a_negative_gap_between_equal_widths_is_the_most_common_gap() { + let mut s = Session::new(); + let xs = [0., 80., 200., 260., 400., 460.]; + let pairs: Vec<(f64, f64)> = xs.iter().map(|x| (*x, 100.)).collect(); + let ids = bars(&mut s, &pairs); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 60., 120., 180., 240., 300.]); +} + +#[test] +fn an_explicit_spacing_is_not_raised() { + // Not measured in Figma: the Spacing field stays as typed. + let mut s = Session::new(); + let pairs: Vec<(f64, f64)> = [(0., 100.), (80., 10.), (200., 100.)].to_vec(); + let ids = bars(&mut s, &pairs); + s.execute("arrange.tidy", &json!({"ids": ids, "spacing": -40})).unwrap(); + assert_eq!(xs_of(&s, &ids), vec![0., 60., 30.]); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 91b215f21..ea99e26ab 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1622,7 +1622,7 @@ name = "English" "This version can't be read." = "This version can't be read." "This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" -"Tidy up needs three or more layers that don't overlap" = "Tidy up needs three or more layers that don't overlap" +"Select a row, a column or a full grid of three or more layers, outside an auto layout flow" = "Select a row, a column or a full grid of three or more layers, outside an auto layout flow" "Tile" = "Tile" "Tile type" = "Tile type" "Timer" = "Timer" diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f054c5b78..b931d7e63 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -657,7 +657,8 @@ pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc, sel: &[N }) } -/// Whether the Design panel offers Tidy up for `sel` (three or more layers that don't overlap). +/// Whether the Design panel offers Tidy up for `sel`: three or more layers forming a row, a column +/// or a full grid, none in an auto layout flow (`can_tidy`). pub(crate) fn tidy_available(ctx: &egui::Context, doc: &Arc, sel: &[NodeId]) -> bool { crate::selection_cache::cached(ctx, "tidy", doc, sel, &[], || figcraft_engine::tidy::can_tidy(doc, sel)) } @@ -711,7 +712,8 @@ fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col ] { let item = ui.add_enabled_ui(on, |ui| menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or(""))); if !on { - item.response.on_disabled_hover_text(tr("Tidy up needs three or more layers that don't overlap")); + item.response + .on_disabled_hover_text(tr("Select a row, a column or a full grid of three or more layers, outside an auto layout flow")); } if item.inner { app.run(c, json!({"ids": ids})); diff --git a/crates/ui-egui/src/tidy_tests.rs b/crates/ui-egui/src/tidy_tests.rs index 971b07dac..26d0d2146 100644 --- a/crates/ui-egui/src/tidy_tests.rs +++ b/crates/ui-egui/src/tidy_tests.rs @@ -1,5 +1,5 @@ //! The Design panel's Tidy up row follows the engine's rule (measured in Figma's web app on -//! 2026-11-12): offered for three or more layers that don't overlap, greyed out otherwise. +//! 2026-11-12): offered for three or more layers forming a row, a column or a full grid, greyed out otherwise. use egui::Context; use figcraft_doc::NodeId; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ee547a5b9..af1fdb89b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -83,7 +83,7 @@ interaction feel only partly compared: the interactions measured so far, case by | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Edit ▸ Set default properties: new layers take the selected layer's properties | done | from a shape (any layer but text): new rectangles, ellipses, polygons and stars drawn or clicked with their tools take its fills, strokes and their settings, effects, opacity, blend and corners; from a text layer: new text from the Text tool takes its fills and font (#2028). Silent in the help center on frames, lines and arrows: they keep their own look, as do layers made by commands; the defaults last for the session | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | -| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes and its transpose, and many runs on the gap, the unavailable cases among them): the order, the sizes and the other axis are kept and the first layer stays. A row (one group of layers whose Y ranges overlap, side by side) evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap (the smaller of two equally common ones), else the mean rounded to a whole number, half up (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; 10, 25 gives 18; 10, 25, 30 gives 22). Layers of a row may overlap along X: the negative gap counts in the mean (50 px squares at X 0, 60, 105, 170, gaps 10, -5, 15, give X 0, 57, 114, 171). A grid (two or more rows and columns, one layer in every cell) is packed along the axis with more groups: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top; a grid with more rows than columns is the mirror image (centred rows, laid out left to right). The vertical gap reads 51 or 52 in Figma's own runs (mean 51.67), so FigCraft's positions along a stack can differ by a pixel there. Unavailable, as measured, for fewer than three layers, for layers that all overlap each other (one row and one column), for a diagonal and for an irregular layout: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow being unavailable (the frame places them; the measurement could not tell this from an irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel apart being different gaps; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. Tests: tidy_tests | +| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes and its transpose, and many runs on the gap, the unavailable cases among them): the order, the sizes and the other axis are kept and the first layer stays. A row (one group of layers whose Y ranges overlap, side by side) evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap (the smaller of two equally common ones), else the mean rounded to a whole number, half up (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; 10, 25 gives 18; 10, 25, 30 gives 22). Layers of a row may overlap along X, and overlapping layers in a chain count as a row (the first and third needn't touch): the negative gap counts in the mean (50 px squares at X 0, 60, 105, 170, gaps 10, -5, 15, give X 0, 57, 114, 171; squares at X 0, 40, 80, 150 give 0, 40, 80, 120). Measured in Figma's web app on 2026-11-13: a negative gap is raised so no layer reaches the one two places before it, floor(-w / 2) + 1 for the narrowest interior layer's width w, so 10, 20 and 30 wide interior layers among 100s give -4, -9 and -14 (X 0, 96, 102, 198, 294, 390 for widths 100, 10, 100 x4), while six 100 wide layers keep the most common gap, -40; only those widths were measured, and an explicit `spacing` is not raised. A grid (two or more rows and columns, one layer in every cell) is packed along the axis with more groups: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top; a grid with more rows than columns is the mirror image (centred rows, laid out left to right). The vertical gap reads 51 or 52 in Figma's own runs (mean 51.67), so FigCraft's positions along a stack can differ by a pixel there. Unavailable, as measured, for fewer than three layers, for layers that all overlap each other (one row and one column), for a diagonal and for an irregular layout: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow being unavailable (the frame places them; the measurement could not tell this from an irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel apart being different gaps; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. Tests: tidy_tests | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also offers enabled libraries' published color styles and Create style (`selection.createColorStyle`); no library variables and no new variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance; a layer without children shows it only with two paint groups, such as a red fill and a blue stroke; one paint group, however many layers share it, hides it), counting the selected layers' own fills and strokes and their descendants' (measured in Figma's web app, 2026-11-09, #2188, on frames, groups and rectangles only; the rule for components and instances is not measured), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | @@ -216,7 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X, and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). | +| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X (also in a chain, and a negative gap is raised so no layer reaches the one two before it, measured in Figma's web app on 2026-11-13), and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). | | 2026-11-12 | patch | M8: Tab while renaming the last layer in the Layers panel commits and opens the first row's rename, as measured in Figma's web app on 2026-11-12 (it ended the rename before); Shift+Tab on the first row is unchanged, not measured; Tab and Shift+Tab now move only the rename and leave the selection on the layer first renamed, as measured in Figma's web app on 2026-11-12 (it followed the rename before) (`tab_on_the_last_row_commits_and_wraps_to_the_first`, `tab_while_renaming_leaves_the_selection_on_the_first_layer`), Rename row | | 2026-11-09 | patch | M3, #2156: the text caret takes the text's solid fill colour (red text, red caret; light grey on a dark frame, light grey, one colour and no inversion), measured in Figma's web app: the fill of the character before the caret (the first one's at the start); FigCraft draws it opaque (how Figma treats a translucent fill was not measured); gradient, image and hidden fills (not measured) give a black caret on a light surface and a white one on a dark one (the nearest layer around the text whose top visible fill is a solid at least half opaque as drawn, counting layer opacity, else the page; a layer whose top fill is a gradient or an image is passed over), not the interface's text colour (white in the dark theme, invisible on the default light page, #2190). Width and height unchanged; collaborators' carets unchanged. Tests: `caret_tests` | | 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) | From fc90078333c77f85af38513734efcf9ffb38903f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 13 Nov 2026 00:36:40 -0500 Subject: [PATCH 4/5] Docs: Tidy up keeps the first layer and the other axis for a row or a column, not a grid The row's opening said the first layer and the other axis are always kept, but a grid centres each layer in its column and can move it on both axes. The claim is now limited to rows and columns; the order and sizes are kept for all three. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index af1fdb89b..77686fb2b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -83,7 +83,7 @@ interaction feel only partly compared: the interactions measured so far, case by | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Edit ▸ Set default properties: new layers take the selected layer's properties | done | from a shape (any layer but text): new rectangles, ellipses, polygons and stars drawn or clicked with their tools take its fills, strokes and their settings, effects, opacity, blend and corners; from a text layer: new text from the Text tool takes its fills and font (#2028). Silent in the help center on frames, lines and arrows: they keep their own look, as do layers made by commands; the defaults last for the session | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | -| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes and its transpose, and many runs on the gap, the unavailable cases among them): the order, the sizes and the other axis are kept and the first layer stays. A row (one group of layers whose Y ranges overlap, side by side) evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap (the smaller of two equally common ones), else the mean rounded to a whole number, half up (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; 10, 25 gives 18; 10, 25, 30 gives 22). Layers of a row may overlap along X, and overlapping layers in a chain count as a row (the first and third needn't touch): the negative gap counts in the mean (50 px squares at X 0, 60, 105, 170, gaps 10, -5, 15, give X 0, 57, 114, 171; squares at X 0, 40, 80, 150 give 0, 40, 80, 120). Measured in Figma's web app on 2026-11-13: a negative gap is raised so no layer reaches the one two places before it, floor(-w / 2) + 1 for the narrowest interior layer's width w, so 10, 20 and 30 wide interior layers among 100s give -4, -9 and -14 (X 0, 96, 102, 198, 294, 390 for widths 100, 10, 100 x4), while six 100 wide layers keep the most common gap, -40; only those widths were measured, and an explicit `spacing` is not raised. A grid (two or more rows and columns, one layer in every cell) is packed along the axis with more groups: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top; a grid with more rows than columns is the mirror image (centred rows, laid out left to right). The vertical gap reads 51 or 52 in Figma's own runs (mean 51.67), so FigCraft's positions along a stack can differ by a pixel there. Unavailable, as measured, for fewer than three layers, for layers that all overlap each other (one row and one column), for a diagonal and for an irregular layout: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow being unavailable (the frame places them; the measurement could not tell this from an irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel apart being different gaps; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. Tests: tidy_tests | +| Tidy up | partial | measured in Figma's web app on 2026-11-12 (a row, a column, a 2 x 3 grid of unequal sizes and its transpose, and many runs on the gap, the unavailable cases among them): the order and the sizes are kept; in a row or a column the first layer also stays and the other axis is untouched (a grid can move a layer on both axes, as below). A row (one group of layers whose Y ranges overlap, side by side) evens its horizontal gaps and a column its vertical gaps to one gap: the most common existing gap (the smaller of two equally common ones), else the mean rounded to a whole number, half up (30, 40, 30 gives 30; 80, 20, 45 gives 48; 10, 10, 50, 90 gives 10; 10, 25 gives 18; 10, 25, 30 gives 22). Layers of a row may overlap along X, and overlapping layers in a chain count as a row (the first and third needn't touch): the negative gap counts in the mean (50 px squares at X 0, 60, 105, 170, gaps 10, -5, 15, give X 0, 57, 114, 171; squares at X 0, 40, 80, 150 give 0, 40, 80, 120). Measured in Figma's web app on 2026-11-13: a negative gap is raised so no layer reaches the one two places before it, floor(-w / 2) + 1 for the narrowest interior layer's width w, so 10, 20 and 30 wide interior layers among 100s give -4, -9 and -14 (X 0, 96, 102, 198, 294, 390 for widths 100, 10, 100 x4), while six layers 100 wide keep the most common gap, -40; only those widths were measured, and an explicit `spacing` is not raised. A grid (two or more rows and columns, one layer in every cell) is packed along the axis with more groups: a column is as wide as its widest layer around the middle of where its layers were, the first column stays, each layer is centred in its column to a whole pixel with Snap to pixel grid on, and every column is stacked from the selection's top; a grid with more rows than columns is the mirror image (centred rows, laid out left to right). The vertical gap reads 51 or 52 in Figma's own runs (mean 51.67), so FigCraft's positions along a stack can differ by a pixel there. Unavailable, as measured, for fewer than three layers, for layers that all overlap each other (one row and one column), for a diagonal and for an irregular layout: a greyed-out row with a tip in the Design panel's menu, the command moving nothing and adding no undo step. Not measured, FigCraft's choices: a 2 x 2 grid packed as columns; layers in an auto layout flow being unavailable (the frame places them; the measurement could not tell this from an irregular layout); the 2,000 layer cap; rotated layers counting by their box; gaps a pixel apart being different gaps; an explicit `spacing` setting the gap and taking two layers; no rounding with Snap to pixel grid off. Tests: tidy_tests | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also offers enabled libraries' published color styles and Create style (`selection.createColorStyle`); no library variables and no new variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | | Selection colors | partial | the Design panel shows the section from two paint groups (paints sharing a style, a variable and a value count as one group), or from one when a selected layer has children (a frame, group, component or instance; a layer without children shows it only with two paint groups, such as a red fill and a blue stroke; one paint group, however many layers share it, hides it), counting the selected layers' own fills and strokes and their descendants' (measured in Figma's web app, 2026-11-09, #2188, on frames, groups and rectangles only; the rule for components and instances is not measured), and lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | From 1a68ded58138d36a7d4fd0dcc79c6de76aeaf564 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 13 Nov 2026 01:43:34 -0500 Subject: [PATCH 5/5] M8: Tidy up keeps the second layer after a narrow first one when the gap is negative raise_negative_gap raised a negative gap from the interior widths only, so a first layer narrower than the gap's size let the second start before it (widths 10, 100 x5 at X 0, 80, 200, 260, 400, 460: the modal -40 gave X 0, -30, 30, ...). The second must start after the first, g > -w(0), in whole pixels floor(-w(0)) + 1. Figma kept the order in every measured run; a narrow first layer was not measured, so this bound keeps the order rather than follow a reading. Test: tidy_tests::a_narrow_first_layer_still_keeps_the_second_after_it. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/engine/src/selection/tidy.rs | 9 +++++++-- crates/engine/src/tidy_tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/selection/tidy.rs b/crates/engine/src/selection/tidy.rs index e5b9ada25..4a940e940 100644 --- a/crates/engine/src/selection/tidy.rs +++ b/crates/engine/src/selection/tidy.rs @@ -167,13 +167,18 @@ fn even_gap(gaps: &[f64], spacing: Option) -> f64 { /// in Figma's web app on 2026-11-13: interior widths of 10, 20 and 30 among 100s gave -4, -9 and /// -14, and widths of 100 left the most common gap, -40, alone. Only those three widths were /// measured; other widths follow the same formula. +/// +/// The first layer has no layer two places before it, but the second must still start after it: +/// `g > -w(0)`, in whole pixels `floor(-w(0)) + 1`. Figma kept the order in every run; a narrow +/// first layer was not measured, so this bound keeps that order rather than following a reading. fn raise_negative_gap(gap: f64, widths: &[f64]) -> f64 { let interior = widths.get(1..widths.len().saturating_sub(1)).unwrap_or(&[]); let w_min = interior.iter().copied().fold(f64::INFINITY, f64::min); - if widths.len() < 3 || !w_min.is_finite() { + let Some(first) = widths.first().copied() else { return gap }; + if widths.len() < 3 || !w_min.is_finite() || !first.is_finite() { return gap; } - gap.max((-w_min / 2.0).floor() + 1.0) + gap.max((-w_min / 2.0).floor() + 1.0).max((-first).floor() + 1.0) } /// A row (`vertical` false) or a column: the first layer stays, each next one follows the last at diff --git a/crates/engine/src/tidy_tests.rs b/crates/engine/src/tidy_tests.rs index 69c52e310..6f6034b78 100644 --- a/crates/engine/src/tidy_tests.rs +++ b/crates/engine/src/tidy_tests.rs @@ -339,6 +339,19 @@ fn a_negative_gap_is_raised_so_no_layer_reaches_the_one_two_before() { } } +#[test] +fn a_narrow_first_layer_still_keeps_the_second_after_it() { + // Not measured in Figma (it kept the order in every run): the modal gap -40 would start the + // second layer at -30, before a 10 wide first layer, so the gap is raised to -9. + let mut s = Session::new(); + let xs = [0., 80., 200., 260., 400., 460.]; + let widths = [10., 100., 100., 100., 100., 100.]; + let pairs: Vec<(f64, f64)> = xs.iter().copied().zip(widths).collect(); + let ids = bars(&mut s, &pairs); + tidy(&mut s, &ids); + assert_eq!(xs_of(&s, &ids), vec![0., 1., 92., 183., 274., 365.]); +} + #[test] fn a_negative_gap_between_equal_widths_is_the_most_common_gap() { let mut s = Session::new();