continued work; wip

This commit is contained in:
Brandon Thomas
2026-09-30 11:24:38 -04:00
parent 94e72f490e
commit 6dd0d2dcff
47 changed files with 8539 additions and 121 deletions
+2
View File
@@ -17,6 +17,7 @@ mod paint;
mod panelcmds;
mod path;
mod pathops;
pub mod prefscmds;
mod recolor;
mod select;
mod textedit;
@@ -143,6 +144,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(panelcmds::specs());
v.extend(buildcmds::specs());
v.extend(brushsym::specs());
v.extend(prefscmds::specs());
v
})
}
+449
View File
@@ -0,0 +1,449 @@
//! 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", "AICB (no transparency support)", bool),
p!("aicbMode", "Clipboard Handling", "On Copy", "AICB", 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;
drawcraft_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(),
))
}
+254 -5
View File
@@ -154,21 +154,249 @@ pub enum DrawMode {
Inside,
}
/// Application preferences that affect commands.
#[derive(Clone, Debug)]
/// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` /
/// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
// General
/// Arrow-key nudge distance (General → Keyboard Increment), points.
pub keyboard_increment: f64,
pub constrain_angle: f64,
pub corner_radius: f64,
pub disable_auto_add_delete: bool,
pub use_precise_cursors: bool,
pub show_tool_tips: bool,
pub anti_aliased_artwork: bool,
pub select_same_tint_percent: bool,
pub show_home_screen: bool,
pub use_preview_bounds: bool,
pub display_print_size: bool,
pub double_click_to_isolate: bool,
pub transform_pattern_tiles: bool,
pub scale_corners: bool,
/// Scale Strokes & Effects.
pub scale_strokes: bool,
pub zoom_with_mouse_wheel: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
pub corner_radius: f64,
// Selection & Anchor Display
pub selection_tolerance: f64,
pub object_selection_by_path_only: bool,
pub snap_to_point_tolerance: f64,
pub ctrl_click_selects_behind: bool,
pub zoom_to_selection: bool,
pub move_locked_with_artboard: bool,
pub anchor_size: u32,
pub handle_style: String,
pub highlight_anchors_on_hover: bool,
pub show_handles_multiple_anchors: bool,
pub hide_corner_widget_above: f64,
pub pen_rubber_band: bool,
pub curvature_rubber_band: bool,
// Type
pub type_size_increment: f64,
pub tracking_increment: f64,
pub baseline_shift_increment: f64,
pub show_east_asian_options: bool,
pub show_indic_options: bool,
pub type_selection_by_path_only: bool,
pub font_names_in_english: bool,
pub auto_size_area_type: bool,
pub font_preview: bool,
pub font_preview_size: String,
pub recent_fonts_count: u32,
pub missing_glyph_protection: bool,
pub highlight_alternate_glyphs: bool,
pub placeholder_text: bool,
// Units
pub units_general: String,
pub units_stroke: String,
pub units_type: String,
pub units_asian_type: String,
pub numbers_without_units_are_points: bool,
pub identify_objects_by: String,
// Guides & Grid
pub guide_color: String,
pub guide_style: String,
pub grid_color: String,
pub grid_style: String,
pub gridline_every: f64,
pub grid_subdivisions: u32,
pub grids_in_back: bool,
pub show_pixel_grid: bool,
// Smart Guides
pub smart_guide_color: String,
pub alignment_guides: bool,
pub object_highlighting: bool,
pub transform_tools_guides: bool,
pub construction_guides: bool,
pub construction_angles: String,
pub anchor_path_labels: bool,
pub measurement_labels: bool,
pub spacing_guides: bool,
pub snapping_tolerance: f64,
// Slices
pub show_slice_numbers: bool,
pub slice_line_color: String,
// Hyphenation
pub hyphenation_language: String,
pub hyphenation_exceptions: String,
// Performance & Storage (Plug-ins & Scratch Disks)
pub plugins_folder: String,
pub scratch_primary: String,
pub scratch_secondary: String,
// User Interface
pub ui_brightness: String,
pub canvas_color: String,
pub auto_collapse_icon_panels: bool,
pub open_documents_as_tabs: bool,
pub large_tabs: bool,
pub ui_scaling: f64,
pub scale_cursor_with_ui: bool,
// Performance
pub gpu_performance: bool,
pub animated_zoom: bool,
pub history_states: u32,
pub real_time_drawing: bool,
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
// File Handling
pub background_save: bool,
pub background_export: bool,
pub autosave_recovery: bool,
pub autosave_interval: u32,
pub recovery_folder: String,
pub recovery_off_for_complex: bool,
pub recent_files_count: u32,
pub low_res_proxy_eps: bool,
pub anti_aliased_bitmaps: bool,
pub update_links: String,
// Clipboard Handling
pub copy_as_svg: bool,
pub copy_as_pdf: bool,
pub copy_aicb: bool,
pub aicb_mode: String,
pub paste_text_formatting: String,
// Appearance of Black
pub black_on_screen: String,
pub black_output: String,
// Devices
pub touch_workspace: bool,
pub touch_gestures: bool,
}
impl Default for Prefs {
fn default() -> Self {
Self { keyboard_increment: 1.0, scale_strokes: true, paste_offset: 10.0, corner_radius: 12.0 }
let s = |v: &str| v.to_string();
Self {
keyboard_increment: 1.0,
constrain_angle: 0.0,
corner_radius: 12.0,
disable_auto_add_delete: false,
use_precise_cursors: false,
show_tool_tips: true,
anti_aliased_artwork: true,
select_same_tint_percent: false,
show_home_screen: true,
use_preview_bounds: false,
display_print_size: false,
double_click_to_isolate: true,
transform_pattern_tiles: false,
scale_corners: false,
scale_strokes: true,
zoom_with_mouse_wheel: false,
paste_offset: 10.0,
selection_tolerance: 3.0,
object_selection_by_path_only: false,
snap_to_point_tolerance: 2.0,
ctrl_click_selects_behind: true,
zoom_to_selection: true,
move_locked_with_artboard: false,
anchor_size: 3,
handle_style: s("solid"),
highlight_anchors_on_hover: true,
show_handles_multiple_anchors: true,
hide_corner_widget_above: 177.0,
pen_rubber_band: true,
curvature_rubber_band: true,
type_size_increment: 2.0,
tracking_increment: 20.0,
baseline_shift_increment: 2.0,
show_east_asian_options: false,
show_indic_options: false,
type_selection_by_path_only: false,
font_names_in_english: true,
auto_size_area_type: false,
font_preview: true,
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
units_type: s("points"),
units_asian_type: s("points"),
numbers_without_units_are_points: true,
identify_objects_by: s("objectName"),
guide_color: s("#4affff"),
guide_style: s("lines"),
grid_color: s("#c8c8c8"),
grid_style: s("lines"),
gridline_every: 72.0,
grid_subdivisions: 8,
grids_in_back: true,
show_pixel_grid: true,
smart_guide_color: s("#ff4af0"),
alignment_guides: true,
object_highlighting: true,
transform_tools_guides: true,
construction_guides: true,
construction_angles: s("90° & 45° Angles"),
anchor_path_labels: true,
measurement_labels: true,
spacing_guides: true,
snapping_tolerance: 4.0,
show_slice_numbers: true,
slice_line_color: s("#ff3f3f"),
hyphenation_language: s("English: USA"),
hyphenation_exceptions: String::new(),
plugins_folder: String::new(),
scratch_primary: s("Startup"),
scratch_secondary: s("None"),
ui_brightness: s("mediumDark"),
canvas_color: s("matchUi"),
auto_collapse_icon_panels: false,
open_documents_as_tabs: true,
large_tabs: false,
ui_scaling: 1.0,
scale_cursor_with_ui: false,
gpu_performance: true,
animated_zoom: true,
history_states: 500,
real_time_drawing: true,
render_threads: -1,
background_save: true,
background_export: true,
autosave_recovery: true,
autosave_interval: 2,
recovery_folder: String::new(),
recovery_off_for_complex: false,
recent_files_count: 20,
low_res_proxy_eps: false,
anti_aliased_bitmaps: false,
update_links: s("askWhenModified"),
copy_as_svg: true,
copy_as_pdf: false,
copy_aicb: false,
aicb_mode: s("preserveAppearance"),
paste_text_formatting: s("keep"),
black_on_screen: s("accurate"),
black_output: s("accurate"),
touch_workspace: true,
touch_gestures: true,
}
}
}
@@ -240,8 +468,23 @@ impl Session {
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// Finish the tool's pending work in the current document and give it fresh state, so nothing
/// (e.g. uncommitted typing) leaks into the document we are switching to.
fn reset_tool_for_doc_switch(&mut self) {
if self.active.is_none() {
return;
}
let view = self.last_view;
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
let _ = self.cancel_interaction();
self.tool = drawcraft_tools::create(self.tool.id());
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
if self.active != Some(index) {
self.reset_tool_for_doc_switch();
}
self.active = Some(index);
true
} else {
@@ -254,7 +497,10 @@ impl Session {
}
/// Add a document and make it active.
pub fn add_document(&mut self, doc: Document, path: Option<String>) -> usize {
self.docs.push(DocState::new(doc, path));
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
i
@@ -263,6 +509,7 @@ impl Session {
if index >= self.docs.len() {
return false;
}
self.reset_tool_for_doc_switch();
self.docs.remove(index);
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
true
@@ -423,6 +670,8 @@ mod tests_menucmds;
#[cfg(test)]
mod tests_panelcmds;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_textedit;
+121
View File
@@ -0,0 +1,121 @@
//! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`.
use serde_json::json;
use super::*;
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_SPECS, validate};
#[test]
fn every_spec_matches_a_prefs_field_and_back() {
let all = Prefs::default().to_json();
let obj = all.as_object().unwrap();
for sp in PREF_SPECS {
assert!(obj.contains_key(sp.key), "spec `{}` has no Prefs field", sp.key);
assert!(PREF_CATEGORIES.contains(&sp.category), "spec `{}` has unknown category", sp.key);
// Defaults validate against their own spec.
assert!(validate(sp.key, &obj[sp.key]).is_ok(), "default of `{}` fails validation", sp.key);
}
for k in obj.keys() {
assert!(PREF_SPECS.iter().any(|s| s.key == k), "Prefs field `{k}` has no spec");
}
for c in PREF_CATEGORIES {
assert!(PREF_SPECS.iter().any(|s| s.category == *c), "category {c} is empty");
}
}
#[test]
fn set_and_get_keyboard_increment_drives_nudge() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 5})).unwrap();
assert_eq!(s.execute("prefs.get", &json!({"key": "keyboardIncrement"})).unwrap(), json!(5.0));
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 15.0).abs() < 1e-9);
}
#[test]
fn set_rejects_out_of_range_and_wrong_types() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": -1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 9})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 2.5})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": 3})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "uiBrightness", "value": "purple"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "gridColor", "value": "#12345"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "nope", "value": 1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes"})).is_err());
assert_eq!(s.prefs, Prefs::default());
}
#[test]
fn set_normalizes_strings_labels_and_colours() {
let mut s = Session::new();
let r = s.execute("prefs.set", &json!({"values": {"uiBrightness": "Medium Light", "gridColor": "ABCDEF", "keyboardIncrement": "2.5 pt", "scaleStrokes": "false"}})).unwrap();
assert_eq!(r["uiBrightness"], json!("mediumLight"));
assert_eq!(s.prefs.grid_color, "#abcdef");
assert_eq!(s.prefs.keyboard_increment, 2.5);
assert!(!s.prefs.scale_strokes);
}
#[test]
fn batch_set_is_atomic() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 3, "anchorSize": 99}})).is_err());
assert_eq!(s.prefs.keyboard_increment, 1.0);
}
#[test]
fn grid_prefs_apply_to_open_documents() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 36, "gridSubdivisions": 4}})).unwrap();
let g = &s.doc().unwrap().doc.grid;
assert_eq!((g.spacing, g.subdivisions), (36.0, 4));
}
#[test]
fn history_states_limit_undo_depth() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "historyStates", "value": 5})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
for i in 0..9 {
s.execute("shape.rectangle", &json!({"x": i * 10, "y": 0, "width": 5, "height": 5})).unwrap();
}
assert!(s.doc().unwrap().history.undo.len() <= 5);
}
#[test]
fn reset_category_and_all() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 7, "gridColor": "#000000"}})).unwrap();
s.execute("prefs.reset", &json!({"category": "General"})).unwrap();
assert_eq!(s.prefs.keyboard_increment, 1.0);
assert_eq!(s.prefs.grid_color, "#000000");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs, Prefs::default());
assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err());
}
#[test]
fn list_describes_every_pref() {
let mut s = Session::new();
let l = s.execute("prefs.list", &json!({})).unwrap();
let a = l.as_array().unwrap();
assert_eq!(a.len(), PREF_SPECS.len());
assert!(a.iter().any(|e| e["key"] == "renderThreads" && e["kind"] == "integer" && e["min"] == -1));
}
#[test]
fn prefs_serde_round_trip_and_tolerates_missing_fields() {
let mut p = Prefs::default();
p.ui_scaling = 1.25;
p.units_general = "millimeters".into();
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back, p);
let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();
assert_eq!(partial.keyboard_increment, 4.0);
assert_eq!(partial.corner_radius, 12.0);
}
+19
View File
@@ -321,3 +321,22 @@ fn commands_are_listed_with_params() {
assert!(cmds.iter().any(|c| c["id"] == id), "{id}");
}
}
#[test]
fn type_tool_state_does_not_leak_across_documents() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("First", v).unwrap();
// A second document whose first object reuses the same id.
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
let id2 = text(&mut s, "Other");
assert!(!s.tool_wants_text(), "switching documents ends the edit");
s.tool_text("XYZ", v).unwrap();
assert_eq!(obj(&s, id2).plain_text(), "Other");
s.set_active(0);
assert_eq!(obj(&s, id).plain_text(), "First");
assert!(!s.in_interaction());
}
+8 -8
View File
@@ -39,13 +39,13 @@ impl Session {
if self.tool.id() == id {
return Ok(());
}
let acts = self.with_cx(view, |t, cx| t.deactivate(cx));
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
self.apply_actions(acts)?;
self.tool = drawcraft_tools::create(id);
Ok(())
}
fn with_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
pub(crate) fn with_tool_cx<R>(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn drawcraft_tools::Tool, &ToolContext) -> R) -> R
where
R: Default,
{
@@ -67,18 +67,18 @@ impl Session {
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev));
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.apply_actions(acts)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.key(cx, key, mods));
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.apply_actions(acts)
}
/// Typed text for the active tool (Type tool).
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_cx(view, |t, cx| t.text_input(cx, text));
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
self.apply_actions(acts)
}
@@ -91,11 +91,11 @@ impl Session {
}
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
self.with_cx(view, |t, cx| t.overlays(cx))
self.with_tool_cx(view, |t, cx| t.overlays(cx))
}
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
self.with_cx(view, |t, cx| t.cursor(cx, p, mods))
self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods))
}
pub fn tool_options(&self) -> Value {
@@ -125,7 +125,7 @@ impl Session {
Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)),
Action::Notify(what) => {
let view = self.last_view;
self.with_cx(view, |t, cx| t.notify(cx, &what));
self.with_tool_cx(view, |t, cx| t.notify(cx, &what));
}
}
}