use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::Rect;
use super::window::visible_window;
use super::{InteractiveWidget, Measure, StatefulWidget};
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::interact::Response;
use crate::interact::Sense;
use crate::state::ListState;
use crate::text::draw_clipped;
use crate::theme::Theme;
#[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(),
row_style: Style::new(),
selected_style: Style::new(),
column_spacing: 1,
}
.theme(Theme::DARK)
}
#[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 Table<'_> {
fn draw(&self, surface: &mut Surface<'_>, state: &ListState) {
let (width, height) = (surface.width(), surface.height());
if width == 0 || height == 0 {
return;
}
draw_row(
surface,
width,
0,
self.headers,
self.widths,
RowStyle {
style: self.header_style,
bg: None,
column_spacing: self.column_spacing,
},
);
let visible_rows = usize::from(height).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 = 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,
width,
y,
row,
self.widths,
RowStyle {
style,
bg,
column_spacing: self.column_spacing,
},
);
}
}
fn index_at(
&self,
area: Rect,
state: &ListState,
pos: retroglyph_core::grid::Pos,
) -> Option<usize> {
let row = pos.y.checked_sub(area.top())?.checked_sub(1)?; let index = state.offset() + usize::from(row);
(index < self.rows.len()).then_some(index)
}
}
impl StatefulWidget for Table<'_> {
type State = ListState;
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
self.draw(surface, state);
}
}
impl<Id> InteractiveWidget<Id> for Table<'_> {
type State = ListState;
fn sense(&self) -> Sense {
Sense::click() | Sense::SCROLL | Sense::HOVER
}
fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
let area = surface.area();
if response.clicked()
&& let Some(pos) = response.pointer_pos()
&& let Some(index) = self.index_at(area, state, pos)
{
state.select(Some(index));
}
let scroll_delta = response.scroll_delta();
if scroll_delta != 0 {
state.scroll_by(scroll_delta);
}
self.draw(surface, state);
}
}
impl Measure for Table<'_> {
fn height_for(&self, _width: u16) -> u16 {
#[allow(clippy::cast_possible_truncation)]
let rows = self.rows.len().min(usize::from(u16::MAX)) as u16;
rows.saturating_add(1)
}
}
#[derive(Clone, Copy)]
struct RowStyle {
style: Style,
bg: Option<Color>,
column_spacing: u16,
}
fn draw_row(
surface: &mut Surface<'_>,
width: u16,
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(0, y, width, 1), ' ', Style::new().bg(bg));
}
let mut x = 0u16;
for (cell, &w) in cells.iter().zip(widths) {
if x >= width {
break;
}
let avail = (width - x).min(w);
let _ = draw_clipped(surface, (x, y), avail, cell, Align::Left, style);
x = x.saturating_add(w.saturating_add(column_spacing));
}
}
#[cfg(test)]
mod tests {
use alloc::vec;
use alloc::vec::Vec;
use retroglyph_core::grid::{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));
StatefulWidget::render(&table, &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(); StatefulWidget::render(&table, &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); StatefulWidget::render(&table, &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); StatefulWidget::render(&table, &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 height_for_is_row_count_plus_the_header_row() {
let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo", "Charlie"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
assert_eq!(table.height_for(80), 4); }
#[test]
fn default_header_style_matches_theme_dark() {
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();
StatefulWidget::render(&table, &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
);
}
#[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();
StatefulWidget::render(&table, &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));
StatefulWidget::render(&table, &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));
StatefulWidget::render(&table, &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();
StatefulWidget::render(&table, &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();
StatefulWidget::render(&table, &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.width(),
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);
StatefulWidget::render(&table, &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);
}
#[test]
fn click_selects_the_row_under_the_pointer() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
let mut state = ListState::new();
let response: Response<()> = Response {
hovered: true,
clicked: true,
pointer_pos: Some(Pos::new(2, 2)), ..Response::default()
};
let mut grid = Grid::new(20, 3);
InteractiveWidget::render(
&table,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state.selected(), Some(1));
}
#[test]
fn click_selects_the_row_under_the_pointer_with_a_scroll_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 state = ListState::new();
state.set_offset(2);
let response: Response<()> = Response {
hovered: true,
clicked: true,
pointer_pos: Some(Pos::new(2, 2)), ..Response::default()
};
let mut grid = Grid::new(20, 3);
InteractiveWidget::render(
&table,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state.selected(), Some(3));
}
#[test]
fn click_on_the_header_row_selects_nothing() {
let area = Rect::new(0, 0, 20, 3);
let headers = ["Name"];
let widths = [10u16];
let rows = rows(&["Alpha", "Bravo"]);
let rows = row_refs(&rows);
let table = Table::new(&headers, &widths, &rows);
let mut state = ListState::new();
state.select(Some(0));
let response: Response<()> = Response {
hovered: true,
clicked: true,
pointer_pos: Some(Pos::new(2, 0)), ..Response::default()
};
let mut grid = Grid::new(20, 3);
InteractiveWidget::render(
&table,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state.selected(), Some(0)); }
#[test]
fn wheel_scroll_moves_the_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 state = ListState::new();
let response: Response<()> = Response {
scroll_delta: 2,
..Response::default()
};
let mut grid = Grid::new(20, 3);
InteractiveWidget::render(
&table,
&mut Surface::new(&mut grid, area, 0),
&mut state,
response,
);
assert_eq!(state.offset(), 2);
}
}