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