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:
YuanSheng Wang
2026-05-07 06:39:35 +08:00
committed by GitHub
parent b59012e765
commit 8cf3745cd6
+171 -19
View File
@@ -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:?}"
);
}
}