From aa55877b7af1f49dd3d3b2858c9b3e90133c541e Mon Sep 17 00:00:00 2001 From: con3code Date: Sat, 17 Oct 2026 22:51:34 +0900 Subject: [PATCH 1/8] Mojikumi: consecutive punctuation closes up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening brackets together, and closing brackets, commas and full stops together, now touch (JLREQ 3.1.4): 」。 and 「『 no longer keep half an em between them. A closing bracket and an opening one stay half an em apart, and other neighbours keep the larger blank, as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/compose/src/tests.rs | 22 ++++++++++++++++++++++ crates/doc/src/mojikumi.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 9d8f6be70..b11031c6c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -5443,6 +5443,28 @@ fn mojikumi_one_em_line_end_set_sets_closing_punctuation_full_width_at_line_end( assert!((open - one.0 - em * 0.5).abs() < 0.01 && (stop - one.1).abs() < 0.01, "one-em set: {one:?} of {open}/{stop}"); } +/// Consecutive punctuation touches (JLREQ 3.1.4): in 漢」。漢(「漢 the closing bracket and +/// the full stop, and the two opening brackets, close up by half an em each. +#[test] +fn mojikumi_closes_up_consecutive_punctuation() { + let family = "Mojikumi Consecutive"; + let font = designcraft_fonts::testing::font_mapping(family, &[('漢', '‰'), ('」', '‰'), ('。', '‰'), ('(', '‰'), ('「', '‰')]).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let text = "漢」。漢(「漢"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + let mut width = |set: &str| { + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some(set.into()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + let g = &line.glyphs[0]; + (line.end_x - line.x0, g.face.units_per_em() * g.sx) + }; + let (natural, em) = width("Nothing"); + let (set, _) = width("LineEndAllOneHalfEmEnum"); + assert!((natural - set - em).abs() < 0.01, "{natural} - {set} should be one em ({em})"); +} + fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec) { let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default()); { diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 415e26bc3..4e99900eb 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -126,6 +126,14 @@ impl<'a> Rules<'a> { if right == 22 { return Aki::fixed(if self.base == Base::LineEndHalfEm { 0.0 } else { trailing(left) }); } + // Consecutive punctuation (JLREQ 3.1.4): opening brackets together, and closing brackets, + // commas and full stops together, touch. Other neighbours keep the larger blank, so a + // closing bracket and an opening one are half an em apart. + let opening = |c| matches!(c, 1 | 26 | 27); + let closing = |c| matches!(c, 2 | 6 | 21 | 28..=31); + if (opening(left) && opening(right)) || (closing(left) && closing(right)) { + return Aki::default(); + } let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; @@ -197,6 +205,24 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; + #[test] + fn consecutive_punctuation_touches_except_closing_before_opening() { + let styles = Styles::default(); + for set in ["LineEndAllOneHalfEmEnum", "LineEndAllOneEmEnum", "SimpChineseDefault"] { + let rules = Rules::resolve(&styles, set).unwrap().unwrap(); + let aki = |a: char, b: char| rules.pair(class(a), class(b)).desired; + for (a, b) in + [('」', '。'), ('」', '、'), ('。', '」'), ('、', '」'), ('」', '』'), (')', '」'), (',', ')'), ('「', '『'), ('(', '「')] + { + assert_eq!(aki(a, b), 0.0, "{set}: {a}{b}"); + } + for (a, b) in [('」', '「'), ('。', '「'), ('、', '('), ('」', '漢'), ('漢', '「')] { + assert_eq!(aki(a, b), 0.5, "{set}: {a}{b}"); + } + assert_eq!(aki('」', '・'), 0.5, "{set}: the larger blank stays"); + } + } + #[test] fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { let mut styles = Styles::default(); From e06f4134d4d887626b371ce0f4e665e84b53caf4 Mon Sep 17 00:00:00 2001 From: con3code Date: Sat, 17 Oct 2026 23:08:18 +0900 Subject: [PATCH 2/8] Mojikumi: move the consecutive punctuation test clear of #1127's Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/doc/src/mojikumi.rs | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 4e99900eb..e0a03d85a 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -205,24 +205,6 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; - #[test] - fn consecutive_punctuation_touches_except_closing_before_opening() { - let styles = Styles::default(); - for set in ["LineEndAllOneHalfEmEnum", "LineEndAllOneEmEnum", "SimpChineseDefault"] { - let rules = Rules::resolve(&styles, set).unwrap().unwrap(); - let aki = |a: char, b: char| rules.pair(class(a), class(b)).desired; - for (a, b) in - [('」', '。'), ('」', '、'), ('。', '」'), ('、', '」'), ('」', '』'), (')', '」'), (',', ')'), ('「', '『'), ('(', '「')] - { - assert_eq!(aki(a, b), 0.0, "{set}: {a}{b}"); - } - for (a, b) in [('」', '「'), ('。', '「'), ('、', '('), ('」', '漢'), ('漢', '「')] { - assert_eq!(aki(a, b), 0.5, "{set}: {a}{b}"); - } - assert_eq!(aki('」', '・'), 0.5, "{set}: the larger blank stays"); - } - } - #[test] fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { let mut styles = Styles::default(); @@ -263,6 +245,24 @@ mod tests { assert_ne!(class('「'), class('(')); } + #[test] + fn consecutive_punctuation_touches_except_closing_before_opening() { + let styles = Styles::default(); + for set in ["LineEndAllOneHalfEmEnum", "LineEndAllOneEmEnum", "SimpChineseDefault"] { + let rules = Rules::resolve(&styles, set).unwrap().unwrap(); + let aki = |a: char, b: char| rules.pair(class(a), class(b)).desired; + for (a, b) in + [('」', '。'), ('」', '、'), ('。', '」'), ('、', '」'), ('」', '』'), (')', '」'), (',', ')'), ('「', '『'), ('(', '「')] + { + assert_eq!(aki(a, b), 0.0, "{set}: {a}{b}"); + } + for (a, b) in [('」', '「'), ('。', '「'), ('、', '('), ('」', '漢'), ('漢', '「')] { + assert_eq!(aki(a, b), 0.5, "{set}: {a}{b}"); + } + assert_eq!(aki('」', '・'), 0.5, "{set}: the larger blank stays"); + } + } + #[test] fn one_em_line_end_set_keeps_closing_punctuation_full_at_line_end_only() { let styles = Styles::default(); From 46596a9d19f21a12f94053ef2cb94b3e5024bda4 Mon Sep 17 00:00:00 2001 From: con3code Date: Sat, 17 Oct 2026 23:42:21 +0900 Subject: [PATCH 3/8] Mojikumi: close up consecutive punctuation in the Japanese sets only The Chinese sets keep the larger blank between two marks, as before: JLREQ's rule is Japanese, and Chinese styles (Taiwan's centred, full-width marks among them) set consecutive punctuation their own way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/doc/src/mojikumi.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index e0a03d85a..daabe34d7 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -126,12 +126,13 @@ impl<'a> Rules<'a> { if right == 22 { return Aki::fixed(if self.base == Base::LineEndHalfEm { 0.0 } else { trailing(left) }); } - // Consecutive punctuation (JLREQ 3.1.4): opening brackets together, and closing brackets, - // commas and full stops together, touch. Other neighbours keep the larger blank, so a - // closing bracket and an opening one are half an em apart. + // Consecutive punctuation in the Japanese sets (JLREQ 3.1.4): opening brackets together, + // and closing brackets, commas and full stops together, touch. Other neighbours keep the + // larger blank, so a closing bracket and an opening one are half an em apart. The Chinese + // sets keep the larger blank throughout. let opening = |c| matches!(c, 1 | 26 | 27); let closing = |c| matches!(c, 2 | 6 | 21 | 28..=31); - if (opening(left) && opening(right)) || (closing(left) && closing(right)) { + if self.base != Base::Chinese && ((opening(left) && opening(right)) || (closing(left) && closing(right))) { return Aki::default(); } let punctuation = trailing(left).max(leading(right)); @@ -248,7 +249,7 @@ mod tests { #[test] fn consecutive_punctuation_touches_except_closing_before_opening() { let styles = Styles::default(); - for set in ["LineEndAllOneHalfEmEnum", "LineEndAllOneEmEnum", "SimpChineseDefault"] { + for set in ["LineEndAllOneHalfEmEnum", "LineEndAllOneEmEnum"] { let rules = Rules::resolve(&styles, set).unwrap().unwrap(); let aki = |a: char, b: char| rules.pair(class(a), class(b)).desired; for (a, b) in @@ -261,6 +262,13 @@ mod tests { } assert_eq!(aki('」', '・'), 0.5, "{set}: the larger blank stays"); } + // The Chinese sets are unchanged: the larger blank stays between any two marks. + for set in ["SimpChineseDefault", "TradChineseDefault"] { + let rules = Rules::resolve(&styles, set).unwrap().unwrap(); + for (a, b) in [('」', '。'), (',', ')'), ('「', '『'), ('」', '「')] { + assert_eq!(rules.pair(class(a), class(b)).desired, 0.5, "{set}: {a}{b}"); + } + } } #[test] From 91d4c66b242bea390e866769ca59cc7ad40844ce Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 18 Oct 2026 01:12:41 +0900 Subject: [PATCH 4/8] docs/cjk.md: mojikumi is applied; consecutive punctuation The status table still said mojikumi tables were kept but not applied, which #129 changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- docs/cjk.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cjk.md b/docs/cjk.md index 4ee48dc06..d37e9c29c 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -1,6 +1,6 @@ # CJK typesetting plan -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; content unchanged in the 2026-10-10 review) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (mojikumi applied; consecutive punctuation) · **Target:** Adobe InDesign 2026 Goal: the Chinese, Japanese and Korean typesetting that InDesign offers with its East Asian feature set (the "Japanese/CJK" feature set, `Feature Set Locale Setting` 257), so that a @@ -37,7 +37,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | -| Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) | +| Mojikumi | applied in composition: the built-in base sets (line end half-width, line end one em, Simplified and Traditional Chinese) and the document's tables with their override rows, kept through IDML; Preflight notes tables it can't apply. In the Japanese sets consecutive punctuation closes up (JLREQ 3.1.4: 」。, 「『 touch; 」「 half an em apart) | | Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | | Character alignment, leading model | em box top / centre / bottom, ICF top / bottom (`BASE` `icft`/`icfb`, else the em box inset by 5%), roman baseline; aki above / below, centre (centre down as centre); under aki below a line's leading is the space to the line below it; with the Japanese composers (`Composer::Japanese`, `JapaneseSingleLine`, IDML `HL Composer J` / `HL Single J`), the others set lines baseline to baseline on the Roman baseline | | Tate-chu-yoko | manual, with offsets; auto tate-chu-yoko of short digit runs (with roman letters when asked) in vertical text, narrowed to fit the em (`type.autoTateChuYoko`, IDML `AutoTcy`, `AutoTcyIncludeRoman`); manual groups of 3+ digits overflow the em | @@ -75,7 +75,7 @@ punctuation centred vs corner-placed). A *mojikumi set* is a table indexed by (class before, class after) giving the space between them as a min/optimum/max triple in em fractions, plus line-start and line-end rules. Sets are document resources (`Styles::mojikumi_tables`, read from and written to IDML with their override -rows), referenced by name from the paragraph's `mojikumi`; what remains is applying them. We +rows), referenced by name from the paragraph's `mojikumi`, and applied in composition. We ship a few built-in sets derived from JLREQ (no line-start indent, one-em indent, half-width punctuation, full-width punctuation…), all original data, for the IDML built-in names. @@ -292,6 +292,7 @@ Each follows what an InDesign user expects. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Mojikumi is applied (since #129); consecutive punctuation closes up in the Japanese sets | | 2026-10-10 | trivial | Reviewed in the full re-measure; status line and this table added; summarized in [typography-parity.md](typography-parity.md) and [localization-parity.md](localization-parity.md) | | 2026-10-10 | minor | Updated (see git history) | | 2026-10-06 | major | Created: CJK typesetting plan and status (the Japanese/CJK feature set) | From 5248ad683cd2e11adf889d90e5d3a13c958fcf36 Mon Sep 17 00:00:00 2001 From: con3code Date: Tue, 20 Oct 2026 19:33:13 +0900 Subject: [PATCH 5/8] Mojikumi: a middle dot beside a bracket keeps only its quarter em (JLREQ 3.1.4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/doc/src/mojikumi.rs | 27 ++++++++++++++++++++------- docs/cjk.md | 2 +- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index daabe34d7..54f3269c0 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -127,13 +127,21 @@ impl<'a> Rules<'a> { return Aki::fixed(if self.base == Base::LineEndHalfEm { 0.0 } else { trailing(left) }); } // Consecutive punctuation in the Japanese sets (JLREQ 3.1.4): opening brackets together, - // and closing brackets, commas and full stops together, touch. Other neighbours keep the - // larger blank, so a closing bracket and an opening one are half an em apart. The Chinese - // sets keep the larger blank throughout. + // and closing brackets, commas and full stops together, touch. A middle dot (・:;) after + // a closing bracket or before an opening one keeps only its own quarter em. Other + // neighbours keep the larger blank, so a closing bracket and an opening one are half an + // em apart. The Chinese sets keep the larger blank throughout. let opening = |c| matches!(c, 1 | 26 | 27); let closing = |c| matches!(c, 2 | 6 | 21 | 28..=31); - if self.base != Base::Chinese && ((opening(left) && opening(right)) || (closing(left) && closing(right))) { - return Aki::default(); + let closing_bracket = |c| matches!(c, 2 | 28 | 29); + let middle_dot = |c| matches!(c, 5 | 32); + if self.base != Base::Chinese { + if (opening(left) && opening(right)) || (closing(left) && closing(right)) { + return Aki::default(); + } + if (closing_bracket(left) && middle_dot(right)) || (middle_dot(left) && opening(right)) { + return Aki { min: 0.0, desired: 0.25, max: 0.25, priority: 1, discrete: false }; + } } let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { @@ -260,12 +268,17 @@ mod tests { for (a, b) in [('」', '「'), ('。', '「'), ('、', '('), ('」', '漢'), ('漢', '「')] { assert_eq!(aki(a, b), 0.5, "{set}: {a}{b}"); } - assert_eq!(aki('」', '・'), 0.5, "{set}: the larger blank stays"); + // A middle dot keeps its quarter em beside a bracket (JLREQ 3.1.4, Figure 69 ⑦). + for (a, b) in [('」', '・'), (')', ':'), ('』', ';'), ('・', '「'), (';', '('), (':', '〈')] { + assert_eq!(aki(a, b), 0.25, "{set}: {a}{b}"); + } + // After a comma or full stop the larger blank stays. + assert_eq!(aki('、', '・'), 0.5, "{set}: 、・"); } // The Chinese sets are unchanged: the larger blank stays between any two marks. for set in ["SimpChineseDefault", "TradChineseDefault"] { let rules = Rules::resolve(&styles, set).unwrap().unwrap(); - for (a, b) in [('」', '。'), (',', ')'), ('「', '『'), ('」', '「')] { + for (a, b) in [('」', '。'), (',', ')'), ('「', '『'), ('」', '「'), ('」', '・'), ('・', '「')] { assert_eq!(rules.pair(class(a), class(b)).desired, 0.5, "{set}: {a}{b}"); } } diff --git a/docs/cjk.md b/docs/cjk.md index d37e9c29c..68839e0e8 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -37,7 +37,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Vertical metrics and shaping | done: upright runs shaped top to bottom (`vert`; `vkrn`/`vpal` when asked) with `vmtx`/`VORG` advances and origins, centred on the ideographic em box (`BASE`, else OS/2); turned runs shaped horizontally, without `vrt2`; Horizontal Kana (`hkna`) takes the vertical kana (`vkna`) in upright runs. Default location of variable fonts only (no `VVAR`) | | Kinsoku | named (hard, soft, Chinese, Korean) and custom kinsoku sets, bunri-kinshi, rensuuji, hanging punctuation (regular, force); without a set, fixed no-start / no-end lists and a break anywhere between CJK characters; kinsoku type (push in / push out priorities) kept but not applied | | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | -| Mojikumi | applied in composition: the built-in base sets (line end half-width, line end one em, Simplified and Traditional Chinese) and the document's tables with their override rows, kept through IDML; Preflight notes tables it can't apply. In the Japanese sets consecutive punctuation closes up (JLREQ 3.1.4: 」。, 「『 touch; 」「 half an em apart) | +| Mojikumi | applied in composition: the built-in base sets (line end half-width, line end one em, Simplified and Traditional Chinese) and the document's tables with their override rows, kept through IDML; Preflight notes tables it can't apply. In the Japanese sets consecutive punctuation closes up (JLREQ 3.1.4: 」。, 「『 touch; 」・ a quarter em, 」「 half an em apart). Still open: 、・ and ・・ take the sum of both blanks in JLREQ (Table 1 notes) | | Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | | Character alignment, leading model | em box top / centre / bottom, ICF top / bottom (`BASE` `icft`/`icfb`, else the em box inset by 5%), roman baseline; aki above / below, centre (centre down as centre); under aki below a line's leading is the space to the line below it; with the Japanese composers (`Composer::Japanese`, `JapaneseSingleLine`, IDML `HL Composer J` / `HL Single J`), the others set lines baseline to baseline on the Roman baseline | | Tate-chu-yoko | manual, with offsets; auto tate-chu-yoko of short digit runs (with roman letters when asked) in vertical text, narrowed to fit the em (`type.autoTateChuYoko`, IDML `AutoTcy`, `AutoTcyIncludeRoman`); manual groups of 3+ digits overflow the em | From d8b31de862068871ff92be24e9708871681cac34 Mon Sep 17 00:00:00 2001 From: con3code Date: Tue, 20 Oct 2026 21:31:24 +0900 Subject: [PATCH 6/8] Type fields read the unit typed (13Q, 5mm), as InDesign's do (#1359) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/ui-egui/src/dialogs.rs | 4 +-- crates/ui-egui/src/dialogs/style_cjk.rs | 2 +- crates/ui-egui/src/object_control.rs | 2 +- crates/ui-egui/src/panels/cell_stroke.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 38 ++++++++++++++++++----- crates/ui-egui/src/taskbar.rs | 4 +-- crates/ui-egui/src/widgets.rs | 39 ++++++++++++++++++++++-- 7 files changed, 74 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..ecf1cf959 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5310,7 +5310,7 @@ impl CharFields<'_> { /// A number attribute shown multiplied by `scale` (100 for a fraction shown as a percentage). fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { let v = cf.get(d, key).as_f64().map(|x| x * scale); - let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let field = crate::widgets::NumField::with_suffix(key, v, suffix, 2).width(80.0).range(range.0, range.1); let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; if let Some(n) = edit { cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); @@ -5414,7 +5414,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/dialogs/style_cjk.rs b/crates/ui-egui/src/dialogs/style_cjk.rs index 674cf44a7..f337324f4 100644 --- a/crates/ui-egui/src/dialogs/style_cjk.rs +++ b/crates/ui-egui/src/dialogs/style_cjk.rs @@ -120,7 +120,7 @@ fn grid(id: &str) -> egui::Grid { /// emptying the field makes it automatic again. fn char_auto_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { let v = cf.get(d, key).as_f64().map(|x| x * scale); - let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let field = crate::widgets::NumField::with_suffix(key, v, suffix, 2).width(80.0).range(range.0, range.1); if let Some(n) = field.show_or_clear(ui) { cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); } diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index 7c2634e23..71fa5f0b2 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -182,7 +182,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 750e180a8..940ec1c11 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.rs b/crates/ui-egui/src/panels/properties.rs index 44acf6e2c..18ea1e1e6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -817,7 +817,7 @@ 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})); } @@ -1235,7 +1235,7 @@ 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}})); } @@ -1288,7 +1288,7 @@ 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}})); } @@ -1298,7 +1298,7 @@ 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}}})); } @@ -1554,7 +1554,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| { @@ -1562,11 +1562,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)) { @@ -3241,6 +3241,30 @@ mod tests { 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/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 5b7d34b92..55d9542df 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); @@ -239,7 +239,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 fb9876aa2..58621c4fb 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, } } } @@ -89,6 +94,14 @@ impl<'a> NumField<'a> { pub fn number(id: &'a str, value: Option, suffix: &'a str, decimals: usize) -> Self { 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 }) + } + /// [`Self::points`] for the ` pt` suffix, [`Self::number`] for any other. + pub fn with_suffix(id: &'a str, value: Option, suffix: &'a str, decimals: usize) -> Self { + if suffix == " pt" { Self::points(id, value, decimals) } else { Self::number(id, value, suffix, decimals) } + } fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false } } @@ -227,7 +240,11 @@ impl<'a> NumField<'a> { ui.data_mut(|d| d.insert_temp(key, buf.clone())); } if r.lost_focus() { + // 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 = self.value.is_some_and(|old| buf.trim() == self.fmt(old).trim()); if let Some(v) = self.parse(&buf).map(clamp) + && !unchanged && self.value.is_none_or(|old| (old - v).abs() > 1e-9) { out = Some(v); @@ -277,7 +294,7 @@ pub fn spin_measure(ui: &mut Ui, id: &str, value: Option, unit: Unit, width /// A plain number field (percent, degrees, counts). pub fn number(ui: &mut Ui, id: &str, value: Option, suffix: &str, width: f32, decimals: usize) -> Option { - NumField::number(id, value, suffix, decimals).width(width).show(ui) + NumField::with_suffix(id, value, suffix, decimals).width(width).show(ui) } /// A number field with the spinner segment. @@ -754,6 +771,22 @@ mod tests { assert_eq!(m.parse("1p0+0p6"), Some(18.0)); 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"); + assert_eq!(NumField::with_suffix("a", None, "%", 0).kind, NumKind::Number { suffix: "%", decimals: 0 }); + } } /// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down From 2ea4ac7d45ec5c0efa56aaee7a0434531d0b9098 Mon Sep 17 00:00:00 2001 From: con3code Date: Thu, 22 Oct 2026 19:19:42 +0900 Subject: [PATCH 7/8] Effects panel: drop shadow Distance and Size in the document's unit (#1523) The panel showed and read them in points while the Effects dialog uses the document's unit, so a bare 7 typed in the dialog of a picas document was 7 picas (84 pt) and showed as 84 pt in the panel. The panel now uses the same unit as the dialog, and reads any unit typed (3mm, 5pt). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/ui-egui/src/panels/properties.rs | 41 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 1cbd623a6..fc1a30c67 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2632,9 +2632,12 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.dropShadow", json!({"on": on})); } if ds.on { + // Distance and Size in the document's unit, as in the Effects dialog: a bare `7` means the + // same in both. + let u = units(app); egui::Grid::new("dsg").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Distance")); - if let Some(v) = number(ui, "dsd", Some(ds.distance), " pt", 56.0, 1) { + if let Some(v) = measure(ui, "dsd", Some(ds.distance), u, 56.0) { let _ = app.run("object.dropShadow", json!({"on": true, "distance": v})); } caption(ui, crate::i18n::tr(&app.ui.language, "Angle")); @@ -2661,7 +2664,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("object.dropShadow", json!({"on": true, "opacity": v / 100.0})); } caption(ui, crate::i18n::tr(&app.ui.language, "Size")); - if let Some(v) = number(ui, "dss", Some(ds.size), " pt", 50.0, 1) { + if let Some(v) = measure(ui, "dss", Some(ds.size), u, 56.0) { let _ = app.run("object.dropShadow", json!({"on": true, "size": v})); } ui.end_row(); @@ -3096,6 +3099,40 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + /// Drop shadow Distance and Size in the Effects panel read and show the document's unit, as + /// the Effects dialog does (#1523): in picas a typed `7` is 7 picas in both. + #[test] + fn effects_panel_shadow_fields_use_the_document_unit() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 144, 144]})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + app.run("object.dropShadow", json!({"on": true, "distance": 7.0, "size": 5.0})).unwrap(); + let shadow = |app: &DesignApp| { + let d = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().effects.drop_shadow.clone(); + (d.distance, d.size) + }; + let mut h = panel_harness(app, effects_panel); + h.run_steps(3); + let values = |h: &Harness<'static, DesignApp>| -> Vec { + h.get_all_by_role(egui::accesskit::Role::TextInput).filter_map(|n| n.value()).collect() + }; + let shown = values(&h); + assert!(shown.contains(&"0p7".to_string()) && shown.contains(&"0p5".to_string()), "{shown:?}"); + for (old, typed, want) in [("0p7", "7", (84.0, 5.0)), ("0p5", "3mm", (84.0, 3.0 * designcraft_geom::units::PT_PER_MM))] { + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.value().as_deref() == Some(old)).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 = shadow(h.state_mut()); + assert!((got.0 - want.0).abs() < 1e-3 && (got.1 - want.1).abs() < 1e-3, "{typed}: {got:?}, want {want:?}"); + } + } + fn kerning(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["kerning"].clone() } From 2c401cb543b292578d3c331ac0797401793ca19e Mon Sep 17 00:00:00 2001 From: con3code Date: Sun, 25 Oct 2026 07:50:15 +0900 Subject: [PATCH 8/8] Number fields: every " pt" field reads the unit typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Control panel's Font Size, Leading and Baseline Shift and Character ••• Baseline Shift still used NumField::number with " pt", which read 13Q as 13 pt. NumField::number now makes a points field for the " pt" suffix, so no point field can miss it; with_suffix is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013phQ3ekq6uGBEWD6FjFYG2 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/style_cjk.rs | 2 +- crates/ui-egui/src/widgets.rs | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f850cacde..bab06814f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5373,7 +5373,7 @@ impl CharFields<'_> { /// A number attribute shown multiplied by `scale` (100 for a fraction shown as a percentage). fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { let v = cf.get(d, key).as_f64().map(|x| x * scale); - let field = crate::widgets::NumField::with_suffix(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; if let Some(n) = edit { cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); diff --git a/crates/ui-egui/src/dialogs/style_cjk.rs b/crates/ui-egui/src/dialogs/style_cjk.rs index f337324f4..674cf44a7 100644 --- a/crates/ui-egui/src/dialogs/style_cjk.rs +++ b/crates/ui-egui/src/dialogs/style_cjk.rs @@ -120,7 +120,7 @@ fn grid(id: &str) -> egui::Grid { /// emptying the field makes it automatic again. fn char_auto_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { let v = cf.get(d, key).as_f64().map(|x| x * scale); - let field = crate::widgets::NumField::with_suffix(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); if let Some(n) = field.show_or_clear(ui) { cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ef78856db..0625fd3dc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -95,17 +95,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 }) } - /// [`Self::points`] for the ` pt` suffix, [`Self::number`] for any other. - pub fn with_suffix(id: &'a str, value: Option, suffix: &'a str, decimals: usize) -> Self { - if suffix == " pt" { Self::points(id, value, decimals) } else { Self::number(id, value, suffix, decimals) } - } fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { NumField { id, @@ -343,7 +344,7 @@ pub fn spin_measure(ui: &mut Ui, id: &str, value: Option, unit: Unit, width /// A plain number field (percent, degrees, counts). pub fn number(ui: &mut Ui, id: &str, value: Option, suffix: &str, width: f32, decimals: usize) -> Option { - NumField::with_suffix(id, value, suffix, decimals).width(width).show(ui) + NumField::number(id, value, suffix, decimals).width(width).show(ui) } /// A number field with the spinner segment. @@ -838,7 +839,10 @@ mod tests { 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"); - assert_eq!(NumField::with_suffix("a", None, "%", 0).kind, NumKind::Number { suffix: "%", decimals: 0 }); + // 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 }); } }