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,
|
||||
) -> 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() {
|
||||
|
||||
Reference in New Issue
Block a user