Merge main into #2310

This commit is contained in:
bflatastic
2026-11-14 01:35:02 +07:00
10 changed files with 717 additions and 50 deletions
+16 -44
View File
@@ -238,53 +238,25 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
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<Value> {
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<(NodeId, Rect)>> = 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<Rect> = 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::<f64>())
/ (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()
}
+3
View File
@@ -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;
+1
View File
@@ -2,3 +2,4 @@ pub mod rename;
pub mod selection_colors;
pub mod selection_reorder;
pub mod selection_spacing;
pub mod tidy;
+271
View File
@@ -0,0 +1,271 @@
//! 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, 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, 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
//! 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: 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;
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);
/// 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, 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 positions across its
/// packing axis to whole pixels.
pub(crate) fn tidy_moves(doc: &Document, ids: &[NodeId], spacing: Option<f64>, snap: bool) -> Vec<Move> {
let min = if spacing.is_some() { 2 } else { 3 };
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<(Vec<Item>, Shape)> {
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = 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 shape = classify(&items)?;
Some((items, shape))
}
/// 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<Shape> {
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, 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 (`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) {
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<Vec<Item>> {
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<Item>> = 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
}
/// Every (column, row) cell holds exactly one layer.
fn one_per_cell(cols: &[Vec<Item>], rows: &[Vec<Item>]) -> 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 (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>) -> 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::<f64>() / gaps.len() as f64 + 0.5 + 1e-9).floor(),
}
}
/// 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.
///
/// 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);
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).max((-first).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<f64>) -> Vec<Move> {
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<f64> = line
.windows(2)
.filter_map(|w| match w {
[a, b] => Some(span(b.1, vertical).0 - span(a.1, vertical).1),
_ => None,
})
.collect();
let widths: Vec<f64> = 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 {
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 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)
}
/// The gaps inside every unit along the other axis, pooled.
fn unit_gaps(units: &[Vec<Item>], vertical: bool) -> Vec<f64> {
let mut out = Vec::new();
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
}
/// 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<Item>], vertical: bool, spacing: Option<f64>, snap: bool) -> Vec<Move> {
let boxes: Vec<(f64, f64)> = units.iter().map(|u| unit_box(u, vertical)).collect();
let across: Vec<f64> = boxes
.windows(2)
.filter_map(|w| match w {
[a, b] => Some(b.0 - (a.0 + a.1)),
_ => None,
})
.collect();
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 (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) });
}
at += w + g_units;
}
out
}
+373
View File
@@ -0,0 +1,373 @@
//! 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<NodeId> {
xs.iter().map(|x| make(s, *x, 0.0, 50.0, 50.0)).collect()
}
fn xs_of(s: &Session, ids: &[NodeId]) -> Vec<f64> {
ids.iter().map(|i| xy(s, *i).0).collect()
}
fn row(s: &mut Session, xs: [f64; 4]) -> Vec<NodeId> {
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<NodeId> {
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<NodeId> = 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");
}
}
/// 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);
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 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());
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.]);
}
#[test]
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., 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., 72., 144., 216.], "a mean of 21.67 goes to 22");
}
#[test]
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), s.undo_depth()), before);
assert!(!can(&s, &ids));
}
#[test]
fn a_grid_with_an_empty_place_is_unavailable() {
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.),
];
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: an empty place in a grid");
assert!(!can(&s, &ids), "not measured in Figma: an empty place in a grid");
}
#[test]
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.]);
assert!(can(&s, &ids));
tidy(&mut s, &ids);
assert_eq!(xs_of(&s, &ids), vec![0., 57., 114., 171.], "gaps 10, -5, 15 have the mean 6.67, which gives 7");
}
#[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<NodeId> = (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_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), s.undo_depth()), before);
assert!(!can(&s, &ids));
}
#[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);
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]
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<figcraft_geom::Rect> { [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 + 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);
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");
}
/// Rectangles at Y 0, H 50 with the given (X, W) pairs.
fn bars(s: &mut Session, xw: &[(f64, f64)]) -> Vec<NodeId> {
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_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();
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.]);
}
+1
View File
@@ -1629,6 +1629,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"
"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"
+2
View File
@@ -278,6 +278,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;
+17 -5
View File
@@ -660,6 +660,12 @@ pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[N
})
}
/// 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<Document>, 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<Document>, sel: &[NodeId]) {
@@ -706,12 +712,18 @@ fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, blo
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("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}));
ui.close();
}
+30
View File
@@ -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 forming a row, a column or a full grid, 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<NodeId> = 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");
}
+3 -1
View File
File diff suppressed because one or more lines are too long