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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Gieson
2026-10-17 12:26:12 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 816097c6bf
commit 2d0da1472f
32 changed files with 1683 additions and 94 deletions
+6 -1
View File
@@ -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}");
}
+145
View File
@@ -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
View File
@@ -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)),
];