use retroglyph_core::{Color, Rect, Style};
use super::StatefulWidget;
use super::window::visible_window;
use crate::ListState;
use crate::Surface;
use crate::Theme;
use crate::draw::fill_rect;
use crate::text::truncate as truncate_to_cols;
#[derive(Clone, Copy, Debug)]
pub struct Table<'a> {
headers: &'a [&'a str],
widths: &'a [u16],
rows: &'a [&'a [&'a str]],
header_style: Style,
row_style: Style,
selected_style: Style,
column_spacing: u16,
}
impl<'a> Table<'a> {
#[must_use]
pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> Self {
Self {
headers,
widths,
rows,
header_style: Style::new().fg(Color::Rgb {
r: 210,
g: 210,
b: 230,
}),
row_style: Style::new().fg(Color::Rgb {
r: 170,
g: 175,
b: 190,
}),
selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
r: 40,
g: 60,
b: 90,
}),
column_spacing: 1,
}
}
#[must_use]
pub const fn header_style(mut self, style: Style) -> Self {
self.header_style = style;
self
}
#[must_use]
pub const fn row_style(mut self, style: Style) -> Self {
self.row_style = style;
self
}
#[must_use]
pub const fn selected_style(mut self, style: Style) -> Self {
self.selected_style = style;
self
}
#[must_use]
pub const fn column_spacing(mut self, spacing: u16) -> Self {
self.column_spacing = spacing;
self
}
#[must_use]
pub fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.header_style = Style::new().fg(theme.fg).bg(bg);
self.row_style = Style::new().fg(theme.dim).bg(bg);
self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
self
}
}
impl StatefulWidget for Table<'_> {
type State = ListState;
fn render(&self, area: Rect, surface: &mut Surface<'_>, state: &mut Self::State) {
if area.width() == 0 || area.height() == 0 {
return;
}
draw_row(
surface,
area,
area.top(),
self.headers,
self.widths,
RowStyle {
style: self.header_style,
bg: None,
column_spacing: self.column_spacing,
},
);
let visible_rows = area.height_usize().saturating_sub(1);
let selected = state.selected();
for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
#[allow(clippy::cast_possible_truncation)]
let row_offset = (row_index - state.offset()) as u16;
let y = area.top() + 1 + row_offset;
let (style, bg) = if Some(row_index) == selected {
(self.selected_style, Some(self.selected_style.background()))
} else {
(self.row_style, None)
};
draw_row(
surface,
area,
y,
row,
self.widths,
RowStyle {
style,
bg,
column_spacing: self.column_spacing,
},
);
}
}
}
#[derive(Clone, Copy)]
struct RowStyle {
style: Style,
bg: Option<Color>,
column_spacing: u16,
}
fn draw_row(
surface: &mut Surface<'_>,
area: Rect,
y: u16,
cells: &[&str],
widths: &[u16],
row_style: RowStyle,
) {
let RowStyle {
style,
bg,
column_spacing,
} = row_style;
if let Some(bg) = bg {
fill_rect(
surface,
Rect::new(area.left(), y, area.width(), 1),
' ',
Style::new().bg(bg),
);
}
let mut x = area.left();
for (cell, &w) in cells.iter().zip(widths) {
if x >= area.right() {
break;
}
let avail = (area.right() - x).min(w) as usize;
let text = truncate_to_cols(cell, avail);
surface.print((x, y), text, style);
x = x.saturating_add(w.saturating_add(column_spacing));
}
}
#[cfg(test)]
mod tests {
use retroglyph_core::{Grid, Pos};
use super::*;
#[test]
fn table_widget_highlights_the_selected_row() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.select(Some(1));
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
let highlighted_bg = grid[Pos::new(0, 2)].style().background();
let plain_bg = grid[Pos::new(0, 1)].style().background();
assert_ne!(highlighted_bg, plain_bg);
}
#[test]
fn table_widget_highlights_nothing_when_unselected() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new(); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
let row0_bg = grid[Pos::new(0, 1)].style().background();
let row1_bg = grid[Pos::new(0, 2)].style().background();
assert_eq!(row0_bg, row1_bg);
}
fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
names.iter().map(|n| [*n]).collect()
}
fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
rows.iter().map(<[&str; 1]>::as_slice).collect()
}
#[test]
fn scroll_offset_renders_the_window_starting_at_offset() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.set_offset(2); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
}
#[test]
fn selection_scrolled_out_of_view_highlights_nothing() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.select(Some(0)); state.set_offset(2); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
let row0_bg = grid[Pos::new(0, 1)].style().background();
let row1_bg = grid[Pos::new(0, 2)].style().background();
assert_eq!(row0_bg, row1_bg); }
#[test]
fn default_header_style_matches_previous_hardcoded_color() {
let area = Rect::new(0, 0, 20, 2);
let headers = ["Name"];
let widths = [10u16];
let rows: Vec<&[&str]> = vec![];
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 2);
let mut state = ListState::new();
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
let expected = Color::Rgb {
r: 210,
g: 210,
b: 230,
};
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), expected);
}
#[test]
fn header_style_can_be_overridden() {
let area = Rect::new(0, 0, 20, 2);
let headers = ["Name"];
let widths = [10u16];
let rows: Vec<&[&str]> = vec![];
let custom = Style::new().fg(Color::RED);
let table = Table::new(&headers, &widths, &rows).header_style(custom);
let mut grid = Grid::new(20, 2);
let mut state = ListState::new();
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
}
#[test]
fn selected_style_can_be_overridden() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
let table = Table::new(&headers, &widths, &rows).selected_style(custom);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.select(Some(1));
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Color::GREEN);
assert_eq!(grid[Pos::new(0, 2)].style().background(), Color::BLUE);
}
#[test]
fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.select(Some(1));
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
assert_eq!(
grid[Pos::new(0, 0)].style().background(),
Theme::DARK.panel_bg
);
assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
assert_eq!(
grid[Pos::new(0, 1)].style().background(),
Theme::DARK.panel_bg
);
assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Theme::DARK.bg);
assert_eq!(
grid[Pos::new(0, 2)].style().background(),
Theme::DARK.accent
);
}
#[test]
fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
let area = Rect::new(0, 0, 20, 2);
let headers = ["Name"];
let widths = [10u16];
let rows: [&[&str]; 1] = [&["Alpha"]];
let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
let mut grid = Grid::new(20, 2);
let mut state = ListState::new();
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
assert_eq!(grid[Pos::new(0, 1)].style().background(), Color::Default);
}
#[test]
fn column_spacing_can_be_overridden() {
let area = Rect::new(0, 0, 20, 1);
let headers = ["A", "B"];
let widths = [1u16, 1u16];
let rows: Vec<&[&str]> = vec![];
let table = Table::new(&headers, &widths, &rows).column_spacing(3);
let mut grid = Grid::new(20, 1);
let mut state = ListState::new();
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
}
#[test]
fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
let area = Rect::new(0, 0, 20, 1);
let cells: [&str; 2] = ["A", "B"];
let widths = [u16::MAX - 1, 1];
let row_style = RowStyle {
style: Style::new(),
bg: None,
column_spacing: 3,
};
let mut grid = Grid::new(20, 1);
draw_row(
&mut Surface::new(&mut grid, area, 0),
area,
0,
&cells,
&widths,
row_style,
);
assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
}
#[test]
fn ensure_visible_before_render_keeps_selection_on_screen() {
let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
let mut grid = Grid::new(20, 3);
let mut state = ListState::new();
state.select(Some(3)); state.ensure_visible(2);
table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
let highlighted_bg = grid[Pos::new(0, 2)].style().background();
let plain_bg = grid[Pos::new(0, 1)].style().background();
assert_ne!(highlighted_bg, plain_bg);
}
}