agent_core/tui/
table.rs

1//! Table rendering utilities for TUI
2//!
3//! Supports multiple rendering strategies via trait.
4
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use ratatui::{
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9};
10use unicode_width::UnicodeWidthStr;
11
12use super::themes::Theme;
13
14/// Indent prefix for table lines
15const TABLE_INDENT: &str = "  ";
16
17/// Trait for table rendering strategies
18pub trait TableRenderer {
19    fn render(&self, table_lines: &[String], theme: &Theme) -> Vec<Line<'static>>;
20}
21
22/// Renders tables using pulldown-cmark parsing
23///
24/// Provides proper CommonMark table parsing with markdown support in cells
25pub struct PulldownRenderer;
26
27/// A styled segment within a cell
28type StyledSegment = (String, Style);
29/// A cell is a list of styled segments
30type StyledCell = Vec<StyledSegment>;
31/// A row is a list of cells
32type StyledRow = Vec<StyledCell>;
33
34impl TableRenderer for PulldownRenderer {
35    fn render(&self, table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
36        // Join lines back into markdown text
37        let markdown = table_lines.join("\n");
38
39        // Parse with table support enabled
40        let mut options = Options::empty();
41        options.insert(Options::ENABLE_TABLES);
42        let parser = Parser::new_ext(&markdown, options);
43
44        // Extract table structure from events
45        let mut rows: Vec<StyledRow> = Vec::new();
46        let mut current_row: StyledRow = Vec::new();
47        let mut current_cell: StyledCell = Vec::new();
48        let mut current_text = String::new();
49        let mut style_stack: Vec<Modifier> = Vec::new();
50        let mut color_stack: Vec<Color> = Vec::new();
51        let mut header_row_count = 0;
52
53        for event in parser {
54            match event {
55                Event::Start(Tag::TableHead) => {
56                    current_row = Vec::new();
57                }
58                Event::End(TagEnd::TableHead) => {
59                    if !current_row.is_empty() {
60                        rows.push(current_row.clone());
61                        header_row_count += 1;
62                    }
63                    current_row = Vec::new();
64                }
65                Event::Start(Tag::TableRow) => {
66                    current_row = Vec::new();
67                }
68                Event::End(TagEnd::TableRow) => {
69                    if !current_row.is_empty() {
70                        rows.push(current_row.clone());
71                    }
72                    current_row = Vec::new();
73                }
74                Event::Start(Tag::TableCell) => {
75                    current_cell = Vec::new();
76                    current_text.clear();
77                    style_stack.clear();
78                    color_stack.clear();
79                }
80                Event::End(TagEnd::TableCell) => {
81                    // Flush any remaining text
82                    if !current_text.is_empty() {
83                        let style = build_cell_style(&style_stack, &color_stack);
84                        current_cell.push((current_text.trim().to_string(), style));
85                        current_text.clear();
86                    }
87                    current_row.push(current_cell.clone());
88                    current_cell = Vec::new();
89                }
90                // Formatting events
91                Event::Start(Tag::Strong) => {
92                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
93                    style_stack.push(theme.bold());
94                }
95                Event::End(TagEnd::Strong) => {
96                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
97                    style_stack.pop();
98                }
99                Event::Start(Tag::Emphasis) => {
100                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
101                    style_stack.push(theme.italic());
102                }
103                Event::End(TagEnd::Emphasis) => {
104                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
105                    style_stack.pop();
106                }
107                Event::Start(Tag::Strikethrough) => {
108                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
109                    style_stack.push(theme.strikethrough());
110                }
111                Event::End(TagEnd::Strikethrough) => {
112                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
113                    style_stack.pop();
114                }
115                Event::Code(code) => {
116                    flush_text(&mut current_text, &mut current_cell, &style_stack, &color_stack);
117                    current_cell.push((code.to_string(), theme.inline_code()));
118                }
119                Event::Text(text) => {
120                    current_text.push_str(&text);
121                }
122                Event::SoftBreak | Event::HardBreak => {
123                    current_text.push(' ');
124                }
125                _ => {}
126            }
127        }
128
129        if rows.is_empty() {
130            return Vec::new();
131        }
132
133        // Calculate column widths (sum of all segment lengths per cell)
134        let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
135        let mut col_widths: Vec<usize> = vec![0; col_count];
136
137        for row in &rows {
138            for (i, cell) in row.iter().enumerate() {
139                if i < col_widths.len() {
140                    let cell_width: usize = cell.iter().map(|(s, _)| s.width()).sum();
141                    col_widths[i] = col_widths[i].max(cell_width);
142                }
143            }
144        }
145
146        // Render using box-drawing style
147        let mut lines = Vec::new();
148        let header_style = theme.table_header();
149        let cell_style = theme.table_cell();
150        let border_style = theme.table_border();
151
152        // Top border
153        lines.push(render_border(&col_widths, '\u{250C}', '\u{252C}', '\u{2510}', border_style));
154
155        // Header rows
156        let header_count = header_row_count.max(1).min(rows.len());
157        for row in rows.iter().take(header_count) {
158            lines.push(render_styled_row(row, &col_widths, header_style, border_style));
159        }
160
161        // Separator after header
162        if rows.len() > header_count {
163            lines.push(render_border(&col_widths, '\u{251C}', '\u{253C}', '\u{2524}', border_style));
164        }
165
166        // Data rows
167        for row in rows.iter().skip(header_count) {
168            lines.push(render_styled_row(row, &col_widths, cell_style, border_style));
169        }
170
171        // Bottom border
172        lines.push(render_border(&col_widths, '\u{2514}', '\u{2534}', '\u{2518}', border_style));
173
174        lines
175    }
176}
177
178/// Flush accumulated text to cell with current style
179fn flush_text(
180    current_text: &mut String,
181    current_cell: &mut StyledCell,
182    style_stack: &[Modifier],
183    color_stack: &[Color],
184) {
185    if !current_text.is_empty() {
186        let style = build_cell_style(style_stack, color_stack);
187        current_cell.push((current_text.clone(), style));
188        current_text.clear();
189    }
190}
191
192/// Build a Style from modifier and color stacks
193fn build_cell_style(modifiers: &[Modifier], colors: &[Color]) -> Style {
194    let mut style = Style::default();
195    for modifier in modifiers {
196        style = style.add_modifier(*modifier);
197    }
198    if let Some(&color) = colors.last() {
199        style = style.fg(color);
200    }
201    style
202}
203
204/// Render a table row with styled cells
205fn render_styled_row(
206    cells: &[StyledCell],
207    col_widths: &[usize],
208    base_style: Style,
209    border_style: Style,
210) -> Line<'static> {
211    let mut spans = vec![
212        Span::raw(TABLE_INDENT),
213        Span::styled("\u{2502}", border_style),
214    ];
215
216    for (i, width) in col_widths.iter().enumerate() {
217        spans.push(Span::styled(" ", base_style)); // left padding
218
219        let mut cell_len = 0;
220        if let Some(cell) = cells.get(i) {
221            for (text, style) in cell {
222                // Merge base_style with segment style (segment style takes precedence)
223                let merged = base_style.patch(*style);
224                spans.push(Span::styled(text.clone(), merged));
225                cell_len += text.width();
226            }
227        }
228
229        // Right padding to fill column width
230        let padding = width.saturating_sub(cell_len) + 1;
231        spans.push(Span::styled(" ".repeat(padding), base_style));
232        spans.push(Span::styled("\u{2502}", border_style));
233    }
234
235    Line::from(spans)
236}
237
238// ============================================================================
239// Public API
240// ============================================================================
241
242/// Check if a line could be part of a table (contains |)
243pub fn is_table_line(line: &str) -> bool {
244    let trimmed = line.trim();
245    trimmed.contains('|')
246}
247
248/// Check if a line is a table separator (|---|---|)
249///
250/// Must be primarily dashes, pipes, colons, and spaces - not content with hyphens
251pub fn is_table_separator(line: &str) -> bool {
252    let trimmed = line.trim();
253    if !trimmed.contains('-') || !trimmed.contains('|') {
254        return false;
255    }
256    // A separator line should be mostly dashes, pipes, colons, and spaces
257    let non_sep_chars = trimmed
258        .chars()
259        .filter(|c| !matches!(c, '-' | '|' | ':' | ' '))
260        .count();
261    non_sep_chars == 0
262}
263
264/// Render a table using PulldownRenderer
265pub fn render_table(table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
266    PulldownRenderer.render(table_lines, theme)
267}
268
269// ============================================================================
270// Internal helpers
271// ============================================================================
272
273/// Render a table border line (with indent)
274fn render_border(
275    col_widths: &[usize],
276    left: char,
277    mid: char,
278    right: char,
279    style: Style,
280) -> Line<'static> {
281    let mut content = String::new();
282    content.push(left);
283    for (i, &width) in col_widths.iter().enumerate() {
284        content.push_str(&"\u{2500}".repeat(width + 2)); // +2 for cell padding
285        if i < col_widths.len() - 1 {
286            content.push(mid);
287        }
288    }
289    content.push(right);
290
291    Line::from(vec![
292        Span::raw(TABLE_INDENT),
293        Span::styled(content, style),
294    ])
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_is_table_line() {
303        assert!(is_table_line("| a | b |"));
304        assert!(is_table_line("a | b"));
305        assert!(!is_table_line("no pipes here"));
306    }
307
308    #[test]
309    fn test_is_table_separator() {
310        assert!(is_table_separator("|---|---|"));
311        assert!(is_table_separator("| --- | --- |"));
312        assert!(is_table_separator("|:---:|:---:|"));
313        assert!(!is_table_separator("| F-150 | truck |")); // content with hyphen
314        assert!(!is_table_separator("no pipes"));
315    }
316
317    #[test]
318    fn test_render_table_empty() {
319        let theme = Theme::default();
320        let lines = render_table(&[], &theme);
321        assert!(lines.is_empty());
322    }
323
324    #[test]
325    fn test_render_table_basic() {
326        let theme = Theme::default();
327        let table_lines = vec![
328            "| Name | Age |".to_string(),
329            "|------|-----|".to_string(),
330            "| Alice | 30 |".to_string(),
331        ];
332        let lines = render_table(&table_lines, &theme);
333        // Should have: top border, header, separator, 1 data row, bottom border = 5 lines
334        assert_eq!(lines.len(), 5);
335    }
336
337    #[test]
338    fn test_pulldown_renderer_basic() {
339        let theme = Theme::default();
340        let table_lines = vec![
341            "| Name | Age |".to_string(),
342            "|------|-----|".to_string(),
343            "| Alice | 30 |".to_string(),
344        ];
345        let lines = PulldownRenderer.render(&table_lines, &theme);
346        assert_eq!(lines.len(), 5);
347    }
348
349    #[test]
350    fn test_pulldown_renderer_multiple_rows() {
351        let theme = Theme::default();
352        let table_lines = vec![
353            "| Product | Price | Stock |".to_string(),
354            "|---------|-------|-------|".to_string(),
355            "| Apple | $1.00 | 50 |".to_string(),
356            "| Banana | $0.50 | 100 |".to_string(),
357        ];
358        let lines = PulldownRenderer.render(&table_lines, &theme);
359        // top border, header, separator, 2 data rows, bottom border = 6 lines
360        assert_eq!(lines.len(), 6);
361    }
362
363    #[test]
364    fn test_pulldown_renderer_styled_cells() {
365        let theme = Theme::default();
366        let table_lines = vec![
367            "| **Name** | Age |".to_string(),
368            "|----------|-----|".to_string(),
369            "| *Alice* | 30 |".to_string(),
370            "| `Bob` | 25 |".to_string(),
371        ];
372        let lines = PulldownRenderer.render(&table_lines, &theme);
373        assert_eq!(lines.len(), 6);
374
375        // The row should contain multiple spans (border + styled cells)
376        let data_row = &lines[3];
377        assert!(data_row.spans.len() > 3, "Data row should have multiple spans for styling");
378    }
379}