commit 92a1f6b7b8f07580deea2e6ce83aeac18e917699
parent 14387f3fc0668827d0d1978f95d6c09564c0f5f6
Author: William Casarin <jb55@jb55.com>
Date: Sun, 15 Feb 2026 20:32:19 -0800
md-stream: add markdown table parsing and rendering
Parse markdown tables (header row + separator + data rows) in both
batch and streaming modes. Render with egui TableBuilder with cell
padding and subtle header background. Also lighten code_bg offset
from +15 to +25.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
5 files changed, 482 insertions(+), 25 deletions(-)
diff --git a/crates/md-stream/src/element.rs b/crates/md-stream/src/element.rs
@@ -21,6 +21,12 @@ pub enum MdElement {
/// Ordered list (starting number)
OrderedList { start: u32, items: Vec<ListItem> },
+ /// Markdown table with headers and data rows
+ Table {
+ headers: Vec<String>,
+ rows: Vec<Vec<String>>,
+ },
+
/// Thematic break (---, ***, ___)
ThematicBreak,
diff --git a/crates/md-stream/src/parser.rs b/crates/md-stream/src/parser.rs
@@ -105,6 +105,12 @@ impl StreamParser {
}
return;
}
+ PartialKind::Table { .. } => {
+ if self.process_table(&remaining) {
+ continue;
+ }
+ return;
+ }
PartialKind::Paragraph => {
// For paragraphs, check if we're at a line start that could be a block element
if self.at_line_start {
@@ -195,6 +201,11 @@ impl StreamParser {
}
}
+ // Could be a table row: starts with | but no newline yet
+ if trimmed.starts_with('|') && !trimmed.contains('\n') {
+ return true;
+ }
+
false
}
@@ -281,6 +292,26 @@ impl StreamParser {
}
}
+ // Table row: starts with |
+ if trimmed.starts_with('|') {
+ if let Some(nl_pos) = trimmed.find('\n') {
+ let line = &trimmed[..nl_pos];
+ let cells = parse_table_row(line);
+ if !cells.is_empty() {
+ self.partial = Some(Partial::new(
+ PartialKind::Table {
+ headers: cells,
+ rows: Vec::new(),
+ seen_separator: false,
+ },
+ self.process_pos,
+ ));
+ self.at_line_start = true;
+ return Some(leading_space + nl_pos + 1);
+ }
+ }
+ }
+
None
}
@@ -366,6 +397,100 @@ impl StreamParser {
}
}
+ /// Process table content line by line.
+ /// Returns true if we should continue processing, false if we need more input.
+ fn process_table(&mut self, text: &str) -> bool {
+ // We need at least one complete line to process
+ if let Some(nl_pos) = text.find('\n') {
+ let line = &text[..nl_pos];
+ let trimmed = line.trim();
+
+ // Check if this line continues the table
+ if trimmed.starts_with('|') {
+ let partial = self.partial.as_mut().unwrap();
+ if let PartialKind::Table {
+ ref mut rows,
+ ref mut seen_separator,
+ ref headers,
+ ..
+ } = partial.kind
+ {
+ if !*seen_separator {
+ // Expecting separator row
+ if is_separator_row(trimmed) {
+ *seen_separator = true;
+ } else {
+ // Not a valid table — emit header as paragraph
+ let header_text = format!("| {} |", headers.join(" | "));
+ let row_text = trimmed.to_string();
+ self.partial = None;
+ let combined = format!("{}\n{}", header_text, row_text);
+ let inlines = parse_inline(&combined);
+ self.parsed.push(MdElement::Paragraph(inlines));
+ self.at_line_start = true;
+ self.advance(nl_pos + 1);
+ return true;
+ }
+ } else {
+ // Data row
+ let cells = parse_table_row(trimmed);
+ rows.push(cells);
+ }
+ }
+ self.advance(nl_pos + 1);
+ return true;
+ }
+
+ // Line doesn't start with | — table is complete
+ let partial = self.partial.take().unwrap();
+ if let PartialKind::Table {
+ headers,
+ rows,
+ seen_separator,
+ } = partial.kind
+ {
+ if seen_separator {
+ self.parsed.push(MdElement::Table { headers, rows });
+ } else {
+ // Never saw separator — emit as paragraph
+ let text = format!("| {} |", headers.join(" | "));
+ let inlines = parse_inline(&text);
+ self.parsed.push(MdElement::Paragraph(inlines));
+ }
+ }
+ self.at_line_start = true;
+ // Don't advance — let the non-table line be re-processed
+ return true;
+ }
+
+ // No newline yet — check if we have a partial line starting with |
+ // If so, wait for more input. If not, table is done.
+ let trimmed = text.trim();
+ if trimmed.starts_with('|') || trimmed.is_empty() {
+ // Could be another table row, wait for newline
+ return false;
+ }
+
+ // Non-pipe content without newline — table is complete
+ let partial = self.partial.take().unwrap();
+ if let PartialKind::Table {
+ headers,
+ rows,
+ seen_separator,
+ } = partial.kind
+ {
+ if seen_separator {
+ self.parsed.push(MdElement::Table { headers, rows });
+ } else {
+ let text = format!("| {} |", headers.join(" | "));
+ let inlines = parse_inline(&text);
+ self.parsed.push(MdElement::Paragraph(inlines));
+ }
+ }
+ self.at_line_start = true;
+ true
+ }
+
/// Process inline content.
fn process_inline(&mut self, text: &str) -> bool {
// Check for paragraph break split across tokens:
@@ -388,37 +513,19 @@ impl StreamParser {
}
}
- if let Some(nl_pos) = text.find("\n\n") {
- // Double newline = paragraph break
- // Combine accumulated partial content with text before \n\n
- let para_text = if let Some(ref mut partial) = self.partial {
- partial.content.push_str(&text[..nl_pos]);
- std::mem::take(&mut partial.content)
- } else {
- text[..nl_pos].to_string()
- };
- self.partial = None;
-
- if !para_text.trim().is_empty() {
- // Parse inline elements from the full paragraph text
- let inline_elements = parse_inline(para_text.trim());
- self.parsed.push(MdElement::Paragraph(inline_elements));
- }
- self.at_line_start = true;
- self.advance(nl_pos + 2);
- return true;
- }
-
if let Some(nl_pos) = text.find('\n') {
let after_nl = &text[nl_pos + 1..];
// Check if text after the newline starts a block element (code fence, heading, etc.)
// If so, emit the current paragraph and let the block parser handle the rest.
+ // This must happen before the \n\n check so that block starts aren't
+ // gobbled into paragraph text by a later double-newline.
if !after_nl.is_empty() {
let trimmed_after = after_nl.trim_start();
let is_block_start = trimmed_after.starts_with("```")
|| trimmed_after.starts_with("~~~")
- || trimmed_after.starts_with('#');
+ || trimmed_after.starts_with('#')
+ || trimmed_after.starts_with('|');
if is_block_start {
// Accumulate text before the newline into the paragraph
let para_text = if let Some(ref mut partial) = self.partial {
@@ -438,6 +545,30 @@ impl StreamParser {
return true;
}
}
+ }
+
+ if let Some(nl_pos) = text.find("\n\n") {
+ // Double newline = paragraph break
+ // Combine accumulated partial content with text before \n\n
+ let para_text = if let Some(ref mut partial) = self.partial {
+ partial.content.push_str(&text[..nl_pos]);
+ std::mem::take(&mut partial.content)
+ } else {
+ text[..nl_pos].to_string()
+ };
+ self.partial = None;
+
+ if !para_text.trim().is_empty() {
+ // Parse inline elements from the full paragraph text
+ let inline_elements = parse_inline(para_text.trim());
+ self.parsed.push(MdElement::Paragraph(inline_elements));
+ }
+ self.at_line_start = true;
+ self.advance(nl_pos + 2);
+ return true;
+ }
+
+ if let Some(nl_pos) = text.find('\n') {
// Single newline - continue accumulating but track position
if let Some(ref mut partial) = self.partial {
@@ -494,6 +625,20 @@ impl StreamParser {
content: partial.content.trim().to_string(),
});
}
+ PartialKind::Table {
+ headers,
+ rows,
+ seen_separator,
+ } => {
+ if seen_separator {
+ self.parsed.push(MdElement::Table { headers, rows });
+ } else {
+ // Never saw separator — not a real table, emit as paragraph
+ let text = format!("| {} |", headers.join(" | "));
+ let inlines = parse_inline(&text);
+ self.parsed.push(MdElement::Paragraph(inlines));
+ }
+ }
PartialKind::Paragraph => {
if !partial.content.trim().is_empty() {
let inline_elements = parse_inline(partial.content.trim());
@@ -517,3 +662,22 @@ impl Default for StreamParser {
Self::new()
}
}
+
+/// Parse a table row into cells by splitting on `|`.
+/// Strips outer pipes and trims each cell.
+fn parse_table_row(line: &str) -> Vec<String> {
+ let trimmed = line.trim();
+ let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
+ let inner = inner.strip_suffix('|').unwrap_or(inner);
+ inner.split('|').map(|c| c.trim().to_string()).collect()
+}
+
+/// Check if a line is a table separator row (e.g. `|---|---|`).
+fn is_separator_row(line: &str) -> bool {
+ let cells = parse_table_row(line);
+ !cells.is_empty()
+ && cells.iter().all(|c| {
+ let t = c.trim().trim_matches(':');
+ !t.is_empty() && t.chars().all(|ch| ch == '-')
+ })
+}
diff --git a/crates/md-stream/src/partial.rs b/crates/md-stream/src/partial.rs
@@ -72,6 +72,13 @@ pub enum PartialKind {
/// Blockquote started with >, collecting content
BlockQuote { depth: usize },
+ /// Table being accumulated row by row
+ Table {
+ headers: Vec<String>,
+ rows: Vec<Vec<String>>,
+ seen_separator: bool,
+ },
+
/// Paragraph being accumulated (waiting for double newline)
Paragraph,
}
diff --git a/crates/md-stream/src/tests.rs b/crates/md-stream/src/tests.rs
@@ -556,3 +556,220 @@ fn test_heading_partial_kind_distinct_from_paragraph() {
partial.kind
);
}
+
+// Table tests
+
+#[test]
+fn test_table_basic_batch() {
+ let mut parser = StreamParser::new();
+ parser.push("| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |\n\n");
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1, "Expected 1 table, got: {:#?}", parser.parsed());
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers, &["Name", "Age"]);
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0], &["Alice", "30"]);
+ assert_eq!(rows[1], &["Bob", "25"]);
+ }
+}
+
+#[test]
+fn test_table_streaming_char_by_char() {
+ let mut parser = StreamParser::new();
+ let input = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |\n\n";
+
+ for ch in input.chars() {
+ parser.push(&ch.to_string());
+ }
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1, "Expected 1 table, got: {:#?}", parser.parsed());
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers, &["Name", "Age"]);
+ assert_eq!(rows.len(), 2);
+ assert_eq!(rows[0], &["Alice", "30"]);
+ assert_eq!(rows[1], &["Bob", "25"]);
+ }
+}
+
+#[test]
+fn test_table_after_paragraph() {
+ let mut parser = StreamParser::new();
+ parser.push("Here is a comparison:\n| A | B |\n|---|---|\n| 1 | 2 |\n\n");
+
+ let has_paragraph = parser.parsed().iter().any(|e| matches!(e, MdElement::Paragraph(_)));
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+
+ assert!(has_paragraph, "Missing paragraph, got: {:#?}", parser.parsed());
+ assert!(has_table, "Missing table, got: {:#?}", parser.parsed());
+}
+
+#[test]
+fn test_table_after_paragraph_streaming() {
+ let mut parser = StreamParser::new();
+ let input = "Here is a comparison:\n| A | B |\n|---|---|\n| 1 | 2 |\n\n";
+
+ for ch in input.chars() {
+ parser.push(&ch.to_string());
+ }
+
+ let has_paragraph = parser.parsed().iter().any(|e| matches!(e, MdElement::Paragraph(_)));
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+
+ assert!(has_paragraph, "Missing paragraph, got: {:#?}", parser.parsed());
+ assert!(has_table, "Missing table, got: {:#?}", parser.parsed());
+}
+
+#[test]
+fn test_table_then_paragraph() {
+ let mut parser = StreamParser::new();
+ parser.push("| X | Y |\n|---|---|\n| a | b |\n\nSome text after.\n\n");
+
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+ let has_paragraph = parser.parsed().iter().any(|e| matches!(e, MdElement::Paragraph(_)));
+
+ assert!(has_table, "Missing table, got: {:#?}", parser.parsed());
+ assert!(has_paragraph, "Missing paragraph, got: {:#?}", parser.parsed());
+}
+
+#[test]
+fn test_table_no_separator_not_a_table() {
+ let mut parser = StreamParser::new();
+ // Two pipe rows but no separator — should not be a table
+ parser.push("| foo | bar |\n| baz | qux |\n\n");
+
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+ assert!(!has_table, "Should NOT be a table without separator row, got: {:#?}", parser.parsed());
+}
+
+#[test]
+fn test_table_uneven_columns() {
+ let mut parser = StreamParser::new();
+ parser.push("| A | B | C |\n|---|---|---|\n| 1 | 2 |\n| x | y | z |\n\n");
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1);
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers.len(), 3);
+ assert_eq!(rows[0].len(), 2); // Fewer cells than headers
+ assert_eq!(rows[1].len(), 3);
+ }
+}
+
+#[test]
+fn test_table_with_alignment() {
+ // Separator with alignment colons should still be recognized
+ let mut parser = StreamParser::new();
+ parser.push("| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |\n\n");
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1, "Expected table with alignment separators, got: {:#?}", parser.parsed());
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers, &["Left", "Center", "Right"]);
+ assert_eq!(rows.len(), 1);
+ assert_eq!(rows[0], &["a", "b", "c"]);
+ }
+}
+
+#[test]
+fn test_table_finalize_incomplete() {
+ // Table without trailing blank line — finalize should emit it
+ let mut parser = StreamParser::new();
+ parser.push("| H1 | H2 |\n|---|---|\n| v1 | v2 |");
+
+ assert_eq!(parser.parsed().len(), 0, "Table shouldn't be complete yet");
+
+ parser.finalize();
+
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+ assert!(has_table, "Finalize should emit the table, got: {:#?}", parser.parsed());
+}
+
+#[test]
+fn test_table_single_column() {
+ let mut parser = StreamParser::new();
+ parser.push("| Item |\n|------|\n| Apple |\n| Banana |\n\n");
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1);
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers, &["Item"]);
+ assert_eq!(rows.len(), 2);
+ }
+}
+
+#[test]
+fn test_table_empty_cells() {
+ let mut parser = StreamParser::new();
+ parser.push("| A | B |\n|---|---|\n| | val |\n| val | |\n\n");
+
+ let tables: Vec<_> = parser.parsed().iter().filter(|e| matches!(e, MdElement::Table { .. })).collect();
+ assert_eq!(tables.len(), 1);
+
+ if let MdElement::Table { headers, rows } = &tables[0] {
+ assert_eq!(headers, &["A", "B"]);
+ assert_eq!(rows[0], &["", "val"]);
+ assert_eq!(rows[1], &["val", ""]);
+ }
+}
+
+#[test]
+fn test_table_streaming_realistic_llm_chunks() {
+ // Simulate LLM-style token delivery
+ let mut parser = StreamParser::new();
+ let chunks = [
+ "Here's",
+ " the comparison:\n",
+ "| Feature",
+ " | ",
+ "Rust | ",
+ "Go |\n",
+ "|---",
+ "---|",
+ "------|------|\n",
+ "| Speed",
+ " | Fast",
+ " | Fast |\n",
+ "| Safety",
+ " | Yes | No |\n",
+ "\nThat's",
+ " the table.",
+ ];
+
+ for chunk in chunks {
+ parser.push(chunk);
+ }
+ parser.finalize();
+
+ let has_paragraph = parser.parsed().iter().any(|e| matches!(e, MdElement::Paragraph(_)));
+ let has_table = parser.parsed().iter().any(|e| matches!(e, MdElement::Table { .. }));
+
+ assert!(has_paragraph, "Missing paragraph, got: {:#?}", parser.parsed());
+ assert!(has_table, "Missing table, got: {:#?}", parser.parsed());
+
+ if let Some(MdElement::Table { headers, rows }) = parser.parsed().iter().find(|e| matches!(e, MdElement::Table { .. })) {
+ assert_eq!(headers.len(), 3, "Expected 3 headers, got: {:?}", headers);
+ assert_eq!(rows.len(), 2, "Expected 2 rows, got: {:?}", rows);
+ }
+}
+
+#[test]
+fn test_table_partial_shows_during_streaming() {
+ let mut parser = StreamParser::new();
+ // Push header + separator, then start a data row
+ parser.push("| A | B |\n|---|---|\n");
+
+ // Should have a table partial with seen_separator=true
+ let partial = parser.partial().expect("Should have partial");
+ assert!(
+ matches!(&partial.kind, PartialKind::Table { seen_separator: true, .. }),
+ "Expected table partial with seen_separator=true, got: {:?}",
+ partial.kind
+ );
+}
diff --git a/crates/notedeck_dave/src/ui/markdown_ui.rs b/crates/notedeck_dave/src/ui/markdown_ui.rs
@@ -21,9 +21,9 @@ impl MdTheme {
let bg = visuals.panel_fill;
// Code bg: slightly lighter than panel background
let code_bg = Color32::from_rgb(
- bg.r().saturating_add(15),
- bg.g().saturating_add(15),
- bg.b().saturating_add(15),
+ bg.r().saturating_add(25),
+ bg.g().saturating_add(25),
+ bg.b().saturating_add(25),
);
Self {
heading_sizes: [24.0, 20.0, 18.0, 16.0, 14.0, 12.0],
@@ -99,6 +99,10 @@ fn render_element(element: &MdElement, theme: &MdTheme, ui: &mut Ui) {
ui.add_space(8.0);
}
+ MdElement::Table { headers, rows } => {
+ render_table(headers, rows, theme, ui);
+ }
+
MdElement::ThematicBreak => {
ui.separator();
ui.add_space(8.0);
@@ -235,6 +239,53 @@ fn render_list_item(item: &ListItem, marker: &str, theme: &MdTheme, ui: &mut Ui)
});
}
+fn render_table(headers: &[String], rows: &[Vec<String>], theme: &MdTheme, ui: &mut Ui) {
+ use egui_extras::{Column, TableBuilder};
+
+ let num_cols = headers.len();
+ if num_cols == 0 {
+ return;
+ }
+
+ let cell_padding = egui::Margin::symmetric(8, 4);
+
+ let mut builder = TableBuilder::new(ui).vscroll(false);
+ for _ in 0..num_cols {
+ builder = builder.column(Column::auto().resizable(true));
+ }
+
+ let header_bg = theme.code_bg;
+
+ builder
+ .header(28.0, |mut header| {
+ for h in headers {
+ header.col(|ui| {
+ ui.painter()
+ .rect_filled(ui.max_rect(), 0.0, header_bg);
+ egui::Frame::NONE.inner_margin(cell_padding).show(ui, |ui| {
+ ui.strong(h);
+ });
+ });
+ }
+ })
+ .body(|mut body| {
+ for row in rows {
+ body.row(28.0, |mut table_row| {
+ for i in 0..num_cols {
+ table_row.col(|ui| {
+ egui::Frame::NONE.inner_margin(cell_padding).show(ui, |ui| {
+ if let Some(cell) = row.get(i) {
+ ui.label(cell);
+ }
+ });
+ });
+ }
+ });
+ }
+ });
+ ui.add_space(8.0);
+}
+
fn render_partial(partial: &Partial, theme: &MdTheme, ui: &mut Ui) {
let content = &partial.content;
if content.is_empty() {
@@ -266,6 +317,18 @@ fn render_partial(partial: &Partial, theme: &MdTheme, ui: &mut Ui) {
ui.add(egui::Label::new(RichText::new(content).size(size).strong()).wrap());
}
+ PartialKind::Table {
+ headers,
+ rows,
+ seen_separator,
+ } => {
+ if *seen_separator {
+ render_table(headers, rows, theme, ui);
+ } else {
+ ui.label(content);
+ }
+ }
+
PartialKind::Paragraph => {
// Parse inline elements from the partial content for proper formatting
let inlines = parse_inline(content);