Skip to main content

herogpui_core/
format.rs

1//! Number formatting for the components that take v3's `formatOptions`.
2//!
3//! v3 hands `Intl.NumberFormatOptions` to `Meter`, `NumberField`, `ProgressBar`,
4//! `ProgressCircle` and `Slider`, which is how a slider reads out `$1,200.00`
5//! rather than `1200`. There is no `Intl` here, so this implements the subset
6//! those components actually use, with the `en-US` conventions the default theme
7//! already assumes: `,` between groups and `.` before the fraction.
8//!
9//! What is deliberately absent is `locale`. Choosing separators, digit systems
10//! and currency placement per locale needs CLDR *number* data, which this crate
11//! carries no ICU dependency for -- the date components do, which is why a
12//! calendar heads itself in the reader's locale and a number does not follow
13//! one. Inventing a partial table would be worse than not offering the prop.
14
15/// `Intl.NumberFormatOptions["style"]`.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum NumberStyle {
18    #[default]
19    /// Plain decimal number.
20    Decimal,
21    /// Multiplies by 100 and appends `%`.
22    Percent,
23    /// Currency amount; uses `NumberFormat::currency`.
24    Currency,
25    /// Number with a unit; uses `NumberFormat::unit`.
26    Unit,
27}
28
29/// `Intl.NumberFormatOptions["currencySign"]`.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum CurrencySign {
32    #[default]
33    /// Negative amounts are prefixed with a minus.
34    Standard,
35    /// Wraps a negative amount in parentheses instead of prefixing a minus.
36    Accounting,
37}
38
39/// `Intl.NumberFormatOptions["unitDisplay"]`.
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum UnitDisplay {
42    /// Narrow unit form.
43    Narrow,
44    #[default]
45    /// Short unit form.
46    Short,
47    /// Long unit form.
48    Long,
49}
50
51/// The `formatOptions` subset these components use.
52#[must_use = "builder methods return a new value; the original is unchanged"]
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct NumberFormat {
55    /// How the number is styled.
56    pub style: NumberStyle,
57    /// ISO 4217 code, e.g. `"USD"`. Rendered as a symbol when one is known.
58    pub currency: Option<&'static str>,
59    /// How negative currency amounts are written.
60    pub currency_sign: CurrencySign,
61    /// A CLDR unit identifier, e.g. `"kilogram"`.
62    pub unit: Option<&'static str>,
63    /// How a unit is written.
64    pub unit_display: UnitDisplay,
65    /// Minimum number of fraction digits, if set.
66    pub minimum_fraction_digits: Option<u8>,
67    /// Maximum number of fraction digits, if set.
68    pub maximum_fraction_digits: Option<u8>,
69    /// Whether digit groups are separated.
70    pub use_grouping: bool,
71}
72
73impl Default for NumberFormat {
74    fn default() -> Self {
75        Self {
76            style: NumberStyle::Decimal,
77            currency: None,
78            currency_sign: CurrencySign::Standard,
79            unit: None,
80            unit_display: UnitDisplay::Short,
81            minimum_fraction_digits: None,
82            maximum_fraction_digits: None,
83            use_grouping: true,
84        }
85    }
86}
87
88impl NumberFormat {
89    /// `{style: "decimal"}`, the default format.
90    pub fn decimal() -> Self {
91        Self::default()
92    }
93
94    /// `{style: "percent"}` — v3's default for `ProgressBar` and `Meter`.
95    pub fn percent() -> Self {
96        Self {
97            style: NumberStyle::Percent,
98            ..Default::default()
99        }
100    }
101
102    /// `{style: "currency", currency: code}`.
103    pub fn currency(code: &'static str) -> Self {
104        Self {
105            style: NumberStyle::Currency,
106            currency: Some(code),
107            ..Default::default()
108        }
109    }
110
111    /// `{style: "unit", unit: name}`.
112    pub fn unit(name: &'static str) -> Self {
113        Self {
114            style: NumberStyle::Unit,
115            unit: Some(name),
116            ..Default::default()
117        }
118    }
119
120    /// Sets how negative currency amounts are written.
121    pub fn currency_sign(mut self, sign: CurrencySign) -> Self {
122        self.currency_sign = sign;
123        self
124    }
125
126    /// Sets how a unit is written.
127    pub fn unit_display(mut self, display: UnitDisplay) -> Self {
128        self.unit_display = display;
129        self
130    }
131
132    /// Sets the minimum number of fraction digits.
133    pub fn minimum_fraction_digits(mut self, n: u8) -> Self {
134        self.minimum_fraction_digits = Some(n);
135        self
136    }
137
138    /// Sets the maximum number of fraction digits.
139    pub fn maximum_fraction_digits(mut self, n: u8) -> Self {
140        self.maximum_fraction_digits = Some(n);
141        self
142    }
143
144    /// Sets whether digit groups are separated.
145    pub fn use_grouping(mut self, v: bool) -> Self {
146        self.use_grouping = v;
147        self
148    }
149
150    /// The fraction-digit range `Intl` would use: currency defaults to 2, every
151    /// other style to 0–3.
152    fn fraction_range(&self) -> (u8, u8) {
153        let (default_min, default_max) = match self.style {
154            NumberStyle::Currency => (2, 2),
155            _ => (0, 3),
156        };
157        let min = self.minimum_fraction_digits.unwrap_or(default_min);
158        // An explicit minimum raises the maximum with it, as `Intl` does.
159        let max = self.maximum_fraction_digits.unwrap_or(default_max.max(min));
160        (min, max.max(min))
161    }
162
163    /// Formats `value`, applying the style's own scaling (`percent` × 100).
164    // `scaled.abs() == 1.0` picks the singular unit name. Intl's plural rules
165    // are exact too — 0.999 is "inches", not "inch" — so a tolerance here would
166    // be a behaviour change, not a fix.
167    #[allow(clippy::float_cmp)]
168    pub fn format(&self, value: f64) -> String {
169        let scaled = match self.style {
170            NumberStyle::Percent => value * 100.0,
171            _ => value,
172        };
173        let negative = scaled < 0.0 || (scaled == 0.0 && scaled.is_sign_negative());
174        let digits = self.digits(scaled.abs());
175
176        let body = match self.style {
177            NumberStyle::Percent => format!("{digits}%"),
178            NumberStyle::Currency => format!("{}{digits}", currency_symbol(self.currency)),
179            NumberStyle::Unit => match (self.unit, self.unit_display) {
180                (Some(u), UnitDisplay::Long) => {
181                    // English pluralises on *exactly* one: 1.5 kilograms is
182                    // plural, so a tolerance would read the grammar wrong.
183                    #[allow(clippy::float_cmp)]
184                    let singular = scaled.abs() == 1.0;
185                    format!("{digits} {}", long_unit(u, singular))
186                }
187                (Some(u), UnitDisplay::Narrow) => format!("{digits}{}", short_unit(u)),
188                (Some(u), UnitDisplay::Short) => format!("{digits} {}", short_unit(u)),
189                (None, _) => digits,
190            },
191            NumberStyle::Decimal => digits,
192        };
193
194        match (negative, self.style, self.currency_sign) {
195            (true, NumberStyle::Currency, CurrencySign::Accounting) => format!("({body})"),
196            (true, _, _) => format!("-{body}"),
197            (false, _, _) => body,
198        }
199    }
200
201    /// The digit run: rounded to the fraction range, then grouped.
202    fn digits(&self, magnitude: f64) -> String {
203        let (min, max) = self.fraction_range();
204        let mut text = format!("{:.*}", max as usize, magnitude);
205        if max > min {
206            // Trim only what the minimum does not require.
207            if text.contains('.') {
208                let keep = text.len() - text.trim_end_matches('0').len();
209                let removable = (max - min) as usize;
210                text.truncate(text.len() - keep.min(removable));
211                if text.ends_with('.') {
212                    text.pop();
213                }
214            }
215        }
216        let (int, frac) = match text.split_once('.') {
217            Some((i, f)) => (i.to_owned(), Some(f.to_owned())),
218            None => (text, None),
219        };
220        let int = if self.use_grouping { group(&int) } else { int };
221        match frac {
222            Some(f) => format!("{int}.{f}"),
223            None => int,
224        }
225    }
226}
227
228/// Inserts `,` every three digits from the right.
229fn group(digits: &str) -> String {
230    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
231    for (i, c) in digits.chars().enumerate() {
232        if i > 0 && (digits.len() - i).is_multiple_of(3) {
233            out.push(',');
234        }
235        out.push(c);
236    }
237    out
238}
239
240/// The symbol for the currencies v3's examples use; anything else keeps its
241/// code, which is what `Intl` falls back to for an unknown one.
242fn currency_symbol(code: Option<&str>) -> String {
243    match code {
244        Some("USD") => "$".into(),
245        Some("EUR") => "€".into(),
246        Some("GBP") => "£".into(),
247        Some("JPY") => "¥".into(),
248        Some(other) => format!("{other} "),
249        None => String::new(),
250    }
251}
252
253fn short_unit(unit: &str) -> &str {
254    match unit {
255        "kilogram" => "kg",
256        "gram" => "g",
257        "pound" => "lb",
258        "meter" => "m",
259        "centimeter" => "cm",
260        "kilometer" => "km",
261        "mile" => "mi",
262        "liter" => "L",
263        "byte" => "byte",
264        "kilobyte" => "kB",
265        "megabyte" => "MB",
266        "gigabyte" => "GB",
267        "second" => "sec",
268        "minute" => "min",
269        "hour" => "hr",
270        "day" => "day",
271        "percent" => "%",
272        "celsius" => "°C",
273        "fahrenheit" => "°F",
274        other => other,
275    }
276}
277
278fn long_unit(unit: &str, singular: bool) -> String {
279    if singular {
280        unit.to_owned()
281    } else {
282        // English plurals for the unit names above; none of them is irregular.
283        match unit {
284            "inch" => "inches".to_owned(),
285            other => format!("{other}s"),
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn decimal_groups_thousands() {
296        assert_eq!(NumberFormat::decimal().format(1234567.0), "1,234,567");
297        assert_eq!(NumberFormat::decimal().format(999.0), "999");
298        assert_eq!(
299            NumberFormat::decimal()
300                .use_grouping(false)
301                .format(1234567.0),
302            "1234567"
303        );
304    }
305
306    #[test]
307    fn percent_scales_by_a_hundred() {
308        assert_eq!(NumberFormat::percent().format(0.42), "42%");
309        // v3's ProgressBar hands a 0..1 fraction, so 1.0 must read as 100%.
310        assert_eq!(NumberFormat::percent().format(1.0), "100%");
311    }
312
313    #[test]
314    fn currency_defaults_to_two_fraction_digits() {
315        assert_eq!(NumberFormat::currency("USD").format(1200.0), "$1,200.00");
316        assert_eq!(NumberFormat::currency("EUR").format(0.5), "€0.50");
317        // An unknown code keeps its code, as Intl does.
318        assert_eq!(NumberFormat::currency("XYZ").format(3.0), "XYZ 3.00");
319    }
320
321    #[test]
322    fn accounting_parenthesises_a_negative() {
323        let f = NumberFormat::currency("EUR").currency_sign(CurrencySign::Accounting);
324        assert_eq!(f.format(-12.0), "(€12.00)");
325        assert_eq!(f.format(12.0), "€12.00");
326        // `standard` keeps the minus sign in front of the symbol.
327        assert_eq!(NumberFormat::currency("EUR").format(-12.0), "-€12.00");
328    }
329
330    #[test]
331    fn fraction_digits_pad_and_trim() {
332        let f = NumberFormat::decimal()
333            .minimum_fraction_digits(2)
334            .maximum_fraction_digits(2);
335        assert_eq!(f.format(3.0), "3.00");
336        assert_eq!(f.format(3.456), "3.46");
337        // Trailing zeros above the minimum are dropped, as Intl drops them.
338        assert_eq!(NumberFormat::decimal().format(2.50), "2.5");
339        assert_eq!(NumberFormat::decimal().format(2.0), "2");
340    }
341
342    #[test]
343    fn units_render_short_narrow_and_long() {
344        let kg = NumberFormat::unit("kilogram");
345        assert_eq!(kg.format(5.0), "5 kg");
346        assert_eq!(
347            kg.clone().unit_display(UnitDisplay::Narrow).format(5.0),
348            "5kg"
349        );
350        assert_eq!(
351            kg.clone().unit_display(UnitDisplay::Long).format(5.0),
352            "5 kilograms"
353        );
354        assert_eq!(kg.unit_display(UnitDisplay::Long).format(1.0), "1 kilogram");
355    }
356
357    #[test]
358    fn an_explicit_minimum_raises_the_maximum() {
359        // Intl throws when max < min; raising max is the sane resolution.
360        let f = NumberFormat::decimal().minimum_fraction_digits(4);
361        assert_eq!(f.format(1.5), "1.5000");
362    }
363}