use crate::component::{Component, EventCx, LayoutCx, MeasureCx};
use crate::event::Event;
use crate::geom::{Rect, Size};
use crate::layout::Constraint;
use crate::render::RenderCx;
use crate::style::Style;
use crate::text::Text;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorType {
Row,
Cell,
}
#[derive(Debug, Clone, Copy)]
pub enum ColumnWidth {
Fixed(u16),
Flex(u16),
}
pub struct TableColumn {
pub title: Text,
pub width: ColumnWidth,
pub align: crate::style::TextAlign,
}
pub struct TableCell {
pub content: Text,
pub style: Option<Style>,
}
impl From<&str> for TableCell {
fn from(s: &str) -> Self { Self { content: Text::from(s), style: None } }
}
impl From<String> for TableCell {
fn from(s: String) -> Self { Self { content: Text::from(s), style: None } }
}
impl From<Text> for TableCell {
fn from(content: Text) -> Self { Self { content, style: None } }
}
pub struct TableRow {
pub cells: Vec<TableCell>,
pub height: u16,
pub style: Option<Style>,
}
impl TableRow {
pub fn new(cells: Vec<impl Into<TableCell>>) -> Self {
Self { cells: cells.into_iter().map(|c| c.into()).collect(), height: 1, style: None }
}
pub fn height(mut self, height: u16) -> Self { self.height = height.max(1); self }
pub fn style(mut self, style: Style) -> Self { self.style = Some(style); self }
}
pub struct Table {
columns: Vec<TableColumn>,
rows: Vec<TableRow>,
footer: Option<TableRow>,
selected: usize,
scroll_offset: usize,
rect: Rect,
style: Style,
header_style: Style,
select_style: Style,
cursor_type: CursorType,
selected_column: usize,
fixed_rows: usize,
column_highlight_style: Style,
cell_highlight_style: Style,
}
impl Table {
pub fn new() -> Self {
Self {
columns: Vec::new(),
rows: Vec::new(),
selected: 0,
scroll_offset: 0,
rect: Rect::default(),
style: Style::default(),
header_style: Style::default().bold(),
select_style: Style::default(),
footer: None,
cursor_type: CursorType::Row,
selected_column: 0,
fixed_rows: 0,
column_highlight_style: Style::default(),
cell_highlight_style: Style::default(),
}
}
pub fn cursor_type(mut self, ct: CursorType) -> Self {
self.cursor_type = ct;
self
}
pub fn fixed_rows(mut self, n: usize) -> Self {
self.fixed_rows = n;
self
}
pub fn column_highlight_style(mut self, style: Style) -> Self {
self.column_highlight_style = style;
self
}
pub fn cell_highlight_style(mut self, style: Style) -> Self {
self.cell_highlight_style = style;
self
}
pub fn selected_column(&self) -> usize { self.selected_column }
pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
self.columns = columns;
self
}
pub fn rows(mut self, rows: Vec<TableRow>) -> Self {
self.rows = rows;
self
}
pub fn rows_simple(mut self, rows: Vec<Vec<impl Into<TableCell>>>) -> Self {
self.rows = rows.into_iter().map(|r| TableRow::new(r)).collect();
self
}
pub fn footer(mut self, row: TableRow) -> Self {
self.footer = Some(row);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn header_style(mut self, style: Style) -> Self {
self.header_style = style;
self
}
pub fn select_style(mut self, style: Style) -> Self {
self.select_style = style;
self
}
pub fn selected(&self) -> usize { self.selected }
pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
if index < self.rows.len() {
self.selected = index;
cx.invalidate_paint();
}
}
pub fn row_count(&self) -> usize {
self.rows.len()
}
pub fn sort_by_column(&mut self, col: usize, cx: &mut EventCx) {
if col < self.columns.len() {
self.rows.sort_by(|a, b| {
let ca = a.cells.get(col).map(|c| c.content.first_text()).unwrap_or("");
let cb = b.cells.get(col).map(|c| c.content.first_text()).unwrap_or("");
ca.cmp(cb)
});
cx.invalidate_paint();
}
}
pub fn set_column_width(&mut self, col: usize, width: u16, cx: &mut EventCx) {
if col < self.columns.len() {
self.columns[col].width = ColumnWidth::Fixed(width.max(3));
cx.invalidate_layout();
}
}
pub fn adjust_column_width(&mut self, col: usize, delta: i16, cx: &mut EventCx) {
if col < self.columns.len() {
if let ColumnWidth::Fixed(w) = self.columns[col].width {
self.columns[col].width = ColumnWidth::Fixed((w as i16 + delta).max(3) as u16);
cx.invalidate_layout();
}
}
}
fn resolved_widths(&self, available: u16) -> Vec<u16> {
let col_count = self.columns.len();
if col_count == 0 { return Vec::new(); }
let sep_w = (col_count.saturating_sub(1)) as u16;
let usable = available.saturating_sub(sep_w);
let mut widths = vec![0u16; col_count];
let mut flex_total: u16 = 0;
for (i, col) in self.columns.iter().enumerate() {
if let ColumnWidth::Fixed(w) = col.width {
widths[i] = w;
} else if let ColumnWidth::Flex(w) = col.width {
flex_total += w;
}
}
let fixed_sum: u16 = widths.iter().sum();
let flex_space = usable.saturating_sub(fixed_sum);
if flex_total > 0 {
let per_flex = flex_space / flex_total;
let mut allocated: u16 = 0;
for (i, col) in self.columns.iter().enumerate() {
if let ColumnWidth::Flex(w) = col.width {
widths[i] = w.saturating_mul(per_flex).max(3);
allocated += widths[i];
}
}
if allocated < flex_space {
for i in (0..col_count).rev() {
if matches!(self.columns[i].width, ColumnWidth::Flex(_)) {
widths[i] += flex_space - allocated;
break;
}
}
}
}
widths
}
fn visible_rows(&self, height: u16) -> usize {
let usable = height.saturating_sub(2); usable as usize
}
}
impl Component for Table {
fn render(&self, cx: &mut RenderCx) {
let columns = &self.columns;
if columns.is_empty() { return; }
let col_count = columns.len();
let widths = self.resolved_widths(cx.rect.width);
let visible = self.visible_rows(cx.rect.height);
let start_row = self.scroll_offset;
let end_row = (start_row + visible).min(self.rows.len());
cx.set_style(self.header_style.clone());
for (i, col) in columns.iter().enumerate() {
let text = truncate_to_width(col.title.first_text(), widths[i], col.align);
cx.text(&text);
if i < col_count - 1 {
cx.text("│");
}
}
cx.line("");
cx.set_style(self.style.clone());
for (i, _col) in columns.iter().enumerate() {
cx.text(&"─".repeat(widths[i] as usize));
if i < col_count - 1 { cx.text("┼"); }
}
cx.line("");
for row_idx in start_row..end_row {
let is_selected = self.selected == row_idx;
let is_col_selected = |ci: usize| {
self.cursor_type == CursorType::Cell
&& self.selected == row_idx
&& self.selected_column == ci
};
let row = &self.rows[row_idx];
let row_style = row.style.clone().unwrap_or(self.style.clone());
for (i, _col) in columns.iter().enumerate() {
let cell_text = row.cells.get(i).map(|c| c.content.first_text()).unwrap_or("");
let cell_style = row.cells.get(i).and_then(|c| c.style.clone()).unwrap_or(row_style.clone());
let mut final_style = cell_style;
if is_selected {
final_style = crate::style_parser::merge_styles(final_style, &self.select_style);
}
if is_col_selected(i) {
final_style = crate::style_parser::merge_styles(final_style, &self.column_highlight_style);
}
if self.cursor_type == CursorType::Cell && is_selected && self.selected_column == i {
final_style = crate::style_parser::merge_styles(final_style, &self.cell_highlight_style);
}
cx.set_style(final_style);
let text = truncate_to_width(cell_text, widths[i], columns[i].align);
cx.text(&text);
if i < col_count - 1 {
cx.text("│");
}
}
cx.line("");
}
if let Some(footer_row) = &self.footer {
cx.set_style(self.style.clone());
for (i, _col) in columns.iter().enumerate() {
cx.text(&"─".repeat(widths[i] as usize));
if i < col_count - 1 { cx.text("┼"); }
}
cx.line("");
for (i, col) in columns.iter().enumerate() {
let cell_text = footer_row.cells.get(i).map(|c| c.content.first_text()).unwrap_or("");
let text = truncate_to_width(cell_text, widths[i], col.align);
cx.text(&text);
if i < col_count - 1 { cx.text("│"); }
}
cx.line("");
}
}
fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
if self.columns.is_empty() { return Size { width: 0, height: 0 }; }
let widths = self.resolved_widths(80);
let width: u16 = widths.iter().sum::<u16>()
+ (self.columns.len() as u16).saturating_sub(1);
let footer_height = if self.footer.is_some() { 2 } else { 0 }; let visible = self.rows.len().min(u16::MAX as usize) as u16;
let height = 2u16.saturating_add(visible).saturating_add(footer_height);
Size { width, height }
}
fn event(&mut self, event: &Event, cx: &mut EventCx) {
if matches!(event, Event::Focus | Event::Blur | Event::Tick) { return; }
if self.rows.is_empty() { return; }
if let Event::Key(key_event) = event {
match &key_event.key {
crate::event::Key::Up => {
if self.selected > 0 {
self.selected -= 1;
self.scroll_to_visible(self.selected);
cx.invalidate_paint();
}
}
crate::event::Key::Down => {
if self.selected + 1 < self.rows.len() {
self.selected += 1;
self.scroll_to_visible(self.selected);
cx.invalidate_paint();
}
}
crate::event::Key::Left => {
if self.cursor_type == CursorType::Cell && self.selected_column > 0 {
self.selected_column -= 1;
cx.invalidate_paint();
}
}
crate::event::Key::Right => {
if self.cursor_type == CursorType::Cell
&& self.selected_column + 1 < self.columns.len()
{
self.selected_column += 1;
cx.invalidate_paint();
}
}
_ => {}
}
}
}
fn layout(&mut self, rect: Rect, _cx: &mut LayoutCx) {
self.rect = rect;
}
fn focusable(&self) -> bool {
false
}
fn style(&self) -> Style {
self.style.clone()
}
}
impl Table {
fn scroll_to_visible(&mut self, idx: usize) {
let visible = self.visible_rows(self.rect.height);
if visible == 0 {
return;
}
if idx < self.scroll_offset {
self.scroll_offset = idx;
} else if idx >= self.scroll_offset + visible {
self.scroll_offset = idx.saturating_sub(visible.saturating_sub(1));
}
}
}
fn truncate_to_width(text: &str, max_width: u16, align: crate::style::TextAlign) -> String {
let mut result = String::new();
let mut used: u16 = 0;
for ch in text.chars() {
let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if used + w > max_width {
break;
}
used += w;
result.push(ch);
}
let padding = max_width.saturating_sub(used);
match align {
crate::style::TextAlign::Left => {
while used < max_width { result.push(' '); used += 1; }
}
crate::style::TextAlign::Center => {
let left = padding / 2;
let right = padding - left;
let mut s = String::new();
for _ in 0..left { s.push(' '); }
s.push_str(&result);
for _ in 0..right { s.push(' '); }
result = s;
}
crate::style::TextAlign::Right => {
let mut s = String::new();
for _ in 0..padding { s.push(' '); }
s.push_str(&result);
result = s;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{Color, TextAlign};
use crate::testbuffer::TestBuffer;
#[test]
fn test_table_headers() {
let mut tb = TestBuffer::new(30, 3);
let cols = vec![TableColumn { title: Text::from("Name"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }];
let rows = vec![TableRow::new(vec![TableCell::from("val")])];
tb.render(&Table::new().columns(cols).rows(rows));
assert!(tb.buffer.cells.iter().any(|c| c.symbol == "N"));
}
#[test]
fn test_column_width_flex() {
let table = Table::new().columns(vec![
TableColumn { title: Text::from("A"), width: ColumnWidth::Flex(1), align: TextAlign::Left },
TableColumn { title: Text::from("B"), width: ColumnWidth::Flex(1), align: TextAlign::Left },
]);
let widths = table.resolved_widths(25); assert_eq!(widths.len(), 2);
assert!(widths[0] >= 10);
assert!(widths[1] >= 10);
}
#[test]
fn test_cell_style() {
let mut tb = TestBuffer::new(40, 3);
let table = Table::new()
.columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
.rows(vec![TableRow::new(vec![TableCell { content: Text::from("hi"), style: Some(Style::default().fg(Color::Cyan)) }])]);
tb.render(&table);
assert_eq!(tb.cell_fg(0, 2), Some(Color::Cyan));
}
#[test]
fn test_merge_style_preserves_select_bg() {
let base = Style::default();
let sel = Style::default().bg(Color::White).fg(Color::Black);
let merged = crate::style_parser::merge_styles(base, &sel);
assert_eq!(merged.bg, Some(Color::White));
assert_eq!(merged.fg, Some(Color::Black));
}
#[test]
fn test_render_with_style() {
use crate::render::RenderCx;
let mut buf = crate::buffer::Buffer::new(crate::geom::Size { width: 10, height: 1 });
let rect = crate::geom::Rect { x: 0, y: 0, width: 10, height: 1 };
let mut cx = RenderCx::new(rect, &mut buf, Style::default());
cx.set_style(Style::default().bg(Color::White).fg(Color::Black));
cx.text("test");
assert_eq!(buf.cells[0].style.bg, Some(Color::White), "render bg");
}
#[test]
fn test_selection_highlight() {
let mut tb = TestBuffer::new(40, 5);
let mut table = Table::new()
.columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
.rows(vec![TableRow::new(vec!["row0"]), TableRow::new(vec!["row1"])])
.select_style(Style::default().bg(Color::White).fg(Color::Black));
table.selected = 0;
tb.render(&table);
let cell = &tb.buffer.cells[2 * 40 + 0];
eprintln!("cell(0,2): sym={:?} fg={:?} bg={:?}", cell.symbol, cell.style.fg, cell.style.bg);
assert_eq!(tb.cell_bg(0, 2), Some(Color::White), "selected row should have white bg, got {:?}", tb.cell_bg(0, 2));
}
#[test]
fn test_footer_renders() {
let mut tb = TestBuffer::new(40, 5);
let table = Table::new()
.columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
.rows(vec![TableRow::new(vec!["data"])])
.footer(TableRow::new(vec![TableCell { content: Text::from("sum"), style: Some(Style::default().bold()) }]));
tb.render(&table);
assert!(tb.buffer.cells.iter().any(|c| c.symbol == "s"));
}
}