use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use super::{ratatui_color, set_cell};
use crate::primitives::data_table::{
ColumnAlign, ColumnMeasure, DataTable, DataTableGlyphs, DataTableLayout, SortDirection,
};
use crate::theme::Theme;
pub fn data_table_layout(area: Rect, table: &DataTable) -> DataTableLayout {
let mut layout = table.layout(
area.width as f32,
area.height as f32,
1.0,
1.0,
1.0,
|col| ColumnMeasure::new(col.title.chars().count() as f32),
);
layout.h_scroll = table.h_scroll.round();
layout
}
pub fn draw_data_table(
buf: &mut Buffer,
area: Rect,
table: &DataTable,
theme: &Theme,
hovered_idx: Option<usize>,
) -> DataTableLayout {
draw_data_table_with_glyphs(buf, area, table, theme, hovered_idx, None, false)
}
pub fn draw_data_table_with_glyphs(
buf: &mut Buffer,
area: Rect,
table: &DataTable,
theme: &Theme,
hovered_idx: Option<usize>,
glyphs: Option<&DataTableGlyphs>,
nerd_fonts_enabled: bool,
) -> DataTableLayout {
let layout = data_table_layout(area, table);
if area.width == 0 || area.height == 0 {
return layout;
}
let header_bg = ratatui_color(theme.tab_bar_bg);
let header_fg = ratatui_color(theme.foreground);
let body_bg = ratatui_color(theme.background);
let body_fg = ratatui_color(theme.foreground);
let sel_bg = ratatui_color(theme.selection_bg);
let muted_fg = ratatui_color(theme.muted_fg);
let header_y = area.y;
for x in 0..area.width {
set_cell(buf, area.x + x, header_y, ' ', header_fg, header_bg);
}
let sep_fg = ratatui_color(theme.separator);
let h_off = layout.h_scroll as i16;
for (col_idx, rc) in layout.columns.iter().enumerate() {
if col_idx >= table.columns.len() {
break;
}
let col = &table.columns[col_idx];
let col_x_raw = area.x as i32 + rc.x.round() as i32 - h_off as i32;
let col_x_end = col_x_raw + rc.width.round() as i32;
if col_x_end <= area.x as i32 || col_x_raw >= (area.x + area.width) as i32 {
continue;
}
let col_w = rc.width.round() as u16;
if col_w == 0 {
continue;
}
if col_idx + 1 < table.columns.len() {
let sep_cx = col_x_raw + col_w as i32 - 1;
if sep_cx >= area.x as i32 && sep_cx < (area.x + area.width) as i32 {
set_cell(buf, sep_cx as u16, header_y, '│', sep_fg, header_bg);
}
}
let sort_glyph: Option<&str> = match &table.sort {
Some((si, dir)) if *si == col_idx => Some(match dir {
SortDirection::Ascending => glyphs
.and_then(DataTableGlyphs::sort_ascending_glyph)
.map(|icon| icon.resolve(nerd_fonts_enabled))
.unwrap_or("â–²"),
SortDirection::Descending => glyphs
.and_then(DataTableGlyphs::sort_descending_glyph)
.map(|icon| icon.resolve(nerd_fonts_enabled))
.unwrap_or("â–¼"),
}),
_ => None,
};
let title = match sort_glyph {
Some(glyph) => format!("{} {glyph}", col.title),
None => col.title.clone(),
};
let text_len = title.chars().count() as i32;
let usable_w = if col_idx + 1 < table.columns.len() {
col_w.saturating_sub(1) as i32
} else {
col_w as i32
};
let start = align_offset(col.align, text_len as u16, usable_w as u16) as i32;
for (i, ch) in title.chars().enumerate() {
let cx = col_x_raw + start + i as i32;
if cx >= col_x_raw + usable_w || cx >= (area.x + area.width) as i32 {
break;
}
if cx >= area.x as i32 {
set_cell(buf, cx as u16, header_y, ch, header_fg, header_bg);
if let Some(cell) =
buf.cell_mut(ratatui::prelude::Position::new(cx as u16, header_y))
{
cell.set_style(ratatui::style::Style::default().add_modifier(Modifier::BOLD));
}
}
}
}
let body_y = area.y + 1;
let visible = layout
.visible_rows
.min(table.rows.len().saturating_sub(table.scroll_offset));
for row_idx in 0..visible {
let abs_idx = table.scroll_offset + row_idx;
let row = &table.rows[abs_idx];
let y = body_y + row_idx as u16;
let is_selected = table.selected_idx == Some(abs_idx);
let is_hovered = hovered_idx == Some(abs_idx) && !is_selected;
let (row_fg, row_bg) = if is_selected {
(body_fg, sel_bg)
} else if is_hovered {
(body_fg, ratatui_color(theme.tab_bar_bg))
} else {
(body_fg, body_bg)
};
for x in 0..area.width {
set_cell(buf, area.x + x, y, ' ', row_fg, row_bg);
}
for (col_idx, rc) in layout.columns.iter().enumerate() {
let col_x_raw = area.x as i32 + rc.x.round() as i32 - h_off as i32;
let col_x_end = col_x_raw + rc.width.round() as i32;
if col_x_end <= area.x as i32 || col_x_raw >= (area.x + area.width) as i32 {
continue;
}
let col_w = rc.width.round() as u16;
if col_w == 0 {
continue;
}
if col_idx + 1 < table.columns.len() {
let sep_cx = col_x_raw + col_w as i32 - 1;
if sep_cx >= area.x as i32 && sep_cx < (area.x + area.width) as i32 {
set_cell(buf, sep_cx as u16, y, '│', sep_fg, row_bg);
}
}
let styled = match row.cells.get(col_idx) {
Some(c) if !c.spans.is_empty() => c,
_ => continue,
};
let full_text: String = styled.spans.iter().map(|s| s.text.as_str()).collect();
if full_text.is_empty() {
continue;
}
let align = table
.columns
.get(col_idx)
.map(|c| c.align)
.unwrap_or(ColumnAlign::Left);
let usable_w: u16 = if col_idx + 1 < table.columns.len() {
col_w.saturating_sub(1)
} else {
col_w
};
let text_len = full_text.chars().count() as u16;
let (visible_len, needs_ellipsis) = if text_len <= usable_w {
(text_len, false)
} else if usable_w == 0 {
(0, false)
} else {
(usable_w - 1, true)
};
let displayed_len = visible_len + u16::from(needs_ellipsis);
let start = align_offset(align, displayed_len, usable_w) as i32;
let is_muted = row.decoration == crate::types::Decoration::Muted;
let mut char_idx = 0u16;
'cell: for span in &styled.spans {
let span_fg = if is_muted {
muted_fg
} else {
span.fg.map(ratatui_color).unwrap_or(row_fg)
};
for ch in span.text.chars() {
if char_idx == visible_len {
if needs_ellipsis {
let cx = col_x_raw + start + char_idx as i32;
if cx >= area.x as i32 && cx < (area.x + area.width) as i32 {
set_cell(buf, cx as u16, y, '…', span_fg, row_bg);
}
}
break 'cell;
}
let cx = col_x_raw + start + char_idx as i32;
if cx >= (area.x + area.width) as i32 {
break 'cell;
}
if cx >= area.x as i32 {
set_cell(buf, cx as u16, y, ch, span_fg, row_bg);
}
char_idx += 1;
}
}
}
}
let footer_h = layout.footer_height.round() as u16;
if table.show_scrollbar
&& table.rows.len() > layout.visible_rows
&& layout.scrollbar_width > 0.0
{
let sb_x = area.x + area.width - layout.scrollbar_width.round() as u16;
let sb_track = crate::event::Rect::new(
sb_x as f32,
(area.y + 1) as f32,
1.0,
(area.height.saturating_sub(1).saturating_sub(footer_h)) as f32,
);
let sb = crate::primitives::scrollbar::Scrollbar::vertical(
table.id,
sb_track,
table.scroll_offset as f32,
table.rows.len() as f32,
layout.visible_rows as f32,
1.0,
);
super::draw_scrollbar(buf, &sb, theme, theme.background);
}
if layout.h_scrollbar_height > 0.0 && layout.content_width > 0.0 {
let hsb_y = area.y + area.height - footer_h - layout.h_scrollbar_height.round() as u16;
let track_w = (area.width as f32 - layout.scrollbar_width).max(1.0);
let hsb_track = crate::event::Rect::new(area.x as f32, hsb_y as f32, track_w, 1.0);
let visible_w = (area.width as f32 - layout.scrollbar_width).max(1.0);
let hsb = crate::primitives::scrollbar::Scrollbar::horizontal(
table.id,
hsb_track,
table.h_scroll,
layout.content_width,
visible_w,
1.0,
);
super::draw_scrollbar(buf, &hsb, theme, theme.background);
}
if let Some(footer) = &table.footer {
if footer_h > 0 {
let footer_y = area.y + area.height - 1;
let footer_bg = ratatui_color(theme.tab_bar_bg);
let footer_fg = ratatui_color(theme.foreground);
if footer_y > area.y {
for x in 0..area.width {
set_cell(buf, area.x + x, footer_y - 1, '─', sep_fg, header_bg);
}
}
for x in 0..area.width {
set_cell(buf, area.x + x, footer_y, ' ', footer_fg, footer_bg);
}
for (col_idx, rc) in layout.columns.iter().enumerate() {
let styled = match footer.cells.get(col_idx) {
Some(c) if !c.spans.is_empty() => c,
_ => continue,
};
let full_text: String = styled.spans.iter().map(|s| s.text.as_str()).collect();
let col_x_raw = area.x as i32 + rc.x.round() as i32 - h_off as i32;
let col_x_end = col_x_raw + rc.width.round() as i32;
if col_x_end <= area.x as i32 || col_x_raw >= (area.x + area.width) as i32 {
continue;
}
let col_w = rc.width.round() as u16;
if col_w == 0 || full_text.is_empty() {
continue;
}
let align = table
.columns
.get(col_idx)
.map(|c| c.align)
.unwrap_or(ColumnAlign::Left);
let text_len = full_text.chars().count() as u16;
let start = align_offset(align, text_len, col_w) as i32;
let mut char_offset = 0i32;
for span in &styled.spans {
let span_fg = span.fg.map(ratatui_color).unwrap_or(footer_fg);
for ch in span.text.chars() {
let cx = col_x_raw + start + char_offset;
if cx >= (area.x + area.width) as i32 {
break;
}
if cx >= area.x as i32 {
set_cell(buf, cx as u16, footer_y, ch, span_fg, footer_bg);
if let Some(cell) =
buf.cell_mut(ratatui::prelude::Position::new(cx as u16, footer_y))
{
cell.set_style(
ratatui::style::Style::default().add_modifier(Modifier::BOLD),
);
}
}
char_offset += 1;
}
}
}
}
}
layout
}
fn align_offset(align: ColumnAlign, text_len: u16, col_w: u16) -> u16 {
match align {
ColumnAlign::Left => 0,
ColumnAlign::Center => col_w.saturating_sub(text_len) / 2,
ColumnAlign::Right => col_w.saturating_sub(text_len),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::data_table::{Column, ColumnWidth, DataRow, DataTable, DataTableHit};
use crate::types::{Decoration, StyledText, WidgetId};
fn find_char_pos(line: &str, needle: &str) -> Option<usize> {
let chars: Vec<char> = line.chars().collect();
let needle: Vec<char> = needle.chars().collect();
if needle.is_empty() || chars.len() < needle.len() {
return None;
}
chars
.windows(needle.len())
.position(|w| w == needle.as_slice())
}
fn make_table() -> DataTable {
DataTable {
id: WidgetId::new("test"),
columns: vec![
Column {
title: "Name".into(),
width: ColumnWidth::Flex(2.0),
align: ColumnAlign::Left,
},
Column {
title: "Status".into(),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Left,
},
Column {
title: "Age".into(),
width: ColumnWidth::Fixed(5.0),
align: ColumnAlign::Right,
},
],
rows: vec![
DataRow {
cells: vec![
StyledText::plain("pod-abc"),
StyledText::plain("Running"),
StyledText::plain("3d"),
],
decoration: Decoration::Normal,
},
DataRow {
cells: vec![
StyledText::plain("pod-xyz"),
StyledText::plain("Pending"),
StyledText::plain("1h"),
],
decoration: Decoration::Normal,
},
],
selected_idx: Some(0),
scroll_offset: 0,
sort: Some((0, SortDirection::Ascending)),
has_focus: true,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn header_paints_column_titles() {
let table = make_table();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
header.contains("Name"),
"header should contain 'Name', got: {header}"
);
assert!(
header.contains("Status"),
"header should contain 'Status', got: {header}"
);
assert!(
header.contains("Age"),
"header should contain 'Age', got: {header}"
);
assert!(
header.contains("â–²"),
"sorted column should show â–², got: {header}"
);
}
#[test]
fn data_table_glyphs_override_swaps_sort_arrow_default_keeps_arrow() {
let area = Rect::new(0, 0, 40, 10);
let table = make_table();
let mut buf = Buffer::empty(area);
draw_data_table_with_glyphs(&mut buf, area, &table, &Theme::default(), None, None, false);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
header.contains('â–²'),
"expected default 'â–²' sort glyph, got: {header}"
);
let glyphs = DataTableGlyphs::new().with_sort_ascending_glyph("^");
let mut buf = Buffer::empty(area);
draw_data_table_with_glyphs(
&mut buf,
area,
&table,
&Theme::default(),
None,
Some(&glyphs),
false,
);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
!header.contains('â–²'),
"default 'â–²' sort glyph leaked through override, got: {header}"
);
assert!(
header.contains('^'),
"expected overridden '^' sort glyph, got: {header}"
);
let mut desc_table = make_table();
desc_table.sort = Some((0, SortDirection::Descending));
let mut buf = Buffer::empty(area);
draw_data_table_with_glyphs(
&mut buf,
area,
&desc_table,
&Theme::default(),
None,
None,
false,
);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
header.contains('â–¼'),
"expected default 'â–¼' sort glyph, got: {header}"
);
let desc_glyphs = DataTableGlyphs::new().with_sort_descending_glyph("v");
let mut buf = Buffer::empty(area);
draw_data_table_with_glyphs(
&mut buf,
area,
&desc_table,
&Theme::default(),
None,
Some(&desc_glyphs),
false,
);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
!header.contains('â–¼'),
"default 'â–¼' sort glyph leaked through override, got: {header}"
);
assert!(
header.contains('v'),
"expected overridden 'v' sort glyph, got: {header}"
);
}
#[test]
fn body_paints_row_content() {
let table = make_table();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let row0: String = (0..40)
.map(|x| buf[(x, 1)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
row0.contains("pod-abc"),
"row 0 should contain 'pod-abc', got: {row0}"
);
assert!(
row0.contains("Running"),
"row 0 should contain 'Running', got: {row0}"
);
}
#[test]
fn body_rows_draw_column_separator_at_same_x_as_header() {
let table = make_table();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let find_seps =
|y: u16| -> Vec<u16> { (0..40).filter(|&x| buf[(x, y)].symbol() == "│").collect() };
let header_seps = find_seps(0);
assert_eq!(
header_seps.len(),
table.columns.len() - 1,
"header should have one separator per internal column boundary, got {header_seps:?}"
);
for row_y in [1u16, 2u16] {
let body_seps = find_seps(row_y);
assert_eq!(
body_seps, header_seps,
"body row {row_y} separators should sit at the same x as the header's"
);
}
}
#[test]
fn body_cell_exactly_filling_column_still_gets_a_separator() {
let mut table = make_table();
table.rows[0].cells[0] = StyledText::plain("x".repeat(22));
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let sep_x = (layout.columns[0].x.round() as i32 + layout.columns[0].width.round() as i32
- 1) as u16;
let ch = buf[(sep_x, 1)].symbol().to_string();
assert_eq!(
ch, "│",
"an exactly-filling cell must not paint over the separator column"
);
}
#[test]
fn body_cell_wider_than_column_is_clipped_and_does_not_bleed_into_neighbour() {
let table = DataTable {
id: WidgetId::new("clip-test"),
columns: vec![
Column {
title: "A".into(),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Left,
},
Column {
title: "B".into(),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Left,
},
Column {
title: "C".into(),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Left,
},
],
rows: vec![DataRow {
cells: vec![
StyledText::plain("A"),
StyledText::plain("this-value-is-far-wider-than-its-resolved-column-share"),
StyledText::plain("C"),
],
decoration: Decoration::Normal,
}],
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
};
let area = Rect::new(0, 0, 30, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let row: Vec<char> = (0..30)
.map(|x| buf[(x, 1)].symbol().chars().next().unwrap_or(' '))
.collect();
let row_str: String = row.iter().collect();
let c_col = &layout.columns[2];
let c_start = c_col.x.round() as usize;
assert_eq!(row[c_start], 'C', "row: {row_str:?}");
for (x, &ch) in row.iter().enumerate().skip(c_start + 1) {
assert_eq!(
ch, ' ',
"cell at x={x} inside column C should be blank, not corrupted by B's overflow \
(row: {row_str:?})"
);
}
let b_col = &layout.columns[1];
let b_range = (b_col.x.round() as usize)..c_start;
assert!(
b_range.clone().any(|x| row[x] == '…'),
"over-long body cell should end in an ellipsis, not a hard cut: {row_str:?}"
);
}
#[test]
fn paint_click_round_trip_header() {
let table = make_table();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let header: String = (0..40)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
let status_pos = header.find("Status").expect("Status should be in header");
let hit = layout.hit_test(status_pos as f32 + 3.0, 0.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Header { col: 1 });
}
#[test]
fn paint_click_round_trip_header_at_nonzero_origin() {
let table = make_table();
let area = Rect::new(7, 13, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let header: String = (area.x..area.x + 40)
.map(|x| buf[(x, area.y)].symbol().chars().next().unwrap_or(' '))
.collect();
let status_pos = header.find("Status").expect("Status should be in header");
let hit = layout.hit_test(status_pos as f32 + 3.0, 0.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Header { col: 1 });
}
#[test]
fn paint_click_round_trip_row() {
let table = make_table();
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let row1: String = (0..40)
.map(|x| buf[(x, 2)].symbol().chars().next().unwrap_or(' '))
.collect();
let pod_pos = row1.find("pod-xyz").expect("pod-xyz should be in row 1");
let hit = layout.hit_test(pod_pos as f32, 2.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Row { idx: 1 });
}
#[test]
fn paint_click_round_trip_row_at_nonzero_origin() {
let table = make_table();
let area = Rect::new(7, 13, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let row1: String = (area.x..area.x + 40)
.map(|x| buf[(x, area.y + 2)].symbol().chars().next().unwrap_or(' '))
.collect();
let pod_pos = row1.find("pod-xyz").expect("pod-xyz should be in row 1");
let hit = layout.hit_test(pod_pos as f32, 2.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Row { idx: 1 });
}
#[test]
fn column_bounds_do_not_move_when_the_table_does() {
let table = make_table();
let cols_at = |origin_x: u16, origin_y: u16| {
let area = Rect::new(origin_x, origin_y, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
layout
.columns
.iter()
.map(|c| (c.x, c.width))
.collect::<Vec<_>>()
};
let at_zero = cols_at(0, 0);
assert!(!at_zero.is_empty(), "sanity: some columns were resolved");
assert_eq!(
cols_at(7, 13),
at_zero,
"column bounds must be table-local, independent of area.x/area.y"
);
}
fn make_scrolling_table(nrows: usize) -> DataTable {
let mut table = make_table();
table.rows = (0..nrows)
.map(|r| DataRow {
cells: vec![
StyledText::plain(format!("pod-{r}")),
StyledText::plain("Running"),
StyledText::plain(format!("{r}d")),
],
decoration: Decoration::Normal,
})
.collect();
table
}
#[test]
fn footer_row_renders_pinned_regardless_of_scroll_offset() {
let mut table = make_scrolling_table(50);
table.footer = Some(DataRow {
cells: vec![
StyledText::plain("TOTAL"),
StyledText::plain(""),
StyledText::plain("50d"),
],
decoration: Decoration::Normal,
});
let area = Rect::new(0, 0, 40, 10);
for scroll_offset in [0, 5, 30] {
table.scroll_offset = scroll_offset;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
assert!(layout.footer_height > 0.0);
let last_row: String = (0..40)
.map(|x| {
buf[(x, area.height - 1)]
.symbol()
.chars()
.next()
.unwrap_or(' ')
})
.collect();
assert!(
last_row.contains("TOTAL"),
"footer should stay pinned at scroll_offset={scroll_offset}, got: {last_row}"
);
}
}
#[test]
fn footer_cell_aligns_under_its_column() {
let mut table = make_table();
table.footer = Some(DataRow {
cells: vec![
StyledText::plain(""),
StyledText::plain(""),
StyledText::plain("4d"),
],
decoration: Decoration::Normal,
});
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let footer_y = area.height - 1;
let footer_line: String = (0..40)
.map(|x| buf[(x, footer_y)].symbol().chars().next().unwrap_or(' '))
.collect();
let body_line: String = (0..40)
.map(|x| buf[(x, 1)].symbol().chars().next().unwrap_or(' '))
.collect();
let footer_pos = find_char_pos(&footer_line, "4d").expect("footer total painted");
let body_pos = find_char_pos(&body_line, "3d").expect("body cell painted");
assert_eq!(
footer_pos, body_pos,
"footer's Age total should align at the same x as a body row's Age cell"
);
let age_col = &layout.columns[2];
assert!(footer_pos as f32 >= age_col.x && (footer_pos as f32) < age_col.x + age_col.width);
}
#[test]
fn footer_click_is_footer_hit_not_row() {
let mut table = make_scrolling_table(50);
table.footer = Some(DataRow {
cells: vec![StyledText::plain("TOTAL")],
decoration: Decoration::Normal,
});
let area = Rect::new(0, 0, 40, 10);
let layout = {
let mut buf = Buffer::empty(area);
draw_data_table(&mut buf, area, &table, &Theme::default(), None)
};
let hit = layout.hit_test(
2.0,
(area.height - 1) as f32 + 0.5,
table.scroll_offset,
table.rows.len(),
);
assert_eq!(hit, DataTableHit::Footer);
}
#[test]
fn none_footer_body_unaffected() {
let table = make_scrolling_table(3);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
assert_eq!(layout.footer_height, 0.0);
let row2: String = (0..40)
.map(|x| buf[(x, 3)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
row2.contains("pod-2"),
"row 2 should paint normally, got: {row2}"
);
}
fn make_h_scrolling_table() -> DataTable {
let titles = ["Alpha", "Bravo", "Charlie", "Delta"];
DataTable {
id: WidgetId::new("hscroll"),
columns: titles
.iter()
.map(|t| Column {
title: (*t).into(),
width: ColumnWidth::Fixed(12.0),
align: ColumnAlign::Left,
})
.collect(),
rows: vec![DataRow {
cells: vec![
StyledText::plain("a-one"),
StyledText::plain("b-two"),
StyledText::plain("c-three"),
StyledText::plain("d-four"),
],
decoration: Decoration::Normal,
}],
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: Some(48.0),
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn h_scrolled_header_click_resolves_to_the_painted_column() {
let area = Rect::new(0, 0, 24, 6);
for h_scroll in [0.0_f32, 6.0, 14.0, 30.0] {
let mut table = make_h_scrolling_table();
table.h_scroll = h_scroll;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let header: String = (0..area.width)
.map(|x| buf[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
let mut checked = 0;
for (col_idx, col) in table.columns.iter().enumerate() {
let Some(pos) = find_char_pos(&header, &col.title) else {
continue; };
checked += 1;
let x = pos + col.title.chars().count() - 1;
assert_eq!(
layout.hit_test(x as f32 + 0.5, 0.5, 0, table.rows.len()),
DataTableHit::Header { col: col_idx },
"h_scroll={h_scroll}: cell {x} paints '{}' but hit-tests elsewhere\n\
header: {header:?}",
col.title
);
}
assert!(
checked > 0,
"h_scroll={h_scroll} painted no fully-visible header title — the assertion \
loop above would be vacuous\nheader: {header:?}"
);
}
}
#[test]
fn h_scrolled_body_cell_click_resolves_to_the_painted_column() {
let area = Rect::new(0, 0, 24, 6);
for h_scroll in [0.0_f32, 6.0, 14.0, 30.0] {
let mut table = make_h_scrolling_table();
table.h_scroll = h_scroll;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let row: String = (0..area.width)
.map(|x| buf[(x, 1)].symbol().chars().next().unwrap_or(' '))
.collect();
let mut checked = 0;
for (col_idx, cell) in table.rows[0].cells.iter().enumerate() {
let text: String = cell.spans.iter().map(|s| s.text.as_str()).collect();
let Some(pos) = find_char_pos(&row, &text) else {
continue;
};
checked += 1;
assert_eq!(
layout.hit_test(pos as f32 + 0.5, 1.5, 0, table.rows.len()),
DataTableHit::Row { idx: 0 },
"h_scroll={h_scroll}: body click should stay row 0"
);
assert_eq!(
layout.column_hit(pos as f32 + 0.5),
Some(col_idx),
"h_scroll={h_scroll}: cell {pos} paints '{text}' but column_hit disagrees\n\
row: {row:?}"
);
}
assert!(
checked > 0,
"h_scroll={h_scroll} painted no fully-visible body cell — the assertion loop \
above would be vacuous\nrow: {row:?}"
);
}
}
fn make_wide_hscroll_table() -> DataTable {
let titles = ["Alpha", "Bravo", "Charlie", "Delta"];
DataTable {
id: WidgetId::new("wide-hscroll"),
columns: titles
.iter()
.map(|t| Column {
title: (*t).into(),
width: ColumnWidth::Fixed(30.0),
align: ColumnAlign::Left,
})
.collect(),
rows: vec![DataRow {
cells: vec![
StyledText::plain("a-one"),
StyledText::plain("b-two"),
StyledText::plain("c-three"),
StyledText::plain("d-four"),
],
decoration: Decoration::Normal,
}],
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: Some(120.0),
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn raw_integer_click_at_fractional_h_scroll_hits_the_painted_column() {
let area = Rect::new(0, 0, 60, 3);
let mut table = make_wide_hscroll_table();
table.h_scroll = 15.6;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
assert_eq!(
layout.h_scroll, 16.0,
"TUI layout must carry the rounded offset"
);
assert_eq!(
layout.column_hit(14.0),
Some(1),
"integer click x=14 sits on c1's painted left edge at h_scroll=15.6"
);
assert_ne!(layout.column_hit(14.0), Some(0));
assert_eq!(
layout.hit_test(14.0, 0.0, 0, table.rows.len()),
DataTableHit::HeaderDivider { col: 0 },
"residual at an exact boundary falls in the pre-existing divider grab zone"
);
assert_ne!(
layout.hit_test(14.0, 0.0, 0, table.rows.len()),
DataTableHit::Header { col: 0 },
);
}
#[test]
fn raw_integer_body_click_at_fractional_h_scroll_hits_the_painted_column() {
let area = Rect::new(0, 0, 60, 3);
let mut table = make_wide_hscroll_table();
table.h_scroll = 15.6;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
assert_eq!(
layout.hit_test(14.0, 1.0, 0, table.rows.len()),
DataTableHit::Row { idx: 0 },
"row resolution is unaffected by h_scroll rounding"
);
assert_eq!(layout.column_hit(14.0), Some(1));
}
#[test]
fn raw_integer_header_click_at_fractional_h_scroll_does_not_silently_arm_a_resize_drag() {
let area = Rect::new(0, 0, 60, 3);
for h_scroll in [15.6_f32, 45.7, 75.9] {
let mut table = make_wide_hscroll_table();
table.h_scroll = h_scroll;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let h_off = h_scroll.round();
for x in 0..60u16 {
let hit = layout.hit_test(x as f32, 0.0, 0, table.rows.len());
if let DataTableHit::Header { col } = hit {
let content_x = x as f32 + h_off;
let painted = layout
.columns
.iter()
.position(|rc| content_x >= rc.x && content_x < rc.x + rc.width);
assert_eq!(
painted,
Some(col),
"h_scroll={h_scroll}: integer x={x} resolved to Header{{col: {col}}} \
but the renderer paints column {painted:?} there"
);
}
}
}
}
#[test]
fn h_scrolled_header_divider_click_resolves_to_the_painted_separator() {
let area = Rect::new(0, 0, 24, 6);
for h_scroll in [0.0_f32, 6.0, 14.0, 30.0] {
let mut table = make_h_scrolling_table();
table.h_scroll = h_scroll;
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
let painted_seps: Vec<u16> = (0..area.width)
.filter(|&x| buf[(x, 0)].symbol() == "│")
.collect();
assert!(
!painted_seps.is_empty(),
"h_scroll={h_scroll}: expected at least one painted separator"
);
for sep_x in painted_seps {
let hit = layout.hit_test(sep_x as f32 + 0.5, 0.5, 0, table.rows.len());
let DataTableHit::HeaderDivider { col } = hit else {
panic!(
"h_scroll={h_scroll}: separator painted at x={sep_x} must hit-test as a \
divider, got {hit:?}"
);
};
let boundary = layout.columns[col].x + layout.columns[col].width;
assert!(
((boundary - h_scroll) - (sep_x as f32 + 1.0)).abs() < 0.01,
"h_scroll={h_scroll}: separator at x={sep_x} resolved to divider {col}, \
whose boundary paints at {}",
boundary - h_scroll
);
}
}
}
#[test]
fn vertical_scrollbar_track_excludes_footer_band() {
let mut table = make_scrolling_table(50);
table.show_scrollbar = true;
table.footer = Some(DataRow {
cells: vec![StyledText::plain("TOTAL")],
decoration: Decoration::Normal,
});
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
let layout = draw_data_table(&mut buf, area, &table, &Theme::default(), None);
assert_eq!(layout.footer_height, 2.0);
assert_eq!(layout.visible_rows, 7);
let sb_x = area.width - 1;
let track_cells = (0..area.height)
.filter(|&y| matches!(buf[(sb_x, y)].symbol(), "â–ˆ" | "â–‘"))
.count();
assert_eq!(
track_cells, layout.visible_rows,
"scrollbar track should span exactly the body band (visible_rows), \
not bleed into the reserved footer band"
);
for y in (area.height - 2)..area.height {
let sym = buf[(sb_x, y)].symbol();
assert_ne!(
sym, "â–ˆ",
"footer row {y} should not be painted over by the scrollbar thumb"
);
assert_ne!(
sym, "â–‘",
"footer row {y} should not be painted over by the scrollbar track"
);
}
}
}
#[cfg(test)]
mod multi_select_driver_tests {
use crate::primitives::data_table::{Column, DataRow, DataTable};
use crate::runner::{AppLogic, Reaction};
use crate::tui::testing::TuiDriver;
use crate::{
apply_selection_gesture, Backend, DataTableEvent, Decoration, Key, Modifiers, NamedKey,
Rect, SelectionGesture, StyledText, UiEvent, WidgetId,
};
const VIEWPORT_W: u16 = 40;
const VIEWPORT_H: u16 = 10;
fn row(label: &str) -> DataRow {
DataRow {
cells: vec![StyledText::plain(label.to_string())],
decoration: Decoration::Normal,
}
}
fn sample_table() -> DataTable {
DataTable {
id: WidgetId::new("dt"),
columns: vec![Column {
title: "Name".to_string(),
width: Default::default(),
align: Default::default(),
}],
rows: (0..5).map(|i| row(&format!("row {i}"))).collect(),
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: true,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: vec![],
footer: None,
}
}
struct TableApp {
table: DataTable,
cursor: usize,
selected: Vec<usize>,
anchor: usize,
events: Vec<DataTableEvent>,
}
impl TableApp {
fn new() -> Self {
Self {
table: sample_table(),
cursor: 0,
selected: Vec::new(),
anchor: 0,
events: Vec::new(),
}
}
}
impl AppLogic for TableApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_data_table(
Rect::new(0.0, 0.0, VIEWPORT_W as f32, VIEWPORT_H as f32),
&self.table,
None,
);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
let UiEvent::KeyPressed { key, modifiers, .. } = event else {
return Reaction::Continue;
};
let last = self.table.rows.len().saturating_sub(1);
match key {
Key::Named(NamedKey::Down) | Key::Named(NamedKey::Up) => {
let delta: isize = if key == Key::Named(NamedKey::Down) {
1
} else {
-1
};
self.cursor = (self.cursor as isize + delta).clamp(0, last as isize) as usize;
if !modifiers.shift {
return Reaction::Redraw;
}
let indices = apply_selection_gesture(
&self.selected,
self.anchor,
self.cursor,
SelectionGesture::Range,
);
self.selected = indices.clone();
self.events.push(DataTableEvent::SelectionSet { indices });
Reaction::Redraw
}
Key::Char(' ') => {
let indices = apply_selection_gesture(
&self.selected,
self.anchor,
self.cursor,
SelectionGesture::Toggle,
);
self.selected = indices.clone();
self.anchor = self.cursor;
self.events.push(DataTableEvent::SelectionSet { indices });
Reaction::Redraw
}
_ => Reaction::Continue,
}
}
}
fn key_event(key: Key, modifiers: Modifiers) -> UiEvent {
UiEvent::KeyPressed {
key,
modifiers,
repeat: false,
}
}
fn driver() -> TuiDriver<TableApp> {
TuiDriver::new(TableApp::new(), VIEWPORT_W, VIEWPORT_H)
}
#[test]
fn plain_arrow_moves_the_cursor_without_changing_selection() {
let mut driver = driver();
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
assert_eq!(driver.app().cursor, 1);
assert_eq!(driver.app().selected, Vec::<usize>::new());
}
#[test]
fn space_toggles_rows_into_a_multi_selection() {
let mut driver = driver();
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Down), Modifiers::default()));
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
assert_eq!(driver.app().selected, vec![0, 2]);
driver.dispatch(key_event(Key::Named(NamedKey::Up), Modifiers::default()));
driver.dispatch(key_event(Key::Named(NamedKey::Up), Modifiers::default()));
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
assert_eq!(driver.app().selected, vec![2]);
assert_eq!(
driver.app().events.last(),
Some(&DataTableEvent::SelectionSet { indices: vec![2] })
);
}
#[test]
fn shift_arrow_extends_a_range_from_the_anchor() {
let mut driver = driver();
driver.dispatch(key_event(Key::Char(' '), Modifiers::default()));
let shift = Modifiers {
shift: true,
..Default::default()
};
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
driver.dispatch(key_event(Key::Named(NamedKey::Down), shift));
assert_eq!(driver.app().selected, vec![0, 1, 2, 3]);
assert_eq!(
driver.app().events.last(),
Some(&DataTableEvent::SelectionSet {
indices: vec![0, 1, 2, 3]
})
);
}
}