Files
figcraft/crates/engine/src/cursor_tests.rs
T
bflatastic 6cdfe2ab8e M3: layers inside instances keep their resizing overrides (#731)
A layer inside an instance can now override its resizing (hug, fill or
fixed, with min and max sizes; override group `resizing`) and its size.
They were refused, and imported ones were dropped at the first relayout.
Kept through rebuilds, undo, save, .fig and REST import, .fig export and
detach; reset per group and with Reset all changes. Position, constraints
and absolute or grid placement stay the main's.
2026-10-17 19:27:35 +07:00

302 lines
15 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.
//! The cursor each tool asks for (`tool.overlay.cursor`), by tool, modifier and what the pointer
//! is over: tables of tool × modifiers × hover target → cursor, the cursors of gestures under way
//! (panning, duplicating, rotating, an edit an instance refuses) and of vector edit mode.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{Cursor, PenCursor, PointerEvent, PointerKind, ToolId};
use figcraft_doc::NodeId;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
#[derive(Clone, Copy, Default)]
struct Mods {
shift: bool,
alt: bool,
cmd: bool,
}
const NONE: Mods = Mods { shift: false, alt: false, cmd: false };
const ALT: Mods = Mods { shift: false, alt: true, cmd: false };
const CMD: Mods = Mods { shift: false, alt: false, cmd: true };
fn ev(kind: PointerKind, (x, y): (f64, f64), m: Mods) -> PointerEvent {
PointerEvent { shift: m.shift, alt: m.alt, cmd: m.cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn hover(s: &mut Session, at: (f64, f64), m: Mods) -> Cursor {
s.pointer(&ev(PointerKind::Hover, at, m)).unwrap();
s.tool.overlay.cursor
}
/// A resize cursor's direction, 0–180° (it has two heads).
fn resize(c: Cursor) -> f64 {
match c {
Cursor::Resize(a) => a.rem_euclid(180.0),
other => panic!("not a resize cursor: {other:?}"),
}
}
fn rotate(c: Cursor) -> f64 {
match c {
Cursor::Rotate(a) => a.rem_euclid(360.0),
other => panic!("not a rotate cursor: {other:?}"),
}
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected.
fn scene() -> (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 mut s = Session::new();
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})));
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}})));
ex(&mut s, "select.set", json!({"ids": [r]}));
(s, r, t)
}
const EMPTY: (f64, f64) = (700.0, 600.0);
const ON_SQUARE: (f64, f64) = (150.0, 150.0);
const ON_TEXT: (f64, f64) = (420.0, 120.0);
#[test]
fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
use Cursor::*;
// (tool, modifiers, where, cursor, why)
type Row = (ToolId, Mods, (f64, f64), Cursor, &'static str);
let rows: Vec<Row> = vec![
(ToolId::Move, NONE, EMPTY, Default, "the arrow over empty canvas"),
(ToolId::Move, NONE, ON_SQUARE, Default, "the arrow over a layer"),
(ToolId::Move, NONE, ON_TEXT, Default, "the arrow over a text that isn't being edited"),
(ToolId::Move, ALT, ON_SQUARE, Copy, "Alt over a selected layer: a drag makes a copy"),
(ToolId::Move, ALT, ON_TEXT, Default, "Alt over another layer measures to it"),
(ToolId::Move, ALT, EMPTY, Default, "Alt over empty canvas"),
(ToolId::Scale, NONE, ON_SQUARE, Default, "the Scale tool away from the handles"),
(ToolId::Scale, ALT, ON_SQUARE, Default, "the Scale tool doesn't duplicate"),
(ToolId::Hand, NONE, EMPTY, Grab, "the open hand"),
(ToolId::Hand, NONE, ON_SQUARE, Grab, "the open hand over a layer too"),
(ToolId::Frame, NONE, EMPTY, Crosshair, "frame"),
(ToolId::Section, NONE, EMPTY, Crosshair, "section"),
(ToolId::Slice, NONE, EMPTY, Crosshair, "slice"),
(ToolId::Rectangle, NONE, ON_SQUARE, Crosshair, "shapes are drawn over layers too"),
(ToolId::Line, NONE, EMPTY, Crosshair, "line"),
(ToolId::Arrow, NONE, EMPTY, Crosshair, "arrow"),
(ToolId::Ellipse, NONE, EMPTY, Crosshair, "ellipse"),
(ToolId::Polygon, NONE, EMPTY, Crosshair, "polygon"),
(ToolId::Star, NONE, EMPTY, Crosshair, "star"),
(ToolId::Pencil, NONE, EMPTY, Crosshair, "pencil"),
(ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"),
(ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"),
(ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
];
for (tool, m, at, want, why) in rows {
let (mut s, ..) = scene();
s.set_tool(tool);
assert_eq!(s.tool.overlay.cursor, tool.cursor(), "{tool:?}: its cursor shows as soon as it is chosen");
assert_eq!(hover(&mut s, at, m), want, "{tool:?} at {at:?}: {why}");
}
// Every tool has a row above.
for t in ToolId::ALL {
assert!(matches!(t.cursor(), Default | Crosshair | Grab | Pen(_) | Comment | Eyedropper), "{t:?}");
}
}
#[test]
fn handles_show_arrows_that_follow_the_handle_and_the_boxs_rotation() {
let (mut s, r, _) = scene();
// (where, the arrow's direction in degrees: 0 = left-right, 45 = top left to bottom right)
let handles = [((200.0, 150.0), 0.0), ((150.0, 100.0), 90.0), ((200.0, 200.0), 45.0), ((100.0, 200.0), 135.0), ((100.0, 100.0), 45.0)];
for (at, want) in handles {
let got = resize(hover(&mut s, at, NONE));
assert!(close(got, want), "handle at {at:?}: {got}° (wanted {want}°)");
}
// Just outside each corner: the rotate cursor, turned to that corner (from the centre).
for (at, want) in [((208.0, 208.0), 45.0), ((92.0, 208.0), 135.0), ((92.0, 92.0), 225.0), ((208.0, 92.0), 315.0)] {
let got = rotate(hover(&mut s, at, NONE));
assert!(close(got, want), "outside the corner at {at:?}: {got}° (wanted {want}°)");
}
// The Scale tool's handles too.
s.set_tool(ToolId::Scale);
assert!(close(resize(hover(&mut s, (200.0, 150.0), NONE)), 0.0));
s.set_tool(ToolId::Move);
// A box turned by 30° turns its cursors by 30°: the handle that was at the right edge.
ex(&mut s, "node.set", json!({"id": r, "rotation": 30}));
let corners = crate::tools::selection_box(s.doc().unwrap(), &[r]).unwrap();
let mid = |a: usize, b: usize| ((corners[a].x + corners[b].x) / 2.0, (corners[a].y + corners[b].y) / 2.0);
let edge = resize(hover(&mut s, mid(1, 2), NONE));
assert!(close(edge, 30.0) || close(edge, 150.0), "the right edge's arrow turned with the box: {edge}°");
let centre = mid(0, 2);
let c = corners[2];
let out = (c.x + (c.x - centre.0) * 0.15, c.y + (c.y - centre.1) * 0.15);
let want = (c.y - centre.1).atan2(c.x - centre.0).to_degrees().rem_euclid(360.0);
let got = rotate(hover(&mut s, out, NONE));
assert!(close(got, want) && !close(got, 45.0), "the rotate cursor points away from the turned box's centre: {got}° (wanted {want}°)");
// A locked layer has no handles: the arrow.
ex(&mut s, "node.set", json!({"id": r, "rotation": 0, "locked": true}));
assert_eq!(hover(&mut s, (200.0, 150.0), NONE), Cursor::Default);
}
#[test]
fn gestures_show_what_they_are_doing() {
// Panning: the hand closes.
let (mut s, r, _) = scene();
s.set_tool(ToolId::Hand);
s.pointer(&ev(PointerKind::Down, EMPTY, NONE)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::Grabbing);
s.pointer(&ev(PointerKind::Up, EMPTY, NONE)).unwrap();
assert_eq!(hover(&mut s, EMPTY, NONE), Cursor::Grab, "and opens again");
// Alt-drag: the copy cursor from the press to the release; a plain drag: the arrow.
for (m, want) in [(ALT, Cursor::Copy), (NONE, Cursor::Default)] {
let (mut s, ..) = scene();
s.pointer(&ev(PointerKind::Down, ON_SQUARE, m)).unwrap();
assert_eq!(s.tool.overlay.cursor, want, "on the press");
s.pointer(&ev(PointerKind::Move, (180.0, 170.0), m)).unwrap();
s.pointer(&ev(PointerKind::Move, (260.0, 190.0), m)).unwrap();
assert_eq!(s.tool.overlay.cursor, want, "while dragging");
s.pointer(&ev(PointerKind::Up, (260.0, 190.0), m)).unwrap();
}
// Rotating: the cursor turns with the pointer about the centre.
s.set_tool(ToolId::Move);
ex(&mut s, "select.set", json!({"ids": [r]}));
s.pointer(&ev(PointerKind::Down, (208.0, 208.0), NONE)).unwrap();
for (at, want) in [((150.0, 260.0), 90.0), ((40.0, 150.0), 180.0), ((150.0, 40.0), 270.0)] {
s.pointer(&ev(PointerKind::Move, at, NONE)).unwrap();
let got = rotate(s.tool.overlay.cursor);
assert!(close(got, want), "rotating with the pointer at {at:?}: {got}° (wanted {want}°)");
}
s.pointer(&ev(PointerKind::Up, (150.0, 40.0), NONE)).unwrap();
// While a text is edited, the I-beam over it (with the Move tool) and the arrow elsewhere.
let (mut s, _, t) = scene();
ex(&mut s, "text.edit.begin", json!({"id": t}));
assert_eq!(hover(&mut s, ON_TEXT, NONE), Cursor::Text);
assert_eq!(hover(&mut s, EMPTY, NONE), Cursor::Default);
ex(&mut s, "text.edit.end", json!({}));
assert_eq!(hover(&mut s, ON_TEXT, NONE), Cursor::Default, "editing over: the arrow");
}
#[test]
fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})));
ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "parent": f}));
let main = id(&ex(&mut s, "component.create", json!({"ids": [f]})));
let inst = id(&ex(&mut s, "instance.create", json!({"component": main, "x": 400, "y": 0})));
// The rectangle inside the instance: (450,50)–(550,150).
let inner = s.doc().unwrap().node(inst).unwrap().children[0];
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
ex(&mut s, "select.set", json!({"ids": [inner]}));
// Resizing it by its right edge: a size of its own (an override).
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
let before = s.doc().unwrap().world_bounds(inner).unwrap();
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
// Moving it: only the main component can.
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
s.pointer(&ev(PointerKind::Up, (520.0, 120.0), NONE)).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
}
#[test]
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
// A real click: the mouse moves a pixel or two between press and release.
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
assert!(s.tool.overlay.pen_handles.is_empty());
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
assert_eq!(out, Some((4.0, 0.0)));
}
#[test]
fn the_pen_shows_what_a_click_would_do() {
// The Pen tool drawing a new path: three points, then over each of them.
let mut s = Session::new();
s.set_tool(ToolId::Pen);
for at in [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0)] {
s.pointer(&ev(PointerKind::Down, at, NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, at, NONE)).unwrap();
}
let rows = [
((300.0, 300.0), PenCursor::New, "empty canvas: a new point"),
((101.0, 101.0), PenCursor::Close, "its first point: closes the path"),
((200.0, 101.0), PenCursor::Close, "another of its points: connects to it and ends the path"),
((200.0, 200.0), PenCursor::New, "its last point: nothing to connect"),
];
for (at, want, why) in rows {
assert_eq!(hover(&mut s, at, NONE), Cursor::Pen(want), "{why}");
}
s.escape().unwrap();
// With an open path selected, its end points go on with it.
let path = id(&ex(&mut s, "vector.create", json!({"points": [[400, 100], [500, 100], [500, 200]], "closed": false})));
ex(&mut s, "select.set", json!({"ids": [path]}));
s.set_tool(ToolId::Pen);
assert_eq!(hover(&mut s, (500.0, 200.0), NONE), Cursor::Pen(PenCursor::Close), "an end point of the selected open path");
assert_eq!(hover(&mut s, (500.0, 100.0), NONE), Cursor::Pen(PenCursor::New), "its middle point isn't an end");
}
#[test]
fn vector_edit_mode_shows_each_tool_and_modifier() {
let mut s = Session::new();
let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false})));
ex(&mut s, "vector.edit.begin", json!({"id": path}));
let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0));
use Cursor::*;
// (vector tool, modifiers, where, cursor, why)
type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str);
let rows: Vec<Row> = vec![
("move", NONE, point, Default, "the arrow over a point"),
("move", NONE, segment, Default, "the arrow over a segment"),
("move", NONE, empty, Default, "the arrow elsewhere"),
("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"),
("move", ALT, point, Default, "Alt over a point adds nothing"),
("move", CMD, segment, Pen(PenCursor::Bend), "Cmd/Ctrl bends the segment"),
("move", CMD, point, Pen(PenCursor::Bend), "Cmd/Ctrl bends the point's handles"),
("move", CMD, empty, Crosshair, "Cmd/Ctrl over nothing"),
("pen", NONE, empty, Pen(PenCursor::New), "the pen: a new point"),
("pen", NONE, point, Pen(PenCursor::Close), "the pen over a point: connects to it or goes on from it"),
("pen", NONE, segment, Pen(PenCursor::Add), "the pen over a segment: adds a point"),
("bend", NONE, segment, Pen(PenCursor::Bend), "the Bend tool"),
("bend", NONE, empty, Pen(PenCursor::Bend), "the Bend tool anywhere"),
("paint", NONE, empty, Crosshair, "paint bucket"),
("lasso", NONE, empty, Crosshair, "lasso"),
("cut", NONE, segment, Crosshair, "cut"),
("eraser", NONE, empty, Crosshair, "eraser (its brush is drawn around the pointer)"),
("width", NONE, segment, Crosshair, "variable width"),
];
for (tool, m, at, want, why) in rows {
ex(&mut s, "vector.edit.tool", json!({"tool": tool}));
assert_eq!(hover(&mut s, at, m), want, "{tool} at {at:?}: {why}");
}
}