1use itertools::{Itertools, Position};
7use once_cell::sync::Lazy;
8use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag};
9use ratatui::style::{Color, Style};
10use ratatui::text::{Line, Span};
11use syntect::easy::HighlightLines;
12use syntect::parsing::SyntaxSet;
13use syntect::util::LinesWithEndings;
14use tracing::{debug, instrument, warn};
15use unicode_width::UnicodeWidthStr;
16
17use crate::tui::theme::{Component, Theme};
18
19static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(SyntaxSet::load_defaults_newlines);
21
22fn syntect_style_to_ratatui(syntect_style: syntect::highlighting::Style) -> Style {
24 let fg = Color::Rgb(
25 syntect_style.foreground.r,
26 syntect_style.foreground.g,
27 syntect_style.foreground.b,
28 );
29 Style::default().fg(fg)
30}
31
32#[derive(Debug, Clone)]
34pub struct MarkedLine {
35 pub line: Line<'static>,
36 pub no_wrap: bool, }
38
39impl MarkedLine {
40 pub fn new(line: Line<'static>) -> Self {
41 Self {
42 line,
43 no_wrap: false,
44 }
45 }
46
47 pub fn new_no_wrap(line: Line<'static>) -> Self {
48 Self {
49 line,
50 no_wrap: true,
51 }
52 }
53}
54
55#[derive(Debug, Default)]
57pub struct MarkedText {
58 pub lines: Vec<MarkedLine>,
59}
60
61impl MarkedText {
62 pub fn height(&self) -> usize {
63 self.lines.len()
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct MarkdownStyles {
70 pub h1: Style,
71 pub h2: Style,
72 pub h3: Style,
73 pub h4: Style,
74 pub h5: Style,
75 pub h6: Style,
76 pub emphasis: Style,
77 pub strong: Style,
78 pub strikethrough: Style,
79 pub blockquote: Style,
80 pub code: Style,
81 pub code_block: Style,
82 pub link: Style,
83 pub list_marker: Style,
84 pub list_number: Style,
85 pub table_border: Style,
86 pub table_header: Style,
87 pub table_cell: Style,
88 pub task_checked: Style,
89 pub task_unchecked: Style,
90}
91
92impl MarkdownStyles {
93 pub fn from_theme(theme: &Theme) -> Self {
95 use ratatui::style::Modifier;
96
97 Self {
98 h1: theme
100 .style(Component::MarkdownH1)
101 .add_modifier(Modifier::BOLD | Modifier::REVERSED),
102 h2: theme
103 .style(Component::MarkdownH2)
104 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
105 h3: theme
106 .style(Component::MarkdownH3)
107 .add_modifier(Modifier::BOLD),
108 h4: theme
109 .style(Component::MarkdownH4)
110 .add_modifier(Modifier::UNDERLINED),
111 h5: theme
112 .style(Component::MarkdownH5)
113 .add_modifier(Modifier::ITALIC),
114 h6: theme
115 .style(Component::MarkdownH6)
116 .add_modifier(Modifier::ITALIC),
117
118 emphasis: Style::default().add_modifier(Modifier::ITALIC),
120 strong: Style::default().add_modifier(Modifier::BOLD),
121 strikethrough: Style::default().add_modifier(Modifier::CROSSED_OUT),
122
123 blockquote: theme
125 .style(Component::MarkdownBlockquote)
126 .add_modifier(Modifier::ITALIC),
127 code: theme.style(Component::MarkdownCode),
128 code_block: theme.style(Component::MarkdownCodeBlock),
129 link: theme
130 .style(Component::MarkdownLink)
131 .add_modifier(Modifier::UNDERLINED),
132 list_marker: theme.style(Component::MarkdownListBullet),
133 list_number: theme.style(Component::MarkdownListNumber),
134 table_border: theme.style(Component::MarkdownTableBorder),
135 table_header: theme.style(Component::MarkdownTableHeader),
136 table_cell: theme.style(Component::MarkdownTableCell),
137 task_checked: theme.style(Component::MarkdownTaskChecked),
138 task_unchecked: theme.style(Component::MarkdownTaskUnchecked),
139 }
140 }
141}
142
143pub fn from_str(input: &str, styles: &MarkdownStyles, theme: &Theme) -> MarkedText {
144 from_str_with_width(input, styles, theme, None)
145}
146
147pub fn from_str_with_width(
148 input: &str,
149 styles: &MarkdownStyles,
150 theme: &Theme,
151 terminal_width: Option<u16>,
152) -> MarkedText {
153 let mut options = Options::empty();
154 options.insert(Options::ENABLE_STRIKETHROUGH);
155 options.insert(Options::ENABLE_TABLES);
156 options.insert(Options::ENABLE_FOOTNOTES);
157 options.insert(Options::ENABLE_TASKLISTS);
158 options.insert(Options::ENABLE_SMART_PUNCTUATION);
159 let parser = Parser::new_ext(input, options);
160 let mut writer = TextWriter::new(parser, styles, theme);
161 writer.terminal_width = terminal_width;
162 writer.run();
163 writer.marked_text
164}
165
166struct TextWriter<'a, I> {
167 iter: I,
169
170 marked_text: MarkedText,
172
173 inline_styles: Vec<Style>,
177
178 line_prefixes: Vec<Span<'a>>,
180
181 line_styles: Vec<Style>,
183
184 list_indices: Vec<Option<u64>>,
186
187 link: Option<CowStr<'a>>,
189
190 needs_newline: bool,
191
192 styles: &'a MarkdownStyles,
194
195 theme: &'a Theme,
197
198 table_alignments: Vec<pulldown_cmark::Alignment>,
200 table_rows: Vec<Vec<Vec<Span<'a>>>>, in_table_header: bool,
202
203 in_list_item_start: bool,
205
206 in_code_block: bool,
208
209 code_block_language: Option<String>,
211
212 terminal_width: Option<u16>,
214}
215
216impl<'a, I> TextWriter<'a, I>
217where
218 I: Iterator<Item = Event<'a>>,
219{
220 fn new(iter: I, styles: &'a MarkdownStyles, theme: &'a Theme) -> Self {
221 Self {
222 iter,
223 marked_text: MarkedText::default(),
224 inline_styles: vec![],
225 line_styles: vec![],
226 line_prefixes: vec![],
227 list_indices: vec![],
228 needs_newline: false,
229 link: None,
230 styles,
231 theme,
232 table_alignments: Vec::new(),
233 table_rows: Vec::new(),
234 in_table_header: false,
235 in_list_item_start: false,
236 in_code_block: false,
237 code_block_language: None,
238 terminal_width: None,
239 }
240 }
241
242 fn run(&mut self) {
243 debug!("Running text writer");
244 while let Some(event) = self.iter.next() {
245 self.handle_event(event);
246 }
247 }
248
249 fn handle_event(&mut self, event: Event<'a>) {
250 match event {
251 Event::Start(tag) => self.start_tag(tag),
252 Event::End(tag) => self.end_tag(tag),
253 Event::Text(text) => self.text(text),
254 Event::Code(code) => self.code(code),
255 Event::Html(html) => {
256 warn!("Rich html not yet supported: {}", html);
257 self.text(html)
258 }
259 Event::FootnoteReference(reference) => {
260 warn!("Footnote reference not yet supported: {}", reference);
261 self.text(reference)
262 }
263 Event::SoftBreak => self.soft_break(),
264 Event::HardBreak => self.hard_break(),
265 Event::Rule => self.rule(),
266 Event::TaskListMarker(checked) => self.task_list_marker(checked),
267 }
268 }
269
270 fn start_tag(&mut self, tag: Tag<'a>) {
271 match tag {
272 Tag::Paragraph => self.start_paragraph(),
273 Tag::Heading(level, _, _) => self.start_heading(level),
274 Tag::BlockQuote => self.start_blockquote(),
275 Tag::CodeBlock(kind) => self.start_codeblock(kind),
276 Tag::List(start_index) => self.start_list(start_index),
277 Tag::Item => self.start_item(),
278 Tag::FootnoteDefinition(_) => warn!("Footnote definition not yet supported"),
279 Tag::Table(alignments) => self.start_table(alignments),
280 Tag::TableHead => self.start_table_head(),
281 Tag::TableRow => self.start_table_row(),
282 Tag::TableCell => self.start_table_cell(),
283 Tag::Emphasis | Tag::Strong | Tag::Strikethrough => {
284 if self.in_list_item_start {
286 self.push_list_marker();
287 self.in_list_item_start = false;
288 }
289
290 match tag {
291 Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
292 Tag::Strong => self.push_inline_style(self.styles.strong),
293 Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
294 _ => unreachable!(),
295 }
296 }
297 Tag::Link(_link_type, dest_url, _title) => {
298 if self.in_list_item_start {
300 self.push_list_marker();
301 self.in_list_item_start = false;
302 }
303 self.push_link(dest_url)
304 }
305 Tag::Image(_link_type, _dest_url, _title) => warn!("Image not yet supported"),
306 }
307 }
308
309 fn end_tag(&mut self, tag: Tag<'a>) {
310 match tag {
311 Tag::Paragraph => self.end_paragraph(),
312 Tag::Heading(..) => self.end_heading(),
313 Tag::BlockQuote => self.end_blockquote(),
314 Tag::CodeBlock(_) => self.end_codeblock(),
315 Tag::List(_) => self.end_list(),
316 Tag::Item => self.end_item(),
317 Tag::FootnoteDefinition(_) => {}
318 Tag::Table(_) => self.end_table(),
319 Tag::TableHead => self.end_table_head(),
320 Tag::TableRow => self.end_table_row(),
321 Tag::TableCell => self.end_table_cell(),
322 Tag::Emphasis => self.pop_inline_style(),
323 Tag::Strong => self.pop_inline_style(),
324 Tag::Strikethrough => self.pop_inline_style(),
325 Tag::Link(..) => self.pop_link(),
326 Tag::Image(..) => {}
327 }
328 }
329
330 fn start_paragraph(&mut self) {
331 if self.needs_newline {
333 self.push_line(Line::default());
334 }
335 self.push_line(Line::default());
336 self.needs_newline = false;
337 }
338
339 fn end_paragraph(&mut self) {
340 self.needs_newline = true
341 }
342
343 fn start_heading(&mut self, level: HeadingLevel) {
344 if self.needs_newline {
345 self.push_line(Line::default());
346 }
347 let style = match level {
348 HeadingLevel::H1 => self.styles.h1,
349 HeadingLevel::H2 => self.styles.h2,
350 HeadingLevel::H3 => self.styles.h3,
351 HeadingLevel::H4 => self.styles.h4,
352 HeadingLevel::H5 => self.styles.h5,
353 HeadingLevel::H6 => self.styles.h6,
354 };
355 self.push_inline_style(style);
357
358 let content = format!("{} ", "#".repeat(level as usize));
359 self.push_line(Line::styled(content, style));
360 self.needs_newline = false;
361 }
362
363 fn end_heading(&mut self) {
364 self.pop_inline_style();
366 self.needs_newline = true
367 }
368
369 fn start_blockquote(&mut self) {
370 if self.needs_newline {
371 self.push_line(Line::default());
372 self.needs_newline = false;
373 }
374 self.line_prefixes.push(Span::from(">"));
375 self.line_styles.push(self.styles.blockquote);
376 }
377
378 fn end_blockquote(&mut self) {
379 self.line_prefixes.pop();
380 self.line_styles.pop();
381 self.needs_newline = true;
382 }
383
384 fn text(&mut self, text: CowStr<'a>) {
385 if self.in_list_item_start {
388 self.push_list_marker();
389 self.in_list_item_start = false;
390 }
391
392 let in_table =
394 self.table_rows.last().is_some() && self.table_rows.last().unwrap().last().is_some();
395
396 if in_table {
397 let style = self.inline_styles.last().copied().unwrap_or_default();
399 let span = Span::styled(text.to_string(), style);
400 self.push_span(span);
401 } else if self.in_code_block {
402 let base_style = self
404 .inline_styles
405 .last()
406 .copied()
407 .unwrap_or_default()
408 .patch(self.styles.code_block);
409
410 let use_highlighting =
412 self.code_block_language.is_some() && self.theme.syntax_theme.is_some();
413
414 if use_highlighting {
415 let lang = self.code_block_language.as_ref().unwrap();
416 let syntax_theme = self.theme.syntax_theme.as_ref().unwrap();
417
418 let syntax = SYNTAX_SET
420 .find_syntax_by_token(lang)
421 .or_else(|| SYNTAX_SET.find_syntax_by_extension(lang))
422 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
423
424 let mut highlighter = HighlightLines::new(syntax, syntax_theme);
425
426 for (line_idx, line) in LinesWithEndings::from(text.as_ref()).enumerate() {
428 if line_idx > 0 || self.needs_newline {
429 self.push_line(Line::default());
430 }
431
432 let highlighted = highlighter
434 .highlight_line(line, &SYNTAX_SET)
435 .unwrap_or_else(|_| vec![(syntect::highlighting::Style::default(), line)]);
436
437 for (style, text) in highlighted {
439 let ratatui_style = syntect_style_to_ratatui(style).patch(base_style);
440 let span = Span::styled(text.to_string(), ratatui_style);
441 self.push_span(span);
442 }
443 }
444
445 self.needs_newline = text.ends_with('\n');
447 } else {
448 let lines: Vec<&str> = text.as_ref().lines().collect();
450 for (idx, line) in lines.iter().enumerate() {
451 if idx > 0 || self.needs_newline {
452 self.push_line(Line::default());
453 }
454
455 let span = Span::styled(line.to_string(), base_style);
457 self.push_span(span);
458 }
459
460 self.needs_newline = text.ends_with('\n') && !lines.is_empty();
462 }
463 } else {
464 for (position, line) in text.lines().with_position() {
466 if self.needs_newline {
467 self.push_line(Line::default());
468 self.needs_newline = false;
469 }
470 if matches!(position, Position::Middle | Position::Last) {
471 self.push_line(Line::default());
472 }
473
474 let style = self.inline_styles.last().copied().unwrap_or_default();
475 let span = Span::styled(line.to_owned(), style);
476 self.push_span(span);
477 }
478 self.needs_newline = false;
479 }
480 }
481
482 fn code(&mut self, code: CowStr<'a>) {
483 if self.in_list_item_start {
485 self.push_list_marker();
486 self.in_list_item_start = false;
487 }
488
489 let span = Span::styled(code, self.styles.code);
490 self.push_span(span);
491 }
492
493 fn hard_break(&mut self) {
494 self.push_span(" ".into()); self.push_line(Line::default());
497 }
498
499 fn start_list(&mut self, index: Option<u64>) {
500 if self.list_indices.is_empty() && self.needs_newline {
501 self.push_line(Line::default());
502 }
503 self.list_indices.push(index);
504 }
505
506 fn end_list(&mut self) {
507 self.list_indices.pop();
508 self.needs_newline = true;
509 }
510
511 fn start_item(&mut self) {
512 self.push_line(Line::default());
513 self.in_list_item_start = true;
515 self.needs_newline = false;
517 }
518
519 fn end_item(&mut self) {
520 if self.in_list_item_start {
523 self.push_list_marker();
524 self.in_list_item_start = false;
525 }
526 }
527
528 fn soft_break(&mut self) {
529 self.push_line(Line::default());
531 }
532
533 fn start_codeblock(&mut self, kind: CodeBlockKind<'_>) {
534 if !self.marked_text.lines.is_empty() {
535 self.push_line(Line::default());
536 }
537
538 self.in_code_block = true;
540
541 self.code_block_language = match kind {
543 CodeBlockKind::Fenced(lang) => {
544 let lang_str = lang.as_ref();
545 if !lang_str.is_empty() {
546 Some(lang_str.to_string())
547 } else {
548 None
549 }
550 }
551 CodeBlockKind::Indented => None,
552 };
553
554 self.line_styles.push(self.styles.code_block);
555 self.needs_newline = false;
556 }
557
558 fn end_codeblock(&mut self) {
559 self.in_code_block = false;
561 self.code_block_language = None;
562
563 self.needs_newline = true;
564 self.line_styles.pop();
565 }
566
567 #[instrument(level = "trace", skip(self))]
568 fn push_inline_style(&mut self, style: Style) {
569 let current_style = self.inline_styles.last().copied().unwrap_or_default();
570 let style = current_style.patch(style);
571 self.inline_styles.push(style);
572 debug!("Pushed inline style: {:?}", style);
573 debug!("Current inline styles: {:?}", self.inline_styles);
574 }
575
576 #[instrument(level = "trace", skip(self))]
577 fn pop_inline_style(&mut self) {
578 self.inline_styles.pop();
579 }
580
581 #[instrument(level = "trace", skip(self))]
582 fn push_line(&mut self, line: Line<'a>) {
583 let style = self.line_styles.last().copied().unwrap_or_default();
584 let mut line = line.patch_style(style);
585
586 let line_prefixes = self.line_prefixes.iter().cloned().collect_vec();
588 let has_prefixes = !line_prefixes.is_empty();
589 if has_prefixes {
590 line.spans.insert(0, " ".into());
591 }
592 for prefix in line_prefixes.iter().rev().cloned() {
593 line.spans.insert(0, prefix);
594 }
595
596 let static_spans: Vec<Span<'static>> = line
598 .spans
599 .into_iter()
600 .map(|span| Span::styled(span.content.into_owned(), span.style))
601 .collect();
602 let static_line = Line::from(static_spans);
603
604 let marked_line = if self.in_code_block {
606 MarkedLine::new_no_wrap(static_line)
607 } else {
608 MarkedLine::new(static_line)
609 };
610
611 self.marked_text.lines.push(marked_line);
612 }
613
614 #[instrument(level = "trace", skip(self))]
615 fn push_span(&mut self, span: Span<'a>) {
616 let in_table =
618 self.table_rows.last().is_some() && self.table_rows.last().unwrap().last().is_some();
619
620 if in_table {
621 let current_row = self.table_rows.last_mut().unwrap();
623 let current_cell = current_row.last_mut().unwrap();
624 current_cell.push(span);
625 } else if let Some(marked_line) = self.marked_text.lines.last_mut() {
626 let static_span = Span::styled(span.content.into_owned(), span.style);
628 marked_line.line.push_span(static_span);
629 } else {
630 self.push_line(Line::from(vec![span]));
631 }
632 }
633
634 #[instrument(level = "trace", skip(self))]
636 fn push_link(&mut self, dest_url: CowStr<'a>) {
637 self.link = Some(dest_url);
638 }
639
640 #[instrument(level = "trace", skip(self))]
642 fn pop_link(&mut self) {
643 if let Some(link) = self.link.take() {
644 self.push_span(" (".into());
645 self.push_span(Span::styled(link, self.styles.link));
646 self.push_span(")".into());
647 }
648 }
649
650 fn start_table(&mut self, alignments: Vec<pulldown_cmark::Alignment>) {
653 if self.needs_newline {
654 self.push_line(Line::default());
655 }
656 self.table_alignments = alignments;
657 self.table_rows.clear();
658 self.needs_newline = false;
659 }
660
661 fn end_table(&mut self) {
662 self.render_table();
663 self.table_alignments.clear();
664 self.table_rows.clear();
665 self.needs_newline = true;
666 }
667
668 fn start_table_head(&mut self) {
669 self.in_table_header = true;
670 self.table_rows.push(Vec::new());
672 }
673
674 fn end_table_head(&mut self) {
675 self.in_table_header = false;
676 }
677
678 fn start_table_row(&mut self) {
679 self.table_rows.push(Vec::new());
680 }
681
682 fn end_table_row(&mut self) {
683 }
685
686 fn start_table_cell(&mut self) {
687 if let Some(current_row) = self.table_rows.last_mut() {
689 current_row.push(Vec::new());
690 }
691 }
692
693 fn end_table_cell(&mut self) {
694 }
696
697 fn render_table(&mut self) {
699 if self.table_rows.is_empty() {
700 return;
701 }
702
703 let rows = std::mem::take(&mut self.table_rows);
705
706 let num_cols = self.table_alignments.len();
708 let mut col_widths = vec![0; num_cols];
709
710 for row in &rows {
711 for (col_idx, cell) in row.iter().enumerate() {
712 if col_idx < num_cols {
713 let cell_width = cell
714 .iter()
715 .map(|span| span.content.as_ref().width())
716 .sum::<usize>();
717 col_widths[col_idx] = col_widths[col_idx].max(cell_width);
718 }
719 }
720 }
721
722 for width in &mut col_widths {
724 *width += 2; }
726
727 let border_style = self.styles.table_border;
729 let header_style = self.styles.table_header;
730 let cell_style = self.styles.table_cell;
731
732 self.render_table_border(&col_widths, '┌', '┬', '┐', border_style);
734
735 for (row_idx, row) in rows.iter().enumerate() {
737 let is_header = row_idx == 0 && rows.len() > 1;
738 let mut line_spans = vec![Span::styled("│", border_style)];
739
740 for (col_idx, cell) in row.iter().enumerate() {
741 if col_idx < num_cols {
742 let cell_text: String = cell
744 .iter()
745 .map(|span| span.content.as_ref())
746 .collect::<Vec<_>>()
747 .join("");
748
749 let padded = self.align_text(
750 &cell_text,
751 col_widths[col_idx],
752 self.table_alignments[col_idx],
753 );
754
755 let style = if is_header { header_style } else { cell_style };
757 line_spans.push(Span::styled(padded, style));
758 line_spans.push(Span::styled("│", border_style));
759 }
760 }
761
762 self.push_line(Line::from(line_spans));
763
764 if is_header {
766 self.render_table_border(&col_widths, '├', '┼', '┤', border_style);
767 }
768 }
769
770 self.render_table_border(&col_widths, '└', '┴', '┘', border_style);
772 }
773
774 fn render_table_border(
776 &mut self,
777 col_widths: &[usize],
778 left: char,
779 mid: char,
780 right: char,
781 style: Style,
782 ) {
783 let mut border = String::from(left);
784
785 for (idx, &width) in col_widths.iter().enumerate() {
786 border.push_str(&"─".repeat(width));
787 if idx < col_widths.len() - 1 {
788 border.push(mid);
789 }
790 }
791
792 border.push(right);
793 self.push_line(Line::from(Span::styled(border, style)));
794 }
795
796 fn align_text(&self, text: &str, width: usize, alignment: pulldown_cmark::Alignment) -> String {
798 let text_len = text.width();
799 let total_padding = width.saturating_sub(text_len);
802
803 match alignment {
804 pulldown_cmark::Alignment::None | pulldown_cmark::Alignment::Left => {
805 let right_padding = total_padding.saturating_sub(1);
807 format!(" {}{}", text, " ".repeat(right_padding))
808 }
809 pulldown_cmark::Alignment::Center => {
810 let left_padding = total_padding / 2;
812 let right_padding = total_padding - left_padding;
813 format!(
814 "{}{}{}",
815 " ".repeat(left_padding),
816 text,
817 " ".repeat(right_padding)
818 )
819 }
820 pulldown_cmark::Alignment::Right => {
821 let left_padding = total_padding.saturating_sub(1);
823 format!("{}{} ", " ".repeat(left_padding), text)
824 }
825 }
826 }
827
828 fn rule(&mut self) {
830 if self.needs_newline {
831 self.push_line(Line::default());
832 }
833
834 let terminal_width = self.terminal_width.unwrap_or(80) as usize;
837 let rule_char = "─"; let rule_content = rule_char.repeat(terminal_width);
839
840 let rule_style = self.styles.blockquote;
842 self.push_line(Line::from(Span::styled(rule_content, rule_style)));
843
844 self.needs_newline = true;
845 }
846
847 fn push_list_marker(&mut self) {
849 if self.list_indices.is_empty() {
851 return;
852 }
853
854 let width = self.list_indices.len().saturating_mul(4).saturating_sub(3);
855 if let Some(last_index) = self.list_indices.last_mut() {
856 let span = match last_index {
857 None => Span::styled(
858 " ".repeat(width.saturating_sub(1)) + "- ",
859 self.styles.list_marker,
860 ),
861 Some(index) => {
862 *index += 1;
863 Span::styled(format!("{:width$}. ", *index - 1), self.styles.list_number)
864 }
865 };
866 self.push_span(span);
867 }
868 }
869
870 fn task_list_marker(&mut self, checked: bool) {
872 if self.list_indices.is_empty() {
874 return;
875 }
876
877 let width = self.list_indices.len().saturating_mul(4).saturating_sub(3);
879 let indent = " ".repeat(width.saturating_sub(1));
880
881 let checkbox = if checked { "[✓] " } else { "[ ] " };
883
884 let style = if checked {
886 self.styles.task_checked
887 } else {
888 self.styles.task_unchecked
889 };
890
891 let span = Span::styled(format!("{indent}- {checkbox}"), style);
892 self.push_span(span);
893
894 self.in_list_item_start = false;
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use crate::tui::theme::Theme;
903 use pulldown_cmark::{Event, Options, Parser};
904
905 #[test]
906 fn test_table_parsing() {
907 let markdown = r#"## Test Results Table
908
909| Test Suite | Status | Passed | Failed | Skipped | Duration |
910|------------|--------|--------|--------|---------|----------|
911| Unit Tests | ✅ | 247 | 0 | 3 | 2m 15s |
912| Integration Tests | ✅ | 89 | 0 | 1 | 5m 42s |"#;
913
914 let mut options = Options::empty();
915 options.insert(Options::ENABLE_TABLES);
916 let parser = Parser::new_ext(markdown, options);
917
918 println!("=== Parser Events ===");
919 for (idx, event) in parser.enumerate() {
920 match &event {
921 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
922 Event::End(tag) => println!("{idx}: End {tag:?}"),
923 Event::Text(text) => println!("{idx}: Text: '{text}'"),
924 _ => println!("{idx}: {event:?}"),
925 }
926 }
927 }
928
929 #[test]
930 fn test_simple_table() {
931 let markdown = r#"| Col1 | Col2 |
932|------|------|
933| A | B |"#;
934
935 let mut options = Options::empty();
936 options.insert(Options::ENABLE_TABLES);
937 let parser = Parser::new_ext(markdown, options);
938
939 println!("\n=== Simple Table Events ===");
940 for (idx, event) in parser.enumerate() {
941 match &event {
942 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
943 Event::End(tag) => println!("{idx}: End {tag:?}"),
944 Event::Text(text) => println!("{idx}: Text: '{text}'"),
945 _ => println!("{idx}: {event:?}"),
946 }
947 }
948 }
949
950 #[test]
951 fn test_table_rendering() {
952 let markdown = r#"## Test Results Table
953
954| Test Suite | Status | Passed | Failed | Skipped | Duration |
955|------------|--------|--------|--------|---------|----------|
956| Unit Tests | ✅ | 247 | 0 | 3 | 2m 15s |
957| Integration Tests | ✅ | 89 | 0 | 1 | 5m 42s |"#;
958
959 let theme = Theme::default();
961 let styles = MarkdownStyles::from_theme(&theme);
962 let rendered = from_str(markdown, &styles, &theme);
963
964 println!("\n=== Rendered Output ===");
965 for (idx, line) in rendered.lines.iter().enumerate() {
966 let line_text: String = line
967 .line
968 .spans
969 .iter()
970 .map(|span| span.content.as_ref())
971 .collect();
972 println!("Line {idx}: '{line_text}'");
973 }
974 }
975
976 #[test]
977 fn test_table_alignment() {
978 let markdown = r#"| Left | Center | Right |
979|:-----|:------:|------:|
980| L | C | R |
981| Long Left Text | Centered | Right Aligned |"#;
982
983 let mut options = Options::empty();
984 options.insert(Options::ENABLE_TABLES);
985 let parser = Parser::new_ext(markdown, options);
986
987 println!("\n=== Alignment Test Events ===");
988 for event in parser {
989 if let Event::Start(Tag::Table(alignments)) = &event {
990 println!("Table alignments: {alignments:?}");
991 }
992 }
993
994 let theme = Theme::default();
996 let styles = MarkdownStyles::from_theme(&theme);
997 let rendered = from_str(markdown, &styles, &theme);
998
999 println!("\n=== Rendered Table with Alignment ===");
1000 for (idx, line) in rendered.lines.iter().enumerate() {
1001 let line_text: String = line
1002 .line
1003 .spans
1004 .iter()
1005 .map(|span| span.content.as_ref())
1006 .collect();
1007 println!("Line {idx}: '{line_text}'");
1008 }
1009 }
1010
1011 #[test]
1012 fn test_table_edge_cases() {
1013 let markdown = r#"| Empty | Unicode | Mixed |
1014|-------|---------|-------|
1015| | 你好 🌍 | Test |
1016| A | | 123 |
1017| | | |"#;
1018
1019 let theme = Theme::default();
1020 let styles = MarkdownStyles::from_theme(&theme);
1021 let rendered = from_str(markdown, &styles, &theme);
1022
1023 println!("\n=== Table with Edge Cases ===");
1024 for (idx, line) in rendered.lines.iter().enumerate() {
1025 let line_text: String = line
1026 .line
1027 .spans
1028 .iter()
1029 .map(|span| span.content.as_ref())
1030 .collect();
1031 println!("Line {idx}: '{line_text}'");
1032 }
1033
1034 assert!(!rendered.lines.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_table_with_star_emojis() {
1040 let markdown = r#"## Complex Data Table
1041
1042| ID | Product | Price | Stock | Category | Rating |
1043|-----|---------------|---------|-------|--------------|--------|
1044| 001 | MacBook Pro | $2,399 | 12 | Electronics | ⭐⭐⭐⭐⭐ |
1045| 002 | Coffee Mug | $15.99 | 250 | Kitchen | ⭐⭐⭐⭐ |
1046| 003 | Desk Chair | $299.00 | 5 | Furniture | ⭐⭐⭐ |"#;
1047
1048 let theme = Theme::default();
1049 let styles = MarkdownStyles::from_theme(&theme);
1050 let rendered = from_str(markdown, &styles, &theme);
1051
1052 println!("\n=== Table with Star Emojis ===");
1053 for (idx, line) in rendered.lines.iter().enumerate() {
1054 let line_text: String = line
1055 .line
1056 .spans
1057 .iter()
1058 .map(|span| span.content.as_ref())
1059 .collect();
1060 println!("Line {idx}: '{line_text}'");
1061 }
1062 }
1063
1064 #[test]
1065 fn test_line_breaks() {
1066 let markdown = r#"This is a line with a hard break
1067at the end.
1068
1069This is a soft break
1070that should become a space.
1071
1072Multiple
1073soft
1074breaks
1075in
1076a
1077row."#;
1078
1079 let theme = Theme::default();
1080 let styles = MarkdownStyles::from_theme(&theme);
1081 let rendered = from_str(markdown, &styles, &theme);
1082
1083 println!("\n=== Line Breaks Test ===");
1084 for (idx, line) in rendered.lines.iter().enumerate() {
1085 let line_text: String = line
1086 .line
1087 .spans
1088 .iter()
1089 .map(|span| span.content.as_ref())
1090 .collect();
1091 println!("Line {idx}: '{line_text}'");
1092 }
1093 }
1094
1095 #[test]
1096 fn test_horizontal_rules() {
1097 let markdown = r#"Some text before
1098
1099---
1100
1101Some text after
1102
1103* * *
1104
1105Another section
1106
1107___
1108
1109Final section"#;
1110
1111 let theme = Theme::default();
1112 let styles = MarkdownStyles::from_theme(&theme);
1113 let rendered = from_str(markdown, &styles, &theme);
1114
1115 println!("\n=== Horizontal Rules Test ===");
1116 for (idx, line) in rendered.lines.iter().enumerate() {
1117 let line_text: String = line
1118 .line
1119 .spans
1120 .iter()
1121 .map(|span| span.content.as_ref())
1122 .collect();
1123 println!("Line {idx}: '{line_text}'");
1124 }
1125
1126 let has_rule = rendered.lines.iter().any(|line| {
1128 line.line
1129 .spans
1130 .iter()
1131 .any(|span| span.content.contains("─"))
1132 });
1133 assert!(has_rule, "Should contain horizontal rules");
1134 }
1135
1136 #[test]
1137 fn test_task_lists() {
1138 let markdown = r#"## Todo List
1139
1140- [x] Complete the parser implementation
1141- [ ] Add more tests
1142- [x] Write documentation
1143- [ ] Review code
1144
1145Regular list items:
1146- Item 1
1147- Item 2
1148
1149Mixed list:
11501. [x] First task (done)
11512. [ ] Second task (pending)
11523. Regular numbered item"#;
1153
1154 let theme = Theme::default();
1155 let styles = MarkdownStyles::from_theme(&theme);
1156 let rendered = from_str(markdown, &styles, &theme);
1157
1158 println!("\n=== Task Lists Test ===");
1159 for (idx, line) in rendered.lines.iter().enumerate() {
1160 let line_text: String = line
1161 .line
1162 .spans
1163 .iter()
1164 .map(|span| span.content.as_ref())
1165 .collect();
1166 println!("Line {idx}: '{line_text}'");
1167 }
1168
1169 let has_checked = rendered.lines.iter().any(|line| {
1171 line.line
1172 .spans
1173 .iter()
1174 .any(|span| span.content.contains("[✓]"))
1175 });
1176 let has_unchecked = rendered.lines.iter().any(|line| {
1177 line.line
1178 .spans
1179 .iter()
1180 .any(|span| span.content.contains("[ ]"))
1181 });
1182 assert!(has_checked, "Should contain checked checkboxes");
1183 assert!(has_unchecked, "Should contain unchecked checkboxes");
1184 }
1185
1186 #[test]
1187 fn test_empty_list_items() {
1188 let markdown = r#"Empty list items:
1190-
1191- Item with content
1192-
1193- Another item
1194
1195Empty numbered items:
11961.
11972. Content here
11983. "#;
1199
1200 let theme = Theme::default();
1201 let styles = MarkdownStyles::from_theme(&theme);
1202 let rendered = from_str(markdown, &styles, &theme);
1203
1204 println!("\n=== Empty List Items Test ===");
1205 for (idx, line) in rendered.lines.iter().enumerate() {
1206 let line_text: String = line
1207 .line
1208 .spans
1209 .iter()
1210 .map(|span| span.content.as_ref())
1211 .collect();
1212 println!("Line {idx}: '{line_text}'");
1213 }
1214
1215 assert!(!rendered.lines.is_empty());
1217 }
1218
1219 #[test]
1220 fn test_malformed_lists() {
1221 let markdown = r#"List interrupted by other content:
1223- Item 1
1224This is a paragraph, not in the list
1225- Item 2
1226
1227Nested list edge cases:
1228- Outer item
1229 - Inner item
1230 Some text here
1231- Back to outer
1232
1233Task list edge cases:
1234- [ ]
1235- [x] Task with content
1236- [ ]
1237
1238Mixed content:
12391. [ ] Task in numbered list
1240Regular text
12412. Another item"#;
1242
1243 let theme = Theme::default();
1244 let styles = MarkdownStyles::from_theme(&theme);
1245 let rendered = from_str(markdown, &styles, &theme);
1246
1247 println!("\n=== Malformed Lists Test ===");
1248 for (idx, line) in rendered.lines.iter().enumerate() {
1249 let line_text: String = line
1250 .line
1251 .spans
1252 .iter()
1253 .map(|span| span.content.as_ref())
1254 .collect();
1255 println!("Line {idx}: '{line_text}'");
1256 }
1257
1258 assert!(!rendered.lines.is_empty());
1260 }
1261
1262 #[test]
1263 fn test_state_tracking_debug() {
1264 let markdown = r#"- Item 1
1266-
1267- [ ] Task item
1268-
1269Regular paragraph
1270
1271- New list"#;
1272
1273 let mut options = Options::empty();
1274 options.insert(Options::ENABLE_TASKLISTS);
1275 let parser = Parser::new_ext(markdown, options);
1276
1277 println!("\n=== State Tracking Debug ===");
1278
1279 let theme = Theme::default();
1280 let styles = MarkdownStyles::from_theme(&theme);
1281 let mut writer = TextWriter::new(parser, &styles, &theme);
1282
1283 let parser = Parser::new_ext(markdown, options);
1285 for (idx, event) in parser.enumerate() {
1286 println!("Event {idx}: {event:?}");
1287 println!(" list_indices.len() = {}", writer.list_indices.len());
1288 println!(" in_list_item_start = {}", writer.in_list_item_start);
1289 writer.handle_event(event);
1290 }
1291
1292 println!("\nFinal state:");
1294 println!(" list_indices.len() = {}", writer.list_indices.len());
1295 println!(" in_list_item_start = {}", writer.in_list_item_start);
1296
1297 assert_eq!(
1298 writer.list_indices.len(),
1299 0,
1300 "list_indices should be empty at end"
1301 );
1302 assert!(
1303 !writer.in_list_item_start,
1304 "in_list_item_start should be false at end"
1305 );
1306 }
1307
1308 #[test]
1309 fn test_syntax_highlighting() {
1310 let markdown = r#"```rust
1311fn main() {
1312 let x = 42;
1313 println!("Hello, world! {}", x);
1314}
1315```
1316
1317```python
1318def hello():
1319 print("Hello from Python")
1320 return 42
1321```"#;
1322 use syntect::highlighting::ThemeSet;
1324 let theme_set = ThemeSet::load_defaults();
1325 let theme = Theme {
1326 syntax_theme: theme_set.themes.get("base16-ocean.dark").cloned(),
1327 ..Default::default()
1328 };
1329
1330 let styles = MarkdownStyles::from_theme(&theme);
1331 let rendered = from_str(markdown, &styles, &theme);
1332
1333 println!("\n=== Syntax Highlighting Test ===");
1334 for (idx, line) in rendered.lines.iter().enumerate() {
1335 println!("Line {}: {} spans", idx, line.line.spans.len());
1336 for (span_idx, span) in line.line.spans.iter().enumerate() {
1337 println!(
1338 " Span {}: '{}' (fg: {:?})",
1339 span_idx,
1340 span.content.as_ref(),
1341 span.style.fg
1342 );
1343 }
1344 }
1345
1346 let has_multiple_spans = rendered.lines.iter().any(|line| line.line.spans.len() > 1);
1349
1350 assert!(
1351 has_multiple_spans,
1352 "Should have lines with multiple colored spans when syntax highlighting is enabled"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_numbered_list_with_formatting() {
1358 let markdown = r#"### TUI State Management: A Broader View
1359
1360The TUI's state architecture is a well-defined, multi-layered system that separates data, UI state, and asynchronous process management.
1361
13621. **MessageViewModel**: This is the central nervous system of the TUI's state.
13632. **ChatStore**: This is the canonical data store for the conversation history.
1364 - **Responsibility**: Holds the ground truth of what should be displayed
1365 - **Key Feature**: Its prune_to_thread method is critical
13663. **ToolCallRegistry**: This is the asynchronous state machine.
13674. **ChatListState**: This is the pure UI view state.
1368
1369Also test with other inline formatting:
13701. *Emphasized text*: Should work with emphasis
13712. ~~Strikethrough text~~: Should work with strikethrough
13723. [Link text](https://example.com): Should work with links
13734. `Code text`: Should work with inline code"#;
1374
1375 let theme = Theme::default();
1376 let styles = MarkdownStyles::from_theme(&theme);
1377 let rendered = from_str(markdown, &styles, &theme);
1378
1379 println!("\n=== Numbered List with Formatting Test ===");
1380 for (idx, line) in rendered.lines.iter().enumerate() {
1381 let line_text: String = line
1382 .line
1383 .spans
1384 .iter()
1385 .map(|span| span.content.as_ref())
1386 .collect();
1387 println!("Line {idx}: '{line_text}'");
1388 }
1389
1390 let has_correct_format = rendered.lines.iter().any(|line| {
1392 let line_text: String = line
1393 .line
1394 .spans
1395 .iter()
1396 .map(|span| span.content.as_ref())
1397 .collect();
1398 line_text.starts_with("1. ") && line_text.contains("MessageViewModel")
1399 });
1400
1401 assert!(
1402 has_correct_format,
1403 "Numbered list with bold text should be formatted as '1. **MessageViewModel**:' not 'MessageViewModel1.'"
1404 );
1405
1406 let has_incorrect_format = rendered.lines.iter().any(|line| {
1408 let line_text: String = line
1409 .line
1410 .spans
1411 .iter()
1412 .map(|span| span.content.as_ref())
1413 .collect();
1414 line_text.contains("MessageViewModel1.")
1415 });
1416
1417 assert!(
1418 !has_incorrect_format,
1419 "Should not have 'MessageViewModel1.' in the output"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_list_item_bullet_rendering() {
1425 let markdown = r#"3. A ChatItem is visible when:
1426• it is a Message whose id is in lineage, or
1427• it has a parent_chat_item_id that (recursively) leads to a Message whose id is in lineage."#;
1428
1429 let options = Options::empty();
1431 let parser = Parser::new_ext(markdown, options);
1432
1433 println!("\n=== Parser Events for Bullet List ===");
1434 for (idx, event) in parser.enumerate() {
1435 match &event {
1436 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
1437 Event::End(tag) => println!("{idx}: End {tag:?}"),
1438 Event::Text(text) => println!("{idx}: Text: '{text}'"),
1439 Event::SoftBreak => println!("{idx}: SoftBreak"),
1440 Event::HardBreak => println!("{idx}: HardBreak"),
1441 _ => println!("{idx}: {event:?}"),
1442 }
1443 }
1444
1445 let theme = Theme::default();
1446 let styles = MarkdownStyles::from_theme(&theme);
1447 let rendered = from_str(markdown, &styles, &theme);
1448
1449 println!("\n=== List Item Bullet Rendering Test ===");
1450 for (idx, line) in rendered.lines.iter().enumerate() {
1451 let line_text: String = line
1452 .line
1453 .spans
1454 .iter()
1455 .map(|span| span.content.as_ref())
1456 .collect();
1457 println!("Line {idx}: '{line_text}'");
1458 }
1459
1460 let lines: Vec<String> = rendered
1462 .lines
1463 .iter()
1464 .map(|line| {
1465 line.line
1466 .spans
1467 .iter()
1468 .map(|span| span.content.as_ref())
1469 .collect()
1470 })
1471 .collect();
1472
1473 assert!(
1475 lines.len() >= 3,
1476 "Should have at least 3 lines for the list and bullets"
1477 );
1478
1479 assert!(
1481 lines[0].starts_with("3."),
1482 "First line should start with '3.'"
1483 );
1484 }
1485
1486 #[test]
1487 fn test_nested_list_with_soft_breaks() {
1488 let markdown = r#"1. First level item
1489 - Nested bullet one
1490 - Nested bullet two
1491 with continuation
1492 - Nested bullet three
14932. Second level item"#;
1494
1495 let theme = Theme::default();
1496 let styles = MarkdownStyles::from_theme(&theme);
1497 let rendered = from_str(markdown, &styles, &theme);
1498
1499 println!("\n=== Nested List with Soft Breaks Test ===");
1500 for (idx, line) in rendered.lines.iter().enumerate() {
1501 let line_text: String = line
1502 .line
1503 .spans
1504 .iter()
1505 .map(|span| span.content.as_ref())
1506 .collect();
1507 println!("Line {idx}: '{line_text}'");
1508 }
1509
1510 let lines: Vec<String> = rendered
1511 .lines
1512 .iter()
1513 .map(|line| {
1514 line.line
1515 .spans
1516 .iter()
1517 .map(|span| span.content.as_ref())
1518 .collect()
1519 })
1520 .collect();
1521
1522 assert!(
1524 lines.len() >= 6,
1525 "Should have at least 6 lines for nested list"
1526 );
1527
1528 let nested_lines: Vec<&String> = lines
1530 .iter()
1531 .filter(|line| line.trim_start().starts_with('-'))
1532 .collect();
1533 assert!(
1534 nested_lines.len() >= 3,
1535 "Should have at least 3 nested bullet points"
1536 );
1537 }
1538}