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}