Skip to main content

agent_air_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(
93                        &mut current_text,
94                        &mut current_cell,
95                        &style_stack,
96                        &color_stack,
97                    );
98                    style_stack.push(theme.bold());
99                }
100                Event::End(TagEnd::Strong) => {
101                    flush_text(
102                        &mut current_text,
103                        &mut current_cell,
104                        &style_stack,
105                        &color_stack,
106                    );
107                    style_stack.pop();
108                }
109                Event::Start(Tag::Emphasis) => {
110                    flush_text(
111                        &mut current_text,
112                        &mut current_cell,
113                        &style_stack,
114                        &color_stack,
115                    );
116                    style_stack.push(theme.italic());
117                }
118                Event::End(TagEnd::Emphasis) => {
119                    flush_text(
120                        &mut current_text,
121                        &mut current_cell,
122                        &style_stack,
123                        &color_stack,
124                    );
125                    style_stack.pop();
126                }
127                Event::Start(Tag::Strikethrough) => {
128                    flush_text(
129                        &mut current_text,
130                        &mut current_cell,
131                        &style_stack,
132                        &color_stack,
133                    );
134                    style_stack.push(theme.strikethrough());
135                }
136                Event::End(TagEnd::Strikethrough) => {
137                    flush_text(
138                        &mut current_text,
139                        &mut current_cell,
140                        &style_stack,
141                        &color_stack,
142                    );
143                    style_stack.pop();
144                }
145                Event::Code(code) => {
146                    flush_text(
147                        &mut current_text,
148                        &mut current_cell,
149                        &style_stack,
150                        &color_stack,
151                    );
152                    current_cell.push((code.to_string(), theme.inline_code()));
153                }
154                Event::Text(text) => {
155                    current_text.push_str(&text);
156                }
157                Event::SoftBreak | Event::HardBreak => {
158                    current_text.push(' ');
159                }
160                _ => {}
161            }
162        }
163
164        if rows.is_empty() {
165            return Vec::new();
166        }
167
168        // Calculate column widths (sum of all segment lengths per cell)
169        let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
170        let mut col_widths: Vec<usize> = vec![0; col_count];
171
172        for row in &rows {
173            for (i, cell) in row.iter().enumerate() {
174                if i < col_widths.len() {
175                    let cell_width: usize = cell.iter().map(|(s, _)| s.width()).sum();
176                    col_widths[i] = col_widths[i].max(cell_width);
177                }
178            }
179        }
180
181        // Render using box-drawing style
182        let mut lines = Vec::new();
183        let header_style = theme.table_header();
184        let cell_style = theme.table_cell();
185        let border_style = theme.table_border();
186
187        // Top border
188        lines.push(render_border(
189            &col_widths,
190            '\u{250C}',
191            '\u{252C}',
192            '\u{2510}',
193            border_style,
194        ));
195
196        // Header rows
197        let header_count = header_row_count.max(1).min(rows.len());
198        for row in rows.iter().take(header_count) {
199            lines.push(render_styled_row(
200                row,
201                &col_widths,
202                header_style,
203                border_style,
204            ));
205        }
206
207        // Separator after header
208        if rows.len() > header_count {
209            lines.push(render_border(
210                &col_widths,
211                '\u{251C}',
212                '\u{253C}',
213                '\u{2524}',
214                border_style,
215            ));
216        }
217
218        // Data rows
219        for row in rows.iter().skip(header_count) {
220            lines.push(render_styled_row(
221                row,
222                &col_widths,
223                cell_style,
224                border_style,
225            ));
226        }
227
228        // Bottom border
229        lines.push(render_border(
230            &col_widths,
231            '\u{2514}',
232            '\u{2534}',
233            '\u{2518}',
234            border_style,
235        ));
236
237        lines
238    }
239}
240
241/// Flush accumulated text to cell with current style
242fn flush_text(
243    current_text: &mut String,
244    current_cell: &mut StyledCell,
245    style_stack: &[Modifier],
246    color_stack: &[Color],
247) {
248    if !current_text.is_empty() {
249        let style = build_cell_style(style_stack, color_stack);
250        current_cell.push((current_text.clone(), style));
251        current_text.clear();
252    }
253}
254
255/// Build a Style from modifier and color stacks
256fn build_cell_style(modifiers: &[Modifier], colors: &[Color]) -> Style {
257    let mut style = Style::default();
258    for modifier in modifiers {
259        style = style.add_modifier(*modifier);
260    }
261    if let Some(&color) = colors.last() {
262        style = style.fg(color);
263    }
264    style
265}
266
267/// Render a table row with styled cells
268fn render_styled_row(
269    cells: &[StyledCell],
270    col_widths: &[usize],
271    base_style: Style,
272    border_style: Style,
273) -> Line<'static> {
274    let mut spans = vec![
275        Span::raw(TABLE_INDENT),
276        Span::styled("\u{2502}", border_style),
277    ];
278
279    for (i, width) in col_widths.iter().enumerate() {
280        spans.push(Span::styled(" ", base_style)); // left padding
281
282        let mut cell_len = 0;
283        if let Some(cell) = cells.get(i) {
284            for (text, style) in cell {
285                // Merge base_style with segment style (segment style takes precedence)
286                let merged = base_style.patch(*style);
287                spans.push(Span::styled(text.clone(), merged));
288                cell_len += text.width();
289            }
290        }
291
292        // Right padding to fill column width
293        let padding = width.saturating_sub(cell_len) + 1;
294        spans.push(Span::styled(" ".repeat(padding), base_style));
295        spans.push(Span::styled("\u{2502}", border_style));
296    }
297
298    Line::from(spans)
299}
300
301// ============================================================================
302// Public API
303// ============================================================================
304
305/// Check if a line could be part of a table (contains |)
306pub fn is_table_line(line: &str) -> bool {
307    let trimmed = line.trim();
308    trimmed.contains('|')
309}
310
311/// Check if a line is a table separator (|---|---|)
312///
313/// Must be primarily dashes, pipes, colons, and spaces - not content with hyphens
314pub fn is_table_separator(line: &str) -> bool {
315    let trimmed = line.trim();
316    if !trimmed.contains('-') || !trimmed.contains('|') {
317        return false;
318    }
319    // A separator line should be mostly dashes, pipes, colons, and spaces
320    let non_sep_chars = trimmed
321        .chars()
322        .filter(|c| !matches!(c, '-' | '|' | ':' | ' '))
323        .count();
324    non_sep_chars == 0
325}
326
327/// Render a table using PulldownRenderer
328pub fn render_table(table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
329    PulldownRenderer.render(table_lines, theme)
330}
331
332// ============================================================================
333// Internal helpers
334// ============================================================================
335
336/// Render a table border line (with indent)
337fn render_border(
338    col_widths: &[usize],
339    left: char,
340    mid: char,
341    right: char,
342    style: Style,
343) -> Line<'static> {
344    let mut content = String::new();
345    content.push(left);
346    for (i, &width) in col_widths.iter().enumerate() {
347        content.push_str(&"\u{2500}".repeat(width + 2)); // +2 for cell padding
348        if i < col_widths.len() - 1 {
349            content.push(mid);
350        }
351    }
352    content.push(right);
353
354    Line::from(vec![Span::raw(TABLE_INDENT), Span::styled(content, style)])
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn test_is_table_line() {
363        assert!(is_table_line("| a | b |"));
364        assert!(is_table_line("a | b"));
365        assert!(!is_table_line("no pipes here"));
366    }
367
368    #[test]
369    fn test_is_table_separator() {
370        assert!(is_table_separator("|---|---|"));
371        assert!(is_table_separator("| --- | --- |"));
372        assert!(is_table_separator("|:---:|:---:|"));
373        assert!(!is_table_separator("| F-150 | truck |")); // content with hyphen
374        assert!(!is_table_separator("no pipes"));
375    }
376
377    #[test]
378    fn test_render_table_empty() {
379        let theme = Theme::default();
380        let lines = render_table(&[], &theme);
381        assert!(lines.is_empty());
382    }
383
384    #[test]
385    fn test_render_table_basic() {
386        let theme = Theme::default();
387        let table_lines = vec![
388            "| Name | Age |".to_string(),
389            "|------|-----|".to_string(),
390            "| Alice | 30 |".to_string(),
391        ];
392        let lines = render_table(&table_lines, &theme);
393        // Should have: top border, header, separator, 1 data row, bottom border = 5 lines
394        assert_eq!(lines.len(), 5);
395    }
396
397    #[test]
398    fn test_pulldown_renderer_basic() {
399        let theme = Theme::default();
400        let table_lines = vec![
401            "| Name | Age |".to_string(),
402            "|------|-----|".to_string(),
403            "| Alice | 30 |".to_string(),
404        ];
405        let lines = PulldownRenderer.render(&table_lines, &theme);
406        assert_eq!(lines.len(), 5);
407    }
408
409    #[test]
410    fn test_pulldown_renderer_multiple_rows() {
411        let theme = Theme::default();
412        let table_lines = vec![
413            "| Product | Price | Stock |".to_string(),
414            "|---------|-------|-------|".to_string(),
415            "| Apple | $1.00 | 50 |".to_string(),
416            "| Banana | $0.50 | 100 |".to_string(),
417        ];
418        let lines = PulldownRenderer.render(&table_lines, &theme);
419        // top border, header, separator, 2 data rows, bottom border = 6 lines
420        assert_eq!(lines.len(), 6);
421    }
422
423    #[test]
424    fn test_pulldown_renderer_styled_cells() {
425        let theme = Theme::default();
426        let table_lines = vec![
427            "| **Name** | Age |".to_string(),
428            "|----------|-----|".to_string(),
429            "| *Alice* | 30 |".to_string(),
430            "| `Bob` | 25 |".to_string(),
431        ];
432        let lines = PulldownRenderer.render(&table_lines, &theme);
433        assert_eq!(lines.len(), 6);
434
435        // The row should contain multiple spans (border + styled cells)
436        let data_row = &lines[3];
437        assert!(
438            data_row.spans.len() > 3,
439            "Data row should have multiple spans for styling"
440        );
441    }
442}