Skip to main content

tablo_core/display/
number.rs

1//! The spellings of a [`Numeric`] value: a number with its thousands grouped, a percentage, and
2//! an amount of a [`Currency`], in English or in the request's locale.
3
4use super::{Number, Numeric};
5
6impl Number {
7    /// Spell `value` with `decimals` digits after the point and its thousands grouped, in
8    /// English, or an empty string when it holds none.
9    ///
10    /// ```rust
11    /// use tablo_core::Number;
12    ///
13    /// assert_eq!(Number::format(1234.5, 2), "1,234.50");
14    /// assert_eq!(Number::format(-3, 0), "-3");
15    /// ```
16    pub fn format(value: impl Numeric, decimals: u8) -> String {
17        value
18            .number()
19            .map(|number| NumberFormat::english().spell(number, decimals))
20            .unwrap_or_default()
21    }
22
23    /// Spell `value` as [`format`](Self::format) does, with the separators of the request's
24    /// locale: `1 234,50` in French.
25    pub fn format_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
26        value
27            .number()
28            .map(|number| NumberFormat::of(cx).spell(number, decimals))
29            .unwrap_or_default()
30    }
31
32    /// Spell `value`, a percentage such as `12.5` for twelve and a half percent, with
33    /// `decimals` digits, in English, or an empty string when it holds none.
34    ///
35    /// ```rust
36    /// use tablo_core::Number;
37    ///
38    /// assert_eq!(Number::percent(12.5, 1), "12.5%");
39    /// assert_eq!(Number::percent(-1250, 0), "-1,250%");
40    /// ```
41    pub fn percent(value: impl Numeric, decimals: u8) -> String {
42        let number = Self::format(value, decimals);
43        if number.is_empty() {
44            return number;
45        }
46        format!("{number}%")
47    }
48
49    /// Spell `value` as [`percent`](Self::percent) does, in the request's locale: its
50    /// separators, and its `tablo-percent` message, `12,5 %` in French.
51    pub fn percent_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
52        let number = Self::format_in(cx, value, decimals);
53        if number.is_empty() {
54            return number;
55        }
56        crate::t!(cx, "tablo-percent", number = number)
57    }
58}
59
60/// How a money column spells its amounts: the symbol, where it goes, the decimals, and whether
61/// the field stores minor units.
62///
63/// ```rust
64/// use tablo_core::Currency;
65///
66/// assert_eq!(Currency::USD.format(1234.5), "$1,234.50");
67/// assert_eq!(Currency::EUR.format(1234.5), "€1,234.50");
68/// assert_eq!(
69///     Currency::new("CHF").symbol_after().format(-3.0),
70///     "-3.00 CHF"
71/// );
72/// assert_eq!(Currency::USD.minor_units().format(123_456), "$1,234.56");
73/// ```
74///
75/// [`format`](Self::format) spells English; a column spells the request's locale through
76/// [`format_in`](Self::format_in): its catalog's separators, `1 234,50`, and its side for the
77/// symbol, `1 234,50 €` in French, unless [`symbol_before`](Self::symbol_before) or
78/// [`symbol_after`](Self::symbol_after) fixes it.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct Currency {
81    symbol: &'static str,
82    decimals: u8,
83    placement: Placement,
84    minor_units: bool,
85}
86
87/// Which side of the amount a [`Currency`] puts its symbol on.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89enum Placement {
90    /// The side the locale's `tablo-money` message puts it on; before in English.
91    Locale,
92    Before,
93    After,
94}
95
96impl Currency {
97    /// US dollars, `$1,234.50`.
98    pub const USD: Self = Self::new("$");
99    /// Euros, `€1,234.50`.
100    pub const EUR: Self = Self::new("€");
101    /// Pounds sterling, `£1,234.50`.
102    pub const GBP: Self = Self::new("£");
103    /// Yen, `¥1,235`: no minor unit.
104    pub const JPY: Self = Self::new("¥").decimals(0);
105
106    /// A currency spelled with `symbol` before the amount and two decimals.
107    pub const fn new(symbol: &'static str) -> Self {
108        Self {
109            symbol,
110            decimals: 2,
111            placement: Placement::Locale,
112            minor_units: false,
113        }
114    }
115
116    /// Spell `decimals` digits after the point.
117    #[must_use]
118    pub const fn decimals(mut self, decimals: u8) -> Self {
119        self.decimals = decimals;
120        self
121    }
122
123    /// Put the symbol after the amount, separated by a space, in every locale.
124    #[must_use]
125    pub const fn symbol_after(mut self) -> Self {
126        self.placement = Placement::After;
127        self
128    }
129
130    /// Put the symbol before the amount in every locale, as English does.
131    #[must_use]
132    pub const fn symbol_before(mut self) -> Self {
133        self.placement = Placement::Before;
134        self
135    }
136
137    /// Read the field as minor units, cents for a two-decimal currency: `123456` is `1,234.56`.
138    #[must_use]
139    pub const fn minor_units(mut self) -> Self {
140        self.minor_units = true;
141        self
142    }
143
144    /// Spell `value` as an amount of this currency in English, or an empty string when it holds
145    /// none.
146    pub fn format(&self, value: impl Numeric) -> String {
147        self.spell(value, &NumberFormat::english(), None)
148    }
149
150    /// Spell `value` as an amount of this currency in the request's locale, or an empty string
151    /// when it holds none.
152    pub fn format_in(&self, cx: &topcoat::context::Cx, value: impl Numeric) -> String {
153        self.spell(value, &NumberFormat::of(cx), Some(cx))
154    }
155
156    /// Spell `value` with `format`'s separators, the symbol on the side `cx`'s locale puts it
157    /// unless the currency fixes it.
158    fn spell(
159        &self,
160        value: impl Numeric,
161        format: &NumberFormat,
162        cx: Option<&topcoat::context::Cx>,
163    ) -> String {
164        let Some(number) = value.number() else {
165            return String::new();
166        };
167        let spelled = match (self.minor_units, number) {
168            (true, Number::Int(minor)) => format_minor_units(minor, self.decimals),
169            (true, Number::Float(minor)) => format_number(
170                Number::Float(minor / 10f64.powi(self.decimals.into())),
171                self.decimals,
172            ),
173            (true, Number::Decimal { mantissa, scale }) => format_number(
174                Number::Decimal {
175                    mantissa,
176                    scale: scale.saturating_add(self.decimals.into()),
177                },
178                self.decimals,
179            ),
180            (false, number) => format_number(number, self.decimals),
181        };
182        let spelled = format.localize(&spelled);
183        let (sign, digits) = match spelled.strip_prefix('-') {
184            Some(digits) => ("-", digits),
185            None => ("", spelled.as_str()),
186        };
187        match (self.placement, cx) {
188            (Placement::After, _) => format!("{sign}{digits} {}", self.symbol),
189            (Placement::Locale, Some(cx)) => crate::t!(
190                cx,
191                "tablo-money",
192                sign = sign.to_owned(),
193                symbol = self.symbol,
194                amount = digits.to_owned()
195            ),
196            (Placement::Before | Placement::Locale, _) => {
197                format!("{sign}{}{digits}", self.symbol)
198            }
199        }
200    }
201}
202
203/// Spell `minor` units with `decimals` of them to the major unit, exactly: `-5` cents is `-0.05`.
204fn format_minor_units(minor: i128, decimals: u8) -> String {
205    // Past 38 decimals no `i128` reaches one major unit.
206    let (whole, fraction) = match 10i128.checked_pow(decimals.into()) {
207        Some(scale) => (minor.abs() / scale, minor.abs() % scale),
208        None => (0, minor.abs()),
209    };
210    let whole = format_number(Number::Int(whole), 0);
211    let sign = if minor < 0 { "-" } else { "" };
212    if decimals == 0 {
213        return format!("{sign}{whole}");
214    }
215    format!(
216        "{sign}{whole}.{fraction:0width$}",
217        width = usize::from(decimals)
218    )
219}
220
221/// How a locale writes a number: its thousands separator and its decimal mark, from its catalog's
222/// `tablo-number-group` and `tablo-number-decimal`.
223#[derive(Clone, Debug, PartialEq, Eq)]
224pub(crate) struct NumberFormat {
225    pub(crate) group: String,
226    pub(crate) decimal: String,
227}
228
229impl NumberFormat {
230    /// `1,234.5`.
231    pub(crate) fn english() -> Self {
232        Self {
233            group: ",".to_owned(),
234            decimal: ".".to_owned(),
235        }
236    }
237
238    /// The request's locale's.
239    pub(crate) fn of(cx: &topcoat::context::Cx) -> Self {
240        Self {
241            group: crate::t!(cx, "tablo-number-group"),
242            decimal: crate::t!(cx, "tablo-number-decimal"),
243        }
244    }
245
246    /// `number` with `decimals` digits after the mark and its thousands grouped.
247    pub(crate) fn spell(&self, number: Number, decimals: u8) -> String {
248        self.localize(&format_number(number, decimals))
249    }
250
251    /// `english`, a number [`format_number`] spelled, in this locale's separators.
252    fn localize(&self, english: &str) -> String {
253        if *self == Self::english() {
254            return english.to_owned();
255        }
256        let mut spelled = String::with_capacity(english.len() + 4);
257        for char in english.chars() {
258            match char {
259                ',' => spelled.push_str(&self.group),
260                '.' => spelled.push_str(&self.decimal),
261                other => spelled.push(other),
262            }
263        }
264        spelled
265    }
266}
267
268/// Spell `number` with `decimals` digits after the point and its thousands grouped by `,`.
269pub(crate) fn format_number(number: Number, decimals: u8) -> String {
270    let fixed = match number {
271        Number::Int(int) if decimals == 0 => int.to_string(),
272        Number::Int(int) => format!("{int}.{}", "0".repeat(decimals.into())),
273        Number::Float(float) => {
274            let fixed = format!("{float:.*}", usize::from(decimals));
275            // `-0.00` reads as zero.
276            if fixed
277                .trim_start_matches('-')
278                .bytes()
279                .all(|b| b == b'0' || b == b'.')
280            {
281                fixed.trim_start_matches('-').to_string()
282            } else {
283                fixed
284            }
285        }
286        Number::Decimal { mantissa, scale } => fixed_decimal(mantissa, scale, decimals),
287    };
288    let (sign, unsigned) = match fixed.strip_prefix('-') {
289        Some(rest) => ("-", rest),
290        None => ("", fixed.as_str()),
291    };
292    let (whole, fraction) = match unsigned.split_once('.') {
293        Some((whole, fraction)) => (whole, Some(fraction)),
294        None => (unsigned, None),
295    };
296    let mut grouped = String::with_capacity(whole.len() + whole.len() / 3);
297    for (index, digit) in whole.chars().enumerate() {
298        if index > 0 && (whole.len() - index) % 3 == 0 {
299            grouped.push(',');
300        }
301        grouped.push(digit);
302    }
303    match fraction {
304        Some(fraction) => format!("{sign}{grouped}.{fraction}"),
305        None => format!("{sign}{grouped}"),
306    }
307}
308
309/// Spell `mantissa / 10^scale` with `decimals` digits after the point and no grouping, rounding
310/// half to even. The digits are worked as text: a scale far past the mantissa's length drops
311/// every digit without spelling the zeros between.
312fn fixed_decimal(mantissa: i128, scale: u32, decimals: u8) -> String {
313    let digits = mantissa.unsigned_abs().to_string();
314    let decimals = u32::from(decimals);
315    // The value times `10^decimals`, as digits.
316    let mut units = if scale <= decimals {
317        let zeros = (decimals - scale) as usize;
318        format!("{digits}{}", "0".repeat(zeros))
319    } else {
320        // `scale - decimals` digits to drop: at most the length, else the value rounds to zero.
321        let dropped = usize::try_from(scale - decimals)
322            .ok()
323            .filter(|&dropped| dropped <= digits.len());
324        match dropped {
325            Some(dropped) => {
326                let (kept, rest) = digits.split_at(digits.len() - dropped);
327                let mut kept = if kept.is_empty() { "0" } else { kept }.to_string();
328                let odd = kept.bytes().last().is_some_and(|digit| digit % 2 == 1);
329                let rounds_up = match rest.as_bytes() {
330                    [first, tail @ ..] => {
331                        *first > b'5'
332                            || (*first == b'5' && (tail.iter().any(|&digit| digit != b'0') || odd))
333                    }
334                    [] => false,
335                };
336                if rounds_up {
337                    increment(&mut kept);
338                }
339                kept
340            }
341            None => "0".to_string(),
342        }
343    };
344    let width = decimals as usize + 1;
345    if units.len() < width {
346        units.insert_str(0, &"0".repeat(width - units.len()));
347    }
348    let point = units.len() - decimals as usize;
349    let negative = mantissa < 0 && units.bytes().any(|digit| digit != b'0');
350    let sign = if negative { "-" } else { "" };
351    if decimals == 0 {
352        format!("{sign}{units}")
353    } else {
354        format!("{sign}{}.{}", &units[..point], &units[point..])
355    }
356}
357
358/// Add one to the decimal digits `digits`, carrying into a new leading digit.
359fn increment(digits: &mut String) {
360    let mut bytes = std::mem::take(digits).into_bytes();
361    let mut carry = true;
362    for digit in bytes.iter_mut().rev() {
363        if *digit == b'9' {
364            *digit = b'0';
365        } else {
366            *digit += 1;
367            carry = false;
368            break;
369        }
370    }
371    if carry {
372        bytes.insert(0, b'1');
373    }
374    *digits = String::from_utf8(bytes).expect("ASCII digits stay ASCII");
375}