use pulldown_cmark::Alignment;
use ratatui::{
style::{Modifier, Style},
text::{Line, Span, Text},
};
use unicode_width::UnicodeWidthStr;
use crate::markdown::{CellSpans, TableBlock, cell_display_width};
use crate::theme::Palette;
pub fn layout_table(
table: &TableBlock,
inner_width: u16,
palette: &Palette,
) -> (Text<'static>, u32, bool) {
let num_cols = table
.headers
.len()
.max(table.rows.iter().map(|r| r.len()).max().unwrap_or(0));
if num_cols == 0 {
return (Text::from(""), 0, false);
}
let border_style = Style::default().fg(palette.table_border);
let header_style = Style::default()
.fg(palette.table_header)
.add_modifier(Modifier::BOLD);
let cell_style = Style::default().fg(palette.foreground);
let dim_style = Style::default().fg(palette.dim);
let min_width = (num_cols as u16) * 3 + (num_cols as u16) + 1;
if inner_width < min_width {
let placeholder = Line::from(Span::styled(
"[ table \u{2014} too narrow, press \u{23ce} to expand ]".to_string(),
dim_style,
));
return (Text::from(vec![placeholder]), 1, true);
}
let target = (inner_width as usize)
.saturating_sub(num_cols + 1)
.saturating_sub(2 * num_cols);
let col_widths = fair_share_widths(&table.natural_widths, num_cols, target);
let was_truncated = col_widths
.iter()
.zip(&table.natural_widths)
.any(|(allotted, &natural)| *allotted < natural);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(table.rows.len() + 4);
lines.push(border_line('┌', '─', '┬', '┐', &col_widths, border_style));
lines.push(span_cell_line(
&table.headers,
&col_widths,
&table.alignments,
border_style,
header_style,
num_cols,
palette,
));
lines.push(border_line('├', '─', '┼', '┤', &col_widths, border_style));
for row in &table.rows {
lines.push(span_cell_line(
row,
&col_widths,
&table.alignments,
border_style,
cell_style,
num_cols,
palette,
));
}
lines.push(border_line('└', '─', '┴', '┘', &col_widths, border_style));
if was_truncated {
lines.push(Line::from(Span::styled(
" [press \u{23ce} to expand full table]".to_string(),
dim_style,
)));
}
let height = lines.len() as u32;
(Text::from(lines), height, was_truncated)
}
fn fair_share_widths(natural_widths: &[usize], num_cols: usize, target: usize) -> Vec<usize> {
let naturals: Vec<usize> = (0..num_cols)
.map(|i| natural_widths.get(i).copied().unwrap_or(1).max(1))
.collect();
let total_natural: usize = naturals.iter().sum();
if total_natural <= target {
return naturals;
}
let mins: Vec<usize> = naturals.iter().map(|&n| n.clamp(1, 6)).collect();
let total_min: usize = mins.iter().sum();
if total_min >= target {
let per_col = (target / num_cols).max(1);
return mins.iter().map(|&m| m.min(per_col).max(1)).collect();
}
let remaining = target - total_min;
let total_excess: usize = naturals
.iter()
.zip(&mins)
.map(|(&n, &m)| n.saturating_sub(m))
.sum();
let mut widths = mins.clone();
if total_excess > 0 {
for (i, (&natural, &min)) in naturals.iter().zip(&mins).enumerate() {
let excess = natural.saturating_sub(min);
let extra = (excess * remaining) / total_excess;
widths[i] = (min + extra).min(natural);
}
}
widths
}
fn border_line(
left: char,
fill: char,
mid: char,
right: char,
col_widths: &[usize],
style: Style,
) -> Line<'static> {
let mut s = String::with_capacity(col_widths.iter().sum::<usize>() + col_widths.len() * 4);
s.push(left);
for (i, &w) in col_widths.iter().enumerate() {
for _ in 0..(w + 2) {
s.push(fill);
}
if i + 1 < col_widths.len() {
s.push(mid);
}
}
s.push(right);
Line::from(Span::styled(s, style))
}
fn span_cell_line(
cells: &[CellSpans],
col_widths: &[usize],
alignments: &[Alignment],
border_style: Style,
cell_style: Style,
num_cols: usize,
palette: &Palette,
) -> Line<'static> {
let empty: CellSpans = Vec::new();
let mut out: Vec<Span<'static>> = Vec::with_capacity(num_cols * 4 + 1);
out.push(Span::styled("│".to_string(), border_style));
for (i, &w) in col_widths.iter().enumerate().take(num_cols) {
let cell = cells.get(i).unwrap_or(&empty);
let cell_w = cell_display_width(cell);
let alignment = alignments.get(i).copied().unwrap_or(Alignment::None);
out.push(Span::styled(" ".to_string(), cell_style));
if cell_w <= w {
let padding = w - cell_w;
let (left_pad, right_pad) = match alignment {
Alignment::Right => (padding, 0),
Alignment::Center => (padding / 2, padding - padding / 2),
Alignment::Left | Alignment::None => (0, padding),
};
if left_pad > 0 {
out.push(Span::styled(" ".repeat(left_pad), cell_style));
}
out.extend(cell.iter().cloned());
if right_pad > 0 {
out.push(Span::styled(" ".repeat(right_pad), cell_style));
}
} else {
out.extend(truncate_spans(cell, w, palette));
}
out.push(Span::styled(" │".to_string(), border_style));
}
Line::from(out)
}
pub fn truncate_spans(
spans: &[Span<'static>],
max_width: usize,
palette: &Palette,
) -> Vec<Span<'static>> {
if max_width == 0 {
return Vec::new();
}
let dim_style = palette.dim_style();
let mut out: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
let budget = max_width.saturating_sub(1);
'outer: for span in spans {
let text = span.content.as_ref();
let span_w = UnicodeWidthStr::width(text);
if used + span_w <= budget {
out.push(span.clone());
used += span_w;
continue;
}
let mut byte_end = 0usize;
let mut accumulated = 0usize;
for ch in text.chars() {
let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + accumulated + ch_w > budget {
break;
}
accumulated += ch_w;
byte_end += ch.len_utf8();
}
if byte_end > 0 {
out.push(Span::styled(text[..byte_end].to_string(), span.style));
}
let pad = budget.saturating_sub(used + accumulated);
if pad > 0 {
out.push(Span::styled(" ".repeat(pad), span.style));
}
out.push(Span::styled("\u{2026}".to_string(), dim_style));
break 'outer;
}
if used
== spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum::<usize>()
{
return out;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::{CellSpans, TableBlock, TableBlockId, cell_display_width};
use crate::theme::{Palette, Theme};
fn palette() -> Palette {
Palette::from_theme(Theme::Default)
}
fn str_cell(s: &str) -> CellSpans {
vec![Span::raw(s.to_string())]
}
fn make_table(headers: &[&str], rows: &[&[&str]], alignments: &[Alignment]) -> TableBlock {
let h: Vec<CellSpans> = headers.iter().map(|s| str_cell(s)).collect();
let r: Vec<Vec<CellSpans>> = rows
.iter()
.map(|row| row.iter().map(|s| str_cell(s)).collect())
.collect();
let aligns: Vec<Alignment> = if alignments.is_empty() {
vec![Alignment::None; headers.len()]
} else {
alignments.to_vec()
};
let mut natural_widths = vec![0usize; headers.len()];
for (i, cell) in h.iter().enumerate() {
natural_widths[i] = natural_widths[i].max(cell_display_width(cell));
}
for row in &r {
for (i, cell) in row.iter().enumerate() {
if i < headers.len() {
natural_widths[i] = natural_widths[i].max(cell_display_width(cell));
}
}
}
for w in &mut natural_widths {
*w = (*w).max(1);
}
TableBlock {
id: TableBlockId(0),
headers: h,
rows: r,
alignments: aligns,
natural_widths,
rendered_height: 3,
}
}
#[test]
fn table_fits_naturally() {
let table = make_table(
&["A", "B", "C"],
&[&["a1", "b1", "c1"], &["a2", "b2", "c2"]],
&[],
);
let (text, _height, was_truncated) = layout_table(&table, 80, &palette());
assert!(!was_truncated, "Short cells should fit naturally");
let top = &text.lines[0].spans[0].content;
assert!(top.contains('┌'), "Top border missing");
assert!(!top.contains('\u{2026}'), "No ellipsis in borders");
}
#[test]
fn table_needs_truncation() {
let long_cell = "x".repeat(200);
let table = make_table(
&["Short", "Very Long Column Header"],
&[&["val", long_cell.as_str()]],
&[],
);
let (text, _height, was_truncated) = layout_table(&table, 60, &palette());
assert!(was_truncated, "200-char cell must trigger truncation");
let last = text.lines.last().unwrap();
let hint: String = last.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
hint.contains('\u{23ce}'),
"Hint line must contain the enter symbol"
);
}
#[test]
fn too_narrow_fallback() {
let table = make_table(&["A", "B", "C"], &[&["x", "y", "z"]], &[]);
let (text, height, was_truncated) = layout_table(&table, 5, &palette());
assert!(was_truncated);
assert_eq!(height, 1, "Too-narrow returns exactly 1 line");
let line: String = text.lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(line.contains("too narrow"), "Placeholder text missing");
}
#[test]
fn unicode_width_cells() {
let wide_cell = "\u{30A2}\u{30A4}\u{30A6}"; let table = make_table(&["JP"], &[&[wide_cell]], &[]);
assert_eq!(table.natural_widths[0], 6);
let (text, _h, was_truncated) = layout_table(&table, 20, &palette());
assert!(!was_truncated, "Wide chars fit in 20 cols");
let (text2, _h2, was_truncated2) = layout_table(&table, 8, &palette());
assert!(
was_truncated2,
"Wide chars must trigger truncation in 8 cols"
);
let row_line: String = text2.lines[3]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
row_line.contains('\u{2026}'),
"Truncated wide cell must end with ellipsis"
);
let _ = text;
}
#[test]
fn alignment_respected() {
let table = make_table(&["Num"], &[&["42"]], &[Alignment::Right]);
let (text, _h, _trunc) = layout_table(&table, 20, &palette());
let data_row: String = text.lines[3]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let inner = data_row.trim_matches('│');
let trimmed = inner.trim();
assert!(
inner.starts_with(' '),
"Right-aligned cell must start with space padding: {inner:?}"
);
assert!(trimmed == "42", "Cell content must be '42'");
}
#[test]
fn truncate_spans_cuts_inside_plain_span() {
use ratatui::style::{Color, Style};
let p = palette();
let bold = Style::default().add_modifier(ratatui::style::Modifier::BOLD);
let plain = Style::default().fg(Color::White);
let code = Style::default().fg(Color::Yellow);
let spans: Vec<Span<'static>> = vec![
Span::styled("bold".to_string(), bold),
Span::styled(" plain text here".to_string(), plain),
Span::styled("`code`".to_string(), code),
];
let result = truncate_spans(&spans, 10, &p);
assert_eq!(result[0].content.as_ref(), "bold");
assert_eq!(result[0].style, bold);
let plain_part = &result[1];
assert_eq!(plain_part.style, plain);
let plain_text = plain_part.content.as_ref();
assert!(
UnicodeWidthStr::width(plain_text) <= 5,
"plain truncated part too wide: {plain_text:?}"
);
let last = result.last().unwrap();
assert_eq!(last.content.as_ref(), "\u{2026}", "must end with ellipsis");
assert!(
!result.iter().any(|s| s.content.as_ref().contains("`code`")),
"code span must not appear after truncation"
);
let total_w: usize = result
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert!(total_w <= 10, "total width {total_w} exceeds limit 10");
}
#[test]
fn truncate_spans_short_cell_no_truncation() {
let p = palette();
let spans = vec![Span::raw("hi".to_string())];
let result = truncate_spans(&spans, 10, &p);
assert_eq!(result.len(), 1);
assert_eq!(result[0].content.as_ref(), "hi");
}
}