Cursors: 36 more tool states get cursors of their own, listed in cursors.toml

Node editing as the public manual describes it: Direct Selection's anchor squares (hollow
or filled), Reshape Segment over a curved segment, Group Selection's plus (also Direct
Selection with Alt), the arrow hollow while a drag snaps, and Alt-copy arrowheads. The
Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows (ShearH,
ShearV) are drawn in code. Lasso, the freehand, symbolism and other tools return cursors
of their own that borrow their old look until drawn (Cursor::stand_in).

crates/tools/src/cursors.toml lists every Cursor value, when it shows and its hotspot,
for whoever draws them; `cargo xtask cursors` (in ci) warns about a missing, unknown,
repeated or incomplete entry and never fails. The busy cursor is listed as missing (G1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Gieson
2026-10-17 12:26:12 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 816097c6bf
commit 2d0da1472f
32 changed files with 1683 additions and 94 deletions
+36 -1
View File
@@ -82,6 +82,8 @@ pub struct SelectionTool {
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
moving: Option<MoveSnap>,
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
guide: GuideEdit,
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
hover: Option<(Point, &'static str)>,
@@ -425,9 +427,12 @@ impl Tool for SelectionTool {
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
let unsnapped = d;
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d, m.alt);
}
// Shift has already constrained `d`: any change is a snap.
self.snapped = (d - unsnapped).hypot() > 1e-9;
self.state = State::Moving { start, began: true, deselect: None, key: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
@@ -553,7 +558,10 @@ impl Tool for SelectionTool {
match self.state {
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Moving { began: true, .. } => return Cursor::Arrow,
// Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move
// snaps to a point or a guide.
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
State::Corner(_) => return Cursor::CornerRadius,
State::Pie(_) => return Cursor::ArrowHollow,
State::Bracket(_) => return Cursor::PathBracket,
@@ -868,6 +876,33 @@ mod tests {
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
}
/// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right
/// one); Alt, which leaves a copy behind, the double arrowhead.
#[test]
fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let l = d.layers[0].id;
let b = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.add(b);
let p = paint();
let c = cx(&d, &s, &p);
let none = Mods::default();
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0));
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow);
t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A");
let alt = Mods { alt: true, ..none };
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy);
}
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
/// whichever of its edges or its centre is nearest a gridline, on each axis.
#[test]