Skip to main content

tablo_core/
display.rs

1//! How a cell presents its value: its [`Color`], its [`Alignment`], and the number and money
2//! spellings of a [`Numeric`] value.
3
4/// A semantic color for a badge, a status dot or a cell's text.
5///
6/// Each maps to a theme token (`--success`, `--warning`, `--info`, `--destructive`,
7/// `--primary`, `--muted-foreground`), so a theme that redeclares the token recolors every cell
8/// that uses it.
9///
10/// ```rust
11/// #[derive(tablo_core::Options)]
12/// enum Status {
13///     #[option(color = "warning")]
14///     Pending,
15///     #[option(color = "success")]
16///     Paid,
17///     #[option(color = "danger")]
18///     Refunded,
19/// }
20/// ```
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum Color {
23    /// A neutral, muted status.
24    #[default]
25    Gray,
26    /// The theme's primary color.
27    Primary,
28    /// Done, paid, active.
29    Success,
30    /// Pending, expiring, low.
31    Warning,
32    /// Failed, refunded, overdue.
33    Danger,
34    /// Informational, in progress.
35    Info,
36}
37
38impl Color {
39    /// The color a `#[option(color = "…")]` attribute names.
40    #[doc(hidden)]
41    pub const fn from_name(name: &str) -> Option<Self> {
42        Some(match name.as_bytes() {
43            b"gray" => Self::Gray,
44            b"primary" => Self::Primary,
45            b"success" => Self::Success,
46            b"warning" => Self::Warning,
47            b"danger" => Self::Danger,
48            b"info" => Self::Info,
49            _ => return None,
50        })
51    }
52
53    /// The class of a badge's status dot in this color.
54    pub(crate) const fn dot_class(self) -> &'static str {
55        match self {
56            Self::Gray => "size-1.5 shrink-0 rounded-full bg-muted-foreground/60",
57            Self::Primary => "size-1.5 shrink-0 rounded-full bg-primary",
58            Self::Success => "size-1.5 shrink-0 rounded-full bg-success",
59            Self::Warning => "size-1.5 shrink-0 rounded-full bg-warning",
60            Self::Danger => "size-1.5 shrink-0 rounded-full bg-destructive",
61            Self::Info => "size-1.5 shrink-0 rounded-full bg-info",
62        }
63    }
64
65    /// The class of a cell's text in this color.
66    pub(crate) const fn text_class(self) -> &'static str {
67        match self {
68            Self::Gray => "text-muted-foreground",
69            Self::Primary => "text-primary",
70            Self::Success => "text-success",
71            Self::Warning => "text-warning",
72            Self::Danger => "text-destructive",
73            Self::Info => "text-info",
74        }
75    }
76}
77
78/// Where a column's header and cells align their content.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
80pub enum Alignment {
81    /// The reading direction's start, the default.
82    #[default]
83    Start,
84    /// The middle of the column.
85    Center,
86    /// The reading direction's end: numbers and money, so their digits line up.
87    End,
88}
89
90impl Alignment {
91    /// The text-alignment class of this alignment, empty at the start.
92    pub(crate) const fn class(self) -> &'static str {
93        match self {
94            Self::Start => "",
95            Self::Center => "text-center",
96            Self::End => "text-end",
97        }
98    }
99}
100
101/// A number as a cell reads it: an integer stays exact, a float is rounded when spelled.
102#[derive(Clone, Copy, Debug, PartialEq)]
103pub enum Number {
104    /// Any integer type, widened.
105    Int(i128),
106    /// `f32` or `f64`.
107    Float(f64),
108}
109
110/// A field a [`TextColumn`](crate::TextColumn) can spell as a number or an amount of money:
111/// every integer and float type, and an `Option` of one, which reads as the table's dash when
112/// `None`.
113pub trait Numeric {
114    /// The value, `None` when it holds none.
115    fn number(&self) -> Option<Number>;
116}
117
118impl<T: Numeric + ?Sized> Numeric for &T {
119    fn number(&self) -> Option<Number> {
120        (**self).number()
121    }
122}
123
124macro_rules! numeric_ints {
125    ($($ty:ty),+) => {
126        $(
127            impl Numeric for $ty {
128                fn number(&self) -> Option<Number> {
129                    Some(Number::Int(i128::from(*self)))
130                }
131            }
132        )+
133    };
134}
135
136numeric_ints!(i8, i16, i32, i64, u8, u16, u32, u64);
137
138impl Numeric for f32 {
139    fn number(&self) -> Option<Number> {
140        Some(Number::Float(f64::from(*self)))
141    }
142}
143
144impl Numeric for f64 {
145    fn number(&self) -> Option<Number> {
146        Some(Number::Float(*self))
147    }
148}
149
150impl<T: Numeric> Numeric for Option<T> {
151    fn number(&self) -> Option<Number> {
152        self.as_ref().and_then(Numeric::number)
153    }
154}
155
156/// How a money column spells its amounts: the symbol, where it goes, the decimals, and whether
157/// the field stores minor units.
158///
159/// ```rust
160/// use tablo_core::Currency;
161///
162/// assert_eq!(Currency::USD.format(1234.5), "$1,234.50");
163/// assert_eq!(Currency::EUR.format(1234.5), "€1,234.50");
164/// assert_eq!(
165///     Currency::new("CHF").symbol_after().format(-3.0),
166///     "-3.00 CHF"
167/// );
168/// assert_eq!(Currency::USD.minor_units().format(123_456), "$1,234.56");
169/// ```
170///
171/// The separators are `,` and `.`: a locale's own spelling is the column's
172/// [`format`](crate::TextColumn::format).
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub struct Currency {
175    symbol: &'static str,
176    decimals: u8,
177    symbol_after: bool,
178    minor_units: bool,
179}
180
181impl Currency {
182    /// US dollars, `$1,234.50`.
183    pub const USD: Self = Self::new("$");
184    /// Euros, `€1,234.50`.
185    pub const EUR: Self = Self::new("€");
186    /// Pounds sterling, `£1,234.50`.
187    pub const GBP: Self = Self::new("£");
188    /// Yen, `¥1,235`: no minor unit.
189    pub const JPY: Self = Self::new("¥").decimals(0);
190
191    /// A currency spelled with `symbol` before the amount and two decimals.
192    pub const fn new(symbol: &'static str) -> Self {
193        Self {
194            symbol,
195            decimals: 2,
196            symbol_after: false,
197            minor_units: false,
198        }
199    }
200
201    /// Spell `decimals` digits after the point.
202    #[must_use]
203    pub const fn decimals(mut self, decimals: u8) -> Self {
204        self.decimals = decimals;
205        self
206    }
207
208    /// Put the symbol after the amount, separated by a space.
209    #[must_use]
210    pub const fn symbol_after(mut self) -> Self {
211        self.symbol_after = true;
212        self
213    }
214
215    /// Read the field as minor units, cents for a two-decimal currency: `123456` is `1,234.56`.
216    #[must_use]
217    pub const fn minor_units(mut self) -> Self {
218        self.minor_units = true;
219        self
220    }
221
222    /// Spell `value` as an amount of this currency, or an empty string when it holds none.
223    pub fn format(&self, value: impl Numeric) -> String {
224        let Some(number) = value.number() else {
225            return String::new();
226        };
227        let spelled = match (self.minor_units, number) {
228            (true, Number::Int(minor)) => format_minor_units(minor, self.decimals),
229            (true, Number::Float(minor)) => format_number(
230                Number::Float(minor / 10f64.powi(self.decimals.into())),
231                self.decimals,
232            ),
233            (false, number) => format_number(number, self.decimals),
234        };
235        let (sign, digits) = match spelled.strip_prefix('-') {
236            Some(digits) => ("-", digits),
237            None => ("", spelled.as_str()),
238        };
239        if self.symbol_after {
240            format!("{sign}{digits} {}", self.symbol)
241        } else {
242            format!("{sign}{}{digits}", self.symbol)
243        }
244    }
245}
246
247/// Spell `minor` units with `decimals` of them to the major unit, exactly: `-5` cents is `-0.05`.
248fn format_minor_units(minor: i128, decimals: u8) -> String {
249    // Past 38 decimals no `i128` reaches one major unit.
250    let (whole, fraction) = match 10i128.checked_pow(decimals.into()) {
251        Some(scale) => (minor.abs() / scale, minor.abs() % scale),
252        None => (0, minor.abs()),
253    };
254    let whole = format_number(Number::Int(whole), 0);
255    let sign = if minor < 0 { "-" } else { "" };
256    if decimals == 0 {
257        return format!("{sign}{whole}");
258    }
259    format!(
260        "{sign}{whole}.{fraction:0width$}",
261        width = usize::from(decimals)
262    )
263}
264
265/// Spell `number` with `decimals` digits after the point and its thousands grouped by `,`.
266pub(crate) fn format_number(number: Number, decimals: u8) -> String {
267    let fixed = match number {
268        Number::Int(int) if decimals == 0 => int.to_string(),
269        Number::Int(int) => format!("{int}.{}", "0".repeat(decimals.into())),
270        Number::Float(float) => {
271            let fixed = format!("{float:.*}", usize::from(decimals));
272            // `-0.00` reads as zero.
273            if fixed
274                .trim_start_matches('-')
275                .bytes()
276                .all(|b| b == b'0' || b == b'.')
277            {
278                fixed.trim_start_matches('-').to_string()
279            } else {
280                fixed
281            }
282        }
283    };
284    let (sign, unsigned) = match fixed.strip_prefix('-') {
285        Some(rest) => ("-", rest),
286        None => ("", fixed.as_str()),
287    };
288    let (whole, fraction) = match unsigned.split_once('.') {
289        Some((whole, fraction)) => (whole, Some(fraction)),
290        None => (unsigned, None),
291    };
292    let mut grouped = String::with_capacity(whole.len() + whole.len() / 3);
293    for (index, digit) in whole.chars().enumerate() {
294        if index > 0 && (whole.len() - index) % 3 == 0 {
295            grouped.push(',');
296        }
297        grouped.push(digit);
298    }
299    match fraction {
300        Some(fraction) => format!("{sign}{grouped}.{fraction}"),
301        None => format!("{sign}{grouped}"),
302    }
303}
304
305#[cfg(test)]
306mod tests;