Files
vectorcraft/crates/engine/src/cmd/prefscmds.rs
T
bflatasticandClaude Opus 5.5 5d9b97c3bd M4.10: neutral file, preference and MCP wording; vendor-name CI gate
Edit -> "PDF Presets…"; the clipboard preferences read "Legacy vector
clipboard (no transparency)" / "Legacy vector clipboard" (keys copyAicb and
aicbMode unchanged); the second keyboard shortcut set is "Classic Defaults",
and the old set name is still accepted from saved UI preferences, imported
sets and shortcuts.preset. The MCP server instructions, the apply_effect and
create_graph tool descriptions, the text.createInPath params doc,
docs/mcp.md, the ui-egui crate description and the PDF compatibility docs
no longer name another vendor.

New `cargo xtask brands` (run by `cargo xtask ci`) fails on vendor product
or company names in Rust string literals (labels, params docs, menus,
panels, MCP definitions; comments and test code skipped), Cargo.toml
descriptions and text under docs/ and packaging/; a required legacy alias
is marked `brand-ok`. xtask shares one git file listing between the asset
and brand checks. docs/development.md describes the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:47:28 +07:00

482 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Preferences (Edit → Preferences): `prefs.get`, `prefs.set`, `prefs.reset`, `prefs.list`.
//!
//! [`PREF_SPECS`] is the single source of truth for every preference: its category, section,
//! label and value kind. The Preferences dialog renders from it and `prefs.set` validates with it.
use serde_json::{Map, Value, json};
use super::*;
use crate::Prefs;
/// What kind of value a preference holds.
#[derive(Clone, Copy, Debug)]
pub enum PrefKind {
Bool,
/// A number in `min..=max` shown with `unit` ("pt", "px", "°", "%", "min", "").
Num {
min: f64,
max: f64,
unit: &'static str,
},
/// A whole number in `min..=max`.
Int {
min: i64,
max: i64,
},
/// One of (value, label).
Choice(&'static [(&'static str, &'static str)]),
/// `#rrggbb`.
Color,
Text,
}
#[derive(Clone, Copy, Debug)]
pub struct PrefSpec {
pub key: &'static str,
pub category: &'static str,
/// Group heading inside the category ("" = none).
pub section: &'static str,
pub label: &'static str,
pub kind: PrefKind,
}
/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here).
pub const PREF_CATEGORIES: &[&str] = &[
"General",
"Selection & Anchor Display",
"Type",
"Units",
"Guides & Grid",
"Smart Guides",
"Slices",
"Hyphenation",
"Performance & Storage",
"User Interface",
"Performance",
"File Handling",
"Clipboard Handling",
"Appearance of Black",
"Devices",
];
pub const UNITS: &[(&str, &str)] = &[
("points", "Points"),
("picas", "Picas"),
("inches", "Inches"),
("millimeters", "Millimeters"),
("centimeters", "Centimeters"),
("pixels", "Pixels"),
];
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
macro_rules! p {
($key:literal, $cat:literal, $sec:literal, $label:literal, bool) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Bool }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, num($min:expr, $max:expr, $unit:literal)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Num { min: $min, max: $max, unit: $unit } }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, int($min:expr, $max:expr)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Int { min: $min, max: $max } }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, choice($c:expr)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Choice($c) }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, color) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Color }
};
($key:literal, $cat:literal, $sec:literal, $label:literal, text) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Text }
};
}
pub const PREF_SPECS: &[PrefSpec] = &[
// General
p!("keyboardIncrement", "General", "", "Keyboard Increment", num(0.001, 1296.0, "pt")),
p!("constrainAngle", "General", "", "Constrain Angle", num(-360.0, 360.0, "°")),
p!("cornerRadius", "General", "", "Corner Radius", num(0.0, 1296.0, "pt")),
p!("pasteOffset", "General", "", "Paste Offset", num(0.0, 1296.0, "pt")),
p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool),
p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool),
p!("showToolTips", "General", "Options", "Show Tool Tips", bool),
p!("antiAliasedArtwork", "General", "Options", "Anti-aliased Artwork", bool),
p!("selectSameTintPercent", "General", "Options", "Select Same Tint %", bool),
p!("showHomeScreen", "General", "Options", "Show The Home Screen When No Documents Are Open", bool),
p!("usePreviewBounds", "General", "Options", "Use Preview Bounds", bool),
p!("displayPrintSize", "General", "Options", "Display Print Size at 100% Zoom", bool),
p!("doubleClickToIsolate", "General", "Options", "Double Click To Isolate", bool),
p!("transformPatternTiles", "General", "Options", "Transform Pattern Tiles", bool),
p!("scaleCorners", "General", "Options", "Scale Corners", bool),
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
p!("snapToPointTolerance", "Selection & Anchor Display", "Selection", "Snap to Point", num(1.0, 8.0, "px")),
p!("ctrlClickSelectsBehind", "Selection & Anchor Display", "Selection", "Command Click to Select Objects Behind", bool),
p!("zoomToSelection", "Selection & Anchor Display", "Selection", "Zoom to Selection", bool),
p!("moveLockedWithArtboard", "Selection & Anchor Display", "Selection", "Move Locked and Hidden Artwork with Artboard", bool),
p!("anchorSize", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Size", int(1, 7)),
p!(
"handleStyle",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Handles",
choice(&[("solid", "Solid"), ("hollow", "Hollow"), ("large", "Large")])
),
p!(
"highlightAnchorsOnHover",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Highlight anchors on mouse over",
bool
),
p!(
"showHandlesMultipleAnchors",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Show handles when multiple anchors are selected",
bool
),
p!(
"hideCornerWidgetAbove",
"Selection & Anchor Display",
"Anchor Points, Handle, and Bounding Box Display",
"Hide Corner Widget for angles greater than",
num(0.0, 180.0, "°")
),
p!("penRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Pen Tool", bool),
p!("curvatureRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Curvature Tool", bool),
// Type
p!("typeSizeIncrement", "Type", "", "Size/Leading", num(0.001, 1296.0, "pt")),
p!("trackingIncrement", "Type", "", "Tracking", num(1.0, 1000.0, "/1000 em")),
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", num(0.001, 1296.0, "pt")),
p!("showEastAsianOptions", "Type", "Language Options", "Show East Asian Options", bool),
p!("showIndicOptions", "Type", "Language Options", "Show Indic Options", bool),
p!("typeSelectionByPathOnly", "Type", "Options", "Type Object Selection by Path Only", bool),
p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool),
p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool),
p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool),
p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])),
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
// Units
p!("unitsGeneral", "Units", "", "General", choice(UNITS)),
p!("unitsStroke", "Units", "", "Stroke", choice(UNITS)),
p!("unitsType", "Units", "", "Type", choice(UNITS)),
p!("unitsAsianType", "Units", "", "East Asian Type", choice(UNITS)),
p!("numbersWithoutUnitsArePoints", "Units", "", "Numbers Without Units Are Points", bool),
p!("identifyObjectsBy", "Units", "", "Identify Objects By", choice(&[("objectName", "Object Name"), ("xmlId", "XML ID")])),
// Guides & Grid
p!("guideColor", "Guides & Grid", "Guides", "Color", color),
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", num(1.0, 16383.0, "pt")),
p!("gridSubdivisions", "Guides & Grid", "Grid", "Subdivisions", int(1, 1000)),
p!("gridsInBack", "Guides & Grid", "Grid", "Grids In Back", bool),
p!("showPixelGrid", "Guides & Grid", "Grid", "Show Pixel Grid (Above 600% Zoom)", bool),
// Smart Guides
p!("smartGuideColor", "Smart Guides", "Display Options", "Color", color),
p!("alignmentGuides", "Smart Guides", "Display Options", "Alignment Guides", bool),
p!("objectHighlighting", "Smart Guides", "Display Options", "Object Highlighting", bool),
p!("transformToolsGuides", "Smart Guides", "Display Options", "Transform Tools", bool),
p!("constructionGuides", "Smart Guides", "Display Options", "Construction Guides", bool),
p!(
"constructionAngles",
"Smart Guides",
"Display Options",
"Angles",
choice(&[
("90° & 45° Angles", "90° & 45° Angles"),
("30° Angles", "30° Angles"),
("45° Angles", "45° Angles"),
("60° Angles", "60° Angles"),
("90° Angles", "90° Angles"),
("90° & 45° & 30° Angles", "90° & 45° & 30° Angles"),
])
),
p!("anchorPathLabels", "Smart Guides", "Display Options", "Anchor/Path Labels", bool),
p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool),
p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool),
p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")),
// Slices
p!("showSliceNumbers", "Slices", "", "Show Slice Numbers", bool),
p!("sliceLineColor", "Slices", "", "Line Color", color),
// Hyphenation
p!(
"hyphenationLanguage",
"Hyphenation",
"",
"Default Language",
choice(&[
("English: USA", "English: USA"),
("English: UK", "English: UK"),
("French", "French"),
("German", "German"),
("Spanish", "Spanish"),
("Italian", "Italian"),
("Dutch", "Dutch"),
("Portuguese", "Portuguese")
])
),
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
// Performance & Storage
p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text),
p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text),
p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text),
// User Interface
p!(
"uiBrightness",
"User Interface",
"",
"Brightness",
choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")])
),
p!("canvasColor", "User Interface", "", "Canvas Color", choice(&[("matchUi", "Match User Interface Brightness"), ("white", "White")])),
p!("autoCollapseIconPanels", "User Interface", "", "Auto-Collapse Iconic Panels", bool),
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
// File Handling
p!("backgroundSave", "File Handling", "", "Enable Background Save", bool),
p!("backgroundExport", "File Handling", "", "Enable Background Export", bool),
p!("autosaveRecovery", "File Handling", "Data Recovery", "Automatically Save Recovery Data", bool),
p!("autosaveInterval", "File Handling", "Data Recovery", "Every (minutes)", int(1, 60)),
p!("recoveryFolder", "File Handling", "Data Recovery", "Folder", text),
p!("recoveryOffForComplex", "File Handling", "Data Recovery", "Turn off Data Recovery for complex documents", bool),
p!("recentFilesCount", "File Handling", "Files", "Number of Recent Files to Display", int(0, 30)),
p!("lowResProxyEps", "File Handling", "Files", "Use Low Resolution Proxy for Linked EPS", bool),
p!("antiAliasedBitmaps", "File Handling", "Files", "Display Bitmaps as Anti-aliased Images in Pixel Preview", bool),
p!(
"updateLinks",
"File Handling",
"Files",
"Update Links",
choice(&[("automatically", "Automatically"), ("manually", "Manually"), ("askWhenModified", "Ask When Modified")])
),
// Clipboard Handling
p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool),
p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool),
p!("copyAicb", "Clipboard Handling", "On Copy", "Legacy vector clipboard (no transparency)", bool),
p!(
"aicbMode",
"Clipboard Handling",
"On Copy",
"Legacy vector clipboard",
choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])
),
p!(
"pasteTextFormatting",
"Clipboard Handling",
"On Paste",
"When pasting text",
choice(&[("keep", "Keep Formatting"), ("plain", "Keep Plain Text")])
),
// Appearance of Black
p!("blackOnScreen", "Appearance of Black", "", "On Screen", choice(BLACK)),
p!("blackOutput", "Appearance of Black", "", "Printing / Exporting", choice(BLACK_OUT)),
// Devices
p!("touchWorkspace", "Devices", "", "Enable Touch Workspace", bool),
p!("touchGestures", "Devices", "", "Enable Touch Gestures", bool),
];
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
PREF_SPECS.iter().find(|s| s.key == key)
}
/// Validate (and normalize) a value for `key`. Numbers may be given as strings; booleans as
/// "true"/"false"; choices by value or label (case-insensitive).
pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
let sp = spec(key).ok_or_else(|| format!("unknown preference `{key}`"))?;
let num =
|| v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().trim_end_matches(|c: char| c.is_alphabetic() || c == '°').trim().parse().ok()));
match sp.kind {
PrefKind::Bool => match v {
Value::Bool(b) => Ok(json!(b)),
Value::String(s) if s == "true" || s == "false" => Ok(json!(s == "true")),
_ => Err(format!("`{key}` must be true or false")),
},
PrefKind::Num { min, max, .. } => match num() {
Some(x) if x.is_finite() && (min..=max).contains(&x) => Ok(json!(x)),
Some(x) => Err(format!("`{key}` must be between {min} and {max} (got {x})")),
None => Err(format!("`{key}` must be a number")),
},
PrefKind::Int { min, max } => match num() {
Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(json!(x as i64)),
Some(x) => Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")),
None => Err(format!("`{key}` must be a whole number")),
},
PrefKind::Choice(opts) => {
let s = v.as_str().ok_or_else(|| format!("`{key}` must be a string"))?;
opts.iter()
.find(|(val, label)| val.eq_ignore_ascii_case(s) || label.eq_ignore_ascii_case(s))
.map(|(val, _)| json!(val))
.ok_or_else(|| format!("`{key}` must be one of {}", opts.iter().map(|o| o.0).collect::<Vec<_>>().join(", ")))
}
PrefKind::Color => {
let s = v.as_str().unwrap_or("");
let hex = s.trim_start_matches('#');
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(json!(format!("#{}", hex.to_ascii_lowercase())))
} else {
Err(format!("`{key}` must be a #rrggbb colour"))
}
}
PrefKind::Text => match v {
Value::String(s) => Ok(json!(s)),
_ => Err(format!("`{key}` must be a string")),
},
}
}
impl Prefs {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
/// Set several validated values at once; on any error nothing changes.
pub fn set_values(&mut self, values: &Map<String, Value>) -> std::result::Result<(), String> {
let mut obj = self.to_json();
for (k, v) in values {
obj[k.as_str()] = validate(k, v)?;
}
*self = serde_json::from_value(obj).map_err(|e| e.to_string())?;
Ok(())
}
/// Values of one category (`None` = all) reset to defaults.
pub fn reset(&mut self, category: Option<&str>) {
let d = Prefs::default().to_json();
let mut obj = self.to_json();
for sp in PREF_SPECS.iter().filter(|s| category.is_none_or(|c| c.eq_ignore_ascii_case(s.category))) {
obj[sp.key] = d[sp.key].clone();
}
if let Ok(p) = serde_json::from_value(obj) {
*self = p;
}
}
}
impl Session {
/// Replace the preferences and push the ones with engine-side consumers into open documents
/// and the renderer (grid, history depth, render threads).
pub fn apply_prefs(&mut self, p: Prefs) {
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
let history_changed = p.history_states != self.prefs.history_states;
self.prefs = p;
vectorcraft_render::set_default_threads(u16::try_from(self.prefs.render_threads).ok());
for st in &mut self.docs {
if history_changed {
st.history.limit = self.prefs.history_states as usize;
let over = st.history.undo.len().saturating_sub(st.history.limit);
st.history.undo.drain(..over);
}
if grid_changed {
let d = std::sync::Arc::make_mut(&mut st.doc);
d.grid.spacing = self.prefs.gridline_every;
d.grid.subdivisions = self.prefs.grid_subdivisions;
st.revision += 1;
}
}
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference, or {prefs: {…all…}}", always, get),
cmd!(
query "prefs.set",
"Set Preference",
[],
None,
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list)",
always,
set
),
cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset),
cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list),
]
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let all = s.prefs.to_json();
match str_param(p, "key") {
Some(k) if spec(k).is_some() => Ok(all[k].clone()),
Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))),
None => Ok(json!({ "prefs": all })),
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
if let Some(k) = str_param(p, "key") {
values.insert(k.to_string(), p.get("value").cloned().ok_or_else(|| bad("prefs.set", "missing `value`"))?);
}
if values.is_empty() {
return Err(bad("prefs.set", "give {key, value} or {values}"));
}
let mut next = s.prefs.clone();
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
s.apply_prefs(next);
let all = s.prefs.to_json();
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let cat = str_param(p, "category");
if let Some(c) = cat
&& !PREF_CATEGORIES.iter().any(|x| x.eq_ignore_ascii_case(c))
{
return Err(bad("prefs.reset", format!("unknown category `{c}`")));
}
let mut next = s.prefs.clone();
next.reset(cat);
s.apply_prefs(next);
Ok(json!({ "prefs": s.prefs.to_json() }))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let all = s.prefs.to_json();
Ok(Value::Array(
PREF_SPECS
.iter()
.map(|sp| {
let mut o = json!({"key": sp.key, "category": sp.category, "section": sp.section, "label": sp.label, "value": all[sp.key]});
match sp.kind {
PrefKind::Bool => o["kind"] = json!("bool"),
PrefKind::Num { min, max, unit } => {
o["kind"] = json!("number");
o["min"] = json!(min);
o["max"] = json!(max);
o["unit"] = json!(unit);
}
PrefKind::Int { min, max } => {
o["kind"] = json!("integer");
o["min"] = json!(min);
o["max"] = json!(max);
}
PrefKind::Choice(c) => {
o["kind"] = json!("choice");
o["options"] = json!(c.iter().map(|x| x.0).collect::<Vec<_>>());
}
PrefKind::Color => o["kind"] = json!("color"),
PrefKind::Text => o["kind"] = json!("text"),
}
o
})
.collect(),
))
}