diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9516801f6..7662c0cb0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7255,7 +7255,7 @@ fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo v if v["kind"] == "points" => v["value"].as_f64(), _ => None, }; - let field = crate::widgets::NumField::number("pslead", lv, " pt", 2).width(80.0).range(0.0, 5000.0); + let field = crate::widgets::NumField::points("pslead", lv, 2).width(80.0).range(0.0, 5000.0); let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; if let Some(n) = edit { cf.set(d, "leading", n.map_or(Value::Null, |n| json!({"kind": "points", "value": n}))); diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index e95f7cc5a..55ebd5e31 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -192,7 +192,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { } icon(ui, rect, 36.0, 0, "panel-stroke", &tr("Stroke Weight")); if let Some(v) = cell(ui, rect, 56.0, 0, 74.0, selected, |ui| { - NumField::number("csw", info.as_ref().map(|i| i.stroke_weight), " pt", 3).width(74.0).spinner().range(0.0, 1000.0).show(ui) + NumField::points("csw", info.as_ref().map(|i| i.stroke_weight), 3).width(74.0).spinner().range(0.0, 1000.0).show(ui) }) { let _ = app.run("object.stroke", json!({"weight": v})); } diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs index 2c99edc34..86d5c97a0 100644 --- a/crates/ui-egui/src/panels/cell_stroke.rs +++ b/crates/ui-egui/src/panels/cell_stroke.rs @@ -186,7 +186,7 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O egui::Grid::new((id, "cell_stroke")).num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { caption(ui, tr(&lang, "Weight:")); let wid = format!("{id}_weight"); - if let Some(w) = NumField::number(&wid, v["weight"].as_f64(), " pt", 3).width(86.0).spinner().presets(WEIGHTS).range(0.0, 1000.0).show(ui) { + if let Some(w) = NumField::points(&wid, v["weight"].as_f64(), 3).width(86.0).spinner().presets(WEIGHTS).range(0.0, 1000.0).show(ui) { out = Some(("weight", json!(w))); } ui.end_row(); diff --git a/crates/ui-egui/src/panels/properties/object.rs b/crates/ui-egui/src/panels/properties/object.rs index a68974f81..48217bf00 100644 --- a/crates/ui-egui/src/panels/properties/object.rs +++ b/crates/ui-egui/src/panels/properties/object.rs @@ -452,7 +452,7 @@ pub(super) fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { - NumField::number("sw", Some(i.stroke_weight), " pt", 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) + NumField::points("sw", Some(i.stroke_weight), 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) }) { let _ = app.run("object.stroke", json!({"weight": v})); } diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 67d440a1c..cdbc9002d 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -613,6 +613,30 @@ fn character_panel_has_kerning_and_takes_a_typed_manual_value() { assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -40.0})); } +/// The size field reads the unit typed, as InDesign's does: 13Q is 9.213 pt (it used to keep +/// the digits only and set 13 pt). A bare number is still points. +#[test] +fn character_size_field_reads_the_unit_typed() { + let mut h = panel_harness(text_app(), character_panel); + h.run_steps(3); + let size = |app: &mut DesignApp| text_attrs(app).unwrap()["chars"]["size"].as_f64().unwrap(); + for (typed, pt) in [("13Q", 13.0 * designcraft_geom::units::PT_PER_Q), ("5mm", 5.0 * designcraft_geom::units::PT_PER_MM), ("14", 14.0)] { + let field = h + .get_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt") && !v.starts_with('(') && v != "0 pt")) + .unwrap(); + field.focus(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).unwrap().type_text(typed); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let got = size(h.state_mut()); + assert!((got - pt).abs() < 1e-6, "{typed}: {got} pt, want {pt}"); + } +} + #[test] fn a_manual_kerning_value_at_a_caret_kerns_the_pair() { let mut app = text_app(); diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 5cbd62c65..f27ceabcc 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -148,7 +148,7 @@ pub(super) fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { - NumField::number("tsw", c["strokeWeight"].as_f64(), " pt", 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 100.0).show(ui) + NumField::points("tsw", c["strokeWeight"].as_f64(), 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 100.0).show(ui) }) { let _ = app.run("type.char", json!({"attrs": {"strokeWeight": v}})); } @@ -204,7 +204,7 @@ pub(super) fn character_section(app: &mut DesignApp, ui: &mut Ui) { let lv = if auto { size.map(|s| s * a["para"]["autoLeading"].as_f64().unwrap_or(1.2)) } else { c["leading"]["value"].as_f64() }; icons::paint(ui.painter(), sub(b, 0.0, 1.0, 19.0, 19.0), "font-size", t.icon); if let Some(v) = place(ui, sub(b, 24.0, 0.0, half - 24.0, FIELD_H), |ui| { - NumField::number("csize", size, " pt", 2).width(half - 24.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui) + NumField::points("csize", size, 2).width(half - 24.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui) }) { let _ = app.run("type.char", json!({"attrs": {"size": v}})); } @@ -214,7 +214,7 @@ pub(super) fn character_section(app: &mut DesignApp, ui: &mut Ui) { if let Some(v) = place(ui, sub(b, half + 34.0, 0.0, lw, FIELD_H), |ui| { let key = if auto { "cleadauto" } else { "clead" }; // Auto leading is shown in parentheses, like "(14.4 pt)". - NumField::number(key, lshow, " pt", 2).width(lw).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) + NumField::points(key, lshow, 2).width(lw).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) }) { let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); } diff --git a/crates/ui-egui/src/panels/properties/typography.rs b/crates/ui-egui/src/panels/properties/typography.rs index d4959c406..797a36bf9 100644 --- a/crates/ui-egui/src/panels/properties/typography.rs +++ b/crates/ui-egui/src/panels/properties/typography.rs @@ -50,7 +50,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let (left_w, right_w) = (caption_width(app, ui, &["Size", "Kerning", "Baseline"]), caption_width(app, ui, &["Leading", "Tracking"])); ui.columns(2, |cols| { let [left, right] = cols else { return }; - if let Some(v) = char_field(app, left, "Size", left_w, NumField::number("pcs", c["size"].as_f64(), " pt", 2)) { + if let Some(v) = char_field(app, left, "Size", left_w, NumField::points("pcs", c["size"].as_f64(), 2)) { let _ = app.run("type.char", json!({"attrs": {"size": v}})); } left.horizontal(|ui| { @@ -58,11 +58,11 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let width = (ui.available_width() - ui.spacing().item_spacing.x).max(40.0); kerning_control(app, ui, "pck", &c["kerning"], width); }); - if let Some(v) = char_field(app, left, "Baseline", left_w, NumField::number("pcb", c["baselineShift"].as_f64(), " pt", 2)) { + if let Some(v) = char_field(app, left, "Baseline", left_w, NumField::points("pcb", c["baselineShift"].as_f64(), 2)) { let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); } // Auto leading is shown in parentheses, as the Paragraph panel shows it. - if let Some(v) = char_field(app, right, "Leading", right_w, NumField::number("pcl", lv, " pt", 2).parens(auto)) { + if let Some(v) = char_field(app, right, "Leading", right_w, NumField::points("pcl", lv, 2).parens(auto)) { let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); } if let Some(v) = char_field(app, right, "Tracking", right_w, NumField::number("pct", c["tracking"].as_f64(), "", 0)) { diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 4411d93dd..48096a0e6 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -156,7 +156,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); icons::paint(ui.painter(), r, "font-size", t.icon); const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; - if let Some(v) = NumField::number("tb_size", c["size"].as_f64(), " pt", 2).width(70.0).presets(SIZES).range(0.1, 1296.0).show(ui) { + if let Some(v) = NumField::points("tb_size", c["size"].as_f64(), 2).width(70.0).presets(SIZES).range(0.1, 1296.0).show(ui) { let _ = app.run("type.char", json!({"attrs": {"size": v}})); } sep(ui); @@ -237,7 +237,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { } } const WEIGHTS: &[f64] = &[0.25, 0.5, 1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0]; - if let Some(v) = NumField::number("tb_sw", Some(i.stroke_weight), " pt", 3).width(64.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) { + if let Some(v) = NumField::points("tb_sw", Some(i.stroke_weight), 3).width(64.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) { let _ = app.run("object.stroke", json!({"weight": v})); } sep(ui); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a286afccf..58ac91e89 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -28,8 +28,11 @@ fn label_font() -> egui::FontId { pub enum NumKind<'a> { /// A length in points shown in `Unit`; accepts any unit and arithmetic. Measure(Unit), - /// A plain number with a display suffix (`%`, `°`, ` pt`). + /// A plain number with a display suffix (`%`, `°`). Number { suffix: &'a str, decimals: usize }, + /// A length in points shown as `12.5 pt` (type size, leading, stroke weight…). Like a measure + /// it reads any unit and arithmetic (`13Q`, `5mm`, `9pt+1H`); a bare number is points. + Points { decimals: usize }, } impl NumKind<'_> { @@ -42,6 +45,7 @@ impl NumKind<'_> { let s = if s == "-0" { "0".to_string() } else { s }; format!("{s}{suffix}") } + NumKind::Points { decimals } => NumKind::Number { suffix: " pt", decimals }.format(v), } } pub fn parse(&self, s: &str) -> Option { @@ -53,6 +57,7 @@ impl NumKind<'_> { s.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '(' | ')' | ' ')).collect(); parse_measure(cleaned.trim(), Unit::Points).ok().or_else(|| cleaned.trim().parse().ok()) } + NumKind::Points { .. } => parse_measure(s, Unit::Points).ok(), } } /// One spinner click (points for measures). @@ -63,7 +68,7 @@ impl NumKind<'_> { Unit::Inches | Unit::InchesDecimal => 72.0 / 16.0, Unit::Millimeters | Unit::Centimeters | Unit::Q | Unit::Ha => Unit::Millimeters.to_pt(1.0), }, - NumKind::Number { .. } => 1.0, + NumKind::Number { .. } | NumKind::Points { .. } => 1.0, } } } @@ -91,9 +96,18 @@ impl<'a> NumField<'a> { pub fn measure(id: &'a str, value: Option, unit: Unit) -> Self { Self::new(id, value, NumKind::Measure(unit)) } + /// A number shown with `suffix` (`%`, `°`). A ` pt` suffix makes it a length in points that + /// reads any unit ([`Self::points`]), so no point field reads `13Q` as 13 pt. pub fn number(id: &'a str, value: Option, suffix: &'a str, decimals: usize) -> Self { + if suffix == " pt" { + return Self::points(id, value, decimals); + } Self::new(id, value, NumKind::Number { suffix, decimals }) } + /// A length in points (type size, leading, stroke weight…): shows `12 pt`, reads any unit. + pub fn points(id: &'a str, value: Option, decimals: usize) -> Self { + Self::new(id, value, NumKind::Points { decimals }) + } /// Initialize a numeric field with no name, label or empty text, a 72-point width, no spinner or presets, the full range and the enabled state. fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { NumField { @@ -290,8 +304,12 @@ impl<'a> NumField<'a> { // whatever was typed, as in InDesign. let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); + // Otherwise a field left as it shows its value commits nothing: reading the rounded + // text back would round the value (13Q is 9.2126 pt, shown as 9.21 pt). + let unchanged = !typed_over_auto && self.value.is_some_and(|old| buf.trim() == self.fmt(old).trim()); if escaped { } else if let Some(v) = self.parse(&buf).map(clamp) + && !unchanged && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { out = Some(v); @@ -845,6 +863,25 @@ mod tests { assert_eq!(m.step(), 1.0); } + /// A length field in points reads the unit typed, as InDesign's do (`12pt` in a Q field shows + /// 16.933 Q): `13Q` is 9.213 pt, not 13 pt. + #[test] + fn points_kind_reads_any_unit() { + let k = NumKind::Points { decimals: 2 }; + assert_eq!(k.format(12.5), "12.5 pt"); + let close = |s: &str, pt: f64| (k.parse(s).unwrap() - pt).abs() < 1e-9; + assert!(close("12", 12.0) && close("12 pt", 12.0) && close("1p", 12.0)); + assert!(close("13Q", 13.0 * designcraft_geom::units::PT_PER_Q), "{:?}", k.parse("13Q")); + assert!(close("22H", 22.0 * designcraft_geom::units::PT_PER_Q)); + assert!(close("5mm", 5.0 * designcraft_geom::units::PT_PER_MM)); + assert!(close("9pt+1H", 9.0 + designcraft_geom::units::PT_PER_Q)); + assert_eq!(k.parse("13x"), None, "an unknown unit is an invalid value, not 13 pt"); + // Every ` pt` field reads units, whichever constructor made it (the Control panel's Font + // Size, Leading and Baseline Shift, Character ••• Baseline Shift…). + assert_eq!(NumField::number("a", None, " pt", 2).kind, NumKind::Points { decimals: 2 }); + assert_eq!(NumField::number("a", None, "%", 0).kind, NumKind::Number { suffix: "%", decimals: 0 }); + } + /// A disabled field's preset button is disabled in AccessKit and cannot open a popup. #[test] fn disabled_numeric_presets_are_disabled_and_do_not_open() {