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)]
22#[non_exhaustive]
23pub enum Color {
24    /// A neutral, muted status.
25    #[default]
26    Gray,
27    /// The theme's primary color.
28    Primary,
29    /// Done, paid, active.
30    Success,
31    /// Pending, expiring, low.
32    Warning,
33    /// Failed, refunded, overdue.
34    Danger,
35    /// Informational, in progress.
36    Info,
37}
38
39impl Color {
40    /// The color a `#[option(color = "…")]` attribute names.
41    #[doc(hidden)]
42    pub const fn from_name(name: &str) -> Option<Self> {
43        Some(match name.as_bytes() {
44            b"gray" => Self::Gray,
45            b"primary" => Self::Primary,
46            b"success" => Self::Success,
47            b"warning" => Self::Warning,
48            b"danger" => Self::Danger,
49            b"info" => Self::Info,
50            _ => return None,
51        })
52    }
53
54    /// The name a `#[option(color = "…")]` attribute gives this color.
55    pub(crate) const fn name(self) -> &'static str {
56        match self {
57            Self::Gray => "gray",
58            Self::Primary => "primary",
59            Self::Success => "success",
60            Self::Warning => "warning",
61            Self::Danger => "danger",
62            Self::Info => "info",
63        }
64    }
65
66    /// The class of a badge's status dot in this color.
67    pub(crate) const fn dot_class(self) -> &'static str {
68        match self {
69            Self::Gray => "size-1.5 shrink-0 rounded-full bg-muted-foreground/60",
70            Self::Primary => "size-1.5 shrink-0 rounded-full bg-primary",
71            Self::Success => "size-1.5 shrink-0 rounded-full bg-success",
72            Self::Warning => "size-1.5 shrink-0 rounded-full bg-warning",
73            Self::Danger => "size-1.5 shrink-0 rounded-full bg-destructive",
74            Self::Info => "size-1.5 shrink-0 rounded-full bg-info",
75        }
76    }
77
78    /// The class of a surface filled with this color, such as an environment's stripe.
79    pub(crate) const fn fill_class(self) -> &'static str {
80        match self {
81            Self::Gray => "bg-muted-foreground",
82            Self::Primary => "bg-primary",
83            Self::Success => "bg-success",
84            Self::Warning => "bg-warning",
85            Self::Danger => "bg-destructive",
86            Self::Info => "bg-info",
87        }
88    }
89
90    /// The class of a table row tinted in this color.
91    pub(crate) const fn row_class(self) -> &'static str {
92        match self {
93            Self::Gray => "bg-muted/50",
94            Self::Primary => "bg-primary/5",
95            Self::Success => "bg-success/10",
96            Self::Warning => "bg-warning/10",
97            Self::Danger => "bg-destructive/10",
98            Self::Info => "bg-info/10",
99        }
100    }
101
102    /// The classes that tint a button in this color, `None` for the neutral button. They are
103    /// marked important to win over the outline button's own colors.
104    pub(crate) const fn button_tone(self) -> Option<&'static str> {
105        match self {
106            Self::Gray => None,
107            Self::Primary => Some("text-primary! hover:bg-primary/5! active:bg-primary/10!"),
108            Self::Success => Some("text-success! hover:bg-success/5! active:bg-success/10!"),
109            Self::Warning => Some("text-warning! hover:bg-warning/5! active:bg-warning/10!"),
110            Self::Danger => {
111                Some("text-destructive! hover:bg-destructive/5! active:bg-destructive/10!")
112            }
113            Self::Info => Some("text-info! hover:bg-info/5! active:bg-info/10!"),
114        }
115    }
116
117    /// The class of a cell's text in this color.
118    pub(crate) const fn text_class(self) -> &'static str {
119        match self {
120            Self::Gray => "text-muted-foreground",
121            Self::Primary => "text-primary",
122            Self::Success => "text-success",
123            Self::Warning => "text-warning",
124            Self::Danger => "text-destructive",
125            Self::Info => "text-info",
126        }
127    }
128}
129
130/// How heavy a column's text renders.
131#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
132pub enum FontWeight {
133    /// Lighter than the body text.
134    Light,
135    /// The body text's weight, the default.
136    #[default]
137    Normal,
138    /// Between normal and semibold.
139    Medium,
140    /// Between medium and bold.
141    Semibold,
142    /// The heaviest.
143    Bold,
144}
145
146impl FontWeight {
147    /// The font-weight class of this weight, empty at the body text's.
148    pub(crate) const fn class(self) -> &'static str {
149        match self {
150            Self::Light => "font-light",
151            Self::Normal => "",
152            Self::Medium => "font-medium",
153            Self::Semibold => "font-semibold",
154            Self::Bold => "font-bold",
155        }
156    }
157}
158
159/// Where a column's header and cells align their content.
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
161pub enum Alignment {
162    /// The reading direction's start, the default.
163    #[default]
164    Start,
165    /// The middle of the column.
166    Center,
167    /// The reading direction's end: numbers and money, so their digits line up.
168    End,
169}
170
171impl Alignment {
172    /// The text-alignment class of this alignment, empty at the start.
173    pub(crate) const fn class(self) -> &'static str {
174        match self {
175            Self::Start => "",
176            Self::Center => "text-center",
177            Self::End => "text-end",
178        }
179    }
180}
181
182/// A number as a cell reads it: an integer and a decimal stay exact, a float is rounded when
183/// spelled.
184///
185/// Equality compares the representation, not the amount: `Decimal { mantissa: 1250, scale: 2 }`
186/// differs from `Decimal { mantissa: 125, scale: 1 }`, and `Int(12)` from
187/// `Decimal { mantissa: 12, scale: 0 }`.
188#[derive(Clone, Copy, Debug, PartialEq)]
189#[non_exhaustive]
190pub enum Number {
191    /// Any integer type, widened.
192    Int(i128),
193    /// `f32` or `f64`.
194    Float(f64),
195    /// `mantissa / 10^scale`, exactly: a decimal type's value. Spelled with fewer digits than
196    /// `scale`, it rounds half to even, as a float's spelling does.
197    Decimal {
198        /// The digits, with the sign.
199        mantissa: i128,
200        /// How many of the digits follow the point.
201        scale: u32,
202    },
203}
204
205/// A field a [`TextColumn`](crate::TextColumn) can spell as a number or an amount of money:
206/// every integer and float type, `rust_decimal::Decimal` and `bigdecimal::BigDecimal` with the
207/// features of those names, and an `Option` of one, which reads as the table's dash when `None`.
208pub trait Numeric {
209    /// The value, `None` when it holds none.
210    fn number(&self) -> Option<Number>;
211}
212
213impl<T: Numeric + ?Sized> Numeric for &T {
214    fn number(&self) -> Option<Number> {
215        (**self).number()
216    }
217}
218
219macro_rules! numeric_ints {
220    ($($ty:ty),+) => {
221        $(
222            impl Numeric for $ty {
223                fn number(&self) -> Option<Number> {
224                    Some(Number::Int(i128::from(*self)))
225                }
226            }
227        )+
228    };
229}
230
231numeric_ints!(i8, i16, i32, i64, u8, u16, u32, u64);
232
233impl Numeric for f32 {
234    fn number(&self) -> Option<Number> {
235        Some(Number::Float(f64::from(*self)))
236    }
237}
238
239impl Numeric for f64 {
240    fn number(&self) -> Option<Number> {
241        Some(Number::Float(*self))
242    }
243}
244
245#[cfg(feature = "rust_decimal")]
246impl Numeric for rust_decimal::Decimal {
247    fn number(&self) -> Option<Number> {
248        Some(Number::Decimal {
249            mantissa: self.mantissa(),
250            scale: self.scale(),
251        })
252    }
253}
254
255/// A value of more than 38 significant digits, which no `i128` mantissa holds, is rounded to 38,
256/// half to even; one whose integer part alone outgrows them reads as a float.
257#[cfg(feature = "bigdecimal")]
258impl Numeric for bigdecimal::BigDecimal {
259    fn number(&self) -> Option<Number> {
260        use bigdecimal::{RoundingMode, ToPrimitive};
261
262        const DIGITS: u64 = 38;
263        let exact = || {
264            let (mantissa, scale) = match self.digits().checked_sub(DIGITS) {
265                Some(excess @ 1..) => {
266                    let (_, scale) = self.as_bigint_and_scale();
267                    let scale = scale.checked_sub(i64::try_from(excess).ok()?)?;
268                    self.with_scale_round(scale, RoundingMode::HalfEven)
269                        .into_bigint_and_scale()
270                }
271                _ => self.clone().into_bigint_and_scale(),
272            };
273            let mantissa = mantissa.to_i128()?;
274            match u32::try_from(scale) {
275                Ok(scale) => Some(Number::Decimal { mantissa, scale }),
276                Err(_) if scale < 0 => {
277                    let shift = 10i128.checked_pow(u32::try_from(scale.unsigned_abs()).ok()?)?;
278                    Some(Number::Decimal {
279                        mantissa: mantissa.checked_mul(shift)?,
280                        scale: 0,
281                    })
282                }
283                Err(_) => None,
284            }
285        };
286        exact().or_else(|| {
287            self.to_f64()
288                .filter(|float| float.is_finite())
289                .map(Number::Float)
290        })
291    }
292}
293
294impl<T: Numeric> Numeric for Option<T> {
295    fn number(&self) -> Option<Number> {
296        self.as_ref().and_then(Numeric::number)
297    }
298}
299
300mod number;
301
302pub use number::Currency;
303pub(crate) use number::NumberFormat;
304#[cfg(test)]
305use number::format_number;
306
307#[cfg(test)]
308mod tests;