Skip to main content

oxiui_table/
format.rs

1//! Cell formatting: convert a [`Cell`] to a display string with configurable
2//! number formatting, date formatting, or a custom closure.
3
4use crate::Cell;
5
6/// Format a [`Cell`] value as a display string.
7///
8/// Implementations are `Send + Sync` so they can be stored in column
9/// definitions that cross thread boundaries.
10pub trait CellFormatter: Send + Sync {
11    /// Render `cell` as a display string.
12    fn format(&self, cell: &Cell) -> String;
13}
14
15// ── Default formatter ────────────────────────────────────────────────────────
16
17/// Uses the cell's [`Display`](std::fmt::Display) impl without modification.
18pub struct DefaultFormatter;
19
20impl CellFormatter for DefaultFormatter {
21    fn format(&self, cell: &Cell) -> String {
22        cell.to_string()
23    }
24}
25
26// ── Number formatter ─────────────────────────────────────────────────────────
27
28/// Formats `Cell::Int` and `Cell::Float` with configurable decimal places and
29/// optional thousands separators. Non-numeric cells fall back to [`DefaultFormatter`].
30pub struct NumberFormatter {
31    /// Number of decimal places shown for float values (and int values cast to
32    /// float when `decimal_places > 0`).
33    pub decimal_places: usize,
34    /// If `true`, inserts `','` every three integer digits.
35    pub thousands_separator: bool,
36}
37
38impl NumberFormatter {
39    /// Format an integer value with optional thousands separator.
40    fn format_integer(n: i64, thousands: bool) -> String {
41        let s = n.unsigned_abs().to_string();
42        let with_sep = if thousands {
43            insert_thousands(s)
44        } else {
45            n.unsigned_abs().to_string()
46        };
47        if n < 0 {
48            format!("-{with_sep}")
49        } else {
50            with_sep
51        }
52    }
53
54    /// Format a float with the requested decimal places and optional thousands
55    /// separator on the integer part.
56    fn format_float(&self, v: f64) -> String {
57        let formatted = format!("{v:.prec$}", prec = self.decimal_places);
58        if !self.thousands_separator {
59            return formatted;
60        }
61        // Split at the decimal point (if present).
62        match formatted.split_once('.') {
63            Some((int_part, dec_part)) => {
64                let neg = int_part.starts_with('-');
65                let digits = if neg { &int_part[1..] } else { int_part };
66                let int_sep = insert_thousands(digits.to_owned());
67                let sign = if neg { "-" } else { "" };
68                format!("{sign}{int_sep}.{dec_part}")
69            }
70            None => insert_thousands(formatted),
71        }
72    }
73}
74
75/// Insert commas every three digits from the right of a purely-digit string.
76fn insert_thousands(s: String) -> String {
77    let chars: Vec<char> = s.chars().collect();
78    let len = chars.len();
79    let mut out = String::with_capacity(len + len / 3);
80    for (i, c) in chars.iter().enumerate() {
81        if i > 0 && (len - i).is_multiple_of(3) {
82            out.push(',');
83        }
84        out.push(*c);
85    }
86    out
87}
88
89impl CellFormatter for NumberFormatter {
90    fn format(&self, cell: &Cell) -> String {
91        match cell {
92            Cell::Int(n) => {
93                if self.decimal_places == 0 {
94                    Self::format_integer(*n, self.thousands_separator)
95                } else {
96                    self.format_float(*n as f64)
97                }
98            }
99            Cell::Float(v) => self.format_float(*v),
100            other => DefaultFormatter.format(other),
101        }
102    }
103}
104
105// ── Date formatter ───────────────────────────────────────────────────────────
106
107/// Formats cell values using a strftime-style format string.
108///
109/// Since `oxiui-table` has no `chrono` dependency, the format is applied to the
110/// cell's string representation via simple token substitution. For real date
111/// formatting add `chrono` as an optional dependency and gate on a feature flag.
112///
113/// Currently only `%Y-%m-%d` token pass-through is supported: it returns the
114/// cell's display string unchanged (the caller is expected to store the date as a
115/// pre-formatted `Cell::Text`).
116pub struct DateFormatter {
117    /// A strftime-style format string (kept for future chrono integration).
118    pub fmt: String,
119}
120
121impl CellFormatter for DateFormatter {
122    fn format(&self, cell: &Cell) -> String {
123        // Without chrono, we can only return the cell's existing representation.
124        // When `chrono` is integrated, parse and re-format here.
125        cell.to_string()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn default_formatter_strings() {
135        let f = DefaultFormatter;
136        assert_eq!(f.format(&Cell::Text("hi".into())), "hi");
137        assert_eq!(f.format(&Cell::Int(42)), "42");
138        assert_eq!(f.format(&Cell::Empty), "");
139    }
140
141    #[test]
142    fn number_formatter_decimal() {
143        let f = NumberFormatter {
144            decimal_places: 2,
145            thousands_separator: false,
146        };
147        assert_eq!(f.format(&Cell::Float(1.5)), "1.50");
148        assert_eq!(f.format(&Cell::Float(9.876_54)), "9.88");
149    }
150
151    #[test]
152    fn number_formatter_thousands() {
153        let f = NumberFormatter {
154            decimal_places: 0,
155            thousands_separator: true,
156        };
157        assert_eq!(f.format(&Cell::Int(1_000_000)), "1,000,000");
158        assert_eq!(f.format(&Cell::Int(1_234)), "1,234");
159        assert_eq!(f.format(&Cell::Int(999)), "999");
160    }
161
162    #[test]
163    fn number_formatter_negative() {
164        let f = NumberFormatter {
165            decimal_places: 0,
166            thousands_separator: true,
167        };
168        assert_eq!(f.format(&Cell::Int(-1_000)), "-1,000");
169    }
170
171    #[test]
172    fn number_formatter_float_thousands() {
173        let f = NumberFormatter {
174            decimal_places: 2,
175            thousands_separator: true,
176        };
177        assert_eq!(f.format(&Cell::Float(1234567.891)), "1,234,567.89");
178    }
179
180    #[test]
181    fn date_formatter_passthrough() {
182        let f = DateFormatter {
183            fmt: "%Y-%m-%d".into(),
184        };
185        assert_eq!(f.format(&Cell::Text("2026-05-29".into())), "2026-05-29");
186    }
187}