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