use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use ratatui_core::text::Line;
use crate::geometry::Size;
use crate::layout::{Dimension, Item, LayoutStyle, solve};
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
use super::select::SelectState;
use super::text::line_width;
#[derive(Clone)]
pub struct Column {
header: Line<'static>,
width: Dimension,
}
impl Column {
pub fn new(header: impl Into<Line<'static>>, width: Dimension) -> Self {
Self {
header: header.into(),
width,
}
}
pub fn auto(header: impl Into<Line<'static>>) -> Self {
Self::new(header, Dimension::Auto)
}
pub fn fixed(header: impl Into<Line<'static>>, cells: u16) -> Self {
Self::new(header, Dimension::Fixed(cells))
}
pub fn flex(header: impl Into<Line<'static>>, weight: u16) -> Self {
Self::new(header, Dimension::Flex(weight))
}
}
pub struct Table {
columns: Vec<Column>,
rows: Vec<Vec<Line<'static>>>,
selected: usize,
viewport: Option<u16>,
scrollbar: bool,
gutter: bool,
show_header: bool,
gap: u16,
caret: char,
header_style: Option<Style>,
preserve_selection_fg: bool,
}
impl Table {
pub fn new(columns: Vec<Column>, rows: Vec<Vec<Line<'static>>>, state: &SelectState) -> Self {
Self {
columns,
rows,
selected: state.selected(),
viewport: None,
scrollbar: true,
gutter: true,
show_header: true,
gap: 2,
caret: '›',
header_style: None,
preserve_selection_fg: false,
}
}
pub fn viewport(mut self, rows: u16) -> Self {
self.viewport = Some(rows.max(1));
self
}
pub fn scrollbar(mut self, show: bool) -> Self {
self.scrollbar = show;
self
}
pub fn gutter(mut self, show: bool) -> Self {
self.gutter = show;
self
}
pub fn header(mut self, show: bool) -> Self {
self.show_header = show;
self
}
pub fn gap(mut self, gap: u16) -> Self {
self.gap = gap;
self
}
pub fn caret(mut self, caret: char) -> Self {
self.caret = caret;
self
}
pub fn header_style(mut self, style: Style) -> Self {
self.header_style = Some(style);
self
}
pub fn preserve_selection_fg(mut self, preserve: bool) -> Self {
self.preserve_selection_fg = preserve;
self
}
fn gutter_width(&self) -> u16 {
if self.gutter { 2 } else { 0 }
}
fn header_rows(&self) -> u16 {
u16::from(self.show_header)
}
fn column_intrinsic(&self, c: usize) -> u16 {
let header_w = if self.show_header {
line_width(&self.columns[c].header)
} else {
0
};
let cells_w = self
.rows
.iter()
.filter_map(|row| row.get(c))
.map(line_width)
.max()
.unwrap_or(0);
header_w.max(cells_w)
}
fn solve_columns(&self, cols_area: Rect) -> Vec<Rect> {
let items: Vec<Item> = self
.columns
.iter()
.enumerate()
.map(|(c, col)| Item::new(col.width, Size::new(self.column_intrinsic(c), 1)))
.collect();
solve(cols_area, &LayoutStyle::row().gap(self.gap), &items)
}
fn window(&self) -> (usize, usize) {
let total = self.rows.len();
match self.viewport {
Some(v) if total > v as usize => {
let v = (v as usize).max(1);
let start = self.selected.saturating_sub(v / 2).min(total - v);
(start, v)
}
_ => (0, total),
}
}
fn draw_cells(
&self,
row: &[Line<'static>],
col_rects: &[Rect],
y: u16,
row_style: Option<Style>,
surface: &mut Surface,
) {
for (c, rect) in col_rects.iter().enumerate() {
let Some(cell) = row.get(c) else {
continue;
};
let mut x = rect.x;
let right = rect.x.saturating_add(rect.width);
for span in &cell.spans {
if x >= right {
break;
}
let style = match row_style {
Some(sel) => span.style.patch(sel),
None => span.style,
};
x = surface.set_string(x, y, span.content.as_ref(), style);
}
}
}
}
impl View for Table {
fn measure(&self, available: Size) -> Size {
let (_, rows) = self.window();
let cols_w: u16 = (0..self.columns.len())
.map(|c| self.column_intrinsic(c))
.fold(0, u16::saturating_add);
let gaps = self
.gap
.saturating_mul(self.columns.len().saturating_sub(1) as u16);
let width = self
.gutter_width()
.saturating_add(cols_w)
.saturating_add(gaps);
let height = self.header_rows().saturating_add(rows as u16);
Size::new(width.min(available.width), height)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 || self.columns.is_empty() {
return;
}
let (start, win_rows) = self.window();
let overflow = self.rows.len() > win_rows;
let gutter_w = self.gutter_width();
let scrollbar_w = u16::from(overflow && self.scrollbar);
let cols_area = Rect::new(
area.x.saturating_add(gutter_w),
area.y,
area.width
.saturating_sub(gutter_w)
.saturating_sub(scrollbar_w),
area.height,
);
let col_rects = self.solve_columns(cols_area);
if self.show_header {
let headers: Vec<Line<'static>> =
self.columns.iter().map(|c| c.header.clone()).collect();
let header_style = self
.header_style
.unwrap_or_else(|| ctx.theme.accent_style());
self.draw_cells(&headers, &col_rects, area.y, Some(header_style), surface);
}
let body_top = area.y.saturating_add(self.header_rows());
let row_span_w = area.width.saturating_sub(scrollbar_w);
for i in 0..win_rows {
let idx = start + i;
let Some(row) = self.rows.get(idx) else {
break;
};
let y = body_top.saturating_add(i as u16);
if y >= area.bottom() {
break;
}
let selected = idx == self.selected;
let row_style = if selected {
let sel = ctx.theme.selection_style();
let mut band = surface.child(Rect::new(area.x, y, row_span_w, 1));
band.fill(sel);
Some(if self.preserve_selection_fg {
Style::default().bg(ctx.theme.selection_bg)
} else {
sel
})
} else {
None
};
if self.gutter {
let caret = if selected { self.caret } else { ' ' };
let caret_style = if selected {
ctx.theme.selection_style()
} else {
ctx.theme.muted_style()
};
surface.set(area.x, y, caret, caret_style);
}
self.draw_cells(row, &col_rects, y, row_style, surface);
}
if scrollbar_w == 1 {
self.draw_scrollbar(area, start, win_rows, surface, ctx);
}
}
}
impl Table {
fn draw_scrollbar(
&self,
area: Rect,
start: usize,
rows: usize,
surface: &mut Surface,
ctx: &RenderCtx,
) {
let total = self.rows.len();
let track_x = area.right() - 1;
let track_top = area.y.saturating_add(self.header_rows());
let track_h = rows as u16;
let max_start = total.saturating_sub(rows).max(1) as u32;
let thumb_h = (((rows * rows) / total).max(1) as u16).min(track_h);
let travel = track_h.saturating_sub(thumb_h);
let thumb_y = track_top + ((start as u32 * travel as u32) / max_start) as u16;
let track_style = Style::default().fg(ctx.theme.dim);
let thumb_style = Style::default().fg(ctx.theme.muted);
for r in 0..track_h {
let y = track_top + r;
if y >= area.bottom() {
break;
}
let within = y >= thumb_y && y < thumb_y.saturating_add(thumb_h);
let (glyph, style) = if within {
('█', thumb_style)
} else {
('│', track_style)
};
surface.set(track_x, y, glyph, style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Event, Key, KeyCode};
use crate::style::Theme;
use crate::test_support::{buffer, rainbow_theme, row};
use crate::view::{RenderCtx, View};
use crate::{Size, Surface};
use ratatui_core::text::{Line, Span};
fn sample() -> (Vec<Column>, Vec<Vec<Line<'static>>>) {
let cols = vec![Column::auto("name"), Column::auto("kind")];
let rows = vec![
vec![Line::from("main"), Line::from("branch")],
vec![Line::from("wip"), Line::from("branch")],
vec![Line::from("hotfix"), Line::from("branch")],
];
(cols, rows)
}
#[test]
fn table_renders_header_and_marks_selection() {
let (cols, rows) = sample();
let mut state = SelectState::new();
state.select(1);
let table = Table::new(cols, rows, &state);
let mut buf = buffer(20, 4);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
table.render(area, &mut surface, &ctx);
assert!(row(&buf, 0).contains("name") && row(&buf, 0).contains("kind"));
assert!(row(&buf, 1).contains("main"));
assert!(row(&buf, 2).contains("wip"));
assert_eq!(buf[(0, 2)].symbol(), "›");
assert_ne!(buf[(0, 1)].symbol(), "›", "unselected row has no caret");
}
#[test]
fn table_selection_highlights_full_row() {
let (cols, rows) = sample();
let t = rainbow_theme();
let mut state = SelectState::new();
state.select(0);
let table = Table::new(cols, rows, &state);
let mut buf = buffer(20, 4);
let area = buf.area;
let ctx = RenderCtx::new(&t);
let mut surface = Surface::new(&mut buf, area);
table.render(area, &mut surface, &ctx);
let y = 1;
for x in [0u16, 2, 8, 15] {
assert_eq!(buf[(x, y)].bg, t.selection_bg, "row bg at col {x}");
}
assert_ne!(buf[(0, 2)].bg, t.selection_bg);
}
#[test]
fn table_columns_use_the_flex_solver() {
let cols = vec![Column::fixed("id", 4), Column::flex("desc", 1)];
let rows = vec![vec![Line::from("1"), Line::from("x")]];
let state = SelectState::new();
let table = Table::new(cols, rows, &state).gutter(false).gap(1);
let rects = table.solve_columns(Rect::new(0, 0, 20, 1));
assert_eq!(rects[0].width, 4, "fixed column keeps its width");
assert_eq!(rects[1].width, 15, "flex column takes the leftover");
assert_eq!(rects[1].x, 5, "second column starts after col1 + gap");
}
#[test]
fn table_windows_long_body_and_keeps_selection_visible() {
let cols = vec![Column::auto("n")];
let rows: Vec<Vec<Line>> = (0..20)
.map(|i| vec![Line::from(format!("row{i}"))])
.collect();
let mut state = SelectState::new();
state.select(15);
let table = Table::new(cols, rows, &state).viewport(4);
let theme = Theme::default();
assert_eq!(table.measure(Size::new(30, 40)).height, 5);
let text = crate::testing::grid(&crate::testing::render(&table, 30, 5, &theme));
assert!(text.contains("row15"), "selection visible:\n{text}");
assert!(!text.contains("row0\n"), "far rows windowed out:\n{text}");
let buf = crate::testing::render(&table, 30, 5, &theme);
let has_bar = (1..5).any(|y| matches!(buf[(29, y)].symbol(), "█" | "│"));
assert!(has_bar, "overflowing table draws a scrollbar:\n{text}");
}
#[test]
fn table_navigates_with_select_state() {
let (_, rows) = sample();
let mut state = SelectState::new();
state.handle(&Event::Key(Key::new(KeyCode::Down)), rows.len());
assert_eq!(state.selected(), 1);
}
#[test]
fn table_pads_short_rows() {
let cols = vec![Column::auto("a"), Column::auto("b")];
let rows = vec![vec![Line::from("only")]]; let state = SelectState::new();
let table = Table::new(cols, rows, &state);
let theme = Theme::default();
let text = crate::testing::grid(&crate::testing::render(&table, 20, 2, &theme));
assert!(text.contains("only"));
}
#[test]
fn table_custom_caret_marks_selection() {
let (cols, rows) = sample();
let mut state = SelectState::new();
state.select(0);
let table = Table::new(cols, rows, &state).caret('▶');
let mut buf = buffer(20, 3);
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
table.render(area, &mut surface, &ctx);
assert_eq!(
buf[(0, 1)].symbol(),
"▶",
"custom caret on the selected row"
);
}
#[test]
fn table_header_style_overrides_the_theme_default() {
use ratatui_core::style::{Color, Style};
let (cols, rows) = sample();
let t = rainbow_theme();
let state = SelectState::new();
let table =
Table::new(cols, rows, &state).header_style(Style::default().fg(Color::Indexed(200)));
let mut buf = buffer(20, 3);
let area = buf.area;
let ctx = RenderCtx::new(&t);
let mut surface = Surface::new(&mut buf, area);
table.render(area, &mut surface, &ctx);
assert_eq!(buf[(2, 0)].fg, Color::Indexed(200), "header 'name' fg");
assert_ne!(
Color::Indexed(200),
t.accent,
"and it differs from the default"
);
}
#[test]
fn table_preserve_selection_fg_keeps_cell_colors() {
use ratatui_core::style::{Color, Style};
let t = rainbow_theme();
let cols = vec![Column::auto("c")];
let rows = vec![vec![Line::from(Span::styled(
"ok",
Style::default().fg(Color::Indexed(46)),
))]];
let state = SelectState::new();
let render = |preserve: bool| {
let table = Table::new(cols.clone(), rows.clone(), &state)
.preserve_selection_fg(preserve)
.gutter(false);
let mut buf = buffer(6, 2);
let area = buf.area;
let ctx = RenderCtx::new(&t);
let mut surface = Surface::new(&mut buf, area);
table.render(area, &mut surface, &ctx);
let cell = &buf[(0, 1)]; (cell.fg, cell.bg)
};
let (fg, bg) = render(true);
assert_eq!(
fg,
Color::Indexed(46),
"column color survives the highlight"
);
assert_eq!(bg, t.selection_bg, "selection background still applied");
let (fg_off, _) = render(false);
assert_eq!(fg_off, t.selection_fg, "default overwrites cell fg");
}
}