feat(tui): add markdown table parsing and rendering
Implemented full markdown table support: - Parse tables with headers, rows, and alignment. - Render tables as a grid when width permits, falling back to a stacked layout for narrow widths. - Added helper structs (`ParsedTable`, `TableAlignment`) and functions for splitting rows, parsing alignments, column width constraints, cell alignment, and wrapping. - Integrated table rendering into `render_markdown_lines`. - Added unit tests for grid rendering and narrow fallback behavior.
This commit is contained in:
@@ -8630,6 +8630,57 @@ fn render_markdown_lines(
|
|||||||
base_style: Style,
|
base_style: Style,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let width = available_width.max(1);
|
let width = available_width.max(1);
|
||||||
|
let lines: Vec<&str> = if markdown.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
markdown.lines().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
return render_markdown_text_block(markdown, indent, width, base_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut buffer: Vec<&str> = Vec::new();
|
||||||
|
let mut index = 0usize;
|
||||||
|
|
||||||
|
while index < lines.len() {
|
||||||
|
if let Some((table, next_index)) = parse_markdown_table(&lines, index) {
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
let block = buffer.join("\n");
|
||||||
|
output.extend(render_markdown_text_block(
|
||||||
|
&block, indent, width, base_style,
|
||||||
|
));
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
output.extend(render_markdown_table(&table, indent, width, base_style));
|
||||||
|
index = next_index;
|
||||||
|
} else {
|
||||||
|
buffer.push(lines[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
let block = buffer.join("\n");
|
||||||
|
output.extend(render_markdown_text_block(
|
||||||
|
&block, indent, width, base_style,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.is_empty() {
|
||||||
|
output.extend(render_markdown_text_block("", indent, width, base_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_text_block(
|
||||||
|
markdown: &str,
|
||||||
|
indent: &str,
|
||||||
|
width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
let mut text = from_str(markdown);
|
let mut text = from_str(markdown);
|
||||||
let mut output: Vec<Line<'static>> = Vec::new();
|
let mut output: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
@@ -8652,15 +8703,682 @@ fn render_markdown_lines(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if output.is_empty() {
|
if output.is_empty() {
|
||||||
|
output.push(blank_line(indent, base_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ParsedTable {
|
||||||
|
headers: Vec<String>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
alignments: Vec<TableAlignment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum TableAlignment {
|
||||||
|
Left,
|
||||||
|
Center,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_markdown_table(lines: &[&str], start: usize) -> Option<(ParsedTable, usize)> {
|
||||||
|
if start + 1 >= lines.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_line = lines[start].trim();
|
||||||
|
let alignment_line = lines[start + 1].trim();
|
||||||
|
|
||||||
|
if header_line.is_empty() || !header_line.contains('|') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers = split_table_row(header_line);
|
||||||
|
if headers.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alignments = parse_alignment_row(alignment_line, headers.len())?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut index = start + 2;
|
||||||
|
|
||||||
|
while index < lines.len() {
|
||||||
|
let raw = lines[index];
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
|
||||||
|
if trimmed.is_empty() || !trimmed.contains('|') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed
|
||||||
|
.chars()
|
||||||
|
.all(|ch| matches!(ch, '-' | ':' | '|' | ' '))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cells = split_table_row(trimmed);
|
||||||
|
if cells.iter().all(|cell| cell.is_empty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if cells.len() < headers.len() {
|
||||||
|
cells.resize(headers.len(), String::new());
|
||||||
|
} else if cells.len() > headers.len() {
|
||||||
|
cells.truncate(headers.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(cells);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((
|
||||||
|
ParsedTable {
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
alignments,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_table_row(line: &str) -> Vec<String> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chars = trimmed.chars().peekable();
|
||||||
|
// Discard a single leading pipe if present.
|
||||||
|
if matches!(chars.peek(), Some('|')) {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cells = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut escape = false;
|
||||||
|
|
||||||
|
for ch in chars {
|
||||||
|
if escape {
|
||||||
|
current.push(ch);
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match ch {
|
||||||
|
'\\' => {
|
||||||
|
escape = true;
|
||||||
|
}
|
||||||
|
'|' => {
|
||||||
|
cells.push(current.trim().to_string());
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
_ => current.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if escape {
|
||||||
|
current.push('\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trimmed.ends_with('|') || !current.trim().is_empty() {
|
||||||
|
cells.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if cells.is_empty() {
|
||||||
|
cells.push(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alignment_row(line: &str, expected_columns: usize) -> Option<Vec<TableAlignment>> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() || !trimmed.contains('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_cells = split_table_row(trimmed);
|
||||||
|
if raw_cells.len() != expected_columns {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut alignments = Vec::with_capacity(expected_columns);
|
||||||
|
for cell in raw_cells {
|
||||||
|
let cell_trimmed = cell.trim();
|
||||||
|
if cell_trimmed.is_empty() {
|
||||||
|
alignments.push(TableAlignment::Left);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cell_trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cell_trimmed.contains('-') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = cell_trimmed.starts_with(':');
|
||||||
|
let right = cell_trimmed.ends_with(':');
|
||||||
|
let alignment = match (left, right) {
|
||||||
|
(true, true) => TableAlignment::Center,
|
||||||
|
(false, true) => TableAlignment::Right,
|
||||||
|
_ => TableAlignment::Left,
|
||||||
|
};
|
||||||
|
alignments.push(alignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(alignments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_table(
|
||||||
|
table: &ParsedTable,
|
||||||
|
indent: &str,
|
||||||
|
available_width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
const MIN_CELL_WIDTH: usize = 3;
|
||||||
|
|
||||||
|
if table.headers.is_empty() {
|
||||||
|
return render_markdown_text_block("", indent, available_width.max(1), base_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let indent_width = UnicodeWidthStr::width(indent);
|
||||||
|
let padding_cost = 1 + table.headers.len() * 3;
|
||||||
|
let available_content = available_width.saturating_sub(indent_width + padding_cost);
|
||||||
|
|
||||||
|
if available_content < table.headers.len().saturating_mul(MIN_CELL_WIDTH) {
|
||||||
|
return render_markdown_table_stacked(table, indent, available_width, base_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut desired_widths = vec![MIN_CELL_WIDTH; table.headers.len()];
|
||||||
|
for (index, header) in table.headers.iter().enumerate() {
|
||||||
|
desired_widths[index] = desired_widths[index].max(cell_display_width(header));
|
||||||
|
}
|
||||||
|
for row in &table.rows {
|
||||||
|
for (index, cell) in row.iter().enumerate().take(desired_widths.len()) {
|
||||||
|
desired_widths[index] = desired_widths[index].max(cell_display_width(cell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let constrained = constrain_column_widths(&desired_widths, available_content, MIN_CELL_WIDTH)
|
||||||
|
.unwrap_or_else(|| vec![MIN_CELL_WIDTH; table.headers.len()]);
|
||||||
|
|
||||||
|
if constrained.iter().sum::<usize>() > available_content {
|
||||||
|
return render_markdown_table_stacked(table, indent, available_width, base_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_markdown_table_grid(table, indent, available_width, base_style, &constrained)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_table_grid(
|
||||||
|
table: &ParsedTable,
|
||||||
|
indent: &str,
|
||||||
|
available_width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
column_widths: &[usize],
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
output.extend(render_table_summary_lines(
|
||||||
|
table,
|
||||||
|
indent,
|
||||||
|
available_width,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
output.push(blank_line(indent, base_style));
|
||||||
|
|
||||||
|
output.push(build_table_border_line(
|
||||||
|
'┌',
|
||||||
|
'┬',
|
||||||
|
'┐',
|
||||||
|
column_widths,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
|
||||||
|
let header_styles = vec![base_style.add_modifier(Modifier::BOLD); table.headers.len()];
|
||||||
|
output.extend(render_table_row_lines(
|
||||||
|
&table.headers,
|
||||||
|
column_widths,
|
||||||
|
&table.alignments,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
&header_styles,
|
||||||
|
));
|
||||||
|
|
||||||
|
if table.rows.is_empty() {
|
||||||
|
output.push(build_table_border_line(
|
||||||
|
'└',
|
||||||
|
'┴',
|
||||||
|
'┘',
|
||||||
|
column_widths,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(build_table_border_line(
|
||||||
|
'├',
|
||||||
|
'┼',
|
||||||
|
'┤',
|
||||||
|
column_widths,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
|
||||||
|
let body_styles = vec![base_style; table.headers.len()];
|
||||||
|
for (index, row) in table.rows.iter().enumerate() {
|
||||||
|
output.extend(render_table_row_lines(
|
||||||
|
row,
|
||||||
|
column_widths,
|
||||||
|
&table.alignments,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
&body_styles,
|
||||||
|
));
|
||||||
|
|
||||||
|
if index == table.rows.len() - 1 {
|
||||||
|
output.push(build_table_border_line(
|
||||||
|
'└',
|
||||||
|
'┴',
|
||||||
|
'┘',
|
||||||
|
column_widths,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
output.push(build_table_border_line(
|
||||||
|
'├',
|
||||||
|
'┼',
|
||||||
|
'┤',
|
||||||
|
column_widths,
|
||||||
|
indent,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown_table_stacked(
|
||||||
|
table: &ParsedTable,
|
||||||
|
indent: &str,
|
||||||
|
available_width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
output.extend(render_table_summary_lines(
|
||||||
|
table,
|
||||||
|
indent,
|
||||||
|
available_width,
|
||||||
|
base_style,
|
||||||
|
));
|
||||||
|
|
||||||
|
if table.rows.is_empty() {
|
||||||
|
output.push(blank_line(indent, base_style));
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"(No rows)",
|
||||||
|
base_style.add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(blank_line(indent, base_style));
|
||||||
|
|
||||||
|
let indent_width = UnicodeWidthStr::width(indent);
|
||||||
|
let available = available_width.saturating_sub(indent_width).max(1);
|
||||||
|
let header_style = base_style.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
|
for (row_index, row) in table.rows.iter().enumerate() {
|
||||||
|
if row_index > 0 {
|
||||||
|
output.push(blank_line(indent, base_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (column_index, header) in table.headers.iter().enumerate() {
|
||||||
|
let value = row.get(column_index).map(String::as_str).unwrap_or("");
|
||||||
|
let bullet_prefix = if column_index == 0 {
|
||||||
|
format!("• {}: ", header)
|
||||||
|
} else {
|
||||||
|
format!(" {}: ", header)
|
||||||
|
};
|
||||||
|
let prefix_width = UnicodeWidthStr::width(bullet_prefix.as_str());
|
||||||
|
let value_width = available.saturating_sub(prefix_width);
|
||||||
|
|
||||||
|
if value_width == 0 {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(bullet_prefix.clone(), header_style));
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
|
||||||
|
let wrapped_values = wrap_table_cell(value, available.max(1));
|
||||||
|
for wrapped in wrapped_values {
|
||||||
|
let mut continuation = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
continuation.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
continuation.push(Span::styled(" ".repeat(prefix_width), header_style));
|
||||||
|
continuation.push(Span::styled(wrapped, base_style));
|
||||||
|
output.push(Line::from(continuation));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let wrapped_values = wrap_table_cell(value, value_width.max(1));
|
||||||
|
for (line_index, wrapped) in wrapped_values.into_iter().enumerate() {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
if line_index == 0 {
|
||||||
|
spans.push(Span::styled(bullet_prefix.clone(), header_style));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(" ".repeat(prefix_width), header_style));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(wrapped, base_style));
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table_summary_lines(
|
||||||
|
table: &ParsedTable,
|
||||||
|
indent: &str,
|
||||||
|
available_width: usize,
|
||||||
|
base_style: Style,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let indent_width = UnicodeWidthStr::width(indent);
|
||||||
|
let summary_width = available_width.saturating_sub(indent_width).max(1);
|
||||||
|
|
||||||
|
let column_list = if table.headers.is_empty() {
|
||||||
|
String::from("No columns")
|
||||||
|
} else {
|
||||||
|
table.headers.join(", ")
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_count = table.rows.len();
|
||||||
|
let prefix = if row_count == 0 {
|
||||||
|
"Table:".to_string()
|
||||||
|
} else if row_count == 1 {
|
||||||
|
"Table (1 row):".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Table ({} rows):", row_count)
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
|
||||||
|
if summary_width <= prefix_width + 1 {
|
||||||
|
let mut combined = prefix.clone();
|
||||||
|
if !column_list.is_empty() {
|
||||||
|
combined.push(' ');
|
||||||
|
combined.push_str(&column_list);
|
||||||
|
}
|
||||||
|
let wrapped = {
|
||||||
|
let mut result = wrap_unicode(combined.as_str(), summary_width);
|
||||||
|
if result.is_empty() {
|
||||||
|
result.push(String::new());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
};
|
||||||
|
for (index, text) in wrapped.into_iter().enumerate() {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
let style = if index == 0 {
|
||||||
|
base_style.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
base_style
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(text, style));
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rest_width = summary_width.saturating_sub(prefix_width + 1).max(1);
|
||||||
|
let mut rest_lines = if column_list.is_empty() {
|
||||||
|
vec![String::new()]
|
||||||
|
} else {
|
||||||
|
let mut wrapped = wrap_unicode(column_list.as_str(), rest_width);
|
||||||
|
if wrapped.is_empty() {
|
||||||
|
wrapped.push(String::new());
|
||||||
|
}
|
||||||
|
wrapped
|
||||||
|
};
|
||||||
|
|
||||||
|
for (index, text) in rest_lines.drain(..).enumerate() {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("{prefix} "),
|
||||||
|
base_style.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
spans.push(Span::styled(text, base_style));
|
||||||
|
} else {
|
||||||
|
spans.push(Span::styled(" ".repeat(prefix_width + 1), base_style));
|
||||||
|
spans.push(Span::styled(text, base_style));
|
||||||
|
}
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_table_border_line(
|
||||||
|
left: char,
|
||||||
|
mid: char,
|
||||||
|
right: char,
|
||||||
|
column_widths: &[usize],
|
||||||
|
indent: &str,
|
||||||
|
base_style: Style,
|
||||||
|
) -> Line<'static> {
|
||||||
|
let mut line = String::new();
|
||||||
|
line.push(left);
|
||||||
|
for (index, width) in column_widths.iter().enumerate() {
|
||||||
|
line.push_str(&"─".repeat(width + 2));
|
||||||
|
if index == column_widths.len() - 1 {
|
||||||
|
line.push(right);
|
||||||
|
} else {
|
||||||
|
line.push(mid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(line, base_style));
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table_row_lines(
|
||||||
|
cells: &[String],
|
||||||
|
column_widths: &[usize],
|
||||||
|
alignments: &[TableAlignment],
|
||||||
|
indent: &str,
|
||||||
|
border_style: Style,
|
||||||
|
cell_styles: &[Style],
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let column_count = column_widths.len();
|
||||||
|
let mut column_lines: Vec<Vec<String>> = Vec::with_capacity(column_count);
|
||||||
|
|
||||||
|
for (index, width) in column_widths.iter().enumerate() {
|
||||||
|
let cell = cells.get(index).map(String::as_str).unwrap_or("");
|
||||||
|
column_lines.push(wrap_table_cell(cell, (*width).max(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_height = column_lines
|
||||||
|
.iter()
|
||||||
|
.map(|lines| lines.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
let mut output = Vec::with_capacity(max_height);
|
||||||
|
for line_index in 0..max_height {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if !indent.is_empty() {
|
||||||
|
spans.push(Span::styled(indent.to_string(), border_style));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled("│".to_string(), border_style));
|
||||||
|
|
||||||
|
for (column_index, width) in column_widths.iter().enumerate() {
|
||||||
|
let content = column_lines
|
||||||
|
.get(column_index)
|
||||||
|
.and_then(|lines| lines.get(line_index))
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let alignment = alignments
|
||||||
|
.get(column_index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(TableAlignment::Left);
|
||||||
|
let aligned = align_cell_line(alignment, content, *width);
|
||||||
|
let cell_style = cell_styles
|
||||||
|
.get(column_index)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(border_style);
|
||||||
|
|
||||||
|
spans.push(Span::styled(" ".to_string(), border_style));
|
||||||
|
spans.push(Span::styled(aligned, cell_style));
|
||||||
|
spans.push(Span::styled(" ".to_string(), border_style));
|
||||||
|
spans.push(Span::styled("│".to_string(), border_style));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn align_cell_line(alignment: TableAlignment, content: &str, width: usize) -> String {
|
||||||
|
let display_width = UnicodeWidthStr::width(content);
|
||||||
|
if display_width >= width {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let padding = width - display_width;
|
||||||
|
match alignment {
|
||||||
|
TableAlignment::Left => format!("{content}{}", " ".repeat(padding)),
|
||||||
|
TableAlignment::Right => format!("{}{}", " ".repeat(padding), content),
|
||||||
|
TableAlignment::Center => {
|
||||||
|
let left = padding / 2;
|
||||||
|
let right = padding - left;
|
||||||
|
format!("{}{}{}", " ".repeat(left), content, " ".repeat(right))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_table_cell(content: &str, width: usize) -> Vec<String> {
|
||||||
|
if width == 0 {
|
||||||
|
return vec![String::new()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for segment in content.split('\n') {
|
||||||
|
let trimmed = segment.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = Options::new(width)
|
||||||
|
.word_separator(WordSeparator::UnicodeBreakProperties)
|
||||||
|
.break_words(true);
|
||||||
|
let wrapped = wrap(trimmed, options);
|
||||||
|
|
||||||
|
if wrapped.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
} else {
|
||||||
|
lines.extend(wrapped.into_iter().map(|line| line.into_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constrain_column_widths(
|
||||||
|
desired: &[usize],
|
||||||
|
available: usize,
|
||||||
|
min_width: usize,
|
||||||
|
) -> Option<Vec<usize>> {
|
||||||
|
if desired.is_empty() {
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
if available < min_width.saturating_mul(desired.len()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut widths: Vec<usize> = desired
|
||||||
|
.iter()
|
||||||
|
.map(|value| (*value).max(min_width))
|
||||||
|
.collect();
|
||||||
|
let mut total: usize = widths.iter().sum();
|
||||||
|
|
||||||
|
if total <= available {
|
||||||
|
return Some(widths);
|
||||||
|
}
|
||||||
|
|
||||||
|
while total > available {
|
||||||
|
let mut changed = false;
|
||||||
|
for value in &mut widths {
|
||||||
|
if total <= available {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if *value > min_width {
|
||||||
|
*value -= 1;
|
||||||
|
total -= 1;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total > available {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(widths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_display_width(value: &str) -> usize {
|
||||||
|
value
|
||||||
|
.split('\n')
|
||||||
|
.map(|segment| UnicodeWidthStr::width(segment.trim_end()))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank_line(indent: &str, base_style: Style) -> Line<'static> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
if !indent.is_empty() {
|
if !indent.is_empty() {
|
||||||
spans.push(Span::styled(indent.to_string(), base_style));
|
spans.push(Span::styled(indent.to_string(), base_style));
|
||||||
}
|
}
|
||||||
spans.push(Span::styled(String::new(), base_style));
|
spans.push(Span::styled(String::new(), base_style));
|
||||||
output.push(Line::from(spans));
|
Line::from(spans)
|
||||||
}
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wrap_markdown_spans(
|
fn wrap_markdown_spans(
|
||||||
@@ -9112,7 +9830,63 @@ pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::wrap_unicode;
|
use super::{render_markdown_lines, wrap_unicode};
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
|
||||||
|
fn lines_to_strings(lines: &[Line<'_>]) -> Vec<String> {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
line.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.content.as_ref())
|
||||||
|
.collect::<String>()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_table_draws_grid_when_width_allows() {
|
||||||
|
let lines = render_markdown_lines(
|
||||||
|
"| Name | Role |\n| --- | --- |\n| Alice | Developer |\n",
|
||||||
|
"",
|
||||||
|
60,
|
||||||
|
Style::default(),
|
||||||
|
);
|
||||||
|
let rendered = lines_to_strings(&lines);
|
||||||
|
assert!(
|
||||||
|
rendered.iter().any(|line| line.contains("Table (1 row):")),
|
||||||
|
"summary line should mention row count"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.iter().any(|line| line.contains('┌')),
|
||||||
|
"grid border should be present when width permits"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.iter().any(|line| line.contains("Alice")),
|
||||||
|
"table rows should include cell content"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_markdown_table_falls_back_when_narrow() {
|
||||||
|
let lines = render_markdown_lines(
|
||||||
|
"| Name | Role |\n| --- | --- |\n| Alice | Developer |\n",
|
||||||
|
"",
|
||||||
|
12,
|
||||||
|
Style::default(),
|
||||||
|
);
|
||||||
|
let rendered = lines_to_strings(&lines);
|
||||||
|
assert!(
|
||||||
|
rendered.iter().any(|line| line.contains("Name:")),
|
||||||
|
"stacked fallback should label headers inline"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.iter().all(|line| !line.contains('┌')),
|
||||||
|
"narrow layout should avoid grid borders"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wrap_unicode_respects_cjk_display_width() {
|
fn wrap_unicode_respects_cjk_display_width() {
|
||||||
|
|||||||
Reference in New Issue
Block a user