Skip to main content

retroglyph_ui/widget/
table.rs

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