Merge main into feat/live-rasterize-effect

This commit is contained in:
dotCooCoo
2026-10-17 01:25:27 -07:00
48 changed files with 17257 additions and 125 deletions
+1
View File
@@ -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`).
+1
View File
@@ -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`).
+1
View File
@@ -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. |
+253
View File
@@ -0,0 +1,253 @@
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
//! The panel gets a Format menu as its accessory view
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
//! `beginSheetModalForWindow:completionHandler:`
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
//! one. The save command takes the format from the extension, so an extension typed by hand still
//! decides it.
//!
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
//! the menu's action have no safe API; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
const MARGIN: f64 = 12.0;
const GAP: f64 = 8.0;
/// The Format menu's size, in points.
const MENU: NSSize = NSSize::new(260.0, 26.0);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
set_allowed_types(&panel, &self.ivars().filters, index);
let name = panel.nameFieldStringValue().to_string();
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
}
}
);
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if named.len() < 2 { Vec::new() } else { named }
}
/// The menu entry of the suggested name's type, or the first (the request's default).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = fileio::extension(name);
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
}
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
/// writes) is replaced, and other dots stay part of the name (a version number).
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return String::new();
}
let has = fileio::extension(name);
let known =
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
}
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
}
panel.setExtensionHidden(false);
if !pick.name.is_empty() {
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
}
let accessory = accessory(mtm, &panel, filters, selected);
panel.setAccessoryView(Some(&accessory));
let (tx, rx) = channel();
let answered = panel.clone();
let handler = RcBlock::new(move |response: NSModalResponse| {
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
// The app gone meanwhile has no use for the answer.
let _ = tx.send(paths);
});
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.sizeToFit();
let size = label.frame().size;
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
let x = MARGIN + size.width + GAP;
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
for (name, _) in &filters {
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
// sheet is open. The selector has the target/action signature NSControl calls it with.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
}
accessory.addSubview(&label);
accessory.addSubview(&popup);
accessory
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
}
}
#[test]
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
for (name, expected) in [
("図面 1.vectorcraft", "図面 1.svg"),
("a.PDF", "a.svg"),
("art", "art.svg"),
("art.v2", "art.v2.svg"),
("art.svg", "art.svg"),
("old.drawcraft", "old.svg"),
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
}
}
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
}
}
}
+15 -7
View File
@@ -35,6 +35,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
@@ -473,7 +475,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
@@ -485,12 +487,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
+5
View File
@@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
doc.template = false;
doc.title = s.next_untitled();
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
doc.layers_open = Some(vec![]);
}
// Save writes the file back only in a format it writes, only a whole PDF (see
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
// writes .ai that way).
@@ -374,3 +374,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}
+2 -2
View File
@@ -345,7 +345,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
/// its top-level layers open (a `.ai` another app wrote opens with none open).
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
@@ -1007,7 +1007,7 @@ impl Default for Prefs {
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
highlight_alternate_glyphs: false,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
+27 -13
View File
@@ -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:?}");
}
@@ -1284,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() {
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType
/// substitutions are not pink on every canvas until the user turns the preference on.
#[test]
fn highlight_alternate_glyphs_preference_is_off_by_default() {
let mut s = Session::new();
assert!(!s.prefs.highlight_alternate_glyphs, "off by default");
set_pref(&mut s, "highlightAlternateGlyphs", json!(true));
assert!(s.prefs.highlight_alternate_glyphs);
set_pref(&mut s, "highlightAlternateGlyphs", json!(false));
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
+1 -1
View File
@@ -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]
+15
View File
@@ -1072,6 +1072,21 @@ fn show_font_names_in_english_does_not_change_font_list_for_agents() {
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so the canvas stays
/// clear until an agent (or the Preferences dialog) turns it on. Headless MCP has no canvas.
#[test]
fn highlight_alternate_glyphs_pref_round_trips_for_agents() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.set", "params": {"key": "highlightAlternateGlyphs", "value": true}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.get", "params": {"key": "highlightAlternateGlyphs"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(true));
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
+42 -14
View File
@@ -136,6 +136,10 @@ pub struct RenderOptions {
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
/// Screen view: Preferences › Type › Highlight Alternate Glyphs. Pink behind glyphs OpenType
/// features substituted (ligatures, contextual forms), not Document Setup's missing-glyph
/// highlights.
pub highlight_alternate_glyphs: bool,
/// Edge smoothing (raster export option).
pub anti_alias: AntiAlias,
/// Screen view: placed documents draw from cached bitmaps made in the background (see
@@ -240,6 +244,7 @@ impl Default for RenderOptions {
guide: ([0x4a, 0xd8, 0xff], false),
mask_view: None,
highlight_substitutions: false,
highlight_alternate_glyphs: false,
anti_alias: AntiAlias::Art,
progressive_placed: false,
trace_views: false,
@@ -1802,8 +1807,11 @@ impl Renderer {
}
if f.opts.highlight_substitutions {
let setup = &f.doc.setup;
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
{
for (on, path) in [
(setup.highlight_substituted_fonts, &g.substituted_fonts),
(setup.highlight_substituted_glyphs, &g.substituted_glyphs),
(f.opts.highlight_alternate_glyphs, &g.alternate_glyphs),
] {
if on && !path.elements().is_empty() {
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
@@ -2231,6 +2239,9 @@ struct TextGeom {
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
substituted_fonts: BezPath,
substituted_glyphs: BezPath,
/// Boxes of glyphs OpenType features substituted (ligatures, contextual forms): Preferences ›
/// Highlight Alternate Glyphs.
alternate_glyphs: BezPath,
/// Inline graphics placed by the layout.
inlines: Vec<vectorcraft_text::InlineGlyph>,
}
@@ -2256,12 +2267,18 @@ impl TextGeom {
/// Approximate heap bytes, for the cache's budget.
fn bytes(&self) -> usize {
size_of::<Self>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs, &self.alternate_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ self.spans.len() * size_of::<(usize, usize, usize)>()
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
}
}
/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose
/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate).
pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool {
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
}
fn text_geom(t: &TextObject) -> TextGeom {
text_geom_snapped(t, None)
}
@@ -2301,30 +2318,39 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
None => (true, None),
})
.collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_glyphs) = (BezPath::new(), BezPath::new(), BezPath::new());
let plain = t.plain_text();
for g in &layout.glyphs {
push(&mut all, g.run, &g.outline);
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing {
&mut substituted_fonts
} else if face.is_some_and(|f| f != g.font_id) {
&mut substituted_glyphs
} else {
continue;
};
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
if missing {
substituted_fonts.extend(cell.iter());
continue;
}
if face.is_some_and(|f| f != g.font_id) {
substituted_glyphs.extend(cell.iter());
continue;
}
// Same face: OpenType may have swapped the glyph (liga, calt, …).
let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue };
let cluster_chars = cluster.chars().count();
let Some(first) = cluster.chars().next() else { continue };
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
alternate_glyphs.extend(cell.iter());
}
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
push(&mut all, run, &bar);
}
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, alternate_glyphs, inlines: layout.inlines }
}
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
/// Document Setup's and Preferences › Highlight Alternate Glyphs pink (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1).
pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) {
@@ -2380,6 +2406,8 @@ mod tests;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
mod tests_alternate;
#[cfg(test)]
mod tests_blend;
#[cfg(test)]
mod tests_charstroke;
+42
View File
@@ -0,0 +1,42 @@
//! Preferences › Type › Highlight Alternate Glyphs (#394): OpenType substitutions get a pink
//! cell behind them on screen when the preference is on.
use super::*;
#[test]
fn is_opentype_alternate_marks_ligatures_and_forms() {
assert!(is_opentype_alternate(100, 100, 2), "ligature cluster");
assert!(is_opentype_alternate(50, 99, 1), "contextual / stylistic form");
assert!(!is_opentype_alternate(50, 50, 1), "nominal cmap glyph");
assert!(!is_opentype_alternate(50, 0, 2), "missing glyph");
}
#[test]
fn highlight_alternate_glyphs_paints_pink_behind_a_ligature() {
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
// Source Serif 4 ships with standard ligatures for "fi".
let mut d = Document::new(200.0, 100.0);
let style = CharStyle { size: 48.0, font_family: "Source Serif 4".into(), ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 70.0), "fi", style))));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let off = RenderOptions {
background: Some([255, 255, 255, 255]),
highlight_substitutions: true,
highlight_alternate_glyphs: false,
..Default::default()
};
let on = RenderOptions { highlight_alternate_glyphs: true, ..off.clone() };
let mut r = Renderer::new();
let without = r.render(&d, 200, 100, Affine::IDENTITY, &off);
let with = r.render(&d, 200, 100, Affine::IDENTITY, &on);
assert_ne!(without.pixels, with.pixels, "the preference changes the frame");
// Pink SUBSTITUTED = (255, 120, 190) at alpha 110, blended over white / ink.
let pinkish = with.pixels.chunks(4).filter(|p| p[0] > 200 && p[1] < 180 && p[2] > 150 && p[2] < 230).count();
assert!(pinkish > 10, "pink highlight cells behind the ligature ({pinkish} pixels)");
}
+1 -1
View File
@@ -705,7 +705,7 @@ impl Tool for LivePaintBucketTool {
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
Cursor::LivePaintBucket
}
}
+665
View File
@@ -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
View File
@@ -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();
+2 -1
View File
@@ -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());
+37 -3
View File
@@ -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();
+12 -2
View File
@@ -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();
+10 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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");
}
}
+1 -1
View File
@@ -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
View File
@@ -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),
+1 -1
View File
@@ -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> {
+36 -1
View File
@@ -82,6 +82,8 @@ pub struct SelectionTool {
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
moving: Option<MoveSnap>,
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
snapped: bool,
guide: GuideEdit,
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
hover: Option<(Point, &'static str)>,
@@ -425,9 +427,12 @@ impl Tool for SelectionTool {
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
let unsnapped = d;
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d, m.alt);
}
// Shift has already constrained `d`: any change is a snap.
self.snapped = (d - unsnapped).hypot() > 1e-9;
self.state = State::Moving { start, began: true, deselect: None, key: None };
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y)));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
@@ -553,7 +558,10 @@ impl Tool for SelectionTool {
match self.state {
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Moving { began: true, .. } => return Cursor::Arrow,
// Alt-dragging leaves a copy behind; otherwise the filled arrow, hollow while the move
// snaps to a point or a guide.
State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy,
State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
State::Corner(_) => return Cursor::CornerRadius,
State::Pie(_) => return Cursor::ArrowHollow,
State::Bracket(_) => return Cursor::PathBracket,
@@ -868,6 +876,33 @@ mod tests {
assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0);
}
/// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right
/// one); Alt, which leaves a copy behind, the double arrowhead.
#[test]
fn a_move_shows_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() {
use vectorcraft_doc::{Appearance, Node};
let (mut d, _) = doc_with_rect();
d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0);
let l = d.layers[0].id;
let b = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
d.insert(Some(l), 1, Node::path(b, sq, Appearance::default_art())).unwrap();
let mut s = Selection::default();
s.add(b);
let p = paint();
let c = cx(&d, &s, &p);
let none = Mods::default();
let mut t = SelectionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 350.0, 350.0));
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), none), Cursor::Arrow);
t.pointer(&c, &ev(PointerKind::Drag, 253.0, 371.0));
assert_eq!(t.cursor(&c, Point::new(253.0, 371.0), none), Cursor::ArrowHollow, "lined up with A");
let alt = Mods { alt: true, ..none };
t.pointer(&c, &ev(PointerKind::Drag, 377.0, 371.0).with_mods(alt));
assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy);
}
/// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by
/// whichever of its edges or its centre is nearest a gridline, on each axis.
#[test]
+10 -1
View File
@@ -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})
+1 -1
View File
@@ -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 }
}
}
+24 -1
View File
@@ -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();
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
}
}
+46 -4
View File
@@ -302,6 +302,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
hidden: vec![],
rot: v.rotation,
anti_alias: app.session.prefs.anti_aliased_artwork,
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
placed: vectorcraft_render::placed_document::generation(),
pixel,
smooth_images,
@@ -354,6 +355,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
guide: key.guide,
mask_view,
highlight_substitutions: true,
// Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions.
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
// General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and
// pattern tiles stay smooth), as in Illustrator.
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
@@ -576,11 +579,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 +603,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 +620,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
View File
@@ -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}");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -94,7 +94,7 @@ fn plural_polish(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 20] = [
pub static LANGUAGES: [LangInfo; 23] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Greek: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
@@ -232,6 +232,26 @@ pub static LANGUAGES: [LangInfo; 20] = [
plural: plural_none,
complete_menus: true,
catalog: OnceLock::new(),
}, // Dutch; every `nl-*` locale (`nl-NL`, `nl-BE`, `nl_NL.UTF-8`…) resolves here.
LangInfo {
code: "nl",
name: "Nederlands",
source: include_str!("nl.tsv"),
plural: plural_one_other,
complete_menus: true,
catalog: OnceLock::new(),
},
// Danish; every `da-*` locale (`da-DK`, `da_DK.UTF-8`…) resolves here.
LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
// Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`…) resolves here. CLDR `id` counts
// have one form, so `@plural` entries list a single form.
LangInfo {
code: "id",
name: "Bahasa Indonesia",
source: include_str!("id.tsv"),
plural: plural_none,
complete_menus: true,
catalog: OnceLock::new(),
},
// Finnish: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa").
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -8,7 +8,7 @@ fn tags_map_to_languages() {
assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN));
assert_eq!(lang_from_tag("C"), Some(Lang::EN));
assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
assert_eq!(lang_from_tag("nl_NL"), None);
assert_eq!(lang_from_tag("qaa-QM"), None, "the private-use tag names no language");
// German: Germany, Austria, Switzerland and the rest share the one catalog.
for tag in ["de", "de_DE.UTF-8", "de-AT", "de_CH", "de-LU", "de_DE.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(de()), "{tag}");
@@ -67,7 +67,7 @@ fn candidates_walk_from_specific_to_general() {
#[test]
fn os_language_lists_are_parsed() {
assert_eq!(first_supported("(\n \"zh-Hant-TW\",\n \"en-US\"\n)\n"), Some(ZH()));
assert_eq!(first_supported("(\n \"nl-NL\",\n \"en-US\"\n)\n"), Some(Lang::EN));
assert_eq!(first_supported("(\n \"qaa-QM\",\n \"en-US\"\n)\n"), Some(Lang::EN), "the private-use tag falls through to the next entry");
assert_eq!(first_supported("(\n \"de-DE\",\n \"en-US\"\n)\n"), Some(de()));
assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr"));
assert_eq!(first_supported("("), None);
+2
View File
@@ -332,6 +332,8 @@ pub struct CacheKey {
pub rot: f64,
/// General › Anti-aliased Artwork.
pub anti_alias: bool,
/// Type › Highlight Alternate Glyphs (#394).
pub highlight_alternate_glyphs: bool,
/// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since.
pub placed: u64,
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
+4
View File
@@ -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.
+3 -1
View File
@@ -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.
@@ -192,8 +193,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G12 Preferences and panels that don't act yet
- **Missing:** ~14 Preferences options stored but not acted on (#394: Open
- **Done:** quick Image Trace presets reveal Threshold and Advanced controls (Paths, Corners, Noise); Properties can reopen them. Other context-specific Properties gaps remain.
- **Missing:** ~15 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open
Documents As Tabs, Real-time Drawing, hyphenation language, devices…), Properties panel per
context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity.
@@ -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 |
+8 -2
View File
@@ -1974,9 +1974,11 @@ their own Anti-aliasing option),
`scrubNumericFields` (on: a horizontal drag on a numeric field's label steps the field, one undo step per drag; the
control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home screen while no document is open;
off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by
default: on, a click away from a panel popped out of the icon column puts it away) and `displayPrintSize` (off by
default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by
default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills
96 screen points, an inch at the system's reference density whatever the display scaling). `blackOnScreen`
96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features
substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Fonts / Glyphs stay
separate). `blackOnScreen`
(Appearance of Black › On Screen, `accurate` by default) sets how the canvas shows pure CMYK black, black ink alone
at 100%: `accurate` shows it through the CMYK profile, a dark grey, and `rich` shows it as RGB black. Rich blacks,
black tints and images show the same either way, and exports are unchanged. `blackOutput` (Appearance of Black ›
@@ -2086,11 +2088,15 @@ Pen's second anchor at (150, 431), in line with the square's centre.
- `hyphenationExceptions` (empty by default): Preferences › Hyphenation › Exceptions. A comma-separated list of words,
matched without regard to case: a word with no hyphens is never hyphenated (`typography`), and a word with hyphens
breaks only where they are (`hap-pen`). `prefs.set` pushes the list into the hyphenator and reflows open documents.
- `highlightAlternateGlyphs` (off by default): Preferences › Type › Highlight Alternate Glyphs. On, the canvas paints
pink behind OpenType substitutions (ligatures, contextual forms). Document Setup's Highlight Substituted Fonts /
Glyphs stay separate. Agents set it with `prefs.set`; the desktop canvas reads it on the next paint.
```json
{"name":"run_command","arguments":{"command":"prefs.set","params":{"values":{"typeSizeIncrement":4,"trackingIncrement":50}}}}
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"fontNamesInEnglish","value":false}}}
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"hyphenationExceptions","value":"typography, hap-pen"}}}
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"highlightAlternateGlyphs","value":true}}}
{"name":"run_command","arguments":{"command":"type.step","params":{"attribute":"tracking","by":-2}}}
```
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on; stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607) |
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) |
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
+7 -1
View File
@@ -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 |
@@ -213,7 +218,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
| Workspaces (save, reset, switch) | done | |
| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending |
| macOS save sheet: choose the format from a menu | missing | #866 (reopened); the menu was withdrawn by #1855 to restore saving: Save As, Save a Copy and the first Save showed no sheet with it. It comes back once the format can be set before the sheet opens |
| macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet |
| Preferences: every option acts | partial | ~16 stored but unused (#394) |
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |
@@ -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
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)),
];