mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Merge pull request #67 from storytold/pr/preferences-units
Preferences ▸ Units drive every length the UI shows and reads (M14.5)
This commit is contained in:
+40
-5
@@ -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<f64> {
|
||||
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<Unit> {
|
||||
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<f64> {
|
||||
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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<Item = &'static str> + '_ {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New…",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
|
||||
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK)}",
|
||||
always,
|
||||
file_new
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Units",
|
||||
["File", "Document Setup"],
|
||||
None,
|
||||
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}",
|
||||
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"|\"Feet & Inches\"|\"Meters\"|\"Yards\"|\"Feet\"} the document's units: every length the UI shows and reads (General)",
|
||||
has_doc,
|
||||
set_units
|
||||
),
|
||||
@@ -64,9 +64,10 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mut d = Document::new_with_mode(w, h, mode);
|
||||
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
|
||||
if let Some(u) = str_param(p, "units") {
|
||||
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
|
||||
}
|
||||
d.units = match str_param(p, "units") {
|
||||
Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?,
|
||||
None => s.default_units(),
|
||||
};
|
||||
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
|
||||
for i in 1..n {
|
||||
let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
|
||||
@@ -82,11 +83,6 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "index": i }))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_unit(u: &str) -> Option<Unit> {
|
||||
let n = u.to_ascii_lowercase();
|
||||
Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n)
|
||||
}
|
||||
|
||||
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
||||
if !s.close_document(i) {
|
||||
@@ -107,11 +103,8 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
|
||||
s.edit("Document Setup", |d, _| {
|
||||
d.units = u;
|
||||
Ok(())
|
||||
})?;
|
||||
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
|
||||
s.set_document_units(u)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,15 @@ pub(crate) fn num_param(p: &Value, key: &str) -> Option<f64> {
|
||||
.filter(|v: &f64| v.is_finite())
|
||||
}
|
||||
|
||||
/// A distance param in points: a number, or a string with a unit (`"5 mm"`; bare numbers are
|
||||
/// points).
|
||||
pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
|
||||
match p.get(key)? {
|
||||
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| num_param(p, key)),
|
||||
_ => num_param(p, key),
|
||||
}
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
@@ -397,7 +406,7 @@ fn join_param(p: &Value, key: &str) -> po::Join {
|
||||
}
|
||||
|
||||
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let delta = num_param(p, "offset").unwrap_or(10.0);
|
||||
let delta = len_param(p, "offset").unwrap_or(10.0);
|
||||
if delta.abs() > 1e5 {
|
||||
return Err(bad("object.path.offsetPath", "offset out of range"));
|
||||
}
|
||||
@@ -661,7 +670,7 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -
|
||||
|
||||
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opts = po::SimplifyOptions {
|
||||
tolerance: num_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
|
||||
tolerance: len_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
|
||||
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
|
||||
straight_lines: bool_or(p, "straightLines", false),
|
||||
};
|
||||
@@ -682,7 +691,7 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
|
||||
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
|
||||
}
|
||||
let gutter = num_param(p, "gutter").unwrap_or(12.0).max(0.0);
|
||||
let gutter = len_param(p, "gutter").unwrap_or(12.0).max(0.0);
|
||||
let roots = selected_roots(s)?;
|
||||
let ids = s.edit("Split Into Grid", |d, sel| {
|
||||
let mut out = vec![];
|
||||
|
||||
@@ -7,6 +7,8 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::Prefs;
|
||||
use crate::units::Measure;
|
||||
use vectorcraft_doc::Unit;
|
||||
|
||||
/// What kind of value a preference holds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -18,6 +20,13 @@ pub enum PrefKind {
|
||||
max: f64,
|
||||
unit: &'static str,
|
||||
},
|
||||
/// A length in points in `min..=max`, shown and typed in the unit of `measure` (also given
|
||||
/// as a string with a unit, "5 mm").
|
||||
Length {
|
||||
min: f64,
|
||||
max: f64,
|
||||
measure: Measure,
|
||||
},
|
||||
/// A whole number in `min..=max`.
|
||||
Int {
|
||||
min: i64,
|
||||
@@ -59,6 +68,7 @@ pub const PREF_CATEGORIES: &[&str] = &[
|
||||
"Devices",
|
||||
];
|
||||
|
||||
/// Every unit as (`Unit::key`, `Unit::label`).
|
||||
pub const UNITS: &[(&str, &str)] = &[
|
||||
("points", "Points"),
|
||||
("picas", "Picas"),
|
||||
@@ -66,6 +76,10 @@ pub const UNITS: &[(&str, &str)] = &[
|
||||
("millimeters", "Millimeters"),
|
||||
("centimeters", "Centimeters"),
|
||||
("pixels", "Pixels"),
|
||||
("feetInches", "Feet & Inches"),
|
||||
("meters", "Meters"),
|
||||
("yards", "Yards"),
|
||||
("feet", "Feet"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
@@ -78,6 +92,9 @@ macro_rules! p {
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, num($min:expr, $max:expr, $unit:literal)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Num { min: $min, max: $max, unit: $unit } }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, len($min:expr, $max:expr, $m:ident)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Length { min: $min, max: $max, measure: Measure::$m } }
|
||||
};
|
||||
($key:literal, $cat:literal, $sec:literal, $label:literal, int($min:expr, $max:expr)) => {
|
||||
PrefSpec { key: $key, category: $cat, section: $sec, label: $label, kind: PrefKind::Int { min: $min, max: $max } }
|
||||
};
|
||||
@@ -94,10 +111,10 @@ macro_rules! p {
|
||||
|
||||
pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// General
|
||||
p!("keyboardIncrement", "General", "", "Keyboard Increment", num(0.001, 1296.0, "pt")),
|
||||
p!("keyboardIncrement", "General", "", "Keyboard Increment", len(0.001, 1296.0, General)),
|
||||
p!("constrainAngle", "General", "", "Constrain Angle", num(-360.0, 360.0, "°")),
|
||||
p!("cornerRadius", "General", "", "Corner Radius", num(0.0, 1296.0, "pt")),
|
||||
p!("pasteOffset", "General", "", "Paste Offset", num(0.0, 1296.0, "pt")),
|
||||
p!("cornerRadius", "General", "", "Corner Radius", len(0.0, 1296.0, General)),
|
||||
p!("pasteOffset", "General", "", "Paste Offset", len(0.0, 1296.0, General)),
|
||||
p!("disableAutoAddDelete", "General", "Options", "Disable Auto Add/Delete", bool),
|
||||
p!("usePreciseCursors", "General", "Options", "Use Precise Cursors", bool),
|
||||
p!("showToolTips", "General", "Options", "Show Tool Tips", bool),
|
||||
@@ -150,9 +167,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("penRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Pen Tool", bool),
|
||||
p!("curvatureRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Curvature Tool", bool),
|
||||
// Type
|
||||
p!("typeSizeIncrement", "Type", "", "Size/Leading", num(0.001, 1296.0, "pt")),
|
||||
p!("typeSizeIncrement", "Type", "", "Size/Leading", len(0.001, 1296.0, Type)),
|
||||
p!("trackingIncrement", "Type", "", "Tracking", num(1.0, 1000.0, "/1000 em")),
|
||||
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", num(0.001, 1296.0, "pt")),
|
||||
p!("baselineShiftIncrement", "Type", "", "Baseline Shift", len(0.001, 1296.0, Type)),
|
||||
p!("showEastAsianOptions", "Type", "Language Options", "Show East Asian Options", bool),
|
||||
p!("showIndicOptions", "Type", "Language Options", "Show Indic Options", bool),
|
||||
p!("typeSelectionByPathOnly", "Type", "Options", "Type Object Selection by Path Only", bool),
|
||||
@@ -176,7 +193,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("guideStyle", "Guides & Grid", "Guides", "Style", choice(LINE_STYLE)),
|
||||
p!("gridColor", "Guides & Grid", "Grid", "Color", color),
|
||||
p!("gridStyle", "Guides & Grid", "Grid", "Style", choice(LINE_STYLE)),
|
||||
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", num(1.0, 16383.0, "pt")),
|
||||
p!("gridlineEvery", "Guides & Grid", "Grid", "Gridline every", len(1.0, 16383.0, General)),
|
||||
p!("gridSubdivisions", "Guides & Grid", "Grid", "Subdivisions", int(1, 1000)),
|
||||
p!("gridsInBack", "Guides & Grid", "Grid", "Grids In Back", bool),
|
||||
p!("showPixelGrid", "Guides & Grid", "Grid", "Show Pixel Grid (Above 600% Zoom)", bool),
|
||||
@@ -333,11 +350,8 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
Value::String(s) if s == "true" || s == "false" => Ok(json!(s == "true")),
|
||||
_ => Err(format!("`{key}` must be true or false")),
|
||||
},
|
||||
PrefKind::Num { min, max, .. } => match num() {
|
||||
Some(x) if x.is_finite() && (min..=max).contains(&x) => Ok(json!(x)),
|
||||
Some(x) => Err(format!("`{key}` must be between {min} and {max} (got {x})")),
|
||||
None => Err(format!("`{key}` must be a number")),
|
||||
},
|
||||
PrefKind::Num { min, max, .. } => in_range(key, num(), min, max),
|
||||
PrefKind::Length { min, max, .. } => in_range(key, v.as_f64().or_else(|| v.as_str().and_then(|s| Unit::Points.parse(s))), min, max),
|
||||
PrefKind::Int { min, max } => match num() {
|
||||
Some(x) if x.fract() == 0.0 && (min as f64..=max as f64).contains(&x) => Ok(json!(x as i64)),
|
||||
Some(x) => Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")),
|
||||
@@ -366,6 +380,15 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` (`None`: not a number) as the value of number preference `key` in `min..=max`.
|
||||
fn in_range(key: &str, x: Option<f64>, min: f64, max: f64) -> std::result::Result<Value, String> {
|
||||
match x {
|
||||
Some(x) if x.is_finite() && (min..=max).contains(&x) => Ok(json!(x)),
|
||||
Some(x) => Err(format!("`{key}` must be between {min} and {max} (got {x})")),
|
||||
None => Err(format!("`{key}` must be a number")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or(Value::Null)
|
||||
@@ -436,7 +459,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; the eyedropper group takes a partial object, as eyedropper.setOptions)",
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
@@ -465,6 +488,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
|
||||
s.apply_prefs(next);
|
||||
// Units ▸ General is also the open document's units (as Document Setup sets them).
|
||||
if values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
s.set_document_units(s.default_units())?;
|
||||
}
|
||||
let all = s.prefs.to_json();
|
||||
Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect()))
|
||||
}
|
||||
@@ -497,6 +524,13 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
o["max"] = json!(max);
|
||||
o["unit"] = json!(unit);
|
||||
}
|
||||
PrefKind::Length { min, max, measure } => {
|
||||
o["kind"] = json!("number");
|
||||
o["min"] = json!(min);
|
||||
o["max"] = json!(max);
|
||||
o["unit"] = json!("pt");
|
||||
o["measure"] = json!(measure.name());
|
||||
}
|
||||
PrefKind::Int { min, max } => {
|
||||
o["kind"] = json!("integer");
|
||||
o["min"] = json!(min);
|
||||
|
||||
@@ -241,7 +241,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
impl Session {
|
||||
/// Units > Stroke (Preferences): the unit stroke weights and dash lengths show in.
|
||||
pub fn stroke_unit(&self) -> Unit {
|
||||
super::edit::parse_unit(&self.prefs.units_stroke).unwrap_or_default()
|
||||
self.unit(crate::units::Measure::Stroke)
|
||||
}
|
||||
|
||||
/// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the
|
||||
|
||||
@@ -8,7 +8,7 @@ use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{num_param, shape_node};
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -169,14 +169,14 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = str_param(p, "font").map(str::to_string);
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let size = num_param(p, "size");
|
||||
let size = len_param(p, "size");
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let leading = match p.get("leading") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
Some(_) => Some(Some(num_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
|
||||
Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
|
||||
};
|
||||
let tracking = num_param(p, "tracking");
|
||||
let justify = match str_param(p, "justify") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> =
|
||||
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));
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,11 +123,11 @@ impl CornerDrag {
|
||||
}
|
||||
|
||||
/// The radius readout next to the pointer.
|
||||
pub fn overlays(&self) -> Vec<Overlay> {
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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)) }]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,10 +231,10 @@ impl Tool for DirectSelectionTool {
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -182,11 +182,11 @@ impl Tool for FamilyTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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![],
|
||||
}
|
||||
|
||||
+19
-1
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Overlay> {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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());
|
||||
|
||||
@@ -185,12 +185,12 @@ impl Tool for ShapeTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
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![],
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<Point>, 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<Point>, 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<Point>, 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<Point>, 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<Point>, 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
|
||||
@@ -111,7 +111,8 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -378,10 +378,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
};
|
||||
let r = match id {
|
||||
"file.newDialog" => {
|
||||
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") {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<usize>, 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<usize>, 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f64> = 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}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<bool>(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::<bool>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,3 +454,5 @@ mod tests_strokedepth;
|
||||
mod tests_strokeux;
|
||||
#[cfg(test)]
|
||||
mod tests_style_libraries;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String, Value> = 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);
|
||||
|
||||
@@ -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<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
|
||||
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::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||||
let enabled = ui.is_enabled();
|
||||
|
||||
@@ -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).
|
||||
|
||||
+19
@@ -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"}}}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user