Skip to main content

oxiui_table/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3//! `oxiui-table` — Virtualized table widget for OxiUI.
4//!
5//! Provides a `Table<S>` widget backed by a `RowSource` trait, with viewport-based
6//! virtualization: only rows visible in the current scroll window (plus a small
7//! overscan) are materialized per frame. This keeps memory and CPU usage constant
8//! regardless of the total row count.
9//!
10//! # Features
11//!
12//! - `egui-table` — egui `ScrollArea::show_rows` rendering backend.
13//! - `iced-table` — iced `scrollable` + windowed `column` rendering backend.
14//!
15//! # Example
16//!
17//! ```rust
18//! use oxiui_table::{Table, RowSource, Cell, ColumnDef};
19//!
20//! struct MyData;
21//!
22//! impl RowSource for MyData {
23//!     fn row_count(&self) -> usize { 1000 }
24//!     fn row(&self, i: usize) -> Vec<Cell> {
25//!         vec![Cell::Int(i as i64), Cell::Text(format!("row-{i}"))]
26//!     }
27//!     fn column_defs(&self) -> &[ColumnDef] {
28//!         &[]
29//!     }
30//! }
31//!
32//! let table = Table::new(MyData).with_row_height(24.0);
33//! let visible = table.materialize_visible(240.0, 0.0);
34//! assert!(visible.len() <= 20);
35//! ```
36
37mod table;
38
39mod align;
40mod csv;
41mod filter;
42mod selection;
43mod sort;
44
45pub mod accessibility;
46pub mod async_source;
47pub mod clipboard;
48pub mod format;
49pub mod header;
50pub mod height;
51pub mod height_cache;
52pub mod nav;
53pub mod pagination;
54pub mod persistence;
55pub mod text_integration;
56pub mod theme_integration;
57
58#[cfg(feature = "egui-table")]
59mod egui_table;
60
61#[cfg(feature = "iced-table")]
62mod iced_table;
63
64pub use table::{RenderedCell, Table};
65
66pub use align::CellAlign;
67pub use csv::to_csv;
68pub use filter::{apply_all, filter_indices, ColumnFilter};
69pub use selection::{SelectionMode, SelectionModel};
70pub use sort::{sort_indices, SortDirection, SortState};
71
72pub use async_source::{AsyncRowSource, BoxFuture, PrefetchBuffer};
73pub use clipboard::{selection_to_tsv, CaptureClipboard, ClipboardSink, NullClipboard};
74pub use format::{CellFormatter, DateFormatter, DefaultFormatter, NumberFormatter};
75pub use header::{handle_row_click, move_column, HeaderSortState, TableIndex};
76pub use height::CumulativeHeights;
77pub use height_cache::{CumulativeHeightCache, RowCache};
78pub use nav::TableNav;
79pub use pagination::PaginationState;
80
81#[cfg(feature = "egui-table")]
82pub use egui_table::EguiTableState;
83
84#[cfg(feature = "iced-table")]
85pub use iced_table::{
86    render_iced, render_iced_sortable, render_iced_with_filters, render_iced_with_selection,
87};
88
89/// The default row height in logical pixels, used by [`RowSource::row_height`]
90/// when no per-row override is provided.
91pub const DEFAULT_ROW_HEIGHT: f32 = 24.0;
92
93/// Events emitted by the table widget in response to user interaction.
94///
95/// Callers receive these via a callback or event list and use them to update
96/// application state (e.g. persist the new sort order, broadcast a row
97/// selection to other panels, etc.).
98#[derive(Debug, Clone)]
99pub enum TableEvent {
100    /// A row was selected (by click or keyboard navigation).
101    ///
102    /// The value is the **visible** row index (after sort/filter).
103    RowSelected(usize),
104    /// A cell's value was edited and committed.
105    CellEdited {
106        /// Visible row index.
107        row: usize,
108        /// Logical column index.
109        col: usize,
110        /// The new cell value as a string.
111        new_value: String,
112    },
113    /// The sort order changed.
114    SortChanged {
115        /// The column that was sorted.
116        col: usize,
117        /// `true` = ascending, `false` = descending.
118        ascending: bool,
119    },
120    /// A column was resized by the user.
121    ColumnResized {
122        /// The logical column index.
123        col: usize,
124        /// The new column width in logical pixels.
125        new_width: f32,
126    },
127    /// The filter text for a column changed.
128    FilterChanged {
129        /// The logical column index.
130        col: usize,
131        /// The new filter string (empty = no filter).
132        new_filter: String,
133    },
134}
135
136/// Errors returned by [`RowSource`] mutating operations.
137#[derive(Debug, Clone, PartialEq)]
138pub enum TableError {
139    /// The data source does not support mutation (read-only).
140    ReadOnly,
141    /// The (row, col) coordinate is outside the source's bounds.
142    OutOfBounds {
143        /// Row index that was out of bounds.
144        row: usize,
145        /// Column index that was out of bounds.
146        col: usize,
147    },
148    /// The supplied value is not valid for this cell (e.g. wrong type).
149    InvalidValue(String),
150}
151
152impl std::fmt::Display for TableError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            TableError::ReadOnly => write!(f, "table source is read-only"),
156            TableError::OutOfBounds { row, col } => {
157                write!(f, "cell ({row}, {col}) is out of bounds")
158            }
159            TableError::InvalidValue(msg) => write!(f, "invalid cell value: {msg}"),
160        }
161    }
162}
163
164impl std::error::Error for TableError {}
165
166/// Trait that provides rows to the table widget.
167pub trait RowSource {
168    /// Returns the total number of rows in the data source.
169    fn row_count(&self) -> usize;
170    /// Returns the cells for row at the given index.
171    fn row(&self, index: usize) -> Vec<Cell>;
172    /// Returns the column definitions (name + preferred width).
173    fn column_defs(&self) -> &[ColumnDef];
174
175    /// Attempt to set a cell value in the source.
176    ///
177    /// The default implementation returns [`TableError::ReadOnly`], marking the
178    /// source as immutable.  Override this method on mutable sources to accept
179    /// edits committed from the UI.
180    fn set_cell(&mut self, _row: usize, _col: usize, _value: Cell) -> Result<(), TableError> {
181        Err(TableError::ReadOnly)
182    }
183
184    /// Per-row height in logical pixels.
185    ///
186    /// The default returns [`DEFAULT_ROW_HEIGHT`] (24 px) for every row,
187    /// producing a uniform-height table.  Override for variable-height rows.
188    fn row_height(&self, _index: usize) -> f32 {
189        DEFAULT_ROW_HEIGHT
190    }
191
192    /// Optional children of a row, enabling tree / grouped tables.
193    ///
194    /// Returns `None` for leaf rows and flat (non-grouped) tables (default).
195    /// Return `Some(child_indices)` for a row that acts as a parent node.
196    fn children(&self, _row: usize) -> Option<Vec<usize>> {
197        None
198    }
199
200    /// Indent level for a row in a tree / grouped table.
201    ///
202    /// Returns `0` for root-level rows (default).  Override to return `1` for
203    /// first-level children, `2` for grandchildren, and so on.
204    fn indent_level(&self, _row: usize) -> usize {
205        0
206    }
207
208    /// Optional footer (aggregate) row displayed below the data rows.
209    ///
210    /// Returns `None` (no footer) by default.  Override to return a
211    /// `Vec<Cell>` whose length matches `column_defs().len()`.
212    fn footer(&self) -> Option<Vec<Cell>> {
213        None
214    }
215}
216
217/// Blanket impl so that `Box<dyn RowSource>` itself implements `RowSource`,
218/// enabling object-safe polymorphic table data sources.
219impl<T: RowSource + ?Sized> RowSource for Box<T> {
220    fn row_count(&self) -> usize {
221        (**self).row_count()
222    }
223    fn row(&self, index: usize) -> Vec<Cell> {
224        (**self).row(index)
225    }
226    fn column_defs(&self) -> &[ColumnDef] {
227        (**self).column_defs()
228    }
229    fn set_cell(&mut self, row: usize, col: usize, value: Cell) -> Result<(), TableError> {
230        (**self).set_cell(row, col, value)
231    }
232    fn row_height(&self, index: usize) -> f32 {
233        (**self).row_height(index)
234    }
235    fn children(&self, row: usize) -> Option<Vec<usize>> {
236        (**self).children(row)
237    }
238    fn indent_level(&self, row: usize) -> usize {
239        (**self).indent_level(row)
240    }
241    fn footer(&self) -> Option<Vec<Cell>> {
242        (**self).footer()
243    }
244}
245
246/// Trait for custom cell rendering.
247///
248/// Implement this to supply a custom display string for [`Cell::Custom`] variants.
249/// The trait requires [`std::fmt::Debug`] and [`Send`] so that cell values can be
250/// inspected and passed across thread boundaries.
251pub trait CellRenderer: std::fmt::Debug + Send {
252    /// Render this value as a display string.
253    fn render_str(&self) -> String;
254}
255
256/// Convert Unix milliseconds since the epoch (1970-01-01) to an ISO-8601 date
257/// string `"YYYY-MM-DD"` using the proleptic Gregorian calendar.
258///
259/// The algorithm is adapted from the Julian Day Number conversion described at
260/// <https://en.wikipedia.org/wiki/Julian_day#Julian_day_number_calculation>.
261/// Unix epoch day 0 equals Julian Day Number 2440588.
262fn unix_ms_to_iso8601(ms: i64) -> String {
263    // Truncate to whole days (floor division, handle negative ms).
264    let days = if ms >= 0 {
265        ms / 86_400_000
266    } else {
267        // For negative milliseconds, floor towards negative infinity.
268        (ms - 86_399_999) / 86_400_000
269    };
270
271    // Convert Unix day offset to Julian Day Number.
272    let jdn = days + 2_440_588_i64;
273
274    let a = jdn + 32044;
275    let b = (4 * a + 3) / 146097;
276    let c = a - (146097 * b) / 4;
277    let d = (4 * c + 3) / 1461;
278    let e = c - (1461 * d) / 4;
279    let m = (5 * e + 2) / 153;
280    let day = e - (153 * m + 2) / 5 + 1;
281    let month = m + 3 - 12 * (m / 10);
282    let year = 100 * b + d - 4800 + m / 10;
283
284    format!("{year:04}-{month:02}-{day:02}")
285}
286
287/// A single table cell value.
288#[derive(Debug)]
289pub enum Cell {
290    /// Text cell.
291    Text(String),
292    /// Integer cell.
293    Int(i64),
294    /// Floating-point cell.
295    Float(f64),
296    /// Boolean cell.
297    Bool(bool),
298    /// Empty / null cell.
299    Empty,
300    /// Date cell, stored as Unix milliseconds since 1970-01-01 00:00:00 UTC.
301    ///
302    /// Displayed as an ISO-8601 date string `"YYYY-MM-DD"`.
303    Date(i64),
304    /// Currency cell: an exact amount in the smallest currency unit (e.g. cents)
305    /// together with a three-letter ISO-4217 currency code.
306    ///
307    /// Displayed as `"<major>.<minor02> <code>"` (e.g. `"123.45 EUR"`).
308    Currency {
309        /// Amount in the smallest denomination (e.g. cents for USD/EUR).
310        amount_cents: i64,
311        /// ISO-4217 currency code (e.g. `"USD"`, `"EUR"`).
312        code: String,
313    },
314    /// Hyperlink cell: a display label and a URL.
315    ///
316    /// The [`Display`](std::fmt::Display) impl shows the `label` only.
317    Link {
318        /// The text shown to the user.
319        label: String,
320        /// The destination URL (not shown in plain-text rendering).
321        url: String,
322    },
323    /// Image cell with a URI pointing to the image resource.
324    ///
325    /// Displayed as `"[image: <uri>]"` in plain-text contexts.
326    Image {
327        /// A URI (file path, `data:` URL, `https://` URL, etc.) identifying the image.
328        uri: String,
329    },
330    /// Custom cell backed by a [`CellRenderer`] implementation.
331    ///
332    /// The renderer is heap-allocated and not [`Clone`]; cloning a `Cell::Custom`
333    /// is intentionally unsupported — wrap in `Arc` at the call site if needed.
334    Custom(Box<dyn CellRenderer>),
335}
336
337impl Clone for Cell {
338    fn clone(&self) -> Self {
339        match self {
340            Cell::Text(s) => Cell::Text(s.clone()),
341            Cell::Int(n) => Cell::Int(*n),
342            Cell::Float(v) => Cell::Float(*v),
343            Cell::Bool(b) => Cell::Bool(*b),
344            Cell::Empty => Cell::Empty,
345            Cell::Date(ms) => Cell::Date(*ms),
346            Cell::Currency { amount_cents, code } => Cell::Currency {
347                amount_cents: *amount_cents,
348                code: code.clone(),
349            },
350            Cell::Link { label, url } => Cell::Link {
351                label: label.clone(),
352                url: url.clone(),
353            },
354            Cell::Image { uri } => Cell::Image { uri: uri.clone() },
355            // Custom cells cannot be cloned generically; fall back to Empty.
356            Cell::Custom(_) => Cell::Empty,
357        }
358    }
359}
360
361impl std::fmt::Display for Cell {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        match self {
364            Cell::Text(s) => write!(f, "{s}"),
365            Cell::Int(n) => write!(f, "{n}"),
366            Cell::Float(v) => write!(f, "{v}"),
367            Cell::Bool(b) => write!(f, "{b}"),
368            Cell::Empty => Ok(()),
369            Cell::Date(ms) => write!(f, "{}", unix_ms_to_iso8601(*ms)),
370            Cell::Currency { amount_cents, code } => {
371                let major = amount_cents / 100;
372                let minor = amount_cents.abs() % 100;
373                // Preserve the negative sign even when major is 0.
374                if *amount_cents < 0 && major == 0 {
375                    write!(f, "-0.{minor:02} {code}")
376                } else {
377                    write!(f, "{major}.{minor:02} {code}")
378                }
379            }
380            Cell::Link { label, .. } => write!(f, "{label}"),
381            Cell::Image { uri } => write!(f, "[image: {uri}]"),
382            Cell::Custom(renderer) => write!(f, "{}", renderer.render_str()),
383        }
384    }
385}
386
387impl Cell {
388    /// Returns `true` if this cell is [`Cell::Empty`].
389    pub fn is_empty(&self) -> bool {
390        matches!(self, Cell::Empty)
391    }
392
393    /// Total ordering between two cells for sorting.
394    ///
395    /// Same-typed cells compare naturally (numbers numerically, text
396    /// lexicographically, bools `false < true`). Floats use a total order where
397    /// `NaN` sorts last. Cross-type comparisons fall back to a stable rank so
398    /// sorting never panics: `Empty < Bool < Int/Float < Text < Date < Currency < Link < Image < Custom`.
399    pub fn compare(&self, other: &Cell) -> std::cmp::Ordering {
400        use std::cmp::Ordering;
401        match (self, other) {
402            (Cell::Int(a), Cell::Int(b)) => a.cmp(b),
403            (Cell::Float(a), Cell::Float(b)) => a.total_cmp(b),
404            // Mixed numeric: promote to f64 and use a total order.
405            (Cell::Int(a), Cell::Float(b)) => (*a as f64).total_cmp(b),
406            (Cell::Float(a), Cell::Int(b)) => a.total_cmp(&(*b as f64)),
407            (Cell::Text(a), Cell::Text(b)) => a.cmp(b),
408            (Cell::Bool(a), Cell::Bool(b)) => a.cmp(b),
409            (Cell::Empty, Cell::Empty) => Ordering::Equal,
410            (Cell::Date(a), Cell::Date(b)) => a.cmp(b),
411            (
412                Cell::Currency {
413                    amount_cents: a, ..
414                },
415                Cell::Currency {
416                    amount_cents: b, ..
417                },
418            ) => a.cmp(b),
419            // Cross-type: order by a stable type rank.
420            _ => self.type_rank().cmp(&other.type_rank()),
421        }
422    }
423
424    /// A stable rank used to order cells of differing variants.
425    fn type_rank(&self) -> u8 {
426        match self {
427            Cell::Empty => 0,
428            Cell::Bool(_) => 1,
429            Cell::Int(_) => 2,
430            Cell::Float(_) => 2, // numeric types share a rank
431            Cell::Text(_) => 3,
432            Cell::Date(_) => 4,
433            Cell::Currency { .. } => 5,
434            Cell::Link { .. } => 6,
435            Cell::Image { .. } => 7,
436            Cell::Custom(_) => 8,
437        }
438    }
439}
440
441impl From<&str> for Cell {
442    fn from(s: &str) -> Self {
443        Cell::Text(s.to_owned())
444    }
445}
446
447impl From<String> for Cell {
448    fn from(s: String) -> Self {
449        Cell::Text(s)
450    }
451}
452
453impl From<i64> for Cell {
454    fn from(n: i64) -> Self {
455        Cell::Int(n)
456    }
457}
458
459impl From<i32> for Cell {
460    fn from(n: i32) -> Self {
461        Cell::Int(n as i64)
462    }
463}
464
465impl From<f64> for Cell {
466    fn from(v: f64) -> Self {
467        Cell::Float(v)
468    }
469}
470
471impl From<bool> for Cell {
472    fn from(b: bool) -> Self {
473        Cell::Bool(b)
474    }
475}
476
477/// Column definition: name, preferred display width, and optional per-column
478/// configuration.
479pub struct ColumnDef {
480    /// Display name shown in the column header.
481    pub name: String,
482    /// Preferred column width in logical pixels.
483    pub width: f32,
484    /// Minimum allowed column width when resizing (logical pixels).
485    pub min_width: f32,
486    /// Maximum allowed column width when resizing (logical pixels).
487    pub max_width: f32,
488    /// Whether the user may resize this column by dragging the header edge.
489    pub resizable: bool,
490    /// Optional custom cell formatter.  `None` falls back to [`DefaultFormatter`].
491    pub formatter: Option<Box<dyn CellFormatter>>,
492    /// Optional alignment override.  `None` falls back to [`CellAlign::default_for`].
493    pub align: Option<CellAlign>,
494}
495
496impl Clone for ColumnDef {
497    fn clone(&self) -> Self {
498        // `CellFormatter` is not Clone; cloning drops the formatter and resets to default.
499        Self {
500            name: self.name.clone(),
501            width: self.width,
502            min_width: self.min_width,
503            max_width: self.max_width,
504            resizable: self.resizable,
505            formatter: None,
506            align: self.align,
507        }
508    }
509}
510
511impl std::fmt::Debug for ColumnDef {
512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513        f.debug_struct("ColumnDef")
514            .field("name", &self.name)
515            .field("width", &self.width)
516            .field("min_width", &self.min_width)
517            .field("max_width", &self.max_width)
518            .field("resizable", &self.resizable)
519            .field("formatter", &self.formatter.as_ref().map(|_| "<formatter>"))
520            .field("align", &self.align)
521            .finish()
522    }
523}
524
525impl Default for ColumnDef {
526    fn default() -> Self {
527        Self {
528            name: String::new(),
529            width: 100.0,
530            min_width: 40.0,
531            max_width: 800.0,
532            resizable: true,
533            formatter: None,
534            align: None,
535        }
536    }
537}
538
539impl ColumnDef {
540    /// Create a column with the given display name and default settings.
541    pub fn new(name: impl Into<String>) -> Self {
542        Self {
543            name: name.into(),
544            ..Self::default()
545        }
546    }
547}
548
549// ── ColumnDefBuilder ─────────────────────────────────────────────────────────
550
551/// Fluent builder for [`ColumnDef`].
552pub struct ColumnDefBuilder {
553    inner: ColumnDef,
554}
555
556impl ColumnDefBuilder {
557    /// Start building a column with the given display name.
558    pub fn new(name: impl Into<String>) -> Self {
559        Self {
560            inner: ColumnDef::new(name),
561        }
562    }
563
564    /// Set the preferred display width (logical pixels).
565    pub fn width(mut self, w: f32) -> Self {
566        self.inner.width = w;
567        self
568    }
569
570    /// Set the minimum allowed width during resizing.
571    pub fn min_width(mut self, w: f32) -> Self {
572        self.inner.min_width = w;
573        self
574    }
575
576    /// Set the maximum allowed width during resizing.
577    pub fn max_width(mut self, w: f32) -> Self {
578        self.inner.max_width = w;
579        self
580    }
581
582    /// Mark this column as resizable (default).
583    pub fn resizable(mut self) -> Self {
584        self.inner.resizable = true;
585        self
586    }
587
588    /// Attach a custom cell formatter.
589    pub fn formatter(mut self, f: impl CellFormatter + 'static) -> Self {
590        self.inner.formatter = Some(Box::new(f));
591        self
592    }
593
594    /// Set the cell alignment for this column.
595    pub fn align(mut self, a: CellAlign) -> Self {
596        self.inner.align = Some(a);
597        self
598    }
599
600    /// Finalise and produce the [`ColumnDef`].
601    pub fn build(self) -> ColumnDef {
602        self.inner
603    }
604}
605
606// ── Aggregate helpers ─────────────────────────────────────────────────────────
607
608/// Sum all numeric cells in `cells`, treating [`Cell::Int`] and [`Cell::Float`]
609/// as `f64`.  Non-numeric and [`Cell::Empty`] cells are skipped.
610pub fn aggregate_sum(cells: &[Cell]) -> f64 {
611    cells
612        .iter()
613        .filter_map(|c| match c {
614            Cell::Int(n) => Some(*n as f64),
615            Cell::Float(f) => Some(*f),
616            _ => None,
617        })
618        .sum()
619}
620
621/// Count non-empty cells in `cells`.  [`Cell::Empty`] cells are excluded.
622pub fn aggregate_count(cells: &[Cell]) -> usize {
623    cells.iter().filter(|c| !matches!(c, Cell::Empty)).count()
624}
625
626/// Compute the arithmetic average of all numeric cells in `cells`.
627///
628/// Returns `None` if there are no numeric cells.
629pub fn aggregate_avg(cells: &[Cell]) -> Option<f64> {
630    let nums: Vec<f64> = cells
631        .iter()
632        .filter_map(|c| match c {
633            Cell::Int(n) => Some(*n as f64),
634            Cell::Float(f) => Some(*f),
635            _ => None,
636        })
637        .collect();
638    if nums.is_empty() {
639        None
640    } else {
641        Some(nums.iter().sum::<f64>() / nums.len() as f64)
642    }
643}
644
645// ── TableBuilder ─────────────────────────────────────────────────────────────
646
647/// Fluent builder for creating a [`Table`] with a concrete data source.
648pub struct TableBuilder<S: RowSource> {
649    source: S,
650    page_size: usize,
651    zebra_striping: bool,
652}
653
654impl<S: RowSource> TableBuilder<S> {
655    /// Start building a table backed by `source`.
656    pub fn new(source: S) -> Self {
657        Self {
658            source,
659            page_size: 50,
660            zebra_striping: false,
661        }
662    }
663
664    /// Set the number of rows per page for pagination.
665    pub fn page_size(mut self, size: usize) -> Self {
666        self.page_size = size;
667        self
668    }
669
670    /// Enable or disable zebra row striping.
671    pub fn zebra_striping(mut self, enabled: bool) -> Self {
672        self.zebra_striping = enabled;
673        self
674    }
675
676    /// Finalise and produce the [`Table`], propagating all builder settings.
677    pub fn build(self) -> Table<S> {
678        Table::new(self.source)
679            .with_page_size(self.page_size)
680            .with_zebra_striping(self.zebra_striping)
681    }
682}
683
684// ── Cell type tests ──────────────────────────────────────────────────────────
685
686#[cfg(test)]
687mod cell_type_tests {
688    use super::*;
689
690    #[test]
691    fn cell_date_epoch() {
692        assert_eq!(format!("{}", Cell::Date(0)), "1970-01-01");
693    }
694
695    #[test]
696    fn cell_date_one_day() {
697        assert_eq!(format!("{}", Cell::Date(86_400_000)), "1970-01-02");
698    }
699
700    #[test]
701    fn cell_date_leap_year_2000_02_29() {
702        // 2000-02-29: year 2000 IS a leap year.
703        // Days from 1970-01-01 to 2000-02-29:
704        //   1970-2000 = 30 years, with leap years 1972,76,80,84,88,92,96,2000 = 8 leaps
705        //   = 30*365 + 8 = 10958 days from 1970-01-01 to 2000-01-01 (exclusive)
706        //   Wait: 1970-01-01 to 2000-01-01 = 10957 days (both endpoints: day 0 is 1970-01-01)
707        //   Then Jan=31, Feb 1-29=29 more days → 31+29-1=59 days into 2000 = day 10957+59=11016
708        let ms = 11_016_i64 * 86_400_000_i64;
709        assert_eq!(format!("{}", Cell::Date(ms)), "2000-02-29");
710    }
711
712    #[test]
713    fn cell_date_2100_02_28() {
714        // 2100 is NOT a leap year (divisible by 100 but not 400).
715        // Days from 1970-01-01 to 2100-02-28:
716        // 130 years: 97 leap years (1972..2096 div by 4 minus 2100) = 31 leaps (1972,76,...,2096)
717        // Actually leap years 1972 to 2096 step 4 = (2096-1972)/4 + 1 = 32 leaps, minus 2100=0
718        // So from 1970 to 2100: 130*365 + 32 = 47450+32 = 47482 days from 1970-01-01 to 2100-01-01
719        // Then Jan=31, Feb 1-28=28 days → 31+28-1=58 days → day 47482+58=47540
720        let ms = 47_540_i64 * 86_400_000_i64;
721        assert_eq!(format!("{}", Cell::Date(ms)), "2100-02-28");
722    }
723
724    #[test]
725    fn cell_currency_display() {
726        let c = Cell::Currency {
727            amount_cents: 12345,
728            code: "EUR".to_string(),
729        };
730        assert_eq!(format!("{c}"), "123.45 EUR");
731    }
732
733    #[test]
734    fn cell_currency_negative() {
735        let c = Cell::Currency {
736            amount_cents: -100,
737            code: "USD".to_string(),
738        };
739        assert_eq!(format!("{c}"), "-1.00 USD");
740    }
741
742    #[test]
743    fn cell_currency_zero() {
744        let c = Cell::Currency {
745            amount_cents: 0,
746            code: "GBP".to_string(),
747        };
748        assert_eq!(format!("{c}"), "0.00 GBP");
749    }
750
751    #[test]
752    fn cell_link_shows_label() {
753        let c = Cell::Link {
754            label: "Click here".to_string(),
755            url: "https://example.com".to_string(),
756        };
757        assert_eq!(format!("{c}"), "Click here");
758    }
759
760    #[test]
761    fn cell_image_display() {
762        let c = Cell::Image {
763            uri: "https://example.com/img.png".to_string(),
764        };
765        assert_eq!(format!("{c}"), "[image: https://example.com/img.png]");
766    }
767
768    #[test]
769    fn cell_custom_delegates_to_render_str() {
770        #[derive(Debug)]
771        struct MyRenderer;
772        impl CellRenderer for MyRenderer {
773            fn render_str(&self) -> String {
774                "custom".to_string()
775            }
776        }
777        let c = Cell::Custom(Box::new(MyRenderer));
778        assert_eq!(format!("{c}"), "custom");
779    }
780}