Files
figcraft/crates/engine/src/double_click_tests.rs
T
9f09b7163c M2: a double-click on an auto layout edge sets Hug contents, with Alt Fill container (#1838) (#1867)
* M2: a double-click on an auto layout edge sets Hug contents, with Alt Fill container (#1838)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Auto layout edges: a text child fills with Alt too, and still enters on a plain double-click

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 05:31:00 -05:00

205 lines
11 KiB
Rust

//! Double-clicks as a mouse sends them (press, release, press, double-click, with a pixel or two
//! of hand movement between) into a group's text, and the drag threshold that keeps such clicks
//! from moving or resizing what they land on.
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}"))
}
/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text).
fn scene(s: &mut Session) -> (NodeId, NodeId) {
// The text is measured with a font: without craft-fonts that is a system one, scanned here
// rather than by whichever test happens to run first (#333).
figcraft_text::scan_system_fonts();
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap();
let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap();
let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
let g = id_of(&ex(s, "object.group", json!({"ids": [r, t]}))["id"]).unwrap();
ex(s, "select.none", json!({}));
(g, t)
}
fn send(s: &mut Session, kind: PointerKind, x: f64, y: f64, zoom: f64) {
s.pointer(&PointerEvent::at(kind, x, y, zoom)).unwrap();
}
/// A click at (x, y) whose release is `jitter` away.
fn click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) {
send(s, PointerKind::Down, x, y, zoom);
send(s, PointerKind::Move, x + jitter, y, zoom);
send(s, PointerKind::Up, x + jitter, y, zoom);
}
/// What the canvas sends for a double-click: a click, a second press, the double-click (no
/// release), then the pointer hovering on.
fn double_click(s: &mut Session, x: f64, y: f64, jitter: f64, zoom: f64) {
click(s, x, y, jitter, zoom);
send(s, PointerKind::Down, x, y, zoom);
send(s, PointerKind::Move, x + jitter, y, zoom);
send(s, PointerKind::DoubleClick, x + jitter, y, zoom);
send(s, PointerKind::Move, x + jitter + 1.0, y, zoom);
}
fn editing(s: &Session) -> Option<NodeId> {
s.ds().unwrap().text_edit.as_ref().map(|t| t.id)
}
fn origin(s: &Session, id: NodeId) -> (f64, f64, f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0, b.width(), b.height())
}
#[test]
fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
// The middle of the text (a move would start there), near its edges (a resize would) and
// with the hand moving a pixel or two between press and release.
for (x, y) in [(100.0, 70.0), (100.0, 61.0), (100.0, 78.0), (61.0, 70.0), (80.0, 70.0)] {
for jitter in [0.0, 1.0, 2.0] {
let mut s = Session::new();
let (g, t) = scene(&mut s);
let before = origin(&s, t);
click(&mut s, x, y, jitter, 1.0);
assert_eq!(s.selection(), vec![g], "a click picks the group ({x}, {y}, {jitter})");
double_click(&mut s, x, y, jitter, 1.0);
assert_eq!(s.selection(), vec![t], "a double-click picks the text ({x}, {y}, {jitter})");
assert_eq!(editing(&s), None);
double_click(&mut s, x, y, jitter, 1.0);
assert_eq!(editing(&s), Some(t), "the next one edits it ({x}, {y}, {jitter})");
assert_eq!(origin(&s, t), before, "and nothing was dragged ({x}, {y}, {jitter})");
}
}
}
#[test]
fn editing_text_keeps_other_document_changes_since_open() {
let mut s = Session::new();
let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}}));
ex(&mut s, "edit.delete", json!({"ids": [deleted]}));
let bounds = s.doc().unwrap().world_bounds(text).unwrap();
double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0);
assert_eq!(editing(&s), Some(text));
crate::textedit::insert(&mut s, "B").unwrap();
crate::textedit::end(&mut s).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0);
assert!(!s.doc().unwrap().contains(deleted));
assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B");
}
#[test]
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
let mut s = Session::new();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
ex(&mut s, "view.snapPixel", json!({"on": false}));
// A move from the middle.
send(&mut s, PointerKind::Down, 100.0, 100.0, zoom);
send(&mut s, PointerKind::Move, 100.0 + by, 100.0, zoom);
send(&mut s, PointerKind::Up, 100.0 + by, 100.0, zoom);
assert_eq!(origin(&s, r).0 != 0.0, moves, "move by {by} at {zoom}");
// Past the threshold the layer follows the pointer from where it was pressed, and coming
// back near the start keeps following.
send(&mut s, PointerKind::Down, 100.0, 100.0, zoom);
send(&mut s, PointerKind::Move, 100.0 + 40.0 / zoom, 100.0, zoom);
send(&mut s, PointerKind::Move, 100.0 + 1.0 / zoom, 100.0, zoom);
let x = origin(&s, r).0;
send(&mut s, PointerKind::Up, 100.0 + 1.0 / zoom, 100.0, zoom);
s.undo().unwrap();
assert!(x != 0.0 || !moves, "back near the start still follows at {zoom}");
}
// A resize from the right edge.
for (by, resizes) in [(2.0, false), (4.0, true)] {
let mut s = Session::new();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200}))["id"]).unwrap();
send(&mut s, PointerKind::Down, 200.0, 100.0, 1.0);
send(&mut s, PointerKind::Move, 200.0 + by, 100.0, 1.0);
send(&mut s, PointerKind::Up, 200.0 + by, 100.0, 1.0);
assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}");
}
}
/// A double-click with Alt held throughout, as the canvas sends it.
fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) {
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
let mut ev = PointerEvent::at(kind, x, y, zoom);
ev.alt = true;
s.pointer(&ev).unwrap();
}
}
/// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to
/// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal
/// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto
/// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout
/// doesn't size keeps the double-click's old meaning.
#[test]
fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() {
use figcraft_doc::Sizing;
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}}));
let c =
id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"])
.unwrap();
let sizing = |s: &Session, id: NodeId| {
let cl = &s.doc().unwrap().node(id).unwrap().child_layout;
(cl.sizing_h, cl.sizing_v)
};
let size = |s: &Session, id: NodeId| {
let n = s.doc().unwrap().node(id).unwrap();
(n.width, n.height)
};
ex(&mut s, "select.set", json!({"ids": [f]}));
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)));
// The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child.
double_click(&mut s, 400.0, 50.0, 0.0, 1.0);
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed));
assert_eq!(size(&s, f), (100.0, 100.0));
assert_eq!(s.selection(), [f], "still selected, not entered");
s.undo().unwrap();
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step");
// Its bottom edge: Hug contents vertically.
double_click(&mut s, 200.0, 100.0, 0.0, 1.0);
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug));
assert_eq!(size(&s, f), (400.0, 40.0));
// Alt on the child's right edge: Fill container horizontally, and the child takes the width.
ex(&mut s, "select.set", json!({"ids": [c]}));
alt_double_click(&mut s, 100.0, 20.0, 1.0);
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
assert_eq!(size(&s, c), (400.0, 40.0));
// Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's
// double-click: nothing to enter here), and a plain rectangle's edge still edits its points.
double_click(&mut s, 400.0, 20.0, 0.0, 1.0);
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
// A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a
// plain double-click on its edge still enters the text.
let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}}));
ex(&mut s, "select.set", json!({"ids": [x]}));
let b = s.doc().unwrap().world_bounds(x).unwrap();
alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0);
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too");
let b = s.doc().unwrap().world_bounds(x).unwrap();
// On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares.
double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0);
assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt");
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed));
crate::textedit::end(&mut s).unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap();
ex(&mut s, "select.set", json!({"ids": [r]}));
double_click(&mut s, 99.0, 350.0, 0.0, 1.0);
assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed));
assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points");
}