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