use crate::parser::output::Alignment;
use crate::tui::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::tui::ui::util::{align_text, wrap_text};
pub struct TableRenderContext<'a> {
pub theme: &'a Theme,
pub row_num: usize,
pub is_header: bool,
pub in_table_mode: bool,
pub is_table_selected: bool,
pub selected_cell: Option<(usize, usize)>,
}
const MIN_COL_WIDTH: usize = 3;
fn calculate_column_widths(headers: &[String], rows: &[Vec<String>]) -> Vec<usize> {
let col_count = headers.len();
if rows.is_empty() {
return headers.iter().map(|h| h.width().max(1)).collect();
}
let mut col_widths: Vec<usize> = vec![0; col_count];
for (i, _header) in headers.iter().enumerate() {
let mut total_width = 0usize;
let mut cell_count = 0usize;
let mut max_width = 0usize;
for row in rows {
if let Some(cell) = row.get(i) {
let cell_width = cell.width();
total_width += cell_width;
cell_count += 1;
max_width = max_width.max(cell_width);
}
}
if let Some(avg_width) = total_width.checked_div(cell_count) {
col_widths[i] = (avg_width * 7 + max_width * 3) / 10;
}
col_widths[i] = col_widths[i].max(headers[i].width()).max(1);
}
col_widths
}
#[allow(clippy::too_many_arguments)]
pub fn render_table(
headers: &[String],
alignments: &[Alignment],
rows: &[Vec<String>],
theme: &Theme,
is_selected: bool,
in_table_mode: bool,
selected_cell: Option<(usize, usize)>,
available_width: Option<u16>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if headers.is_empty() {
return lines;
}
let col_count = headers.len();
let mut col_widths = calculate_column_widths(headers, rows);
let mut padding = 2usize;
for width in &mut col_widths {
*width += padding;
}
if let Some(max_width) = available_width {
let max_width = max_width as usize;
let prefix_width = if in_table_mode || is_selected { 2 } else { 0 };
let border_width = col_count + 1;
loop {
let total_width: usize = col_widths.iter().sum::<usize>() + border_width + prefix_width;
if total_width <= max_width || max_width <= border_width + prefix_width {
break;
}
let available_for_cols = max_width.saturating_sub(border_width + prefix_width);
let current_col_total: usize = col_widths.iter().sum();
if current_col_total == 0 {
break;
}
if padding > 0 {
let potential_savings = col_count * padding;
if total_width - potential_savings <= max_width {
let needed_reduction = total_width - max_width;
let padding_reduction = (needed_reduction / col_count).max(1).min(padding);
for width in &mut col_widths {
*width = width.saturating_sub(padding_reduction);
}
padding = padding.saturating_sub(padding_reduction);
continue;
}
for width in &mut col_widths {
*width = width.saturating_sub(padding);
}
padding = 0;
continue;
}
let shrink_ratio = available_for_cols as f64 / current_col_total as f64;
for width in &mut col_widths {
let new_width = ((*width as f64) * shrink_ratio) as usize;
*width = new_width.max(MIN_COL_WIDTH);
}
let mut total_after: usize = col_widths.iter().sum();
while total_after > available_for_cols {
if let Some(max_idx) = col_widths
.iter()
.enumerate()
.filter(|(_, w)| **w > MIN_COL_WIDTH)
.max_by_key(|(_, w)| **w)
.map(|(i, _)| i)
{
col_widths[max_idx] -= 1;
total_after -= 1;
} else {
break; }
}
break;
}
}
let mut top_border_spans = vec![];
if in_table_mode {
top_border_spans.push(Span::raw(" "));
} else if is_selected {
top_border_spans.push(Span::styled(
"→ ",
Style::default()
.fg(theme.selection_indicator_fg)
.bg(theme.selection_indicator_bg)
.add_modifier(Modifier::BOLD),
));
}
let mut top_border = String::from("┌");
for (i, &width) in col_widths.iter().enumerate() {
top_border.push_str(&"─".repeat(width));
if i < col_widths.len() - 1 {
top_border.push('┬');
}
}
top_border.push('┐');
top_border_spans.push(Span::styled(
top_border,
Style::default().fg(theme.table_border),
));
lines.push(Line::from(top_border_spans));
let header_lines = render_table_row(
headers,
&col_widths,
alignments,
&TableRenderContext {
theme,
row_num: 0,
is_header: true,
in_table_mode,
is_table_selected: is_selected,
selected_cell,
},
);
lines.extend(header_lines);
let mut separator_spans = vec![];
if in_table_mode || is_selected {
separator_spans.push(Span::raw(" "));
}
let mut separator = String::from("├");
for (i, &width) in col_widths.iter().enumerate() {
separator.push_str(&"─".repeat(width));
if i < col_widths.len() - 1 {
separator.push('┼');
}
}
separator.push('┤');
separator_spans.push(Span::styled(
separator,
Style::default().fg(theme.table_border),
));
lines.push(Line::from(separator_spans));
for (row_idx, row) in rows.iter().enumerate() {
let data_row = row_idx + 1; let row_lines = render_table_row(
row,
&col_widths,
alignments,
&TableRenderContext {
theme,
row_num: data_row,
is_header: false,
in_table_mode,
is_table_selected: is_selected,
selected_cell,
},
);
lines.extend(row_lines);
}
let mut bottom_border_spans = vec![];
if in_table_mode || is_selected {
bottom_border_spans.push(Span::raw(" "));
}
let mut bottom_border = String::from("└");
for (i, &width) in col_widths.iter().enumerate() {
bottom_border.push_str(&"─".repeat(width));
if i < col_widths.len() - 1 {
bottom_border.push('┴');
}
}
bottom_border.push('┘');
bottom_border_spans.push(Span::styled(
bottom_border,
Style::default().fg(theme.table_border),
));
lines.push(Line::from(bottom_border_spans));
lines
}
pub fn render_table_row(
cells: &[String],
col_widths: &[usize],
alignments: &[Alignment],
ctx: &TableRenderContext,
) -> Vec<Line<'static>> {
let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
let mut max_lines = 1;
for (i, cell) in cells.iter().enumerate() {
let width = col_widths.get(i).copied().unwrap_or(10);
let content_width = width.saturating_sub(2);
let wrapped = if content_width > 0 {
wrap_text(cell, content_width)
} else {
vec![String::new()]
};
max_lines = max_lines.max(wrapped.len());
wrapped_cells.push(wrapped);
}
let mut row_lines = Vec::new();
for line_idx in 0..max_lines {
let mut spans = Vec::new();
if ctx.in_table_mode {
let is_selected_row = ctx.selected_cell.map(|(r, _)| r) == Some(ctx.row_num);
if is_selected_row && line_idx == 0 {
spans.push(Span::styled(
"→ ",
Style::default()
.fg(ctx.theme.selection_indicator_fg)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
} else if ctx.is_table_selected {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
"│",
Style::default().fg(ctx.theme.table_border),
));
for (i, wrapped_cell) in wrapped_cells.iter().enumerate() {
let width = col_widths.get(i).copied().unwrap_or(10);
let alignment = alignments.get(i).unwrap_or(&Alignment::Left);
let line_text = wrapped_cell.get(line_idx).cloned().unwrap_or_default();
let cell_text = align_text(&line_text, width, alignment);
let is_selected = ctx.selected_cell == Some((ctx.row_num, i));
let style = if is_selected {
Style::default()
.fg(ctx.theme.link_selected_fg)
.bg(ctx.theme.link_selected_bg)
.add_modifier(Modifier::BOLD)
} else if ctx.is_header {
Style::default()
.fg(ctx.theme.heading_color(3))
.add_modifier(Modifier::BOLD)
} else {
ctx.theme.text_style()
};
spans.push(Span::styled(cell_text, style));
spans.push(Span::styled(
"│",
Style::default().fg(ctx.theme.table_border),
));
}
row_lines.push(Line::from(spans));
}
row_lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::theme::ThemeName;
fn test_theme() -> Theme {
Theme::from_name(ThemeName::OceanDark)
}
mod render_table_tests {
use super::*;
#[test]
fn test_empty_headers_returns_empty() {
let theme = test_theme();
let lines = render_table(&[], &[], &[], &theme, false, false, None, None);
assert!(lines.is_empty());
}
#[test]
fn test_single_column_table() {
let theme = test_theme();
let headers = vec!["Name".to_string()];
let alignments = vec![Alignment::Left];
let rows = vec![vec!["Alice".to_string()], vec!["Bob".to_string()]];
let lines = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
None,
);
assert!(lines.len() >= 6);
}
#[test]
fn test_multi_column_table() {
let theme = test_theme();
let headers = vec!["Name".to_string(), "Age".to_string(), "City".to_string()];
let alignments = vec![Alignment::Left, Alignment::Right, Alignment::Center];
let rows = vec![
vec!["Alice".to_string(), "30".to_string(), "NYC".to_string()],
vec!["Bob".to_string(), "25".to_string(), "LA".to_string()],
];
let lines = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
None,
);
assert!(lines.len() >= 6);
}
#[test]
fn test_selected_table_adds_arrow() {
let theme = test_theme();
let headers = vec!["Col".to_string()];
let rows = vec![vec!["Data".to_string()]];
let _lines_unselected =
render_table(&headers, &[], &rows, &theme, false, false, None, None);
let lines_selected =
render_table(&headers, &[], &rows, &theme, true, false, None, None);
let first_selected = &lines_selected[0];
assert!(first_selected.spans.iter().any(|s| s.content.contains("→")));
}
#[test]
fn test_table_mode_shows_row_arrow() {
let theme = test_theme();
let headers = vec!["Col".to_string()];
let rows = vec![vec!["Row1".to_string()], vec!["Row2".to_string()]];
let lines = render_table(&headers, &[], &rows, &theme, true, true, Some((1, 0)), None);
assert!(
lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains("→")))
);
}
#[test]
fn test_header_only_table() {
let theme = test_theme();
let headers = vec!["Header1".to_string(), "Header2".to_string()];
let alignments = vec![Alignment::Left, Alignment::Right];
let rows: Vec<Vec<String>> = vec![];
let lines = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
None,
);
assert_eq!(lines.len(), 4);
}
#[test]
fn test_table_width_constraint() {
let theme = test_theme();
let headers = vec![
"Very Long Header Name".to_string(),
"Another Long Header".to_string(),
];
let alignments = vec![Alignment::Left, Alignment::Left];
let rows = vec![vec![
"Some content here".to_string(),
"More content".to_string(),
]];
let lines_unconstrained = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
None,
);
let lines_constrained = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
Some(40),
);
assert!(lines_constrained.len() >= lines_unconstrained.len());
}
#[test]
fn test_seven_column_table_at_width_146_no_panic() {
let theme = test_theme();
let headers = vec![
"Protocol".to_string(),
"Port(s)".to_string(),
"Transport".to_string(),
"Purpose".to_string(),
"Encryption".to_string(),
"Key Feature".to_string(),
"Common Usage".to_string(),
];
let alignments = vec![Alignment::Left; 7];
let rows = vec![
vec![
"HTTP".to_string(),
"80".to_string(),
"TCP".to_string(),
"Web".to_string(),
"No".to_string(),
"Stateless".to_string(),
"Websites".to_string(),
],
vec![
"HTTPS".to_string(),
"443".to_string(),
"TCP".to_string(),
"Secure Web".to_string(),
"TLS/SSL".to_string(),
"Encrypted HTTP".to_string(),
"Secure websites".to_string(),
],
vec![
"FTP".to_string(),
"20/21".to_string(),
"TCP".to_string(),
"File Transfer".to_string(),
"Optional".to_string(),
"Active/Passive".to_string(),
"File sharing".to_string(),
],
];
for width in [30, 50, 80, 100, 130, 140, 145, 146, 147, 150, 160, 180, 200] {
let lines = render_table(
&headers,
&alignments,
&rows,
&theme,
false,
false,
None,
Some(width),
);
assert!(!lines.is_empty(), "Table should render at width {}", width);
}
}
}
mod render_table_row_tests {
use super::*;
#[test]
fn test_basic_row() {
let theme = test_theme();
let cells = vec!["A".to_string(), "B".to_string()];
let col_widths = vec![5, 5];
let alignments = vec![Alignment::Left, Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 0,
is_header: false,
in_table_mode: false,
is_table_selected: false,
selected_cell: None,
};
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
let line = &row_lines[0];
assert!(line.spans.len() >= 5);
}
#[test]
fn test_header_row_styling() {
let theme = test_theme();
let cells = vec!["Header".to_string()];
let col_widths = vec![10];
let alignments = vec![Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 0,
is_header: true,
in_table_mode: false,
is_table_selected: false,
selected_cell: None,
};
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
let line = &row_lines[0];
let cell_span = line.spans.iter().find(|s| s.content.contains("Header"));
assert!(cell_span.is_some());
assert!(
cell_span
.unwrap()
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
fn test_selected_cell_highlighting() {
let theme = test_theme();
let cells = vec!["A".to_string(), "B".to_string()];
let col_widths = vec![5, 5];
let alignments = vec![Alignment::Left, Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 1,
is_header: false,
in_table_mode: true,
is_table_selected: true,
selected_cell: Some((1, 1)), };
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
let line = &row_lines[0];
let cell_b_span = line.spans.iter().find(|s| s.content.contains("B"));
assert!(cell_b_span.is_some());
assert!(cell_b_span.unwrap().style.bg.is_some());
}
#[test]
fn test_row_with_arrow_when_selected() {
let theme = test_theme();
let cells = vec!["Data".to_string()];
let col_widths = vec![8];
let alignments = vec![Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 1,
is_header: false,
in_table_mode: true,
is_table_selected: true,
selected_cell: Some((1, 0)),
};
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
let line = &row_lines[0];
assert!(line.spans[0].content.contains("→"));
}
#[test]
fn test_row_wrapping() {
let theme = test_theme();
let cells = vec!["Very long text that should wrap".to_string()];
let col_widths = vec![10]; let alignments = vec![Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 1,
is_header: false,
in_table_mode: false,
is_table_selected: false,
selected_cell: None,
};
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
assert!(row_lines.len() > 1);
}
#[test]
fn test_row_without_arrow_when_not_selected() {
let theme = test_theme();
let cells = vec!["Data".to_string()];
let col_widths = vec![8];
let alignments = vec![Alignment::Left];
let ctx = TableRenderContext {
theme: &theme,
row_num: 2,
is_header: false,
in_table_mode: true,
is_table_selected: true,
selected_cell: Some((1, 0)), };
let row_lines = render_table_row(&cells, &col_widths, &alignments, &ctx);
let line = &row_lines[0];
assert_eq!(line.spans[0].content, " ");
}
}
}