frus-widgets 0.5.0

frus — declarative widget tree
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
//! [`DataTable`]: a [`Table`](crate::Table) that **sorts its own data**.
//!
//! The base `Table` is purely controlled: it only emits the column clicked (`on_sort`) and
//! shows the indicator for the `sorted` state it is handed — it is the application that
//! reorders its rows. `DataTable` encapsulates that **display** reordering: it is given the
//! raw rows and the sort state `(column, direction)`, and rebuilds a `Table` with the rows
//! already **sorted** (numeric-aware, case-insensitive) and the indicator. The model stays
//! controlled — the sort state lives in the app — but the sorting logic is no longer copied
//! out by hand.

use std::cmp::Ordering;
use std::rc::Rc;

use frus_core::{Rect, Scene};
use frus_layout::{Align, Style};

use crate::flex::Flex;
use crate::interaction::Status;
use crate::pagination::Pagination;
use crate::segmented::SegmentedButton;
use crate::table::Table;

/// An application's own ordering for one column — dates, amounts, priorities — where
/// comparing the rendered strings would sort them wrongly.
type Comparator = Rc<dyn Fn(&str, &str) -> Ordering>;

/// The buttons of the bulk-action bar, built on demand while a selection stands.
type BulkActions<Msg> = Rc<dyn Fn() -> Vec<Box<dyn Widget<Msg>>>>;

/// A **lazy row source**: how many rows there are, and how to build one — see
/// [`DataTable::lazy`].
type LazyRows = (usize, Rc<dyn Fn(usize) -> Vec<String>>);
use crate::text::Text;
use crate::textinput::TextField;
use crate::theme::Theme;
use crate::widget::Widget;

/// Compares two text cells: **numerically** when both read as numbers, otherwise lexically
/// and **case-insensitively**. The basis of a [`DataTable`]'s sorting.
pub fn compare_cells(a: &str, b: &str) -> Ordering {
    match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
        (Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
        _ => a.to_lowercase().cmp(&b.to_lowercase()),
    }
}

/// `true` when a cell of `row` **contains** `query` (a **case-insensitive** substring). An
/// empty or blank query lets everything through. The basis of a [`DataTable`]'s filter;
/// reusable outside the widget (a reducer can filter its data the same way).
pub fn row_matches(row: &[String], query: &str) -> bool {
    let q = query.trim().to_lowercase();
    if q.is_empty() {
        return true;
    }
    row.iter().any(|cell| cell.to_lowercase().contains(&q))
}

/// Returns a **copy** of `rows` sorted by column `col` (`ascending` = increasing) through
/// [`compare_cells`]. A missing cell (a row too short) counts as empty. Reusable outside the
/// widget: a reducer can sort its data in exactly the same way.
pub fn sort_rows(rows: &[Vec<String>], col: usize, ascending: bool) -> Vec<Vec<String>> {
    let empty = String::new();
    let mut out = rows.to_vec();
    out.sort_by(|a, b| {
        let ord = compare_cells(a.get(col).unwrap_or(&empty), b.get(col).unwrap_or(&empty));
        if ascending {
            ord
        } else {
            ord.reverse()
        }
    });
    out
}

/// The number of pages for `len` rows cut into slices of `per_page` (at least **1**).
pub fn page_count(len: usize, per_page: usize) -> usize {
    let per = per_page.max(1);
    (len.div_ceil(per)).max(1)
}

/// The **slice** of rows of page `current` (1-indexed) with a size of `per_page`. The page is
/// brought back into `[1, page_count]` if it overflows. Reusable outside the widget.
pub fn page_rows(rows: &[Vec<String>], current: usize, per_page: usize) -> Vec<Vec<String>> {
    let per = per_page.max(1);
    let current = current.clamp(1, page_count(rows.len(), per));
    let start = (current - 1) * per;
    let end = (start + per).min(rows.len());
    rows[start..end].to_vec()
}

/// The "N-M of T" line under the current slice (`0 of 0` when empty) — milestone 236.
///
/// **The sentence comes from the reader's language**, and so do the numbers in it: a comma
/// groups a thousand in English and a space does in French, and the word in the middle is
/// not in the same place in every language. See
/// [`Localizations::page_range_label`](crate::Localizations::page_range_label).
pub fn page_range_label(current: usize, per_page: usize, total: usize) -> String {
    let per = per_page.max(1);
    let current = current.clamp(1, page_count(total, per));
    let start = (current - 1) * per + 1;
    let end = (current * per).min(total);
    crate::localizations::of().page_range_label(start, end, total)
}

/// A **text** data table that sorts its own rows according to the sort state supplied, then
/// delegates the rendering to a [`Table`](crate::Table).
///
/// ```
/// use frus_widgets::DataTable;
/// let rows = vec![vec!["Bob".to_string(), "9".to_string()], vec!["Ada".to_string(), "10".to_string()]];
/// let table: DataTable = DataTable::new(["Name", "Score"], rows).sorted(1, true);
/// ```
pub struct DataTable<Msg = crate::callback::Callback> {
    headers: Vec<String>,
    rows: Vec<Vec<String>>,
    /// **Rows asked for one at a time**, for a set too big to hold: `(how many, how to
    /// build one)`. See [`DataTable::lazy`]. `None` is the ordinary table, whose rows are
    /// in `rows`.
    lazy: Option<LazyRows>,
    widths: Vec<f32>,
    sort: Option<(usize, bool)>,
    on_sort: Option<Rc<dyn Fn(usize) -> Msg>>,
    /// Pagination: `(current page, 1-indexed; page size)` — milestone 233.
    page: Option<(usize, usize)>,
    on_page: Option<Rc<dyn Fn(usize) -> Msg>>,
    /// The page sizes offered + the callback on change (the footer's selector) — milestone 236.
    page_sizes: Vec<usize>,
    on_page_size: Option<Rc<dyn Fn(usize) -> Msg>>,
    /// Row selection (milestone 239): a callback on click plus the highlighted rows. The indices
    /// are those of the **source rows** (before sorting and pagination) — the `DataTable` does
    /// the translation against the displayed slice, as it already does for sorting and paging.
    on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
    selected: Vec<usize>,
    /// A **custom** comparator per column (milestone 240): `None` = the default sort
    /// ([`compare_cells`]). It allows ordering cells the default sorts badly — formatted
    /// dates, amounts ("$1.2M"), priorities ("High"/"Medium"/"Low").
    comparators: Vec<Option<Comparator>>,
    /// **Multiple** selection (milestone 241): a column of checkboxes. `on_check(source_row)`
    /// toggles one row, and `on_check_all` toggles the header box. The checked state follows
    /// [`selected`](Self::selected), with the same source indices.
    on_check: Option<Rc<dyn Fn(usize) -> Msg>>,
    on_check_all: Option<Msg>,
    /// Search (milestone 242): the current query + a callback on typing. When `on_query` is set,
    /// a search field caps the table and the source rows are **filtered** ([`row_matches`])
    /// before sorting and pagination — all in source indices (selection and boxes unchanged).
    query: Option<String>,
    on_query: Option<Rc<dyn Fn(String) -> Msg>>,
    /// The **bulk actions** bar (milestone 243): a factory of action widgets (buttons…), called
    /// again on every rebuild. Rendered **above** the table **only** when rows are selected,
    /// preceded by an "N selected". The app wires up its buttons (variants, messages).
    bulk_actions: Option<BulkActions<Msg>>,
    /// The **empty state**'s text (milestone 244): shown centred, under the header, when no row
    /// is visible (empty data, or a filter with no result). Overridable through
    /// [`empty_text`](DataTable::empty_text).
    empty_text: String,
    /// The rendering: the `Table` (sorted and paginated rows), optionally capped by a footer
    /// (the slice label + `Pagination` + the size selector) beneath it.
    ///
    /// **Composed on the first ask, not on every builder call.** A table is built by a
    /// chain of eight or nine methods, each of which used to compose the whole thing again
    /// and throw the last one away — nine times over the rows for one table. It matters for
    /// a lazy source, where composing means *building the rows*: `DataTable::lazy(…, 4_000,
    /// …).paginated(2, 10, …)` would have asked for four thousand rows and then for ten.
    inner: std::cell::OnceCell<Box<dyn Widget<Msg>>>,
}

impl<Msg: Clone + 'static> DataTable<Msg> {
    /// Creates a table from its **headers** and its **rows** (text). Without a sort state, the
    /// rows are shown in the order supplied.
    pub fn new(
        headers: impl IntoIterator<Item = impl Into<String>>,
        rows: impl IntoIterator<Item = Vec<String>>,
    ) -> Self {
        let headers: Vec<String> = headers.into_iter().map(Into::into).collect();
        let cols = headers.len().max(1);
        let mut me = Self {
            headers,
            rows: rows.into_iter().collect(),
            widths: vec![0.0; cols],
            sort: None,
            on_sort: None,
            page: None,
            on_page: None,
            page_sizes: Vec::new(),
            on_page_size: None,
            on_select: None,
            selected: Vec::new(),
            lazy: None,
            comparators: Vec::new(),
            on_check: None,
            on_check_all: None,
            query: None,
            on_query: None,
            bulk_actions: None,
            empty_text: crate::localizations::of().no_results_label().to_string(),
            inner: std::cell::OnceCell::new(),
        };
        me.rebuild();
        me
    }

    /// A table whose rows are **asked for one at a time**: `count` of them, and
    /// `row(index)` to build one.
    ///
    /// For a set too big to hold twice. An ordinary table is handed every row, so showing
    /// ten of four thousand builds four thousand: this builds ten, and asks only for the
    /// ones the current page shows.
    ///
    /// **What a lazy table gives up is the work it cannot do without the rows.** Sorting,
    /// searching and the row filter belong to whoever owns the data:
    ///
    /// - [`sorted`](Self::sorted) still draws the arrow and [`on_sort`](Self::on_sort)
    ///   still fires, but the table does **not** reorder anything. The application sorts
    ///   its own set and hands back rows in the new order.
    /// - [`searchable`](Self::searchable) still draws the field and still emits, and the
    ///   application filters — which is what changes `count`.
    /// - Every index — [`selected`](Self::selected), `on_select_row`, the checkboxes — is
    ///   an index **into the set as it stands**, the one `row` was asked for. In an
    ///   ordinary table it is the index of the row as it was given, before sorting; here
    ///   there is no *before*, because the table never saw the other rows.
    ///
    /// ```ignore
    /// DataTable::lazy(["Name", "Score"], people.len(), move |i| {
    ///     vec![people[i].name.clone(), people[i].score.to_string()]
    /// })
    /// .paginated(page, 10, Msg::Page)
    /// ```
    pub fn lazy(
        headers: impl IntoIterator<Item = impl Into<String>>,
        count: usize,
        row: impl Fn(usize) -> Vec<String> + 'static,
    ) -> Self {
        let mut table = Self::new(headers, Vec::new());
        table.lazy = Some((count, Rc::new(row)));
        table.rebuild();
        table
    }

    /// The **fixed** width of each column, in pixels (`0` or less = a flexible column).
    pub fn column_widths(mut self, widths: &[f32]) -> Self {
        for (i, w) in widths.iter().enumerate().take(self.widths.len()) {
            self.widths[i] = *w;
        }
        self.rebuild();
        self
    }

    /// The sorted column and its direction (`true` = ascending): it **sorts** the rows for
    /// display and shows the direction indicator.
    pub fn sorted(mut self, column: usize, ascending: bool) -> Self {
        self.sort = Some((column, ascending));
        self.rebuild();
        self
    }

    /// Makes the headers **clickable**: `on_sort(column)` on click (the application then flips
    /// the direction and passes `sorted(...)` back).
    pub fn on_sort<R: crate::callback::IntoMsg<Msg>>(
        mut self,
        on_sort: impl Fn(usize) -> R + 'static,
    ) -> Self {
        self.on_sort = Some(Rc::new(crate::callback::handler1(on_sort)));
        self.rebuild();
        self
    }

    /// **Paginates** the table: it shows only the slice of page `current` (1-indexed) with a
    /// size of `per_page`, and places a [`Pagination`](crate::Pagination) selector beneath it.
    /// `on_page(page)` when a page is clicked (the application updates `current`). The slicing
    /// follows the **sort**.
    ///
    /// **A page that no longer exists shows the last one that does.** A filter that
    /// shortens the set past the current page is the everyday way there — type a letter
    /// into the search field on page 40 of 400 and there are two pages left — and the
    /// alternatives are worse than the clamp: an empty table with a pager under it says
    /// the filter matched nothing, which is a lie, and jumping to page 1 loses the reader's
    /// place for a filter that shortened the set by one row. The label, the pager and the
    /// slice all say the same clamped number, so nothing on screen disagrees with anything
    /// else; the application's own `current` catches up on the next click.
    pub fn paginated(
        mut self,
        current: usize,
        per_page: usize,
        on_page: impl Fn(usize) -> Msg + 'static,
    ) -> Self {
        self.page = Some((current.max(1), per_page.max(1)));
        self.on_page = Some(Rc::new(on_page));
        self.rebuild();
        self
    }

    /// Adds a **page size selector** (a `SegmentedButton` of the `sizes` offered) to the
    /// footer. `on_page_size(size)` on change (the app updates the size and, usually, returns
    /// to page 1). It has no effect if the table is not paginated.
    pub fn page_sizes(
        mut self,
        sizes: &[usize],
        on_page_size: impl Fn(usize) -> Msg + 'static,
    ) -> Self {
        self.page_sizes = sizes.iter().copied().filter(|&s| s > 0).collect();
        self.on_page_size = Some(Rc::new(on_page_size));
        self.rebuild();
        self
    }

    /// Makes the rows **clickable**: `on_select_row(source_row)` when a row is clicked. The
    /// index passed is that of the **source row** (before sorting and pagination) — the
    /// `DataTable` translates the displayed position into the original index.
    pub fn on_select_row<R: crate::callback::IntoMsg<Msg>>(
        mut self,
        on_select: impl Fn(usize) -> R + 'static,
    ) -> Self {
        self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
        self.rebuild();
        self
    }

    /// The **selected** rows (highlighted, or checked), designated by their **source row**
    /// index. The `DataTable` only highlights (and checks) those visible in the current
    /// slice.
    pub fn selected(mut self, rows: &[usize]) -> Self {
        self.selected = rows.to_vec();
        self.rebuild();
        self
    }

    /// Enables **multiple selection**: a column of checkboxes capped by a "check all".
    /// `on_check(source_row)` toggles one row (the **source row** index, translated from the
    /// displayed position), and `on_check_all` toggles the header box. The checked state
    /// reflects [`selected`](Self::selected). It combines with
    /// [`on_select_row`](Self::on_select_row): the box handles group selection, and a click
    /// on the row's body stays a row click.
    pub fn checkboxes(
        mut self,
        on_check: impl Fn(usize) -> Msg + 'static,
        on_check_all: Msg,
    ) -> Self {
        self.on_check = Some(Rc::new(on_check));
        self.on_check_all = Some(on_check_all);
        self.rebuild();
        self
    }

    /// Makes the table **searchable**: a search field (with value `query`) caps the table, and
    /// the source rows are **filtered** ([`row_matches`], a case-insensitive substring across
    /// every column) before sorting and pagination. `on_query(text)` on each keystroke (the
    /// application updates `query` and usually returns to page 1). The filter acts upstream
    /// of the sort, the page **and** the selection: boxes and highlighting stay in source
    /// indices, over the visible subset.
    pub fn searchable(
        mut self,
        query: impl Into<String>,
        on_query: impl Fn(String) -> Msg + 'static,
    ) -> Self {
        self.query = Some(query.into());
        self.on_query = Some(Rc::new(on_query));
        self.rebuild();
        self
    }

    /// Adds a **bulk actions bar** above the table, visible **only** when rows are
    /// [`selected`](Self::selected). The `make` factory produces the action widgets (typically
    /// [`Button`](crate::Button)s — the app chooses variants and messages); the bar precedes
    /// them with an "N selected" label. Called again on every rebuild (fresh widgets). The
    /// number shown is that of the **selected** rows, across all pages.
    pub fn bulk_actions(mut self, make: impl Fn() -> Vec<Box<dyn Widget<Msg>>> + 'static) -> Self {
        self.bulk_actions = Some(Rc::new(make));
        self.rebuild();
        self
    }

    /// Overrides the **empty state**'s text (default "No results") — "No people match your
    /// search", say. Shown centred under the header when no row is visible (milestone 244).
    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
        self.empty_text = text.into();
        self.rebuild();
        self
    }

    /// Gives column `col` a **custom comparator**: `cmp(a, b)` orders two of its (text) cells.
    /// It replaces the default sort ([`compare_cells`]) for that column — useful when the
    /// values sort badly as they stand: formatted dates ("Mar 2024"), amounts ("$1.2M"),
    /// priorities ("High"/"Medium"/"Low"). The direction (`sorted(_, ascending)`) applies on
    /// top (the comparator defines the **ascending** order).
    pub fn sort_with(mut self, col: usize, cmp: impl Fn(&str, &str) -> Ordering + 'static) -> Self {
        if self.comparators.len() <= col {
            self.comparators.resize_with(col + 1, || None);
        }
        self.comparators[col] = Some(Rc::new(cmp));
        self.rebuild();
        self
    }

    /// The order of the **source row indices** after **filtering** (search) then sorting
    /// (stable); the filtered identity when there is no sort. The sort uses the column's
    /// **custom** comparator when it has one, otherwise [`compare_cells`].
    /// One row: held, or built on demand. **Only the rows of the current page** ever get
    /// here, which is what a lazy source buys.
    fn row(&self, index: usize) -> Vec<String> {
        match &self.lazy {
            Some((_, build)) => build(index),
            None => self.rows.get(index).cloned().unwrap_or_default(),
        }
    }

    fn sorted_order(&self) -> Vec<usize> {
        // **A lazy table is already in the order it is going to be shown in.** Neither the
        // filter nor the sort can run here: both need every row, and the whole point of a
        // lazy source is that the table has not got them. The application does both, and
        // the result arrives as a different `count` and a different `row`.
        if let Some((count, _)) = &self.lazy {
            return (0..*count).collect();
        }
        // The search filter, upstream: it keeps only the matching source rows.
        let mut order: Vec<usize> = (0..self.rows.len())
            .filter(|&i| match &self.query {
                Some(q) => row_matches(&self.rows[i], q),
                None => true,
            })
            .collect();
        if let Some((col, asc)) = self.sort {
            let empty = String::new();
            let custom = self.comparators.get(col).and_then(|c| c.as_ref());
            order.sort_by(|&a, &b| {
                let (x, y) = (
                    self.rows[a].get(col).unwrap_or(&empty),
                    self.rows[b].get(col).unwrap_or(&empty),
                );
                let ord = match custom {
                    Some(cmp) => cmp(x, y),
                    None => compare_cells(x, y),
                };
                if asc {
                    ord
                } else {
                    ord.reverse()
                }
            });
        }
        order
    }

    /// (Re)builds the rendering: rows sorted according to `sort`, cut to page `page` where
    /// applicable, in a `Table` (headers, widths, indicator) optionally capped by a
    /// `Pagination` beneath.
    /// Throws away the rendering, so the next ask composes it again. Cheap: every builder
    /// method calls it, and the work happens once, when someone finally looks.
    fn rebuild(&mut self) {
        self.inner.take();
    }

    /// The rendering, composed if it has not been.
    fn built(&self) -> &dyn Widget<Msg> {
        self.inner.get_or_init(|| self.compose()).as_ref()
    }

    fn compose(&self) -> Box<dyn Widget<Msg>> {
        // The reasoning is in terms of **source row indices** (sorted, then sliced): that
        // preserves each row's original identity through the sort and the pagination, so the
        // selection can be translated (displayed index ↔ source index) both ways.
        let order = self.sorted_order();
        let total = order.len();
        let page_indices: Vec<usize> = match self.page {
            Some((current, per)) => {
                let per = per.max(1);
                let current = current.clamp(1, page_count(total, per));
                let start = (current - 1) * per;
                let end = (start + per).min(total);
                order[start..end].to_vec()
            }
            None => order.clone(),
        };
        let hrefs: Vec<&str> = self.headers.iter().map(|s| s.as_str()).collect();
        let mut t = Table::new(self.headers.len().max(1)).header(&hrefs);
        for &i in &page_indices {
            let row = self.row(i);
            let refs: Vec<&str> = row.iter().map(|s| s.as_str()).collect();
            t = t.row(&refs);
        }
        if self.widths.iter().any(|w| *w > 0.0) {
            t = t.column_widths(&self.widths);
        }
        if let Some(f) = &self.on_sort {
            let f = f.clone();
            t = t.on_sort(move |c| f(c));
        }
        if let Some((col, asc)) = self.sort {
            t = t.sorted(col, asc);
        }
        // Selection: `Table` reasons in **displayed positions** (0..slice). The click is wired
        // to return the **source** index, and the selected rows are translated source → position.
        if let Some(f) = &self.on_select {
            let f = f.clone();
            let indices = page_indices.clone();
            t = t.on_select_row(move |d| f(indices.get(d).copied().unwrap_or(d)));
        }
        // Multiple selection: the same displayed position → source index translation for the boxes.
        if let (Some(f), Some(all)) = (&self.on_check, &self.on_check_all) {
            let f = f.clone();
            let indices = page_indices.clone();
            let all = all.clone();
            t = t.checkboxes(move |d| f(indices.get(d).copied().unwrap_or(d)), all);
        }
        if !self.selected.is_empty() {
            let display_sel: Vec<usize> = page_indices
                .iter()
                .enumerate()
                .filter(|(_, orig)| self.selected.contains(orig))
                .map(|(d, _)| d)
                .collect();
            if !display_sel.is_empty() {
                t = t.selected(&display_sel);
            }
        }
        // The empty state (empty data, or a filter with no result): header + a centred message,
        // **without** a footer (a "0 of 0" pager under an empty body adds nothing). The
        // message is overridable.
        let block: Box<dyn Widget<Msg>> = if total == 0 {
            let message = Text::new(self.empty_text.clone()).size(15.0);
            let empty = Flex::column()
                .align(Align::Center)
                .padding(24.0)
                .child(message);
            Box::new(Flex::column().gap(8.0).child(t).child(empty))
        } else {
            match (self.page, &self.on_page) {
                (Some((current, per)), Some(on_page)) => {
                    let pages = page_count(total, per);
                    let current = current.clamp(1, pages);
                    // The "N–M of T" label of the current slice (milestone 236).
                    let label = Text::new(page_range_label(current, per, total)).size(13.0);
                    let on_page = on_page.clone();
                    let pager = Pagination::new(current, pages, move |p| on_page(p));
                    let mut footer = Flex::row()
                        .align(Align::Center)
                        .gap(12.0)
                        .child(label)
                        .child(Flex::row().flex(1.0))
                        .child(pager);
                    // The page size selector, when offered (milestone 236).
                    if let (Some(on_size), false) = (&self.on_page_size, self.page_sizes.is_empty())
                    {
                        let sizes = self.page_sizes.clone();
                        let sel = sizes.iter().position(|&s| s == per).unwrap_or(0);
                        let on_size = on_size.clone();
                        // A row of numbers in a table's footer: the checkmark would take
                        // more room than the digits it sits beside, and which one is chosen
                        // is already plain from the fill.
                        let mut seg = SegmentedButton::new(sel, move |i| on_size(sizes[i]))
                            .show_selected_icon(false)
                            .padding(10.0);
                        for s in &self.page_sizes {
                            seg = seg.segment(s.to_string());
                        }
                        // **Named**, because a row of bare numbers in a footer is a row of
                        // bare numbers: 10 · 25 · 50 says nothing about what it counts.
                        footer = footer
                            .child(
                                Text::new(crate::localizations::of().rows_per_page_label())
                                    .size(13.0),
                            )
                            .child(seg);
                    }
                    Box::new(Flex::column().gap(12.0).child(t).child(footer))
                }
                _ => Box::new(t),
            }
        };
        // Bulk actions: a bar above the table, only when a selection exists.
        let mut block = block;
        if let Some(make) = &self.bulk_actions {
            if !self.selected.is_empty() {
                let label = Text::new(
                    crate::localizations::of().selected_row_count_label(self.selected.len()),
                )
                .size(14.0);
                let mut bar = Flex::row()
                    .align(Align::Center)
                    .gap(8.0)
                    .child(label)
                    .child(Flex::row().flex(1.0));
                for w in make() {
                    bar = bar.child(w);
                }
                block = Box::new(Flex::column().gap(12.0).child(bar).child(block));
            }
        }
        // Search: it caps the table with a field (otherwise the block is kept as is).
        if let Some(on_query) = &self.on_query {
            let on_query = on_query.clone();
            let field = TextField::new(self.query.clone().unwrap_or_default())
                .placeholder(crate::localizations::of().search_field_label())
                .width(240.0)
                .on_input(move |s| on_query(s));
            Box::new(Flex::column().gap(12.0).child(field).child(block))
        } else {
            block
        }
    }
}

impl<Msg: Clone + 'static> Widget<Msg> for DataTable<Msg> {
    fn style(&self) -> Style {
        self.built().style()
    }

    fn style_themed(&self, theme: &Theme) -> Style {
        self.built().style_themed(theme)
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        self.built().children()
    }

    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}

    fn on_click(&self) -> Option<Msg> {
        None
    }

    fn stack(&self) -> bool {
        self.built().stack()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> Vec<Vec<String>> {
        vec![
            vec!["Bob".to_string(), "9".to_string()],
            vec!["alice".to_string(), "10".to_string()],
            vec!["Carol".to_string(), "2".to_string()],
        ]
    }

    #[test]
    fn sort_rows_is_numeric_aware_and_case_insensitive() {
        let rows = sample();
        // Column 1 is **numeric**: 2 < 9 < 10 (not the lexical order "10" < "2" < "9").
        let by_num = sort_rows(&rows, 1, true);
        assert_eq!(
            by_num.iter().map(|r| r[1].as_str()).collect::<Vec<_>>(),
            ["2", "9", "10"]
        );
        // Column 0 is **text**, case-insensitive: alice < Bob < Carol.
        let by_name = sort_rows(&rows, 0, true);
        assert_eq!(
            by_name.iter().map(|r| r[0].as_str()).collect::<Vec<_>>(),
            ["alice", "Bob", "Carol"]
        );
        // **Descending** direction: the order is reversed.
        let desc = sort_rows(&rows, 1, false);
        assert_eq!(
            desc.iter().map(|r| r[1].as_str()).collect::<Vec<_>>(),
            ["10", "9", "2"]
        );
    }

    #[test]
    fn compare_cells_prefers_numbers_then_text() {
        assert_eq!(compare_cells("2", "10"), Ordering::Less, "numeric: 2 < 10");
        assert_eq!(
            compare_cells("Bob", "alice"),
            Ordering::Greater,
            "text: b > a (case-insensitive)"
        );
    }

    #[test]
    fn data_table_builds_a_non_empty_tree() {
        let dt = DataTable::<()>::new(["Name", "Score"], sample())
            .column_widths(&[120.0, 80.0])
            .sorted(1, true)
            .on_sort(|_| ());
        // Header + rows → a non-empty render tree.
        assert!(
            !Widget::<()>::children(&dt).is_empty(),
            "the DataTable produces a tree"
        );
    }

    #[test]
    fn pagination_slices_rows_and_counts_pages() {
        let rows: Vec<Vec<String>> = (1..=7).map(|i| vec![i.to_string()]).collect();
        assert_eq!(page_count(7, 3), 3, "7 rows / 3 = 3 pages");
        assert_eq!(page_count(0, 3), 1, "at least one page");
        let col0 = |rs: Vec<Vec<String>>| rs.iter().map(|r| r[0].clone()).collect::<Vec<_>>();
        // Page 1: 1,2,3; page 3: 7 (the last, partial one).
        assert_eq!(col0(page_rows(&rows, 1, 3)), ["1", "2", "3"]);
        assert_eq!(col0(page_rows(&rows, 3, 3)), ["7"]);
        // An out-of-bounds page is brought back into the interval.
        assert_eq!(col0(page_rows(&rows, 99, 3)), ["7"]);
    }

    #[test]
    fn data_table_with_pagination_builds_table_and_pager() {
        let rows: Vec<Vec<String>> = (1..=10)
            .map(|i| vec![format!("R{i}"), i.to_string()])
            .collect();
        let dt = DataTable::<()>::new(["Name", "Score"], rows)
            .sorted(1, true)
            .paginated(1, 4, |_| ());
        // inner = the column [table, footer] → two children.
        assert_eq!(
            Widget::<()>::children(&dt).len(),
            2,
            "table + pied de pagination"
        );
    }

    #[test]
    fn page_range_label_describes_the_slice() {
        assert_eq!(page_range_label(1, 3, 7), "1\u{2013}3 of 7");
        assert_eq!(page_range_label(2, 3, 7), "4\u{2013}6 of 7");
        assert_eq!(
            page_range_label(3, 3, 7),
            "7\u{2013}7 of 7",
            "the last, partial page"
        );
        assert_eq!(page_range_label(1, 3, 0), "0 of 0", "empty");
        assert_eq!(
            page_range_label(99, 3, 7),
            "7\u{2013}7 of 7",
            "an out-of-bounds page is brought back"
        );
    }

    /// Collects, in tree order, every non-null `on_click` message of a subtree.
    fn collect_clicks(w: &dyn Widget<usize>, out: &mut Vec<usize>) {
        if let Some(m) = w.on_click() {
            out.push(m);
        }
        for c in w.children() {
            collect_clicks(c.as_ref(), out);
        }
    }

    #[test]
    fn selection_click_reports_the_source_row_through_sort_and_page() {
        // Three rows, with a numeric key in column 1. Ascending sort → source order [1, 2, 0].
        let rows = vec![
            vec!["A".to_string(), "3".to_string()],
            vec!["B".to_string(), "1".to_string()],
            vec!["C".to_string(), "2".to_string()],
        ];
        let make = |page: usize| -> DataTable<usize> {
            DataTable::new(["N", "K"], rows.clone())
                .sorted(1, true)
                .paginated(page, 2, |_| 0)
                .on_select_row(|i| i)
        };
        // `children()[0]` = the Table; `[1]` = the footer (the pager), which is ignored.
        let clicks_of = |dt: &DataTable<usize>| {
            let mut v = Vec::new();
            collect_clicks(Widget::<usize>::children(dt)[0].as_ref(), &mut v);
            v.dedup(); // one clickable cell per column → consecutive repeats
            v
        };
        // Page 1 (size 2) of the sorted rows [1, 2, 0]: the click returns **source** index 1, then 2.
        assert_eq!(
            clicks_of(&make(1)),
            vec![1, 2],
            "the click returns the source row's index"
        );
        // Page 2: the last sorted row, source index 0 — pagination does not alter identity.
        assert_eq!(
            clicks_of(&make(2)),
            vec![0],
            "the translation survives pagination"
        );
    }

    #[test]
    fn empty_filter_drops_rows_and_pager() {
        // Two rows, with a "zzz" filter that matches nothing → the empty state: no clickable row,
        // and the **pagination footer is removed** (otherwise its single page button would
        // emit `0`).
        let rows = vec![vec!["Ada".to_string()], vec!["Bob".to_string()]];
        let dt: DataTable<usize> = DataTable::new(["N"], rows)
            .searchable("zzz", |_| 0)
            .paginated(1, 5, |_| 0)
            .on_select_row(|i| i);
        let mut clicks = Vec::new();
        for c in Widget::<usize>::children(&dt) {
            collect_clicks(c.as_ref(), &mut clicks);
        }
        assert!(
            clicks.is_empty(),
            "neither a clickable row nor a pager when the filter matches nothing"
        );
        // The tree stays non-empty: the header + the empty-state message.
        assert!(
            !Widget::<usize>::children(&dt).is_empty(),
            "header + message rendered"
        );
    }

    #[test]
    fn bulk_actions_bar_shows_only_with_a_selection() {
        use crate::button::Button;
        let rows = vec![vec!["A".to_string()], vec!["B".to_string()]];
        // `777` = the message of one bar action (a sentinel).
        let make = |sel: &[usize]| -> DataTable<usize> {
            DataTable::new(["N"], rows.clone())
                .checkboxes(|i| i, 900usize)
                .selected(sel)
                .bulk_actions(|| vec![Box::new(Button::new("Clear").on_press(777usize)) as Box<dyn Widget<usize>>])
        };
        let has_action = |dt: &DataTable<usize>| {
            let mut v = Vec::new();
            for c in Widget::<usize>::children(dt) {
                collect_clicks(c.as_ref(), &mut v);
            }
            v.contains(&777)
        };
        assert!(!has_action(&make(&[])), "no bar without a selection");
        assert!(
            has_action(&make(&[0])),
            "the action bar appears as soon as a row is selected"
        );
    }

    #[test]
    fn row_matches_is_case_insensitive_substring_over_all_cells() {
        let row = vec!["Ada Lovelace".to_string(), "Engineer".to_string()];
        assert!(
            row_matches(&row, ""),
            "an empty query lets everything through"
        );
        assert!(
            row_matches(&row, "  "),
            "a blank query lets everything through"
        );
        assert!(row_matches(&row, "ENGIN"), "case-insensitive, a substring");
        assert!(row_matches(&row, "love"), "another column");
        assert!(!row_matches(&row, "zzz"), "no match");
    }

    #[test]
    fn search_filters_rows_before_sort_and_keeps_source_indices() {
        // Four rows; searching for "a" → only Ada and Cal match (source indices 0 and 2).
        let rows = vec![
            vec!["Ada".to_string(), "3".to_string()],
            vec!["Bob".to_string(), "1".to_string()],
            vec!["Cal".to_string(), "2".to_string()],
            vec!["Eve".to_string(), "4".to_string()],
        ];
        // An ascending sort by key among {Ada(3), Cal(2)} → Cal(2), Ada(3) = source indices [2, 0].
        let dt: DataTable<usize> = DataTable::new(["N", "K"], rows)
            .searchable("a", |_| 0)
            .sorted(1, true)
            .on_select_row(|i| i);
        let mut clicks = Vec::new();
        for c in Widget::<usize>::children(&dt) {
            collect_clicks(c.as_ref(), &mut clicks);
        }
        clicks.dedup();
        assert_eq!(
            clicks,
            vec![2, 0],
            "filtered then sorted, in source indices"
        );
    }

    #[test]
    fn checkbox_click_reports_the_source_row_through_sort_and_page() {
        // The same data as the single selection: ascending sort → source order [1, 2, 0].
        let rows = vec![
            vec!["A".to_string(), "3".to_string()],
            vec!["B".to_string(), "1".to_string()],
            vec!["C".to_string(), "2".to_string()],
        ];
        // `999` = the "check all" box's message (a sentinel, filtered out).
        let dt: DataTable<usize> = DataTable::new(["N", "K"], rows)
            .sorted(1, true)
            .paginated(2, 2, |_| 0)
            .checkboxes(|i| i, 999);
        let mut v = Vec::new();
        // `children()[0]` = the Table; `[1]` = the footer (the pager), ignored.
        collect_clicks(Widget::<usize>::children(&dt)[0].as_ref(), &mut v);
        v.retain(|&m| m != 999); // removes the header box
        v.dedup();
        // Page 2 (size 2) of the sorted rows [1, 2, 0] → the box returns **source** index 0.
        assert_eq!(
            v,
            vec![0],
            "the box returns the source row's index, pagination included"
        );
    }

    #[test]
    fn custom_comparator_orders_a_column_semantically() {
        // A **priority** column that a text sort would order alphabetically
        // (High < Low < Medium) — semantically wrong. A home-made comparator imposes
        // Low < Medium < High.
        let rows = vec![
            vec!["A".to_string(), "High".to_string()],
            vec!["B".to_string(), "Low".to_string()],
            vec!["C".to_string(), "Medium".to_string()],
        ];
        let rank = |s: &str| match s {
            "Low" => 0,
            "Medium" => 1,
            "High" => 2,
            _ => 3,
        };
        let dt: DataTable<usize> = DataTable::new(["N", "Prio"], rows)
            .sorted(1, true)
            .sort_with(1, move |a, b| rank(a).cmp(&rank(b)))
            .on_select_row(|i| i);
        let mut clicks = Vec::new();
        for c in Widget::<usize>::children(&dt) {
            collect_clicks(c.as_ref(), &mut clicks);
        }
        clicks.dedup();
        // The semantic ascending order Low(1) < Medium(2) < High(0) → source indices [1, 2, 0],
        // not the alphabetical order [High(0), Low(1), Medium(2)] of the default sort.
        assert_eq!(
            clicks,
            vec![1, 2, 0],
            "the custom comparator orders by priority"
        );
    }

    /// Collects the words a subtree draws, in paint order — what a reader would see.
    fn collect_texts<Msg: Clone + 'static>(root: &dyn Widget<Msg>) -> Vec<String> {
        let ui = crate::ui::build_ui(
            root,
            frus_core::Size::new(600.0, 600.0),
            &crate::Runtime::default(),
            &Theme::default(),
        );
        ui.scene()
            .primitives()
            .iter()
            .filter_map(|p| match p {
                frus_core::Primitive::Text { text, .. } => Some(text.clone()),
                _ => None,
            })
            .collect()
    }

    /// **Only the page's rows are built.** That is the whole of what a lazy source buys: an
    /// ordinary table showing ten rows of four thousand builds four thousand.
    #[test]
    fn a_lazy_table_builds_only_the_page_it_shows() {
        use std::cell::RefCell;
        let asked: Rc<RefCell<Vec<usize>>> = Rc::new(RefCell::new(Vec::new()));
        let seen = asked.clone();
        let table = DataTable::<()>::lazy(["N"], 4_000, move |i| {
            seen.borrow_mut().push(i);
            vec![format!("row {i}")]
        })
        .paginated(2, 10, |_| ());
        // Nothing yet: a table composes itself when someone looks at it, so a chain of
        // builders costs one composition rather than one each.
        assert!(asked.borrow().is_empty(), "not built until it is looked at");
        let texts = collect_texts(&table);
        assert_eq!(
            *asked.borrow(),
            (10..20).collect::<Vec<_>>(),
            "page 2 of ten, and nothing else"
        );
        assert!(texts.contains(&"row 10".to_string()), "{texts:?}");
        assert!(texts.contains(&"row 19".to_string()), "{texts:?}");
        assert!(!texts.contains(&"row 20".to_string()), "{texts:?}");
    }

    /// **A page that no longer exists shows the last one that does**, and everything on
    /// screen agrees about which page that is.
    ///
    /// The everyday way there is a filter: a reader on page 40 of 400 types a letter into
    /// the search field and there are two pages left. This is where a hand-rolled pager goes
    /// wrong — an empty table under a pager, which says the filter matched nothing.
    #[test]
    fn a_filter_that_shrinks_the_set_below_the_current_page_shows_the_last_one() {
        // 400 rows cut ten to a page, the reader on page 40; then a filter leaves 12.
        let shrunk = DataTable::<()>::lazy(["N"], 12, |i| vec![format!("row {i}")]).paginated(
            40,
            10,
            |_| (),
        );
        let texts = collect_texts(&shrunk);
        assert!(
            texts.contains(&"row 10".to_string()) && texts.contains(&"row 11".to_string()),
            "the last page's two rows, not an empty table: {texts:?}"
        );
        assert!(
            texts.iter().any(|t| t == "11–12 of 12"),
            "and the line says the same page the slice does: {texts:?}"
        );
    }

    /// **The footer's line is the reader's language**, numbers included: the word in the
    /// middle moves, and a thousand is grouped with a space rather than a comma.
    #[test]
    fn the_page_line_is_localised() {
        assert_eq!(page_range_label(2, 10, 4_000), "11–20 of 4,000");
        crate::localizations::scope(Rc::new(crate::French), || {
            assert_eq!(page_range_label(2, 10, 4_000), "11–20 sur 4\u{a0}000");
        });
    }

    /// **A selection survives a page change**, because every index the table takes and
    /// gives back is an index into the set rather than into the slice: ticking a row on
    /// page 1 and paging away must not silently drop it.
    #[test]
    fn a_selection_made_on_one_page_is_still_there_on_the_next() {
        let rows: Vec<Vec<String>> = (0..30).map(|i| vec![format!("row {i}")]).collect();
        // The pager's own messages are numbered from a thousand, so the rows' can be told
        // from them without reaching into the footer's shape.
        let table = DataTable::<usize>::new(["N"], rows)
            .paginated(2, 10, |p| 1_000 + p)
            .selected(&[3, 15])
            .on_select_row(|i| i);
        let mut clicks = Vec::new();
        for child in Widget::<usize>::children(&table) {
            collect_clicks(child.as_ref(), &mut clicks);
        }
        clicks.retain(|m| *m < 1_000);
        clicks.dedup();
        assert_eq!(
            clicks,
            (10..20).collect::<Vec<_>>(),
            "a click on page 2 reports the set's index, not the slice's"
        );
    }

    #[test]
    fn page_size_selector_appears_in_the_footer() {
        let rows: Vec<Vec<String>> = (1..=7).map(|i| vec![i.to_string()]).collect();
        // inner = [table, footer]; the footer is a Flex row [label, spacer, pager, (selector)].
        let footer_len = |dt: &DataTable<()>| Widget::<()>::children(dt)[1].children().len();
        let base = DataTable::<()>::new(["N"], rows.clone()).paginated(1, 3, |_| ());
        let sized = DataTable::<()>::new(["N"], rows)
            .paginated(1, 3, |_| ())
            .page_sizes(&[3, 5], |_| ());
        assert_eq!(footer_len(&base), 3, "label + spacer + pager");
        assert_eq!(
            footer_len(&sized),
            5,
            "+ \"Rows per page\" and the selector"
        );
    }
}