feat(tui): render markdown tables with borders
## Summary - render markdown table groups with top, middle, and bottom box-drawing borders - preserve separator rows as table structure instead of dropping them - update markdown rendering tests for bordered tables ## Test plan - cargo fmt --all -- --check - git diff --check - cargo test -p deepseek-tui table --all-features
This commit is contained in:
@@ -72,8 +72,11 @@ pub enum Block {
|
||||
ListItem { bullet: String, text: String },
|
||||
/// A line inside a fenced code block. Fences themselves are dropped.
|
||||
Code { line: String },
|
||||
/// A table row: cells split on `|`. Separator rows (`|---|`) are dropped.
|
||||
/// A table row: cells split on `|`.
|
||||
TableRow(Vec<String>),
|
||||
/// A table separator row (`|---|---|`). Kept so the renderer can draw
|
||||
/// horizontal rules at the correct positions.
|
||||
TableSeparator,
|
||||
/// A non-empty paragraph line that may contain inline links.
|
||||
Paragraph { text: String },
|
||||
/// An empty source line, preserved so paragraph spacing survives.
|
||||
@@ -147,7 +150,10 @@ pub fn parse(content: &str) -> ParsedMarkdown {
|
||||
blocks.push(Block::TableRow(cells));
|
||||
continue;
|
||||
}
|
||||
None if trimmed.starts_with('|') => continue, // separator row — drop it
|
||||
None if trimmed.starts_with('|') => {
|
||||
blocks.push(Block::TableSeparator);
|
||||
continue;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -175,8 +181,30 @@ pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) ->
|
||||
let width = width.max(1) as usize;
|
||||
let mut out: Vec<Line<'static>> = Vec::with_capacity(parsed.blocks.len());
|
||||
|
||||
for block in &parsed.blocks {
|
||||
match block {
|
||||
let mut i = 0;
|
||||
while i < parsed.blocks.len() {
|
||||
if matches!(
|
||||
&parsed.blocks[i],
|
||||
Block::TableRow(_) | Block::TableSeparator
|
||||
) {
|
||||
let start = i;
|
||||
while i < parsed.blocks.len()
|
||||
&& matches!(
|
||||
&parsed.blocks[i],
|
||||
Block::TableRow(_) | Block::TableSeparator
|
||||
)
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
out.extend(render_table_group(
|
||||
&parsed.blocks[start..i],
|
||||
width,
|
||||
base_style,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
match &parsed.blocks[i] {
|
||||
Block::Heading { text, .. } => {
|
||||
let style = Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
@@ -195,9 +223,6 @@ pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) ->
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
)));
|
||||
}
|
||||
Block::TableRow(cells) => {
|
||||
out.extend(render_table_row(cells, width, base_style));
|
||||
}
|
||||
Block::ListItem { bullet, text } => {
|
||||
let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY);
|
||||
out.extend(render_list_line(
|
||||
@@ -221,12 +246,11 @@ pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) ->
|
||||
out.extend(render_line_with_links(text, width, base_style, link_style));
|
||||
}
|
||||
Block::Blank => {
|
||||
// Preserve paragraph spacing. The original renderer also pushed
|
||||
// a blank line for empty source lines that fell through the
|
||||
// paragraph branch; mirror that exactly.
|
||||
out.push(Line::from(""));
|
||||
}
|
||||
Block::TableRow(_) | Block::TableSeparator => unreachable!(),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
@@ -559,6 +583,98 @@ fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Li
|
||||
vec![Line::from(spans)]
|
||||
}
|
||||
|
||||
fn table_col_width(num_cols: usize, term_width: usize) -> usize {
|
||||
let col_width = (term_width.saturating_sub(3 * num_cols + 1)) / num_cols;
|
||||
col_width.max(4)
|
||||
}
|
||||
|
||||
fn render_table_border(
|
||||
num_cols: usize,
|
||||
col_width: usize,
|
||||
sep_style: Style,
|
||||
left: &str,
|
||||
mid: &str,
|
||||
right: &str,
|
||||
) -> Line<'static> {
|
||||
let fill = "\u{2500}".repeat(col_width);
|
||||
let mut s = String::new();
|
||||
s.push_str(left);
|
||||
for i in 0..num_cols {
|
||||
s.push_str(&fill);
|
||||
if i + 1 < num_cols {
|
||||
s.push_str(mid);
|
||||
} else {
|
||||
s.push_str(right);
|
||||
}
|
||||
}
|
||||
Line::from(Span::styled(s, sep_style))
|
||||
}
|
||||
|
||||
fn render_table_group(blocks: &[Block], width: usize, base_style: Style) -> Vec<Line<'static>> {
|
||||
let sep_style = Style::default().fg(palette::TEXT_DIM);
|
||||
|
||||
let num_cols = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
Block::TableRow(cells) => Some(cells.len()),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
|
||||
let col_width = table_col_width(num_cols, width);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Top border
|
||||
lines.push(render_table_border(
|
||||
num_cols,
|
||||
col_width,
|
||||
sep_style,
|
||||
"\u{250C}\u{2500}",
|
||||
"\u{2500}\u{252C}\u{2500}",
|
||||
"\u{2500}\u{2510}",
|
||||
));
|
||||
|
||||
let mid_border = || {
|
||||
render_table_border(
|
||||
num_cols,
|
||||
col_width,
|
||||
sep_style,
|
||||
"\u{251C}\u{2500}",
|
||||
"\u{2500}\u{253C}\u{2500}",
|
||||
"\u{2500}\u{2524}",
|
||||
)
|
||||
};
|
||||
|
||||
for i in 0..blocks.len() {
|
||||
match &blocks[i] {
|
||||
Block::TableRow(cells) => {
|
||||
lines.extend(render_table_row(cells, width, base_style));
|
||||
if i + 1 < blocks.len() && matches!(&blocks[i + 1], Block::TableRow(_)) {
|
||||
lines.push(mid_border());
|
||||
}
|
||||
}
|
||||
Block::TableSeparator => {
|
||||
lines.push(mid_border());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
lines.push(render_table_border(
|
||||
num_cols,
|
||||
col_width,
|
||||
sep_style,
|
||||
"\u{2514}\u{2500}",
|
||||
"\u{2500}\u{2534}\u{2500}",
|
||||
"\u{2500}\u{2518}",
|
||||
));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn link_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_BLUE)
|
||||
@@ -745,21 +861,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_separator_row_is_dropped() {
|
||||
// "|---|---|" must not appear in output
|
||||
fn table_separator_row_is_kept() {
|
||||
// Separator rows are now kept as TableSeparator blocks so the
|
||||
// renderer can draw horizontal rules at the correct positions.
|
||||
let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.88+ |\n";
|
||||
let parsed = parse(src);
|
||||
let blocks: Vec<_> = parsed.blocks.iter().collect();
|
||||
// Should have 2 TableRow blocks (header + data), no separator
|
||||
// Should have 2 TableRow blocks (header + data) + 1 TableSeparator
|
||||
let table_rows: Vec<_> = blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b, Block::TableRow(_)))
|
||||
.collect();
|
||||
assert_eq!(table_rows.len(), 2, "expected 2 table rows: {blocks:?}");
|
||||
let separators: Vec<_> = blocks
|
||||
.iter()
|
||||
.filter(|b| matches!(b, Block::TableSeparator))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
table_rows.len(),
|
||||
2,
|
||||
"expected 2 table rows, got {}: {blocks:?}",
|
||||
table_rows.len()
|
||||
separators.len(),
|
||||
1,
|
||||
"expected 1 table separator: {blocks:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -779,14 +900,45 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_renders_with_pipe_separator() {
|
||||
fn table_renders_with_box_drawing_borders() {
|
||||
let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
|
||||
let lines = render_markdown(src, 60, Style::default());
|
||||
let text: String = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
|
||||
.collect();
|
||||
// Column pipes still present
|
||||
assert!(text.contains('│'), "table pipe separator missing: {text:?}");
|
||||
assert!(!text.contains("|---|"), "separator row leaked: {text:?}");
|
||||
// Separator row rendered as middle border, not raw markdown
|
||||
assert!(
|
||||
!text.contains("|---|"),
|
||||
"raw separator row leaked: {text:?}"
|
||||
);
|
||||
// Top and bottom borders present
|
||||
assert!(
|
||||
text.contains('\u{250C}'),
|
||||
"top-left corner missing: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains('\u{2510}'),
|
||||
"top-right corner missing: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains('\u{2514}'),
|
||||
"bottom-left corner missing: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains('\u{2518}'),
|
||||
"bottom-right corner missing: {text:?}"
|
||||
);
|
||||
// Middle separator present (at the |---|---| position)
|
||||
assert!(
|
||||
text.contains('\u{251C}'),
|
||||
"middle-left junction missing: {text:?}"
|
||||
);
|
||||
assert!(
|
||||
text.contains('\u{2524}'),
|
||||
"middle-right junction missing: {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user