Files
figcraft/crates/engine/src/selection_depth_tests.rs
T
BFlatandClaude Opus 5.5 df2309f5b9 M1: measuring, scale tool handles and selection depth (#77)
Measuring (Alt/Option): distances from the selection to the hovered layer,
its parent (over the selection itself or empty space) and ruler guides;
Cmd+Alt for nested layers; containment both ways, overlaps between matching
edges, gaps with dashed extension lines; rotated layers and several selected
layers by their bounds; the measured box outlined. Alt while dragging shows
live distances to the neighbours (or the parent's edges); Shift+Alt reads out
an auto layout frame's padding and gaps. Labels are placed so they never
overlap each other or the size label and stay the same size at every zoom.
Commands: measure.distances, measure.padding.

Scale tool (K) canvas handles: one factor about the Scale panel's anchor
(tool.scaleAnchor, shared by the panel and the canvas; the opposite handle when
the anchor is on the side being dragged), Alt from the centre, flips past the
pivot, rotated layers and lines along their own axes, one undo step per drag,
a live factor in the size label and the pivot marked. scale_node now also
scales min/max sizes, layout grids, guides, fixed grid tracks, tiled images,
paragraph indent and run letter spacing.

Selection: Cmd/Ctrl-marquee selects nested layers, Shift-marquee toggles,
the context menu's Select layer lists every layer under the click
(select.layersAt), outlines are purple for every layer inside components and
instances, hover outlines show with the Scale tool too. Fixed marquees
selecting layers that only overlapped the marquee on one axis.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:04:17 +07:00

126 lines
5.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Selection behaviours: Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee
//! toggling, Enter / Shift+Enter / Tab / Shift+Tab, and the layers under a point (Select layer).
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect_in(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id_of(&ex(s, "shape.rectangle", p)["id"]).unwrap()
}
/// A frame (0,0 300×200) holding a group of two rectangles and a loose one; and one rectangle
/// on the page at (400, 0).
fn scene(s: &mut Session) -> (NodeId, NodeId, [NodeId; 3], NodeId) {
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200}))["id"]).unwrap();
let a = rect_in(s, 20.0, 20.0, Some(f));
let b = rect_in(s, 80.0, 20.0, Some(f));
let g = id_of(&ex(s, "object.group", json!({"ids": [a, b]}))["id"]).unwrap();
let c = rect_in(s, 20.0, 120.0, Some(f));
let out = rect_in(s, 400.0, 0.0, None);
(f, g, [a, b, c], out)
}
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool, cmd: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
(e.shift, e.cmd) = (shift, cmd);
e
}
fn marquee(s: &mut Session, a: (f64, f64), b: (f64, f64), shift: bool, cmd: bool) -> Vec<NodeId> {
s.pointer(&ev(PointerKind::Down, a.0, a.1, shift, cmd)).unwrap();
s.pointer(&ev(PointerKind::Move, b.0, b.1, shift, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, b.0, b.1, shift, cmd)).unwrap();
s.selection()
}
fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
v.sort();
v
}
#[test]
fn cmd_click_selects_the_nested_layer() {
let mut s = Session::new();
let (_, g, [a, ..], _) = scene(&mut s);
s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap();
s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap();
assert_eq!(s.selection(), vec![g], "a click selects the frame's child");
ex(&mut s, "select.none", json!({}));
s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, true)).unwrap();
s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, true)).unwrap();
assert_eq!(s.selection(), vec![a], "Cmd/Ctrl-click goes straight to the layer");
}
#[test]
fn cmd_marquee_selects_nested_layers() {
let mut s = Session::new();
let (f, _, [a, b, c], out) = scene(&mut s);
// From empty canvas, a plain marquee across the frame only touches top-level layers (and the
// frame isn't fully covered).
assert_eq!(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false), vec![out]);
// Cmd/Ctrl: the innermost layers it touches, through the frame and the group.
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, true)), sorted(vec![a, b, out]));
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (100.0, 190.0), false, true)), sorted(vec![a, b, c]));
// Only the frame's own background touched: the frame itself.
assert_eq!(marquee(&mut s, (350.0, 180.0), (250.0, 150.0), false, true), vec![f]);
}
#[test]
fn shift_marquee_toggles() {
let mut s = Session::new();
let (_, _, _, out) = scene(&mut s);
let other = rect_in(&mut s, 400.0, 100.0, None);
ex(&mut s, "select.set", json!({"ids": [out]}));
// Touching both (starting clear of the selection's handles): the selected one comes out, the
// other goes in.
assert_eq!(marquee(&mut s, (370.0, -30.0), (450.0, 150.0), true, false), vec![other]);
assert_eq!(sorted(marquee(&mut s, (370.0, -30.0), (450.0, 20.0), true, false)), sorted(vec![out, other]));
}
#[test]
fn keyboard_selection_moves_through_the_tree() {
let mut s = Session::new();
let (f, g, [a, b, c], _) = scene(&mut s);
ex(&mut s, "select.set", json!({"ids": [f]}));
ex(&mut s, "select.children", json!({}));
assert_eq!(sorted(s.selection()), sorted(vec![g, c]), "Enter");
ex(&mut s, "select.set", json!({"ids": [a]}));
ex(&mut s, "select.parent", json!({}));
assert_eq!(s.selection(), vec![g], "Shift+Enter");
ex(&mut s, "select.set", json!({"ids": [b]}));
// Tab goes down the layers list (back in z order), wrapping; Shift+Tab goes up.
ex(&mut s, "select.next", json!({}));
assert_eq!(s.selection(), vec![a], "Tab");
ex(&mut s, "select.previous", json!({}));
assert_eq!(s.selection(), vec![b], "Shift+Tab");
ex(&mut s, "select.previous", json!({}));
assert_eq!(s.selection(), vec![a], "wraps");
}
#[test]
fn layers_at_a_point_list_the_stack_front_most_first() {
let mut s = Session::new();
let (f, g, [a, ..], _) = scene(&mut s);
let over = rect_in(&mut s, 10.0, 10.0, Some(f));
let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30}));
let got: Vec<(NodeId, u64)> =
out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect();
assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]);
assert_eq!(out["layers"][0]["type"], json!("FRAME"));
// Nothing under empty canvas; junk is refused.
assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([]));
assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err());
}