M14.5: Preferences > Units drive every length the UI shows and reads

The General unit is the active document's units: Preferences > Units >
General sets them (one undo step) and is the units new documents start
in (and the unit shown with no document open); Document Setup changes
only the document. The Transform panel, Properties, the Control bar, the
Info panel, rulers, the status bar, Align spacing, Tabs, paragraph
indents, Pattern Options, dialog distances (Move, shape sizes, path
dialogs, Artboard Options, effect distances, Transform Each, parameter
dialogs), New Document and the canvas measurement labels (smart-guide
sizes, moves, the Measure tool, the Live Corners radius) all follow it,
live. Units > Type drives font size, leading and baseline shift; Units >
Stroke also reaches the Appearance panel and the Magic Wand weight
tolerance. Typed units ("5 mm", "12 pt") work in any field; all ten
units are choices; length preferences show in their unit and accept a
unit; offset/simplify/grid/type-size params read typed units. Typed text
starting with a multi-byte character no longer panics the parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 07:43:41 +07:00
co-authored by Claude Opus 5.5
parent 0e31fc92a2
commit 3b99df3b9a
50 changed files with 869 additions and 182 deletions
+8 -15
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
Some("Cmd+N"),
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
always,
file_new
),
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Units",
["File", "Document Setup"],
None,
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}",
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"|\"Feet & Inches\"|\"Meters\"|\"Yards\"|\"Feet\"} the document's units: every length the UI shows and reads (General)",
has_doc,
set_units
),
@@ -64,9 +64,10 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut d = Document::new_with_mode(w, h, mode);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
if let Some(u) = str_param(p, "units") {
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
}
d.units = match str_param(p, "units") {
Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?,
None => s.default_units(),
};
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
for i in 1..n {
let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
@@ -82,11 +83,6 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "index": i }))
}
pub(crate) fn parse_unit(u: &str) -> Option<Unit> {
let n = u.to_ascii_lowercase();
Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n)
}
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if !s.close_document(i) {
@@ -107,11 +103,8 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
}
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.edit("Document Setup", |d, _| {
d.units = u;
Ok(())
})?;
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.set_document_units(u)?;
ok()
}
+12 -3
View File
@@ -180,6 +180,15 @@ pub(crate) fn num_param(p: &Value, key: &str) -> Option<f64> {
.filter(|v: &f64| v.is_finite())
}
/// A distance param in points: a number, or a string with a unit (`"5 mm"`; bare numbers are
/// points).
pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
match p.get(key)? {
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| num_param(p, key)),
_ => num_param(p, key),
}
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
@@ -397,7 +406,7 @@ fn join_param(p: &Value, key: &str) -> po::Join {
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let delta = num_param(p, "offset").unwrap_or(10.0);
let delta = len_param(p, "offset").unwrap_or(10.0);
if delta.abs() > 1e5 {
return Err(bad("object.path.offsetPath", "offset out of range"));
}
@@ -661,7 +670,7 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let opts = po::SimplifyOptions {
tolerance: num_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
tolerance: len_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
straight_lines: bool_or(p, "straightLines", false),
};
@@ -682,7 +691,7 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
}
let gutter = num_param(p, "gutter").unwrap_or(12.0).max(0.0);
let gutter = len_param(p, "gutter").unwrap_or(12.0).max(0.0);
let roots = selected_roots(s)?;
let ids = s.edit("Split Into Grid", |d, sel| {
let mut out = vec![];
+46 -12
View File
@@ -7,6 +7,8 @@ use serde_json::{Map, Value, json};
use super::*;
use crate::Prefs;
use crate::units::Measure;
use vectorcraft_doc::Unit;
/// What kind of value a preference holds.
#[derive(Clone, Copy, Debug)]
@@ -18,6 +20,13 @@ pub enum PrefKind {
max: f64,
unit: &'static str,
},
/// A length in points in `min..=max`, shown and typed in the unit of `measure` (also given
/// as a string with a unit, "5 mm").
Length {
min: f64,
max: f64,
measure: Measure,
},
/// A whole number in `min..=max`.
Int {
min: i64,
@@ -59,6 +68,7 @@ pub const PREF_CATEGORIES: &[&str] = &[
"Devices",
];
/// Every unit as (`Unit::key`, `Unit::label`).
pub const UNITS: &[(&str, &str)] = &[
("points", "Points"),
("picas", "Picas"),
@@ -66,6 +76,10 @@ pub const UNITS: &[(&str, &str)] = &[
("millimeters", "Millimeters"),
("centimeters", "Centimeters"),
("pixels", "Pixels"),
("feetInches", "Feet & Inches"),
("meters", "Meters"),
("yards", "Yards"),
("feet", "Feet"),
];
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")];
@@ -78,6 +92,9 @@ macro_rules! p {
($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, len($min:expr, $max:expr, $m:ident)) => {
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Length { min: $min, max: $max, measure: Measure::$m } }
};
($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 } }
};
@@ -94,10 +111,10 @@ macro_rules! p {
pub const PREF_SPECS: &[PrefSpec] = &[
// General
p!("keyboardIncrement", "General", "", "Keyboard Increment", num(0.001, 1296.0, "pt")),
p!("keyboardIncrement", "General", "", "Keyboard Increment", len(0.001, 1296.0, General)),
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!("cornerRadius", "General", "", "Corner Radius", len(0.0, 1296.0, General)),
p!("pasteOffset", "General", "", "Paste Offset", len(0.0, 1296.0, General)),
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),
@@ -150,9 +167,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[
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!("typeSizeIncrement", "Type", "", "Size/Leading", len(0.001, 1296.0, Type)),
p!("trackingIncrement", "Type", "", "Tracking", num(1.0, 1000.0, "/1000 em")),
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", num(0.001, 1296.0, "pt")),
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", len(0.001, 1296.0, Type)),
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),
@@ -176,7 +193,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
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!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)),
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),
@@ -333,11 +350,8 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
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::Num { min, max, .. } => in_range(key, num(), min, max),
PrefKind::Length { min, max, .. } => in_range(key, v.as_f64().or_else(|| v.as_str().and_then(|s| Unit::Points.parse(s))), min, max),
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})")),
@@ -366,6 +380,15 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
}
}
/// `x` (`None`: not a number) as the value of number preference `key` in `min..=max`.
fn in_range(key: &str, x: Option<f64>, min: f64, max: f64) -> std::result::Result<Value, String> {
match x {
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")),
}
}
impl Prefs {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
@@ -436,7 +459,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Preference",
[],
None,
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; the eyedropper group takes a partial object, as eyedropper.setOptions)",
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions)",
always,
set
),
@@ -465,6 +488,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = s.prefs.clone();
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
s.apply_prefs(next);
// Units ▸ General is also the open document's units (as Document Setup sets them).
if values.contains_key("unitsGeneral") && s.active().is_some() {
s.set_document_units(s.default_units())?;
}
let all = s.prefs.to_json();
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
}
@@ -497,6 +524,13 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
o["max"] = json!(max);
o["unit"] = json!(unit);
}
PrefKind::Length { min, max, measure } => {
o["kind"] = json!("number");
o["min"] = json!(min);
o["max"] = json!(max);
o["unit"] = json!("pt");
o["measure"] = json!(measure.name());
}
PrefKind::Int { min, max } => {
o["kind"] = json!("integer");
o["min"] = json!(min);
+1 -1
View File
@@ -241,7 +241,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
impl Session {
/// Units > Stroke (Preferences): the unit stroke weights and dash lengths show in.
pub fn stroke_unit(&self) -> Unit {
super::edit::parse_unit(&self.prefs.units_stroke).unwrap_or_default()
self.unit(crate::units::Measure::Stroke)
}
/// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the
+3 -3
View File
@@ -8,7 +8,7 @@ use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::PathData;
use super::edit::selected_roots;
use super::pathops::{num_param, shape_node};
use super::pathops::{len_param, num_param, shape_node};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -169,14 +169,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setStyle";
let font = str_param(p, "font").map(str::to_string);
let style = str_param(p, "style").map(str::to_string);
let size = num_param(p, "size");
let size = len_param(p, "size");
if size.is_some_and(|v| v <= 0.0) {
return Err(bad(C, "size must be positive"));
}
let leading = match p.get("leading") {
None | Some(Value::Null) => None,
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
Some(_) => Some(Some(num_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
};
let tracking = num_param(p, "tracking");
let justify = match str_param(p, "justify") {