Skip to main content

rosace_widgets/tree/
data_table.rs

1//! `DataTable` (D115/Phase 32 Step 1) — the data-grid RENDERING layer on
2//! top of [`Table`] (the layout primitive): header row with sort-direction
3//! indicators, optional row-selection checkboxes, row striping. Sorting
4//! itself is rendering-only (per `PHASE_32.md`'s Out of Scope note) — the
5//! app owns the actual sort/comparator and re-passes already-sorted rows;
6//! `on_sort` just reports which column/direction the user asked for.
7//! Virtualization (windowed rendering for huge row counts) is explicitly
8//! OUT OF SCOPE for this MVP — named, not silently dropped.
9//!
10//! Cells are plain text (the common tabular-data case) — richer per-cell
11//! widgets are future work. Rows are stored as `String`s (`Clone`, cheap)
12//! rather than built `BoxedWidget`s specifically so a fresh [`Table`] can
13//! be constructed independently in both `layout()` and `paint()` (the
14//! widget protocol calls them separately on a borrowed `&self`, and
15//! `Box<dyn Widget>` isn't `Clone` — building from owned strings sidesteps
16//! that rather than fighting it).
17
18use std::sync::Arc;
19use rosace_render::Color;
20use super::{BoxedWidget, LayoutCtx, PaintCtx, Widget};
21use super::table::{Table, TableColumn};
22use super::text::Text;
23use super::checkbox::Checkbox;
24use super::pressable::Pressable;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum SortDirection {
28    Ascending,
29    Descending,
30}
31
32/// One column definition: header label + width policy.
33#[derive(Clone)]
34pub struct DataTableColumn {
35    label: String,
36    sizing: TableColumn,
37}
38
39impl DataTableColumn {
40    pub fn new(label: impl Into<String>) -> Self {
41        Self { label: label.into(), sizing: TableColumn::auto() }
42    }
43    pub fn fixed_width(mut self, px: f32) -> Self { self.sizing = TableColumn::fixed(px); self }
44    pub fn flex(mut self, factor: f32) -> Self { self.sizing = TableColumn::flex(factor); self }
45}
46
47/// A data grid: typed columns + text row data, rendered via [`Table`] with
48/// a sortable header and optional selection checkboxes.
49pub struct DataTable {
50    columns: Vec<DataTableColumn>,
51    /// Row-major text cells — one `Vec<String>` per row, ideally
52    /// `columns.len()` entries each (short/long rows are handled by
53    /// `Table` itself: missing cells render empty, extras are ignored).
54    rows: Vec<Vec<String>>,
55    sort_col: Option<usize>,
56    sort_dir: SortDirection,
57    selectable: bool,
58    selected_rows: Vec<bool>,
59    row_striping: Option<Color>,
60    on_sort: Option<Arc<dyn Fn(usize, SortDirection) + Send + Sync>>,
61    on_select: Option<Arc<dyn Fn(usize, bool) + Send + Sync>>,
62}
63
64impl DataTable {
65    pub fn new(columns: Vec<DataTableColumn>) -> Self {
66        Self {
67            columns,
68            rows: Vec::new(),
69            sort_col: None,
70            sort_dir: SortDirection::Ascending,
71            selectable: false,
72            selected_rows: Vec::new(),
73            row_striping: None,
74            on_sort: None,
75            on_select: None,
76        }
77    }
78
79    /// Append one row of cell text (one string per column, left to right).
80    pub fn row(mut self, cells: Vec<impl Into<String>>) -> Self {
81        self.rows.push(cells.into_iter().map(Into::into).collect());
82        self
83    }
84    /// `count` rows, each built by calling `builder(i)` for its cell text
85    /// (one string per column, left to right) — the same convenience
86    /// constructor `Table::row_builder`/`Grid::builder` have.
87    pub fn row_builder<S: Into<String>>(mut self, count: usize, builder: impl Fn(usize) -> Vec<S>) -> Self {
88        for i in 0..count {
89            self.rows.push(builder(i).into_iter().map(Into::into).collect());
90        }
91        self
92    }
93
94    /// Which column currently shows a sort indicator, and its direction —
95    /// visual only; the app is responsible for actually sorting `rows`.
96    pub fn sorted_by(mut self, col: usize, dir: SortDirection) -> Self {
97        self.sort_col = Some(col);
98        self.sort_dir = dir;
99        self
100    }
101    pub fn row_striping(mut self, c: Color) -> Self { self.row_striping = Some(c); self }
102
103    /// Shows a leading checkbox column. `selected` must be as long as `rows`.
104    pub fn selectable(mut self, selected: Vec<bool>) -> Self {
105        self.selectable = true;
106        self.selected_rows = selected;
107        self
108    }
109
110    pub fn on_sort(mut self, f: impl Fn(usize, SortDirection) + Send + Sync + 'static) -> Self {
111        self.on_sort = Some(Arc::new(f));
112        self
113    }
114    pub fn on_select(mut self, f: impl Fn(usize, bool) + Send + Sync + 'static) -> Self {
115        self.on_select = Some(Arc::new(f));
116        self
117    }
118
119    /// Builds the underlying layout `Table` fresh — header row (with sort
120    /// arrows, clickable via `Pressable`) + one body row per entry (with an
121    /// optional leading checkbox) — delegating all column-sizing/striping/
122    /// divider work to `Table` rather than re-implementing it. Called
123    /// independently from both `layout()` and `paint()`.
124    fn build_table(&self) -> Table {
125        let mut table = Table::new();
126        if self.selectable {
127            table = table.column(TableColumn::fixed(32.0));
128        }
129        table = table.columns(self.columns.iter().map(|c| c.sizing).collect());
130
131        // Header row.
132        let mut header: Vec<BoxedWidget> = Vec::new();
133        if self.selectable {
134            header.push(Box::new(Text::new("")));
135        }
136        for (i, col) in self.columns.iter().enumerate() {
137            let label = if self.sort_col == Some(i) {
138                let arrow = match self.sort_dir { SortDirection::Ascending => "^", SortDirection::Descending => "v" };
139                format!("{} {arrow}", col.label)
140            } else {
141                col.label.clone()
142            };
143            let cell: BoxedWidget = match &self.on_sort {
144                Some(f) => {
145                    let f = f.clone();
146                    let next_dir = if self.sort_col == Some(i) && self.sort_dir == SortDirection::Ascending {
147                        SortDirection::Descending
148                    } else {
149                        SortDirection::Ascending
150                    };
151                    Box::new(Pressable::new(Text::new(label).weight(rosace_render::FontWeight::Bold), move || f(i, next_dir)))
152                }
153                None => Box::new(Text::new(label).weight(rosace_render::FontWeight::Bold)),
154            };
155            header.push(cell);
156        }
157        table = table.row(header);
158
159        // Body rows.
160        for (r, cells) in self.rows.iter().enumerate() {
161            let mut row_widgets: Vec<BoxedWidget> = Vec::new();
162            if self.selectable {
163                let checked = self.selected_rows.get(r).copied().unwrap_or(false);
164                let cb = Checkbox::new(checked);
165                let cb: BoxedWidget = match &self.on_select {
166                    Some(f) => {
167                        let f = f.clone();
168                        Box::new(cb.on_change(move |v| f(r, v)))
169                    }
170                    None => Box::new(cb),
171                };
172                row_widgets.push(cb);
173            }
174            for text in cells {
175                row_widgets.push(Box::new(Text::new(text.clone())));
176            }
177            table = table.row(row_widgets);
178        }
179
180        if let Some(c) = self.row_striping {
181            table = table.row_background(c);
182        }
183        table.cell_padding(8.0).divider(1.0)
184    }
185}
186
187impl Widget for DataTable {
188    fn layout(&self, ctx: &LayoutCtx) -> rosace_core::types::Size {
189        self.build_table().layout(ctx)
190    }
191
192    fn paint(&self, ctx: &mut PaintCtx) {
193        self.build_table().paint(ctx);
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use rosace_layout::Constraints;
201
202    #[test]
203    fn layout_delegates_to_underlying_table() {
204        let font = rosace_render::FontCache::embedded();
205        let theme = rosace_theme::built_in::dark_theme();
206        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
207        let dt = DataTable::new(vec![DataTableColumn::new("Name"), DataTableColumn::new("Qty")])
208            .row(vec!["Widget", "3"]);
209        let size = dt.layout(&ctx);
210        assert!(size.width > 0.0 && size.height > 0.0);
211    }
212
213    #[test]
214    fn sorted_by_toggles_direction_on_repeat_click() {
215        let dt = DataTable::new(vec![DataTableColumn::new("Name")])
216            .sorted_by(0, SortDirection::Ascending);
217        assert_eq!(dt.sort_col, Some(0));
218        assert_eq!(dt.sort_dir, SortDirection::Ascending);
219    }
220
221    #[test]
222    fn layout_is_stable_across_repeated_calls() {
223        // Guards the "build_table() called independently by layout/paint"
224        // design: two separate calls on the same borrowed value must agree.
225        let font = rosace_render::FontCache::embedded();
226        let theme = rosace_theme::built_in::dark_theme();
227        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
228        let dt = DataTable::new(vec![DataTableColumn::new("Name")]).row(vec!["A"]).row(vec!["B"]);
229        let s1 = dt.layout(&ctx);
230        let s2 = dt.layout(&ctx);
231        assert_eq!((s1.width, s1.height), (s2.width, s2.height));
232    }
233}