From 3b99df3b9addf8d9cc463c8f489cd4c2651c9e57 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:42:34 +0700 Subject: [PATCH] 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 --- crates/doc/src/lib.rs | 45 ++++++- crates/doc/src/tests_units.rs | 65 +++++++++ crates/effects/src/lib.rs | 18 ++- crates/engine/src/cmd/edit.rs | 23 ++-- crates/engine/src/cmd/pathops.rs | 15 ++- crates/engine/src/cmd/prefscmds.rs | 58 ++++++-- crates/engine/src/cmd/stroke.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 6 +- crates/engine/src/lib.rs | 3 + crates/engine/src/tests_units.rs | 134 +++++++++++++++++++ crates/engine/src/tooling.rs | 3 + crates/engine/src/units.rs | 83 ++++++++++++ crates/tools/src/corners.rs | 4 +- crates/tools/src/direct.rs | 4 +- crates/tools/src/distort/width.rs | 2 +- crates/tools/src/draw2/family.rs | 4 +- crates/tools/src/lib.rs | 20 ++- crates/tools/src/select.rs | 8 +- crates/tools/src/shape.rs | 4 +- crates/tools/src/xform.rs | 4 - crates/tools/src/xform/artboard.rs | 7 +- crates/tools/src/xform/free.rs | 8 +- crates/tools/src/xform/measure.rs | 4 +- crates/ui-egui/src/canvas.rs | 61 +++++---- crates/ui-egui/src/chrome.rs | 26 +++- crates/ui-egui/src/dialogs/command.rs | 18 ++- crates/ui-egui/src/dialogs/effect.rs | 3 +- crates/ui-egui/src/dialogs/form.rs | 64 ++++++++- crates/ui-egui/src/dialogs/mod.rs | 5 +- crates/ui-egui/src/dialogs/new_document.rs | 59 +++++--- crates/ui-egui/src/dialogs/save_pdf.rs | 2 +- crates/ui-egui/src/dialogs/transform.rs | 2 +- crates/ui-egui/src/dialogs/transform_each.rs | 15 +-- crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/panels/align.rs | 3 +- crates/ui-egui/src/panels/appearance.rs | 14 +- crates/ui-egui/src/panels/character.rs | 9 +- crates/ui-egui/src/panels/info.rs | 4 +- crates/ui-egui/src/panels/magic_wand.rs | 26 +++- crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/paragraph.rs | 14 +- crates/ui-egui/src/panels/pattern_options.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/panels/tabs.rs | 4 +- crates/ui-egui/src/panels/tests_units.rs | 128 ++++++++++++++++++ crates/ui-egui/src/panels/transform.rs | 10 +- crates/ui-egui/src/prefs_dialog.rs | 14 ++ crates/ui-egui/src/widgets.rs | 2 +- docs/control-protocol.md | 7 + docs/mcp.md | 19 +++ 50 files changed, 869 insertions(+), 182 deletions(-) create mode 100644 crates/doc/src/tests_units.rs create mode 100644 crates/engine/src/tests_units.rs create mode 100644 crates/engine/src/units.rs create mode 100644 crates/ui-egui/src/panels/tests_units.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 194f294f..5b51fa76 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -149,23 +149,55 @@ impl Unit { } /// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`). pub fn format(self, pt: f64) -> String { - let v = self.from_pt(pt); - let s = format!("{:.3}", v); + format!("{} {}", self.number(pt), self.suffix()) + } + /// [`Unit::format`] without the suffix (`12.5`), for narrow fields. + pub fn number(self, pt: f64) -> String { + let s = format!("{:.3}", self.from_pt(pt)); let s = s.trim_end_matches('0').trim_end_matches('.'); - let s = if s == "-0" { "0" } else { s }; - format!("{s} {}", self.suffix()) + if s == "-0" { "0".into() } else { s.into() } } /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. pub fn parse(self, s: &str) -> Option { parse_measure(s, self) } + /// The value naming this unit in the Units preferences (`points`, `millimeters`, + /// `feetInches`). + pub fn key(self) -> &'static str { + match self { + Unit::Points => "points", + Unit::Picas => "picas", + Unit::Inches => "inches", + Unit::Millimeters => "millimeters", + Unit::Centimeters => "centimeters", + Unit::Pixels => "pixels", + Unit::FeetInches => "feetInches", + Unit::Meters => "meters", + Unit::Yards => "yards", + Unit::Feet => "feet", + } + } + /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and + /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`). + pub fn named(name: &str) -> Option { + let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); + let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()); + Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given())) + } + /// A point value as the canvas measurement labels show it: two decimals and the suffix + /// (`12.50 mm`). + pub fn readout(self, pt: f64) -> String { + format!("{:.2} {}", self.from_pt(pt), self.suffix()) + } } fn parse_measure(s: &str, default: Unit) -> Option { let s = s.trim(); // Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2"). for op in ['+', '-', '*', '/'] { - if let Some(i) = s[1.min(s.len())..].rfind(op).map(|i| i + 1.min(s.len())) { + // Past the first character, so a leading sign isn't an operator. + let skip = s.chars().next().map_or(0, char::len_utf8); + if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) { let (l, r) = (&s[..i], &s[i + 1..]); if l.trim().is_empty() { continue; @@ -901,3 +933,6 @@ mod tests { assert_eq!(Unit::Inches.format(144.0), "2 in"); } } + +#[cfg(test)] +mod tests_units; diff --git a/crates/doc/src/tests_units.rs b/crates/doc/src/tests_units.rs new file mode 100644 index 00000000..25692284 --- /dev/null +++ b/crates/doc/src/tests_units.rs @@ -0,0 +1,65 @@ +//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names +//! and canvas readouts. + +use super::Unit; + +#[test] +fn every_unit_round_trips_through_its_field_text() { + for u in Unit::ALL { + // Field text keeps three decimals of the unit: half a step is the most a round trip loses. + let tol = 0.0005 * u.points() + 1e-9; + for pt in [0.0, 1.0, 12.5, 72.0, 100.0, 595.28, 1234.5678, -36.0] { + let shown = u.format(pt); + let back = u.parse(&shown).unwrap_or_else(|| panic!("{u:?} can't read back `{shown}`")); + assert!((back - pt).abs() <= tol, "{u:?}: {pt} → `{shown}` → {back}"); + // The bare number reads in the field's unit, and in any unit with its suffix. + let bare = u.parse(&u.number(pt)).unwrap(); + assert!((bare - pt).abs() <= tol, "{u:?}: bare `{}` → {bare}", u.number(pt)); + let pts = Unit::Points.parse(&shown).unwrap(); + assert!((pts - pt).abs() <= tol, "{u:?}: `{shown}` in a points field → {pts}"); + } + } +} + +#[test] +fn typed_units_win_over_the_fields_unit() { + let mm = 72.0 / 25.4; + assert_eq!(Unit::Millimeters.parse("10"), Some(10.0 * mm)); + assert_eq!(Unit::Millimeters.parse("12 pt"), Some(12.0)); + assert_eq!(Unit::Millimeters.parse("1in"), Some(72.0)); + assert_eq!(Unit::Points.parse("5 mm"), Some(5.0 * mm)); + assert_eq!(Unit::Centimeters.parse("2p6"), Some(30.0)); + assert_eq!(Unit::Millimeters.parse("10+5"), Some(15.0 * mm)); + assert_eq!(Unit::Inches.parse("1 + 36 pt"), Some(108.0)); + assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0)); +} + +#[test] +fn odd_text_reads_as_nothing_without_panicking() { + for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] { + assert_eq!(Unit::Millimeters.parse(s), None, "{s}"); + } +} + +#[test] +fn units_are_named_by_label_key_or_suffix() { + for u in Unit::ALL { + assert_eq!(Unit::named(u.label()), Some(u)); + assert_eq!(Unit::named(u.key()), Some(u)); + assert_eq!(Unit::named(&u.label().to_uppercase()), Some(u)); + } + assert_eq!(Unit::named("mm"), Some(Unit::Millimeters)); + assert_eq!(Unit::named("Feet & Inches"), Some(Unit::FeetInches)); + assert_eq!(Unit::named("feetInches"), Some(Unit::FeetInches)); + assert_eq!(Unit::named("furlongs"), None); + assert_eq!(Unit::named(""), None); +} + +#[test] +fn readouts_show_two_decimals() { + assert_eq!(Unit::Points.readout(15.0), "15.00 pt"); + assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm"); + assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in"); + assert_eq!(Unit::Millimeters.number(1.0), "0.353"); + assert_eq!(Unit::Points.number(-0.0001), "0"); +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index e0e65e06..8cba3550 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -83,6 +83,19 @@ pub struct Lengths { pub absolute: &'static [&'static str], } +impl Lengths { + /// The distance parameters while the effect's `relative` parameter is `relative`. + pub fn keys(&self, relative: bool) -> impl Iterator + '_ { + self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied() + } +} + +/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is +/// `relative`? +pub fn is_length(id: &str, key: &str, relative: bool) -> bool { + catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) +} + /// The distance parameters of effect `id` (none for unknown effects). fn lengths_of(id: &str) -> Lengths { let always = |always| Lengths { always, absolute: &[] }; @@ -316,14 +329,13 @@ pub fn scale_effect(e: &mut Effect, s: f64) { let Some(info) = catalog_index().get(e.id.as_str()) else { return }; let merged = merged_params(&e.id, &e.params); let relative = util::flag(&merged, "relative", false); - let keys = info.lengths.always.iter().chain(if relative { &[][..] } else { info.lengths.absolute }); - for k in keys { + for k in info.lengths.keys(relative) { let v = util::num(&merged, k, f64::NAN); if v.is_finite() { if !e.params.is_object() { e.params = Value::Object(Map::new()); } - e.params[*k] = json!(v * s); + e.params[k] = json!(v * s); } } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c2441bac..e3050a08 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "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 { "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 { }; 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 { Ok(json!({ "index": i })) } -pub(crate) fn parse_unit(u: &str) -> Option { - 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 { 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 { } fn set_units(s: &mut Session, p: &Value) -> Result { - 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() } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 03224caf..a060826b 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -180,6 +180,15 @@ pub(crate) fn num_param(p: &Value, key: &str) -> Option { .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 { + 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::>() }) } @@ -397,7 +406,7 @@ fn join_param(p: &Value, key: &str) -> po::Join { } fn offset_path(s: &mut Session, p: &Value) -> Result { - 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 { 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 { 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![]; diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 912bb7aa..9ee95dde 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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(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 { } } +/// `x` (`None`: not a number) as the value of number preference `key` in `min..=max`. +fn in_range(key: &str, x: Option, min: f64, max: f64) -> std::result::Result { + 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 { "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 { 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 { 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); diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 83097eba..d397056a 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -241,7 +241,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { 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 diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2dfeeff5..7eaf908e 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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 { @@ -169,14 +169,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result { 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") { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e907bbdb..805cb463 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod cmd; pub mod guard; pub mod inspect; mod tooling; +pub mod units; use std::sync::Arc; @@ -1004,6 +1005,8 @@ mod tests_tints; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] +mod tests_units; +#[cfg(test)] mod tests_widthpoints; #[cfg(test)] mod tests_widthprofiles; diff --git a/crates/engine/src/tests_units.rs b/crates/engine/src/tests_units.rs new file mode 100644 index 00000000..b0df220a --- /dev/null +++ b/crates/engine/src/tests_units.rs @@ -0,0 +1,134 @@ +//! Preferences ▸ Units: the General unit is the open document's units (Preferences and Document +//! Setup set it, new documents start in the preference), Stroke and Type are preferences, and the +//! canvas measurement labels follow them. + +use serde_json::json; +use vectorcraft_doc::Unit; +use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; + +use super::*; +use crate::cmd::prefscmds::{PREF_SPECS, PrefKind, UNITS}; +use crate::units::Measure; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn units(s: &Session) -> Unit { + s.doc().unwrap().doc.units +} + +#[test] +fn preferences_general_sets_the_open_documents_units_as_one_undo_step() { + let mut s = session(); + assert_eq!(s.general_unit(), Unit::Points); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); + assert_eq!((units(&s), s.general_unit(), s.default_units()), (Unit::Millimeters, Unit::Millimeters, Unit::Millimeters)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Setting it again (the Preferences dialog's OK sends every value) adds no step. + s.execute("prefs.set", &json!({"values": {"unitsGeneral": "millimeters", "keyboardIncrement": 2}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(units(&s), Unit::Points); + assert_eq!(s.prefs.units_general, "millimeters", "undo is the document's; the preference stays"); +} + +#[test] +fn new_documents_start_in_the_preference_and_document_setup_only_changes_the_document() { + let mut s = Session::new(); + assert_eq!(s.general_unit(), Unit::Points, "no document: the preference"); + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "inches"})).unwrap(); + assert_eq!(s.general_unit(), Unit::Inches); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert_eq!(units(&s), Unit::Inches); + s.execute("file.new", &json!({"width": 100, "height": 100, "units": "Pixels"})).unwrap(); + assert_eq!((units(&s), s.general_unit()), (Unit::Pixels, Unit::Pixels), "given units win"); + s.execute("document.setUnits", &json!({"units": "cm"})).unwrap(); + assert_eq!((units(&s), s.default_units()), (Unit::Centimeters, Unit::Inches)); + // Each document keeps its own units. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.general_unit(), Unit::Inches); + assert!(s.execute("file.new", &json!({"units": "furlongs"})).is_err()); +} + +#[test] +fn document_setup_without_a_document_fails() { + let mut s = Session::new(); + assert!(s.execute("document.setUnits", &json!({"units": "mm"})).is_err()); + // The preference alone still changes. + s.execute("prefs.set", &json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); + assert_eq!(s.prefs.units_general, "millimeters"); +} + +#[test] +fn every_unit_is_a_units_choice() { + let want: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.key(), u.label())).collect(); + assert_eq!(UNITS, want.as_slice()); + let mut s = session(); + for u in Unit::ALL { + s.execute("prefs.set", &json!({"values": {"unitsGeneral": u.label(), "unitsStroke": u.key(), "unitsType": u.key()}})).unwrap(); + assert_eq!((s.general_unit(), s.type_unit()), (u, u)); + assert_eq!(s.stroke_unit(), u); + } +} + +#[test] +fn stroke_and_type_units_are_preferences() { + let mut s = session(); + s.execute("prefs.set", &json!({"values": {"unitsStroke": "millimeters", "unitsType": "pixels"}})).unwrap(); + assert_eq!((s.unit(Measure::General), s.unit(Measure::Stroke), s.unit(Measure::Type)), (Unit::Points, Unit::Millimeters, Unit::Pixels)); + assert_eq!(units(&s), Unit::Points, "they leave the document alone"); +} + +#[test] +fn length_preferences_take_typed_units_and_say_what_they_follow() { + let mut s = session(); + s.execute("prefs.set", &json!({"values": {"keyboardIncrement": "1 mm", "gridlineEvery": "1 in", "typeSizeIncrement": 3}})).unwrap(); + assert!((s.prefs.keyboard_increment - 72.0 / 25.4).abs() < 1e-9); + assert_eq!((s.prefs.gridline_every, s.prefs.type_size_increment), (72.0, 3.0)); + assert!(s.execute("prefs.set", &json!({"key": "pasteOffset", "value": "1 yd"})).is_err(), "out of range"); + let list = s.execute("prefs.list", &json!({})).unwrap(); + let row = |k: &str| list.as_array().unwrap().iter().find(|r| r["key"] == k).unwrap().clone(); + assert_eq!((row("keyboardIncrement")["measure"].clone(), row("keyboardIncrement")["unit"].clone()), (json!("general"), json!("pt"))); + assert_eq!(row("baselineShiftIncrement")["measure"], json!("type")); + assert!(row("snappingTolerance")["measure"].is_null(), "a screen distance"); + let lengths = PREF_SPECS.iter().filter(|p| matches!(p.kind, PrefKind::Length { .. })).count(); + assert_eq!(lengths, 6); +} + +#[test] +fn canvas_measurement_labels_follow_the_units() { + let mut s = session(); + s.execute("prefs.set", &json!({"values": {"unitsGeneral": "millimeters", "unitsStroke": "inches"}})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + for (kind, x, y) in [(PointerKind::Down, 0.0, 0.0), (PointerKind::Drag, 72.0, 36.0)] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap(); + } + let labels: Vec = + s.overlays(v).into_iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None }).collect(); + assert_eq!(labels, ["W: 25.40 mm\nH: 12.70 mm"]); +} + +#[test] +fn units_survive_a_native_round_trip() { + let mut s = session(); + s.execute("document.setUnits", &json!({"units": "Feet & Inches"})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(back.units, Unit::FeetInches); +} + +#[test] +fn distance_params_take_a_unit() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Hi"})).unwrap()["id"].clone(); + s.execute("select.set", &json!({ "ids": [id] })).unwrap(); + s.execute("text.setStyle", &json!({"size": "0.5 in", "leading": "1in", "tracking": "20"})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let NodeKind::Text(t) = &n.kind else { panic!("not text") }; + let st = t.first_style(); + assert_eq!((st.size, st.leading, st.tracking), (36.0, Some(72.0), 20.0)); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index d07cdade..923b004b 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -65,6 +65,7 @@ impl Session { R: Default, { let Some(st) = self.active.and_then(|i| self.docs.get(i)) else { return R::default() }; + let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit()); let cx = ToolContext { doc: &st.doc, selection: &st.selection, @@ -85,6 +86,8 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + unit, + stroke_unit, }; let tool = &mut self.tool; match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) { diff --git a/crates/engine/src/units.rs b/crates/engine/src/units.rs new file mode 100644 index 00000000..34053a05 --- /dev/null +++ b/crates/engine/src/units.rs @@ -0,0 +1,83 @@ +//! Units (Preferences ▸ Units): the unit every length the UI shows and reads is in. +//! +//! General (rulers, positions, sizes, dialog distances, canvas readouts) is the active document's +//! units, so they travel with the file: Document Setup (`document.setUnits`) changes them, and +//! Preferences ▸ Units ▸ General changes them too and is also the units new documents start in (and +//! the unit shown while no document is open). Stroke (weights, dashes) and Type (sizes, leading, +//! baseline shift) are application preferences. Typed values with a unit ("5 mm", "12 pt") work in +//! any unit. + +use vectorcraft_doc::Unit; + +use crate::{Result, Session}; + +/// Which Units preference a length follows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Measure { + General, + Stroke, + Type, +} + +impl Measure { + /// Its name in `prefs.list` (`measure`). + pub fn name(self) -> &'static str { + match self { + Measure::General => "general", + Measure::Stroke => "stroke", + Measure::Type => "type", + } + } + + /// The preference that picks its unit. + pub fn pref_key(self) -> &'static str { + match self { + Measure::General => "unitsGeneral", + Measure::Stroke => "unitsStroke", + Measure::Type => "unitsType", + } + } +} + +/// The unit a Units preference value names (points when it names none). +fn pref_unit(value: &str) -> Unit { + Unit::named(value).unwrap_or_default() +} + +impl Session { + /// The unit lengths of kind `m` show in and bare numbers typed for them are read in. + pub fn unit(&self, m: Measure) -> Unit { + match m { + Measure::General => self.active().map_or_else(|| self.default_units(), |d| d.doc.units), + Measure::Stroke => pref_unit(&self.prefs.units_stroke), + Measure::Type => pref_unit(&self.prefs.units_type), + } + } + + /// The General unit: the active document's units (Preferences ▸ Units ▸ General without one). + pub fn general_unit(&self) -> Unit { + self.unit(Measure::General) + } + + /// Units ▸ Type: the unit type sizes, leading and baseline shift show in. + pub fn type_unit(&self) -> Unit { + self.unit(Measure::Type) + } + + /// Preferences ▸ Units ▸ General: the units new documents start in. + pub fn default_units(&self) -> Unit { + pref_unit(&self.prefs.units_general) + } + + /// Set the active document's units (one undo step, as Document Setup does; none when they + /// already are `u`). + pub(crate) fn set_document_units(&mut self, u: Unit) -> Result<()> { + if self.doc()?.doc.units == u { + return Ok(()); + } + self.edit("Document Setup", |d, _| { + d.units = u; + Ok(()) + }) + } +} diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index 705e5b84..10fb830c 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -123,11 +123,11 @@ impl CornerDrag { } /// The radius readout next to the pointer. - pub fn overlays(&self) -> Vec { + pub fn overlays(&self, cx: &ToolContext) -> Vec { if !self.began { return vec![]; } - vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", crate::xform::fmt_pt(self.radius)) }] + vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) }] } } diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a98cc136..a18b461f 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -231,10 +231,10 @@ impl Tool for DirectSelectionTool { _ => vec![], } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match &self.state { State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], - State::Corner(c) => c.overlays(), + State::Corner(c) => c.overlays(cx), _ => vec![], } } diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 4d51a255..4fa59e2e 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -386,7 +386,7 @@ impl Tool for WidthTool { let q = d.spot.p; out.push(Overlay::Line { a: q + d.spot.n * d.left, b: q - d.spot.n * d.right, color: BLUE, dashed: false }); out.push(Overlay::Path { path: diamond(q, r), color: BLUE, width: 2.0, dashed: false }); - out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {:.2} pt", d.left + d.right) }); + out.push(Overlay::Measure { p: q + d.spot.n * (d.left + r * 3.0), text: format!("W: {}", cx.stroke_unit.readout(d.left + d.right)) }); } else if let Some(h) = &self.hover { out.push(Overlay::Line { a: h.p + h.n * h.left, b: h.p - h.n * h.right, color: BLUE, dashed: true }); out.push(Overlay::Path { path: diamond(h.p, r), color: BLUE, width: 1.0, dashed: false }); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 9510da34..0c066a66 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -182,11 +182,11 @@ impl Tool for FamilyTool { vec![] } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match self.start { Some(s) if self.began => { let d = self.last - s; - vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }] + vec![Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }] } _ => vec![], } diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 85d92b33..c3d7ace9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -28,7 +28,7 @@ pub mod xform; use serde::{Deserialize, Serialize}; use serde_json::Value; use vectorcraft_color::Paint; -use vectorcraft_doc::{Document, NodeId, Selection}; +use vectorcraft_doc::{Document, NodeId, Selection, Unit}; use vectorcraft_geom::{BezPath, Point, Rect}; pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; @@ -201,6 +201,10 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// Units ▸ General: the unit measurement labels show lengths in. + pub unit: Unit, + /// Units ▸ Stroke: the unit measurement labels show stroke widths in. + pub stroke_unit: Unit, } impl ToolContext<'_> { @@ -208,6 +212,18 @@ impl ToolContext<'_> { pub fn tol(&self, px: f64) -> f64 { px / self.zoom.max(1e-9) } + /// A length as measurement labels show it, in the General unit (`12.50 mm`). + pub fn len(&self, v: f64) -> String { + self.unit.readout(v) + } + /// A size label: `W: …` over `H: …`. + pub fn size_label(&self, w: f64, h: f64) -> String { + format!("W: {}\nH: {}", self.len(w), self.len(h)) + } + /// A move label: `dX: …` over `dY: …`. + pub fn offset_label(&self, dx: f64, dy: f64) -> String { + format!("dX: {}\ndY: {}", self.len(dx), self.len(dy)) + } pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions { vectorcraft_doc::hit::HitOptions { tol: self.tol(3.0), outline: self.outline, path_only: false } } @@ -384,6 +400,8 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + unit: Unit::Points, + stroke_unit: Unit::Points, } } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 5952dca2..6adf8283 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -160,14 +160,14 @@ impl Tool for SelectionTool { self.guides.clear(); } self.state = State::Moving { start, began: true }; - self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", d.x, d.y))); + self.measure = Some((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 }))); out } (PointerKind::Drag, State::Scaling { handle, rect, .. }) => { let a = scale_for_drag(rect, handle, p, m.shift, m.alt); let nr = a.transform_rect_bbox(rect); - self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height()))); + self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { @@ -217,11 +217,11 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; match &self.state { State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), - State::Corner(c) => o.extend(c.overlays()), + State::Corner(c) => o.extend(c.overlays(cx)), _ => {} } o.extend(self.guides.iter().cloned()); diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 3338ff8f..0afb0525 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -185,12 +185,12 @@ impl Tool for ShapeTool { vec![] } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match self.start { Some(s) if self.began => { let d = self.last - s; let mut o = self.guides.clone(); - o.push(Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }); + o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }); o } _ => vec![], diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs index 6c52ae15..32203a3b 100644 --- a/crates/tools/src/xform.rs +++ b/crates/tools/src/xform.rs @@ -86,7 +86,3 @@ pub fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId { _ => leaf, } } - -pub(crate) fn fmt_pt(v: f64) -> String { - format!("{v:.2} pt") -} diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 83b68fed..19b351d3 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -172,10 +172,7 @@ impl Tool for ArtboardTool { if let Some(Drag::Create { start, cur }) = self.drag { let r = Rect::from_points(start, cur); o.push(Overlay::Marquee(r)); - o.push(Overlay::Measure { - p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), - text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()), - }); + o.push(Overlay::Measure { p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.size_label(r.width(), r.height()) }); return o; } let Some(ab) = cx.doc.artboards.get(self.active) else { return o }; @@ -188,7 +185,7 @@ impl Tool for ArtboardTool { if let Some(Drag::Resize { began: true, .. }) = self.drag { o.push(Overlay::Measure { p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)), - text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()), + text: cx.size_label(r.width(), r.height()), }); } o diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 6235df00..8ee8db89 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -148,7 +148,7 @@ impl FreeTransformTool { r.contains(p).then_some(Op::Move) } - fn preview(&mut self, d: Drag, p: Point, m: Mods) -> Action { + fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action { let shift = m.shift || self.constrain; let delta = p - d.start; let xf = match d.op { @@ -161,13 +161,13 @@ impl FreeTransformTool { } Op::Move => { let v = move_delta(d.start, p, shift); - self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", v.x, v.y))); + self.measure = Some((p, cx.offset_label(v.x, v.y))); Affine::translate(v) } Op::Scale(h) => { let a = scale_for_drag(d.rect, h, p, shift, m.alt); let nr = a.transform_rect_bbox(d.rect); - self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height()))); + self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); a } Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt), @@ -220,7 +220,7 @@ impl Tool for FreeTransformTool { out.push(Action::Begin(label.into())); } self.drag = Some(d); - out.push(self.preview(d, p, ev.mods)); + out.push(self.preview(cx, d, p, ev.mods)); out } PointerKind::Up => { diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index b55aa5b3..c2160e66 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; -use super::{CYAN, fmt_pt}; +use super::CYAN; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Default)] @@ -62,7 +62,7 @@ impl Tool for MeasureTool { Overlay::Line { a, b, color: CYAN, dashed: false }, Overlay::Measure { p: b + Vec2::new(cx.tol(12.0), cx.tol(12.0)), - text: format!("D: {}\nAngle: {ang:.1}°\ndX: {}\ndY: {}", fmt_pt(dist), fmt_pt(dx), fmt_pt(dy)), + text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)), }, ] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f8f09b8..8e080e96 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3,7 +3,7 @@ use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Node, NodeKind, Unit}; use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect}; use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; @@ -296,7 +296,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } if app.ui.view.rulers && app.ui.screen_mode < 3 { - rulers(ui, full, &xf, app.hover_doc, &t); + rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t); } if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 { task_bar(app, ui, &xf); @@ -573,7 +573,16 @@ fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) { } } -fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) { +/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole +/// numbers, or as many decimals as the step has. +pub(crate) fn ruler_label(v: f64, step: f64) -> String { + let decimals = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize }; + let s = format!("{v:.decimals$}"); + if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s } +} + +/// The rulers, numbered in `unit` (the General unit). +fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, t: &Tokens) { let p = ui.painter(); let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER)); let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom())); @@ -586,17 +595,19 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) // Crosshair in the origin box. p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick)); p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick)); - // Pick a label step that gives ≥ 50 px between labels. - let steps = [1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; - let step = steps.iter().copied().find(|s| s * xf.zoom >= 50.0).unwrap_or(10000.0); + // Pick a label step (in `unit`) that gives ≥ 50 px between labels; positions below are in `unit` + // (`per` points each). + let per = unit.points(); + let steps = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0]; + let step = steps.iter().copied().find(|s| s * per * xf.zoom >= 50.0).unwrap_or(10000.0); let minor = step / 10.0; let font = egui::FontId::proportional(9.5); let a = xf.to_doc(top.left_top()); let b = xf.to_doc(top.right_top()); let clip_top = p.with_clip_rect(top); - let mut x = (a.x / minor).floor() * minor; - while x <= b.x { - let sx = xf.to_screen(Point::new(x, 0.0)).x; + let mut x = (a.x / per / minor).floor() * minor; + while x * per <= b.x { + let sx = xf.to_screen(Point::new(x * per, 0.0)).x; let is_major = ((x / step).round() * step - x).abs() < minor * 0.01; let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01; let len = if is_major { @@ -608,16 +619,16 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) }; clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); if is_major { - clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick); + clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick); } x += minor; } let a = xf.to_doc(left.left_top()); let b = xf.to_doc(left.left_bottom()); let clip_left = p.with_clip_rect(left); - let mut y = (a.y / minor).floor() * minor; - while y <= b.y { - let sy = xf.to_screen(Point::new(0.0, y)).y; + let mut y = (a.y / per / minor).floor() * minor; + while y * per <= b.y { + let sy = xf.to_screen(Point::new(0.0, y * per)).y; let is_major = ((y / step).round() * step - y).abs() < minor * 0.01; let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01; let len = if is_major { @@ -630,7 +641,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, t: &Tokens) clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); if is_major { // Vertical labels read top-to-bottom, one digit per line like Illustrator. - let s = format!("{}", y.round() as i64); + let s = ruler_label(y, step); for (k, ch) in s.chars().enumerate() { clip_left.text( pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), @@ -1013,17 +1024,21 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { ui.add_space(28.0); ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text)); ui.add_space(10.0); - let presets: [(&str, &str, f64, f64); 6] = [ - ("Letter", "612 × 792 pt", 612.0, 792.0), - ("A4", "595.28 × 841.89 pt", 595.28, 841.89), - ("Web 1920", "1920 × 1080 px", 1920.0, 1080.0), - ("Mobile", "390 × 844 px", 390.0, 844.0), - ("Postcard", "288 × 432 pt", 432.0, 288.0), - ("Square", "1080 × 1080 px", 1080.0, 1080.0), + // (name, width, height in points, in pixels); print sizes show in Preferences ▸ Units ▸ General. + let presets: [(&str, f64, f64, bool); 6] = [ + ("Letter", 612.0, 792.0, false), + ("A4", 595.28, 841.89, false), + ("Web 1920", 1920.0, 1080.0, true), + ("Mobile", 390.0, 844.0, true), + ("Postcard", 432.0, 288.0, false), + ("Square", 1080.0, 1080.0, true), ]; + let print = app.session.default_units(); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(14.0, 14.0); - for (name, size, w, h) in presets { + for (name, w, h, pixels) in presets { + let unit = if pixels { Unit::Pixels } else { print }; + let size = format!("{} × {}", unit.number(w), unit.format(h)); let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click()); ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); let s = (70.0 / w.max(h)) as f32; @@ -1033,7 +1048,7 @@ fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text); ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim); if resp.clicked() { - app.run("file.new", json!({"width": w, "height": h, "units": if size.ends_with("px") { "Pixels" } else { "Points" }})).ok(); + app.run("file.new", json!({"width": w, "height": h, "units": unit.label()})).ok(); } } }); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 50eda91e..e2f8164d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -28,7 +28,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { crate::brand::paint_mark(ui, r); ui.add_space(4.0); if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() { - app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); + crate::dialogs::open_new_document(app); } ui.add_space(2.0); let menus_end = if app.native_menu { @@ -66,9 +66,20 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let open_palette = if search_full { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); ui.painter().rect_filled(r, CornerRadius::same(12), t.input); - ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); + ui.painter().rect_stroke( + r, + CornerRadius::same(12), + Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), + StrokeKind::Inside, + ); icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); - ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); + ui.painter().text( + r.left_center() + vec2(26.0, 0.0), + egui::Align2::LEFT_CENTER, + "Search commands and tools", + egui::FontId::proportional(11.5), + t.text_dim, + ); resp.clicked() } else { widgets::icon_button(ui, "search", "Search commands and tools", false, 24.0).clicked() @@ -106,7 +117,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let sel = st.selection.objects.clone(); - let units = st.doc.units; + let units = app.session.general_unit(); let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); let anchor_mode = !st.selection.anchors.is_empty(); let label = match &first { @@ -185,7 +196,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] { ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); - if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) { + if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) { app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); } } @@ -360,7 +371,10 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { .color(t.text_disabled), ); if let Some(p) = app.hover_doc { - ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim)); + let u = app.session.general_unit(); + ui.label( + egui::RichText::new(format!("X: {} Y: {}", u.readout(p.x), u.readout(p.y))).font(theme::mono(10.5)).color(t.text_dim), + ); } if !app.ui.status.is_empty() { ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text)); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index d732cda7..42e5c376 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -9,14 +9,28 @@ use crate::state::Dialog; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), - body: |_, ui, d| { - form::param_fields(ui, d); + body: |app, ui, d| { + let lengths = lengths(&d.str("__command")); + form::param_fields(ui, d, &|k| lengths.contains(&k), app.session.general_unit()); false }, confirm, ..DialogSpec::FORM }; +/// The parameters of the commands this dialog edits that are distances. +fn lengths(command: &str) -> &'static [&'static str] { + match command { + "graph.create" => &["width", "height"], + "shape.flare" => &["diameter", "pathLength"], + "artboard.rearrange" => &["spacing"], + "perspective.grid.set" => &["cell", "distance"], + "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], + "text.areaOptions" => &["gutter", "inset", "firstBaselineMin"], + _ => &[], + } +} + /// Closes before running, so a dialog the command opens stays open. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index a8333562..c7b3a88d 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -111,7 +111,8 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let changed = form::param_fields(ui, d); + let (id, relative) = (d.str("__effect"), d.bool("relative")); + let changed = form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()); ui.add_space(6.0); let mut pv = d.bool("preview"); let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 41947f62..23851e48 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -2,18 +2,56 @@ //! and the typed parameter editor of the command and effect dialogs. use serde_json::{Value, json}; +use vectorcraft_doc::Unit; use crate::state::Dialog; use crate::theme::Tokens; +/// Width of a dialog's value fields ([`text`] and [`length`], frame included). +const FIELD_W: f32 = 132.0; + /// A labelled text field bound to `d.fields[key]` (one grid row). pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(label).color(t.text_dim)); - text(ui, d, key, 120.0); + text(ui, d, key, FIELD_W - 12.0); ui.end_row(); } +/// A labelled [`length`] field (one grid row). +pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(label).color(t.text_dim)); + length(ui, d, key, unit, FIELD_W); + ui.end_row(); +} + +/// A distance field `width` wide bound to `d.fields[key]`: shown and typed in `unit` (a typed unit, +/// "5 mm", wins), kept in points (a string with a unit, "12 pt", also reads). Returns true when it +/// changed. +pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool { + let value = d.fields.contains_key(key).then(|| d.f64(key, 0.0)); + let Some(v) = crate::widgets::num_field(ui, ("dlg-len", key), value, unit, width) else { return false }; + d.fields.insert(key.into(), json!(v)); + true +} + +/// The fields of the form dialogs ([`grid`]) that are distances, by dialog kind. +fn lengths(kind: &str) -> &'static [&'static str] { + match kind { + "move" => &["dx", "dy"], + "rectangle" | "ellipse" | "artboardOptions" => &["width", "height"], + "roundedRectangle" => &["width", "height", "radius"], + "polygon" => &["radius"], + "star" => &["radius1", "radius2"], + "lineSegment" => &["length"], + "offsetPath" => &["offset"], + "simplify" => &["tolerance"], + "splitIntoGrid" => &["gutter"], + _ => &[], + } +} + /// A text field `width` wide bound to `d.fields[key]`. Returns true when it changed. pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> bool { text_edit(ui, d, key, width).changed() @@ -43,15 +81,21 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } } -/// The generic dialog body: a text field per non-boolean value (positions and indices hidden). -pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog) { +/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the +/// distances in `unit`. +pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + let lengths = lengths(&d.kind); egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); for (k, v) in keys { if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() { continue; } - field(ui, d, &k, &humanize(&k)); + if lengths.contains(&k.as_str()) { + length_field(ui, d, &k, &humanize(&k), unit); + } else { + field(ui, d, &k, &humanize(&k)); + } } }); } @@ -61,9 +105,10 @@ pub(super) fn params(d: &Dialog) -> Value { Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) } -/// Generic editor for command/effect parameters: numbers, booleans, strings and colours. -/// Returns true when a value changed. -pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { +/// Generic editor for command/effect parameters: numbers, booleans, strings and colours; the +/// parameters `is_length` names are distances shown and typed in `unit`. Returns true when a +/// value changed. +pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn(&str) -> bool, unit: Unit) -> bool { let t = Tokens::get(ui.ctx()); let mut changed = false; egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -79,6 +124,11 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); continue; } + if is_length(&k) { + changed |= length(ui, d, &k, unit, 140.0); + ui.end_row(); + continue; + } match v { Value::Number(n) => { let mut x = n.as_f64().unwrap_or(0.0); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 178f3939..3ea5a5ad 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -47,6 +47,7 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use effect::open as open_effect_dialog; +pub use new_document::open as open_new_document; pub use save_pdf::open as open_save_pdf; pub use tools::open_tool_dialog; @@ -82,8 +83,8 @@ impl DialogSpec { pub const FORM: Self = Self { window: None, heading: |_| "Dialog".into(), - body: |_, ui, d| { - form::grid(ui, d); + body: |app, ui, d| { + form::grid(ui, d, app.session.general_unit()); false }, confirm: |app, _| { diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index ef4e549f..8be06d8f 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -1,39 +1,57 @@ -//! File → New: presets and the document details (name, size, artboards, colour mode). +//! File → New: presets and the document details (name, size, units, artboards, colour mode). +//! +//! Fields: `preset`, `name`, `width` and `height` (points, or strings with a unit), `units` (a unit +//! label; print presets start in Preferences ▸ Units ▸ General), `artboards`, `colorMode`. use serde_json::{Value, json}; +use vectorcraft_doc::Unit; use super::{DialogSpec, form}; -use crate::VectorcraftApp; use crate::state::Dialog; use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "New Document".into(), body, confirm, ok: Some("Create"), min_width: 560.0, ..DialogSpec::FORM }; -fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +/// The presets: (name, width, height in points, in pixels). +const PRESETS: [(&str, f64, f64, bool); 8] = [ + ("Letter", 612.0, 792.0, false), + ("Legal", 612.0, 1008.0, false), + ("Tabloid", 792.0, 1224.0, false), + ("A4", 595.28, 841.89, false), + ("A3", 841.89, 1190.55, false), + ("Web 1920", 1920.0, 1080.0, true), + ("Phone 390×844", 390.0, 844.0, true), + ("Square Post", 1080.0, 1080.0, true), +]; + +/// The units a preset starts in: pixels for screen presets, else the General preference. +fn preset_units(app: &VectorcraftApp, pixels: bool) -> Unit { + if pixels { Unit::Pixels } else { app.session.default_units() } +} + +/// Open the dialog on the Letter preset. +pub fn open(app: &mut VectorcraftApp) { + let units = preset_units(app, false).label(); + let fields = json!({"preset": "Letter", "width": 612, "height": 792, "units": units, "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}); + app.ui.dialog = Some(Dialog::new("newDocument", fields)); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let t = Tokens::get(ui.ctx()); - let presets: [(&str, &str, &str, &str); 8] = [ - ("Letter", "612 pt", "792 pt", "Points"), - ("Legal", "612 pt", "1008 pt", "Points"), - ("Tabloid", "792 pt", "1224 pt", "Points"), - ("A4", "595.28 pt", "841.89 pt", "Points"), - ("A3", "841.89 pt", "1190.55 pt", "Points"), - ("Web 1920", "1920 px", "1080 px", "Pixels"), - ("Phone 390×844", "390 px", "844 px", "Pixels"), - ("Square Post", "1080 px", "1080 px", "Pixels"), - ]; ui.horizontal(|ui| { ui.vertical(|ui| { ui.set_width(300.0); ui.label(egui::RichText::new("Presets").color(t.text_dim)); ui.horizontal_wrapped(|ui| { - for (name, w, h, u) in presets { + for (name, w, h, pixels) in PRESETS { let sel = d.str("preset") == name; if ui.selectable_label(sel, name).clicked() { d.fields.insert("preset".into(), json!(name)); d.fields.insert("width".into(), json!(w)); d.fields.insert("height".into(), json!(h)); - d.fields.insert("units".into(), json!(u)); + d.fields.insert("units".into(), json!(preset_units(app, pixels).label())); } } }); @@ -41,10 +59,17 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.separator(); ui.vertical(|ui| { ui.label(egui::RichText::new("Preset Details").font(theme::semibold(12.5))); + let unit = Unit::named(&d.str("units")).unwrap_or_default(); egui::Grid::new("newdoc").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { form::field(ui, d, "name", "Name:"); - form::field(ui, d, "width", "Width:"); - form::field(ui, d, "height", "Height:"); + form::length_field(ui, d, "width", "Width:", unit); + form::length_field(ui, d, "height", "Height:", unit); + ui.label(egui::RichText::new("Units:").color(t.text_dim)); + let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "newdoc-units", unit.label(), &labels, 132.0) { + d.fields.insert("units".into(), json!(labels[i])); + } + ui.end_row(); form::field(ui, d, "artboards", "Artboards:"); form::field(ui, d, "colorMode", "Color Mode:"); }); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 2c9dd607..8c52a991 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -326,7 +326,7 @@ fn compression(ui: &mut egui::Ui, d: &mut Dialog) { } fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { - let unit = app.session.active().map_or(Unit::Points, |s| s.doc.units); + let unit = app.session.general_unit(); const MARKS: [(&str, &str); 4] = [ ("marks.trim", "Trim marks"), ("marks.registration", "Registration marks"), diff --git a/crates/ui-egui/src/dialogs/transform.rs b/crates/ui-egui/src/dialogs/transform.rs index 26956743..4ef5fbaf 100644 --- a/crates/ui-egui/src/dialogs/transform.rs +++ b/crates/ui-egui/src/dialogs/transform.rs @@ -20,7 +20,7 @@ fn title(kind: &str) -> &'static str { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - form::grid(ui, d); + form::grid(ui, d, app.session.general_unit()); ui.add_space(6.0); if d.kind == "scale" { form::check(ui, d, "uniform", "Uniform"); diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 71662629..d8609bc3 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -37,23 +37,20 @@ fn params(d: &Dialog) -> Value { }) } -/// A labelled number row of the field grid: `key` shown with `suffix` (document units for "pt"). +/// A labelled number row of the field grid: `key` shown with `suffix` (a distance in `units` for +/// "pt"). fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, suffix: &str, units: vectorcraft_doc::Unit) { widgets::dim_label(ui, label); - let v = d.f64(key, 0.0); - let new = if suffix == "pt" { - widgets::num_field(ui, ("te", key), Some(v), units, 90.0) - } else { - widgets::plain_field(ui, ("te", key), v, suffix, 2, 90.0) - }; - if let Some(n) = new { + if suffix == "pt" { + form::length(ui, d, key, units, 90.0); + } else if let Some(n) = widgets::plain_field(ui, ("te", key), d.f64(key, 0.0), suffix, 2, 90.0) { d.fields.insert(key.into(), json!(n)); } ui.end_row(); } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let units = app.session.active().map_or(vectorcraft_doc::Unit::Points, |s| s.doc.units); + let units = app.session.general_unit(); ui.horizontal_top(|ui| { ui.vertical(|ui| { egui::Grid::new("te-grid").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 78ffd763..677d4b2b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -378,10 +378,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::state::Dialog::new( - "newDocument", - json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}), - )); + crate::dialogs::open_new_document(app); Ok(Value::Null) } "file.open" => match s("path") { diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 150247da..16d4d209 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -3,7 +3,6 @@ use egui::Ui; use serde_json::{Value, json}; -use vectorcraft_doc::Unit; use super::{pstate, selection_len, set_pstate}; use crate::VectorcraftApp; @@ -100,7 +99,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("object.distributeSpacing", json!({"axis": "horizontal", "spacing": sp})).ok(); } ui.add_enabled_ui(key, |ui| { - if let Some(v) = widgets::spin_field(ui, "align-spacing", Some(spacing), Unit::Points, 70.0, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "align-spacing", Some(spacing), app.session.general_unit(), 70.0, 1.0, 0.0, &[]) { set_pstate(ui.ctx(), "align-spacing", Some(v)); } }); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 5557a25f..8c5b6423 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -328,7 +328,7 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { } chip(ui, Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)), it.paint()); if let AppearanceItem::Stroke(st) = it { - let w = format!("{} pt", st.width); + let w = app.session.stroke_unit().format(st.width); text(ui, pos2(r.left() + EYE_W + 106.0, r.center().y), &w, false); stroke_notes(ui, Rect::from_min_max(pos2(r.left() + EYE_W + 166.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); } @@ -428,7 +428,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: if let AppearanceItem::Stroke(st) = it { let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); - if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), vectorcraft_doc::Unit::Points, 56.0) { + if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), app.session.stroke_unit(), 56.0) { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); @@ -712,6 +712,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } } let mut change: Option<(String, Value)> = None; + let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false)); egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 3.0; if params.is_empty() { @@ -734,8 +735,13 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: } } Value::Number(n) => { - let x = n.as_f64().unwrap_or(0.0); - if let Some(nx) = widgets::plain_field(ui, ("fx", item, k, key.as_str()), x, "", 2, 70.0) { + let (x, id) = (n.as_f64().unwrap_or(0.0), ("fx", item, k, key.as_str())); + let nx = if vectorcraft_effects::is_length(&e.id, key, relative) { + widgets::num_field(ui, id, Some(x), unit, 70.0) + } else { + widgets::plain_field(ui, id, x, "", 2, 70.0) + }; + if let Some(nx) = nx { change = Some((key.clone(), json!(nx))); } } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 121430af..7f17962a 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -5,7 +5,7 @@ use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{CharStyle, NodeId, NodeKind, Unit}; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind}; use vectorcraft_tools::{Mods, ToolKey}; use super::{first_selected, pstate, set_pstate}; @@ -253,15 +253,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space(2.0); let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); + let unit = app.session.type_unit(); egui::Grid::new("ch-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { cell(ui, "T", "Font Size", |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-size", Some(s.size), Unit::Points, fw, 1.0, 0.1, &SIZE_PRESETS) { + if let Some(v) = widgets::spin_field(ui, "ch-size", Some(s.size), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } }); cell(ui, "A↕", "Leading", |ui| { let presets: Vec = SIZE_PRESETS.iter().map(|v| v * 1.2).collect(); - if let Some(v) = widgets::spin_field(ui, "ch-lead", Some(s.effective_leading()), Unit::Points, fw, 1.0, 0.1, &presets) { + if let Some(v) = widgets::spin_field(ui, "ch-lead", Some(s.effective_leading()), unit, fw, 1.0, 0.1, &presets) { style(app, json!({"leading": v})); } }); @@ -290,7 +291,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.end_row(); cell(ui, "Aª", "Baseline Shift", |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), Unit::Points, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"baselineShift": v})); } }); diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 0948c6db..dcb5eb04 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -46,7 +46,7 @@ fn paint_block(ui: &mut Ui, p: &Paint) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); + let units = app.session.general_unit(); let p = app.hover_doc.unwrap_or_default(); let bounds = app.session.active().and_then(|st| st.doc.bounds_of(&st.selection.objects, false)); let mono = |ui: &mut Ui, s: String| { @@ -80,7 +80,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); let st = tx.first_style(); mono(ui, format!("{} {}", st.font_family, st.font_style)); - mono(ui, format!("Size: {} Tracking: {:.0}", units.format(st.size), st.tracking)); + mono(ui, format!("Size: {} Tracking: {:.0}", app.session.type_unit().format(st.size), st.tracking)); } } diff --git a/crates/ui-egui/src/panels/magic_wand.rs b/crates/ui-egui/src/panels/magic_wand.rs index a6e3b150..6e9c18c8 100644 --- a/crates/ui-egui/src/panels/magic_wand.rs +++ b/crates/ui-egui/src/panels/magic_wand.rs @@ -13,19 +13,31 @@ fn options(app: &mut VectorcraftApp) -> Value { app.session.execute("magicWand.options", &json!({})).unwrap_or_default() } +/// A tolerance: a plain number with a suffix, or a stroke weight (in the Stroke unit). +#[derive(Clone, Copy)] +enum Tol { + Plain(&'static str), + Weight, +} + /// One row: checkbox + tolerance field. -fn row(app: &mut VectorcraftApp, ui: &mut Ui, o: &Value, label: &str, flag: &str, tol: Option<(&str, &str, f64)>) { +fn row(app: &mut VectorcraftApp, ui: &mut Ui, o: &Value, label: &str, flag: &str, tol: Option<(&str, Tol, f64)>) { let on = o[flag].as_bool().unwrap_or(false); ui.horizontal(|ui| { ui.set_min_height(26.0); if widgets::check(ui, label, on, true) { app.run("magicWand.set", json!({ flag: !on })).ok(); } - if let Some((key, unit, max)) = tol { + if let Some((key, kind, max)) = tol { + let stroke_unit = app.session.stroke_unit(); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_enabled_ui(on, |ui| { let v = o[key].as_f64().unwrap_or(0.0); - if let Some(n) = widgets::spin_plain(ui, ("wand", key), v, unit, 0, 64.0, 1.0, 0.0, &[]) { + let n = match kind { + Tol::Plain(suffix) => widgets::spin_plain(ui, ("wand", key), v, suffix, 0, 64.0, 1.0, 0.0, &[]), + Tol::Weight => widgets::spin_field(ui, ("wand", key), Some(v), stroke_unit, 64.0, 1.0, 0.0, &[]), + }; + if let Some(n) = n { app.run("magicWand.set", json!({ key: n.clamp(0.0, max) })).ok(); } }); @@ -37,15 +49,15 @@ fn row(app: &mut VectorcraftApp, ui: &mut Ui, o: &Value, label: &str, flag: &str pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(app); - row(app, ui, &o, "Fill Color", "fillColor", Some(("fillTolerance", "", 255.0))); + row(app, ui, &o, "Fill Color", "fillColor", Some(("fillTolerance", Tol::Plain(""), 255.0))); if !pstate::(ui.ctx(), "wand-hide-stroke") { widgets::divider(ui); - row(app, ui, &o, "Stroke Color", "strokeColor", Some(("strokeTolerance", "", 255.0))); - row(app, ui, &o, "Stroke Weight", "strokeWeight", Some(("weightTolerance", " pt", 1000.0))); + row(app, ui, &o, "Stroke Color", "strokeColor", Some(("strokeTolerance", Tol::Plain(""), 255.0))); + row(app, ui, &o, "Stroke Weight", "strokeWeight", Some(("weightTolerance", Tol::Weight, 1000.0))); } if !pstate::(ui.ctx(), "wand-hide-transparency") { widgets::divider(ui); - row(app, ui, &o, "Opacity", "opacity", Some(("opacityTolerance", "%", 100.0))); + row(app, ui, &o, "Opacity", "opacity", Some(("opacityTolerance", Tol::Plain("%"), 100.0))); row(app, ui, &o, "Blending Mode", "blendingMode", None); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 04ad1aec..9906efe8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -454,3 +454,5 @@ mod tests_strokedepth; mod tests_strokeux; #[cfg(test)] mod tests_style_libraries; +#[cfg(test)] +mod tests_units; diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 59f7b5ad..c3097e99 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -2,7 +2,7 @@ use egui::{Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Justify, Unit}; +use vectorcraft_doc::Justify; use super::character::text_style; use super::{pstate, set_pstate}; @@ -49,32 +49,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.add_space(4.0); let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0); + // Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type. + let unit = app.session.general_unit(); let label = |ui: &mut Ui, s: &str, tip: &str| { ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(s).size(11.5).strong().color(t.text))).on_hover_text(tip); }; egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| { label(ui, "→|", "Left Indent"); - if let Some(v) = widgets::spin_field(ui, "pa-li", Some(para.left_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-li", Some(para.left_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"leftIndent": v})); } label(ui, "|←", "Right Indent"); - if let Some(v) = widgets::spin_field(ui, "pa-ri", Some(para.right_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-ri", Some(para.right_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"rightIndent": v})); } ui.end_row(); label(ui, "1→", "First-line Left Indent"); - if let Some(v) = widgets::spin_field(ui, "pa-fi", Some(para.first_line_indent), Unit::Points, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-fi", Some(para.first_line_indent), unit, fw, 1.0, -1296.0, &[]) { format(app, json!({"firstLineIndent": v})); } ui.label(""); ui.label(""); ui.end_row(); label(ui, "↑¶", "Space Before Paragraph"); - if let Some(v) = widgets::spin_field(ui, "pa-sb", Some(para.space_before), Unit::Points, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-sb", Some(para.space_before), unit, fw, 1.0, 0.0, &[]) { format(app, json!({"spaceBefore": v})); } label(ui, "↓¶", "Space After Paragraph"); - if let Some(v) = widgets::spin_field(ui, "pa-sa", Some(para.space_after), Unit::Points, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field(ui, "pa-sa", Some(para.space_after), unit, fw, 1.0, 0.0, &[]) { format(app, json!({"spaceAfter": v})); } ui.end_row(); diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index 63199485..09a11388 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -19,7 +19,7 @@ const COPIES: [(&str, u32); 4] = [("3 x 3", 3), ("5 x 5", 5), ("7 x 7", 7), ("9 fn editing(app: &VectorcraftApp) -> Option<(PatternDef, Unit)> { let st = app.session.active()?; let pe = st.doc.pattern_edit.as_ref()?; - Some((st.doc.pattern(&pe.pattern)?.clone(), st.doc.units)) + Some((st.doc.pattern(&pe.pattern)?.clone(), app.session.general_unit())) } fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5466f1bb..1d9d553c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -126,7 +126,7 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { } fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { - let units = app.session.active().map(|d| d.doc.units).unwrap_or_default(); + let units = app.session.general_unit(); section_header(ui, "Document"); ui.horizontal(|ui| { dim_label(ui, "Units"); @@ -207,7 +207,7 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; - let units = st.doc.units; + let units = app.session.general_unit(); let Some(b) = app.session.transform_bounds(&st.selection.objects) else { dim_label(ui, "No Selection"); return; @@ -337,11 +337,12 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.horizontal(|ui| { dim_label(ui, "Size"); - if let Some(v) = widgets::num_field(ui, "fsize", Some(s.size), Unit::Points, 70.0) { + let type_unit = app.session.type_unit(); + if let Some(v) = widgets::num_field(ui, "fsize", Some(s.size), type_unit, 70.0) { app.run("text.setStyle", json!({"size": v})).ok(); } dim_label(ui, "Leading"); - if let Some(v) = widgets::num_field(ui, "lead", Some(s.effective_leading()), Unit::Points, 70.0) { + if let Some(v) = widgets::num_field(ui, "lead", Some(s.effective_leading()), type_unit, 70.0) { app.run("text.setStyle", json!({"leading": v})).ok(); } }); diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 3116ba2c..1f6d2aeb 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -3,7 +3,7 @@ use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{NodeKind, TabAlign, TabStop, Unit}; +use vectorcraft_doc::{NodeKind, TabAlign, TabStop}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; @@ -85,7 +85,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { ui.label(egui::RichText::new("X:").color(t.text)); let x = stops.get(sel).map(|s| s.position); - if let Some(v) = widgets::spin_field(ui, "tabs-x", x, Unit::Points, 90.0, 1.0, 0.0, &[]) + if let Some(v) = widgets::spin_field(ui, "tabs-x", x, app.session.general_unit(), 90.0, 1.0, 0.0, &[]) && let Some(s) = stops.get_mut(sel) { s.position = v.max(0.0); diff --git a/crates/ui-egui/src/panels/tests_units.rs b/crates/ui-egui/src/panels/tests_units.rs new file mode 100644 index 00000000..2e70e487 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_units.rs @@ -0,0 +1,128 @@ +//! Headless frames of Preferences ▸ Units: the General unit in the Transform panel, Properties, +//! the Control bar, the Info panel, dialogs, the Preferences dialog itself and New Document; the +//! Type unit in the Character panel; the change showing in the next frame. + +use serde_json::json; +use vectorcraft_doc::Unit; + +use super::tests_appearance::{app_with_rect, frame_events, run}; +use super::*; + +/// Every text drawn in the second headless frame of `f` (windows size themselves in the first). +fn texts(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut Ui)) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame_events(&ctx, app, vec![], &mut f); + frame_events(&ctx, app, vec![], f).into_iter().map(|(s, _)| s).collect() +} + +/// Every text drawn by the open dialog in one headless frame. +fn dialog_texts(app: &mut VectorcraftApp) -> Vec { + texts(app, |app, ui| crate::dialogs::show(app, ui.ctx())) +} + +fn shows(t: &[String], s: &str) -> bool { + t.iter().any(|x| x == s) +} + +/// The 100 pt square of [`app_with_rect`] in millimetres. +fn side_mm() -> String { + Unit::Millimeters.format(100.0) +} + +fn general_mm(app: &mut VectorcraftApp) { + app.run("prefs.set", json!({"key": "unitsGeneral", "value": "Millimeters"})).unwrap(); +} + +#[test] +fn panels_show_millimetres_after_the_preference_changes() { + let mut app = app_with_rect(); + assert_eq!(side_mm(), "35.278 mm"); + assert!(shows(&texts(&mut app, transform::show), "100 pt"), "points first"); + general_mm(&mut app); + // The next frame: no restart, nothing cached. + let t = texts(&mut app, transform::show); + assert!(shows(&t, &side_mm()) && !shows(&t, "100 pt"), "Transform panel: {t:?}"); + assert!(shows(&texts(&mut app, properties::show), &side_mm()), "Properties panel"); + assert!(shows(&texts(&mut app, crate::chrome::control_bar), &side_mm()), "Control bar"); + assert!(shows(&texts(&mut app, info::show), &format!("W: {}", side_mm())), "Info panel"); + // Document Setup back to points: everything follows the document. + run(&mut app, "document.setUnits", json!({"units": "Points"})); + assert!(shows(&texts(&mut app, transform::show), "100 pt")); +} + +#[test] +fn dialog_lengths_show_in_the_general_unit() { + let mut app = app_with_rect(); + general_mm(&mut app); + crate::menus::invoke(&mut app, "object.move", json!({})); + let t = dialog_texts(&mut app); + assert!(shows(&t, "0 mm"), "Move: {t:?}"); + // A typed unit wins; the move is in points. + app.ui.dialog.as_mut().unwrap().fields.insert("dx".into(), json!("10 mm")); + assert!(shows(&dialog_texts(&mut app), "10 mm")); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let b = st.doc.bounds_of(&st.selection.objects, false).unwrap(); + assert!((b.x0 - (10.0 + 10.0 * 72.0 / 25.4)).abs() < 1e-6, "{b:?}"); + // A shape tool's size dialog and an effect's distances. + crate::dialogs::open_tool_dialog(&mut app, "rectangle", json!({"x": 0, "y": 0})); + assert!(shows(&dialog_texts(&mut app), &side_mm()), "Rectangle"); + app.run("effect.dialog", json!({"effect": "stylize.dropShadow"})).unwrap(); + let x = vectorcraft_effects::default_params("stylize.dropShadow").unwrap()["x"].as_f64().unwrap(); + let t = dialog_texts(&mut app); + assert!(shows(&t, &Unit::Millimeters.format(x)), "Drop Shadow: {t:?}"); + crate::dialogs::cancel(&mut app); +} + +#[test] +fn type_sizes_follow_units_type() { + let mut app = app_with_rect(); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 24}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + general_mm(&mut app); + assert!(shows(&texts(&mut app, character::show), "24 pt"), "General leaves type alone"); + app.run("prefs.set", json!({"key": "unitsType", "value": "inches"})).unwrap(); + let t = texts(&mut app, character::show); + assert!(shows(&t, &Unit::Inches.format(24.0)), "Character panel: {t:?}"); +} + +#[test] +fn the_preferences_dialog_shows_the_documents_units() { + let mut app = app_with_rect(); + run(&mut app, "document.setUnits", json!({"units": "Millimeters"})); + crate::prefs_dialog::open(&mut app, Some("General")); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["unitsGeneral"], json!("millimeters")); + let inc = app.session.prefs.keyboard_increment; + let frame = |app: &mut VectorcraftApp| texts(app, |app, ui| crate::prefs_dialog::show(app, ui.ctx())); + assert!(shows(&frame(&mut app), &Unit::Millimeters.format(inc)), "Keyboard Increment in mm"); + // Choosing another General unit in the dialog shows at once, and OK applies it. + app.ui.dialog.as_mut().unwrap().fields.insert("unitsGeneral".into(), json!("inches")); + assert!(shows(&frame(&mut app), &Unit::Inches.format(inc))); + crate::prefs_dialog::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.units, Unit::Inches); + assert_eq!(app.session.prefs.units_general, "inches"); +} + +#[test] +fn new_documents_start_in_the_general_preference() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + general_mm(&mut app); + app.run("file.newDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["units"], json!("Millimeters")); + let t = dialog_texts(&mut app); + assert!(shows(&t, &Unit::Millimeters.format(612.0)), "Letter in mm: {t:?}"); + crate::dialogs::confirm(&mut app).unwrap(); + let d = &app.session.active().unwrap().doc; + assert_eq!((d.units, d.artboards[0].rect.width()), (Unit::Millimeters, 612.0)); +} + +#[test] +fn ruler_labels_have_the_steps_decimals() { + use crate::canvas::ruler_label; + assert_eq!(ruler_label(100.0000001, 100.0), "100"); + assert_eq!(ruler_label(0.30000000004, 0.1), "0.3"); + assert_eq!(ruler_label(1.25, 0.05), "1.25"); + assert_eq!(ruler_label(-0.0000001, 0.5), "0"); + assert_eq!(ruler_label(-2.5, 0.5), "-2.5"); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a3d05ad6..276ab79f 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -27,7 +27,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::dim_label(ui, "No document"); return; }; - let units = st.doc.units; + let units = app.session.general_unit(); let bounds = app.session.transform_bounds(&st.selection.objects); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); let link: bool = pstate(ui.ctx(), "xf-link"); @@ -41,20 +41,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_enabled_ui(has, |ui| { egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { widgets::dim_label(ui, "X:"); - if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x), units, 74.0) { + if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x), units, 80.0) { app.run("object.setBounds", json!({"x": v, "reference": refi})).ok(); } widgets::dim_label(ui, "W:"); - if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 74.0) { + if let Some(v) = widgets::num_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) { app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); widgets::dim_label(ui, "Y:"); - if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y), units, 74.0) { + if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y), units, 80.0) { app.run("object.setBounds", json!({"y": v, "reference": refi})).ok(); } widgets::dim_label(ui, "H:"); - if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 74.0) { + if let Some(v) = widgets::num_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) { app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index ee49cc53..06c85a23 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -23,6 +23,10 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { // One field per preference key or group (the flattener presets have commands of their own). let mut fields: Map = app.session.prefs.to_json().as_object().cloned().unwrap_or_default(); fields.retain(|k, _| vectorcraft_engine::cmd::prefscmds::spec(k).is_some() || PREF_GROUPS.contains(&k.as_str())); + // Units ▸ General shows (and OK sets) the open document's units. + if let Some(st) = app.session.active() { + fields.insert("unitsGeneral".into(), json!(st.doc.units.key())); + } let cat = category.and_then(|c| PREF_CATEGORIES.iter().find(|x| x.eq_ignore_ascii_case(c))).copied().unwrap_or(PREF_CATEGORIES[0]); fields.insert("__category".into(), json!(cat)); fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); @@ -234,6 +238,16 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { } }); } + PrefKind::Length { min, max, measure } => { + // In the dialog's own Units choice, so a change there shows right away. + let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default(); + let x = d.f64(sp.key, min); + labeled(ui, sp.label, |ui| { + if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) { + d.fields.insert(sp.key.into(), json!(x.clamp(min, max))); + } + }); + } PrefKind::Int { min, max } => { labeled(ui, sp.label, |ui| { let mut x = v.as_i64().unwrap_or(min); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index eba2a74d..ad279eb4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -978,7 +978,7 @@ pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); - let shown = value.map(|v| unit.format(v).trim_end_matches(unit.suffix()).trim_end().to_string()).unwrap_or_default(); + let shown = value.map(|v| unit.number(v)).unwrap_or_default(); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; let enabled = ui.is_enabled(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index acbe4645..6761d5e2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -196,3 +196,10 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt `widthPoint` dialog (fields `id`, `index`, `t`, `side1` and `side2`: the left and right widths in points, `linked`, `adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard", value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`. + +Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`, +Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the +length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a +document). Through `ui.dialog.set` a number is in points and a string may carry its unit (`"10 mm"`; a bare number in a +string is points). The `preferences` dialog's `unitsGeneral` starts as the active document's units, and OK sets them. +`newDocument` has `units` (a unit label; print presets start in `unitsGeneral`, screen presets in Pixels). diff --git a/docs/mcp.md b/docs/mcp.md index 4174d2bf..1072fdeb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -752,3 +752,22 @@ report warn about it). ```json {"name":"run_command","arguments":{"command":"paint.editGradient","params":{"stroke":true,"strokeMode":"along"}}} ``` + +## Units + +Commands take and return lengths in points (the distances of `object.path.offsetPath`, `object.path.simplify`, +`object.path.splitIntoGrid` and `text.setStyle` `size`/`leading` also take a string with a unit, `"5 mm"`). The units +only change what the UI shows and how it reads typed numbers. Preferences ▸ Units has +three: General (rulers, positions and sizes, the Info panel, dialog distances, canvas measurement labels), Stroke +(weights, dashes) and Type (font size, leading, baseline shift). General is the active document's units +(`document.inspect` → `units`): `document.setUnits {units}` (Document Setup) sets it for that document, and +`prefs.set {key: "unitsGeneral", value}` sets it for the active document too (one undo step) and is the units +`file.new` starts in when it gets no `units`. Stroke and Type are the preferences `unitsStroke` and `unitsType`. +`prefs.list` marks the length preferences (`keyboardIncrement`, `cornerRadius`, `pasteOffset`, `gridlineEvery`, +`typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take +a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`, +`Meters`, `Yards`, `Feet` (the preferences use `points`, `picas`, … `feetInches`, `meters`, `yards`, `feet`). + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} +```