use crate::parser::output::Alignment;
use crate::tui::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::tui::ui::format_inline_markdown;
use crate::tui::ui::util::{align_text, terminal_width, 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;
const CELL_PADDING: usize = 2;
fn fit_columns_to_budget(col_widths: &mut [usize], budget: usize) {
if col_widths.is_empty() || col_widths.iter().sum::<usize>() <= budget {
return;
}
let capped_total = |cap: usize, w: &[usize]| -> usize {
w.iter().map(|x| (*x).min(cap).max(MIN_COL_WIDTH)).sum()
};
let mut lo = MIN_COL_WIDTH;
let mut hi = col_widths.iter().copied().max().unwrap_or(MIN_COL_WIDTH);
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if capped_total(mid, col_widths) <= budget {
lo = mid;
} else {
hi = mid - 1;
}
}
for width in col_widths.iter_mut() {
*width = (*width).min(lo).max(MIN_COL_WIDTH);
}
let mut slack = budget.saturating_sub(col_widths.iter().sum::<usize>());
let clipped: Vec<usize> = col_widths
.iter()
.enumerate()
.filter(|(_, w)| **w == lo)
.map(|(i, _)| i)
.collect();
for idx in clipped {
if slack == 0 {
break;
}
col_widths[idx] += 1;
slack -= 1;
}
}
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| terminal_width(h).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 = terminal_width(cell);
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(terminal_width(&headers[i])).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);
for width in &mut col_widths {
*width += CELL_PADDING;
}
if let Some(max_width) = available_width {
let prefix_width = if in_table_mode || is_selected { 2 } else { 0 };
let border_width = col_count + 1; let budget = usize::from(max_width).saturating_sub(border_width + prefix_width);
fit_columns_to_budget(&mut col_widths, budget);
}
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(CELL_PADDING);
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 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()
};
if !is_selected && line_text.contains('`') {
let formatted = format_inline_markdown(&line_text, ctx.theme);
let rendered_width: usize =
formatted.iter().map(|s| terminal_width(&s.content)).sum();
let padding_total = width.saturating_sub(rendered_width);
let (lead, trail) = match alignment {
Alignment::Right => (padding_total, 0),
Alignment::Center => {
let half = padding_total / 2;
(half, padding_total - half)
}
_ => (0, padding_total),
};
if lead > 0 {
spans.push(Span::styled(" ".repeat(lead), style));
}
for span in formatted {
let effective_style =
if span.style == Style::default() || span.style == ctx.theme.text_style() {
style
} else {
span.style
};
spans.push(Span::styled(span.content.into_owned(), effective_style));
}
if trail > 0 {
spans.push(Span::styled(" ".repeat(trail), style));
}
} else {
let cell_text = align_text(&line_text, width, alignment);
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_halfwidth_katakana_table_widths_match_terminal_cells() {
let theme = test_theme();
let headers = vec!["Kana".to_string()];
let rows = vec![vec!["ガ".to_string()], vec!["パ".to_string()]];
let lines = render_table(
&headers,
&[Alignment::Left],
&rows,
&theme,
false,
false,
None,
Some(8),
);
for line in lines {
let width: usize = line
.spans
.iter()
.map(|span| terminal_width(&span.content))
.sum();
assert!(width <= 8, "line is {width} cells wide: {line:?}");
}
}
#[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());
}
fn rendered_col_widths(lines: &[Line<'static>]) -> Vec<usize> {
let border: String = lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
border
.trim_start_matches(|c| c != '┌')
.trim_matches(['┌', '┐'])
.split('┬')
.map(|seg| seg.chars().count())
.collect()
}
fn unequal_table() -> (Vec<String>, Vec<Alignment>, Vec<Vec<String>>) {
let headers = ["ID", "OK", "Name", "Description"]
.iter()
.map(|s| s.to_string())
.collect();
let rows = (0..3)
.map(|r| {
vec![
format!("{r}"),
"yes".to_string(),
format!("item-{r}"),
"a considerably longer description column that dominates the width"
.to_string(),
]
})
.collect();
(headers, vec![Alignment::Left; 4], rows)
}
#[test]
fn narrow_columns_are_not_crushed_to_fit_a_dominant_one() {
let theme = test_theme();
let (headers, aligns, rows) = unequal_table();
for width in [60u16, 80, 100] {
let lines = render_table(
&headers,
&aligns,
&rows,
&theme,
false,
false,
None,
Some(width),
);
let widths = rendered_col_widths(&lines);
assert_eq!(widths.len(), 4, "at {width}: {widths:?}");
let description = widths[3];
for (i, header) in ["ID", "OK"].iter().enumerate() {
assert!(
widths[i] >= header.len() || widths[i] >= description,
"at {width}: {header} got {} cells while description kept {description}: {widths:?}",
widths[i]
);
}
}
}
#[test]
fn shrinking_never_exceeds_the_available_width() {
let theme = test_theme();
let (headers, aligns, rows) = unequal_table();
let floor = (4 * MIN_COL_WIDTH + 5) as u16;
for width in floor..=120 {
let lines = render_table(
&headers,
&aligns,
&rows,
&theme,
false,
false,
None,
Some(width),
);
for line in &lines {
let rendered: usize =
line.spans.iter().map(|s| terminal_width(&s.content)).sum();
assert!(
rendered <= usize::from(width),
"at {width} a line rendered {rendered} cells wide"
);
}
}
}
#[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, " ");
}
#[test]
fn test_header_inline_code_keeps_surrounding_header_style() {
let theme = test_theme();
let cells = vec!["Use `foo` now".to_string()];
let col_widths = vec![20];
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 trailing_text = line
.spans
.iter()
.find(|span| span.content.as_ref() == " now")
.expect("expected plain trailing text after inline code");
assert!(
trailing_text.style.add_modifier.contains(Modifier::BOLD),
"plain text after inline code in a header cell should keep header styling"
);
}
}
}