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:
2025-10-14 01:50:12 +02:00
parent 498e6e61b6
commit 96e0436d43

View File

@@ -8630,6 +8630,57 @@ fn render_markdown_lines(
base_style: Style,
) -> Vec<Line<'static>> {
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 output: Vec<Line<'static>> = Vec::new();
@@ -8652,17 +8703,684 @@ fn render_markdown_lines(
}
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(String::new(), 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();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
spans.push(Span::styled(String::new(), base_style));
Line::from(spans)
}
fn wrap_markdown_spans(
spans: Vec<Span<'static>>,
indent: &str,
@@ -9112,7 +9830,63 @@ pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec<String> {
#[cfg(test)]
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]
fn wrap_unicode_respects_cjk_display_width() {