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(
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 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 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 lines.push(render_border(
189 &col_widths,
190 '\u{250C}',
191 '\u{252C}',
192 '\u{2510}',
193 border_style,
194 ));
195
196 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 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 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 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
241fn 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
255fn 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
267fn 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)); let mut cell_len = 0;
283 if let Some(cell) = cells.get(i) {
284 for (text, style) in cell {
285 let merged = base_style.patch(*style);
287 spans.push(Span::styled(text.clone(), merged));
288 cell_len += text.width();
289 }
290 }
291
292 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
301pub fn is_table_line(line: &str) -> bool {
307 let trimmed = line.trim();
308 trimmed.contains('|')
309}
310
311pub fn is_table_separator(line: &str) -> bool {
315 let trimmed = line.trim();
316 if !trimmed.contains('-') || !trimmed.contains('|') {
317 return false;
318 }
319 let non_sep_chars = trimmed
321 .chars()
322 .filter(|c| !matches!(c, '-' | '|' | ':' | ' '))
323 .count();
324 non_sep_chars == 0
325}
326
327pub fn render_table(table_lines: &[String], theme: &Theme) -> Vec<Line<'static>> {
329 PulldownRenderer.render(table_lines, theme)
330}
331
332fn 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)); 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 |")); 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 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 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 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}