mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
816097c6bf
commit
2d0da1472f
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
@@ -135,6 +135,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
|
||||
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
|
||||
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
|
||||
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
|
||||
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
|
||||
|
||||
@@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
("pen", Cursor::Pen),
|
||||
("addAnchor", Cursor::PenAdd),
|
||||
("deleteAnchor", Cursor::PenDelete),
|
||||
("anchorPoint", Cursor::Pen),
|
||||
("curvature", Cursor::Pen),
|
||||
("mesh", Cursor::PenAdd),
|
||||
("anchorPoint", Cursor::AnchorPoint),
|
||||
("curvature", Cursor::Curvature),
|
||||
("mesh", Cursor::MeshAdd),
|
||||
("eyedropper", Cursor::Eyedropper),
|
||||
("slice", Cursor::Slice),
|
||||
("blend", Cursor::Blend),
|
||||
("shapeBuilder", Cursor::ShapeBuilder),
|
||||
("paintbrush", Cursor::Paintbrush),
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("shaper", Cursor::Shaper),
|
||||
("pencil", Cursor::Pencil),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("eraser", Cursor::Eraser),
|
||||
("knife", Cursor::Knife),
|
||||
];
|
||||
for (tool, c) in own {
|
||||
assert_eq!(cursor(&mut s, tool), c, "{tool} off");
|
||||
@@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
"polygon",
|
||||
"star",
|
||||
"flare",
|
||||
"paintbrush",
|
||||
"blobBrush",
|
||||
"shaper",
|
||||
"pencil",
|
||||
"smooth",
|
||||
"pathEraser",
|
||||
"join",
|
||||
"eraser",
|
||||
"knife",
|
||||
"mirrorCut",
|
||||
"lineCut",
|
||||
"rectCut",
|
||||
@@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
|
||||
assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on");
|
||||
}
|
||||
assert_eq!(cursor(&mut s, "type"), Cursor::Text);
|
||||
assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed);
|
||||
assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed);
|
||||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
|
||||
for (tool, c) in own {
|
||||
assert_eq!(cursor(&mut s, tool), c, "{tool} off again");
|
||||
@@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() {
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::AnchorPoint,
|
||||
Cursor::Curvature,
|
||||
] {
|
||||
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo
|
||||
assert_eq!(node(&s, b), &before_b);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
|
||||
// Check the captured cursor last: a mismatch must not hide the actual conversion/history controls.
|
||||
assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join");
|
||||
assert_eq!(advertised, Cursor::AnchorPoint, "Alt cursor must describe the conversion actually performed, not Join");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -705,7 +705,7 @@ impl Tool for LivePaintBucketTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::LivePaintBucket
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
# The cursors VectorCraft needs: one [[cursor]] per `Cursor` value (crates/tools/src/lib.rs).
|
||||
#
|
||||
# This is the source of truth for what each cursor is for, written for whoever draws them:
|
||||
# designers never decide what a cursor is for, this file does. `cargo xtask cursors` warns about
|
||||
# a `Cursor` value without an entry. Adding a cursor: CLAUDE.md › Adding a cursor.
|
||||
#
|
||||
# app the app's name as designers read it
|
||||
# id the `Cursor` variant's name (PenAdd), which names its drawing
|
||||
# group the heading it's listed under for designers
|
||||
# shown_when every situation that shows it, one plain sentence each
|
||||
# hotspot where the click lands
|
||||
# look optional: only what the drawing must mean or match (a badge, a sibling cursor).
|
||||
# Never describe another app's cursors; describe what ours must say.
|
||||
# designer optional, default true: false for a cursor the operating system draws on purpose
|
||||
# (nothing to draw)
|
||||
#
|
||||
# Wording: plain sentences for a designer, behaviour only. `cargo xtask brands` rejects vendor
|
||||
# names here.
|
||||
|
||||
app = "VectorCraft"
|
||||
|
||||
# ── Selection ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Arrow"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool over empty canvas or over an unselected object (no Alt).",
|
||||
"Dragging with the Selection tool (a move), or with Direct Selection (an anchor, a segment or a handle).",
|
||||
"Any tool with no cursor of its own (Touch Type, Rotate View).",
|
||||
"The resting state of Live Paint Selection, Reshape (away from a path), Free Transform, Puppet Warp and Crop Image (nothing selected or outside the box), the Perspective Grid (locked or away from handles) and Perspective Selection.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The default pointer: a solid arrow. ArrowHollow is its hollow twin."
|
||||
|
||||
[[cursor]]
|
||||
id = "ArrowHollow"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Direct Selection at rest: away from anchors and curved segments, and over handles and fills.",
|
||||
"The pen-family tools with Cmd/Ctrl held (which lends Direct Selection).",
|
||||
"While a Selection or Direct Selection drag snaps to a point or a guide (the solid arrow turns hollow).",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The same pointer as Arrow, hollow: white with a dark outline."
|
||||
|
||||
[[cursor]]
|
||||
id = "DirectAnchor"
|
||||
group = "Selection"
|
||||
shown_when = ["Direct Selection over an anchor of a path that isn't selected."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a small hollow square badge (an unselected anchor)."
|
||||
|
||||
[[cursor]]
|
||||
id = "DirectAnchorSelected"
|
||||
group = "Selection"
|
||||
shown_when = ["Direct Selection over an anchor of a selected path."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a small filled square badge (a selected anchor)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ReshapeSegment"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Direct Selection over a curved segment of a path that isn't selected as a whole.",
|
||||
"The Anchor Point tool over a segment, and while it reshapes one.",
|
||||
"The Pen with Alt held over a segment of a selected path, and while it reshapes one.",
|
||||
]
|
||||
hotspot = "The arrowhead's tip."
|
||||
look = "Says \"a drag bends this segment\": an arrowhead with a small curve between two anchor marks."
|
||||
|
||||
[[cursor]]
|
||||
id = "GroupSelection"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Group Selection tool: each click selects the next enclosing group.",
|
||||
"Direct Selection with Alt held (Alt makes it Group Selection for the next click).",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a plus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "SelectionCopy"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"Alt-dragging a copy with the Selection tool (the drag leaves the original behind).",
|
||||
"Alt-dragging a copy with Direct Selection, dragging a path whole (by its fill, or by an anchor of a path selected whole).",
|
||||
]
|
||||
hotspot = "The front arrowhead's tip."
|
||||
look = "Says \"a copy\": a double arrowhead, one in front of the other."
|
||||
|
||||
[[cursor]]
|
||||
id = "Move"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool over a selected object, or over any object with Alt held (once an Alt-drag starts, SelectionCopy shows).",
|
||||
"Reshape over a path or while dragging.",
|
||||
"Inside the Free Transform and Crop Image boxes.",
|
||||
"Puppet Warp over a pin.",
|
||||
"The Mesh tool over a mesh point or handle.",
|
||||
"The Artboard tool inside an artboard.",
|
||||
"The Gradient tool over the annotator's handles and during most gradient drags.",
|
||||
"The Perspective Grid and Perspective Selection over plane widgets and handles.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "A four-way move cursor."
|
||||
|
||||
[[cursor]]
|
||||
id = "CornerRadius"
|
||||
group = "Selection"
|
||||
shown_when = ["Selection and Direct Selection (not Group Selection) over a Live Corners widget, and while dragging one (rounding the corners)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a rounded-corner badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PathBracket"
|
||||
group = "Selection"
|
||||
shown_when = ["Selection and Direct Selection over a bracket of selected type on a path, or dragging one (the type slides along its path)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a bracket badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "TypeWidget"
|
||||
group = "Selection"
|
||||
shown_when = ["The Selection tool over the type widget of selected type (a double-click converts point type to area type and back)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a type badge (a letter T)."
|
||||
|
||||
# ── Pen family ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Pen"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen tool where a click starts a new path.",
|
||||
"The Curvature tool away from its points.",
|
||||
"With Use Precise Cursors on, the crosshair shows instead.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "A pen nib. Every Pen-family cursor is this nib with a different badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenDrawing"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen tool while a path is being drawn, where a click adds the path's next point.",
|
||||
"The close, join and convert badges still show over the points they act on.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with no badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenAdd"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen over a segment of a selected path with Auto Add/Delete on (Shift off), or over a selected blend's spine.",
|
||||
"The Add Anchor Point tool everywhere (and the Delete Anchor Point tool with Alt).",
|
||||
"The Curvature tool over a segment.",
|
||||
"Stands in for MeshAdd and PuppetPin until they're drawn.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a plus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenDelete"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen over an anchor of a selected path (Auto Add/Delete), or over a blend-spine point.",
|
||||
"The Delete Anchor Point tool everywhere (and the Add Anchor Point tool with Alt).",
|
||||
"The Mesh tool with Alt over a mesh point.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a minus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenClose"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen while drawing, over the first anchor of the path (a click closes it).",
|
||||
"The Curvature tool over its first point (three points or more), or near the other end of the current open path.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a ring badge (closing the loop)."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenContinue"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Pen (not drawing) over an end of an open path (a click continues it).",
|
||||
"The Curvature tool over an open end.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a slash badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenJoin"
|
||||
group = "Pen"
|
||||
shown_when = ["The Pen while drawing, over an end of another open path (a click joins the two)."]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a badge showing a line running into an end point."
|
||||
|
||||
[[cursor]]
|
||||
id = "PenConvert"
|
||||
group = "Pen"
|
||||
shown_when = ["The Pen while drawing, over the last anchor (a click retracts its outgoing handle). With Alt the Pen shows AnchorPoint and ReshapeSegment instead."]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a caret badge (like the AnchorPoint caret)."
|
||||
|
||||
[[cursor]]
|
||||
id = "AnchorPoint"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Anchor Point tool, except over a segment (ReshapeSegment shows there).",
|
||||
"The Pen with Alt held, over an anchor or a handle end of a selected path.",
|
||||
]
|
||||
hotspot = "The anchor mark at the caret's apex."
|
||||
look = "A caret (an upside-down V) with a small anchor square at its apex."
|
||||
|
||||
[[cursor]]
|
||||
id = "Curvature"
|
||||
group = "Pen"
|
||||
shown_when = [
|
||||
"The Curvature tool over empty canvas, or anywhere while drawing a path away from its points.",
|
||||
"Elsewhere the Curvature tool borrows Pen-family cursors: PenAdd over a segment, Move over an anchor, PenContinue at an open end, PenConvert with Alt, PenClose over the first point of the path being drawn.",
|
||||
]
|
||||
hotspot = "The nib's tip."
|
||||
look = "The Pen nib with a curve badge."
|
||||
|
||||
# ── Type ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Text"
|
||||
group = "Type"
|
||||
shown_when = [
|
||||
"Every horizontal type tool: Type, Area Type and Type on a Path (the Area and Path ones show the crosshair over a suitable path).",
|
||||
"Stands in for TypeVertical until it's drawn.",
|
||||
]
|
||||
hotspot = "The I-beam's centre."
|
||||
look = "An I-beam with a short crossbar marking the baseline."
|
||||
|
||||
[[cursor]]
|
||||
id = "TypeVertical"
|
||||
group = "Type"
|
||||
shown_when = ["The vertical type tools: Vertical Type, Vertical Area Type and Vertical Type on a Path (the Area and Path ones show the crosshair over a suitable path)."]
|
||||
hotspot = "The I-beam's centre."
|
||||
look = "The Text I-beam turned a quarter turn."
|
||||
|
||||
# ── Transform and resize ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Crosshair"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The generic drawing cursor: all shape, line, grid, flare, cut, graph and liquify tools, and Place.",
|
||||
"The Rotate, Reflect, Scale and Shear tools with a selection (Reflect, Scale and Shear even mid-drag).",
|
||||
"The Area Type and Type on a Path tools over a suitable path.",
|
||||
"The Artboard tool outside every artboard; the Gradient tool away from the annotator; Free Transform corners in Distort or Perspective mode.",
|
||||
"With Use Precise Cursors on, it replaces the Pen, Eyedropper, Slice, Blend and Shape Builder cursors.",
|
||||
"Stands in for every drawing tool cursor not drawn yet.",
|
||||
]
|
||||
hotspot = "The centre of the open gap."
|
||||
look = "Four arms around an open centre, no dot, so the art under the hotspot stays visible."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeH"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The left and right handles of the bounding box (or whichever handle reads as left/right once the box is rotated) with Selection, Free Transform, Artboard, Slice Selection and Crop Image.",
|
||||
"Over or dragging a vertical ruler guide.",
|
||||
"Perspective Grid extent handles and near-horizontal Perspective Selection handles.",
|
||||
"The freeform gradient's spread ring.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "A horizontal double arrow. ResizeV, ResizeNwSe and ResizeNeSw are the same arrow turned."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeV"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top and bottom handles of the same boxes as ResizeH.",
|
||||
"Over or dragging a horizontal ruler guide.",
|
||||
"The Perspective Grid's horizon, height and cell-size handles, and near-vertical Perspective Selection handles.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, vertical."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeNwSe"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top-left and bottom-right corner handles of the same boxes as ResizeH (rotation-adjusted).",
|
||||
"Perspective Selection handles at 112.5° to 157.5°.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, diagonal from top-left to bottom-right."
|
||||
|
||||
[[cursor]]
|
||||
id = "ResizeNeSw"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The top-right and bottom-left corner handles of the same boxes as ResizeH (rotation-adjusted).",
|
||||
"Perspective Selection handles at 22.5° to 67.5°.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
look = "The ResizeH arrow, diagonal from bottom-left to top-right."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShearH"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"Free Transform with Cmd/Ctrl held over the top or bottom handle of the bounding box, and while that drag shears the object sideways.",
|
||||
"Without Cmd/Ctrl the same handles show ResizeV.",
|
||||
]
|
||||
hotspot = "The centre, between the two arrows."
|
||||
look = "Two single arrows side by side, one above the other, pointing opposite ways (left and right): one side sliding past the other."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShearV"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"Free Transform with Cmd/Ctrl held over the left or right handle of the bounding box, and while that drag shears the object up or down.",
|
||||
"Without Cmd/Ctrl the same handles show ResizeH.",
|
||||
]
|
||||
hotspot = "The centre, between the two arrows."
|
||||
look = "The ShearH arrows turned upright."
|
||||
|
||||
[[cursor]]
|
||||
id = "Rotate"
|
||||
group = "Transform and resize"
|
||||
shown_when = [
|
||||
"The Rotate tool while dragging.",
|
||||
"The Gradient tool over its rotate zone, or rotating.",
|
||||
"Puppet Warp with Alt near a selected pin.",
|
||||
"The bounding-box rotate cursor when the pointer is exactly on the box's centre.",
|
||||
]
|
||||
hotspot = "Below the arc, in its empty middle."
|
||||
look = "An arc with an arrowhead at each end. RotateToward is the same arc, turned."
|
||||
|
||||
[[cursor]]
|
||||
id = "RotateToward"
|
||||
group = "Transform and resize"
|
||||
shown_when = ["Selection and Free Transform in the rotate zone just outside a bounding-box corner, and while rotating there. The Rotate, Gradient and Puppet Warp rotate cursors don't turn."]
|
||||
hotspot = "Below the arc, in its empty middle, as for Rotate."
|
||||
look = "The Rotate arc drawn for the top-right corner (direction 1). VectorCraft turns it in 45° steps so it wraps whichever corner the pointer is at."
|
||||
|
||||
# ── Navigation ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Hand"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Hand tool, or Space held with any tool (not with Cmd/Ctrl): ready to pan."]
|
||||
hotspot = "The middle of the hand."
|
||||
look = "An open hand. HandGrab is the same hand closed."
|
||||
|
||||
[[cursor]]
|
||||
id = "HandGrab"
|
||||
group = "Navigation"
|
||||
shown_when = ["While panning: a Hand tool drag, a Space drag, or a middle-button drag with any tool."]
|
||||
hotspot = "The middle of the hand, as for Hand."
|
||||
look = "The Hand, closed (grabbing)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ZoomIn"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space with any tool: a click zooms in."]
|
||||
hotspot = "The lens centre."
|
||||
look = "A magnifier with a plus in its lens."
|
||||
|
||||
[[cursor]]
|
||||
id = "ZoomOut"
|
||||
group = "Navigation"
|
||||
shown_when = ["The Zoom tool, or Cmd/Ctrl+Space, with Alt held: a click zooms out."]
|
||||
hotspot = "The lens centre."
|
||||
look = "The ZoomIn magnifier with a minus in its lens."
|
||||
|
||||
[[cursor]]
|
||||
id = "NotAllowed"
|
||||
group = "Navigation"
|
||||
shown_when = [
|
||||
"The Rotate, Reflect, Scale and Shear tools with nothing selected.",
|
||||
"Puppet Warp off the mesh, where no pin can go.",
|
||||
]
|
||||
hotspot = "The centre."
|
||||
designer = false
|
||||
|
||||
# ── Colour and gradient ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Eyedropper"
|
||||
group = "Colour and gradient"
|
||||
shown_when = ["The Eyedropper tool, whatever the modifier keys. With Use Precise Cursors on, the crosshair shows instead."]
|
||||
hotspot = "The pipette's tip."
|
||||
look = "A pipette."
|
||||
|
||||
[[cursor]]
|
||||
id = "AddStop"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Gradient tool over the annotator's bar (a click adds a stop).",
|
||||
"The freeform gradient over its line or the art.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a plus badge. RemoveStop is the same with a minus."
|
||||
|
||||
[[cursor]]
|
||||
id = "RemoveStop"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Gradient tool while a stop is dragged off the bar (releasing deletes it).",
|
||||
"The freeform gradient while a point is dragged out.",
|
||||
]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a minus badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "MeshAdd"
|
||||
group = "Colour and gradient"
|
||||
shown_when = [
|
||||
"The Mesh tool over a mesh or a path, off a mesh point: a click adds a mesh point (turning a path into a mesh).",
|
||||
"Elsewhere the Mesh tool shows PenDelete (Alt over a mesh point) and Move (over a point or handle).",
|
||||
]
|
||||
hotspot = "Where the mesh point goes."
|
||||
|
||||
[[cursor]]
|
||||
id = "LivePaintBucket"
|
||||
group = "Colour and gradient"
|
||||
shown_when = ["The Live Paint Bucket: a click fills a face (or an edge) of a Live Paint group."]
|
||||
hotspot = "Where the paint pours out."
|
||||
|
||||
# ── Width, blend, slice and shape builder ─────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Width"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool away from strokes."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "ArrowHollow with a lens badge (a stroke that swells in the middle). WidthAdd and WidthPoint build on it."
|
||||
|
||||
[[cursor]]
|
||||
id = "WidthAdd"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool over a stroke, away from its width points (a drag adds one)."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The Width cursor with a plus."
|
||||
|
||||
[[cursor]]
|
||||
id = "WidthPoint"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Width tool over a width point or a handle end, and during any Width drag."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "The Width cursor with a mark across the swell (the width being dragged)."
|
||||
|
||||
[[cursor]]
|
||||
id = "Blend"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool away from art. With Use Precise Cursors on, the crosshair shows instead."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a hollow square badge. BlendObject and BlendAnchor change the badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlendObject"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool over an object it can blend (away from its anchors)."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a filled square badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlendAnchor"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Blend tool over an anchor point (the blend starts there)."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a target badge (a ring with a centre dot)."
|
||||
|
||||
[[cursor]]
|
||||
id = "Slice"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Slice tool, always. With Use Precise Cursors on, the plain crosshair shows instead."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a blade badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "SliceSelect"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Slice Selection tool away from the selected slice's handles."]
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a small slice badge (a rectangle cut by a line)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShapeBuilder"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Shape Builder tool (merge mode), and during a drag that started without Alt."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a plus badge. ShapeBuilderErase is the same with a minus."
|
||||
|
||||
[[cursor]]
|
||||
id = "ShapeBuilderErase"
|
||||
group = "Width, blend, slice and shape builder"
|
||||
shown_when = ["The Shape Builder with Alt held (erase mode), and during a drag that started with Alt."]
|
||||
hotspot = "The crosshair's centre."
|
||||
look = "Crosshair with a minus badge."
|
||||
|
||||
# ── Drawing and editing tools ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
id = "Pencil"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."]
|
||||
hotspot = "The lead tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Paintbrush"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."]
|
||||
hotspot = "The bristle tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "BlobBrush"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Blob Brush tool: paints filled shapes that merge with touching ones of the same colour. No brush-size outline shows while hovering."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "Eraser"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Eraser tool: a drag erases the area it covers. No size outline shows while hovering."]
|
||||
hotspot = "The eraser's centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "Smooth"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Smooth tool: a drag along a selected path smooths it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "PathEraser"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Path Eraser tool: a drag along a selected path erases that part of it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Join"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Join tool: scrubbing across the gap or overlap between open path ends joins them."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Knife"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Knife tool: a freehand drag cuts objects along the line."]
|
||||
hotspot = "The blade's point."
|
||||
|
||||
[[cursor]]
|
||||
id = "Scissors"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Scissors tool over a path segment: a click splits the path there."]
|
||||
hotspot = "Where the blades cross, exact enough to cut where aimed."
|
||||
look = "Scissors. ScissorsNotAllowed is this drawing with a badge."
|
||||
|
||||
[[cursor]]
|
||||
id = "ScissorsNotAllowed"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Scissors tool away from every path segment: a click does nothing."]
|
||||
hotspot = "The same point as Scissors."
|
||||
look = "The Scissors with a small not-allowed badge, so the cursor keeps its size and shape and only the badge says a click won't cut."
|
||||
|
||||
[[cursor]]
|
||||
id = "Shaper"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Shaper tool: a rough drawn shape becomes a clean live shape (rectangle, ellipse, polygon, line); scribbling over art deletes it."]
|
||||
hotspot = "The tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "Lasso"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it."]
|
||||
hotspot = "Where the loop is drawn from."
|
||||
|
||||
[[cursor]]
|
||||
id = "MagicWand"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Magic Wand tool: a click selects objects like the one clicked (per the Magic Wand options); Shift adds, Alt subtracts."]
|
||||
hotspot = "The wand's tip."
|
||||
|
||||
[[cursor]]
|
||||
id = "PuppetPin"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = [
|
||||
"Puppet Warp over the selection's mesh where a pin can go: a click adds a pin.",
|
||||
"Elsewhere Puppet Warp shows NotAllowed (off the mesh), Move (over a pin) and Rotate (Alt near a selected pin).",
|
||||
]
|
||||
hotspot = "Where the pin goes."
|
||||
|
||||
[[cursor]]
|
||||
id = "Measure"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Measure tool: drag to measure distance and angle."]
|
||||
hotspot = "Exact, because precision matters here (an open centre suits it)."
|
||||
|
||||
[[cursor]]
|
||||
id = "PrintTiling"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Print Tiling tool: a drag moves the printable page area over the artboard (it snaps to the artboard's edges)."]
|
||||
hotspot = "The page corner being placed."
|
||||
|
||||
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
|
||||
# The brush size is drawn by the app as a circle around the cursor.
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSprayer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Sprayer: a drag sprays instances of the current symbol; Alt reverses it."]
|
||||
hotspot = "The spray centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolShifter"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Shifter: a drag pushes symbol instances along; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolScruncher"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Scruncher: a drag pulls instances together; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSizer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Sizer: a drag enlarges instances; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolSpinner"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Spinner: a drag rotates instances in its direction; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolStainer"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Stainer: a drag tints instances toward the fill colour; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolScreener"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Screener: a drag makes instances more transparent; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
[[cursor]]
|
||||
id = "SymbolStyler"
|
||||
group = "Symbolism tools"
|
||||
shown_when = ["The Symbol Styler: a drag applies the chosen graphic style to instances; Alt reverses it."]
|
||||
hotspot = "The brush centre."
|
||||
|
||||
# ── Not needed ────────────────────────────────────────────────────────────────────────────────────
|
||||
# Tools that deliberately have no cursor of their own (listed for designers so nobody draws one).
|
||||
|
||||
[[not_needed]]
|
||||
what = "Liquify tools (Warp, Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle)"
|
||||
why = "They show the Crosshair, and the app draws the brush's real size and tilt as an outline; a fixed-size cursor would disagree with it."
|
||||
|
||||
[[not_needed]]
|
||||
what = "Shape, line, grid, flare, graph, cut and gradient tools"
|
||||
why = "The generic Crosshair is the intended drawing cursor for them."
|
||||
+171
-3
@@ -33,7 +33,7 @@ use std::borrow::Cow;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
@@ -122,6 +122,16 @@ enum State {
|
||||
},
|
||||
}
|
||||
|
||||
/// How far (pt) a snap must move a dragged point for the arrow to say it snapped.
|
||||
const SNAPPED: f64 = 1e-9;
|
||||
|
||||
/// Whether a drag step snapped: the snapped move `d` left where the pointer alone put it
|
||||
/// (`unsnapped`). With Shift the constraint (General › Constrain Angle) moves it too, so only a
|
||||
/// snap's guide tells then.
|
||||
fn snapped(d: Vec2, unsnapped: Vec2, shift: bool, guides: &[Overlay]) -> bool {
|
||||
if shift { !guides.is_empty() } else { (d - unsnapped).hypot() > SNAPPED }
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
group: bool,
|
||||
state: State,
|
||||
@@ -139,6 +149,8 @@ pub struct DirectSelectionTool {
|
||||
handle_snap: HandleSnap,
|
||||
/// What the objects being moved snap to, gathered when the move begins.
|
||||
move_snap: Option<MoveSnap>,
|
||||
/// The drag snapped to a point or a guide at its last step: the arrow turns hollow.
|
||||
snapped: bool,
|
||||
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
|
||||
hover: Option<(NodeId, Point)>,
|
||||
guide: GuideEdit,
|
||||
@@ -161,6 +173,7 @@ impl DirectSelectionTool {
|
||||
copy_snap: None,
|
||||
handle_snap: HandleSnap::default(),
|
||||
move_snap: None,
|
||||
snapped: false,
|
||||
hover: None,
|
||||
guide: GuideEdit::default(),
|
||||
click: None,
|
||||
@@ -466,6 +479,7 @@ impl Tool for DirectSelectionTool {
|
||||
return vec![Action::Begin("Reshape Spine".into())];
|
||||
}
|
||||
self.spine = None;
|
||||
self.snapped = false;
|
||||
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
|
||||
self.state = State::Mesh;
|
||||
return self.mesh.press(g);
|
||||
@@ -574,6 +588,7 @@ impl Tool for DirectSelectionTool {
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
let unsnapped = d;
|
||||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||||
self.guides.clear();
|
||||
@@ -582,6 +597,7 @@ impl Tool for DirectSelectionTool {
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
out
|
||||
}
|
||||
@@ -618,6 +634,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
let unsnapped = d;
|
||||
self.guides.clear();
|
||||
let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap };
|
||||
if let (Some(grab), Some(snap)) = (grab, snap) {
|
||||
@@ -627,6 +644,7 @@ impl Tool for DirectSelectionTool {
|
||||
} else if let Some(snap) = &self.move_snap {
|
||||
(d, self.guides) = snap.snap(cx, start, d, ev.mods.alt);
|
||||
}
|
||||
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
|
||||
out.push(Action::Preview(
|
||||
"object.transform".into(),
|
||||
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
|
||||
@@ -648,6 +666,7 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
|
||||
self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides);
|
||||
self.guides = guides;
|
||||
vec![Action::Preview(
|
||||
"path.setHandle".into(),
|
||||
@@ -803,15 +822,89 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow };
|
||||
if !self.group {
|
||||
match self.state {
|
||||
// Alt-dragging leaves a copy behind.
|
||||
State::MoveObject { began: true, .. } if m.alt => return Cursor::SelectionCopy,
|
||||
// Bending a segment keeps the cursor that offered it.
|
||||
State::Segment { began: true, .. } | State::SpineSegment { began: true, .. } => return Cursor::ReshapeSegment,
|
||||
// A drag shows the filled arrow, hollow while it snaps to a point or a guide.
|
||||
State::Handle { .. }
|
||||
| State::SpineHandle { .. }
|
||||
| State::SpinePoint { began: true, .. }
|
||||
| State::TypeArea { began: true, .. }
|
||||
| State::MoveAnchors { began: true, .. }
|
||||
| State::MoveObject { began: true, .. } => {
|
||||
return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow };
|
||||
}
|
||||
// Alt makes it the Group Selection tool for the next press, unless the press takes
|
||||
// a handle, a mesh or spine point, a type frame's edge or a ruler guide.
|
||||
State::Idle if m.alt => {
|
||||
let other = self.takes_point(cx, p)
|
||||
|| hovered_anchor(cx, p).is_none() && (hit_frame_edge(cx, p, cx.pick_tol()).is_some() || self.guide.cursor(cx, p).is_some());
|
||||
if !other {
|
||||
return Cursor::GroupSelection;
|
||||
}
|
||||
}
|
||||
State::Idle => {
|
||||
if let Some(c) = self.part_under(cx, p, m) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A press on the highlighted anchor picks it, not the guide.
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(Cursor::ArrowHollow)
|
||||
self.guide.cursor(cx, p).filter(|_| self.hover.is_none() || self.guide.busy()).unwrap_or(rest)
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// A press at `p` takes a mesh point or handle, a direction handle's end, the clicked spine
|
||||
/// point's handle or a spine point, before any anchor or segment.
|
||||
fn takes_point(&self, cx: &ToolContext, p: Point) -> bool {
|
||||
let point = cx.point_tol();
|
||||
self.mesh.hit(cx, &cx.selection.objects, p).is_some()
|
||||
|| hit_handle(cx, p, point).is_some()
|
||||
|| self.spine.is_some_and(|sel| hit_spine_handle(cx, sel, p, point).is_some())
|
||||
|| hit_spine_point(cx, p, point).is_some()
|
||||
}
|
||||
|
||||
/// The cursor for the path part a press at `p` takes, in the press's order: an anchor (its
|
||||
/// square filled when its path is selected), then a curved segment a drag reshapes (a path's
|
||||
/// not selected whole, or a selected blend's spine's). Handles, mesh and spine points, frame
|
||||
/// edges, ruler guides, straight segments and fills keep the plain cursor.
|
||||
fn part_under(&self, cx: &ToolContext, p: Point, m: Mods) -> Option<Cursor> {
|
||||
let tol = cx.pick_tol();
|
||||
if self.takes_point(cx, p) {
|
||||
return None;
|
||||
}
|
||||
// A selected path's anchors or the anchors of the path under the pointer, as Highlight
|
||||
// anchors on mouse over finds them: walking every path on each hover is too slow.
|
||||
if let Some((id, _)) = hovered_anchor(cx, p) {
|
||||
return Some(if cx.selection.contains(id) { Cursor::DirectAnchorSelected } else { Cursor::DirectAnchor });
|
||||
}
|
||||
if hit_frame_edge(cx, p, tol).is_some() {
|
||||
return None;
|
||||
}
|
||||
if matches!(hit_spine_segment(cx, p, tol), Some((.., false))) {
|
||||
return Some(Cursor::ReshapeSegment);
|
||||
}
|
||||
if m.shift || self.guide.cursor(cx, p).is_some() {
|
||||
return None;
|
||||
}
|
||||
let h = hit_test(cx.doc, p, cx.hit_options())?;
|
||||
let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none();
|
||||
let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved);
|
||||
(!whole && curved).then_some(Cursor::ReshapeSegment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1423,6 +1516,81 @@ mod tests {
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle");
|
||||
}
|
||||
|
||||
/// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor
|
||||
/// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment
|
||||
/// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a
|
||||
/// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool.
|
||||
#[test]
|
||||
fn hovering_shows_the_anchor_square_reshape_and_group_cursors() {
|
||||
let (d, arch) = arch_doc();
|
||||
let p = paint();
|
||||
let m = Mods::default();
|
||||
let pt = Point::new;
|
||||
let none = Selection::default();
|
||||
let c = cx(&d, &none, &p);
|
||||
let t = DirectSelectionTool::new(false);
|
||||
assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow);
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves");
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill");
|
||||
let alt = Mods { alt: true, ..m };
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection);
|
||||
assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection);
|
||||
let mut whole = Selection::default();
|
||||
whole.set([arch]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole");
|
||||
// One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press
|
||||
// takes the handle).
|
||||
let s = anchors_of(arch, &[(0, 0)]);
|
||||
let c = cx(&d, &s, &p);
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end");
|
||||
assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt");
|
||||
assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected);
|
||||
// Bending the curve keeps the Reshape Segment cursor.
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let c = cx(&d, &none, &p);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
|
||||
assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment);
|
||||
}
|
||||
|
||||
/// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the
|
||||
/// double arrowhead.
|
||||
#[test]
|
||||
fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
|
||||
let (d, _, b) = two_squares();
|
||||
let p = paint();
|
||||
let none = Mods::default();
|
||||
let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m);
|
||||
let one = anchors_of(b, &[(0, 0)]);
|
||||
let c = cx(&d, &one, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none));
|
||||
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none));
|
||||
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow);
|
||||
// Onto A's bottom-right corner.
|
||||
t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none));
|
||||
assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow);
|
||||
t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none));
|
||||
// Shift with a Constrain Angle of 10°: the constraint alone isn't a snap.
|
||||
let shift = Mods { shift: true, ..none };
|
||||
let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) };
|
||||
t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift));
|
||||
t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift));
|
||||
assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow);
|
||||
t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift));
|
||||
let mut whole = Selection::default();
|
||||
whole.set([b]);
|
||||
let c = cx(&d, &whole, &p);
|
||||
let alt = Mods { alt: true, ..none };
|
||||
t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt));
|
||||
t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt));
|
||||
assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy);
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
|
||||
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
|
||||
@@ -475,7 +475,7 @@ impl Tool for PuppetWarpTool {
|
||||
} else if Self::pin_at(cx, &c.set, p).is_some() {
|
||||
Cursor::Move
|
||||
} else if c.set.rest_of(p).is_some() {
|
||||
Cursor::PenAdd
|
||||
Cursor::PuppetPin
|
||||
} else {
|
||||
// Off the mesh: no pin goes there.
|
||||
Cursor::NotAllowed
|
||||
@@ -733,6 +733,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_click_off_the_mesh_places_no_pin_and_says_so() {
|
||||
let mut r = Rig::new();
|
||||
assert_eq!(r.cursor(130.0, 170.0, Mods::default()), Cursor::PuppetPin, "on the mesh, away from the pins");
|
||||
r.click(130.0, 170.0, Mods::default());
|
||||
assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed);
|
||||
assert!(r.click(400.0, 400.0, Mods::default()).is_empty());
|
||||
|
||||
@@ -219,22 +219,56 @@ impl Tool for AnchorTool {
|
||||
"deleteAnchor" => Cursor::PenAdd,
|
||||
"scissors" => {
|
||||
if hit_segment(cx, p, cx.pick_tol()).is_some() {
|
||||
Cursor::Crosshair
|
||||
Cursor::Scissors
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
Cursor::ScissorsNotAllowed
|
||||
}
|
||||
}
|
||||
_ => Cursor::Pen,
|
||||
_ => match self.state {
|
||||
State::Reshape { .. } => Cursor::ReshapeSegment,
|
||||
State::Idle => press_cursor(cx, p),
|
||||
State::Convert { .. } | State::Handle { .. } => Cursor::AnchorPoint,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Anchor Point tool's cursor for a press at `p`: the Reshape Segment cursor where it takes a
|
||||
/// segment (a path's or a selected blend's spine's), else its caret. Handles and anchors win
|
||||
/// over the segments through them, as the press has it.
|
||||
pub(crate) fn press_cursor(cx: &ToolContext, p: Point) -> Cursor {
|
||||
let (point, tol) = (cx.point_tol(), cx.pick_tol());
|
||||
let segment = hit_handle(cx, p, point).is_none()
|
||||
&& hit_anchor(cx, p, point).is_none()
|
||||
&& (hit_segment(cx, p, tol).is_some() || crate::direct::hit_spine_segment(cx, p, tol).is_some());
|
||||
if segment { Cursor::ReshapeSegment } else { Cursor::AnchorPoint }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::Selection;
|
||||
|
||||
/// The Anchor Point tool has its own caret cursor, not the Pen's start-a-path one, and over a
|
||||
/// segment the Reshape Segment cursor, kept while the segment is dragged.
|
||||
#[test]
|
||||
fn anchor_point_tool_shows_its_caret_and_reshape_over_segments() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = AnchorTool::new("anchorPoint");
|
||||
let none = Mods::default();
|
||||
for at in [Point::new(20.0, 20.0), Point::new(200.0, 100.0)] {
|
||||
assert_eq!(t.cursor(&cx, at, none), Cursor::AnchorPoint, "{at:?}");
|
||||
}
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 101.0), none), Cursor::ReshapeSegment);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 140.0));
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 140.0), none), Cursor::ReshapeSegment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_and_delete_anchor_clicks() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -621,7 +621,7 @@ impl Tool for CurvatureTool {
|
||||
Some(0) if !self.closed && self.pts.len() >= 3 => Cursor::PenClose,
|
||||
Some(_) if m.alt => Cursor::PenConvert,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::Pen,
|
||||
None => Cursor::Curvature,
|
||||
};
|
||||
}
|
||||
let tol = point_tol(cx);
|
||||
@@ -642,7 +642,7 @@ impl Tool for CurvatureTool {
|
||||
if end.is_none() && segment_in(cx, editable_paths(cx), p, tol).is_some() {
|
||||
return Cursor::PenAdd;
|
||||
}
|
||||
Cursor::Pen
|
||||
Cursor::Curvature
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +653,16 @@ mod tests {
|
||||
use vectorcraft_doc::{Appearance, Document, Node, Selection};
|
||||
use vectorcraft_geom::{ParamCurve, PathData};
|
||||
|
||||
/// The Curvature tool has its own cursor, not the Pen's start-a-path one.
|
||||
#[test]
|
||||
fn curvature_tool_shows_its_cursor() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
assert_eq!(CurvatureTool::default().cursor(&cx, Point::new(10.0, 10.0), Mods::default()), Cursor::Curvature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_click_creates_then_notify_tracks_path() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -312,7 +312,16 @@ impl Tool for GestureTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
match self.id {
|
||||
"paintbrush" => Cursor::Paintbrush,
|
||||
"blobBrush" => Cursor::BlobBrush,
|
||||
"eraser" => Cursor::Eraser,
|
||||
"knife" => Cursor::Knife,
|
||||
"smooth" => Cursor::Smooth,
|
||||
"pathEraser" => Cursor::PathEraser,
|
||||
"join" => Cursor::Join,
|
||||
_ => Cursor::Pencil,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
match self.id {
|
||||
|
||||
@@ -393,7 +393,7 @@ impl Tool for ShaperTool {
|
||||
vec![Overlay::Path { path: crate::draw2::polyline(&self.points), color: BLUE, width: 1.5, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Shaper
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+211
-4
@@ -427,12 +427,30 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
ArrowHollow,
|
||||
/// Direct Selection over an anchor of a path that isn't selected: the white arrow with a
|
||||
/// hollow square.
|
||||
DirectAnchor,
|
||||
/// Direct Selection over an anchor of a selected path: the white arrow with a filled square.
|
||||
DirectAnchorSelected,
|
||||
/// Over a segment a drag reshapes (Direct Selection, the Anchor Point tool, the Pen with Alt):
|
||||
/// an arrowhead with a small curve between two anchor marks.
|
||||
ReshapeSegment,
|
||||
/// The Group Selection tool, and Direct Selection with Alt held: the white arrow with a plus.
|
||||
GroupSelection,
|
||||
/// Alt-dragging a copy with the Selection or Direct Selection tool: a double arrowhead.
|
||||
SelectionCopy,
|
||||
Move,
|
||||
Crosshair,
|
||||
ResizeH,
|
||||
ResizeV,
|
||||
ResizeNwSe,
|
||||
ResizeNeSw,
|
||||
/// Free Transform with Cmd/Ctrl over the top or bottom side, where a drag shears the object
|
||||
/// sideways: two arrows side by side, pointing opposite ways along that side.
|
||||
ShearH,
|
||||
/// Free Transform with Cmd/Ctrl over the left or right side, where a drag shears the object up
|
||||
/// or down: the ShearH arrows turned upright.
|
||||
ShearV,
|
||||
Rotate,
|
||||
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
|
||||
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
|
||||
@@ -449,10 +467,14 @@ pub enum Cursor {
|
||||
PenDelete,
|
||||
PenClose,
|
||||
PenContinue,
|
||||
/// The Anchor Point tool, and the Pen with Alt held (which lends its gestures): a caret, its tip
|
||||
/// on the anchor it converts.
|
||||
AnchorPoint,
|
||||
/// The Curvature tool: the pen nib with a curve badge.
|
||||
Curvature,
|
||||
/// While drawing, over an end of another open path (a click joins the two).
|
||||
PenJoin,
|
||||
/// Over the last anchor of the path being drawn (a click retracts its outgoing handle), or with
|
||||
/// Alt held over a selected path's handle or anchor (the Anchor Point tool's gesture).
|
||||
/// Over the last anchor of the path being drawn: a click retracts its outgoing handle.
|
||||
PenConvert,
|
||||
Text,
|
||||
Hand,
|
||||
@@ -490,6 +512,60 @@ pub enum Cursor {
|
||||
ShapeBuilder,
|
||||
/// ...or, with Alt held, a minus (erase mode).
|
||||
ShapeBuilderErase,
|
||||
/// The Lasso tool.
|
||||
Lasso,
|
||||
/// The Magic Wand tool.
|
||||
MagicWand,
|
||||
/// The Pencil tool.
|
||||
Pencil,
|
||||
/// The Paintbrush tool.
|
||||
Paintbrush,
|
||||
/// The Blob Brush tool.
|
||||
BlobBrush,
|
||||
/// The Eraser tool.
|
||||
Eraser,
|
||||
/// The Smooth tool.
|
||||
Smooth,
|
||||
/// The Path Eraser tool.
|
||||
PathEraser,
|
||||
/// The Join tool.
|
||||
Join,
|
||||
/// The Knife tool.
|
||||
Knife,
|
||||
/// The Shaper tool.
|
||||
Shaper,
|
||||
/// The Live Paint Bucket.
|
||||
LivePaintBucket,
|
||||
/// The Measure tool.
|
||||
Measure,
|
||||
/// The Print Tiling tool.
|
||||
PrintTiling,
|
||||
/// The Symbol Sprayer tool.
|
||||
SymbolSprayer,
|
||||
/// The Symbol Shifter tool.
|
||||
SymbolShifter,
|
||||
/// The Symbol Scruncher tool.
|
||||
SymbolScruncher,
|
||||
/// The Symbol Sizer tool.
|
||||
SymbolSizer,
|
||||
/// The Symbol Spinner tool.
|
||||
SymbolSpinner,
|
||||
/// The Symbol Stainer tool.
|
||||
SymbolStainer,
|
||||
/// The Symbol Screener tool.
|
||||
SymbolScreener,
|
||||
/// The Symbol Styler tool.
|
||||
SymbolStyler,
|
||||
/// The vertical type tools: a turned I-beam.
|
||||
TypeVertical,
|
||||
/// The Scissors over a path segment (a click splits the path there)...
|
||||
Scissors,
|
||||
/// ...or away from every segment (a click does nothing).
|
||||
ScissorsNotAllowed,
|
||||
/// The Mesh tool off a mesh point: a click adds one.
|
||||
MeshAdd,
|
||||
/// Puppet Warp over the selection's mesh where a pin can go: a click adds one.
|
||||
PuppetPin,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
@@ -506,7 +582,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
|
||||
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
/// Anchor Point and Curvature tools', the Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
|
||||
pub fn precise(self) -> Self {
|
||||
match self {
|
||||
Cursor::Pen
|
||||
@@ -517,16 +593,79 @@ impl Cursor {
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert
|
||||
| Cursor::AnchorPoint
|
||||
| Cursor::Curvature
|
||||
| Cursor::Eyedropper
|
||||
| Cursor::Slice
|
||||
| Cursor::Blend
|
||||
| Cursor::BlendObject
|
||||
| Cursor::BlendAnchor
|
||||
| Cursor::ShapeBuilder
|
||||
| Cursor::ShapeBuilderErase => Cursor::Crosshair,
|
||||
| Cursor::ShapeBuilderErase
|
||||
| Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor this one borrows until it's drawn: the generic crosshair for the
|
||||
/// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of
|
||||
/// its own.
|
||||
pub fn stand_in(self) -> Option<Self> {
|
||||
match self {
|
||||
Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool state machine.
|
||||
@@ -749,3 +888,71 @@ pub(crate) mod testutil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cursor_tests {
|
||||
use super::testutil::{cx, doc_with_rect, paint};
|
||||
use super::*;
|
||||
|
||||
fn cursor_at(id: &str, d: &Document, p: Point) -> Cursor {
|
||||
let (s, paint) = (Selection::default(), paint());
|
||||
create(id).cursor(&cx(d, &s, &paint), p, Mods::default())
|
||||
}
|
||||
|
||||
/// Each of these tools has a cursor of its own (borrowing the crosshair until it's drawn), and
|
||||
/// Use Precise Cursors turns it into the crosshair.
|
||||
#[test]
|
||||
fn drawing_tools_show_their_own_cursor() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let away = Point::new(400.0, 400.0);
|
||||
for (id, c) in [
|
||||
("lasso", Cursor::Lasso),
|
||||
("magicWand", Cursor::MagicWand),
|
||||
("pencil", Cursor::Pencil),
|
||||
("paintbrush", Cursor::Paintbrush),
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
("eraser", Cursor::Eraser),
|
||||
("smooth", Cursor::Smooth),
|
||||
("pathEraser", Cursor::PathEraser),
|
||||
("join", Cursor::Join),
|
||||
("knife", Cursor::Knife),
|
||||
("shaper", Cursor::Shaper),
|
||||
("livePaintBucket", Cursor::LivePaintBucket),
|
||||
("measure", Cursor::Measure),
|
||||
("printTiling", Cursor::PrintTiling),
|
||||
("symbolSprayer", Cursor::SymbolSprayer),
|
||||
("symbolShifter", Cursor::SymbolShifter),
|
||||
("symbolScruncher", Cursor::SymbolScruncher),
|
||||
("symbolSizer", Cursor::SymbolSizer),
|
||||
("symbolSpinner", Cursor::SymbolSpinner),
|
||||
("symbolStainer", Cursor::SymbolStainer),
|
||||
("symbolScreener", Cursor::SymbolScreener),
|
||||
("symbolStyler", Cursor::SymbolStyler),
|
||||
("mesh", Cursor::MeshAdd),
|
||||
] {
|
||||
assert_eq!(cursor_at(id, &d, away), c, "{id}");
|
||||
assert_eq!(c.precise(), Cursor::Crosshair, "{id}");
|
||||
assert_eq!(c.stand_in(), Some(if id == "mesh" { Cursor::PenAdd } else { Cursor::Crosshair }), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vertical_type_tools_show_the_turned_i_beam() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let away = Point::new(400.0, 400.0);
|
||||
for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] {
|
||||
assert_eq!(cursor_at(id, &d, away), Cursor::TypeVertical, "{id}");
|
||||
}
|
||||
assert_eq!(cursor_at("type", &d, away), Cursor::Text);
|
||||
assert_eq!(Cursor::TypeVertical.stand_in(), Some(Cursor::Text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scissors_say_whether_a_click_cuts() {
|
||||
let (d, _) = doc_with_rect();
|
||||
assert_eq!(cursor_at("scissors", &d, Point::new(100.0, 150.0)), Cursor::Scissors, "over a segment");
|
||||
assert_eq!(cursor_at("scissors", &d, Point::new(400.0, 400.0)), Cursor::ScissorsNotAllowed, "away from paths");
|
||||
assert_eq!(Cursor::ScissorsNotAllowed.stand_in(), Some(Cursor::NotAllowed));
|
||||
assert_eq!(Cursor::ScissorsNotAllowed.precise(), Cursor::ScissorsNotAllowed, "nothing to aim at");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ impl Tool for MeshTool {
|
||||
match self.grab_at(cx, p) {
|
||||
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
|
||||
Some(_) => Cursor::Move,
|
||||
None => Cursor::PenAdd,
|
||||
None => Cursor::MeshAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> serde_json::Value {
|
||||
|
||||
+21
-4
@@ -152,6 +152,17 @@ fn alt_converts(cx: &ToolContext, p: Point, m: Mods) -> bool {
|
||||
|| crate::draw2::segment_in(cx, crate::draw2::shown_paths(cx), p, cx.pick_tol()).is_some())
|
||||
}
|
||||
|
||||
/// The cursor where [`alt_converts`]: Alt lends the Anchor Point tool, so its caret over a handle
|
||||
/// end or an anchor, the Reshape Segment cursor over a segment.
|
||||
fn alt_cursor(cx: &ToolContext, p: Point) -> Cursor {
|
||||
let point = cx.point_tol();
|
||||
if hit_handle(cx, p, point).is_some() || crate::draw2::anchor_in(cx, crate::draw2::shown_paths(cx), p, point).is_some() {
|
||||
Cursor::AnchorPoint
|
||||
} else {
|
||||
Cursor::ReshapeSegment
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview the outgoing handle of anchor `ai` of subpath `si` at `h`, the incoming one left alone.
|
||||
fn set_out_handle(id: NodeId, si: usize, ai: usize, h: Point) -> Action {
|
||||
Action::Preview(
|
||||
@@ -390,6 +401,10 @@ impl Tool for PenTool {
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
// An Alt gesture under way keeps the Anchor Point tool's cursor.
|
||||
if let Some(t) = &self.convert {
|
||||
return t.cursor(cx, p, m);
|
||||
}
|
||||
let tol = cx.tol(5.0).max(cx.point_tol());
|
||||
let active = self.active(cx);
|
||||
if let Some((id, first, last, _)) = active {
|
||||
@@ -404,7 +419,7 @@ impl Tool for PenTool {
|
||||
}
|
||||
}
|
||||
if alt_converts(cx, p, m) {
|
||||
return Cursor::PenConvert;
|
||||
return alt_cursor(cx, p);
|
||||
}
|
||||
// These show whenever no path is being drawn, also after another path was selected while
|
||||
// the Pen was drawing.
|
||||
@@ -1212,8 +1227,9 @@ mod tests {
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let t = PenTool::default();
|
||||
let cx0 = cx(&d, &s, &p);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::PenConvert);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::PenConvert);
|
||||
// Alt lends the Anchor Point tool, and its caret with it.
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), alt), Cursor::AnchorPoint);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(200.0, 101.0), alt), Cursor::AnchorPoint);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(250.0, 101.0), Mods::default()), Cursor::Pen);
|
||||
assert_eq!(t.cursor(&cx0, Point::new(150.0, 300.0), alt), Cursor::Pen);
|
||||
// A drag of the handle moves it alone, as one gesture.
|
||||
@@ -1249,10 +1265,11 @@ mod tests {
|
||||
let cx = cx(&d, &s, &p);
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::PenConvert);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), alt), Cursor::ReshapeSegment);
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 302.0), Mods::default()), Cursor::PenDrawing);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 302.0).with_mods(alt)), vec![]);
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 262.0).with_mods(alt));
|
||||
assert_eq!(t.cursor(&cx, Point::new(150.0, 262.0), alt), Cursor::ReshapeSegment, "kept during the reshape");
|
||||
assert_eq!(a[0], Action::Begin("Reshape".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["id"] == id.0 && v["segment"] == 0 && v["dy"] == -40.0),
|
||||
|
||||
@@ -87,7 +87,7 @@ impl Tool for PrintTilingTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::PrintTiling
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -170,7 +170,16 @@ impl Tool for SymbolismTool {
|
||||
vec![Overlay::Path { path: circle, color: FEEDBACK, width: 1.0, dashed: false }]
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
match self.id {
|
||||
"symbolShifter" => Cursor::SymbolShifter,
|
||||
"symbolScruncher" => Cursor::SymbolScruncher,
|
||||
"symbolSizer" => Cursor::SymbolSizer,
|
||||
"symbolSpinner" => Cursor::SymbolSpinner,
|
||||
"symbolStainer" => Cursor::SymbolStainer,
|
||||
"symbolScreener" => Cursor::SymbolScreener,
|
||||
"symbolStyler" => Cursor::SymbolStyler,
|
||||
_ => Cursor::SymbolSprayer,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
json!({"diameter": self.diameter, "intensity": self.intensity, "density": self.density, "symbol": self.symbol, "style": self.style})
|
||||
|
||||
@@ -814,7 +814,7 @@ impl Tool for TypeTool {
|
||||
if self.mode != Mode::Type && Self::path_at(cx, p, self.mode == Mode::Area).is_some() {
|
||||
return Cursor::Crosshair;
|
||||
}
|
||||
Cursor::Text
|
||||
if self.vertical { Cursor::TypeVertical } else { Cursor::Text }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -315,7 +315,9 @@ impl Tool for FreeTransformTool {
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
|
||||
Some(Op::Move) => Cursor::Move,
|
||||
Some(Op::Scale(h) | Op::Shear(h)) => match h {
|
||||
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
|
||||
Some(Op::Shear(_)) => Cursor::ShearV,
|
||||
Some(Op::Scale(h)) => match h {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||||
@@ -401,6 +403,27 @@ mod tests {
|
||||
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_over_a_side_shows_the_shear_cursor_along_it() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = FreeTransformTool::default();
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
let (top, left) = (Point::new(150.0, 100.0), Point::new(100.0, 150.0));
|
||||
assert_eq!(t.cursor(&cx, top, cmd), Cursor::ShearH);
|
||||
assert_eq!(t.cursor(&cx, left, cmd), Cursor::ShearV);
|
||||
// Without Cmd the sides scale; corners never shear.
|
||||
assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeV);
|
||||
assert_eq!(t.cursor(&cx, Point::new(200.0, 200.0), cmd), Cursor::Crosshair);
|
||||
// The cursor stays while the shear is dragged.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
|
||||
assert_eq!(t.cursor(&cx, Point::new(170.0, 100.0), cmd), Cursor::ShearH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifiers_pressed_during_a_handle_drag_change_what_it_does() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -68,7 +68,7 @@ impl Tool for MeasureTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Measure
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
|
||||
@@ -41,7 +41,7 @@ impl Tool for MagicWandTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::MagicWand
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ impl Tool for LassoTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
Cursor::Lasso
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -576,11 +576,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius | Cursor::PathBracket | Cursor::TypeWidget => C::Default,
|
||||
Cursor::Arrow
|
||||
| Cursor::ArrowHollow
|
||||
| Cursor::DirectAnchor
|
||||
| Cursor::DirectAnchorSelected
|
||||
| Cursor::ReshapeSegment
|
||||
| Cursor::GroupSelection
|
||||
| Cursor::CornerRadius
|
||||
| Cursor::PathBracket
|
||||
| Cursor::TypeWidget => C::Default,
|
||||
Cursor::SelectionCopy => C::Copy,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH => C::ResizeHorizontal,
|
||||
Cursor::ResizeV => C::ResizeVertical,
|
||||
Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal,
|
||||
Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical,
|
||||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||||
@@ -591,7 +600,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::PenClose
|
||||
| Cursor::PenContinue
|
||||
| Cursor::PenJoin
|
||||
| Cursor::PenConvert => C::Crosshair,
|
||||
| Cursor::PenConvert
|
||||
| Cursor::AnchorPoint
|
||||
| Cursor::Curvature => C::Crosshair,
|
||||
Cursor::Text => C::Text,
|
||||
Cursor::Hand => C::Grab,
|
||||
Cursor::HandGrab => C::Grabbing,
|
||||
@@ -606,6 +617,34 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||||
Cursor::WidthPoint => C::Move,
|
||||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||||
Cursor::TypeVertical => C::VerticalText,
|
||||
// Not drawn yet: the cursor each borrows.
|
||||
Cursor::Lasso
|
||||
| Cursor::MagicWand
|
||||
| Cursor::Pencil
|
||||
| Cursor::Paintbrush
|
||||
| Cursor::BlobBrush
|
||||
| Cursor::Eraser
|
||||
| Cursor::Smooth
|
||||
| Cursor::PathEraser
|
||||
| Cursor::Join
|
||||
| Cursor::Knife
|
||||
| Cursor::Shaper
|
||||
| Cursor::LivePaintBucket
|
||||
| Cursor::Measure
|
||||
| Cursor::PrintTiling
|
||||
| Cursor::SymbolSprayer
|
||||
| Cursor::SymbolShifter
|
||||
| Cursor::SymbolScruncher
|
||||
| Cursor::SymbolSizer
|
||||
| Cursor::SymbolSpinner
|
||||
| Cursor::SymbolStainer
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+244
-44
@@ -95,6 +95,13 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
}
|
||||
"-" => line(p, b + vec2(0.0, 3.0), b + vec2(6.0, 3.0)),
|
||||
"/" => line(p, b + vec2(0.0, 6.0), b + vec2(5.0, 0.0)),
|
||||
"~" => {
|
||||
// A quarter circle bulging up-left, from the badge's bottom-left to its top-right.
|
||||
let c = b + vec2(7.0, 7.0);
|
||||
let arc: Vec<Pos2> = (0..=8).map(|i| c + egui::Vec2::angled(std::f32::consts::PI * (1.0 + i as f32 / 16.0)) * 6.0).collect();
|
||||
p.add(Shape::line(arc.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::line(arc, Stroke::new(1.2, INK)));
|
||||
}
|
||||
// Join: a segment running into a square end point.
|
||||
"j" => {
|
||||
line(p, b + vec2(0.0, 6.0), b + vec2(3.5, 2.5));
|
||||
@@ -114,6 +121,79 @@ fn pen(p: &mut Ink, o: Pos2, badge: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Anchor Point tool's caret (as its toolbar icon): a small hollow square on the anchor at
|
||||
/// the hotspot, legs spreading down from its lower corners. Every halo goes under every stroke, so
|
||||
/// the legs join the square.
|
||||
fn anchor_point(p: &mut Ink, o: Pos2) {
|
||||
let s = 2.5;
|
||||
let square = vec![o + vec2(-s, -s), o + vec2(s, -s), o + vec2(s, s), o + vec2(-s, s)];
|
||||
let legs = [[o + vec2(-s, s), o + vec2(-7.5, 13.0)], [o + vec2(s, s), o + vec2(7.5, 13.0)]];
|
||||
for leg in legs {
|
||||
p.line_segment(leg, Stroke::new(3.0, HALO));
|
||||
}
|
||||
p.add(Shape::closed_line(square.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::convex_polygon(square.clone(), HALO, Stroke::NONE));
|
||||
for leg in legs {
|
||||
p.line_segment(leg, Stroke::new(1.2, INK));
|
||||
}
|
||||
p.add(Shape::closed_line(square, Stroke::new(1.0, INK)));
|
||||
}
|
||||
|
||||
/// Halos under every stroke first, then the ink, so crossing strokes don't cut each other.
|
||||
fn strokes(p: &mut Ink, parts: &[Vec<Pos2>]) {
|
||||
for s in parts {
|
||||
p.add(Shape::line(s.clone(), Stroke::new(3.0, HALO)));
|
||||
}
|
||||
for s in parts {
|
||||
p.add(Shape::line(s.clone(), Stroke::new(1.2, INK)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A stemless arrowhead, tip at `o`.
|
||||
fn arrowhead(o: Pos2) -> Vec<Pos2> {
|
||||
vec![o, o + vec2(0.0, 14.0), o + vec2(4.2, 10.4), o + vec2(10.0, 10.0)]
|
||||
}
|
||||
|
||||
/// Direct Selection over an anchor: the white arrow with a small square at its lower right,
|
||||
/// filled when the anchor's path is selected.
|
||||
fn arrow_square(p: &mut Ink, o: Pos2, filled: bool) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(11.0, 13.0);
|
||||
let square = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)].map(|(x, y)| b + vec2(x, y)).to_vec();
|
||||
poly(p, square, if filled { INK } else { HALO }, INK);
|
||||
}
|
||||
|
||||
/// The Reshape Segment cursor: a black arrowhead with a small curve between two anchor marks at
|
||||
/// its lower right.
|
||||
fn reshape_segment(p: &mut Ink, o: Pos2) {
|
||||
poly(p, arrowhead(o), INK, INK);
|
||||
let (a, c) = (o + vec2(10.0, 19.0), o + vec2(19.0, 19.0));
|
||||
let curve: Vec<Pos2> = (0..=12)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 12.0;
|
||||
a + (c - a) * t - vec2(0.0, 4.5 * (std::f32::consts::PI * t).sin())
|
||||
})
|
||||
.collect();
|
||||
strokes(p, &[curve]);
|
||||
for m in [a, c] {
|
||||
let mark = [(-1.5, -1.5), (1.5, -1.5), (1.5, 1.5), (-1.5, 1.5)].map(|(x, y)| m + vec2(x, y)).to_vec();
|
||||
poly(p, mark, INK, INK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Group Selection: the white arrow with a plus at its lower right.
|
||||
fn arrow_plus(p: &mut Ink, o: Pos2) {
|
||||
arrow(p, o, true);
|
||||
let c = o + vec2(14.0, 15.0);
|
||||
strokes(p, &[vec![c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], vec![c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)]]);
|
||||
}
|
||||
|
||||
/// An Alt-drag copy: a black arrowhead in front of a white one set down and to the right.
|
||||
fn double_arrowhead(p: &mut Ink, o: Pos2) {
|
||||
poly(p, arrowhead(o + vec2(4.0, 4.0)), HALO, INK);
|
||||
poly(p, arrowhead(o), INK, INK);
|
||||
}
|
||||
|
||||
fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
let d = dir.normalized() * 8.0;
|
||||
let n = vec2(-d.y, d.x) * 0.45;
|
||||
@@ -123,6 +203,19 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two single arrows side by side across the hotspot, pointing opposite ways along `dir`: a side
|
||||
/// sliding past its opposite one, as a shear does.
|
||||
fn shear(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
|
||||
let d = dir.normalized() * 7.0;
|
||||
let n = vec2(-d.y, d.x) / 7.0;
|
||||
for (side, way) in [(-3.5, 1.0), (3.5, -1.0)] {
|
||||
let (c, d) = (o + n * side, d * way);
|
||||
line(p, c - d, c + d * 0.55);
|
||||
let back = c + d * 0.4;
|
||||
poly(p, vec![c + d, back + n * 2.6, back - n * 2.6], INK, INK);
|
||||
}
|
||||
}
|
||||
|
||||
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
|
||||
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
|
||||
/// bounding box it wraps the corner the pointer is at.
|
||||
@@ -314,13 +407,102 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 74] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
Cursor::ZoomOut,
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::DirectAnchor,
|
||||
Cursor::DirectAnchorSelected,
|
||||
Cursor::ReshapeSegment,
|
||||
Cursor::GroupSelection,
|
||||
Cursor::SelectionCopy,
|
||||
Cursor::Move,
|
||||
Cursor::Crosshair,
|
||||
Cursor::ResizeH,
|
||||
Cursor::ResizeV,
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::ShearH,
|
||||
Cursor::ShearV,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenDrawing,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenJoin,
|
||||
Cursor::PenConvert,
|
||||
Cursor::AnchorPoint,
|
||||
Cursor::Curvature,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
Cursor::AddStop,
|
||||
Cursor::RemoveStop,
|
||||
Cursor::Slice,
|
||||
Cursor::SliceSelect,
|
||||
Cursor::Width,
|
||||
Cursor::WidthAdd,
|
||||
Cursor::WidthPoint,
|
||||
Cursor::Blend,
|
||||
Cursor::BlendObject,
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::Lasso,
|
||||
Cursor::MagicWand,
|
||||
Cursor::Pencil,
|
||||
Cursor::Paintbrush,
|
||||
Cursor::BlobBrush,
|
||||
Cursor::Eraser,
|
||||
Cursor::Smooth,
|
||||
Cursor::PathEraser,
|
||||
Cursor::Join,
|
||||
Cursor::Knife,
|
||||
Cursor::Shaper,
|
||||
Cursor::LivePaintBucket,
|
||||
Cursor::Measure,
|
||||
Cursor::PrintTiling,
|
||||
Cursor::SymbolSprayer,
|
||||
Cursor::SymbolShifter,
|
||||
Cursor::SymbolScruncher,
|
||||
Cursor::SymbolSizer,
|
||||
Cursor::SymbolSpinner,
|
||||
Cursor::SymbolStainer,
|
||||
Cursor::SymbolScreener,
|
||||
Cursor::SymbolStyler,
|
||||
Cursor::TypeVertical,
|
||||
Cursor::Scissors,
|
||||
Cursor::MeshAdd,
|
||||
Cursor::PuppetPin,
|
||||
];
|
||||
|
||||
/// The shapes of cursor `c` with its hotspot at `p`; `None` for cursors that stay system cursors
|
||||
/// (the busy states).
|
||||
/// (the busy states): the one drawn here, else the glyph of the cursor it borrows
|
||||
/// ([`Cursor::stand_in`]).
|
||||
fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
let ink = &mut Ink::default();
|
||||
// A cursor not drawn yet: the one it borrows.
|
||||
if let Some(stand_in) = c.stand_in() {
|
||||
return glyph(stand_in, p);
|
||||
}
|
||||
match c {
|
||||
Cursor::Arrow => arrow(ink, p, false),
|
||||
Cursor::ArrowHollow => arrow(ink, p, true),
|
||||
Cursor::DirectAnchor => arrow_square(ink, p, false),
|
||||
Cursor::DirectAnchorSelected => arrow_square(ink, p, true),
|
||||
Cursor::ReshapeSegment => reshape_segment(ink, p),
|
||||
Cursor::GroupSelection => arrow_plus(ink, p),
|
||||
Cursor::SelectionCopy => double_arrowhead(ink, p),
|
||||
Cursor::Move => {
|
||||
arrow(ink, p, false);
|
||||
double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0));
|
||||
@@ -331,6 +513,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)),
|
||||
Cursor::ResizeNwSe => double_arrow(ink, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(ink, p, vec2(1.0, -1.0)),
|
||||
Cursor::ShearH => shear(ink, p, vec2(1.0, 0.0)),
|
||||
Cursor::ShearV => shear(ink, p, vec2(0.0, -1.0)),
|
||||
Cursor::Rotate => rotate(ink, p, 0),
|
||||
Cursor::RotateToward(octant) => rotate(ink, p, octant),
|
||||
Cursor::CornerRadius => corner_radius(ink, p),
|
||||
@@ -340,6 +524,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
|
||||
Cursor::PenDelete => pen(ink, p, "-"),
|
||||
Cursor::PenClose => pen(ink, p, "o"),
|
||||
Cursor::PenContinue => pen(ink, p, "/"),
|
||||
Cursor::AnchorPoint => anchor_point(ink, p),
|
||||
Cursor::Curvature => pen(ink, p, "~"),
|
||||
Cursor::PenJoin => pen(ink, p, "j"),
|
||||
Cursor::PenConvert => pen(ink, p, "^"),
|
||||
Cursor::Text => ibeam(ink, p),
|
||||
@@ -519,55 +705,68 @@ mod tests {
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Every cursor with a glyph.
|
||||
const GLYPHS: [Cursor; 39] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
Cursor::ZoomOut,
|
||||
Cursor::Arrow,
|
||||
Cursor::ArrowHollow,
|
||||
Cursor::Move,
|
||||
Cursor::Crosshair,
|
||||
Cursor::ResizeH,
|
||||
Cursor::ResizeV,
|
||||
Cursor::ResizeNwSe,
|
||||
Cursor::ResizeNeSw,
|
||||
Cursor::Rotate,
|
||||
Cursor::RotateToward(1),
|
||||
Cursor::CornerRadius,
|
||||
Cursor::Pen,
|
||||
Cursor::PenDrawing,
|
||||
Cursor::PenAdd,
|
||||
Cursor::PenDelete,
|
||||
Cursor::PenClose,
|
||||
Cursor::PenContinue,
|
||||
Cursor::PenJoin,
|
||||
Cursor::PenConvert,
|
||||
Cursor::Text,
|
||||
Cursor::Eyedropper,
|
||||
Cursor::AddStop,
|
||||
Cursor::RemoveStop,
|
||||
Cursor::Slice,
|
||||
Cursor::SliceSelect,
|
||||
Cursor::Width,
|
||||
Cursor::WidthAdd,
|
||||
Cursor::WidthPoint,
|
||||
Cursor::Blend,
|
||||
Cursor::BlendObject,
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
];
|
||||
|
||||
/// The straight RGBA of pixel (x, y).
|
||||
fn pixel(img: &CustomCursorImage, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * img.size[0] as usize + x) * 4;
|
||||
[img.rgba[i], img.rgba[i + 1], img.rgba[i + 2], img.rgba[i + 3]]
|
||||
}
|
||||
|
||||
/// The Anchor Point caret's hotspot is the middle of its hollow square: white there, with the
|
||||
/// square's ink a few points above it.
|
||||
#[test]
|
||||
fn anchor_point_hotspot_is_inside_its_square() {
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
let img = rasterize(Cursor::AnchorPoint, ppp).expect("anchor point has a bitmap");
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
assert_eq!(pixel(&img, hx, hy), [255, 255, 255, 255], "hollow at the hotspot at {ppp}x");
|
||||
let reach = (4.0 * ppp).round() as usize;
|
||||
let inked = (1..=reach.min(hy)).any(|d| {
|
||||
let p = pixel(&img, hx, hy - d);
|
||||
p[0] < 64 && p[3] > 200
|
||||
});
|
||||
assert!(inked, "ink on the square's top edge within 4 points above the hotspot at {ppp}x");
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Selection's anchor square is hollow over an unselected path and filled over a
|
||||
/// selected one; the arrow stays white. The Reshape Segment and copy arrowheads are black and
|
||||
/// Group Selection's arrow white, just inside their tips.
|
||||
#[test]
|
||||
fn selection_arrows_are_white_or_black() {
|
||||
let at = |img: &CustomCursorImage, x: f32, y: f32, ppp: f32| {
|
||||
let [hx, hy] = img.hotspot.map(usize::from);
|
||||
pixel(img, hx + (x * ppp).round() as usize, hy + (y * ppp).round() as usize)
|
||||
};
|
||||
for ppp in [1.0f32, 2.0] {
|
||||
for c in [Cursor::DirectAnchor, Cursor::DirectAnchorSelected] {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
let tip = at(&img, 1.5, 6.0, ppp);
|
||||
assert!(tip[0] > 192 && tip[3] > 200, "{c:?}'s arrow is white at {ppp}x: {tip:?}");
|
||||
}
|
||||
// Same arrow, so the filled square shows as more ink than the hollow one.
|
||||
let ink = |c| {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
img.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 64 && p[3] > 200).count()
|
||||
};
|
||||
let (hollow, filled) = (ink(Cursor::DirectAnchor), ink(Cursor::DirectAnchorSelected));
|
||||
assert!(filled > hollow, "the selected path's square is filled at {ppp}x: {filled} vs {hollow}");
|
||||
for (c, white) in [(Cursor::ReshapeSegment, false), (Cursor::SelectionCopy, false), (Cursor::GroupSelection, true)] {
|
||||
let img = rasterize(c, ppp).expect("a bitmap");
|
||||
let tip = at(&img, 1.5, 6.0, ppp);
|
||||
assert_eq!(tip[0] > 192, white, "{c:?} at {ppp}x: {tip:?}");
|
||||
assert!(tip[3] > 200, "{c:?} at {ppp}x: {tip:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_not_drawn_yet_looks_like_the_one_it_borrows() {
|
||||
for c in GLYPHS {
|
||||
let Some(stand_in) = c.stand_in() else { continue };
|
||||
assert_eq!(rasterize(c, 2.0), rasterize(stand_in, 2.0), "{c:?} borrows {stand_in:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_glyph_becomes_a_bitmap_with_its_hotspot_inside() {
|
||||
for ppp in [1.0, 1.25, 1.5, 2.0, 3.0] {
|
||||
@@ -590,6 +789,7 @@ mod tests {
|
||||
assert!(pixel(&dropper, hx, hy)[3] > 0, "the tip covers the hotspot at {ppp}");
|
||||
}
|
||||
assert!(rasterize(Cursor::NotAllowed, 1.0).is_none(), "the busy states stay system cursors");
|
||||
assert!(rasterize(Cursor::ScissorsNotAllowed, 1.0).is_none(), "until it's drawn it borrows the system not-allowed cursor");
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
assert!(rasterize(Cursor::Arrow, ppp).is_none(), "{ppp}");
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@ other `sv-*` locale resolve to it), Czech (`cs`, every menu label) and Brazilian
|
||||
system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts.
|
||||
A Traditional Chinese UI font is still to be added to craft-fonts for the web build.
|
||||
|
||||
## Cursors
|
||||
|
||||
`crates/tools/src/cursors.toml` lists every `Cursor` value: when it shows, in plain behaviour terms, and where its hotspot is, so whoever draws the cursors knows what each one is for without reading the tools (CLAUDE.md › Adding a cursor). `cargo xtask cursors` (part of `cargo xtask ci`) warns about a `Cursor` value with no entry, an entry with no `Cursor` value, a repeated id or a missing field; it never fails the build. A cursor not drawn yet borrows another's look (`Cursor::stand_in`).
|
||||
|
||||
## Vendor names gate
|
||||
|
||||
`cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment.
|
||||
|
||||
@@ -70,6 +70,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
OS cursor display and Caps Lock remain unverified.
|
||||
- **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement.
|
||||
- **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage.
|
||||
- **Missing:** a busy cursor while a long operation runs (a big file opening, a heavy effect): VectorCraft has no busy state to show it yet. Once it has one, its `Cursor` value goes in the cursor list (`crates/tools/src/cursors.toml`).
|
||||
- **Missing:** Average and Join in one step, with a corner or smooth choice for the joined anchor. `path.average` and `path.join` are separate commands and Join has no such choice (coinciding anchors merge into one since #1533). [ui-parity.md](ui-parity.md) listed "Join with corner/smooth choice" as done until 2026-10-13. About 1–2 h (estimated), inside the estimate below.
|
||||
- **Implemented (#1760):** Effect › Warp has a shared style selector with original previews, orientation buttons and percent sliders. Existing effects retain their stack slot and target. Focused checks passed and original captures were reviewed; optional Properties integration and remembered defaults remain outside this change.
|
||||
- **Done (#2120):** Smart Guides line extension: dragging the end of a straight segment with Direct Selection or Curvature, the point snaps to the segment's own direction at any angle and the guide runs along it from the other end. Inside the estimate below.
|
||||
@@ -333,6 +334,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
| 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) |
|
||||
| 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). |
|
||||
| 2026-10-16 | minor | G1: Smart Guides line extension for a dragged end of a straight segment, at any angle (#2120); estimate unchanged |
|
||||
| 2026-10-15 | minor | G1: no busy cursor during long operations, listed; estimate unchanged |
|
||||
| 2026-10-14 | minor | G16: Brazilian Portuguese (`pt-br`) catalog complete under the tests (#1914); three key languages remain (Hindi, Arabic, Indonesian), 55–90 → 50–85 h |
|
||||
| 2026-10-14 | minor | G6: dataset CSV import/export (`dataset.import` / `dataset.export`); remaining: scripting surface, Batch, graph variables; estimate 6–12 → 4–8 h |
|
||||
| 2026-10-14 | minor | G6: image variables (`VariableKind::Image`, `variable.makeImageDynamic`, path values on `dataset.select` keeping bounds); remaining: scripting surface, Batch, graph variables, dataset import/export; estimate 8–14 → 6–12 h |
|
||||
|
||||
@@ -645,7 +645,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns.
|
||||
The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429).
|
||||
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739).
|
||||
- **MCP vertical type:** `add_text {vertical: true}` forwards to `text.create` or `text.createInPath`. Regression coverage checks point type, rectangular area type, type in a path and type on a path with true, false and omitted direction. This exposes an existing engine option; it does not change glyph orientation, CJK composition or the type/automation scores.
|
||||
- **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions.
|
||||
@@ -687,6 +687,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
| 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged |
|
||||
| 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). |
|
||||
| 2026-10-16 | minor | Symbols: Symbol Options › Export Type (Graphic / Movie Clip) with Registration; Symbols score 85% → 87% (estimated) |
|
||||
| 2026-10-15 | minor | Evidence: 36 more tool states get cursors of their own (node editing, Anchor Point, Curvature, side-shear drawn; the rest borrowed until drawn), listed in `crates/tools/src/cursors.toml`; scores unchanged |
|
||||
| 2026-10-14 | minor | Localization: Brazilian Portuguese (`pt-br`) catalog completed under the tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85; full readiness unchanged (~62%) |
|
||||
| 2026-10-14 | minor | Automation: dataset CSV import/export; headline Automation ~62% → ~65%, detail ~68% → ~72%, hours 6–12 → 4–8 / 5–9 → 4–7 (estimated) |
|
||||
| 2026-10-14 | minor | Automation: image variables (`kind: image`, `variable.makeImageDynamic`, path values on datasets that keep object bounds); headline Automation ~60% → ~62%, detail ~65% → ~68%, hours 8–14 → 6–12 / 6–10 → 5–9 (estimated) |
|
||||
|
||||
@@ -51,6 +51,11 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Rubber Band preview | done | Preferences option |
|
||||
| Auto Add/Delete on a selected path; Shift or the preference disables it | done | |
|
||||
| Pen cursors (new path ×, continue /, close o, add +, delete −, convert ^) | partial | vector cursors exist; not every state verified |
|
||||
| Node-editing cursors: Direct Selection's white arrow with a hollow or filled square over an anchor (path unselected or selected); the Reshape Segment cursor over a curved segment (Direct Selection, Anchor Point, the Pen with Alt); the Anchor Point caret for the Pen with Alt; Group Selection's plus (also Direct Selection with Alt); a filled arrow while dragging that turns hollow on a snap; a double arrowhead for an Alt-drag copy | partial | as the public manual describes them, drawn in code; no cue over handles (none documented) |
|
||||
| Anchor Point and Curvature tools' own cursors | done | the caret on the anchor it converts; the pen nib with a curve badge |
|
||||
| Free Transform's Cmd/Ctrl side-shear cursor | done | two arrows passing along the side (ShearH, ShearV), instead of the resize arrow across it |
|
||||
| Cursors of their own for Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors (over a segment and away), Mesh and Puppet Warp | partial | each tool returns its own cursor and Use Precise Cursors turns them into the crosshair; until they're drawn they borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed |
|
||||
| Busy cursor during long operations | missing | VectorCraft has no busy state yet (gap G1) |
|
||||
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
|
||||
| Add/Delete/Anchor Point tools; Anchor Point tool drags segments to reshape | done | #2267: a click on a handle end retracts that handle and leaves the anchor a corner with the other one |
|
||||
@@ -232,6 +237,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, #
|
||||
| 2026-10-16 | minor | Batch exporting in Export for Screens (#1935): a file name pattern (document and item names, a counter, the date), folders per format, item or both, Layers and Selection tabs with thumbnails, EPS, AI and VectorCraft rows, a PDF row with printer's marks, and export presets (built-in Logo Package and Print-Ready PDF, saved ones); scores and hours unchanged (estimated) |
|
||||
| 2026-10-16 | minor | Anchor Point tool: a click on one handle end retracts that handle only; the anchor becomes a corner and keeps the other handle (#2267). Handle drags start past the same 2 px as anchor drags. Scores and hours unchanged. |
|
||||
| 2026-10-16 | minor | Smart Guides: the dragged end of a straight segment snaps to the segment's extension at any angle, with the guide from its other end (#2120); scores and hours unchanged |
|
||||
| 2026-10-15 | minor | Cursors of their own for 36 more tool states: node-editing cursors as the public manual describes them, Anchor Point and Curvature, Free Transform's side-shear; the rest borrow a drawn cursor until they're drawn. Busy cursor listed as missing. Every cursor is listed in `crates/tools/src/cursors.toml` |
|
||||
| 2026-10-14 | minor | Effect > Warp: style, orientation and percent controls; focused checks passed and original captures reviewed; scores and hours unchanged |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete leaves separate surviving runs; dedicated Remove Anchors remains connected. Keyboard and history regression coverage added; scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Curvature: the Curvature tool's curve: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; the rubber band, dragged, inserted and converted points use the same rule; the Pencil's fitting unchanged (#1236, in part); scores unchanged (estimated) |
|
||||
|
||||
+6
-1
@@ -6,6 +6,7 @@
|
||||
//! MCP tool definitions…). Comments and test code (`tests*.rs`, `*_tests.rs`, `tests/` and
|
||||
//! `benches/` folders, items under `#[cfg(test)]`) are skipped;
|
||||
//! * the `description` of every `Cargo.toml`;
|
||||
//! * the cursor manifest `crates/tools/src/cursors.toml` (designers read it);
|
||||
//! * every text file under `packaging/`, and under `docs/` except Markdown pages: Markdown docs
|
||||
//! (like `README.md` and `ROADMAP.md`) may name the reference app and compare against it.
|
||||
//!
|
||||
@@ -38,6 +39,9 @@ const ALLOW: &str = "brand-ok";
|
||||
/// This file lists the names it rejects.
|
||||
const SELF: &str = "xtask/src/brands.rs";
|
||||
|
||||
/// The cursor manifest, which designers read.
|
||||
const CURSOR_MANIFEST: &str = "crates/tools/src/cursors.toml";
|
||||
|
||||
/// A denied name found in a file.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
@@ -89,7 +93,7 @@ enum Kind {
|
||||
}
|
||||
|
||||
fn kind_of(path: &str) -> Option<Kind> {
|
||||
if (path.starts_with("docs/") && !path.ends_with(".md")) || path.starts_with("packaging/") {
|
||||
if (path.starts_with("docs/") && !path.ends_with(".md")) || path.starts_with("packaging/") || path == CURSOR_MANIFEST {
|
||||
Some(Kind::Text)
|
||||
} else if path == "Cargo.toml" || path.ends_with("/Cargo.toml") {
|
||||
Some(Kind::Manifest)
|
||||
@@ -351,6 +355,7 @@ line Pantone"; }
|
||||
assert_eq!(kind_of("crates/ui-egui/Cargo.toml"), Some(Kind::Manifest));
|
||||
assert_eq!(kind_of("docs/brand/LICENSE-brand.txt"), Some(Kind::Text));
|
||||
assert_eq!(kind_of("packaging/linux/nfpm.yaml"), Some(Kind::Text));
|
||||
assert_eq!(kind_of(CURSOR_MANIFEST), Some(Kind::Text));
|
||||
for skipped in ["README.md", "ROADMAP.md", "CLAUDE.md", "AGENTS.md", ".github/workflows/release.yml", "ASSETS.md", "docs/mcp.md"] {
|
||||
assert_eq!(kind_of(skipped), None, "{skipped}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! `cargo xtask cursors`: does the cursor manifest (`crates/tools/src/cursors.toml`) cover every
|
||||
//! `Cursor` value?
|
||||
//!
|
||||
//! The manifest check only warns: a cursor without an entry still works in the app (it falls
|
||||
//! back), it just isn't listed for whoever draws the cursors. Development never stops for a missing cursor.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
pub const ENUM_FILE: &str = "crates/tools/src/lib.rs";
|
||||
pub const MANIFEST: &str = "crates/tools/src/cursors.toml";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let read = |p: &str| std::fs::read_to_string(root.join(p)).map_err(|e| format!("{p}: {e}"));
|
||||
let (enum_src, manifest) = (read(ENUM_FILE)?, read(MANIFEST)?);
|
||||
let warnings = check(&enum_src, &manifest);
|
||||
if warnings.is_empty() {
|
||||
println!("cursors: every Cursor has an entry in {MANIFEST}");
|
||||
}
|
||||
for w in &warnings {
|
||||
eprintln!("warning: {w}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Warnings for `Cursor` variants in `enum_src` and `[[cursor]]` entries in `manifest` that don't
|
||||
/// match, and for entries that repeat an id or lack a required field.
|
||||
pub fn check(enum_src: &str, manifest: &str) -> Vec<String> {
|
||||
let variants = variants(enum_src);
|
||||
let entries = entries(manifest);
|
||||
let mut out = vec![];
|
||||
if variants.is_empty() {
|
||||
out.push(format!("no `pub enum Cursor` found in {ENUM_FILE}"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for e in &entries {
|
||||
let name = e.id.as_deref().unwrap_or("?");
|
||||
match &e.id {
|
||||
None => out.push(format!("{MANIFEST}:{}: a [[cursor]] without an id", e.line)),
|
||||
Some(id) if !ids.insert(id.as_str()) => out.push(format!("{MANIFEST}:{}: {id} is listed twice", e.line)),
|
||||
Some(id) if !variants.iter().any(|v| v.name == *id) => out.push(format!("{MANIFEST}:{}: {id} is not a Cursor value", e.line)),
|
||||
Some(_) => {}
|
||||
}
|
||||
for field in ["shown_when", "hotspot"] {
|
||||
if !e.fields.contains(field) {
|
||||
out.push(format!("{MANIFEST}:{}: {name} has no {field}", e.line));
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in variants.iter().filter(|v| !ids.contains(v.name.as_str())) {
|
||||
out.push(format!("Cursor::{} has no entry in {MANIFEST} (see CLAUDE.md › Adding a cursor)", v.name));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `Cursor` variant, by name (`RotateToward(u8)` is `RotateToward`).
|
||||
pub struct Variant {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The variants of `pub enum Cursor` in `src`, in order.
|
||||
pub fn variants(src: &str) -> Vec<Variant> {
|
||||
src.lines()
|
||||
.skip_while(|l| !l.starts_with("pub enum Cursor {"))
|
||||
.skip(1)
|
||||
.take_while(|l| !l.starts_with('}'))
|
||||
.map(str::trim)
|
||||
.filter(|l| l.starts_with(|c: char| c.is_ascii_uppercase()))
|
||||
.map(|l| Variant { name: l.chars().take_while(|c| c.is_ascii_alphanumeric()).collect() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One `[[cursor]]` table: its first line (1-based), its `id`, the keys it sets, and whether
|
||||
/// designers draw it (`designer = false`: the operating system does).
|
||||
pub struct Entry {
|
||||
line: usize,
|
||||
pub id: Option<String>,
|
||||
fields: BTreeSet<String>,
|
||||
pub designer: bool,
|
||||
}
|
||||
|
||||
/// The `[[cursor]]` tables of `manifest`, read line by line (keys at the start of a line).
|
||||
pub fn entries(manifest: &str) -> Vec<Entry> {
|
||||
let mut out: Vec<Entry> = vec![];
|
||||
let mut in_cursor = false;
|
||||
for (i, raw) in manifest.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
if line.starts_with('[') && !line.starts_with("[\"") {
|
||||
in_cursor = line == "[[cursor]]";
|
||||
if in_cursor {
|
||||
out.push(Entry { line: i + 1, id: None, fields: BTreeSet::new(), designer: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (Some(e), Some((key, value))) = (out.last_mut().filter(|_| in_cursor), raw.split_once('=')) else { continue };
|
||||
if raw.starts_with(char::is_whitespace) || key.trim().contains(' ') {
|
||||
continue;
|
||||
}
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"');
|
||||
match key {
|
||||
"id" => e.id = Some(value.to_string()),
|
||||
"designer" => e.designer = value != "false",
|
||||
_ => {}
|
||||
}
|
||||
e.fields.insert(key.to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ENUM: &str = "pub enum Cursor {\n #[default]\n Arrow,\n /// Doc.\n RotateToward(u8),\n Pen,\n}\n";
|
||||
|
||||
fn entry(id: &str) -> String {
|
||||
format!("[[cursor]]\nid = \"{id}\"\nshown_when = [\n \"One.\",\n]\nhotspot = \"Tip.\"\n\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_manifest_has_no_warnings() {
|
||||
let m = [entry("Arrow"), entry("RotateToward"), entry("Pen"), "[[not_needed]]\nwhat = \"x\"\n".into()].concat();
|
||||
assert_eq!(check(ENUM, &m), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warns_about_missing_unknown_repeated_and_incomplete_entries() {
|
||||
let m = [entry("Arrow"), entry("Arrow"), entry("Gone"), "[[cursor]]\nid = \"Pen\"\n".into()].concat();
|
||||
let w = check(ENUM, &m).join("\n");
|
||||
assert!(w.contains("Arrow is listed twice"), "{w}");
|
||||
assert!(w.contains("Gone is not a Cursor value"), "{w}");
|
||||
assert!(w.contains("Pen has no shown_when") && w.contains("Pen has no hotspot"), "{w}");
|
||||
assert!(w.contains("Cursor::RotateToward has no entry"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_variants_in_order_and_entries_with_their_designer_flags() {
|
||||
let v: Vec<String> = variants(ENUM).into_iter().map(|v| v.name).collect();
|
||||
assert_eq!(v, ["Arrow", "RotateToward", "Pen"]);
|
||||
let m = format!("app = \"TestCraft\"\n\n{}[[cursor]]\nid = \"Pen\"\ndesigner = false\n", entry("Arrow"));
|
||||
let e: Vec<(Option<String>, bool)> = entries(&m).into_iter().map(|e| (e.id, e.designer)).collect();
|
||||
assert_eq!(e, [(Some("Arrow".into()), true), (Some("Pen".into()), false)]);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -7,6 +7,7 @@ mod affinity_corpus;
|
||||
mod assets;
|
||||
mod brands;
|
||||
mod bundle;
|
||||
mod cursors;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod pdf_deps;
|
||||
@@ -25,10 +26,11 @@ usage: cargo xtask <command>
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||||
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
|
||||
cursors warn about Cursor values with no entry in crates/tools/src/cursors.toml (never fails)
|
||||
pdf-deps verify immutable shared PDF writer and patched Krilla sources
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app
|
||||
ci fmt --check, clippy -D warnings, test, no-bundled-fonts, pdf-deps, assets, brands, layers, wasm (stops at first failure)
|
||||
ci fmt --check, clippy -D warnings, test, no-bundled-fonts, pdf-deps, assets, brands, cursors (warnings only), layers, wasm (stops at first failure)
|
||||
corpus [--download] [--affinity]
|
||||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite,
|
||||
--affinity the pinned public Affinity documents into corpus/affinity
|
||||
@@ -46,6 +48,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("brands") => brands::run(&root()),
|
||||
Some("cursors") => cursors::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
@@ -215,6 +218,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
|
||||
("assets", Box::new(|| assets::run(&root()))),
|
||||
("brands", Box::new(|| brands::run(&root()))),
|
||||
("cursors", Box::new(|| cursors::run(&root()))),
|
||||
("layers", Box::new(cmd_layers)),
|
||||
("wasm", Box::new(cmd_wasm)),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user