Skip to main content

retroglyph_widgets/widget/
table.rs

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