1use 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
14const TABLE_INDENT: &str = " ";
16
17pub trait TableRenderer {
19 fn render(&self, table_lines: &[String], theme: &Theme) -> Vec<Line<'static>>;
20}
21
22pub struct PulldownRenderer;
26
27type StyledSegment = (String, Style);
29type StyledCell = Vec<StyledSegment>;
31type StyledRow = Vec<StyledCell>;
33
34impl TableRenderer for PulldownRenderer {
35 fn render(&self, table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
36 let markdown = table_lines.join("\n");
38
39 let mut options = Options::empty();
41 options.insert(Options::ENABLE_TABLES);
42 let parser = Parser::new_ext(&markdown, options);
43
44 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 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 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 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 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 lines.push(render_border(&col_widths, '\u{250C}', '\u{252C}', '\u{2510}', border_style));
154
155 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 if rows.len() > header_count {
163 lines.push(render_border(&col_widths, '\u{251C}', '\u{253C}', '\u{2524}', border_style));
164 }
165
166 for row in rows.iter().skip(header_count) {
168 lines.push(render_styled_row(row, &col_widths, cell_style, border_style));
169 }
170
171 lines.push(render_border(&col_widths, '\u{2514}', '\u{2534}', '\u{2518}', border_style));
173
174 lines
175 }
176}
177
178fn 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
192fn 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
204fn 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)); let mut cell_len = 0;
220 if let Some(cell) = cells.get(i) {
221 for (text, style) in cell {
222 let merged = base_style.patch(*style);
224 spans.push(Span::styled(text.clone(), merged));
225 cell_len += text.width();
226 }
227 }
228
229 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
238pub fn is_table_line(line: &str) -> bool {
244 let trimmed = line.trim();
245 trimmed.contains('|')
246}
247
248pub fn is_table_separator(line: &str) -> bool {
252 let trimmed = line.trim();
253 if !trimmed.contains('-') || !trimmed.contains('|') {
254 return false;
255 }
256 let non_sep_chars = trimmed
258 .chars()
259 .filter(|c| !matches!(c, '-' | '|' | ':' | ' '))
260 .count();
261 non_sep_chars == 0
262}
263
264pub fn render_table(table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
266 PulldownRenderer.render(table_lines, theme)
267}
268
269fn 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)); 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 |")); 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 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 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 let data_row = &lines[3];
377 assert!(data_row.spans.len() > 3, "Data row should have multiple spans for styling");
378 }
379}