Skip to main content

retroglyph_widgets/widget/
table.rs

1//! [`Table`]: a fixed-column, scrollable table with a highlighted row.
2use retroglyph_core::{Color, Rect, Style};
3
4use super::StatefulWidget;
5use super::window::visible_window;
6use crate::ListState;
7use crate::Surface;
8use crate::Theme;
9use crate::draw::fill_rect;
10use crate::text::truncate as truncate_to_cols;
11
12/// A fixed-column, scrollable table with a [`ListState`]-driven highlighted
13/// row.
14///
15/// `headers` render on the first row of the area it's rendered into;
16/// `rows` follow, one per line, clipped to that area. `widths` gives each
17/// column's cell width; columns are space-separated and truncated to fit.
18///
19/// `state.offset()` is the index of the first row drawn below the header --
20/// rendering draws whatever window `offset` names and does not clamp or
21/// auto-scroll it, matching [`ListState`]'s existing "only the caller knows
22/// the viewport height" design. Call
23/// [`state.ensure_visible(visible_row_count)`](ListState::ensure_visible)
24/// before rendering to keep `state.selected()` on-screen. If `selected()` is
25/// `Some` and its row falls within the visible window, that row is drawn
26/// with an inverted highlight background; if it has scrolled out of view,
27/// no row is highlighted.
28///
29/// `header_style`, `row_style`, and `selected_style` each default to a fixed
30/// palette (a light blue-gray header, a dim gray-blue for unselected rows,
31/// and a bright-white-on-dark-blue highlight for the selected row); set them
32/// with [`Table::header_style`], [`Table::row_style`], and
33/// [`Table::selected_style`]. `column_spacing` defaults to `1` (a single
34/// blank column between cells); set it with [`Table::column_spacing`].
35///
36/// # Examples
37///
38/// ```
39/// use retroglyph_core::{Grid, Rect};
40/// use retroglyph_widgets::{ListState, StatefulWidget, Surface, Table};
41///
42/// let headers = ["Name", "Score"];
43/// let widths = [10u16, 6];
44/// let rows: [&[&str]; 2] = [&["Alpha", "10"], &["Bravo", "20"]];
45///
46/// let mut state = ListState::new();
47/// state.select(Some(1));
48///
49/// let area = Rect::new(0, 0, 20, 3);
50/// let mut grid = Grid::new(20, 3);
51/// Table::new(&headers, &widths, &rows).render(
52///     area,
53///     &mut Surface::new(&mut grid, area, 0),
54///     &mut state,
55/// );
56/// ```
57#[derive(Clone, Copy, Debug)]
58pub struct Table<'a> {
59    headers: &'a [&'a str],
60    widths: &'a [u16],
61    rows: &'a [&'a [&'a str]],
62    header_style: Style,
63    row_style: Style,
64    selected_style: Style,
65    column_spacing: u16,
66}
67
68impl<'a> Table<'a> {
69    /// A table with the given header labels, column widths, and rows, in the
70    /// default style.
71    #[must_use]
72    pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> Self {
73        Self {
74            headers,
75            widths,
76            rows,
77            header_style: Style::new().fg(Color::Rgb {
78                r: 210,
79                g: 210,
80                b: 230,
81            }),
82            row_style: Style::new().fg(Color::Rgb {
83                r: 170,
84                g: 175,
85                b: 190,
86            }),
87            selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
88                r: 40,
89                g: 60,
90                b: 90,
91            }),
92            column_spacing: 1,
93        }
94    }
95
96    /// Set the header row's style.
97    #[must_use]
98    pub const fn header_style(mut self, style: Style) -> Self {
99        self.header_style = style;
100        self
101    }
102
103    /// Set the style of unselected rows.
104    #[must_use]
105    pub const fn row_style(mut self, style: Style) -> Self {
106        self.row_style = style;
107        self
108    }
109
110    /// Set the style of the selected row, including its background fill.
111    #[must_use]
112    pub const fn selected_style(mut self, style: Style) -> Self {
113        self.selected_style = style;
114        self
115    }
116
117    /// Set the number of blank columns between cells.
118    #[must_use]
119    pub const fn column_spacing(mut self, spacing: u16) -> Self {
120        self.column_spacing = spacing;
121        self
122    }
123
124    /// Applies `theme`'s named roles to this table's row styles: `header_style` becomes
125    /// `theme.fg` (brighter, matching the header's original brighter-than-row default) on
126    /// `theme.panel_bg`, `row_style` becomes `theme.dim` (the same de-emphasized role a plain
127    /// body row already reads as) on `theme.panel_bg`, and `selected_style` becomes `theme.bg`
128    /// on `theme.accent`: the same bright-on-accent highlight [`super::List::theme`] and
129    /// [`super::Button::theme`] use.
130    ///
131    /// `header_style`/`row_style` always set an explicit background rather than leaving it at
132    /// [`Style::new()`]'s default: an unset background isn't "transparent" once a real backend
133    /// draws it (a bare `Color::Default` cell paints as solid black behind the glyph, not
134    /// whatever was there before; see `retroglyph-software`'s `DEFAULT_BG`), so this widget
135    /// assumes it's drawn on `theme.panel_bg` (true when composed with a themed
136    /// [`super::Panel`]/[`super::Modal`], the common case) rather than risk a black box behind
137    /// every row on a light [`Theme`]. Drawing this table directly on the raw screen background
138    /// instead of inside a themed panel needs a manual `.header_style(...)`/`.row_style(...)`
139    /// override afterwards.
140    ///
141    /// Call before any manual [`Table::header_style`]/[`Table::row_style`]/
142    /// [`Table::selected_style`] override you want to keep.
143    #[must_use]
144    pub fn theme(self, theme: Theme) -> Self {
145        self.theme_on(theme, theme.panel_bg)
146    }
147
148    /// Same as [`Table::theme`], but `header_style`/`row_style` are drawn on `bg` instead of
149    /// `theme.panel_bg`: for a table drawn directly on a backdrop other than a themed
150    /// [`super::Panel`]/[`super::Modal`]'s fill, e.g. the raw screen background or a different
151    /// panel's fill color. [`Table::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
152    #[must_use]
153    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
154        self.header_style = Style::new().fg(theme.fg).bg(bg);
155        self.row_style = Style::new().fg(theme.dim).bg(bg);
156        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
157        self
158    }
159}
160
161impl StatefulWidget for Table<'_> {
162    type State = ListState;
163
164    fn render(&self, area: Rect, surface: &mut Surface<'_>, state: &mut Self::State) {
165        if area.width() == 0 || area.height() == 0 {
166            return;
167        }
168        draw_row(
169            surface,
170            area,
171            area.top(),
172            self.headers,
173            self.widths,
174            RowStyle {
175                style: self.header_style,
176                bg: None,
177                column_spacing: self.column_spacing,
178            },
179        );
180
181        let visible_rows = area.height_usize().saturating_sub(1);
182        let selected = state.selected();
183        for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
184            // `row_index - state.offset()` is a row within the visible window, so it never
185            // exceeds `visible_rows` (`area.height_usize()`, itself widened from a `u16` height).
186            #[allow(clippy::cast_possible_truncation)]
187            let row_offset = (row_index - state.offset()) as u16;
188            let y = area.top() + 1 + row_offset;
189            let (style, bg) = if Some(row_index) == selected {
190                (self.selected_style, Some(self.selected_style.background()))
191            } else {
192                (self.row_style, None)
193            };
194            draw_row(
195                surface,
196                area,
197                y,
198                row,
199                self.widths,
200                RowStyle {
201                    style,
202                    bg,
203                    column_spacing: self.column_spacing,
204                },
205            );
206        }
207    }
208}
209
210/// The style and layout options for drawing one [`Table`] row, grouped to keep [`draw_row`]'s
211/// argument count within clippy's limit.
212#[derive(Clone, Copy)]
213struct RowStyle {
214    /// The text (and, for the selected row, background) style.
215    style: Style,
216    /// When set, the whole row width is filled with this background first.
217    bg: Option<Color>,
218    /// The number of blank columns between cells.
219    column_spacing: u16,
220}
221
222/// Draw one table row of `column_spacing`-separated, per-column-clipped cells at row `y`.
223fn draw_row(
224    surface: &mut Surface<'_>,
225    area: Rect,
226    y: u16,
227    cells: &[&str],
228    widths: &[u16],
229    row_style: RowStyle,
230) {
231    let RowStyle {
232        style,
233        bg,
234        column_spacing,
235    } = row_style;
236    if let Some(bg) = bg {
237        fill_rect(
238            surface,
239            Rect::new(area.left(), y, area.width(), 1),
240            ' ',
241            Style::new().bg(bg),
242        );
243    }
244    let mut x = area.left();
245    for (cell, &w) in cells.iter().zip(widths) {
246        if x >= area.right() {
247            break;
248        }
249        let avail = (area.right() - x).min(w) as usize;
250        let text = truncate_to_cols(cell, avail);
251        surface.print((x, y), text, style);
252        x = x.saturating_add(w.saturating_add(column_spacing));
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use retroglyph_core::{Grid, Pos};
259
260    use super::*;
261
262    #[test]
263    fn table_widget_highlights_the_selected_row() {
264        let area = Rect::new(0, 0, 20, 3);
265        let headers = ["Name"];
266        let widths = [10u16];
267        let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
268        let table = Table::new(&headers, &widths, &rows);
269
270        let mut grid = Grid::new(20, 3);
271        let mut state = ListState::new();
272        state.select(Some(1));
273        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
274
275        // Row 1 ("Bravo") is highlighted; row 0 ("Alpha") is not.
276        let highlighted_bg = grid[Pos::new(0, 2)].style().background();
277        let plain_bg = grid[Pos::new(0, 1)].style().background();
278        assert_ne!(highlighted_bg, plain_bg);
279    }
280
281    #[test]
282    fn table_widget_highlights_nothing_when_unselected() {
283        let area = Rect::new(0, 0, 20, 3);
284        let headers = ["Name"];
285        let widths = [10u16];
286        let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
287        let table = Table::new(&headers, &widths, &rows);
288
289        let mut grid = Grid::new(20, 3);
290        let mut state = ListState::new(); // nothing selected
291        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
292
293        let row0_bg = grid[Pos::new(0, 1)].style().background();
294        let row1_bg = grid[Pos::new(0, 2)].style().background();
295        assert_eq!(row0_bg, row1_bg);
296    }
297
298    fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
299        names.iter().map(|n| [*n]).collect()
300    }
301
302    fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
303        rows.iter().map(<[&str; 1]>::as_slice).collect()
304    }
305
306    #[test]
307    fn scroll_offset_renders_the_window_starting_at_offset() {
308        // 2 visible rows (area height 3, minus the header row).
309        let area = Rect::new(0, 0, 20, 3);
310        let headers = ["Name"];
311        let widths = [10u16];
312        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
313        let rows = row_refs(&rows);
314        let table = Table::new(&headers, &widths, &rows);
315
316        let mut grid = Grid::new(20, 3);
317        let mut state = ListState::new();
318        state.set_offset(2); // window is [Charlie, Delta]
319        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
320
321        // Row 1 is "Charlie", row 2 is "Delta"; neither "Alpha" nor "Bravo"
322        // (offset 0/1) are drawn anywhere.
323        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
324        assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
325    }
326
327    #[test]
328    fn selection_scrolled_out_of_view_highlights_nothing() {
329        let area = Rect::new(0, 0, 20, 3);
330        let headers = ["Name"];
331        let widths = [10u16];
332        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
333        let rows = row_refs(&rows);
334        let table = Table::new(&headers, &widths, &rows);
335
336        let mut grid = Grid::new(20, 3);
337        let mut state = ListState::new();
338        state.select(Some(0)); // "Alpha"
339        state.set_offset(2); // but the window starts at "Charlie"
340        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
341
342        let row0_bg = grid[Pos::new(0, 1)].style().background();
343        let row1_bg = grid[Pos::new(0, 2)].style().background();
344        assert_eq!(row0_bg, row1_bg); // neither visible row is highlighted
345    }
346
347    #[test]
348    fn default_header_style_matches_previous_hardcoded_color() {
349        let area = Rect::new(0, 0, 20, 2);
350        let headers = ["Name"];
351        let widths = [10u16];
352        let rows: Vec<&[&str]> = vec![];
353        let table = Table::new(&headers, &widths, &rows);
354
355        let mut grid = Grid::new(20, 2);
356        let mut state = ListState::new();
357        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
358
359        let expected = Color::Rgb {
360            r: 210,
361            g: 210,
362            b: 230,
363        };
364        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), expected);
365    }
366
367    #[test]
368    fn header_style_can_be_overridden() {
369        let area = Rect::new(0, 0, 20, 2);
370        let headers = ["Name"];
371        let widths = [10u16];
372        let rows: Vec<&[&str]> = vec![];
373        let custom = Style::new().fg(Color::RED);
374        let table = Table::new(&headers, &widths, &rows).header_style(custom);
375
376        let mut grid = Grid::new(20, 2);
377        let mut state = ListState::new();
378        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
379
380        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
381    }
382
383    #[test]
384    fn selected_style_can_be_overridden() {
385        let area = Rect::new(0, 0, 20, 3);
386        let headers = ["Name"];
387        let widths = [10u16];
388        let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
389        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
390        let table = Table::new(&headers, &widths, &rows).selected_style(custom);
391
392        let mut grid = Grid::new(20, 3);
393        let mut state = ListState::new();
394        state.select(Some(1));
395        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
396
397        assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Color::GREEN);
398        assert_eq!(grid[Pos::new(0, 2)].style().background(), Color::BLUE);
399    }
400
401    #[test]
402    fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
403        let area = Rect::new(0, 0, 20, 3);
404        let headers = ["Name"];
405        let widths = [10u16];
406        let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
407        let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
408
409        let mut grid = Grid::new(20, 3);
410        let mut state = ListState::new();
411        state.select(Some(1));
412        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
413
414        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
415        assert_eq!(
416            grid[Pos::new(0, 0)].style().background(),
417            Theme::DARK.panel_bg
418        );
419        assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
420        assert_eq!(
421            grid[Pos::new(0, 1)].style().background(),
422            Theme::DARK.panel_bg
423        );
424        assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Theme::DARK.bg);
425        assert_eq!(
426            grid[Pos::new(0, 2)].style().background(),
427            Theme::DARK.accent
428        );
429    }
430
431    #[test]
432    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
433        let area = Rect::new(0, 0, 20, 2);
434        let headers = ["Name"];
435        let widths = [10u16];
436        let rows: [&[&str]; 1] = [&["Alpha"]];
437        let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
438
439        let mut grid = Grid::new(20, 2);
440        let mut state = ListState::new();
441        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
442
443        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
444        assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
445        assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
446        assert_eq!(grid[Pos::new(0, 1)].style().background(), Color::Default);
447    }
448
449    #[test]
450    fn column_spacing_can_be_overridden() {
451        let area = Rect::new(0, 0, 20, 1);
452        let headers = ["A", "B"];
453        let widths = [1u16, 1u16];
454        let rows: Vec<&[&str]> = vec![];
455        let table = Table::new(&headers, &widths, &rows).column_spacing(3);
456
457        let mut grid = Grid::new(20, 1);
458        let mut state = ListState::new();
459        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
460
461        // Default spacing (1) would put "B" at column 2; spacing 3 pushes
462        // it out to column 4.
463        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
464        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
465    }
466
467    #[test]
468    fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
469        // A column width near `u16::MAX` combined with a nonzero `column_spacing` must not
470        // overflow the intermediate `w + column_spacing` addition (see issue #315); the whole
471        // expression should saturate to `u16::MAX` instead of panicking (debug) or wrapping
472        // (release).
473        let area = Rect::new(0, 0, 20, 1);
474        let cells: [&str; 2] = ["A", "B"];
475        let widths = [u16::MAX - 1, 1];
476        let row_style = RowStyle {
477            style: Style::new(),
478            bg: None,
479            column_spacing: 3,
480        };
481
482        let mut grid = Grid::new(20, 1);
483        draw_row(
484            &mut Surface::new(&mut grid, area, 0),
485            area,
486            0,
487            &cells,
488            &widths,
489            row_style,
490        );
491
492        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
493    }
494
495    #[test]
496    fn ensure_visible_before_render_keeps_selection_on_screen() {
497        let area = Rect::new(0, 0, 20, 3); // 2 visible rows
498        let headers = ["Name"];
499        let widths = [10u16];
500        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
501        let rows = row_refs(&rows);
502        let table = Table::new(&headers, &widths, &rows);
503
504        let mut grid = Grid::new(20, 3);
505        let mut state = ListState::new();
506        state.select(Some(3)); // "Delta", off the front of the default window
507        state.ensure_visible(2);
508        table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
509
510        // ensure_visible moved the window to [2, 4): "Charlie" then "Delta",
511        // with "Delta" (the selection) highlighted on the last visible row.
512        assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
513        let highlighted_bg = grid[Pos::new(0, 2)].style().background();
514        let plain_bg = grid[Pos::new(0, 1)].style().background();
515        assert_ne!(highlighted_bg, plain_bg);
516    }
517}