Skip to main content

perspective_viewer/config/
number_string_format.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13mod enums;
14pub use enums::*;
15use serde::{Deserialize, Serialize};
16use strum::{Display, EnumIter};
17use ts_rs::TS;
18
19/// The `style` family of a numeric column's `number_format` — serialized
20/// FLATTENED into [`CustomNumberFormatConfig`]'s object, discriminated by
21/// the `style` key (`"decimal"` default, `"currency"` + `currency`/
22/// `currencyDisplay`/`currencySign`, `"percent"`, `"unit"` + `unit`/
23/// `unitDisplay`), mirroring `Intl.NumberFormat` options.
24#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, TS)]
25#[serde(rename_all = "camelCase", tag = "style")]
26pub enum NumberFormatStyle {
27    #[default]
28    Decimal,
29    Currency(CurrencyNumberFormatStyle),
30    Percent,
31    Unit(UnitNumberFormatStyle),
32}
33
34#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
35#[serde(rename_all = "camelCase")]
36pub enum CurrencyDisplay {
37    Code,
38    #[default]
39    Symbol,
40    NarrowSymbol,
41    Name,
42}
43
44#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
45#[serde(rename_all = "camelCase")]
46pub enum CurrencySign {
47    #[default]
48    Standard,
49    Accounting,
50}
51
52#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Clone, TS)]
53#[serde(rename_all = "camelCase")]
54pub struct CurrencyNumberFormatStyle {
55    #[serde(default)]
56    pub currency: CurrencyCode,
57
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub currency_display: Option<CurrencyDisplay>,
60
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub currency_sign: Option<CurrencySign>,
63}
64
65#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
66#[serde(rename_all = "camelCase")]
67pub enum UnitDisplay {
68    #[default]
69    Short,
70    Narrow,
71    Long,
72}
73
74#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, TS)]
75#[serde(rename_all = "camelCase")]
76pub struct UnitNumberFormatStyle {
77    #[serde(default)]
78    pub unit: Unit,
79
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub unit_display: Option<UnitDisplay>,
82}
83
84#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
85#[serde(rename_all = "camelCase")]
86pub enum RoundingPriority {
87    #[default]
88    Auto,
89    MorePrecision,
90    LessPrecision,
91}
92
93#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
94#[serde(rename_all = "camelCase")]
95pub enum RoundingMode {
96    Ceil,
97    Floor,
98    Expand,
99    Trunc,
100    HalfCeil,
101    HalfFloor,
102    #[default]
103    HalfExpand,
104    HalfTrunc,
105    HalfEven,
106}
107
108#[derive(Default, Debug, PartialEq, Clone, TS)]
109pub enum RoundingIncrement {
110    #[default]
111    Auto,
112    Custom(f64),
113}
114impl std::fmt::Display for RoundingIncrement {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            RoundingIncrement::Auto => f.write_str("Auto"),
118            RoundingIncrement::Custom(val) => f.write_fmt(format_args!("{val}")),
119        }
120    }
121}
122
123pub const ROUNDING_INCREMENTS: [f64; 15] = [
124    1., 2., 5., 10., 20., 25., 50., 100., 200., 250., 500., 1000., 2000., 2500., 5000.,
125];
126
127#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
128#[serde(rename_all = "camelCase")]
129pub enum TrailingZeroDisplay {
130    #[default]
131    Auto,
132    StripIfInteger,
133}
134
135/// The `notation` family of a numeric column's `number_format` —
136/// serialized FLATTENED into [`CustomNumberFormatConfig`]'s object,
137/// discriminated by the `notation` key (`"standard"` default,
138/// `"scientific"`, `"engineering"`, `"compact"` + `compactDisplay`),
139/// mirroring `Intl.NumberFormat` options.
140#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, TS)]
141#[serde(rename_all = "camelCase", tag = "notation")]
142pub enum Notation {
143    #[default]
144    Standard,
145    Scientific,
146    Engineering,
147    Compact(CompactDisplay),
148}
149
150#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
151#[serde(rename_all = "camelCase", tag = "compactDisplay")]
152pub enum CompactDisplay {
153    #[default]
154    Short,
155    Long,
156}
157
158#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
159#[serde(rename_all = "snake_case")]
160pub enum UseGrouping {
161    Always,
162
163    #[default]
164    Auto,
165    Min2, // default if notation is compact
166
167    #[serde(untagged)]
168    False(bool),
169}
170
171#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Clone, Copy, EnumIter, Display, TS)]
172#[serde(rename_all = "camelCase")]
173pub enum SignDisplay {
174    #[default]
175    Auto,
176    Always,
177    ExceptZero,
178    Negative,
179    Never,
180}
181
182/// A numeric column's `number_format` (`columns_config` value) —
183/// `Intl.NumberFormat`-shaped options, written by the Style tab's number
184/// format editor and read by `createNumberFormatter`. The `style` and
185/// `notation` families ([`NumberFormatStyle`] / [`Notation`]) are serde-
186/// FLATTENED into this object but `#[ts(skip)]`'d (ts-rs cannot flatten
187/// `Option<enum>`) — the package's `NumberFormatConfig` re-composes the
188/// full wire type as `CustomNumberFormatConfig & Partial<NumberFormatStyle>
189/// & Partial<Notation>` (see `column-format.ts`).
190#[derive(Serialize, Deserialize, Debug, Default, PartialEq, Clone, TS)]
191#[serde(rename_all = "camelCase")]
192pub struct CustomNumberFormatConfig {
193    #[serde(flatten)]
194    #[ts(skip)]
195    pub _style: Option<NumberFormatStyle>,
196
197    // see Digit Options
198    // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#minimumintegerdigits
199    // these min/max props can all be specified but it results in possible conflicts
200    // may consider making them distinct options
201    #[serde(skip_serializing_if = "Option::is_none")]
202    #[ts(optional, as = "Option<_>")]
203    pub minimum_integer_digits: Option<f64>,
204
205    #[serde(skip_serializing_if = "Option::is_none")]
206    #[ts(optional, as = "Option<_>")]
207    pub minimum_fraction_digits: Option<f64>,
208
209    #[serde(skip_serializing_if = "Option::is_none")]
210    #[ts(optional, as = "Option<_>")]
211    pub maximum_fraction_digits: Option<f64>,
212
213    #[serde(skip_serializing_if = "Option::is_none")]
214    #[ts(optional, as = "Option<_>")]
215    pub minimum_significant_digits: Option<f64>,
216
217    #[serde(skip_serializing_if = "Option::is_none")]
218    #[ts(optional, as = "Option<_>")]
219    pub maximum_significant_digits: Option<f64>,
220
221    #[serde(skip_serializing_if = "Option::is_none")]
222    #[ts(optional, as = "Option<_>")]
223    pub rounding_priority: Option<RoundingPriority>,
224
225    // specific values https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingincrement
226    // Only available with automatic rounding priority
227    // Cannot be mixed with sigfig rounding. (Does this mean max/min sigfig must be unset?)
228    #[serde(skip_serializing_if = "Option::is_none")]
229    #[ts(optional, as = "Option<_>")]
230    pub rounding_increment: Option<f64>,
231
232    #[serde(skip_serializing_if = "Option::is_none")]
233    #[ts(optional, as = "Option<_>")]
234    pub rounding_mode: Option<RoundingMode>,
235
236    #[serde(skip_serializing_if = "Option::is_none")]
237    #[ts(optional, as = "Option<_>")]
238    pub trailing_zero_display: Option<TrailingZeroDisplay>,
239
240    #[serde(flatten)]
241    #[ts(skip)]
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub _notation: Option<Notation>,
244
245    /// NOTE (audit 2026-08-05): serialized values are the STRINGS
246    /// `"always"`/`"auto"`/`"min2"` or the untagged BOOLEAN `false` —
247    /// the former hand-written TS `useGrouping?: boolean` was wrong for
248    /// the string cases.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    #[ts(optional, as = "Option<_>")]
251    pub use_grouping: Option<UseGrouping>,
252
253    #[serde(skip_serializing_if = "Option::is_none")]
254    #[ts(optional, as = "Option<_>")]
255    pub sign_display: Option<SignDisplay>,
256}
257
258/// The active plugin's `NumberFormat` default overlaid on the built-in
259/// `number_format` values.
260#[derive(Clone, Debug, PartialEq)]
261pub struct NumberFormatDefaults {
262    pub is_float: bool,
263    pub style: NumberFormatStyle,
264    pub notation: Notation,
265    pub minimum_integer_digits: f64,
266    pub fraction: (f64, f64),
267    pub significant: (f64, f64),
268    pub rounding_priority: RoundingPriority,
269    pub rounding_mode: RoundingMode,
270    pub trailing_zero_display: TrailingZeroDisplay,
271    pub use_grouping: UseGrouping,
272    pub sign_display: SignDisplay,
273}
274
275impl NumberFormatDefaults {
276    pub fn builtin(is_float: bool) -> Self {
277        Self {
278            is_float,
279            style: NumberFormatStyle::default(),
280            notation: Notation::default(),
281            minimum_integer_digits: 1.,
282            fraction: if is_float { (2., 2.) } else { (0., 0.) },
283            significant: (1., 21.),
284            rounding_priority: RoundingPriority::default(),
285            rounding_mode: RoundingMode::default(),
286            trailing_zero_display: TrailingZeroDisplay::default(),
287            use_grouping: UseGrouping::default(),
288            sign_display: SignDisplay::default(),
289        }
290    }
291
292    /// Overlay a plugin-declared partial default onto [`Self::builtin`].
293    pub fn resolve(spec_default: Option<&CustomNumberFormatConfig>, is_float: bool) -> Self {
294        let mut out = Self::builtin(is_float);
295        let Some(d) = spec_default else {
296            return out;
297        };
298
299        if let Some(style) = &d._style {
300            out.style = style.clone();
301        }
302
303        if let Some(notation) = &d._notation {
304            out.notation = notation.clone();
305        }
306
307        if let Some(v) = d.minimum_integer_digits {
308            out.minimum_integer_digits = v;
309        }
310
311        if let Some(v) = d.minimum_fraction_digits {
312            out.fraction.0 = v;
313        }
314
315        if let Some(v) = d.maximum_fraction_digits {
316            out.fraction.1 = v;
317        }
318
319        if let Some(v) = d.minimum_significant_digits {
320            out.significant.0 = v;
321        }
322
323        if let Some(v) = d.maximum_significant_digits {
324            out.significant.1 = v;
325        }
326
327        if let Some(v) = d.rounding_priority {
328            out.rounding_priority = v;
329        }
330
331        if let Some(v) = d.rounding_mode {
332            out.rounding_mode = v;
333        }
334
335        if let Some(v) = d.trailing_zero_display {
336            out.trailing_zero_display = v;
337        }
338
339        if let Some(v) = d.use_grouping {
340            out.use_grouping = v;
341        }
342
343        if let Some(v) = d.sign_display {
344            out.sign_display = v;
345        }
346
347        out
348    }
349}
350
351impl CustomNumberFormatConfig {
352    pub fn filter_default(self, defaults: &NumberFormatDefaults) -> Self {
353        let (frac_min, frac_max) = defaults.fraction;
354        let rounding_increment = self.rounding_increment;
355        let use_grouping = self
356            .use_grouping
357            .filter(|val| *val != defaults.use_grouping);
358
359        let mut minimum_fraction_digits =
360            self.minimum_fraction_digits.filter(|val| *val != frac_min);
361
362        let mut maximum_fraction_digits =
363            self.maximum_fraction_digits.filter(|val| *val != frac_max);
364
365        let mut show_frac = defaults.is_float
366            && (minimum_fraction_digits.is_some()
367                || maximum_fraction_digits.is_some()
368                || use_grouping.is_some()
369                || matches!(
370                    self._style,
371                    Some(NumberFormatStyle::Percent | NumberFormatStyle::Unit(_))
372                ))
373            || !defaults.is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
374
375        // Rounding increment does not work unless `minimum_fraction_digits`
376        // and `maximum_fraction_digits` are set to 0.
377        if rounding_increment.is_some() {
378            show_frac = true;
379            minimum_fraction_digits = Some(0.);
380            maximum_fraction_digits = Some(0.);
381        }
382
383        let minimum_significant_digits = self
384            .minimum_significant_digits
385            .filter(|val| *val != defaults.significant.0);
386
387        let maximum_significant_digits = self
388            .maximum_significant_digits
389            .filter(|val| *val != defaults.significant.1);
390
391        let show_sig = minimum_significant_digits.is_some() || maximum_significant_digits.is_some();
392        Self {
393            _style: self._style.filter(|style| *style != defaults.style),
394            minimum_integer_digits: self
395                .minimum_integer_digits
396                .filter(|val| *val != defaults.minimum_integer_digits),
397            minimum_fraction_digits: show_frac
398                .then_some(minimum_fraction_digits.unwrap_or(frac_min)),
399            maximum_fraction_digits: show_frac
400                .then_some(maximum_fraction_digits.unwrap_or(frac_max)),
401            minimum_significant_digits: show_sig
402                .then_some(minimum_significant_digits.unwrap_or(defaults.significant.0)),
403            maximum_significant_digits: show_sig
404                .then_some(maximum_significant_digits.unwrap_or(defaults.significant.1)),
405            rounding_priority: self
406                .rounding_priority
407                .filter(|val| *val != defaults.rounding_priority),
408            rounding_increment,
409            rounding_mode: self
410                .rounding_mode
411                .filter(|val| *val != defaults.rounding_mode),
412            trailing_zero_display: self
413                .trailing_zero_display
414                .filter(|val| *val != defaults.trailing_zero_display),
415            _notation: self
416                ._notation
417                .filter(|notation| *notation != defaults.notation),
418            use_grouping,
419            sign_display: self
420                .sign_display
421                .filter(|val| *val != defaults.sign_display),
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn sig(min: f64, max: f64) -> CustomNumberFormatConfig {
431        CustomNumberFormatConfig {
432            minimum_significant_digits: Some(min),
433            maximum_significant_digits: Some(max),
434            ..Default::default()
435        }
436    }
437
438    #[test]
439    fn resolve_overlays_partial_default_on_builtin() {
440        let spec = CustomNumberFormatConfig {
441            _notation: Some(Notation::Compact(CompactDisplay::Short)),
442            minimum_fraction_digits: Some(0.),
443            maximum_fraction_digits: Some(1.),
444            ..Default::default()
445        };
446
447        let defaults = NumberFormatDefaults::resolve(Some(&spec), true);
448        assert_eq!(defaults.notation, Notation::Compact(CompactDisplay::Short));
449        assert_eq!(defaults.fraction, (0., 1.));
450        assert_eq!(defaults.style, NumberFormatStyle::Decimal);
451        assert_eq!(defaults.significant, (1., 21.));
452        assert_eq!(
453            NumberFormatDefaults::resolve(None, true),
454            NumberFormatDefaults::builtin(true)
455        );
456    }
457
458    #[test]
459    fn filter_preserves_max_significant_digits() {
460        let filtered = sig(1., 5.).filter_default(&NumberFormatDefaults::builtin(true));
461        assert_eq!(filtered.minimum_significant_digits, Some(1.));
462        assert_eq!(filtered.maximum_significant_digits, Some(5.));
463
464        let filtered = sig(3., 21.).filter_default(&NumberFormatDefaults::builtin(true));
465        assert_eq!(filtered.minimum_significant_digits, Some(3.));
466        assert_eq!(filtered.maximum_significant_digits, Some(21.));
467    }
468
469    #[test]
470    fn filter_elides_values_equal_to_resolved_defaults() {
471        let defaults = NumberFormatDefaults {
472            fraction: (0., 1.),
473            notation: Notation::Compact(CompactDisplay::Short),
474            ..NumberFormatDefaults::builtin(true)
475        };
476
477        let config = CustomNumberFormatConfig {
478            _notation: Some(Notation::Compact(CompactDisplay::Short)),
479            minimum_fraction_digits: Some(0.),
480            maximum_fraction_digits: Some(1.),
481            ..Default::default()
482        };
483
484        assert_eq!(
485            config.filter_default(&defaults),
486            CustomNumberFormatConfig::default()
487        );
488    }
489
490    #[test]
491    fn filter_serializes_builtin_values_under_override() {
492        let defaults = NumberFormatDefaults {
493            notation: Notation::Compact(CompactDisplay::Short),
494            ..NumberFormatDefaults::builtin(true)
495        };
496
497        let config = CustomNumberFormatConfig {
498            _notation: Some(Notation::Standard),
499            ..Default::default()
500        };
501
502        let filtered = config.filter_default(&defaults);
503        assert_eq!(filtered._notation, Some(Notation::Standard));
504    }
505
506    #[test]
507    fn filter_bakes_fraction_digits_for_percent_style() {
508        let config = CustomNumberFormatConfig {
509            _style: Some(NumberFormatStyle::Percent),
510            ..Default::default()
511        };
512
513        let filtered = config.filter_default(&NumberFormatDefaults::builtin(true));
514        assert_eq!(filtered._style, Some(NumberFormatStyle::Percent));
515        assert_eq!(filtered.minimum_fraction_digits, Some(2.));
516        assert_eq!(filtered.maximum_fraction_digits, Some(2.));
517    }
518}