Merge pull request #2142 from TrevorBrown-dev/feat/al-drag-reorder-edge

Auto layout: a dragged child passes a sibling when its leading edge reaches the sibling's middle
This commit is contained in:
BFlat authored and GitHub committed 2026-11-12 09:23:43 +07:00
commit 0839958f54
4 files changed
+151 -9

No files matched your search

+97
View File
@@ -69,3 +69,100 @@ fn grid_drag_takes_the_cell_under_the_pointer() {
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
assert_eq!((cl.grid_column, cl.grid_row), (None, None), "one undo step");
}
/// Three 60 x 60 rectangles in a row (or column), gap 20, at 0, 80 and 160 along the flow.
fn three(s: &mut Session, mode: &str) -> (NodeId, [NodeId; 3]) {
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap());
let kids: Vec<NodeId> =
(0..3).map(|_| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 60, "parent": f})).unwrap())).collect();
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"layoutMode": mode, "gap": 20, "padding": 0, "sizingH": "fixed", "sizingV": "fixed"}})).unwrap();
s.execute("select.none", &json!({})).unwrap();
(f, [kids[0], kids[1], kids[2]])
}
/// The order after dragging kid `which` by `by` along the flow, grabbed `grab` from its start edge.
fn dragged_order(mode: &str, which: usize, grab: f64, by: f64) -> Vec<usize> {
let mut s = Session::new();
let (f, kids) = three(&mut s, mode);
let vertical = mode == "vertical";
let b = s.doc().unwrap().world_bounds(kids[which]).unwrap();
let from = if vertical { (b.center().x, b.y0 + grab) } else { (b.x0 + grab, b.center().y) };
let to = if vertical { (from.0, from.1 + by) } else { (from.0 + by, from.1) };
drag(&mut s, from, to);
let order = s.doc().unwrap().node(f).unwrap().children.clone();
order.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect()
}
#[test]
fn a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle() {
assert_eq!(dragged_order("horizontal", 0, 30.0, 49.0), vec![0, 1, 2]);
assert_eq!(dragged_order("horizontal", 0, 30.0, 50.0), vec![1, 0, 2]);
assert_eq!(dragged_order("horizontal", 0, 30.0, 129.0), vec![1, 0, 2]);
assert_eq!(dragged_order("horizontal", 0, 30.0, 130.0), vec![1, 2, 0]);
}
#[test]
fn where_the_child_is_grabbed_does_not_change_its_slot() {
assert_eq!(dragged_order("horizontal", 0, 10.0, 49.0), vec![0, 1, 2]);
assert_eq!(dragged_order("horizontal", 0, 10.0, 50.0), vec![1, 0, 2]);
assert_eq!(dragged_order("horizontal", 0, 10.0, 129.0), vec![1, 0, 2]);
assert_eq!(dragged_order("horizontal", 0, 10.0, 130.0), vec![1, 2, 0]);
}
#[test]
fn a_child_dragged_left_passes_a_sibling_by_its_left_edge() {
assert_eq!(dragged_order("horizontal", 2, 30.0, -49.0), vec![0, 1, 2]);
assert_eq!(dragged_order("horizontal", 2, 30.0, -50.0), vec![0, 2, 1]);
assert_eq!(dragged_order("horizontal", 2, 30.0, -130.0), vec![2, 0, 1]);
}
#[test]
fn a_column_reorders_by_the_bottom_edge() {
assert_eq!(dragged_order("vertical", 0, 30.0, 50.0), vec![1, 0, 2]);
assert_eq!(dragged_order("vertical", 0, 30.0, 130.0), vec![1, 2, 0]);
}
#[test]
fn a_child_dragged_left_passes_the_second_sibling_one_pixel_later() {
assert_eq!(dragged_order("horizontal", 2, 30.0, -129.0), vec![0, 2, 1]);
}
#[test]
fn a_column_reorders_at_the_boundaries_both_ways() {
assert_eq!(dragged_order("vertical", 0, 30.0, 49.0), vec![0, 1, 2]);
assert_eq!(dragged_order("vertical", 0, 30.0, 129.0), vec![1, 0, 2]);
assert_eq!(dragged_order("vertical", 2, 30.0, -49.0), vec![0, 1, 2]);
assert_eq!(dragged_order("vertical", 2, 30.0, -50.0), vec![0, 2, 1]);
assert_eq!(dragged_order("vertical", 2, 30.0, -129.0), vec![0, 2, 1]);
assert_eq!(dragged_order("vertical", 2, 30.0, -130.0), vec![2, 0, 1]);
}
#[test]
fn a_drag_with_no_move_along_the_flow_keeps_the_slot() {
// Back at the start, and a drag across the flow only, with snapping on (the default).
assert_eq!(dragged_order("horizontal", 1, 30.0, 0.0), vec![0, 1, 2]);
assert_eq!(dragged_order("vertical", 1, 30.0, 0.0), vec![0, 1, 2]);
let mut s = Session::new();
let (f, kids) = three(&mut s, "horizontal");
let b = s.doc().unwrap().world_bounds(kids[1]).unwrap();
let from = (b.center().x, b.center().y);
drag(&mut s, from, (from.0, from.1 + 40.0));
let order = s.doc().unwrap().node(f).unwrap().children.clone();
assert_eq!(order, kids.to_vec());
}
#[test]
fn several_dragged_children_use_their_combined_box() {
// Kids 0 and 1 together span 0..140; moved right by 49 the leading edge is at 189, just short of the middle of kid 2 (190).
for (by, expected) in [(49.0, vec![0, 1, 2]), (50.0, vec![2, 0, 1])] {
let mut s = Session::new();
let (f, kids) = three(&mut s, "horizontal");
s.execute("select.set", &json!({"ids": [kids[0], kids[1]]})).unwrap();
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
let from = (b.center().x, b.center().y);
drag(&mut s, from, (from.0 + by, from.1));
let order: Vec<usize> = s.doc().unwrap().node(f).unwrap().children.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect();
assert_eq!(order, expected, "by {by}");
}
}
+37 -5
View File
@@ -20,6 +20,32 @@ pub struct DropSlot {
/// placed child whose centre is past the pointer, and the line to draw there. Hidden children and
/// those ignoring auto layout don't take part.
pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[NodeId]) -> Option<(usize, Option<[f64; 4]>)> {
slot_in(doc, parent, p, exclude, None)
}
/// [`auto_layout_slot`] for layers dragged within their own frame (measured in Figma's web app,
/// 2026-11-09): the slot follows the leading edge of `moved` in the direction of `delta`. The caller
/// passes `moved` as the layers' combined start bounds plus the raw (unsnapped) drag, and `delta`
/// as that same drag on the page; only its sign along the flow axis matters, and a zero delta uses
/// the low edge. Wrapping layouts keep the pointer rule.
pub fn auto_layout_slot_within(
doc: &Document,
parent: NodeId,
p: Point,
exclude: &[NodeId],
moved: figcraft_geom::Rect,
delta: Point,
) -> Option<(usize, Option<[f64; 4]>)> {
slot_in(doc, parent, p, exclude, Some((moved, delta)))
}
fn slot_in(
doc: &Document,
parent: NodeId,
p: Point,
exclude: &[NodeId],
dragged: Option<(figcraft_geom::Rect, Point)>,
) -> Option<(usize, Option<[f64; 4]>)> {
let pn = doc.node(parent)?;
let layout = pn.layout.as_ref()?;
let vertical = layout.mode == LayoutMode::Vertical;
@@ -34,11 +60,17 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod
let (k, line) = wrap_slot(doc, &flow_ids, p, vertical);
return Some((index(k), line));
}
let pos = if vertical { p.y } else { p.x };
let k = flow_ids
.iter()
.position(|c| doc.world_bounds(*c).is_some_and(|b| pos < if vertical { b.center().y } else { b.center().x }))
.unwrap_or(flow_ids.len());
let mid = |c: &NodeId| doc.world_bounds(*c).map(|b| if vertical { b.center().y } else { b.center().x });
let k = if let Some((r, d)) = dragged {
let (lo, hi, dd) = if vertical { (r.y0, r.y1, d.y) } else { (r.x0, r.x1, d.x) };
// Forward: past every sibling whose middle the leading (high) edge has reached; backward: past
// every one whose middle lies before the leading (low) edge. A zero delta (no move along the
// flow) takes the backward rule, which leaves the layers where they were.
flow_ids.iter().position(|c| mid(c).is_some_and(|m| if dd > 0.0 { m > hi } else { m >= lo })).unwrap_or(flow_ids.len())
} else {
let pos = if vertical { p.y } else { p.x };
flow_ids.iter().position(|c| mid(c).is_some_and(|m| pos < m)).unwrap_or(flow_ids.len())
};
let line = flow_ids.get(k).and_then(|c| doc.world_bounds(*c)).map(|b| line_before(b, vertical, 2.0)).or_else(|| {
flow_ids
.last()
+14 -2
View File
@@ -216,6 +216,8 @@ impl Session {
}
}
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
// The pointer's own drag, before snapping: a reorder in auto layout follows it.
let raw = d;
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
let mut guides = Vec::new();
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
@@ -268,8 +270,18 @@ impl Session {
// cell) under the pointer, as the frame shows it now. A frame other than the
// one they come from is outlined too.
let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None };
let idx =
if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) };
// Within the frame they came from, the slot follows the leading edge of the
// box they would fill (their combined start bounds moved by the drag), not
// the pointer. The preview starts from the document as the drag began.
let within = moving.iter().all(|m| doc.node(*m).is_some_and(|n| n.parent == Some(target)));
let idx = if mode == LayoutMode::Grid {
0
} else if let (true, Some(b)) = (within, doc.bounds_of(&moving)) {
let moved = Rect::new(b.x0 + raw.x, b.y0 + raw.y, b.x1 + raw.x, b.y1 + raw.y);
crate::drop::auto_layout_slot_within(doc, target, p, &moving, moved, Point::new(raw.x, raw.y)).map_or(0, |(i, _)| i)
} else {
crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i)
};
for m in &moving {
if doc.get(*m)?.parent != Some(target) {
doc.reparent(*m, target, None)?;
+3 -2
View File
@@ -1,6 +1,6 @@
# Auto layout and constraints parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (a child dragged within its own auto layout row or column passes a sibling when its leading edge reaches the sibling's middle, as measured in Figma's web app, wherever it is grabbed; a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
@@ -88,7 +88,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Absolute position (ignore auto layout) with constraints | done | |
| Strokes included in layout (inside strokes), canvas stacking | done | |
| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) |
| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` |
| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs`. A child dragged within its own row or column takes its slot by the dragged box's leading edge, not the pointer: it passes a sibling when the edge facing the drag reaches the sibling's middle (right or bottom edge going forward, left or top edge going back), wherever it was grabbed. Measured in Figma's web app, 2026-11-09 (three 60 x 60 rectangles, gap 20: after B at 50, after C at 130; the mirror for a leftward drag; the same along y). Several moving layers use their combined box. Layers dragged in from outside, wrapping layouts, grids and a drag out of the frame keep their earlier rules (unmeasured). The measurement was made in the web app; the desktop target was not re-checked. Tests: `a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle`, `where_the_child_is_grabbed_does_not_change_its_slot`, `a_child_dragged_left_passes_a_sibling_by_its_left_edge`, `a_column_reorders_by_the_bottom_edge`, `a_column_reorders_at_the_boundaries_both_ways`, `a_drag_with_no_move_along_the_flow_keeps_the_slot`, `several_dragged_children_use_their_combined_box` |
| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` |
| Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | |
| Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | |
@@ -104,6 +104,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
| 2026-11-09 | patch | M2: a child dragged within its own auto layout row or column takes its slot by the dragged box's leading edge instead of the pointer, as measured in Figma's web app on 2026-11-09: it passes a sibling when the edge facing the drag reaches the sibling's middle, so the result no longer depends on where the child was grabbed. Several moving layers use their combined box. Dragging in from outside, wrapping layouts and grids are unchanged. Tests in `al_drag_tests.rs` |
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` |