Skip to main content

frus_widgets/
table.rs

1//! [`Table`]: a text data grid built from **`Flex` rows** (columns of **fixed or flexible**
2//! width). A header that **sorts** on click (with a direction indicator), **selectable**
3//! rows, and optional **multi-selection** through a checkbox column topped by a
4//! "check all" box.
5//!
6//! As everywhere else in frus, sorting and selection are **decided by the application**: the
7//! table emits a message on click (`on_sort`, `on_select_row`, `on_check`, `on_check_all`)
8//! and displays nothing but the state it is handed (`sorted`, `selected`).
9
10use std::rc::Rc;
11
12use frus_core::{Color, Insets, Path, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
13use frus_layout::{Align, Dimension, Justify, Style};
14
15use crate::flex::Flex;
16use crate::icons::{IconData, Icons};
17use crate::interaction::{Key, KeyResponse, Status};
18use crate::list::ListView;
19use crate::scroll::{Axis, SingleChildScrollView};
20use crate::stack::Stack;
21use crate::theme::Theme;
22use crate::widget::{CellFn, Widget};
23
24/// A column reordering: `(from, to, callback)`. The two indices bound keyboard
25/// reordering; the callback turns a move into a message.
26type ReorderSpec<Msg> = (usize, usize, Rc<dyn Fn(usize, usize) -> Msg>);
27
28/// A row of widget cells, built on demand from the row index.
29type RowWidgets<Msg> = Rc<dyn Fn(usize) -> Vec<Box<dyn Widget<Msg>>>>;
30
31const ROW_H: f32 = 34.0;
32const PAD_X: f32 = 10.0;
33
34/// A column heading's style: what the table was told, else what the theme says, else the
35/// reference's — a data table's headings are `titleSmall` and its cells `bodyMedium`, and
36/// they are **named apart** there rather than being one size for both.
37///
38/// **Resolved once** so that the number the box is measured with is the number the glyphs
39/// are drawn at. Resolving is the single place the reader's font setting is applied
40/// (milestone 403); a size that never passes through it is a size the reader cannot change.
41fn heading_style(theme: Option<&Theme>) -> ResolvedTextStyle {
42    theme
43        .and_then(|t| t.widgets.table.heading_text_style)
44        .unwrap_or_else(|| crate::theme::type_scale(theme).title_small)
45        .resolved()
46}
47
48/// A data cell's style. See [`heading_style`].
49fn data_style(theme: Option<&Theme>) -> ResolvedTextStyle {
50    theme
51        .and_then(|t| t.widgets.table.data_text_style)
52        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
53        .resolved()
54}
55
56/// The style a cell wears, given which of the two rows it is in.
57fn cell_text_style(header: bool, theme: Option<&Theme>) -> ResolvedTextStyle {
58    if header {
59        heading_style(theme)
60    } else {
61        data_style(theme)
62    }
63}
64
65/// Gap between rows and between cells (must match the geometry of the handles).
66const ROW_GAP: f32 = 2.0;
67/// Width of the checkbox column (multi-selection).
68const CHECK_W: f32 = 40.0;
69/// Side of the drawn checkbox.
70const BOX: f32 = 18.0;
71/// Width of a resize handle's **grab zone**.
72const HANDLE_W: f32 = 8.0;
73
74/// Shared background of a cell, by its role and the interaction (a shared factor).
75fn cell_background(
76    header: bool,
77    selected: bool,
78    clickable: bool,
79    theme: &Theme,
80    status: &Status,
81) -> Color {
82    let base = if header {
83        theme.surface.lerp(theme.on_surface, 0.06)
84    } else if selected {
85        theme.surface.lerp(theme.primary, 0.16)
86    } else {
87        theme.surface
88    };
89    if clickable {
90        theme.state_layer(base, theme.on_surface, status)
91    } else {
92        base
93    }
94}
95
96/// Style of a cell: the column width (fixed or flexible), and an **adaptive** height — a
97/// floor that grows with the content. Since the row aligns its cells with `Stretch`, they
98/// all follow the tallest one.
99///
100/// The floor is `ROW_H` *or what the label needs*, whichever is larger. A cell **paints**
101/// its label rather than laying it out, so as far as the layout is concerned its content
102/// is empty: a bare `ROW_H` would have been a ceiling in everything but name, and at a text
103/// scale of 2 a 15 px label wants 36 px of line inside 34.
104fn cell_style(width: Dimension, header: bool, theme: Option<&Theme>) -> Style {
105    let flex_grow = if matches!(width, Dimension::Length(_)) {
106        0.0
107    } else {
108        1.0
109    };
110    Style {
111        width,
112        height: Dimension::Auto,
113        // A **floor**, and one that answers to the reader: the cell paints its label
114        // rather than laying it out, so its content height is zero and `min_height` is the
115        // only thing standing between a row and text taller than it.
116        min_height: Dimension::Length(frus_text::line_box(
117            ROW_H,
118            &cell_text_style(header, theme),
119            0.0,
120        )),
121        flex_grow,
122        ..Default::default()
123    }
124}
125
126/// Side of a header icon (a `24×24` grid, scaled).
127const ICON: f32 = 16.0;
128/// Gap between a header icon and its label.
129const ICON_GAP: f32 = 6.0;
130
131/// A text cell (a header or a data cell), themed at render time.
132struct Cell<Msg> {
133    label: String,
134    width: Dimension,
135    header: bool,
136    selected: bool,
137    /// Row index (on a data cell): used to announce "Row N selected" to the screen
138    /// reader. `None` on a header.
139    row: Option<usize>,
140    /// **Leading** icon (headers only): painted before the label (icon + text).
141    icon: Option<IconData>,
142    /// The header's sort indicator: `Some(true)` = ▲, `Some(false)` = ▼.
143    sort: Option<bool>,
144    message: Option<Msg>,
145    /// **Reorderable** header: `(column index, column count, the on_reorder(from, to)
146    /// callback)`. The column count bounds keyboard reordering.
147    reorder: Option<ReorderSpec<Msg>>,
148    /// The header's **action widget** (0 or 1): a button (a filter, a menu…) placed on the
149    /// right, a **child** of the cell. It captures its own click (deepest-first hit-test)
150    /// while the rest of the header goes on sorting — hence a `Vec`, to expose it through
151    /// [`children`](Widget::children).
152    action: Vec<Box<dyn Widget<Msg>>>,
153}
154
155impl<Msg> Cell<Msg> {
156    fn sizing(&self, theme: Option<&Theme>) -> Style {
157        let base = cell_style(self.width, self.header, theme);
158        // With an action widget, it sits on the **right** (the label goes on being painted
159        // on the left) and is vertically centred.
160        if self.action.is_empty() {
161            base
162        } else {
163            Style {
164                justify: Justify::End,
165                align: Align::Center,
166                padding: Insets::new(0.0, PAD_X, 0.0, 0.0),
167                ..base
168            }
169        }
170    }
171}
172
173impl<Msg: Clone> Widget<Msg> for Cell<Msg> {
174    fn style(&self) -> Style {
175        self.sizing(None)
176    }
177
178    fn style_themed(&self, theme: &Theme) -> Style {
179        self.sizing(Some(theme))
180    }
181
182    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
183        &self.action
184    }
185
186    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
187        let o = status.opacity;
188        let clickable = self.message.is_some();
189        let bg = cell_background(self.header, self.selected, clickable, theme, &status);
190        if self.header || self.selected || bg != theme.surface {
191            scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
192        }
193
194        let color = if self.header {
195            theme.muted
196        } else {
197            theme.on_surface
198        };
199        // The style's **own** line, not one recomputed from its size: milestone 412 left
200        // this one behind because it was written against a bare constant rather than a
201        // style, which is exactly the formulation a compiler cannot find.
202        let style = cell_text_style(self.header, Some(theme));
203        let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
204
205        // Leading icon (headers): painted on the left, the label shifted after it.
206        let mut text_x = bounds.x + PAD_X;
207        if let Some(icon) = self.icon {
208            let iy = bounds.y + (bounds.height - ICON) * 0.5;
209            let path = icon.placed(ICON, text_x, iy, theme.direction);
210            scene.fill_path(&path, color.fade(o));
211            text_x += ICON + ICON_GAP;
212        }
213        scene.text(
214            Point::new(text_x, ty),
215            self.label.clone(),
216            &style,
217            color.fade(o),
218        );
219
220        if let (true, Some(ascending)) = (self.header, self.sort) {
221            // Measured with the style the label was **drawn** with: a sort arrow placed
222            // from a bare size lands inside the word the moment the reader enlarges it.
223            let lw = frus_text::measure_resolved(&self.label, &style).width;
224            let cx = text_x + lw + 8.0;
225            let cy = bounds.y + bounds.height * 0.5;
226            let (w, h) = (4.0, 4.0);
227            let tri = if ascending {
228                Path::new()
229                    .move_to(Point::new(cx, cy - h))
230                    .line_to(Point::new(cx - w, cy + h))
231                    .line_to(Point::new(cx + w, cy + h))
232                    .close()
233            } else {
234                Path::new()
235                    .move_to(Point::new(cx, cy + h))
236                    .line_to(Point::new(cx - w, cy - h))
237                    .line_to(Point::new(cx + w, cy - h))
238                    .close()
239            };
240            scene.fill_path(&tri, theme.on_surface.fade(o));
241        }
242    }
243
244    fn on_click(&self) -> Option<Msg> {
245        self.message.clone()
246    }
247
248    fn focusable(&self) -> bool {
249        // Only sortable headers take keyboard focus (Enter/Space = sort); data cells stay
250        // clickable with the mouse without cluttering the Tab cycle.
251        self.header && self.message.is_some()
252    }
253
254    fn reorder_index(&self) -> Option<usize> {
255        self.reorder.as_ref().map(|(col, _, _)| *col)
256    }
257
258    fn on_reorder(&self, to: usize) -> Option<Msg> {
259        self.reorder.as_ref().map(|(col, _, cb)| cb(*col, to))
260    }
261
262    fn announce(&self) -> Option<String> {
263        // A sortable header: announces the **resulting** sort to the screen reader (it flips
264        // the current direction: ascending by default, otherwise inverted — the usual pattern).
265        if self.header && self.message.is_some() {
266            let ascending = !matches!(self.sort, Some(true));
267            let dir = if ascending { "ascending" } else { "descending" };
268            return Some(format!("Sorted by {} {}", self.label, dir));
269        }
270        // A selectable data cell: announces the row's **resulting** state.
271        if let (false, Some(row), true) = (self.header, self.row, self.message.is_some()) {
272            let verb = if self.selected {
273                "deselected"
274            } else {
275                "selected"
276            };
277            return Some(format!("Row {} {}", row + 1, verb));
278        }
279        None
280    }
281
282    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
283        // A header: announced to screen readers (label + position when it is reorderable, so
284        // that a user perceives a column move when going through them again).
285        if !self.header {
286            return None;
287        }
288        let mut sem =
289            frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
290        if let Some((col, columns, _)) = &self.reorder {
291            sem = sem.value(format!("column {} of {}", col + 1, columns));
292        }
293        Some(sem)
294    }
295
296    fn on_key(&self, key: &Key) -> KeyResponse<Msg> {
297        // Ctrl+Arrows (Left/Right with `word`) on a focused header: moves the column by one
298        // step (clamped). Bare arrows let the focus navigate instead.
299        let Some((col, columns, cb)) = self.reorder.as_ref() else {
300            return KeyResponse::Ignored;
301        };
302        let to = match key {
303            Key::Left { word: true, .. } => col.checked_sub(1),
304            Key::Right { word: true, .. } if col + 1 < *columns => Some(col + 1),
305            _ => return KeyResponse::Ignored,
306        };
307        match to {
308            Some(to) => KeyResponse::Handled(Some(cb(*col, to))),
309            None => KeyResponse::Ignored, // at the edge: let the focus navigate
310        }
311    }
312}
313
314/// A checkbox cell (the multi-selection column).
315struct CheckCell<Msg> {
316    checked: bool,
317    /// The **indeterminate** state (some rows checked, not all) — the "check all" box.
318    /// It takes precedence over the unchecked look; ignored when `checked`.
319    indeterminate: bool,
320    header: bool,
321    selected: bool,
322    message: Option<Msg>,
323}
324
325impl<Msg: Clone> Widget<Msg> for CheckCell<Msg> {
326    fn style(&self) -> Style {
327        cell_style(Dimension::Length(CHECK_W), self.header, None)
328    }
329
330    fn style_themed(&self, theme: &Theme) -> Style {
331        cell_style(Dimension::Length(CHECK_W), self.header, Some(theme))
332    }
333
334    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
335        &[]
336    }
337
338    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
339        let o = status.opacity;
340        let clickable = self.message.is_some();
341        let bg = cell_background(self.header, self.selected, clickable, theme, &status);
342        if self.header || self.selected || bg != theme.surface {
343            scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
344        }
345
346        let bx = bounds.x + (bounds.width - BOX) * 0.5;
347        let by = bounds.y + (bounds.height - BOX) * 0.5;
348        let box_rect = Rect::new(bx, by, BOX, BOX);
349        if self.checked {
350            scene.draw_rect(
351                box_rect,
352                theme.primary.fade(o),
353                4.0,
354                0.0,
355                Color::TRANSPARENT,
356            );
357            // The tick: the filled Check icon, centred in the box.
358            let tick = BOX - 4.0;
359            let inset = (BOX - tick) * 0.5;
360            let path = Icons::CHECK.placed(tick, bx + inset, by + inset, theme.direction);
361            scene.fill_path(&path, theme.on_primary.fade(o));
362        } else if self.indeterminate {
363            // Indeterminate: a filled box crossed by a dash.
364            scene.draw_rect(
365                box_rect,
366                theme.primary.fade(o),
367                4.0,
368                0.0,
369                Color::TRANSPARENT,
370            );
371            let dash = Rect::new(bx + 4.0, by + BOX * 0.5 - 1.0, BOX - 8.0, 2.0);
372            scene.draw_rect(dash, theme.on_primary.fade(o), 1.0, 0.0, Color::TRANSPARENT);
373        } else {
374            scene.draw_rect(box_rect, Color::TRANSPARENT, 4.0, 1.5, theme.muted.fade(o));
375        }
376    }
377
378    fn on_click(&self) -> Option<Msg> {
379        self.message.clone()
380    }
381
382    fn focusable(&self) -> bool {
383        self.message.is_some()
384    }
385
386    fn announce(&self) -> Option<String> {
387        // A checkbox: announces the **resulting** state of the toggle (checked → it will be
388        // unchecked). The header's box acts on **every** row.
389        self.message.as_ref()?;
390        let selecting = !self.checked;
391        let words = crate::localizations::of();
392        Some(match (self.header, selecting) {
393            (true, true) => words.all_rows_selected_label().into(),
394            (true, false) => words.all_rows_deselected_label().into(),
395            (false, true) => words.row_selected_label().into(),
396            (false, false) => words.row_deselected_label().into(),
397        })
398    }
399}
400
401/// Factory of a cell widget: **called again on every rebuild** (the table rebuilds itself
402/// after each setting), so it produces a **fresh** widget every time. This is what allows
403/// rich cells (avatars, chips, action buttons) beyond plain text.
404pub type CellFactory<Msg> = std::rc::Rc<dyn Fn() -> Box<dyn Widget<Msg>>>;
405
406/// Content of a data row: plain text, or widgets (one per column).
407enum RowKind<Msg> {
408    Text(Vec<String>),
409    Widgets(Vec<CellFactory<Msg>>),
410}
411
412/// Factory for the content of a **virtualised** row, by index: texts (one value per column)
413/// or widgets (one per column). Shared (`Rc`) so it can be captured in the virtualised
414/// list's `'static` factory.
415#[derive(Clone)]
416enum VirtualBuild<Msg> {
417    Text(std::rc::Rc<dyn Fn(usize) -> Vec<String>>),
418    Widgets(RowWidgets<Msg>),
419}
420
421/// A **widget** cell: arbitrary content (centred, with the cell background and, when the row
422/// is selectable, the selection click). The content paints on top.
423struct WidgetCell<Msg> {
424    width: Dimension,
425    selected: bool,
426    /// A **header** cell (with the header background) rather than a data cell.
427    header: bool,
428    /// Row index: to announce "Row N selected" to the screen reader.
429    row: usize,
430    message: Option<Msg>,
431    content: Vec<Box<dyn Widget<Msg>>>,
432}
433
434impl<Msg> WidgetCell<Msg> {
435    fn sizing(&self, theme: Option<&Theme>) -> Style {
436        Style {
437            align: Align::Center,
438            padding: Insets::new(0.0, PAD_X, 0.0, PAD_X),
439            // A widget cell carries no label of its own, and still has to be as tall as
440            // the text cells beside it — the row stretches to the tallest.
441            ..cell_style(self.width, false, theme)
442        }
443    }
444}
445
446impl<Msg: Clone> Widget<Msg> for WidgetCell<Msg> {
447    fn style(&self) -> Style {
448        self.sizing(None)
449    }
450
451    fn style_themed(&self, theme: &Theme) -> Style {
452        self.sizing(Some(theme))
453    }
454
455    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
456        &self.content
457    }
458
459    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
460        let o = status.opacity;
461        let clickable = self.message.is_some();
462        let bg = cell_background(self.header, self.selected, clickable, theme, &status);
463        if self.header || self.selected || bg != theme.surface {
464            scene.draw_rect(bounds, bg.fade(o), theme.radius, 0.0, Color::TRANSPARENT);
465        }
466    }
467
468    fn on_click(&self) -> Option<Msg> {
469        self.message.clone()
470    }
471
472    fn announce(&self) -> Option<String> {
473        // A selectable widget row: announces the selection's **resulting** state.
474        self.message.as_ref()?;
475        let verb = if self.selected {
476            "deselected"
477        } else {
478            "selected"
479        };
480        Some(format!("Row {} {}", self.row + 1, verb))
481    }
482}
483
484/// Width of a frozen column's **separation shadow** gradient.
485const FROZEN_SHADOW_W: f32 = 8.0;
486
487/// The frozen columns' **separation shadow** layer: a gradient (scrim → transparent) placed
488/// at the inner edge of the pinned block, over the scrolling area — the visual cue of the
489/// freeze. **Inert** (it captures no click): the cells beneath stay clickable.
490struct FrozenShadow {
491    /// Size of the layer (it fills the stack) — otherwise `Auto` would shrink it to 0×0.
492    width: f32,
493    height: f32,
494    /// x of the right edge of the **left** pinned block (the shadow runs rightwards).
495    left: Option<f32>,
496    /// x of the left edge of the **right** pinned block (the shadow runs leftwards).
497    right: Option<f32>,
498}
499
500impl<Msg: Clone> Widget<Msg> for FrozenShadow {
501    fn style(&self) -> Style {
502        Style {
503            width: Dimension::Length(self.width),
504            height: Dimension::Length(self.height),
505            ..Default::default()
506        }
507    }
508
509    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
510        &[]
511    }
512
513    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
514        let o = status.opacity;
515        let dark = theme.scheme.scrim.with_alpha(0.28 * o);
516        let clear = theme.scheme.scrim.with_alpha(0.0);
517        if let Some(x) = self.left {
518            scene.gradient_rect(
519                Rect::new(bounds.x + x, bounds.y, FROZEN_SHADOW_W, bounds.height),
520                dark,
521                clear,
522                [1.0, 0.0],
523                0.0,
524                0.0,
525                Color::TRANSPARENT,
526            );
527        }
528        if let Some(x) = self.right {
529            scene.gradient_rect(
530                Rect::new(
531                    bounds.x + x - FROZEN_SHADOW_W,
532                    bounds.y,
533                    FROZEN_SHADOW_W,
534                    bounds.height,
535                ),
536                clear,
537                dark,
538                [1.0, 0.0],
539                0.0,
540                0.0,
541                Color::TRANSPARENT,
542            );
543        }
544    }
545
546    fn on_click(&self) -> Option<Msg> {
547        None
548    }
549}
550
551/// A transparent, **inert** space (neither clickable nor draggable): it wedges the resize
552/// handles onto the column edges without blocking clicks (the handle layer floats above
553/// the grid).
554struct Spacer {
555    width: f32,
556    height: f32,
557}
558
559impl<Msg: Clone> Widget<Msg> for Spacer {
560    fn style(&self) -> Style {
561        Style {
562            width: Dimension::Length(self.width),
563            height: Dimension::Length(self.height),
564            ..Default::default()
565        }
566    }
567
568    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
569        &[]
570    }
571
572    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
573
574    fn on_click(&self) -> Option<Msg> {
575        None
576    }
577}
578
579/// A column **resize handle**: a thin vertical bar at a column's right edge. Dragged
580/// horizontally, it emits `on_resize(col, dx)` where `dx` is the movement (px) since the last
581/// event — the application **accumulates** the width. The handle layer floats above the grid.
582struct ResizeHandle<Msg> {
583    col: usize,
584    height: f32,
585    on_resize: Rc<dyn Fn(usize, f32) -> Msg>,
586}
587
588impl<Msg: Clone> Widget<Msg> for ResizeHandle<Msg> {
589    fn style(&self) -> Style {
590        Style {
591            width: Dimension::Length(HANDLE_W),
592            height: Dimension::Length(self.height),
593            ..Default::default()
594        }
595    }
596
597    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
598        &[]
599    }
600
601    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
602        let o = status.opacity;
603        // A centred grab line: discreet at rest, tinted `primary` and thickened on hover /
604        // while being dragged.
605        let t = status.hover_progress;
606        let color = theme.border.lerp(theme.primary, t);
607        let lw = 1.0 + t; // 1 px → 2 px
608        let x = bounds.x + (HANDLE_W - lw) * 0.5;
609        scene.draw_rect(
610            Rect::new(x, bounds.y, lw, bounds.height),
611            color.fade(o),
612            0.0,
613            0.0,
614            Color::TRANSPARENT,
615        );
616    }
617
618    fn on_click(&self) -> Option<Msg> {
619        None
620    }
621
622    fn draggable(&self) -> bool {
623        true
624    }
625
626    fn on_drag_delta(&self, dx: f32) -> Option<Msg> {
627        // A zero delta (a press, or purely vertical movement) changes nothing.
628        if dx == 0.0 {
629            None
630        } else {
631            Some((self.on_resize)(self.col, dx))
632        }
633    }
634}
635
636/// A data grid with fixed or flexible columns (see the module).
637pub struct Table<Msg = crate::callback::Callback> {
638    columns: usize,
639    headers: Vec<String>,
640    /// Leading icon per header column (icon + label). Missing = none.
641    header_icons: Vec<Option<IconData>>,
642    /// A **fully widget** header (per column): it replaces the text header row. Empty = the
643    /// ordinary text header. Automatic sorting/reordering does not apply to these headers —
644    /// the application wires the behaviour into its own widgets.
645    header_widgets: Vec<CellFactory<Msg>>,
646    /// Action widget per header column (a filter/menu button), built again on every rebuild.
647    /// `None` = none. Placed on the right of the header, it captures its own click.
648    header_actions: Vec<Option<CellFactory<Msg>>>,
649    rows: Vec<RowKind<Msg>>,
650    /// Width per column: `> 0` = fixed (px), `<= 0` = flexible (an equal share).
651    widths: Vec<f32>,
652    total_width: Option<f32>,
653    sort: Option<(usize, bool)>,
654    selected: Vec<usize>,
655    on_sort: Option<Box<dyn Fn(usize) -> Msg>>,
656    on_select: Option<Rc<dyn Fn(usize) -> Msg>>,
657    on_check: Option<Rc<dyn Fn(usize) -> Msg>>,
658    on_check_all: Option<Msg>,
659    /// Resize callback (column, delta px). Only active when every column has a **fixed**
660    /// width (the geometry of the edges is then known).
661    on_resize: Option<Rc<dyn Fn(usize, f32) -> Msg>>,
662    /// Reorder callback (`on_reorder(from, to)`): dragging a header onto another moves the
663    /// column. Also requires `on_sort` (clickable headers).
664    on_reorder: Option<Rc<dyn Fn(usize, usize) -> Msg>>,
665    /// **Virtualised** mode: `(row count, viewport height, row factory by index — texts or
666    /// widgets)`. Only the **visible** rows are built (internal scrolling), for grids of
667    /// thousands of rows. Excludes `rows`.
668    virtual_data: Option<(usize, f32, VirtualBuild<Msg>)>,
669    /// **Frozen** columns `(left, right)`: pinned at both edges while the middle scrolls
670    /// horizontally. Requires a total width and columns that are all **fixed**.
671    frozen: (usize, usize),
672    heading_text_style: Option<TextStyle>,
673    data_text_style: Option<TextStyle>,
674    root: Box<dyn Widget<Msg>>,
675}
676
677/// Dimension of a column from the widths: fixed when `> 0`, flexible otherwise (a factor
678/// shared by the direct build and the virtualised one).
679fn col_dimension(widths: &[f32], c: usize) -> Dimension {
680    match widths.get(c).copied().unwrap_or(0.0) {
681        w if w > 0.0 => Dimension::Length(w),
682        _ => Dimension::Auto,
683    }
684}
685
686impl<Msg: Clone + 'static> Table<Msg> {
687    /// Creates a table of `columns` columns (flexible, of equal width by default).
688    pub fn new(columns: usize) -> Self {
689        let columns = columns.max(1);
690        Self {
691            columns,
692            headers: Vec::new(),
693            header_icons: Vec::new(),
694            header_widgets: Vec::new(),
695            header_actions: Vec::new(),
696            rows: Vec::new(),
697            widths: vec![0.0; columns],
698            total_width: None,
699            sort: None,
700            selected: Vec::new(),
701            on_sort: None,
702            on_select: None,
703            on_check: None,
704            on_check_all: None,
705            on_resize: None,
706            on_reorder: None,
707            virtual_data: None,
708            frozen: (0, 0),
709            heading_text_style: None,
710            data_text_style: None,
711            root: Box::new(Flex::<Msg>::column().gap(ROW_GAP)),
712        }
713    }
714
715    /// The column headings' type, over the theme's and the reference's.
716    ///
717    /// It reaches the cells through [`Widget::theme_override`] rather than through their
718    /// fields: cells are built in half a dozen places here — the header row, the data rows,
719    /// the virtualised rows, the frozen columns — and a value carried down the theme
720    /// arrives at all of them without any of them being taught to pass it on.
721    #[must_use]
722    pub fn heading_text_style(mut self, style: TextStyle) -> Self {
723        self.heading_text_style = Some(style);
724        self
725    }
726
727    /// The cells' type, over the theme's and the reference's. See
728    /// [`heading_text_style`](Self::heading_text_style).
729    #[must_use]
730    pub fn data_text_style(mut self, style: TextStyle) -> Self {
731        self.data_text_style = Some(style);
732        self
733    }
734
735    /// Sets the header row (one label per column).
736    pub fn header(mut self, labels: &[&str]) -> Self {
737        self.headers = labels.iter().map(|s| s.to_string()).collect();
738        self.rebuild();
739        self
740    }
741
742    /// Replaces the header row with **fully widget headers** (one per column) — for heavily
743    /// customised grids (a bespoke sort button, an embedded filter, a two-line title…).
744    /// **Automatic** sorting and reordering do not apply here: the application wires the
745    /// behaviour into the widgets it supplies (e.g. a button emitting its own sort message).
746    /// Each factory is called again on every rebuild (a fresh widget). Excludes
747    /// [`header`](Self::header) (the last one called wins).
748    pub fn widget_header(mut self, cells: Vec<CellFn<Msg>>) -> Self {
749        self.header_widgets = cells.into_iter().map(std::rc::Rc::from).collect();
750        self.headers.clear();
751        self.rebuild();
752        self
753    }
754
755    /// Gives header columns a **leading icon** (icon + label): `None` leaves the column
756    /// without one. The header stays **sortable** and **reorderable** just like a text
757    /// header (the icon is purely decorative).
758    pub fn header_icons(mut self, icons: &[Option<IconData>]) -> Self {
759        self.header_icons = icons.to_vec();
760        self.rebuild();
761        self
762    }
763
764    /// Places an **action widget** (a filter button, a menu…) on the right of column `col`'s
765    /// header. It is a **child** of the cell: it captures its own click (deepest-first
766    /// hit-test), while the rest of the header **sorts** and **reorders** as usual. The
767    /// factory is called again on every rebuild (a fresh widget).
768    ///
769    /// **Column menu**: pass a [`PopupMenuButton`](crate::PopupMenuButton) or a [`DropdownButton`](crate::DropdownButton) here.
770    /// Its **floating** menu is rendered even when **nested** inside the header (overlays are
771    /// collected at any depth), reachable with **Tab** and driven by the arrows/Enter, and
772    /// closed by Escape / an outside click — with no table-specific code (the application
773    /// drives the menu's open/closed state).
774    pub fn header_action(
775        mut self,
776        col: usize,
777        make: impl Fn() -> Box<dyn Widget<Msg>> + 'static,
778    ) -> Self {
779        if col < self.columns {
780            if self.header_actions.len() <= col {
781                self.header_actions.resize_with(col + 1, || None);
782            }
783            self.header_actions[col] = Some(std::rc::Rc::new(make));
784        }
785        self.rebuild();
786        self
787    }
788
789    /// Adds a data row (one **text** value per column).
790    pub fn row(mut self, cells: &[&str]) -> Self {
791        self.rows
792            .push(RowKind::Text(cells.iter().map(|s| s.to_string()).collect()));
793        self.rebuild();
794        self
795    }
796
797    /// Adds a row whose every cell is a **widget** (an avatar, a chip, an action button…),
798    /// supplied by a **factory** called again on every rebuild. The row stays selectable
799    /// (a background on click, outside the internal clickable zones).
800    ///
801    /// **Sorting a widget column**: the table cannot compare widgets — it only emits the
802    /// column that was clicked (`on_sort`). The **application** supplies the key: on the sort
803    /// message it orders its own data by the field matching that column (e.g. the name behind
804    /// an avatar), then hands the already-sorted rows back.
805    pub fn widget_row(mut self, cells: Vec<CellFn<Msg>>) -> Self {
806        self.rows.push(RowKind::Widgets(
807            cells.into_iter().map(std::rc::Rc::from).collect(),
808        ));
809        self.rebuild();
810        self
811    }
812
813    /// Sets the table's total width, in logical pixels (the flexible columns share what is
814    /// left).
815    pub fn width(mut self, width: f32) -> Self {
816        self.total_width = Some(width);
817        self.rebuild();
818        self
819    }
820
821    /// **Fixed** width of each column, in pixels (`0` or less = a flexible column). Missing
822    /// entries leave the column flexible.
823    pub fn column_widths(mut self, widths: &[f32]) -> Self {
824        for (i, w) in widths.iter().enumerate().take(self.columns) {
825            self.widths[i] = *w;
826        }
827        self.rebuild();
828        self
829    }
830
831    /// Makes the headers **clickable**: `on_sort(column)` on a click on a header.
832    pub fn on_sort<R: crate::callback::IntoMsg<Msg>>(
833        mut self,
834        on_sort: impl Fn(usize) -> R + 'static,
835    ) -> Self {
836        self.on_sort = Some(Box::new(crate::callback::handler1(on_sort)));
837        self.rebuild();
838        self
839    }
840
841    /// States the sorted column and its direction (`true` = ascending) → shows the indicator.
842    pub fn sorted(mut self, column: usize, ascending: bool) -> Self {
843        self.sort = Some((column, ascending));
844        self.rebuild();
845        self
846    }
847
848    /// Makes the rows **clickable**: `on_select_row(row)` on a click on a row.
849    pub fn on_select_row<R: crate::callback::IntoMsg<Msg>>(
850        mut self,
851        on_select: impl Fn(usize) -> R + 'static,
852    ) -> Self {
853        self.on_select = Some(Rc::new(crate::callback::handler1(on_select)));
854        self.rebuild();
855        self
856    }
857
858    /// Turns on **multi-selection**: a checkbox column (on the left) topped by a "check all"
859    /// box. `on_check(row)` toggles one row, `on_check_all` toggles every row. The checked
860    /// state mirrors [`selected`](Self::selected).
861    pub fn checkboxes(
862        mut self,
863        on_check: impl Fn(usize) -> Msg + 'static,
864        on_check_all: Msg,
865    ) -> Self {
866        self.on_check = Some(Rc::new(on_check));
867        self.on_check_all = Some(on_check_all);
868        self.rebuild();
869        self
870    }
871
872    /// **Freezes** the first `n` columns: they stay **pinned** on the left while the rest
873    /// **scrolls horizontally** (the scrolling columns' header follows its columns), with a
874    /// **separation shadow** at the freeze edge. For wide grids where an identifier must stay
875    /// in view. Requires a total [`width`](Self::width) and columns that are **all fixed**
876    /// ([`column_widths`](Self::column_widths)) — otherwise it does nothing. **Text** tables
877    /// only; not combined with virtualisation / checkboxes / resizing / reordering.
878    pub fn frozen_columns(mut self, n: usize) -> Self {
879        self.frozen.0 = n;
880        self.rebuild();
881        self
882    }
883
884    /// **Freezes** the **last** `m` columns (pinned on the **right** — action columns,
885    /// totals…), the middle scrolling. Combines with
886    /// [`frozen_columns`](Self::frozen_columns) (both edges frozen). Same conditions and
887    /// exclusions.
888    pub fn frozen_columns_right(mut self, m: usize) -> Self {
889        self.frozen.1 = m;
890        self.rebuild();
891        self
892    }
893
894    /// Switches the table to **virtualised** mode: `count` data rows, of which only the
895    /// **visible** ones are built/laid out/painted (internal vertical scrolling inside a
896    /// viewport of `viewport_height` px). `build(index)` supplies the row's **texts** (one
897    /// value per column). Indispensable for large grids (thousands of rows): the per-frame
898    /// cost is ∝ the visible rows, not the total. The header stays **pinned**.
899    ///
900    /// Selection: [`on_select_row`](Self::on_select_row) and [`selected`](Self::selected) work
901    /// on the visible rows; **multi-selection** ([`checkboxes`](Self::checkboxes)) is
902    /// supported too (a checkbox column + a pinned "check all"). Not combined with resizing /
903    /// reordering — they are ignored in virtualised mode. Excludes [`row`](Self::row).
904    pub fn virtual_rows(
905        mut self,
906        count: usize,
907        viewport_height: f32,
908        build: impl Fn(usize) -> Vec<String> + 'static,
909    ) -> Self {
910        self.virtual_data = Some((count, viewport_height, VirtualBuild::Text(Rc::new(build))));
911        self.rows.clear();
912        self.rebuild();
913        self
914    }
915
916    /// Like [`virtual_rows`](Self::virtual_rows), but every row is made of **widgets**
917    /// (avatars, chips, buttons…): `build(index)` returns one widget per column. Only the
918    /// visible rows are built. Click selection **and** checkboxes; the header stays pinned.
919    /// Same exclusions (resizing / reordering).
920    pub fn virtual_widget_rows(
921        mut self,
922        count: usize,
923        viewport_height: f32,
924        build: impl Fn(usize) -> Vec<Box<dyn Widget<Msg>>> + 'static,
925    ) -> Self {
926        self.virtual_data = Some((
927            count,
928            viewport_height,
929            VirtualBuild::Widgets(Rc::new(build)),
930        ));
931        self.rows.clear();
932        self.rebuild();
933        self
934    }
935
936    /// Makes the columns **resizable** with the mouse: a thin handle at each column's right
937    /// edge (except the last) emits `on_resize(column, delta_px)` when dragged. The
938    /// application **accumulates** the width, e.g.
939    /// `widths[col] = (widths[col] + delta).max(MIN)`, and hands it back through
940    /// [`column_widths`](Self::column_widths). Only has an effect when **every** column has a
941    /// fixed width (so the edges are known).
942    pub fn on_resize<R: crate::callback::IntoMsg<Msg>>(
943        mut self,
944        on_resize: impl Fn(usize, f32) -> R + 'static,
945    ) -> Self {
946        self.on_resize = Some(Rc::new(crate::callback::handler2(on_resize)));
947        self.rebuild();
948        self
949    }
950
951    /// Makes the columns **reorderable**: dragging a header (past the threshold) and dropping
952    /// it on another emits `on_reorder(from, to)`; the application permutes its own column
953    /// order. A plain **click** still sorts (`on_sort`). Does nothing when the headers are
954    /// not clickable.
955    pub fn on_reorder<R: crate::callback::IntoMsg<Msg>>(
956        mut self,
957        on_reorder: impl Fn(usize, usize) -> R + 'static,
958    ) -> Self {
959        self.on_reorder = Some(Rc::new(crate::callback::handler2(on_reorder)));
960        self.rebuild();
961        self
962    }
963
964    /// States the selected rows (highlighted, their boxes checked).
965    pub fn selected(mut self, rows: &[usize]) -> Self {
966        self.selected = rows.to_vec();
967        self.rebuild();
968        self
969    }
970
971    /// Dimension of column `c`: fixed when `widths[c] > 0`, flexible otherwise.
972    fn col_width(&self, c: usize) -> Dimension {
973        col_dimension(&self.widths, c)
974    }
975
976    /// One `Flex` row, at the table's total width when one is set.
977    fn new_row(&self) -> Flex<Msg> {
978        let row = Flex::row().gap(ROW_GAP);
979        match self.total_width {
980            Some(w) => row.width(w),
981            None => row,
982        }
983    }
984
985    /// Number of data rows: the **virtualised** count when one is set, otherwise the
986    /// materialised rows. (In virtualised mode, `rows` is empty.)
987    fn row_count(&self) -> usize {
988        self.virtual_data
989            .as_ref()
990            .map(|(count, _, _)| *count)
991            .unwrap_or(self.rows.len())
992    }
993
994    /// Number of selected rows **within range** (indices `< row_count`). Assumes unique, valid
995    /// indices ([`selected`](Self::selected)'s contract) — O(selection), so it stays viable
996    /// even for a virtualised grid of millions of rows.
997    fn selected_count(&self) -> usize {
998        let n = self.row_count();
999        self.selected.iter().filter(|&&r| r < n).count()
1000    }
1001
1002    /// True when every row is selected (for the "check all" box).
1003    fn all_selected(&self) -> bool {
1004        let n = self.row_count();
1005        n > 0 && self.selected_count() == n
1006    }
1007
1008    /// True when **some** rows (not all) are selected → an indeterminate "check all".
1009    fn some_selected(&self) -> bool {
1010        let s = self.selected_count();
1011        s > 0 && s < self.row_count()
1012    }
1013
1014    /// The handle layer (one per column edge, except the last), to be laid over the grid.
1015    /// `None` when the table is not resizable, or when the columns are not all fixed.
1016    fn resize_overlay(&self, total_h: f32) -> Option<Flex<Msg>> {
1017        let on_resize = self.on_resize.as_ref()?;
1018        // The edges are only known when every column is fixed.
1019        if !(0..self.columns).all(|c| self.widths.get(c).copied().unwrap_or(0.0) > 0.0) {
1020            return None;
1021        }
1022        let base = if self.on_check.is_some() {
1023            CHECK_W + ROW_GAP
1024        } else {
1025            0.0
1026        };
1027        // A row with no gap: the spacing is materialised by spacers.
1028        let mut row = Flex::row();
1029        let mut consumed = 0.0f32;
1030        let mut edge = base;
1031        for c in 0..self.columns {
1032            edge += self.widths[c]; // right edge of column c
1033            if c + 1 < self.columns {
1034                let handle_left = edge - HANDLE_W * 0.5;
1035                row = row
1036                    .child(Spacer {
1037                        width: (handle_left - consumed).max(0.0),
1038                        height: total_h,
1039                    })
1040                    .child(ResizeHandle {
1041                        col: c,
1042                        height: total_h,
1043                        on_resize: on_resize.clone(),
1044                    });
1045                consumed = handle_left + HANDLE_W;
1046            }
1047            edge += ROW_GAP; // the inter-column gap
1048        }
1049        Some(row)
1050    }
1051
1052    /// Header cell for **frozen columns** mode (label + icon + sorting, with no action and
1053    /// no reordering).
1054    fn frozen_header_cell(&self, c: usize) -> Cell<Msg> {
1055        let sort = self.sort.filter(|(col, _)| *col == c).map(|(_, asc)| asc);
1056        Cell {
1057            label: self.headers.get(c).cloned().unwrap_or_default(),
1058            width: self.col_width(c),
1059            header: true,
1060            selected: false,
1061            row: None,
1062            icon: self.header_icons.get(c).copied().flatten(),
1063            sort,
1064            message: self.on_sort.as_ref().map(|f| f(c)),
1065            reorder: None,
1066            action: Vec::new(),
1067        }
1068    }
1069
1070    /// Builds a **block of columns** (header + text rows) for the columns `cols`, at width
1071    /// `w`. Assumes **text** rows (validated upstream).
1072    fn frozen_block(
1073        &self,
1074        cols: std::ops::Range<usize>,
1075        w: f32,
1076        header_present: bool,
1077    ) -> Flex<Msg> {
1078        let mut col = Flex::column().gap(ROW_GAP).width(w);
1079        if header_present {
1080            let mut hr = Flex::row().gap(ROW_GAP).width(w);
1081            for c in cols.clone() {
1082                hr = hr.child(self.frozen_header_cell(c));
1083            }
1084            col = col.child(hr);
1085        }
1086        for (r, row) in self.rows.iter().enumerate() {
1087            let RowKind::Text(cells) = row else { continue };
1088            let selected = self.selected.contains(&r);
1089            let mut rr = Flex::row().gap(ROW_GAP).width(w);
1090            for c in cols.clone() {
1091                rr = rr.child(Cell {
1092                    label: cells.get(c).cloned().unwrap_or_default(),
1093                    width: self.col_width(c),
1094                    header: false,
1095                    selected,
1096                    row: Some(r),
1097                    icon: None,
1098                    sort: None,
1099                    message: self.on_select.as_ref().map(|f| f(r)),
1100                    reorder: None,
1101                    action: Vec::new(),
1102                });
1103            }
1104            col = col.child(rr);
1105        }
1106        col
1107    }
1108
1109    /// Builds the **frozen columns** layout: a pinned block on the left and/or the right, the
1110    /// middle inside a horizontal scroll (header included), with a **separation shadow** at
1111    /// each freeze edge. `None` when the conditions are not met (no total width, columns not
1112    /// all fixed, counts out of range, or an unsupported combination: virtualisation /
1113    /// checkboxes / widget rows) → it falls back to the ordinary layout.
1114    fn build_frozen(&self) -> Option<Box<dyn Widget<Msg>>> {
1115        let (left, right) = self.frozen;
1116        if (left == 0 && right == 0) || self.virtual_data.is_some() || self.on_check.is_some() {
1117            return None;
1118        }
1119        if left + right >= self.columns {
1120            return None;
1121        }
1122        let total_width = self.total_width?;
1123        if !(0..self.columns).all(|c| self.widths.get(c).copied().unwrap_or(0.0) > 0.0) {
1124            return None;
1125        }
1126        // Frozen columns: text only (off-screen content is not retained).
1127        if self.rows.iter().any(|r| !matches!(r, RowKind::Text(_))) {
1128            return None;
1129        }
1130
1131        let span = |a: usize, b: usize| -> f32 {
1132            if b <= a {
1133                return 0.0;
1134            }
1135            let cols: f32 = (a..b).map(|c| self.widths[c]).sum();
1136            cols + ROW_GAP * ((b - a) - 1) as f32
1137        };
1138        let mid_end = self.columns - right;
1139        let (left_w, mid_w, right_w) = (
1140            span(0, left),
1141            span(left, mid_end),
1142            span(mid_end, self.columns),
1143        );
1144
1145        let header_present = !self.headers.is_empty();
1146        let n_rows = header_present as usize + self.rows.len();
1147        let total_h = if n_rows > 0 {
1148            n_rows as f32 * ROW_H + (n_rows as f32 - 1.0) * ROW_GAP
1149        } else {
1150            0.0
1151        };
1152
1153        // Gaps between the blocks that are present (the scrolling middle is always there).
1154        let gaps = (left > 0) as usize + (right > 0) as usize;
1155        let viewport_w = (total_width - left_w - right_w - gaps as f32 * ROW_GAP).max(0.0);
1156
1157        let mut row = Flex::row().gap(ROW_GAP).width(total_width);
1158        if left > 0 {
1159            row = row.child(self.frozen_block(0..left, left_w, header_present));
1160        }
1161        row = row.child(
1162            SingleChildScrollView::new()
1163                .axis(Axis::Horizontal)
1164                .width(viewport_w)
1165                .height(total_h)
1166                .child(self.frozen_block(left..mid_end, mid_w, header_present)),
1167        );
1168        if right > 0 {
1169            row = row.child(self.frozen_block(mid_end..self.columns, right_w, header_present));
1170        }
1171
1172        // A separation shadow at each pinned block's inner edge (over the scrolling part).
1173        let shadow = FrozenShadow {
1174            width: total_width,
1175            height: total_h,
1176            left: (left > 0).then_some(left_w),
1177            right: (right > 0).then_some(total_width - right_w),
1178        };
1179        Some(Box::new(
1180            Stack::new()
1181                .width(total_width)
1182                .height(total_h)
1183                .layer(row)
1184                .layer(shadow),
1185        ))
1186    }
1187
1188    fn rebuild(&mut self) {
1189        // Frozen columns: a dedicated layout (a pinned block + horizontal scrolling for the rest).
1190        if let Some(root) = self.build_frozen() {
1191            self.root = root;
1192            return;
1193        }
1194
1195        let checks = self.on_check.is_some();
1196        let mut col = Flex::column().gap(ROW_GAP);
1197
1198        // The header row (when there are text labels, widget headers, or checkboxes).
1199        let widget_headers = !self.header_widgets.is_empty();
1200        if !self.headers.is_empty() || widget_headers || checks {
1201            let mut hrow = self.new_row();
1202            if checks {
1203                hrow = hrow.child(CheckCell {
1204                    checked: self.all_selected(),
1205                    indeterminate: self.some_selected(),
1206                    header: true,
1207                    selected: false,
1208                    message: self.on_check_all.clone(),
1209                });
1210            }
1211            if widget_headers {
1212                // Fully widget headers: each cell hosts the widget supplied (header
1213                // background, centred content). Sorting/reordering are wired by the app.
1214                for (c, make) in self.header_widgets.iter().enumerate() {
1215                    hrow = hrow.child(WidgetCell {
1216                        width: self.col_width(c),
1217                        selected: false,
1218                        header: true,
1219                        row: 0,
1220                        message: None,
1221                        content: vec![make()],
1222                    });
1223                }
1224            } else {
1225                for (c, label) in self.headers.iter().enumerate() {
1226                    let sort = self.sort.filter(|(col, _)| *col == c).map(|(_, asc)| asc);
1227                    let message = self.on_sort.as_ref().map(|f| f(c));
1228                    let reorder = self
1229                        .on_reorder
1230                        .as_ref()
1231                        .map(|cb| (c, self.columns, cb.clone()));
1232                    let action = self
1233                        .header_actions
1234                        .get(c)
1235                        .and_then(|a| a.as_ref())
1236                        .map(|make| vec![make()])
1237                        .unwrap_or_default();
1238                    hrow = hrow.child(Cell {
1239                        label: label.clone(),
1240                        width: self.col_width(c),
1241                        header: true,
1242                        selected: false,
1243                        row: None,
1244                        icon: self.header_icons.get(c).copied().flatten(),
1245                        sort,
1246                        message,
1247                        reorder,
1248                        action,
1249                    });
1250                }
1251            }
1252            col = col.child(hrow);
1253        }
1254
1255        // Virtualised mode: the pinned header above a **virtualised list** of data rows (only
1256        // the visible ones are built). Every parameter a row needs is **captured** (cloned)
1257        // into the list's factory, which stays `'static` — so the closure cannot reach `self`.
1258        if let Some((count, viewport_height, build)) = self.virtual_data.clone() {
1259            let columns = self.columns;
1260            let widths = self.widths.clone();
1261            let total_width = self.total_width;
1262            let selected = self.selected.clone();
1263            let on_select = self.on_select.clone();
1264            let on_check = self.on_check.clone();
1265            let list = ListView::new(count, ROW_H, move |i| {
1266                let is_selected = selected.contains(&i);
1267                let message = on_select.as_ref().map(|f| f(i));
1268                let mut row = Flex::row().gap(ROW_GAP);
1269                if let Some(w) = total_width {
1270                    row = row.width(w);
1271                }
1272                // The multi-selection column (like the pinned "check all" header).
1273                if let Some(check) = &on_check {
1274                    row = row.child(CheckCell {
1275                        checked: is_selected,
1276                        indeterminate: false,
1277                        header: false,
1278                        selected: is_selected,
1279                        message: Some(check(i)),
1280                    });
1281                }
1282                match &build {
1283                    VirtualBuild::Text(f) => {
1284                        let cells = f(i);
1285                        for c in 0..columns {
1286                            row = row.child(Cell {
1287                                label: cells.get(c).cloned().unwrap_or_default(),
1288                                width: col_dimension(&widths, c),
1289                                header: false,
1290                                selected: is_selected,
1291                                row: Some(i),
1292                                icon: None,
1293                                sort: None,
1294                                message: message.clone(),
1295                                reorder: None,
1296                                action: Vec::new(),
1297                            });
1298                        }
1299                    }
1300                    VirtualBuild::Widgets(f) => {
1301                        for (c, widget) in f(i).into_iter().enumerate().take(columns) {
1302                            row = row.child(WidgetCell {
1303                                width: col_dimension(&widths, c),
1304                                selected: is_selected,
1305                                header: false,
1306                                row: i,
1307                                message: message.clone(),
1308                                content: vec![widget],
1309                            });
1310                        }
1311                    }
1312                }
1313                row
1314            });
1315            let list = match total_width {
1316                Some(w) => list.width(w).height(viewport_height),
1317                None => list.height(viewport_height),
1318            };
1319            col = col.child(list);
1320            self.root = Box::new(col);
1321            return;
1322        }
1323
1324        // The data rows.
1325        for (r, row) in self.rows.iter().enumerate() {
1326            let selected = self.selected.contains(&r);
1327            let mut drow = self.new_row();
1328            if checks {
1329                drow = drow.child(CheckCell {
1330                    checked: selected,
1331                    indeterminate: false,
1332                    header: false,
1333                    selected,
1334                    message: self.on_check.as_ref().map(|f| f(r)),
1335                });
1336            }
1337            match row {
1338                RowKind::Text(cells) => {
1339                    for (c, label) in cells.iter().enumerate() {
1340                        let message = self.on_select.as_ref().map(|f| f(r));
1341                        drow = drow.child(Cell {
1342                            label: label.clone(),
1343                            width: self.col_width(c),
1344                            header: false,
1345                            selected,
1346                            row: Some(r),
1347                            icon: None,
1348                            sort: None,
1349                            message,
1350                            reorder: None,
1351                            action: Vec::new(),
1352                        });
1353                    }
1354                }
1355                RowKind::Widgets(factories) => {
1356                    for (c, make) in factories.iter().enumerate() {
1357                        drow = drow.child(WidgetCell {
1358                            width: self.col_width(c),
1359                            selected,
1360                            header: false,
1361                            row: r,
1362                            message: self.on_select.as_ref().map(|f| f(r)),
1363                            content: vec![make()],
1364                        });
1365                    }
1366                }
1367            }
1368            col = col.child(drow);
1369        }
1370
1371        // Total height of the grid (rows + gaps): to size the handle layer, which runs the
1372        // whole height. Based on `ROW_H` (the adaptive floor): exact for a text table — which
1373        // is the resizing case, where every column is fixed; a taller widget row makes it an
1374        // under-estimate.
1375        let header_present = !self.headers.is_empty() || widget_headers || checks;
1376        let n = header_present as usize + self.rows.len();
1377        let total_h = if n > 0 {
1378            n as f32 * ROW_H + (n as f32 - 1.0) * ROW_GAP
1379        } else {
1380            0.0
1381        };
1382
1383        self.root = match self.resize_overlay(total_h) {
1384            Some(handles) => {
1385                let base = if checks { CHECK_W + ROW_GAP } else { 0.0 };
1386                let cols_w: f32 = (0..self.columns).map(|c| self.widths[c]).sum();
1387                let computed = base + cols_w + ROW_GAP * self.columns.saturating_sub(1) as f32;
1388                let total_w = self.total_width.unwrap_or(computed);
1389                Box::new(
1390                    Stack::new()
1391                        .width(total_w)
1392                        .height(total_h)
1393                        .layer(col)
1394                        .layer(handles),
1395                )
1396            }
1397            None => Box::new(col),
1398        };
1399    }
1400}
1401
1402impl<Msg: Clone> Widget<Msg> for Table<Msg> {
1403    fn style(&self) -> Style {
1404        Widget::<Msg>::style(&self.root)
1405    }
1406
1407    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1408        Widget::<Msg>::children(&self.root)
1409    }
1410
1411    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
1412
1413    fn on_click(&self) -> Option<Msg> {
1414        None
1415    }
1416
1417    fn stack(&self) -> bool {
1418        // When the table is resizable the root is a stack (the grid + the handle layer): the
1419        // flag is relayed so the layers superimpose instead of lining up.
1420        Widget::<Msg>::stack(&self.root)
1421    }
1422
1423    /// Carries what this table was told down to its cells, over what the subtree
1424    /// inherited. `None` when it was told nothing, so a table that says nothing costs
1425    /// nothing and the theme's own answer stands.
1426    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
1427        if self.heading_text_style.is_none() && self.data_text_style.is_none() {
1428            return None;
1429        }
1430        let mut theme = inherited.clone();
1431        if let Some(style) = self.heading_text_style {
1432            theme.widgets.table.heading_text_style = Some(style);
1433        }
1434        if let Some(style) = self.data_text_style {
1435            theme.widgets.table.data_text_style = Some(style);
1436        }
1437        Some(Box::new(theme))
1438    }
1439}
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444    use crate::{build_ui, Runtime, Size};
1445    use frus_core::{Point, Primitive};
1446
1447    #[test]
1448    fn header_and_rows_produce_rows_of_cells() {
1449        let table = Table::<()>::new(2)
1450            .header(&["Nom", "Note"])
1451            .row(&["Ada", "5"])
1452            .row(&["Bob", "3"]);
1453        // 1 header + 2 data rows.
1454        assert_eq!(Widget::<()>::children(&table).len(), 3);
1455        // Each row has 2 cells.
1456        assert_eq!(Widget::<()>::children(&table)[0].children().len(), 2);
1457
1458        let ui = build_ui(
1459            &table,
1460            Size::new(240.0, 200.0),
1461            &Runtime::default(),
1462            &Theme::default(),
1463        );
1464        let has = |t: &str| {
1465            ui.scene()
1466                .primitives()
1467                .iter()
1468                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1469        };
1470        assert!(has("Nom") && has("Ada") && has("Bob"));
1471    }
1472
1473    #[derive(Clone, Debug, PartialEq)]
1474    enum Msg {
1475        Sort(usize),
1476        Select(usize),
1477        Check(usize),
1478        CheckAll,
1479        Resize(usize, f32),
1480        Reorder(usize, usize),
1481        Filter,
1482    }
1483
1484    #[test]
1485    fn header_click_sorts_and_row_click_selects() {
1486        let table = Table::<Msg>::new(2)
1487            .width(240.0)
1488            .header(&["Name", "Score"])
1489            .on_sort(Msg::Sort)
1490            .on_select_row(Msg::Select)
1491            .row(&["Ada", "5"])
1492            .row(&["Bob", "3"]);
1493        let ui = build_ui(
1494            &table,
1495            Size::new(240.0, 200.0),
1496            &Runtime::default(),
1497            &Theme::default(),
1498        );
1499        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
1500        assert_eq!(click(180.0, ROW_H * 0.5), Some(Msg::Sort(1)));
1501        assert_eq!(click(30.0, ROW_H * 2.5), Some(Msg::Select(1)));
1502    }
1503
1504    #[test]
1505    fn checkbox_column_toggles_rows_and_all() {
1506        let table = Table::<Msg>::new(2)
1507            .width(280.0)
1508            .header(&["Name", "Score"])
1509            .checkboxes(Msg::Check, Msg::CheckAll)
1510            .selected(&[0])
1511            .row(&["Ada", "5"])
1512            .row(&["Bob", "3"]);
1513        // Each row has 3 cells: the checkbox + 2 columns.
1514        assert_eq!(Widget::<Msg>::children(&table)[0].children().len(), 3);
1515
1516        let ui = build_ui(
1517            &table,
1518            Size::new(280.0, 200.0),
1519            &Runtime::default(),
1520            &Theme::default(),
1521        );
1522        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
1523        // The "check all" box in the header (the left column).
1524        assert_eq!(click(CHECK_W * 0.5, ROW_H * 0.5), Some(Msg::CheckAll));
1525        // The box on the 2nd data row (r=1).
1526        assert_eq!(click(CHECK_W * 0.5, ROW_H * 2.5), Some(Msg::Check(1)));
1527    }
1528
1529    #[test]
1530    fn select_all_is_indeterminate_on_partial_selection() {
1531        // The header's "check all" box = the 1st cell of the 1st row.
1532        let header_check = |sel: &[usize]| {
1533            let table = Table::<Msg>::new(2)
1534                .header(&["A", "B"])
1535                .checkboxes(Msg::Check, Msg::CheckAll)
1536                .selected(sel)
1537                .row(&["x", "1"])
1538                .row(&["y", "2"]);
1539            let row0 = &Widget::<Msg>::children(&table)[0];
1540            // Painting the cell to read its state from the primitives would be heavy; the
1541            // helpers are tested directly instead.
1542            let _ = row0;
1543            (table.all_selected(), table.some_selected())
1544        };
1545        assert_eq!(header_check(&[]), (false, false), "nothing checked");
1546        assert_eq!(header_check(&[0]), (false, true), "partial → indeterminate");
1547        assert_eq!(header_check(&[0, 1]), (true, false), "all checked");
1548    }
1549
1550    #[test]
1551    fn only_headers_take_keyboard_focus() {
1552        let table = Table::<Msg>::new(2)
1553            .width(240.0)
1554            .header(&["Name", "Score"])
1555            .on_sort(Msg::Sort)
1556            .on_select_row(Msg::Select)
1557            .row(&["Ada", "5"]);
1558        let ui = build_ui(
1559            &table,
1560            Size::new(240.0, 200.0),
1561            &Runtime::default(),
1562            &Theme::default(),
1563        );
1564        // Two sortable headers are focusable; the data cells are not. The focusables are
1565        // counted by walking the Tab cycle.
1566        let first = ui.focus_next(None, true);
1567        let mut count = 0;
1568        let mut cur = first;
1569        while let Some(id) = cur {
1570            count += 1;
1571            let next = ui.focus_next(Some(id), true);
1572            if next == first || count > 10 {
1573                break;
1574            }
1575            cur = next;
1576        }
1577        assert_eq!(count, 2, "only the 2 headers take focus (got {count})");
1578    }
1579
1580    #[test]
1581    fn resize_handle_emits_accumulating_delta() {
1582        let table = Table::<Msg>::new(2)
1583            .column_widths(&[100.0, 100.0])
1584            .header(&["A", "B"])
1585            .on_resize(Msg::Resize)
1586            .row(&["x", "y"]);
1587        // Root = a stack; layer 1 is the row of handles.
1588        assert_eq!(Widget::<Msg>::children(&table).len(), 2);
1589        let overlay = &Widget::<Msg>::children(&table)[1];
1590        // A single handle (the edge between the 2 columns) among the spacers.
1591        let handle = overlay
1592            .children()
1593            .iter()
1594            .find(|c| c.draggable())
1595            .expect("a resize handle");
1596        // Dragging emits an accumulable delta; a zero delta does nothing.
1597        assert_eq!(handle.on_drag_delta(12.0), Some(Msg::Resize(0, 12.0)));
1598        assert_eq!(handle.on_drag_delta(0.0), None);
1599
1600        // The handle is reachable as a draggable at the 1st column's edge (x≈100).
1601        let ui = build_ui(
1602            &table,
1603            Size::new(220.0, 120.0),
1604            &Runtime::default(),
1605            &Theme::default(),
1606        );
1607        assert!(
1608            ui.draggable_at(Point::new(100.0, 20.0)).is_some(),
1609            "the handle can be grabbed at the edge"
1610        );
1611        // Outside fixed columns there is no overlay: one flexible width turns it off.
1612        let flex = Table::<Msg>::new(2)
1613            .header(&["A", "B"])
1614            .on_resize(Msg::Resize)
1615            .row(&["x", "y"]);
1616        // A bare grid (header + 1 row), with no handle layer over it.
1617        assert_eq!(
1618            Widget::<Msg>::children(&flex).len(),
1619            2,
1620            "flexible columns: no handle layer"
1621        );
1622        assert!(
1623            Widget::<Msg>::children(&flex)
1624                .iter()
1625                .all(|r| r.children().iter().all(|c| !c.draggable())),
1626            "no handle without fixed widths",
1627        );
1628    }
1629
1630    #[test]
1631    fn reorderable_headers_expose_index_and_message() {
1632        let table = Table::<Msg>::new(3)
1633            .width(300.0)
1634            .header(&["A", "B", "C"])
1635            .on_sort(Msg::Sort)
1636            .on_reorder(Msg::Reorder)
1637            .row(&["x", "y", "z"]);
1638        let hrow = &Widget::<Msg>::children(&table)[0];
1639        let cells = hrow.children();
1640        // Each header knows its column (source/target) and produces Reorder(from, to).
1641        assert_eq!(cells[0].reorder_index(), Some(0));
1642        assert_eq!(cells[2].reorder_index(), Some(2));
1643        assert_eq!(cells[0].on_reorder(2), Some(Msg::Reorder(0, 2)));
1644        // A click still sorts (tap = sort, drag = reorder).
1645        assert_eq!(cells[1].on_click(), Some(Msg::Sort(1)));
1646        // Data cells are not reorderable.
1647        let drow = &Widget::<Msg>::children(&table)[1];
1648        assert_eq!(drow.children()[0].reorder_index(), None);
1649    }
1650
1651    #[test]
1652    fn ctrl_arrows_reorder_focused_header() {
1653        use crate::interaction::{Key, KeyResponse};
1654        let table = Table::<Msg>::new(3)
1655            .width(300.0)
1656            .header(&["A", "B", "C"])
1657            .on_sort(Msg::Sort)
1658            .on_reorder(Msg::Reorder)
1659            .row(&["x", "y", "z"]);
1660        let cells = Widget::<Msg>::children(&table)[0].children();
1661        let ctrl_left = Key::Left {
1662            shift: false,
1663            word: true,
1664        };
1665        let ctrl_right = Key::Right {
1666            shift: false,
1667            word: true,
1668        };
1669        // The middle column (1): Ctrl+Left → 0, Ctrl+Right → 2.
1670        assert_eq!(
1671            cells[1].on_key(&ctrl_left),
1672            KeyResponse::Handled(Some(Msg::Reorder(1, 0)))
1673        );
1674        assert_eq!(
1675            cells[1].on_key(&ctrl_right),
1676            KeyResponse::Handled(Some(Msg::Reorder(1, 2)))
1677        );
1678        // At the edges: ignored (the focus navigates). Col 0 leftwards, col 2 rightwards.
1679        assert_eq!(cells[0].on_key(&ctrl_left), KeyResponse::Ignored);
1680        assert_eq!(cells[2].on_key(&ctrl_right), KeyResponse::Ignored);
1681        // A bare arrow (no Ctrl): ignored → focus navigation.
1682        assert_eq!(
1683            cells[1].on_key(&Key::Left {
1684                shift: false,
1685                word: false
1686            }),
1687            KeyResponse::Ignored
1688        );
1689    }
1690
1691    #[test]
1692    fn reorderable_header_is_announced_with_position() {
1693        let table = Table::<Msg>::new(3)
1694            .width(300.0)
1695            .header(&["A", "B", "C"])
1696            .on_sort(Msg::Sort)
1697            .on_reorder(Msg::Reorder)
1698            .row(&["x", "y", "z"]);
1699        let sem = Widget::<Msg>::children(&table)[0].children()[1]
1700            .semantics()
1701            .expect("an announced header");
1702        assert_eq!(sem.label.as_deref(), Some("B"));
1703        assert_eq!(sem.value.as_deref(), Some("column 2 of 3"));
1704        // Data cells carry no header semantics.
1705        assert!(Widget::<Msg>::children(&table)[1].children()[0]
1706            .semantics()
1707            .is_none());
1708    }
1709
1710    #[test]
1711    fn widget_rows_embed_arbitrary_widgets() {
1712        use crate::Text;
1713        let table = Table::<Msg>::new(2)
1714            .width(240.0)
1715            .header(&["Name", "Tag"])
1716            .on_select_row(Msg::Select)
1717            .widget_row(vec![
1718                Box::new(|| Box::new(Text::new("Ada"))),
1719                Box::new(|| Box::new(Text::new("admin"))),
1720            ]);
1721        // A header + 1 row of 2 widget cells, each holding its own widget.
1722        let rows = Widget::<Msg>::children(&table);
1723        assert_eq!(rows.len(), 2);
1724        let drow = &rows[1];
1725        assert_eq!(drow.children().len(), 2);
1726        assert_eq!(
1727            drow.children()[0].children().len(),
1728            1,
1729            "the cell holds a widget"
1730        );
1731
1732        // The content ("admin") is indeed rendered, and the row stays selectable.
1733        let ui = build_ui(
1734            &table,
1735            Size::new(240.0, 120.0),
1736            &Runtime::default(),
1737            &Theme::default(),
1738        );
1739        let has_admin = ui
1740            .scene()
1741            .primitives()
1742            .iter()
1743            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "admin"));
1744        assert!(has_admin, "the cell widget is painted");
1745        let click = ui
1746            .hit(Point::new(30.0, ROW_H * 1.5))
1747            .and_then(|id| ui.msg_for(id));
1748        assert_eq!(click, Some(Msg::Select(0)), "the widget row is selectable");
1749    }
1750
1751    #[test]
1752    fn header_icon_shifts_label_and_paints() {
1753        // A leading icon pushes the header's label back (icon + text).
1754        let name_x = |icons: bool| {
1755            let mut t = Table::<Msg>::new(2).width(240.0).header(&["Name", "Score"]);
1756            if icons {
1757                t = t.header_icons(&[Some(Icons::STAR), None]);
1758            }
1759            let ui = build_ui(
1760                &t,
1761                Size::new(240.0, 100.0),
1762                &Runtime::default(),
1763                &Theme::default(),
1764            );
1765            ui.scene()
1766                .primitives()
1767                .iter()
1768                .find_map(|p| match p {
1769                    Primitive::Text { text, position, .. } if text == "Name" => Some(position.x),
1770                    _ => None,
1771                })
1772                .unwrap()
1773        };
1774        let (plain, iconed) = (name_x(false), name_x(true));
1775        assert!(
1776            iconed >= plain + ICON,
1777            "the label is shifted behind the icon: {iconed} vs {plain}"
1778        );
1779        // The column without an icon ("Score") is not shifted.
1780    }
1781
1782    #[test]
1783    fn sort_and_selection_are_announced() {
1784        // A sortable header: the resulting sort announces the flipped direction.
1785        let unsorted = Table::<Msg>::new(2)
1786            .header(&["Name", "Score"])
1787            .on_sort(Msg::Sort);
1788        let head =
1789            |t: &Table<Msg>, c: usize| Widget::<Msg>::children(t)[0].children()[c].announce();
1790        assert_eq!(
1791            head(&unsorted, 0).as_deref(),
1792            Some("Sorted by Name ascending")
1793        );
1794        // Already ascending → a click switches to descending.
1795        let asc = Table::<Msg>::new(2)
1796            .header(&["Name", "Score"])
1797            .on_sort(Msg::Sort)
1798            .sorted(0, true);
1799        assert_eq!(head(&asc, 0).as_deref(), Some("Sorted by Name descending"));
1800
1801        // Checkboxes: the resulting state of the toggle.
1802        let table = Table::<Msg>::new(2)
1803            .header(&["Name", "Score"])
1804            .checkboxes(Msg::Check, Msg::CheckAll)
1805            .selected(&[0])
1806            .row(&["Ada", "5"])
1807            .row(&["Bob", "3"]);
1808        // The "check all" box (header, partial) → check them all.
1809        let all = Widget::<Msg>::children(&table)[0].children()[0].announce();
1810        assert_eq!(all.as_deref(), Some("All rows selected"));
1811        // Row 0 (checked) → uncheck; row 1 (unchecked) → check.
1812        let row0 = Widget::<Msg>::children(&table)[1].children()[0].announce();
1813        let row1 = Widget::<Msg>::children(&table)[2].children()[0].announce();
1814        assert_eq!(row0.as_deref(), Some("Row deselected"));
1815        assert_eq!(row1.as_deref(), Some("Row selected"));
1816    }
1817
1818    #[test]
1819    fn header_action_widget_captures_its_click() {
1820        use frus_core::Color;
1821        // An action button placed in the header, clickable independently of the sorting.
1822        struct Btn;
1823        impl Widget<Msg> for Btn {
1824            fn style(&self) -> Style {
1825                Style {
1826                    width: Dimension::Length(24.0),
1827                    height: Dimension::Length(24.0),
1828                    ..Default::default()
1829                }
1830            }
1831            fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1832                &[]
1833            }
1834            fn paint(&self, bounds: Rect, _s: Status, t: &Theme, scene: &mut Scene) {
1835                scene.draw_rect(bounds, t.primary, 4.0, 0.0, Color::TRANSPARENT);
1836            }
1837            fn on_click(&self) -> Option<Msg> {
1838                Some(Msg::Filter)
1839            }
1840        }
1841        let table = Table::<Msg>::new(2)
1842            .width(240.0)
1843            .header(&["Name", "Score"])
1844            .on_sort(Msg::Sort)
1845            .header_action(1, || Box::new(Btn));
1846        // Column 1's header cell carries the action widget.
1847        assert_eq!(
1848            Widget::<Msg>::children(&table)[0].children()[1]
1849                .children()
1850                .len(),
1851            1,
1852            "column 1's header carries the action",
1853        );
1854        let ui = build_ui(
1855            &table,
1856            Size::new(240.0, 100.0),
1857            &Runtime::default(),
1858            &Theme::default(),
1859        );
1860        let click = |x: f32| {
1861            ui.hit(Point::new(x, ROW_H * 0.5))
1862                .and_then(|id| ui.msg_for(id))
1863        };
1864        // A click on the right (on the button, centred ~218) → the action; elsewhere → sorting.
1865        assert_eq!(
1866            click(218.0),
1867            Some(Msg::Filter),
1868            "the button captures its click"
1869        );
1870        assert_eq!(
1871            click(130.0),
1872            Some(Msg::Sort(1)),
1873            "the rest of the header sorts"
1874        );
1875    }
1876
1877    #[test]
1878    fn row_click_selection_is_announced() {
1879        // Clicking a row (text or widget) announces the resulting state, with its number.
1880        let table = Table::<Msg>::new(2)
1881            .header(&["Name", "Tag"])
1882            .on_select_row(Msg::Select)
1883            .selected(&[1])
1884            .row(&["Ada", "admin"])
1885            .widget_row(vec![
1886                Box::new(|| Box::new(crate::Text::new("Bob"))),
1887                Box::new(|| Box::new(crate::Text::new("editor"))),
1888            ]);
1889        let rows = Widget::<Msg>::children(&table);
1890        // Text row 0 (not selected) → "selected"; any cell of the row will do.
1891        assert_eq!(
1892            rows[1].children()[0].announce().as_deref(),
1893            Some("Row 1 selected")
1894        );
1895        // Widget row 1 (selected) → "deselected".
1896        assert_eq!(
1897            rows[2].children()[0].announce().as_deref(),
1898            Some("Row 2 deselected")
1899        );
1900        // With no selection possible, there is no announcement.
1901        let plain = Table::<Msg>::new(1).header(&["N"]).row(&["x"]);
1902        assert_eq!(
1903            Widget::<Msg>::children(&plain)[1].children()[0].announce(),
1904            None
1905        );
1906    }
1907
1908    #[test]
1909    fn widget_row_grows_to_tall_content() {
1910        use frus_core::Color;
1911        // A widget taller than ROW_H: the row adapts instead of clipping it.
1912        struct Tall(f32);
1913        impl Widget<Msg> for Tall {
1914            fn style(&self) -> Style {
1915                Style {
1916                    width: Dimension::Length(40.0),
1917                    height: Dimension::Length(self.0),
1918                    ..Default::default()
1919                }
1920            }
1921            fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1922                &[]
1923            }
1924            fn paint(&self, bounds: Rect, _s: Status, _t: &Theme, scene: &mut Scene) {
1925                scene.fill_rect(bounds, Color::rgb(1.0, 0.0, 0.0));
1926            }
1927            fn on_click(&self) -> Option<Msg> {
1928                None
1929            }
1930        }
1931        let tall = 60.0;
1932        let table = Table::<Msg>::new(2)
1933            .width(240.0)
1934            .header(&["A", "B"])
1935            .widget_row(vec![
1936                Box::new(move || Box::new(Tall(tall))),
1937                Box::new(|| Box::new(crate::Text::new("x"))),
1938            ]);
1939        let ui = build_ui(
1940            &table,
1941            Size::new(240.0, 200.0),
1942            &Runtime::default(),
1943            &Theme::default(),
1944        );
1945        // The red bar (the tall widget) is painted at its full height: the cell, and so the
1946        // row, followed it (no clipping to ROW_H).
1947        let bar_h = ui.scene().primitives().iter().find_map(|p| match p {
1948            Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
1949                Some(rect.height)
1950            }
1951            _ => None,
1952        });
1953        assert!(
1954            bar_h.unwrap_or(0.0) >= tall - 1.0,
1955            "the row follows the tall content: {bar_h:?}"
1956        );
1957        assert!(
1958            tall - 1.0 > ROW_H,
1959            "the content does exceed the nominal height"
1960        );
1961    }
1962
1963    #[test]
1964    fn header_action_menu_opens_as_column_menu() {
1965        use crate::{Button, PopupMenuButton};
1966        // A PopupMenuButton dropped in as a header action widget = a column menu: its floating overlay is
1967        // collected **even when nested** in the header, and it closes (Escape / an outside
1968        // click). The items are focusable → navigable with the keyboard.
1969        let table = Table::<Msg>::new(2)
1970            .width(240.0)
1971            .header(&["Name", "Score"])
1972            .on_sort(Msg::Sort)
1973            .header_action(1, || {
1974                Box::new(
1975                    PopupMenuButton::new(
1976                        Button::new("...").on_press(Msg::Filter),
1977                        true,
1978                        Msg::CheckAll,
1979                    )
1980                    .item("Sort ascending", Msg::Sort(1))
1981                    .item("Sort descending", Msg::Sort(1)),
1982                )
1983            });
1984        let ui = build_ui(
1985            &table,
1986            Size::new(240.0, 200.0),
1987            &Runtime::default(),
1988            &Theme::default(),
1989        );
1990        // The menu's (nested) overlay is indeed collected → dismissible.
1991        assert_eq!(
1992            ui.top_dismiss(),
1993            Some(Msg::CheckAll),
1994            "the column menu's overlay is collected"
1995        );
1996        // The menu floats and paints (its items are rendered over the grid).
1997        let painted = |t: &str| {
1998            ui.scene()
1999                .primitives()
2000                .iter()
2001                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2002        };
2003        assert!(
2004            painted("Sort ascending"),
2005            "the column menu floats and paints"
2006        );
2007    }
2008
2009    #[test]
2010    fn widget_header_hosts_arbitrary_header_widgets() {
2011        use crate::{Button, Text};
2012        let table = Table::<Msg>::new(2)
2013            .width(240.0)
2014            .widget_header(vec![
2015                Box::new(|| Box::new(Text::new("Name"))),
2016                Box::new(|| Box::new(Button::new("Sort").on_press(Msg::Sort(1)))),
2017            ])
2018            .row(&["Ada", "5"]);
2019        // Header = the 1st row: 2 widget cells, each hosting its own widget.
2020        let rows = Widget::<Msg>::children(&table);
2021        assert_eq!(rows[0].children().len(), 2);
2022        assert_eq!(
2023            rows[0].children()[0].children().len(),
2024            1,
2025            "the widget header holds its widget"
2026        );
2027
2028        let ui = build_ui(
2029            &table,
2030            Size::new(240.0, 120.0),
2031            &Runtime::default(),
2032            &Theme::default(),
2033        );
2034        // The "Name" label (a header widget) is painted.
2035        let painted = |t: &str| {
2036            ui.scene()
2037                .primitives()
2038                .iter()
2039                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2040        };
2041        assert!(
2042            painted("Name") && painted("Sort"),
2043            "the header widgets are painted"
2044        );
2045        // The bespoke header button emits **its** message (no automatic sorting).
2046        let click = ui
2047            .hit(Point::new(180.0, ROW_H * 0.5))
2048            .and_then(|id| ui.msg_for(id));
2049        assert_eq!(
2050            click,
2051            Some(Msg::Sort(1)),
2052            "the app wires the sorting into its own header widget"
2053        );
2054    }
2055
2056    #[test]
2057    fn virtual_table_builds_only_visible_rows() {
2058        use std::cell::Cell as StdCell;
2059        use std::rc::Rc as StdRc;
2060        let built = StdRc::new(StdCell::new(0usize));
2061        let counter = built.clone();
2062        let table = Table::<Msg>::new(2)
2063            .width(200.0)
2064            .header(&["Name", "Score"])
2065            .on_select_row(Msg::Select)
2066            .virtual_rows(5000, 200.0, move |i| {
2067                counter.set(counter.get() + 1);
2068                vec![format!("R{i}"), format!("{i}")]
2069            });
2070        let ui = build_ui(
2071            &table,
2072            Size::new(200.0, 260.0),
2073            &Runtime::default(),
2074            &Theme::default(),
2075        );
2076        // Viewport 200 / ROW_H ≈ 6 visible rows (+ a margin) — never 5000.
2077        assert!(
2078            built.get() < 20,
2079            "only the visible rows are built: {}",
2080            built.get()
2081        );
2082        assert!(
2083            built.get() >= 5,
2084            "at least the visible window: {}",
2085            built.get()
2086        );
2087        // The **pinned** header + the first visible row are painted.
2088        let has = |t: &str| {
2089            ui.scene()
2090                .primitives()
2091                .iter()
2092                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2093        };
2094        assert!(has("Name"), "the header is pinned above the list");
2095        assert!(has("R0"), "the first virtualised row is built");
2096        // The scroll spans the whole content (5000 × ROW_H − the viewport).
2097        let maxes = ui.scrollable_maxes();
2098        assert_eq!(maxes.len(), 1, "one scrollable viewport");
2099        assert_eq!(
2100            maxes[0].2,
2101            5000.0 * ROW_H - 200.0,
2102            "the scroll bound = the total content"
2103        );
2104        // A visible row stays clickable (selection).
2105        let click = ui
2106            .hit(Point::new(20.0, ROW_H + 15.0))
2107            .and_then(|id| ui.msg_for(id));
2108        assert!(
2109            matches!(click, Some(Msg::Select(_))),
2110            "a virtualised row is clickable: {click:?}"
2111        );
2112    }
2113
2114    #[test]
2115    fn frozen_columns_split_into_pinned_and_scrolling_blocks() {
2116        let table = Table::<Msg>::new(3)
2117            .width(240.0)
2118            .column_widths(&[80.0, 120.0, 120.0])
2119            .header(&["Name", "A", "B"])
2120            .on_sort(Msg::Sort)
2121            .on_select_row(Msg::Select)
2122            .frozen_columns(1)
2123            .row(&["Ada", "1", "2"])
2124            .row(&["Bob", "3", "4"]);
2125        // Root = a stack [row of blocks, shadow]; the row carries the pinned block + the scrolling one.
2126        let ui = build_ui(
2127            &table,
2128            Size::new(240.0, 160.0),
2129            &Runtime::default(),
2130            &Theme::default(),
2131        );
2132        // **Horizontal** scrolling: the content (120+120) is wider than the remaining viewport.
2133        let maxes = ui.scrollable_maxes();
2134        assert_eq!(maxes.len(), 1, "one scrollable area");
2135        assert!(maxes[0].1 > 0.0, "max horizontal > 0 : {:?}", maxes[0]);
2136        // A **frozen** cell is clickable (selection); a **scrolling** header sorts — the
2137        // shadow at the freeze edge does not block clicks.
2138        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2139        assert_eq!(
2140            click(40.0, ROW_H * 1.5),
2141            Some(Msg::Select(0)),
2142            "a frozen cell selects"
2143        );
2144        assert_eq!(
2145            click(90.0, ROW_H * 0.5),
2146            Some(Msg::Sort(1)),
2147            "a scrolling header sorts"
2148        );
2149    }
2150
2151    #[test]
2152    fn freezing_both_edges_pins_left_and_right_columns() {
2153        // 4 columns: 1 frozen on the left, 1 on the right → column 2 (the middle) scrolls.
2154        let table = Table::<Msg>::new(4)
2155            .width(260.0)
2156            .column_widths(&[70.0, 120.0, 120.0, 70.0])
2157            .header(&["Name", "A", "B", "Act"])
2158            .on_sort(Msg::Sort)
2159            .on_select_row(Msg::Select)
2160            .frozen_columns(1)
2161            .frozen_columns_right(1)
2162            .row(&["Ada", "1", "2", "x"]);
2163        let ui = build_ui(
2164            &table,
2165            Size::new(260.0, 120.0),
2166            &Runtime::default(),
2167            &Theme::default(),
2168        );
2169        // The middle (A + B = 240) overflows the remaining viewport → horizontal scrolling.
2170        let maxes = ui.scrollable_maxes();
2171        assert_eq!(maxes.len(), 1);
2172        assert!(maxes[0].1 > 0.0, "the middle scrolls: {:?}", maxes[0]);
2173        // The header pinned on the right ("Act", column 3) sorts, at the far right of the table.
2174        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2175        let right = click(225.0, ROW_H * 0.5);
2176        assert_eq!(
2177            right,
2178            Some(Msg::Sort(3)),
2179            "the column frozen on the right: {right:?}"
2180        );
2181        // The header pinned on the left ("Name", column 0) sorts.
2182        assert_eq!(
2183            click(30.0, ROW_H * 0.5),
2184            Some(Msg::Sort(0)),
2185            "the column frozen on the left"
2186        );
2187    }
2188
2189    #[test]
2190    fn virtual_table_supports_checkboxes() {
2191        let table = Table::<Msg>::new(2)
2192            .width(240.0)
2193            .header(&["Name", "Score"])
2194            .checkboxes(Msg::Check, Msg::CheckAll)
2195            .selected(&[0])
2196            .virtual_rows(1000, 200.0, |i| vec![format!("R{i}"), format!("{i}")]);
2197        let ui = build_ui(
2198            &table,
2199            Size::new(240.0, 260.0),
2200            &Runtime::default(),
2201            &Theme::default(),
2202        );
2203        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
2204        // "Check all" in the pinned header.
2205        assert_eq!(click(CHECK_W * 0.5, ROW_H * 0.5), Some(Msg::CheckAll));
2206        // The box on the first visible row (the left column, below the header).
2207        let row_check = click(CHECK_W * 0.5, ROW_H + 15.0);
2208        assert!(
2209            matches!(row_check, Some(Msg::Check(_))),
2210            "a virtualised row's box: {row_check:?}"
2211        );
2212    }
2213
2214    #[test]
2215    fn virtual_widget_rows_builds_only_visible() {
2216        use crate::Text;
2217        use std::cell::Cell as StdCell;
2218        use std::rc::Rc as StdRc;
2219        let built = StdRc::new(StdCell::new(0usize));
2220        let counter = built.clone();
2221        let table = Table::<Msg>::new(1)
2222            .width(200.0)
2223            .header(&["Item"])
2224            .on_select_row(Msg::Select)
2225            .virtual_widget_rows(3000, 200.0, move |i| {
2226                counter.set(counter.get() + 1);
2227                vec![Box::new(Text::new(format!("W{i}"))) as Box<dyn Widget<Msg>>]
2228            });
2229        let ui = build_ui(
2230            &table,
2231            Size::new(200.0, 260.0),
2232            &Runtime::default(),
2233            &Theme::default(),
2234        );
2235        assert!(
2236            built.get() < 20 && built.get() >= 5,
2237            "only the visible ones: {}",
2238            built.get()
2239        );
2240        let has = |t: &str| {
2241            ui.scene()
2242                .primitives()
2243                .iter()
2244                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2245        };
2246        assert!(has("Item"), "the header is pinned");
2247        assert!(has("W0"), "the first row's widget is built");
2248        // The virtualised widget row stays selectable (the cell captures the click).
2249        let click = ui
2250            .hit(Point::new(20.0, ROW_H + 15.0))
2251            .and_then(|id| ui.msg_for(id));
2252        assert!(
2253            matches!(click, Some(Msg::Select(_))),
2254            "the widget row is clickable: {click:?}"
2255        );
2256    }
2257
2258    #[test]
2259    fn fixed_column_width_is_applied() {
2260        let table = Table::<()>::new(2)
2261            .width(300.0)
2262            .column_widths(&[80.0]) // 1st column fixed at 80, 2nd flexible
2263            .header(&["A", "B"])
2264            .row(&["x", "y"]);
2265        let ui = build_ui(
2266            &table,
2267            Size::new(300.0, 100.0),
2268            &Runtime::default(),
2269            &Theme::default(),
2270        );
2271        // The 1st header column ("A") takes 80 px: "B" starts beyond 80 + the gap.
2272        let text_x = |t: &str| {
2273            ui.scene().primitives().iter().find_map(|p| match p {
2274                Primitive::Text { text, position, .. } if text == t => Some(position.x),
2275                _ => None,
2276            })
2277        };
2278        let (ax, bx) = (text_x("A").unwrap(), text_x("B").unwrap());
2279        assert!(bx >= ax + 80.0, "a fixed column of 80: bx={bx} ax={ax}");
2280    }
2281}