mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -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
+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