notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/md-stream/src/element.rs | 6++++++
Mcrates/md-stream/src/parser.rs | 208++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/md-stream/src/partial.rs | 7+++++++
Mcrates/md-stream/src/tests.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/markdown_ui.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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);