Numeral display modes: Arabic, Hindi, Context and System digits (view.numeral) (#501)

* Numeral display modes: Arabic, Hindi, Context and System digits

Display-only digit substitution (Word File > Options > Advanced >
Numeral): ASCII and Arabic-Indic digits are substituted before shaping
while clusters keep indexing the original text, so find, spelling,
copy/paste and saved files see the typed digits.

- layout: NumeralMode (arabic|hindi|context|system) with System resolved
  from LC_ALL/LANG, threaded through LayoutOptions/ParaEnv with cache
  invalidation; Context follows the surrounding text
- engine: view.numeral command + catalog row
- tests: layout tests_numeral, engine command test

* Options page: Numerals picker in File > Options > Display

ComboBox for the view.numeral mode (Arabic/Hindi/Context/System),
mirroring Word's File > Options > Advanced > Numeral. Display-only,
session-scoped like the other Display options.

* fmt: collapse numeral direction branch

* #501: translate the Numerals option label in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: abdo4312 <abdo4312@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abdo4312 2026-10-12 12:04:43 +03:00 committed by GitHub
parent 1c188ba140
commit 2f58d441b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 550 additions and 31 deletions

View File

@ -20,6 +20,7 @@ File|Backstage|Properties|file.properties
File|Backstage|Export PDF|file.exportPdf
File|Backstage|Export Image|file.exportPng
File|Backstage|Options|file.options
File|Options|Numerals|view.numeral
File|Backstage|Share|file.share
File|Backstage|Protect Document|file.protect
File|Backstage|Inspect Document|file.inspect

View File

@ -1,7 +1,7 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde_json::{Value, json};
use wordcraft_layout::ViewMode;
use wordcraft_layout::{NumeralMode, ViewMode};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@ -83,6 +83,22 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string())))
.pure(),
// Word › File › Options › Advanced › Numeral: how digits look on screen (display
// only; the document text keeps the typed codepoints).
CommandSpec::new("view.numeral", "Numerals", "File › Options › Advanced", |s, v| match v.get("value") {
None => Ok(json!({"value": s.view.numeral})),
Some(Value::String(name)) => {
let Some(mode) = NumeralMode::parse(name) else {
return Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into()));
};
s.view.numeral = mode;
s.relayout();
Ok(json!({"value": s.view.numeral}))
}
Some(_) => Err(CmdError::Params("numeral mode: arabic|hindi|context|system".into())),
})
.params(r#"{"value"?: "arabic|hindi|context|system"}"#)
.pure(),
]
}

View File

@ -25,7 +25,7 @@ use serde_json::Value;
pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState};
pub use session::{ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, ViewState, set_system_locale};
pub use wordcraft_doc as doc;
pub use wordcraft_layout as layout;
pub use wordcraft_proof as proof;

View File

@ -1,13 +1,22 @@
//! The editing session: document, selection, undo/redo, layout, view state.
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System.
static SYSTEM_LOCALE: OnceLock<String> = OnceLock::new();
/// Tell the engine the system locale (the shell calls this once at startup; layout never reads
/// the environment). Headless sessions without it show Western digits for Numeral › System.
pub fn set_system_locale(tag: &str) {
let _ = SYSTEM_LOCALE.set(tag.to_string());
}
use serde::{Deserialize, Serialize};
use serde_json::Value;
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
use crate::{CmdError, Registry};
@ -86,6 +95,9 @@ pub struct ViewState {
pub track_changes_pane: bool,
/// Check spelling and grammar as you type.
pub proofing: bool,
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
#[serde(default)]
pub numeral: NumeralMode,
/// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document).
#[serde(default)]
pub hide_ink: bool,
@ -138,6 +150,7 @@ impl Default for ViewState {
show_markup: true,
track_changes_pane: false,
proofing: true,
numeral: NumeralMode::default(),
hide_ink: false,
draw: Default::default(),
field_codes: false,
@ -269,8 +282,8 @@ pub struct Session {
document_id: u64,
cache: LayoutCache,
/// (revision, web width, view, layout, proofing, outline, (deletions left out, body
/// deletions in balloons)).
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool, Option<wordcraft_layout::outline::OutlineView>, (bool, bool))>,
/// deletions in balloons), numeral display).
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool, Option<wordcraft_layout::outline::OutlineView>, (bool, bool), NumeralMode)>,
/// Picture edits: edited media key → original media key (Reset Picture).
pub originals: std::collections::HashMap<String, String>,
/// Last mutating command (Repeat).
@ -572,11 +585,12 @@ impl Session {
let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions();
let deleted = (hide_deleted, self.revisions_in_balloons());
let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view());
if let Some((r, w, m, l, pf, o, hd)) = &self.layout
if let Some((r, w, m, l, pf, o, hd, n)) = &self.layout
&& *r == self.rev
&& (*w == ww || self.view.mode == ViewMode::Print)
&& *m == self.view.mode
&& *pf == self.view.proofing
&& *n == self.view.numeral
&& *hd == deleted
&& *o == outline
{
@ -589,13 +603,14 @@ impl Session {
hide_deleted,
deletions_in_balloons: deleted.1,
proofing: self.view.proofing,
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
comment_balloons: false,
field_codes: self.view.field_codes,
outline: outline.clone(),
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
};
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted));
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted, self.view.numeral));
l
}
/// A layout for output (PDF, images, print): no proofing marks, print view.

View File

@ -2615,6 +2615,30 @@ fn page_and_table_gridlines_toggle_independently() {
assert_eq!(state["tableGridlines"], false);
}
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
#[test]
fn numeral_mode_sets_gets_and_rejects_junk() {
use wordcraft_layout::NumeralMode;
let mut s = s();
assert_eq!(s.view.numeral, NumeralMode::Arabic, "Western digits by default");
let r = run(&mut s, "view.numeral", json!({"value": "hindi"}));
assert_eq!(r["value"], "hindi");
assert_eq!(s.view.numeral, NumeralMode::Hindi);
let r = run(&mut s, "view.numeral", json!({}));
assert_eq!(r["value"], "hindi", "no params reads the current mode");
run(&mut s, "view.numeral", json!({"value": "Context"}));
assert_eq!(s.view.numeral, NumeralMode::Context, "mode names ignore case");
assert!(s.run("view.numeral", &json!({"value": "western"})).is_err());
assert!(s.run("view.numeral", &json!({"value": 3})).is_err());
assert!(s.run("view.numeral", &json!({"value": ["hindi"]})).is_err());
assert_eq!(s.view.numeral, NumeralMode::Context, "rejected values keep the mode");
// Switching modes relayouts without disturbing the text.
s.doc = wordcraft_doc::Document::from_text("Year 2024");
run(&mut s, "view.numeral", json!({"value": "hindi"}));
let _l = s.layout();
assert_eq!(text(&s), "Year 2024");
}
/// Issue #67: View › Zoom steps. Zoom In/Out leave a fit mode and step 10% from the current zoom
/// (the UI keeps `view.zoom` equal to the shown zoom while a fit mode is on), and never get stuck
/// short of the 10%–500% limits.

View File

@ -52,6 +52,88 @@ pub enum ViewMode {
Outline,
}
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
///
/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and
/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9,
/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits
/// are substituted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NumeralMode {
/// Western digits everywhere (`0123456789`); the default, today's behaviour.
#[default]
Arabic,
/// Eastern Arabic-Indic digits everywhere (`٠١٢٣٤٥٦٧٨٩`).
Hindi,
/// Hindi digits in and around right-to-left text, Western digits elsewhere (numbers take
/// an even bidi level, so the surrounding text decides).
Context,
/// Hindi digits when the system locale is an Arabic-script language, else Western digits.
/// Call [`NumeralMode::resolve`] before laying out.
System,
}
impl NumeralMode {
/// Short UI label (Word's option names).
pub fn label(self) -> &'static str {
match self {
NumeralMode::Arabic => "Arabic",
NumeralMode::Hindi => "Hindi",
NumeralMode::Context => "Context",
NumeralMode::System => "System",
}
}
/// Parse a command value (`"arabic" | "hindi" | "context" | "system"`, any case).
pub fn parse(s: &str) -> Option<NumeralMode> {
match s.to_lowercase().as_str() {
"arabic" => Some(NumeralMode::Arabic),
"hindi" => Some(NumeralMode::Hindi),
"context" => Some(NumeralMode::Context),
"system" => Some(NumeralMode::System),
_ => None,
}
}
/// Resolve `System` against the system locale (a BCP 47 or POSIX tag such as `ar-EG` or
/// `fa_IR.UTF-8`, found by the shell: layout never reads the environment). Hindi digits for
/// Arabic-script languages, else Western; unknown locale = Western. Other modes pass through.
pub fn resolve(self, system_locale: Option<&str>) -> NumeralMode {
if self != NumeralMode::System {
return self;
}
if system_locale.is_some_and(hindi_locale) { NumeralMode::Hindi } else { NumeralMode::Arabic }
}
/// The displayed form of `c` in a run shaped with direction `rtl`.
///
/// One character maps to exactly one character, so shaped text stays codepoint-aligned
/// with the document text (only byte offsets shift). A `System` mode that was not
/// resolved falls back to Western digits.
pub fn map(self, c: char, rtl: bool) -> char {
let hindi = match self {
NumeralMode::Arabic => false,
NumeralMode::Hindi => true,
NumeralMode::Context => rtl,
NumeralMode::System => false,
};
if hindi {
if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c }
} else if ('\u{660}'..='\u{669}').contains(&c) {
(b'0' + (c as u32 - 0x660) as u8) as char
} else {
c
}
}
}
/// Does locale `tag` use Hindi digits (Arabic, Persian, Urdu, Pashto, Sindhi, Kurdish)?
fn hindi_locale(tag: &str) -> bool {
let lang = tag.split(['_', '-', '.', '@']).next().unwrap_or_default().to_ascii_lowercase();
matches!(lang.as_str(), "ar" | "fa" | "ur" | "ps" | "sd" | "ku" | "ckb")
}
#[derive(Clone, Debug, Default)]
pub struct LayoutOptions {
pub view: ViewMode,
@ -66,6 +148,9 @@ pub struct LayoutOptions {
pub deletions_in_balloons: bool,
/// Check spelling as you type (squiggles).
pub proofing: bool,
/// How digits look on screen (display only; the text is untouched). Callers resolve
/// `System` ([`NumeralMode::resolve`]); an unresolved `System` shows Western digits.
pub numeral: NumeralMode,
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
/// and show the comments there in balloons joined to their anchors, as Word prints them.
/// The editor draws its own interactive balloons and leaves this off.
@ -409,6 +494,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
opts.hide_deleted,
opts.deletions_in_balloons,
opts.proofing,
opts.numeral,
opts.field_codes,
opts.proof,
wordcraft_proof::dictionary_generation(),
@ -549,6 +635,7 @@ impl Ctx<'_> {
hide_deleted: false,
table: None,
proofing: None,
numeral: self.opts.numeral,
exclusions: &[],
eq_number: 0,
grid: para::Grid::default(),
@ -618,6 +705,7 @@ impl Ctx<'_> {
hide_deleted: self.hide_deleted,
table,
proofing: self.opts.proofing.then_some(self.opts.proof),
numeral: self.opts.numeral,
exclusions,
eq_number,
grid: self.grid,
@ -2715,6 +2803,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
table: None,
proofing: None,
numeral: ctx.opts.numeral,
exclusions: &[],
eq_number: ctx.eq_count,
grid: ctx.grid,
@ -3038,6 +3127,8 @@ mod tests_complex;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_numeral;
#[cfg(test)]
mod tests_pages;
#[cfg(test)]
mod tests_textbox;

View File

@ -12,6 +12,7 @@ use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
use wordcraft_doc::{Document, Paragraph};
use wordcraft_fonts::FaceRef;
use crate::NumeralMode;
use crate::fields::{FIELD_SHADING, FieldCtx, field_code_text, field_text};
/// A face at a size with the formatting needed to draw it.
@ -334,6 +335,8 @@ pub struct ParaEnv<'a> {
pub table: Option<&'a CellText>,
/// Check spelling (and grammar, as the options say) for squiggles; `None` = no squiggles.
pub proofing: Option<crate::ProofOptions>,
/// How digits look on screen (an unresolved `System` shows Western digits).
pub numeral: NumeralMode,
/// Areas text must flow around (floating objects), relative to the paragraph: x from the
/// column's left edge, y from the top of the first line.
pub exclusions: &'a [Exclusion],
@ -467,6 +470,64 @@ fn is_mark(c: char) -> bool {
matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control()
}
/// A digit Word's Numeral modes substitute: ASCII or Arabic-Indic (U+0660–U+0669).
fn is_numeral_digit(c: char) -> bool {
c.is_ascii_digit() || ('\u{660}'..='\u{669}').contains(&c)
}
/// Characters skipped when looking for the text around a number: digits, spaces and the
/// Arabic decimal/thousands separators (٫ ٬).
fn is_number_inside(c: char) -> bool {
is_numeral_digit(c) || ('\u{6f0}'..='\u{6f9}').contains(&c) || c == ' ' || c == '\t' || c == '\u{66b}' || c == '\u{66c}'
}
/// Which digits of run `text` sit in right-to-left surroundings (Word › Numeral › Context), by
/// byte offset. European numbers take an even bidi level, so a digit's own level says little;
/// the nearest neighbour on either side that isn't part of the number (digits, spaces,
/// separators) decides. One pass each way, so long runs of digits and spaces stay linear.
/// Offsets past `levels` count as left to right.
fn context_digit_dirs(text: &str, levels: &[u8], base: usize) -> Vec<bool> {
let odd = |o: usize| levels.get(base.saturating_add(o)).is_some_and(|l| l % 2 == 1);
let mut out = vec![false; text.len()];
let mut left_rtl = false;
for (o, c) in text.char_indices() {
if !is_number_inside(c) {
left_rtl = odd(o);
} else if is_numeral_digit(c)
&& let Some(v) = out.get_mut(o)
{
*v = odd(o) || left_rtl;
}
}
let mut right_rtl = false;
for (o, c) in text.char_indices().rev() {
if !is_number_inside(c) {
right_rtl = odd(o);
} else if right_rtl
&& is_numeral_digit(c)
&& let Some(v) = out.get_mut(o)
{
*v = true;
}
}
out
}
/// Map a byte range of numeral-substituted display text back to the original text.
///
/// `map_starts[i]` is the display byte offset of the `i`th char and `orig_of[i]` its original
/// byte offset (`orig_of` ends with the original length as a sentinel). Substitution is
/// 1 char → 1 char, so both ends land on char boundaries; anything else falls back to the
/// end of the original text instead of panicking or slicing mid-character.
pub(crate) fn remap_range(map_starts: &[usize], orig_of: &[usize], orig_len: usize, ms: usize, me: usize) -> (usize, usize) {
let n = map_starts.len();
let i = map_starts.partition_point(|&b| b < ms).min(n);
let j = map_starts.partition_point(|&b| b < me).min(n);
let s = orig_of.get(i).copied().unwrap_or(orig_len);
let e = orig_of.get(j).copied().unwrap_or(orig_len);
(s.min(e), s.max(e))
}
/// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the
/// paragraph is left to right and has no right-to-left characters.
pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec<u8> {
@ -517,6 +578,15 @@ fn east_asian(c: char) -> bool {
}
impl<'a> Builder<'a> {
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
/// [`context_digit_dirs`], empty otherwise) gives each digit's surroundings, since European
/// numbers take an even bidi level.
fn numeral_char(&self, context: &[bool], at: usize, c: char, rtl: bool) -> char {
let dir = if !context.is_empty() && is_numeral_digit(c) { context.get(at).copied().unwrap_or(false) } else { rtl };
self.env.numeral.map(c, dir)
}
/// Style index for a resolved char in `face` (caps-scaled `size` override for small caps).
fn style(&mut self, rc: &Arc<ResolvedChar>, face_override: Option<FaceRef>, small: bool) -> u16 {
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
@ -595,6 +665,7 @@ impl<'a> Builder<'a> {
.filter(|r| !r.substituted)
.map(|r| r.face);
let use_levels = para_text && !self.levels.is_empty();
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
// One fallback face for the run's East Asian characters: the first face in fallback order
// covering every character the primary font lacks, so the run draws in a single CJK face
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
@ -616,27 +687,31 @@ impl<'a> Builder<'a> {
let mut cur: Option<SegKey> = None;
let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
for (i, c) in text.char_indices() {
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
// Displayed form (Word › Numeral): one char in, one char out. Coverage and the
// fallback face are picked for the displayed char, so Hindi digits land in an
// Arabic-capable face and Western digits in a Latin one.
let dc = self.numeral_char(&context, i, c, rtl);
let mark = is_mark(c);
let complex = match cur {
Some(k) if mark => k.complex,
_ => rc_cs.is_some() && rc.uses_complex(c),
_ => rc_cs.is_some() && rc.uses_complex(dc),
};
let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };
let face = match cur {
Some(k) if mark => k.face,
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
_ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
_ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
// Stay in the fallback face the word started in while it covers the letters.
Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f),
Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
// The run's unified East Asian face, when one face covers the whole run.
_ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
_ => {
let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
}
};
let small = rc.small_caps && !rc.caps && c.is_lowercase();
let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1);
let k = SegKey { face, small, complex, rtl };
match cur {
Some(p) if p.same(&k) => {}
@ -662,21 +737,38 @@ impl<'a> Builder<'a> {
let si = self.style(rc, key.face, small);
let Some(st) = self.styles.get(si as usize).cloned() else { continue };
let upper = rc.caps || small;
// Displayed text: numeral substitution is 1 char → 1 char, so codepoints stay
// aligned with the document and only byte offsets shift; `orig_of` maps each
// displayed char back to its original byte offset (+ a `sub.len()` sentinel).
let substituted = sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
let mut mapped = String::new();
let mut map_starts: Vec<usize> = Vec::new();
let mut orig_of: Vec<usize> = Vec::new();
if substituted {
mapped = String::with_capacity(sub.len());
for (i, c) in sub.char_indices() {
map_starts.push(mapped.len());
orig_of.push(i);
mapped.push(self.numeral_char(&context, a + i, c, key.rtl));
}
orig_of.push(sub.len());
}
let shape_text = if substituted { mapped.as_str() } else { sub };
let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c };
let shaped = if use_levels {
wordcraft_fonts::shape_run(&st.face, sub, &[], map, key.rtl)
wordcraft_fonts::shape_run(&st.face, shape_text, &[], map, key.rtl)
} else {
wordcraft_fonts::shape(&st.face, sub, &[], map)
wordcraft_fonts::shape(&st.face, shape_text, &[], map)
};
let k = st.size / st.face.upem.max(1.0) as f32;
let hscale = rc.scale / 100.0;
// Group glyphs by cluster byte offset; graphemes may span several shaper clusters.
// Complex-script syllables the graphemes split (Myanmar vowel signs, stacked
// consonants) stay one cluster: the caret never stops inside them.
let bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(sub);
let bounds: Vec<usize> = wordcraft_doc::cluster::cluster_starts(shape_text);
let mut gi = 0usize;
for (bi, &gs) in bounds.iter().enumerate() {
let ge = bounds.get(bi + 1).copied().unwrap_or(sub.len());
let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len());
let g0 = self.glyphs.len() as u32;
let mut adv = 0.0f32;
while let Some(g) = shaped.get(gi) {
@ -688,7 +780,7 @@ impl<'a> Builder<'a> {
gi += 1;
}
let g1 = self.glyphs.len() as u32;
let s = sub.get(gs..ge).unwrap_or("");
let s = shape_text.get(gs..ge).unwrap_or("");
let ch = s.chars().next().unwrap_or(' ');
let kind = kind_override.unwrap_or(if ch == ' ' || ch == '\u{3000}' {
ClKind::Space
@ -698,9 +790,11 @@ impl<'a> Builder<'a> {
ClKind::Text
});
let adv = if kind == ClKind::Marker { 0.0 } else { adv + rc.spacing };
// Clusters index the document text, never the displayed text.
let (cs, ce) = if substituted { remap_range(&map_starts, &orig_of, sub.len(), gs, ge) } else { (gs, ge) };
self.clusters.push(Cluster {
start: base + a + gs,
end: base + a + ge,
start: base + a + cs,
end: base + a + ce,
adv,
kind,
style: si,

View File

@ -168,6 +168,8 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
hide_deleted: false,
table: text.as_ref(),
proofing: None,
// Measurement uses a fixed environment (like show_hidden/proofing above).
numeral: crate::NumeralMode::default(),
exclusions: &[],
eq_number: 0,
grid: crate::para::Grid::default(),

View File

@ -0,0 +1,178 @@
//! Numeral display modes (Word › File › Options › Advanced › Numeral): Western digits,
//! Hindi digits, or context-following digits — display only, the text is untouched.
use super::*;
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::{Pos, StoryRef};
fn lay_opts(text: &str, rtl_para: bool, numeral: NumeralMode) -> DocLayout {
let mut d = Document::from_text(text);
if rtl_para {
let at = Pos::body(0, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
}
let mut c = LayoutCache::new();
layout(&d, &mut c, &LayoutOptions { numeral, ..LayoutOptions::default() })
}
/// Glyph ids of the first line's clusters covering `text`, in cluster order.
fn line_gids(l: &DocLayout, text: &str) -> Vec<Vec<u32>> {
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Body, para, .. } = it {
let line = &para.lines[0];
let mut out = Vec::new();
for k in line.c0..line.c1 {
let cl = &para.clusters[k];
assert!(cl.start <= cl.end && cl.end <= text.len(), "cluster indexes the original text");
let gids: Vec<u32> = para.glyphs[cl.g0 as usize..cl.g1 as usize].iter().map(|g| g.gid).collect();
out.push(gids);
}
return out;
}
}
}
panic!("body paragraph not laid out");
}
#[test]
fn parse_accepts_the_four_modes_any_case() {
assert_eq!(NumeralMode::parse("arabic"), Some(NumeralMode::Arabic));
assert_eq!(NumeralMode::parse("hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("context"), Some(NumeralMode::Context));
assert_eq!(NumeralMode::parse("system"), Some(NumeralMode::System));
assert_eq!(NumeralMode::parse("Hindi"), Some(NumeralMode::Hindi));
assert_eq!(NumeralMode::parse("CONTEXT"), Some(NumeralMode::Context));
assert_eq!(NumeralMode::parse("western"), None);
assert_eq!(NumeralMode::parse(""), None);
assert_eq!(NumeralMode::parse("hindi "), None);
}
#[test]
fn map_substitutes_digits_both_ways() {
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction");
assert_eq!(NumeralMode::Arabic.map(hindi, false), d);
assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways");
assert_eq!(NumeralMode::Arabic.map(d, false), d);
assert_eq!(NumeralMode::Context.map(d, true), hindi);
assert_eq!(NumeralMode::Context.map(d, false), d);
assert_eq!(NumeralMode::Context.map(hindi, true), hindi);
assert_eq!(NumeralMode::Context.map(hindi, false), d);
}
// Letters, punctuation and Persian digits are never touched.
for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.map(c, false), c, "{m:?} leaves {c:?} alone (ltr)");
assert_eq!(m.map(c, true), c, "{m:?} leaves {c:?} alone (rtl)");
}
}
}
#[test]
fn resolve_uses_the_given_system_locale() {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context] {
assert_eq!(m.resolve(Some("ar-EG")), m, "only System is resolved");
}
for tag in ["ar", "ar-EG", "fa_IR.UTF-8", "ur-PK", "ckb-IQ", "AR-sa"] {
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Hindi, "{tag}");
}
for tag in ["en-US", "C", "", "tr_TR", "ary"] {
assert_eq!(NumeralMode::System.resolve(Some(tag)), NumeralMode::Arabic, "{tag}");
}
assert_eq!(NumeralMode::System.resolve(None), NumeralMode::Arabic, "unknown locale: Western digits");
}
#[test]
fn context_mode_on_a_very_long_digit_run_stays_linear() {
// Every digit used to scan back over all the digits and spaces before it (O(n²)).
let text = format!("سنة {}", "1 ".repeat(100_000));
let start = std::time::Instant::now();
let l = lay_opts(&text, false, NumeralMode::Context);
assert!(!l.pages.is_empty());
assert!(start.elapsed() < std::time::Duration::from_secs(60), "took {:?}", start.elapsed());
// The digits follow the Arabic word before them.
let short = "سنة 1 2 3";
assert_eq!(line_gids(&lay_opts(short, false, NumeralMode::Context), short), line_gids(&lay_opts(short, false, NumeralMode::Hindi), short));
}
#[test]
fn hindi_mode_reshapes_digits_but_keeps_offsets() {
let text = "Year 2024!";
let latin = lay_opts(text, false, NumeralMode::Arabic);
let hindi = lay_opts(text, false, NumeralMode::Hindi);
let a = line_gids(&latin, text);
let h = line_gids(&hindi, text);
assert_eq!(a.len(), h.len(), "same clusters in both modes");
assert!(!a.is_empty());
// Letter clusters draw identical glyphs; digit clusters draw different ones.
for (n, (ga, gh)) in a.iter().zip(h.iter()).enumerate() {
let ch = text.chars().nth(n).unwrap();
if ch.is_ascii_digit() {
assert_ne!(ga, gh, "digit {ch:?} reshaped in Hindi mode");
assert!(!gh.is_empty(), "Hindi digit draws something");
} else {
assert_eq!(ga, gh, "{ch:?} untouched by Hindi mode");
}
}
}
#[test]
fn arabic_mode_converts_hindi_digits_back() {
let text = "عام ٢٠٢٤";
let hindi = lay_opts(text, true, NumeralMode::Hindi);
let arabic = lay_opts(text, true, NumeralMode::Arabic);
let h = line_gids(&hindi, text);
let a = line_gids(&arabic, text);
assert_eq!(h.len(), a.len());
let mut changed = 0;
for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() {
if text.chars().nth(n).unwrap() == '٢' {
assert_ne!(gh, ga);
changed += 1;
}
}
assert!(changed > 0, "typed Hindi digits display Western in Arabic mode");
}
#[test]
fn context_mode_follows_run_direction() {
// Right-to-left paragraph: digits behave like Hindi mode.
let rtl = "سنة 2024";
let ctx = line_gids(&lay_opts(rtl, true, NumeralMode::Context), rtl);
let hin = line_gids(&lay_opts(rtl, true, NumeralMode::Hindi), rtl);
assert_eq!(ctx, hin, "digits in RTL text show Hindi in Context mode");
// Left-to-right paragraph: digits behave like Arabic mode.
let ltr = "Year 2024";
let ctx = line_gids(&lay_opts(ltr, false, NumeralMode::Context), ltr);
let ara = line_gids(&lay_opts(ltr, false, NumeralMode::Arabic), ltr);
assert_eq!(ctx, ara, "digits in LTR text show Western in Context mode");
// Mixed paragraph: digits next to Arabic show Hindi even in a left-to-right paragraph.
let mixed = "Year سنة 2024";
let ctx = line_gids(&lay_opts(mixed, false, NumeralMode::Context), mixed);
let hin = line_gids(&lay_opts(mixed, false, NumeralMode::Hindi), mixed);
assert_eq!(ctx, hin, "digits by Arabic text show Hindi in Context mode");
}
#[test]
fn remap_range_maps_display_bytes_to_original() {
use crate::para::remap_range;
// "a5ب": '5' (1 byte) displays as '٥' (2 bytes), so display offsets shift after it.
let orig = "a5ب";
let disp = "a٥ب";
let map_starts: Vec<usize> = disp.char_indices().map(|(i, _)| i).collect();
let orig_of: Vec<usize> = orig.char_indices().map(|(i, _)| i).collect();
let mut orig_of = orig_of;
orig_of.push(orig.len());
// Display "a" [0,1) → original [0,1); "٥" [1,3) → original [1,2); "ب" [3,5) → [2,4).
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 1), (0, 1));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 1, 3), (1, 2));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 5), (2, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 0, 5), (0, 4));
// Hostile inputs never panic and stay in range.
assert_eq!(remap_range(&[], &[], 0, 0, 0), (0, 0));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2));
}

View File

@ -2048,3 +2048,8 @@
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otevře Nastavení › Výchozí aplikace, kde zvolíte WordCraft pro .docx a další typy dokumentů.
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Chcete-li dokumenty Word otevírat ve WordCraftu jako výchozí: vyberte jeden ve Finderu, zvolte Soubor › Informace, v části „Otevřít v aplikaci“ vyberte WordCraft a klikněte na „Změnit vše“.
WordCraft is now the default app for Word documents. WordCraft je nyní výchozí aplikací pro dokumenty Word.
Numerals: Číslice:
Numerals Číslice
Hindi Hindské
Context Podle kontextu
System Systémové

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -2042,3 +2042,8 @@
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Öffnet Einstellungen › Standard-Apps, wo Sie WordCraft für .docx und die anderen Dokumenttypen wählen.
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Um Word-Dokumente standardmäßig mit WordCraft zu öffnen: Wählen Sie eines im Finder aus, wählen Sie Ablage › Informationen, wählen Sie WordCraft unter „Öffnen mit“ und klicken Sie auf „Alle ändern“.
WordCraft is now the default app for Word documents. WordCraft ist jetzt die Standard-App für Word-Dokumente.
Numerals: Ziffern:
Numerals Ziffern
Hindi Hindi
Context Kontext
System System

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1648,6 +1648,12 @@
Enlarge symbol Agrandar símbolo
Enclosure Contorno
Circle Círculo
Numerals: Números:
Numerals Números
Arabic Árabe
Hindi Hindi
Context Contexto
System Sistema
Templates Plantillas
Templates and Add-ins Plantillas y complementos
Attach Template Adjuntar plantilla
@ -1993,7 +1999,6 @@
Add line numbering Agregar numeración de líneas
Add to Document Agregar al documento
Address labels Etiquetas de dirección
Arabic Árabe
Auto Auto
Automatically hyphenate document Separar palabras automáticamente en el documento
Chinese (Simplified) Chino (simplificado)

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1579,6 +1579,12 @@
Right (vertical) Paremal (vertikaalne)
Ruby text: Ruby-tekst:
Size (pt): Suurus (pt):
Numerals: Numbrid:
Numerals Numbrid
Arabic Araabia
Hindi Hindi
Context Kontekst
System Süsteem
Couldn't make WordCraft the default: {error} WordCrafti ei õnnestunud vaikerakenduseks määrata: {error}
In Default apps, choose WordCraft for the document types you want it to open. Vali jaotises „Vaikerakendused“ WordCraft nende dokumenditüüpide jaoks, mida see peaks avama.
Make WordCraft the default for Word documents… Määra WordCraft Wordi dokumentide vaikerakenduseks…
@ -1987,7 +1993,6 @@
Add line numbering Lisa reanummerdus
Add to Document Lisa dokumenti
Address labels Aadressisildid
Arabic Araabia
Auto Automaatne
Automatically hyphenate document Poolita dokumenti automaatselt
Chinese (Simplified) Hiina (lihtsustatud)

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -2427,3 +2427,8 @@ preview Title Esempio
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Apre Impostazioni › App predefinite, dove scegli WordCraft per .docx e gli altri tipi di documento.
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Per aprire i documenti Word con WordCraft per impostazione predefinita: selezionane uno nel Finder, scegli Archivio › Ottieni informazioni, scegli WordCraft in «Apri con», quindi fai clic su «Modifica tutto».
WordCraft is now the default app for Word documents. WordCraft è ora l'app predefinita per i documenti Word.
Numerals: Cifre:
Numerals Cifre
Hindi Hindi
Context Contesto
System Sistema

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1632,6 +1632,12 @@
Enlarge symbol 記号を拡大
Enclosure 囲い
Circle 円
Numerals: 数字:
Numerals 数字
Arabic アラビア
Hindi ヒンディー
Context コンテキスト
System システム
Templates テンプレート
Templates and Add-ins テンプレートとアドイン
Attach Template テンプレートの添付
@ -1977,7 +1983,6 @@
Add line numbering 行番号を追加する
Add to Document 文書に追加
Address labels 宛名ラベル
Arabic アラビア語
Auto 自動
Automatically hyphenate document 文書を自動的にハイフネーションする
Chinese (Simplified) 中国語(簡体字)

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -2060,3 +2060,8 @@
Opens Settings › Default apps, where you choose WordCraft for .docx and the other document types. Otwiera Ustawienia › Aplikacje domyślne, gdzie wybierasz WordCraft dla .docx i innych typów dokumentów.
To open Word documents with WordCraft by default: select one in Finder, choose File › Get Info, pick WordCraft under Open with, then click Change All. Aby otwierać dokumenty Word domyślnie w WordCraft: zaznacz jeden w Finderze, wybierz Plik › Pokaż info, wybierz WordCraft w „Otwórz za pomocą”, a następnie kliknij „Zmień wszystkie”.
WordCraft is now the default app for Word documents. WordCraft jest teraz domyślną aplikacją dla dokumentów Word.
Numerals: Cyfry:
Numerals Cyfry
Hindi Hindi
Context Kontekstowe
System Systemowe

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1644,6 +1644,12 @@
Enlarge symbol Ampliar símbolo
Enclosure Contorno
Circle Círculo
Numerals: Numerais:
Numerals Numerais
Arabic Arábico
Hindi Hindi
Context Contexto
System Sistema
Templates Modelos
Templates and Add-ins Modelos e suplementos
Attach Template Anexar modelo
@ -1989,7 +1995,6 @@
Add line numbering Adicionar numeração de linhas
Add to Document Adicionar ao documento
Address labels Etiquetas de endereço
Arabic Árabe
Auto Auto
Automatically hyphenate document Hifenizar o documento automaticamente
Chinese (Simplified) Chinês (simplificado)

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1998,6 +1998,12 @@ preview Title Пример
Search icons Поиск значков
No icons match your search. Нет значков, соответствующих поиску.
Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок
Numerals: Цифры:
Numerals Цифры
Arabic Арабские
Hindi Хинди
Context Контекст
System Системные
Couldn't make WordCraft the default: {error} Не удалось сделать WordCraft приложением по умолчанию: {error}
In Default apps, choose WordCraft for the document types you want it to open. В разделе «Приложения по умолчанию» выберите WordCraft для нужных типов документов.
Make WordCraft the default for Word documents… Сделать WordCraft приложением по умолчанию для документов Word…
@ -2323,7 +2329,6 @@ preview Title Пример
Add line numbering Добавить нумерацию строк
Add to Document Добавить в документ
Address labels Адресные наклейки
Arabic Арабский
Auto Авто
Automatically hyphenate document Автоматически расставлять переносы в документе
Chinese (Simplified) Китайский (упрощённый)

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1642,6 +1642,12 @@
Enlarge symbol Uvećaj simbol
Enclosure Okvir
Circle Krug
Numerals: Brojevi:
Numerals Brojevi
Arabic Arapski
Hindi Hindi
Context Kontekst
System Sistem
Templates Šabloni
Templates and Add-ins Šabloni i dodaci
Attach Template Priloži šablon
@ -1987,7 +1993,6 @@
Add line numbering Dodaj brojeve redova
Add to Document Dodaj u dokument
Address labels Adresne nalepnice
Arabic Arapski
Auto Automatski
Automatically hyphenate document Automatski deli reči u dokumentu
Chinese (Simplified) Kineski (pojednostavljeni)

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1646,6 +1646,12 @@
Enlarge symbol Увећај симбол
Enclosure Оквир
Circle Круг
Numerals: Бројеви:
Numerals Бројеви
Arabic Арапски
Hindi Хинди
Context Контекст
System Систем
Templates Шаблони
Templates and Add-ins Шаблони и додаци
Attach Template Приложи шаблон
@ -1991,7 +1997,6 @@
Add line numbering Додај бројеве редова
Add to Document Додај у документ
Address labels Адресне налепнице
Arabic Арапски
Auto Аутоматски
Automatically hyphenate document Аутоматски дели речи у документу
Chinese (Simplified) Кинески (поједностављени)

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1623,6 +1623,12 @@
Search icons Simgelerde ara
No icons match your search. Aramanızla eşleşen simge yok.
Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir
Numerals: Rakamlar:
Numerals Rakamlar
Arabic Arapça
Hindi Hintçe
Context Bağlam
System Sistem
Couldn't make WordCraft the default: {error} WordCraft varsayılan yapılamadı: {error}
In Default apps, choose WordCraft for the document types you want it to open. Varsayılan uygulamalar bölümünde, WordCraft'ın açmasını istediğiniz belge türlerini seçin.
Make WordCraft the default for Word documents… WordCraft'ı Word belgeleri için varsayılan yap…
@ -1945,7 +1951,6 @@
Add line numbering Satır numaralandırma ekle
Add to Document Belgeye Ekle
Address labels Adres etiketleri
Arabic Arapça
Auto Otomatik
Automatically hyphenate document Belgede otomatik heceleme yap
Chinese (Simplified) Çince (Basitleştirilmiş)

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1644,6 +1644,12 @@
Enlarge symbol Збільшити символ
Enclosure Обведення
Circle Коло
Numerals: Цифри:
Numerals Цифри
Arabic Арабські
Hindi Гінді
Context Контекст
System Системні
Templates Шаблони
Templates and Add-ins Шаблони й надбудови
Attach Template Приєднати шаблон
@ -1989,7 +1995,6 @@
Add line numbering Додати нумерацію рядків
Add to Document Додати до документа
Address labels Адресні етикетки
Arabic Арабська
Auto Авто
Automatically hyphenate document Автоматично переносити слова в документі
Chinese (Simplified) Китайська (спрощена)

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1644,6 +1644,12 @@
Enlarge symbol 增大圈号
Enclosure 圈号
Circle 圆形
Numerals: 数字:
Numerals 数字
Arabic 阿拉伯
Hindi 印地
Context 上下文
System 系统
Templates 模板
Templates and Add-ins 模板和加载项
Attach Template 附加模板
@ -1989,7 +1995,6 @@
Add line numbering 添加行号
Add to Document 添加到文档
Address labels 地址标签
Arabic 阿拉伯语
Auto 自动
Automatically hyphenate document 自动断字
Chinese (Simplified) 中文(简体)

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1633,6 +1633,12 @@
Enlarge symbol 放大符號
Enclosure 圍繞符號
Circle 圓形
Numerals: 數字:
Numerals 數字
Arabic 阿拉伯
Hindi 印地
Context 上下文
System 系統
Templates 範本
Templates and Add-ins 範本與增益集
Attach Template 附加範本
@ -1978,7 +1984,6 @@
Add line numbering 新增行號
Add to Document 新增至文件
Address labels 地址標籤
Arabic 阿拉伯文
Auto 自動
Automatically hyphenate document 自動斷字
Chinese (Simplified) 中文(簡體)

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -404,6 +404,10 @@ impl SaveChoice {
impl WordApp {
pub fn new(session: Session, services: Services) -> Self {
// Numeral › System follows the system locale, found here rather than in layout.
if let Some(tag) = sys_locale::get_locale() {
wordcraft_engine::set_system_locale(&tag);
}
WordApp {
session,
ui: UiState::default(),

View File

@ -191,6 +191,7 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
if ui.checkbox(&mut ruler, tl!("Show rulers")).changed() {
let _ = app.run("view.ruler", json!({"value": ruler}));
}
numeral_picker(app, ui);
ui.add_space(8.0);
section(ui, "Printing");
ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color"));
@ -504,6 +505,24 @@ fn default_app_button(app: &mut WordApp, ui: &mut Ui) {
}
}
/// File ▸ Options ▸ Numerals: how digits look on screen (display only, like Word's
/// File › Options › Advanced › Numeral).
fn numeral_picker(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_layout::NumeralMode;
ui.horizontal(|ui| {
ui.label(tl!("Numerals:"));
let mut sel = app.session.view.numeral;
egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| {
for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context, NumeralMode::System] {
ui.selectable_value(&mut sel, m, tl!(m.label()));
}
});
if sel != app.session.view.numeral {
let _ = app.run("view.numeral", json!({ "value": sel }));
}
});
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;