Skip to main content

frus_widgets/
datatable.rs

1//! [`DataTable`]: a [`Table`](crate::Table) that **sorts its own data**.
2//!
3//! The base `Table` is purely controlled: it only emits the column clicked (`on_sort`) and
4//! shows the indicator for the `sorted` state it is handed — it is the application that
5//! reorders its rows. `DataTable` encapsulates that **display** reordering: it is given the
6//! raw rows and the sort state `(column, direction)`, and rebuilds a `Table` with the rows
7//! already **sorted** (numeric-aware, case-insensitive) and the indicator. The model stays
8//! controlled — the sort state lives in the app — but the sorting logic is no longer copied
9//! out by hand.
10
11use std::cmp::Ordering;
12use std::rc::Rc;
13
14use frus_core::{Rect, Scene};
15use frus_layout::{Align, Style};
16
17use crate::flex::Flex;
18use crate::interaction::Status;
19use crate::pagination::Pagination;
20use crate::segmented::SegmentedButton;
21use crate::table::Table;
22
23/// An application's own ordering for one column — dates, amounts, priorities — where
24/// comparing the rendered strings would sort them wrongly.
25type Comparator = Rc<dyn Fn(&str, &str) -> Ordering>;
26
27/// The buttons of the bulk-action bar, built on demand while a selection stands.
28type BulkActions<Msg> = Rc<dyn Fn() -> Vec<Box<dyn Widget<Msg>>>>;
29
30/// A **lazy row source**: how many rows there are, and how to build one — see
31/// [`DataTable::lazy`].
32type LazyRows = (usize, Rc<dyn Fn(usize) -> Vec<String>>);
33use crate::text::Text;
34use crate::textinput::TextField;
35use crate::theme::Theme;
36use crate::widget::Widget;
37
38/// Compares two text cells: **numerically** when both read as numbers, otherwise lexically
39/// and **case-insensitively**. The basis of a [`DataTable`]'s sorting.
40pub fn compare_cells(a: &str, b: &str) -> Ordering {
41    match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
42        (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
43        _ => a.to_lowercase().cmp(&b.to_lowercase()),
44    }
45}
46
47/// `true` when a cell of `row` **contains** `query` (a **case-insensitive** substring). An
48/// empty or blank query lets everything through. The basis of a [`DataTable`]'s filter;
49/// reusable outside the widget (a reducer can filter its data the same way).
50pub fn row_matches(row: &[String], query: &str) -> bool {
51    let q = query.trim().to_lowercase();
52    if q.is_empty() {
53        return true;
54    }
55    row.iter().any(|cell| cell.to_lowercase().contains(&q))
56}
57
58/// Returns a **copy** of `rows` sorted by column `col` (`ascending` = increasing) through
59/// [`compare_cells`]. A missing cell (a row too short) counts as empty. Reusable outside the
60/// widget: a reducer can sort its data in exactly the same way.
61pub fn sort_rows(rows: &[Vec<String>], col: usize, ascending: bool) -> Vec<Vec<String>> {
62    let empty = String::new();
63    let mut out = rows.to_vec();
64    out.sort_by(|a, b| {
65        let ord = compare_cells(a.get(col).unwrap_or(&empty), b.get(col).unwrap_or(&empty));
66        if ascending {
67            ord
68        } else {
69            ord.reverse()
70        }
71    });
72    out
73}
74
75/// The number of pages for `len` rows cut into slices of `per_page` (at least **1**).
76pub fn page_count(len: usize, per_page: usize) -> usize {
77    let per = per_page.max(1);
78    (len.div_ceil(per)).max(1)
79}
80
81/// The **slice** of rows of page `current` (1-indexed) with a size of `per_page`. The page is
82/// brought back into `[1, page_count]` if it overflows. Reusable outside the widget.
83pub fn page_rows(rows: &[Vec<String>], current: usize, per_page: usize) -> Vec<Vec<String>> {
84    let per = per_page.max(1);
85    let current = current.clamp(1, page_count(rows.len(), per));
86    let start = (current - 1) * per;
87    let end = (start + per).min(rows.len());
88    rows[start..end].to_vec()
89}
90
91/// The "N-M of T" line under the current slice (`0 of 0` when empty) — milestone 236.
92///
93/// **The sentence comes from the reader's language**, and so do the numbers in it: a comma
94/// groups a thousand in English and a space does in French, and the word in the middle is
95/// not in the same place in every language. See
96/// [`Localizations::page_range_label`](crate::Localizations::page_range_label).
97pub fn page_range_label(current: usize, per_page: usize, total: usize) -> String {
98    let per = per_page.max(1);
99    let current = current.clamp(1, page_count(total, per));
100    let start = (current - 1) * per + 1;
101    let end = (current * per).min(total);
102    crate::localizations::of().page_range_label(start, end, total)
103}
104
105/// A **text** data table that sorts its own rows according to the sort state supplied, then
106/// delegates the rendering to a [`Table`](crate::Table).
107///
108/// ```
109/// use frus_widgets::DataTable;
110/// let rows = vec![vec!["Bob".to_string(), "9".to_string()], vec!["Ada".to_string(), "10".to_string()]];
111/// let table: DataTable = DataTable::new(["Name", "Score"], rows).sorted(1, true);
112/// ```
113pub struct DataTable<Msg = crate::callback::Callback> {
114    headers: Vec<String>,
115    rows: Vec<Vec<String>>,
116    /// **Rows asked for one at a time**, for a set too big to hold: `(how many, how to
117    /// build one)`. See [`DataTable::lazy`]. `None` is the ordinary table, whose rows are
118    /// in `rows`.
119    lazy: Option<LazyRows>,
120    widths: Vec<f32>,
121    sort: Option<(usize, bool)>,
122    on_sort: Option<Rc<dyn Fn(usize) -> Msg>>,
123    /// Pagination: `(current page, 1-indexed; page size)` — milestone 233.
124    page: Option<(usize, usize)>,
125    on_page: Option<Rc<dyn Fn(usize) -> Msg>>,
126    /// The page sizes offered + the callback on change (the footer's selector) — milestone 236.
127    page_sizes: Vec<usize>,
128    on_page_size: Option<Rc<dyn Fn(usize) -> Msg>>,
129    /// Row selection (milestone 239): a callback on click plus the highlighted rows. The indices
130    /// are those of the **source rows** (before sorting and pagination) — the `DataTable` does
131    /// the translation against the displayed slice, as it already does for sorting and paging.
132    on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
133    selected: Vec<usize>,
134    /// A **custom** comparator per column (milestone 240): `None` = the default sort
135    /// ([`compare_cells`]). It allows ordering cells the default sorts badly — formatted
136    /// dates, amounts ("$1.2M"), priorities ("High"/"Medium"/"Low").
137    comparators: Vec<Option<Comparator>>,
138    /// **Multiple** selection (milestone 241): a column of checkboxes. `on_check(source_row)`
139    /// toggles one row, and `on_check_all` toggles the header box. The checked state follows
140    /// [`selected`](Self::selected), with the same source indices.
141    on_check: Option<Rc<dyn Fn(usize) -> Msg>>,
142    on_check_all: Option<Msg>,
143    /// Search (milestone 242): the current query + a callback on typing. When `on_query` is set,
144    /// a search field caps the table and the source rows are **filtered** ([`row_matches`])
145    /// before sorting and pagination — all in source indices (selection and boxes unchanged).
146    query: Option<String>,
147    on_query: Option<Rc<dyn Fn(String) -> Msg>>,
148    /// The **bulk actions** bar (milestone 243): a factory of action widgets (buttons…), called
149    /// again on every rebuild. Rendered **above** the table **only** when rows are selected,
150    /// preceded by an "N selected". The app wires up its buttons (variants, messages).
151    bulk_actions: Option<BulkActions<Msg>>,
152    /// The **empty state**'s text (milestone 244): shown centred, under the header, when no row
153    /// is visible (empty data, or a filter with no result). Overridable through
154    /// [`empty_text`](DataTable::empty_text).
155    empty_text: String,
156    /// The rendering: the `Table` (sorted and paginated rows), optionally capped by a footer
157    /// (the slice label + `Pagination` + the size selector) beneath it.
158    ///
159    /// **Composed on the first ask, not on every builder call.** A table is built by a
160    /// chain of eight or nine methods, each of which used to compose the whole thing again
161    /// and throw the last one away — nine times over the rows for one table. It matters for
162    /// a lazy source, where composing means *building the rows*: `DataTable::lazy(…, 4_000,
163    /// …).paginated(2, 10, …)` would have asked for four thousand rows and then for ten.
164    inner: std::cell::OnceCell<Box<dyn Widget<Msg>>>,
165}
166
167impl<Msg: Clone + 'static> DataTable<Msg> {
168    /// Creates a table from its **headers** and its **rows** (text). Without a sort state, the
169    /// rows are shown in the order supplied.
170    pub fn new(
171        headers: impl IntoIterator<Item = impl Into<String>>,
172        rows: impl IntoIterator<Item = Vec<String>>,
173    ) -> Self {
174        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
175        let cols = headers.len().max(1);
176        let mut me = Self {
177            headers,
178            rows: rows.into_iter().collect(),
179            widths: vec![0.0; cols],
180            sort: None,
181            on_sort: None,
182            page: None,
183            on_page: None,
184            page_sizes: Vec::new(),
185            on_page_size: None,
186            on_select: None,
187            selected: Vec::new(),
188            lazy: None,
189            comparators: Vec::new(),
190            on_check: None,
191            on_check_all: None,
192            query: None,
193            on_query: None,
194            bulk_actions: None,
195            empty_text: crate::localizations::of().no_results_label().to_string(),
196            inner: std::cell::OnceCell::new(),
197        };
198        me.rebuild();
199        me
200    }
201
202    /// A table whose rows are **asked for one at a time**: `count` of them, and
203    /// `row(index)` to build one.
204    ///
205    /// For a set too big to hold twice. An ordinary table is handed every row, so showing
206    /// ten of four thousand builds four thousand: this builds ten, and asks only for the
207    /// ones the current page shows.
208    ///
209    /// **What a lazy table gives up is the work it cannot do without the rows.** Sorting,
210    /// searching and the row filter belong to whoever owns the data:
211    ///
212    /// - [`sorted`](Self::sorted) still draws the arrow and [`on_sort`](Self::on_sort)
213    ///   still fires, but the table does **not** reorder anything. The application sorts
214    ///   its own set and hands back rows in the new order.
215    /// - [`searchable`](Self::searchable) still draws the field and still emits, and the
216    ///   application filters — which is what changes `count`.
217    /// - Every index — [`selected`](Self::selected), `on_select_row`, the checkboxes — is
218    ///   an index **into the set as it stands**, the one `row` was asked for. In an
219    ///   ordinary table it is the index of the row as it was given, before sorting; here
220    ///   there is no *before*, because the table never saw the other rows.
221    ///
222    /// ```ignore
223    /// DataTable::lazy(["Name", "Score"], people.len(), move |i| {
224    ///     vec![people[i].name.clone(), people[i].score.to_string()]
225    /// })
226    /// .paginated(page, 10, Msg::Page)
227    /// ```
228    pub fn lazy(
229        headers: impl IntoIterator<Item = impl Into<String>>,
230        count: usize,
231        row: impl Fn(usize) -> Vec<String> + 'static,
232    ) -> Self {
233        let mut table = Self::new(headers, Vec::new());
234        table.lazy = Some((count, Rc::new(row)));
235        table.rebuild();
236        table
237    }
238
239    /// The **fixed** width of each column, in pixels (`0` or less = a flexible column).
240    pub fn column_widths(mut self, widths: &[f32]) -> Self {
241        for (i, w) in widths.iter().enumerate().take(self.widths.len()) {
242            self.widths[i] = *w;
243        }
244        self.rebuild();
245        self
246    }
247
248    /// The sorted column and its direction (`true` = ascending): it **sorts** the rows for
249    /// display and shows the direction indicator.
250    pub fn sorted(mut self, column: usize, ascending: bool) -> Self {
251        self.sort = Some((column, ascending));
252        self.rebuild();
253        self
254    }
255
256    /// Makes the headers **clickable**: `on_sort(column)` on click (the application then flips
257    /// the direction and passes `sorted(...)` back).
258    pub fn on_sort<R: crate::callback::IntoMsg<Msg>>(
259        mut self,
260        on_sort: impl Fn(usize) -> R + 'static,
261    ) -> Self {
262        self.on_sort = Some(Rc::new(crate::callback::handler1(on_sort)));
263        self.rebuild();
264        self
265    }
266
267    /// **Paginates** the table: it shows only the slice of page `current` (1-indexed) with a
268    /// size of `per_page`, and places a [`Pagination`](crate::Pagination) selector beneath it.
269    /// `on_page(page)` when a page is clicked (the application updates `current`). The slicing
270    /// follows the **sort**.
271    ///
272    /// **A page that no longer exists shows the last one that does.** A filter that
273    /// shortens the set past the current page is the everyday way there — type a letter
274    /// into the search field on page 40 of 400 and there are two pages left — and the
275    /// alternatives are worse than the clamp: an empty table with a pager under it says
276    /// the filter matched nothing, which is a lie, and jumping to page 1 loses the reader's
277    /// place for a filter that shortened the set by one row. The label, the pager and the
278    /// slice all say the same clamped number, so nothing on screen disagrees with anything
279    /// else; the application's own `current` catches up on the next click.
280    pub fn paginated(
281        mut self,
282        current: usize,
283        per_page: usize,
284        on_page: impl Fn(usize) -> Msg + 'static,
285    ) -> Self {
286        self.page = Some((current.max(1), per_page.max(1)));
287        self.on_page = Some(Rc::new(on_page));
288        self.rebuild();
289        self
290    }
291
292    /// Adds a **page size selector** (a `SegmentedButton` of the `sizes` offered) to the
293    /// footer. `on_page_size(size)` on change (the app updates the size and, usually, returns
294    /// to page 1). It has no effect if the table is not paginated.
295    pub fn page_sizes(
296        mut self,
297        sizes: &[usize],
298        on_page_size: impl Fn(usize) -> Msg + 'static,
299    ) -> Self {
300        self.page_sizes = sizes.iter().copied().filter(|&s| s > 0).collect();
301        self.on_page_size = Some(Rc::new(on_page_size));
302        self.rebuild();
303        self
304    }
305
306    /// Makes the rows **clickable**: `on_select_row(source_row)` when a row is clicked. The
307    /// index passed is that of the **source row** (before sorting and pagination) — the
308    /// `DataTable` translates the displayed position into the original index.
309    pub fn on_select_row<R: crate::callback::IntoMsg<Msg>>(
310        mut self,
311        on_select: impl Fn(usize) -> R + 'static,
312    ) -> Self {
313        self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
314        self.rebuild();
315        self
316    }
317
318    /// The **selected** rows (highlighted, or checked), designated by their **source row**
319    /// index. The `DataTable` only highlights (and checks) those visible in the current
320    /// slice.
321    pub fn selected(mut self, rows: &[usize]) -> Self {
322        self.selected = rows.to_vec();
323        self.rebuild();
324        self
325    }
326
327    /// Enables **multiple selection**: a column of checkboxes capped by a "check all".
328    /// `on_check(source_row)` toggles one row (the **source row** index, translated from the
329    /// displayed position), and `on_check_all` toggles the header box. The checked state
330    /// reflects [`selected`](Self::selected). It combines with
331    /// [`on_select_row`](Self::on_select_row): the box handles group selection, and a click
332    /// on the row's body stays a row click.
333    pub fn checkboxes(
334        mut self,
335        on_check: impl Fn(usize) -> Msg + 'static,
336        on_check_all: Msg,
337    ) -> Self {
338        self.on_check = Some(Rc::new(on_check));
339        self.on_check_all = Some(on_check_all);
340        self.rebuild();
341        self
342    }
343
344    /// Makes the table **searchable**: a search field (with value `query`) caps the table, and
345    /// the source rows are **filtered** ([`row_matches`], a case-insensitive substring across
346    /// every column) before sorting and pagination. `on_query(text)` on each keystroke (the
347    /// application updates `query` and usually returns to page 1). The filter acts upstream
348    /// of the sort, the page **and** the selection: boxes and highlighting stay in source
349    /// indices, over the visible subset.
350    pub fn searchable(
351        mut self,
352        query: impl Into<String>,
353        on_query: impl Fn(String) -> Msg + 'static,
354    ) -> Self {
355        self.query = Some(query.into());
356        self.on_query = Some(Rc::new(on_query));
357        self.rebuild();
358        self
359    }
360
361    /// Adds a **bulk actions bar** above the table, visible **only** when rows are
362    /// [`selected`](Self::selected). The `make` factory produces the action widgets (typically
363    /// [`Button`](crate::Button)s — the app chooses variants and messages); the bar precedes
364    /// them with an "N selected" label. Called again on every rebuild (fresh widgets). The
365    /// number shown is that of the **selected** rows, across all pages.
366    pub fn bulk_actions(mut self, make: impl Fn() -> Vec<Box<dyn Widget<Msg>>> + 'static) -> Self {
367        self.bulk_actions = Some(Rc::new(make));
368        self.rebuild();
369        self
370    }
371
372    /// Overrides the **empty state**'s text (default "No results") — "No people match your
373    /// search", say. Shown centred under the header when no row is visible (milestone 244).
374    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
375        self.empty_text = text.into();
376        self.rebuild();
377        self
378    }
379
380    /// Gives column `col` a **custom comparator**: `cmp(a, b)` orders two of its (text) cells.
381    /// It replaces the default sort ([`compare_cells`]) for that column — useful when the
382    /// values sort badly as they stand: formatted dates ("Mar 2024"), amounts ("$1.2M"),
383    /// priorities ("High"/"Medium"/"Low"). The direction (`sorted(_, ascending)`) applies on
384    /// top (the comparator defines the **ascending** order).
385    pub fn sort_with(mut self, col: usize, cmp: impl Fn(&str, &str) -> Ordering + 'static) -> Self {
386        if self.comparators.len() <= col {
387            self.comparators.resize_with(col + 1, || None);
388        }
389        self.comparators[col] = Some(Rc::new(cmp));
390        self.rebuild();
391        self
392    }
393
394    /// The order of the **source row indices** after **filtering** (search) then sorting
395    /// (stable); the filtered identity when there is no sort. The sort uses the column's
396    /// **custom** comparator when it has one, otherwise [`compare_cells`].
397    /// One row: held, or built on demand. **Only the rows of the current page** ever get
398    /// here, which is what a lazy source buys.
399    fn row(&self, index: usize) -> Vec<String> {
400        match &self.lazy {
401            Some((_, build)) => build(index),
402            None => self.rows.get(index).cloned().unwrap_or_default(),
403        }
404    }
405
406    fn sorted_order(&self) -> Vec<usize> {
407        // **A lazy table is already in the order it is going to be shown in.** Neither the
408        // filter nor the sort can run here: both need every row, and the whole point of a
409        // lazy source is that the table has not got them. The application does both, and
410        // the result arrives as a different `count` and a different `row`.
411        if let Some((count, _)) = &self.lazy {
412            return (0..*count).collect();
413        }
414        // The search filter, upstream: it keeps only the matching source rows.
415        let mut order: Vec<usize> = (0..self.rows.len())
416            .filter(|&i| match &self.query {
417                Some(q) => row_matches(&self.rows[i], q),
418                None => true,
419            })
420            .collect();
421        if let Some((col, asc)) = self.sort {
422            let empty = String::new();
423            let custom = self.comparators.get(col).and_then(|c| c.as_ref());
424            order.sort_by(|&a, &b| {
425                let (x, y) = (
426                    self.rows[a].get(col).unwrap_or(&empty),
427                    self.rows[b].get(col).unwrap_or(&empty),
428                );
429                let ord = match custom {
430                    Some(cmp) => cmp(x, y),
431                    None => compare_cells(x, y),
432                };
433                if asc {
434                    ord
435                } else {
436                    ord.reverse()
437                }
438            });
439        }
440        order
441    }
442
443    /// (Re)builds the rendering: rows sorted according to `sort`, cut to page `page` where
444    /// applicable, in a `Table` (headers, widths, indicator) optionally capped by a
445    /// `Pagination` beneath.
446    /// Throws away the rendering, so the next ask composes it again. Cheap: every builder
447    /// method calls it, and the work happens once, when someone finally looks.
448    fn rebuild(&mut self) {
449        self.inner.take();
450    }
451
452    /// The rendering, composed if it has not been.
453    fn built(&self) -> &dyn Widget<Msg> {
454        self.inner.get_or_init(|| self.compose()).as_ref()
455    }
456
457    fn compose(&self) -> Box<dyn Widget<Msg>> {
458        // The reasoning is in terms of **source row indices** (sorted, then sliced): that
459        // preserves each row's original identity through the sort and the pagination, so the
460        // selection can be translated (displayed index ↔ source index) both ways.
461        let order = self.sorted_order();
462        let total = order.len();
463        let page_indices: Vec<usize> = match self.page {
464            Some((current, per)) => {
465                let per = per.max(1);
466                let current = current.clamp(1, page_count(total, per));
467                let start = (current - 1) * per;
468                let end = (start + per).min(total);
469                order[start..end].to_vec()
470            }
471            None => order.clone(),
472        };
473        let hrefs: Vec<&str> = self.headers.iter().map(|s| s.as_str()).collect();
474        let mut t = Table::new(self.headers.len().max(1)).header(&hrefs);
475        for &i in &page_indices {
476            let row = self.row(i);
477            let refs: Vec<&str> = row.iter().map(|s| s.as_str()).collect();
478            t = t.row(&refs);
479        }
480        if self.widths.iter().any(|w| *w > 0.0) {
481            t = t.column_widths(&self.widths);
482        }
483        if let Some(f) = &self.on_sort {
484            let f = f.clone();
485            t = t.on_sort(move |c| f(c));
486        }
487        if let Some((col, asc)) = self.sort {
488            t = t.sorted(col, asc);
489        }
490        // Selection: `Table` reasons in **displayed positions** (0..slice). The click is wired
491        // to return the **source** index, and the selected rows are translated source → position.
492        if let Some(f) = &self.on_select {
493            let f = f.clone();
494            let indices = page_indices.clone();
495            t = t.on_select_row(move |d| f(indices.get(d).copied().unwrap_or(d)));
496        }
497        // Multiple selection: the same displayed position → source index translation for the boxes.
498        if let (Some(f), Some(all)) = (&self.on_check, &self.on_check_all) {
499            let f = f.clone();
500            let indices = page_indices.clone();
501            let all = all.clone();
502            t = t.checkboxes(move |d| f(indices.get(d).copied().unwrap_or(d)), all);
503        }
504        if !self.selected.is_empty() {
505            let display_sel: Vec<usize> = page_indices
506                .iter()
507                .enumerate()
508                .filter(|(_, orig)| self.selected.contains(orig))
509                .map(|(d, _)| d)
510                .collect();
511            if !display_sel.is_empty() {
512                t = t.selected(&display_sel);
513            }
514        }
515        // The empty state (empty data, or a filter with no result): header + a centred message,
516        // **without** a footer (a "0 of 0" pager under an empty body adds nothing). The
517        // message is overridable.
518        let block: Box<dyn Widget<Msg>> = if total == 0 {
519            let message = Text::new(self.empty_text.clone()).size(15.0);
520            let empty = Flex::column()
521                .align(Align::Center)
522                .padding(24.0)
523                .child(message);
524            Box::new(Flex::column().gap(8.0).child(t).child(empty))
525        } else {
526            match (self.page, &self.on_page) {
527                (Some((current, per)), Some(on_page)) => {
528                    let pages = page_count(total, per);
529                    let current = current.clamp(1, pages);
530                    // The "N–M of T" label of the current slice (milestone 236).
531                    let label = Text::new(page_range_label(current, per, total)).size(13.0);
532                    let on_page = on_page.clone();
533                    let pager = Pagination::new(current, pages, move |p| on_page(p));
534                    let mut footer = Flex::row()
535                        .align(Align::Center)
536                        .gap(12.0)
537                        .child(label)
538                        .child(Flex::row().flex(1.0))
539                        .child(pager);
540                    // The page size selector, when offered (milestone 236).
541                    if let (Some(on_size), false) = (&self.on_page_size, self.page_sizes.is_empty())
542                    {
543                        let sizes = self.page_sizes.clone();
544                        let sel = sizes.iter().position(|&s| s == per).unwrap_or(0);
545                        let on_size = on_size.clone();
546                        // A row of numbers in a table's footer: the checkmark would take
547                        // more room than the digits it sits beside, and which one is chosen
548                        // is already plain from the fill.
549                        let mut seg = SegmentedButton::new(sel, move |i| on_size(sizes[i]))
550                            .show_selected_icon(false)
551                            .padding(10.0);
552                        for s in &self.page_sizes {
553                            seg = seg.segment(s.to_string());
554                        }
555                        // **Named**, because a row of bare numbers in a footer is a row of
556                        // bare numbers: 10 · 25 · 50 says nothing about what it counts.
557                        footer = footer
558                            .child(
559                                Text::new(crate::localizations::of().rows_per_page_label())
560                                    .size(13.0),
561                            )
562                            .child(seg);
563                    }
564                    Box::new(Flex::column().gap(12.0).child(t).child(footer))
565                }
566                _ => Box::new(t),
567            }
568        };
569        // Bulk actions: a bar above the table, only when a selection exists.
570        let mut block = block;
571        if let Some(make) = &self.bulk_actions {
572            if !self.selected.is_empty() {
573                let label = Text::new(
574                    crate::localizations::of().selected_row_count_label(self.selected.len()),
575                )
576                .size(14.0);
577                let mut bar = Flex::row()
578                    .align(Align::Center)
579                    .gap(8.0)
580                    .child(label)
581                    .child(Flex::row().flex(1.0));
582                for w in make() {
583                    bar = bar.child(w);
584                }
585                block = Box::new(Flex::column().gap(12.0).child(bar).child(block));
586            }
587        }
588        // Search: it caps the table with a field (otherwise the block is kept as is).
589        if let Some(on_query) = &self.on_query {
590            let on_query = on_query.clone();
591            let field = TextField::new(self.query.clone().unwrap_or_default())
592                .placeholder(crate::localizations::of().search_field_label())
593                .width(240.0)
594                .on_input(move |s| on_query(s));
595            Box::new(Flex::column().gap(12.0).child(field).child(block))
596        } else {
597            block
598        }
599    }
600}
601
602impl<Msg: Clone + 'static> Widget<Msg> for DataTable<Msg> {
603    fn style(&self) -> Style {
604        self.built().style()
605    }
606
607    fn style_themed(&self, theme: &Theme) -> Style {
608        self.built().style_themed(theme)
609    }
610
611    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
612        self.built().children()
613    }
614
615    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
616
617    fn on_click(&self) -> Option<Msg> {
618        None
619    }
620
621    fn stack(&self) -> bool {
622        self.built().stack()
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    fn sample() -> Vec<Vec<String>> {
631        vec![
632            vec!["Bob".to_string(), "9".to_string()],
633            vec!["alice".to_string(), "10".to_string()],
634            vec!["Carol".to_string(), "2".to_string()],
635        ]
636    }
637
638    #[test]
639    fn sort_rows_is_numeric_aware_and_case_insensitive() {
640        let rows = sample();
641        // Column 1 is **numeric**: 2 < 9 < 10 (not the lexical order "10" < "2" < "9").
642        let by_num = sort_rows(&rows, 1, true);
643        assert_eq!(
644            by_num.iter().map(|r| r[1].as_str()).collect::<Vec<_>>(),
645            ["2", "9", "10"]
646        );
647        // Column 0 is **text**, case-insensitive: alice < Bob < Carol.
648        let by_name = sort_rows(&rows, 0, true);
649        assert_eq!(
650            by_name.iter().map(|r| r[0].as_str()).collect::<Vec<_>>(),
651            ["alice", "Bob", "Carol"]
652        );
653        // **Descending** direction: the order is reversed.
654        let desc = sort_rows(&rows, 1, false);
655        assert_eq!(
656            desc.iter().map(|r| r[1].as_str()).collect::<Vec<_>>(),
657            ["10", "9", "2"]
658        );
659    }
660
661    #[test]
662    fn compare_cells_prefers_numbers_then_text() {
663        assert_eq!(compare_cells("2", "10"), Ordering::Less, "numeric: 2 < 10");
664        assert_eq!(
665            compare_cells("Bob", "alice"),
666            Ordering::Greater,
667            "text: b > a (case-insensitive)"
668        );
669    }
670
671    #[test]
672    fn data_table_builds_a_non_empty_tree() {
673        let dt = DataTable::<()>::new(["Name", "Score"], sample())
674            .column_widths(&[120.0, 80.0])
675            .sorted(1, true)
676            .on_sort(|_| ());
677        // Header + rows → a non-empty render tree.
678        assert!(
679            !Widget::<()>::children(&dt).is_empty(),
680            "the DataTable produces a tree"
681        );
682    }
683
684    #[test]
685    fn pagination_slices_rows_and_counts_pages() {
686        let rows: Vec<Vec<String>> = (1..=7).map(|i| vec![i.to_string()]).collect();
687        assert_eq!(page_count(7, 3), 3, "7 rows / 3 = 3 pages");
688        assert_eq!(page_count(0, 3), 1, "at least one page");
689        let col0 = |rs: Vec<Vec<String>>| rs.iter().map(|r| r[0].clone()).collect::<Vec<_>>();
690        // Page 1: 1,2,3; page 3: 7 (the last, partial one).
691        assert_eq!(col0(page_rows(&rows, 1, 3)), ["1", "2", "3"]);
692        assert_eq!(col0(page_rows(&rows, 3, 3)), ["7"]);
693        // An out-of-bounds page is brought back into the interval.
694        assert_eq!(col0(page_rows(&rows, 99, 3)), ["7"]);
695    }
696
697    #[test]
698    fn data_table_with_pagination_builds_table_and_pager() {
699        let rows: Vec<Vec<String>> = (1..=10)
700            .map(|i| vec![format!("R{i}"), i.to_string()])
701            .collect();
702        let dt = DataTable::<()>::new(["Name", "Score"], rows)
703            .sorted(1, true)
704            .paginated(1, 4, |_| ());
705        // inner = the column [table, footer] → two children.
706        assert_eq!(
707            Widget::<()>::children(&dt).len(),
708            2,
709            "table + pied de pagination"
710        );
711    }
712
713    #[test]
714    fn page_range_label_describes_the_slice() {
715        assert_eq!(page_range_label(1, 3, 7), "1\u{2013}3 of 7");
716        assert_eq!(page_range_label(2, 3, 7), "4\u{2013}6 of 7");
717        assert_eq!(
718            page_range_label(3, 3, 7),
719            "7\u{2013}7 of 7",
720            "the last, partial page"
721        );
722        assert_eq!(page_range_label(1, 3, 0), "0 of 0", "empty");
723        assert_eq!(
724            page_range_label(99, 3, 7),
725            "7\u{2013}7 of 7",
726            "an out-of-bounds page is brought back"
727        );
728    }
729
730    /// Collects, in tree order, every non-null `on_click` message of a subtree.
731    fn collect_clicks(w: &dyn Widget<usize>, out: &mut Vec<usize>) {
732        if let Some(m) = w.on_click() {
733            out.push(m);
734        }
735        for c in w.children() {
736            collect_clicks(c.as_ref(), out);
737        }
738    }
739
740    #[test]
741    fn selection_click_reports_the_source_row_through_sort_and_page() {
742        // Three rows, with a numeric key in column 1. Ascending sort → source order [1, 2, 0].
743        let rows = vec![
744            vec!["A".to_string(), "3".to_string()],
745            vec!["B".to_string(), "1".to_string()],
746            vec!["C".to_string(), "2".to_string()],
747        ];
748        let make = |page: usize| -> DataTable<usize> {
749            DataTable::new(["N", "K"], rows.clone())
750                .sorted(1, true)
751                .paginated(page, 2, |_| 0)
752                .on_select_row(|i| i)
753        };
754        // `children()[0]` = the Table; `[1]` = the footer (the pager), which is ignored.
755        let clicks_of = |dt: &DataTable<usize>| {
756            let mut v = Vec::new();
757            collect_clicks(Widget::<usize>::children(dt)[0].as_ref(), &mut v);
758            v.dedup(); // one clickable cell per column → consecutive repeats
759            v
760        };
761        // Page 1 (size 2) of the sorted rows [1, 2, 0]: the click returns **source** index 1, then 2.
762        assert_eq!(
763            clicks_of(&make(1)),
764            vec![1, 2],
765            "the click returns the source row's index"
766        );
767        // Page 2: the last sorted row, source index 0 — pagination does not alter identity.
768        assert_eq!(
769            clicks_of(&make(2)),
770            vec![0],
771            "the translation survives pagination"
772        );
773    }
774
775    #[test]
776    fn empty_filter_drops_rows_and_pager() {
777        // Two rows, with a "zzz" filter that matches nothing → the empty state: no clickable row,
778        // and the **pagination footer is removed** (otherwise its single page button would
779        // emit `0`).
780        let rows = vec![vec!["Ada".to_string()], vec!["Bob".to_string()]];
781        let dt: DataTable<usize> = DataTable::new(["N"], rows)
782            .searchable("zzz", |_| 0)
783            .paginated(1, 5, |_| 0)
784            .on_select_row(|i| i);
785        let mut clicks = Vec::new();
786        for c in Widget::<usize>::children(&dt) {
787            collect_clicks(c.as_ref(), &mut clicks);
788        }
789        assert!(
790            clicks.is_empty(),
791            "neither a clickable row nor a pager when the filter matches nothing"
792        );
793        // The tree stays non-empty: the header + the empty-state message.
794        assert!(
795            !Widget::<usize>::children(&dt).is_empty(),
796            "header + message rendered"
797        );
798    }
799
800    #[test]
801    fn bulk_actions_bar_shows_only_with_a_selection() {
802        use crate::button::Button;
803        let rows = vec![vec!["A".to_string()], vec!["B".to_string()]];
804        // `777` = the message of one bar action (a sentinel).
805        let make = |sel: &[usize]| -> DataTable<usize> {
806            DataTable::new(["N"], rows.clone())
807                .checkboxes(|i| i, 900usize)
808                .selected(sel)
809                .bulk_actions(|| vec![Box::new(Button::new("Clear").on_press(777usize)) as Box<dyn Widget<usize>>])
810        };
811        let has_action = |dt: &DataTable<usize>| {
812            let mut v = Vec::new();
813            for c in Widget::<usize>::children(dt) {
814                collect_clicks(c.as_ref(), &mut v);
815            }
816            v.contains(&777)
817        };
818        assert!(!has_action(&make(&[])), "no bar without a selection");
819        assert!(
820            has_action(&make(&[0])),
821            "the action bar appears as soon as a row is selected"
822        );
823    }
824
825    #[test]
826    fn row_matches_is_case_insensitive_substring_over_all_cells() {
827        let row = vec!["Ada Lovelace".to_string(), "Engineer".to_string()];
828        assert!(
829            row_matches(&row, ""),
830            "an empty query lets everything through"
831        );
832        assert!(
833            row_matches(&row, "  "),
834            "a blank query lets everything through"
835        );
836        assert!(row_matches(&row, "ENGIN"), "case-insensitive, a substring");
837        assert!(row_matches(&row, "love"), "another column");
838        assert!(!row_matches(&row, "zzz"), "no match");
839    }
840
841    #[test]
842    fn search_filters_rows_before_sort_and_keeps_source_indices() {
843        // Four rows; searching for "a" → only Ada and Cal match (source indices 0 and 2).
844        let rows = vec![
845            vec!["Ada".to_string(), "3".to_string()],
846            vec!["Bob".to_string(), "1".to_string()],
847            vec!["Cal".to_string(), "2".to_string()],
848            vec!["Eve".to_string(), "4".to_string()],
849        ];
850        // An ascending sort by key among {Ada(3), Cal(2)} → Cal(2), Ada(3) = source indices [2, 0].
851        let dt: DataTable<usize> = DataTable::new(["N", "K"], rows)
852            .searchable("a", |_| 0)
853            .sorted(1, true)
854            .on_select_row(|i| i);
855        let mut clicks = Vec::new();
856        for c in Widget::<usize>::children(&dt) {
857            collect_clicks(c.as_ref(), &mut clicks);
858        }
859        clicks.dedup();
860        assert_eq!(
861            clicks,
862            vec![2, 0],
863            "filtered then sorted, in source indices"
864        );
865    }
866
867    #[test]
868    fn checkbox_click_reports_the_source_row_through_sort_and_page() {
869        // The same data as the single selection: ascending sort → source order [1, 2, 0].
870        let rows = vec![
871            vec!["A".to_string(), "3".to_string()],
872            vec!["B".to_string(), "1".to_string()],
873            vec!["C".to_string(), "2".to_string()],
874        ];
875        // `999` = the "check all" box's message (a sentinel, filtered out).
876        let dt: DataTable<usize> = DataTable::new(["N", "K"], rows)
877            .sorted(1, true)
878            .paginated(2, 2, |_| 0)
879            .checkboxes(|i| i, 999);
880        let mut v = Vec::new();
881        // `children()[0]` = the Table; `[1]` = the footer (the pager), ignored.
882        collect_clicks(Widget::<usize>::children(&dt)[0].as_ref(), &mut v);
883        v.retain(|&m| m != 999); // removes the header box
884        v.dedup();
885        // Page 2 (size 2) of the sorted rows [1, 2, 0] → the box returns **source** index 0.
886        assert_eq!(
887            v,
888            vec![0],
889            "the box returns the source row's index, pagination included"
890        );
891    }
892
893    #[test]
894    fn custom_comparator_orders_a_column_semantically() {
895        // A **priority** column that a text sort would order alphabetically
896        // (High < Low < Medium) — semantically wrong. A home-made comparator imposes
897        // Low < Medium < High.
898        let rows = vec![
899            vec!["A".to_string(), "High".to_string()],
900            vec!["B".to_string(), "Low".to_string()],
901            vec!["C".to_string(), "Medium".to_string()],
902        ];
903        let rank = |s: &str| match s {
904            "Low" => 0,
905            "Medium" => 1,
906            "High" => 2,
907            _ => 3,
908        };
909        let dt: DataTable<usize> = DataTable::new(["N", "Prio"], rows)
910            .sorted(1, true)
911            .sort_with(1, move |a, b| rank(a).cmp(&rank(b)))
912            .on_select_row(|i| i);
913        let mut clicks = Vec::new();
914        for c in Widget::<usize>::children(&dt) {
915            collect_clicks(c.as_ref(), &mut clicks);
916        }
917        clicks.dedup();
918        // The semantic ascending order Low(1) < Medium(2) < High(0) → source indices [1, 2, 0],
919        // not the alphabetical order [High(0), Low(1), Medium(2)] of the default sort.
920        assert_eq!(
921            clicks,
922            vec![1, 2, 0],
923            "the custom comparator orders by priority"
924        );
925    }
926
927    /// Collects the words a subtree draws, in paint order — what a reader would see.
928    fn collect_texts<Msg: Clone + 'static>(root: &dyn Widget<Msg>) -> Vec<String> {
929        let ui = crate::ui::build_ui(
930            root,
931            frus_core::Size::new(600.0, 600.0),
932            &crate::Runtime::default(),
933            &Theme::default(),
934        );
935        ui.scene()
936            .primitives()
937            .iter()
938            .filter_map(|p| match p {
939                frus_core::Primitive::Text { text, .. } => Some(text.clone()),
940                _ => None,
941            })
942            .collect()
943    }
944
945    /// **Only the page's rows are built.** That is the whole of what a lazy source buys: an
946    /// ordinary table showing ten rows of four thousand builds four thousand.
947    #[test]
948    fn a_lazy_table_builds_only_the_page_it_shows() {
949        use std::cell::RefCell;
950        let asked: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
951        let seen = asked.clone();
952        let table = DataTable::<()>::lazy(["N"], 4_000, move |i| {
953            seen.borrow_mut().push(i);
954            vec![format!("row {i}")]
955        })
956        .paginated(2, 10, |_| ());
957        // Nothing yet: a table composes itself when someone looks at it, so a chain of
958        // builders costs one composition rather than one each.
959        assert!(asked.borrow().is_empty(), "not built until it is looked at");
960        let texts = collect_texts(&table);
961        assert_eq!(
962            *asked.borrow(),
963            (10..20).collect::<Vec<_>>(),
964            "page 2 of ten, and nothing else"
965        );
966        assert!(texts.contains(&"row 10".to_string()), "{texts:?}");
967        assert!(texts.contains(&"row 19".to_string()), "{texts:?}");
968        assert!(!texts.contains(&"row 20".to_string()), "{texts:?}");
969    }
970
971    /// **A page that no longer exists shows the last one that does**, and everything on
972    /// screen agrees about which page that is.
973    ///
974    /// The everyday way there is a filter: a reader on page 40 of 400 types a letter into
975    /// the search field and there are two pages left. This is where a hand-rolled pager goes
976    /// wrong — an empty table under a pager, which says the filter matched nothing.
977    #[test]
978    fn a_filter_that_shrinks_the_set_below_the_current_page_shows_the_last_one() {
979        // 400 rows cut ten to a page, the reader on page 40; then a filter leaves 12.
980        let shrunk = DataTable::<()>::lazy(["N"], 12, |i| vec![format!("row {i}")]).paginated(
981            40,
982            10,
983            |_| (),
984        );
985        let texts = collect_texts(&shrunk);
986        assert!(
987            texts.contains(&"row 10".to_string()) && texts.contains(&"row 11".to_string()),
988            "the last page's two rows, not an empty table: {texts:?}"
989        );
990        assert!(
991            texts.iter().any(|t| t == "11–12 of 12"),
992            "and the line says the same page the slice does: {texts:?}"
993        );
994    }
995
996    /// **The footer's line is the reader's language**, numbers included: the word in the
997    /// middle moves, and a thousand is grouped with a space rather than a comma.
998    #[test]
999    fn the_page_line_is_localised() {
1000        assert_eq!(page_range_label(2, 10, 4_000), "11–20 of 4,000");
1001        crate::localizations::scope(Rc::new(crate::French), || {
1002            assert_eq!(page_range_label(2, 10, 4_000), "11–20 sur 4\u{a0}000");
1003        });
1004    }
1005
1006    /// **A selection survives a page change**, because every index the table takes and
1007    /// gives back is an index into the set rather than into the slice: ticking a row on
1008    /// page 1 and paging away must not silently drop it.
1009    #[test]
1010    fn a_selection_made_on_one_page_is_still_there_on_the_next() {
1011        let rows: Vec<Vec<String>> = (0..30).map(|i| vec![format!("row {i}")]).collect();
1012        // The pager's own messages are numbered from a thousand, so the rows' can be told
1013        // from them without reaching into the footer's shape.
1014        let table = DataTable::<usize>::new(["N"], rows)
1015            .paginated(2, 10, |p| 1_000 + p)
1016            .selected(&[3, 15])
1017            .on_select_row(|i| i);
1018        let mut clicks = Vec::new();
1019        for child in Widget::<usize>::children(&table) {
1020            collect_clicks(child.as_ref(), &mut clicks);
1021        }
1022        clicks.retain(|m| *m < 1_000);
1023        clicks.dedup();
1024        assert_eq!(
1025            clicks,
1026            (10..20).collect::<Vec<_>>(),
1027            "a click on page 2 reports the set's index, not the slice's"
1028        );
1029    }
1030
1031    #[test]
1032    fn page_size_selector_appears_in_the_footer() {
1033        let rows: Vec<Vec<String>> = (1..=7).map(|i| vec![i.to_string()]).collect();
1034        // inner = [table, footer]; the footer is a Flex row [label, spacer, pager, (selector)].
1035        let footer_len = |dt: &DataTable<()>| Widget::<()>::children(dt)[1].children().len();
1036        let base = DataTable::<()>::new(["N"], rows.clone()).paginated(1, 3, |_| ());
1037        let sized = DataTable::<()>::new(["N"], rows)
1038            .paginated(1, 3, |_| ())
1039            .page_sizes(&[3, 5], |_| ());
1040        assert_eq!(footer_len(&base), 3, "label + spacer + pager");
1041        assert_eq!(
1042            footer_len(&sized),
1043            5,
1044            "+ \"Rows per page\" and the selector"
1045        );
1046    }
1047}