Files
figcraft/crates/engine/src/selection_depth_tests.rs
T
Trevor BrownandClaude Sonnet 5.5 cb52621414 M5: marquee selection follows Figma's measured rules (gap 4)
A plain marquee now selects each top-level layer it touches: a partly covered frame or section gives the layers in it the marquee touches, an enclosed one is selected itself, an empty frame on touch, a rotated shape by its outline. A drag from inside a frame never selects that frame and also takes the top-level layers it touches outside it. Once the marquee selects a frame whole, no frame opens and a partly covered frame comes whole. Measured in Figma's web app, 2026-11-11.

Flipped existing assertions: marquee_selects_what_it_touches, cmd_marquee_selects_nested_layers, a_box_selects_like_a_marquee. Tests: marquee_behaviour_tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium
2026-11-11 17:13:26 -05:00

126 lines
5.5 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, g, [a, b, c], out) = scene(&mut s);
// From empty canvas, a plain marquee across the frame (not fully covered) gives the layers in it
// it touches (the group whole), plus the top-level layer.
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false)), sorted(vec![g, 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());
}