1use itertools::{Itertools, Position};
7use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag};
8use ratatui::style::{Color, Style};
9use ratatui::text::{Line, Span};
10use syntect::easy::HighlightLines;
11use syntect::parsing::SyntaxSet;
12use syntect::util::LinesWithEndings;
13use tracing::{debug, instrument, warn};
14use unicode_width::UnicodeWidthStr;
15
16use crate::tui::theme::{Component, Theme};
17
18static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
20 std::sync::LazyLock::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, pub indent_level: usize, }
39
40impl MarkedLine {
41 pub fn new(line: Line<'static>) -> Self {
42 Self {
43 line,
44 no_wrap: false,
45 indent_level: 0,
46 }
47 }
48
49 pub fn new_no_wrap(line: Line<'static>) -> Self {
50 Self {
51 line,
52 no_wrap: true,
53 indent_level: 0,
54 }
55 }
56
57 pub fn with_indent(mut self, indent: usize) -> Self {
58 self.indent_level = indent;
59 self
60 }
61}
62
63#[derive(Debug, Default)]
65pub struct MarkedText {
66 pub lines: Vec<MarkedLine>,
67}
68
69impl MarkedText {
70 pub fn height(&self) -> usize {
71 self.lines.len()
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct MarkdownStyles {
78 pub h1: Style,
79 pub h2: Style,
80 pub h3: Style,
81 pub h4: Style,
82 pub h5: Style,
83 pub h6: Style,
84 pub emphasis: Style,
85 pub strong: Style,
86 pub strikethrough: Style,
87 pub blockquote: Style,
88 pub code: Style,
89 pub code_block: Style,
90 pub link: Style,
91 pub list_marker: Style,
92 pub list_number: Style,
93 pub table_border: Style,
94 pub table_header: Style,
95 pub table_cell: Style,
96 pub task_checked: Style,
97 pub task_unchecked: Style,
98}
99
100impl MarkdownStyles {
101 pub fn from_theme(theme: &Theme) -> Self {
103 use ratatui::style::Modifier;
104
105 Self {
106 h1: theme
108 .style(Component::MarkdownH1)
109 .add_modifier(Modifier::BOLD | Modifier::REVERSED),
110 h2: theme
111 .style(Component::MarkdownH2)
112 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
113 h3: theme
114 .style(Component::MarkdownH3)
115 .add_modifier(Modifier::BOLD),
116 h4: theme
117 .style(Component::MarkdownH4)
118 .add_modifier(Modifier::UNDERLINED),
119 h5: theme
120 .style(Component::MarkdownH5)
121 .add_modifier(Modifier::ITALIC),
122 h6: theme
123 .style(Component::MarkdownH6)
124 .add_modifier(Modifier::ITALIC),
125
126 emphasis: Style::default().add_modifier(Modifier::ITALIC),
128 strong: Style::default().add_modifier(Modifier::BOLD),
129 strikethrough: Style::default().add_modifier(Modifier::CROSSED_OUT),
130
131 blockquote: theme
133 .style(Component::MarkdownBlockquote)
134 .add_modifier(Modifier::ITALIC),
135 code: theme.style(Component::MarkdownCode),
136 code_block: theme.style(Component::MarkdownCodeBlock),
137 link: theme
138 .style(Component::MarkdownLink)
139 .add_modifier(Modifier::UNDERLINED),
140 list_marker: theme.style(Component::MarkdownListBullet),
141 list_number: theme.style(Component::MarkdownListNumber),
142 table_border: theme.style(Component::MarkdownTableBorder),
143 table_header: theme.style(Component::MarkdownTableHeader),
144 table_cell: theme.style(Component::MarkdownTableCell),
145 task_checked: theme.style(Component::MarkdownTaskChecked),
146 task_unchecked: theme.style(Component::MarkdownTaskUnchecked),
147 }
148 }
149}
150
151pub fn from_str(input: &str, styles: &MarkdownStyles, theme: &Theme) -> MarkedText {
152 from_str_with_width(input, styles, theme, None)
153}
154
155pub fn from_str_with_width(
156 input: &str,
157 styles: &MarkdownStyles,
158 theme: &Theme,
159 terminal_width: Option<u16>,
160) -> MarkedText {
161 let mut options = Options::empty();
162 options.insert(Options::ENABLE_STRIKETHROUGH);
163 options.insert(Options::ENABLE_TABLES);
164 options.insert(Options::ENABLE_FOOTNOTES);
165 options.insert(Options::ENABLE_TASKLISTS);
166 options.insert(Options::ENABLE_SMART_PUNCTUATION);
167 let parser = Parser::new_ext(input, options);
168 let mut writer = TextWriter::new(parser, styles, theme);
169 writer.terminal_width = terminal_width;
170 writer.run();
171 writer.marked_text
172}
173
174struct TextWriter<'a, I> {
175 iter: I,
177
178 marked_text: MarkedText,
180
181 inline_styles: Vec<Style>,
185
186 line_prefixes: Vec<Span<'a>>,
188
189 line_styles: Vec<Style>,
191
192 list_indices: Vec<Option<u64>>,
194
195 link: Option<CowStr<'a>>,
197
198 needs_newline: bool,
199
200 styles: &'a MarkdownStyles,
202
203 theme: &'a Theme,
205
206 table_alignments: Vec<pulldown_cmark::Alignment>,
208 table_rows: Vec<Vec<Vec<Span<'a>>>>, in_table_header: bool,
210
211 in_list_item_start: bool,
213
214 in_code_block: bool,
216
217 code_block_language: Option<String>,
219
220 terminal_width: Option<u16>,
222
223 list_item_indent: usize,
225}
226
227impl<'a, I> TextWriter<'a, I>
228where
229 I: Iterator<Item = Event<'a>>,
230{
231 fn new(iter: I, styles: &'a MarkdownStyles, theme: &'a Theme) -> Self {
232 Self {
233 iter,
234 marked_text: MarkedText::default(),
235 inline_styles: vec![],
236 line_styles: vec![],
237 line_prefixes: vec![],
238 list_indices: vec![],
239 needs_newline: false,
240 link: None,
241 styles,
242 theme,
243 table_alignments: Vec::new(),
244 table_rows: Vec::new(),
245 in_table_header: false,
246 in_list_item_start: false,
247 in_code_block: false,
248 code_block_language: None,
249 terminal_width: None,
250 list_item_indent: 0,
251 }
252 }
253
254 fn run(&mut self) {
255 debug!("Running text writer");
256 while let Some(event) = self.iter.next() {
257 self.handle_event(event);
258 }
259 }
260
261 fn handle_event(&mut self, event: Event<'a>) {
262 match event {
263 Event::Start(tag) => self.start_tag(tag),
264 Event::End(tag) => self.end_tag(tag),
265 Event::Text(text) => self.text(text),
266 Event::Code(code) => self.code(code),
267 Event::Html(html) => {
268 warn!("Rich html not yet supported: {}", html);
269 self.text(html);
270 }
271 Event::FootnoteReference(reference) => {
272 warn!("Footnote reference not yet supported: {}", reference);
273 self.text(reference);
274 }
275 Event::SoftBreak => self.soft_break(),
276 Event::HardBreak => self.hard_break(),
277 Event::Rule => self.rule(),
278 Event::TaskListMarker(checked) => self.task_list_marker(checked),
279 }
280 }
281
282 fn start_tag(&mut self, tag: Tag<'a>) {
283 match tag {
284 Tag::Paragraph => self.start_paragraph(),
285 Tag::Heading(level, _, _) => self.start_heading(level),
286 Tag::BlockQuote => self.start_blockquote(),
287 Tag::CodeBlock(kind) => self.start_codeblock(kind),
288 Tag::List(start_index) => self.start_list(start_index),
289 Tag::Item => self.start_item(),
290 Tag::FootnoteDefinition(_) => warn!("Footnote definition not yet supported"),
291 Tag::Table(alignments) => self.start_table(alignments),
292 Tag::TableHead => self.start_table_head(),
293 Tag::TableRow => self.start_table_row(),
294 Tag::TableCell => self.start_table_cell(),
295 Tag::Emphasis | Tag::Strong | Tag::Strikethrough => {
296 if self.in_list_item_start {
298 self.push_list_marker();
299 self.in_list_item_start = false;
300 }
301
302 match tag {
303 Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
304 Tag::Strong => self.push_inline_style(self.styles.strong),
305 Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
306 _ => unreachable!(),
307 }
308 }
309 Tag::Link(_link_type, dest_url, _title) => {
310 if self.in_list_item_start {
312 self.push_list_marker();
313 self.in_list_item_start = false;
314 }
315 self.push_link(dest_url);
316 }
317 Tag::Image(_link_type, _dest_url, _title) => warn!("Image not yet supported"),
318 }
319 }
320
321 fn end_tag(&mut self, tag: Tag<'a>) {
322 match tag {
323 Tag::Paragraph => self.end_paragraph(),
324 Tag::Heading(..) => self.end_heading(),
325 Tag::BlockQuote => self.end_blockquote(),
326 Tag::CodeBlock(_) => self.end_codeblock(),
327 Tag::List(_) => self.end_list(),
328 Tag::Item => self.end_item(),
329 Tag::FootnoteDefinition(_) => {}
330 Tag::Table(_) => self.end_table(),
331 Tag::TableHead => self.end_table_head(),
332 Tag::TableRow => Self::end_table_row(),
333 Tag::TableCell => Self::end_table_cell(),
334 Tag::Emphasis => self.pop_inline_style(),
335 Tag::Strong => self.pop_inline_style(),
336 Tag::Strikethrough => self.pop_inline_style(),
337 Tag::Link(..) => self.pop_link(),
338 Tag::Image(..) => {}
339 }
340 }
341
342 fn start_paragraph(&mut self) {
343 if self.needs_newline {
345 self.push_line(Line::default());
346 }
347 self.push_line(Line::default());
348 self.needs_newline = false;
349 }
350
351 fn end_paragraph(&mut self) {
352 self.needs_newline = true;
353 }
354
355 fn start_heading(&mut self, level: HeadingLevel) {
356 if self.needs_newline {
357 self.push_line(Line::default());
358 }
359 let style = match level {
360 HeadingLevel::H1 => self.styles.h1,
361 HeadingLevel::H2 => self.styles.h2,
362 HeadingLevel::H3 => self.styles.h3,
363 HeadingLevel::H4 => self.styles.h4,
364 HeadingLevel::H5 => self.styles.h5,
365 HeadingLevel::H6 => self.styles.h6,
366 };
367 self.push_inline_style(style);
369
370 let content = format!("{} ", "#".repeat(level as usize));
371 self.push_line(Line::styled(content, style));
372 self.needs_newline = false;
373 }
374
375 fn end_heading(&mut self) {
376 self.pop_inline_style();
378 self.needs_newline = true;
379 }
380
381 fn start_blockquote(&mut self) {
382 if self.needs_newline {
383 self.push_line(Line::default());
384 self.needs_newline = false;
385 }
386 self.line_prefixes.push(Span::from(">"));
387 self.line_styles.push(self.styles.blockquote);
388 }
389
390 fn end_blockquote(&mut self) {
391 self.line_prefixes.pop();
392 self.line_styles.pop();
393 self.needs_newline = true;
394 }
395
396 fn text(&mut self, text: CowStr<'a>) {
397 if self.in_list_item_start {
400 self.push_list_marker();
401 self.in_list_item_start = false;
402 }
403
404 let in_table = self.table_rows.last().and_then(|row| row.last()).is_some();
406
407 if in_table {
408 let style = self.inline_styles.last().copied().unwrap_or_default();
410 let span = Span::styled(text.to_string(), style);
411 self.push_span(span);
412 } else if self.in_code_block {
413 let base_style = self
415 .inline_styles
416 .last()
417 .copied()
418 .unwrap_or_default()
419 .patch(self.styles.code_block);
420
421 let use_highlighting =
423 self.code_block_language.is_some() && self.theme.syntax_theme.is_some();
424
425 if use_highlighting {
426 let lang = if let Some(lang) = self.code_block_language.as_ref() {
427 lang
428 } else {
429 self.needs_newline = text.ends_with('\n');
430 return;
431 };
432 let Some(syntax_theme) = self.theme.syntax_theme.as_ref() else {
433 self.needs_newline = text.ends_with('\n');
434 return;
435 };
436
437 let syntax = SYNTAX_SET
439 .find_syntax_by_token(lang)
440 .or_else(|| SYNTAX_SET.find_syntax_by_extension(lang))
441 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
442
443 let mut highlighter = HighlightLines::new(syntax, syntax_theme);
444
445 for (line_idx, line) in LinesWithEndings::from(text.as_ref()).enumerate() {
447 if line_idx > 0 || self.needs_newline {
448 self.push_line(Line::default());
449 }
450
451 let highlighted = highlighter
453 .highlight_line(line, &SYNTAX_SET)
454 .unwrap_or_else(|_| vec![(syntect::highlighting::Style::default(), line)]);
455
456 for (style, text) in highlighted {
458 let ratatui_style = syntect_style_to_ratatui(style).patch(base_style);
459 let span = Span::styled(text.to_string(), ratatui_style);
460 self.push_span(span);
461 }
462 }
463
464 self.needs_newline = text.ends_with('\n');
466 } else {
467 let lines: Vec<&str> = text.as_ref().lines().collect();
469 for (idx, line) in lines.iter().enumerate() {
470 if idx > 0 || self.needs_newline {
471 self.push_line(Line::default());
472 }
473
474 let span = Span::styled((*line).to_string(), base_style);
476 self.push_span(span);
477 }
478
479 self.needs_newline = text.ends_with('\n') && !lines.is_empty();
481 }
482 } else {
483 for (position, line) in text.lines().with_position() {
485 if self.needs_newline {
486 self.push_line(Line::default());
487 self.needs_newline = false;
488 }
489 if matches!(position, Position::Middle | Position::Last) {
490 self.push_line(Line::default());
491 }
492
493 let style = self.inline_styles.last().copied().unwrap_or_default();
494 let span = Span::styled(line.to_owned(), style);
495 self.push_span(span);
496 }
497 self.needs_newline = false;
498 }
499 }
500
501 fn code(&mut self, code: CowStr<'a>) {
502 if self.in_list_item_start {
504 self.push_list_marker();
505 self.in_list_item_start = false;
506 }
507
508 let span = Span::styled(code, self.styles.code);
509 self.push_span(span);
510 }
511
512 fn hard_break(&mut self) {
513 self.push_span(" ".into()); self.push_line(Line::default());
516 }
517
518 fn start_list(&mut self, index: Option<u64>) {
519 if self.list_indices.is_empty() && self.needs_newline {
520 self.push_line(Line::default());
521 }
522 self.list_indices.push(index);
523 }
524
525 fn end_list(&mut self) {
526 self.list_indices.pop();
527 self.needs_newline = true;
528 }
529
530 fn start_item(&mut self) {
531 self.push_line(Line::default());
532 self.in_list_item_start = true;
534 self.list_item_indent = 0;
537 self.needs_newline = false;
539 }
540
541 fn end_item(&mut self) {
542 if self.in_list_item_start {
545 self.push_list_marker();
546 self.in_list_item_start = false;
547 }
548 }
549
550 fn soft_break(&mut self) {
551 self.push_line(Line::default());
553 }
554
555 fn start_codeblock(&mut self, kind: CodeBlockKind<'_>) {
556 if !self.marked_text.lines.is_empty() {
557 self.push_line(Line::default());
558 }
559
560 self.in_code_block = true;
562
563 self.code_block_language = match kind {
565 CodeBlockKind::Fenced(lang) => {
566 let lang_str = lang.as_ref();
567 if lang_str.is_empty() {
568 None
569 } else {
570 Some(lang_str.to_string())
571 }
572 }
573 CodeBlockKind::Indented => None,
574 };
575
576 self.line_styles.push(self.styles.code_block);
577 self.needs_newline = false;
578 }
579
580 fn end_codeblock(&mut self) {
581 self.in_code_block = false;
583 self.code_block_language = None;
584
585 self.needs_newline = true;
586 self.line_styles.pop();
587 }
588
589 #[instrument(level = "trace", skip(self))]
590 fn push_inline_style(&mut self, style: Style) {
591 let current_style = self.inline_styles.last().copied().unwrap_or_default();
592 let style = current_style.patch(style);
593 self.inline_styles.push(style);
594 debug!("Pushed inline style: {:?}", style);
595 debug!("Current inline styles: {:?}", self.inline_styles);
596 }
597
598 #[instrument(level = "trace", skip(self))]
599 fn pop_inline_style(&mut self) {
600 self.inline_styles.pop();
601 }
602
603 #[instrument(level = "trace", skip(self))]
604 fn push_line(&mut self, line: Line<'a>) {
605 let style = self.line_styles.last().copied().unwrap_or_default();
606 let mut line = line.patch_style(style);
607
608 let line_prefixes = self.line_prefixes.iter().cloned().collect_vec();
610 let has_prefixes = !line_prefixes.is_empty();
611 if has_prefixes {
612 line.spans.insert(0, " ".into());
613 }
614 for prefix in line_prefixes.iter().rev().cloned() {
615 line.spans.insert(0, prefix);
616 }
617
618 let static_spans: Vec<Span<'static>> = line
620 .spans
621 .into_iter()
622 .map(|span| Span::styled(span.content.into_owned(), span.style))
623 .collect();
624 let static_line = Line::from(static_spans);
625
626 let marked_line = if self.in_code_block {
628 MarkedLine::new_no_wrap(static_line)
629 } else {
630 let indent = if !self.list_indices.is_empty() && !has_prefixes {
632 self.list_item_indent
633 } else {
634 0
635 };
636 MarkedLine::new(static_line).with_indent(indent)
637 };
638
639 self.marked_text.lines.push(marked_line);
640 }
641
642 #[instrument(level = "trace", skip(self))]
643 fn push_span(&mut self, span: Span<'a>) {
644 let in_table = self.table_rows.last().and_then(|row| row.last()).is_some();
646
647 if in_table {
648 if let Some(current_row) = self.table_rows.last_mut() {
649 if let Some(current_cell) = current_row.last_mut() {
650 current_cell.push(span);
651 } else if let Some(marked_line) = self.marked_text.lines.last_mut() {
652 let static_span = Span::styled(span.content.into_owned(), span.style);
653 marked_line.line.push_span(static_span);
654 } else {
655 self.push_line(Line::from(vec![span]));
656 }
657 } else if let Some(marked_line) = self.marked_text.lines.last_mut() {
658 let static_span = Span::styled(span.content.into_owned(), span.style);
659 marked_line.line.push_span(static_span);
660 } else {
661 self.push_line(Line::from(vec![span]));
662 }
663 } else if let Some(marked_line) = self.marked_text.lines.last_mut() {
664 let static_span = Span::styled(span.content.into_owned(), span.style);
666 marked_line.line.push_span(static_span);
667 } else {
668 self.push_line(Line::from(vec![span]));
669 }
670 }
671
672 #[instrument(level = "trace", skip(self))]
674 fn push_link(&mut self, dest_url: CowStr<'a>) {
675 self.link = Some(dest_url);
676 }
677
678 #[instrument(level = "trace", skip(self))]
680 fn pop_link(&mut self) {
681 if let Some(link) = self.link.take() {
682 self.push_span(" (".into());
683 self.push_span(Span::styled(link, self.styles.link));
684 self.push_span(")".into());
685 }
686 }
687
688 fn start_table(&mut self, alignments: Vec<pulldown_cmark::Alignment>) {
691 if self.needs_newline {
692 self.push_line(Line::default());
693 }
694 self.table_alignments = alignments;
695 self.table_rows.clear();
696 self.needs_newline = false;
697 }
698
699 fn end_table(&mut self) {
700 self.render_table();
701 self.table_alignments.clear();
702 self.table_rows.clear();
703 self.needs_newline = true;
704 }
705
706 fn start_table_head(&mut self) {
707 self.in_table_header = true;
708 self.table_rows.push(Vec::new());
710 }
711
712 fn end_table_head(&mut self) {
713 self.in_table_header = false;
714 }
715
716 fn start_table_row(&mut self) {
717 self.table_rows.push(Vec::new());
718 }
719
720 fn end_table_row() {
721 }
723
724 fn start_table_cell(&mut self) {
725 if let Some(current_row) = self.table_rows.last_mut() {
727 current_row.push(Vec::new());
728 }
729 }
730
731 fn end_table_cell() {
732 }
734
735 fn render_table(&mut self) {
737 if self.table_rows.is_empty() {
738 return;
739 }
740
741 let rows = std::mem::take(&mut self.table_rows);
743
744 let num_cols = self.table_alignments.len();
746 let mut col_widths = vec![0; num_cols];
747
748 for row in &rows {
749 for (col_idx, cell) in row.iter().enumerate() {
750 if col_idx < num_cols {
751 let cell_width = cell
752 .iter()
753 .map(|span| span.content.as_ref().width())
754 .sum::<usize>();
755 col_widths[col_idx] = col_widths[col_idx].max(cell_width);
756 }
757 }
758 }
759
760 for width in &mut col_widths {
762 *width += 2; }
764
765 let border_style = self.styles.table_border;
767 let header_style = self.styles.table_header;
768 let cell_style = self.styles.table_cell;
769
770 self.render_table_border(&col_widths, '┌', '┬', '┐', border_style);
772
773 for (row_idx, row) in rows.iter().enumerate() {
775 let is_header = row_idx == 0 && rows.len() > 1;
776 let mut line_spans = vec![Span::styled("│", border_style)];
777
778 for (col_idx, cell) in row.iter().enumerate() {
779 if col_idx < num_cols {
780 let cell_text: String = cell
782 .iter()
783 .map(|span| span.content.as_ref())
784 .collect::<Vec<_>>()
785 .join("");
786
787 let padded = Self::align_text(
788 &cell_text,
789 col_widths[col_idx],
790 self.table_alignments[col_idx],
791 );
792
793 let style = if is_header { header_style } else { cell_style };
795 line_spans.push(Span::styled(padded, style));
796 line_spans.push(Span::styled("│", border_style));
797 }
798 }
799
800 self.push_line(Line::from(line_spans));
801
802 if is_header {
804 self.render_table_border(&col_widths, '├', '┼', '┤', border_style);
805 }
806 }
807
808 self.render_table_border(&col_widths, '└', '┴', '┘', border_style);
810 }
811
812 fn render_table_border(
814 &mut self,
815 col_widths: &[usize],
816 left: char,
817 mid: char,
818 right: char,
819 style: Style,
820 ) {
821 let mut border = String::from(left);
822
823 for (idx, &width) in col_widths.iter().enumerate() {
824 border.push_str(&"─".repeat(width));
825 if idx < col_widths.len() - 1 {
826 border.push(mid);
827 }
828 }
829
830 border.push(right);
831 self.push_line(Line::from(Span::styled(border, style)));
832 }
833
834 fn align_text(text: &str, width: usize, alignment: pulldown_cmark::Alignment) -> String {
836 let text_len = text.width();
837 let total_padding = width.saturating_sub(text_len);
840
841 match alignment {
842 pulldown_cmark::Alignment::None | pulldown_cmark::Alignment::Left => {
843 let right_padding = total_padding.saturating_sub(1);
845 format!(" {}{}", text, " ".repeat(right_padding))
846 }
847 pulldown_cmark::Alignment::Center => {
848 let left_padding = total_padding / 2;
850 let right_padding = total_padding - left_padding;
851 format!(
852 "{}{}{}",
853 " ".repeat(left_padding),
854 text,
855 " ".repeat(right_padding)
856 )
857 }
858 pulldown_cmark::Alignment::Right => {
859 let left_padding = total_padding.saturating_sub(1);
861 format!("{}{} ", " ".repeat(left_padding), text)
862 }
863 }
864 }
865
866 fn rule(&mut self) {
868 if self.needs_newline {
869 self.push_line(Line::default());
870 }
871
872 let terminal_width = self.terminal_width.unwrap_or(80) as usize;
875 let rule_char = "─"; let rule_content = rule_char.repeat(terminal_width);
877
878 let rule_style = self.styles.blockquote;
880 self.push_line(Line::from(Span::styled(rule_content, rule_style)));
881
882 self.needs_newline = true;
883 }
884
885 fn push_list_marker(&mut self) {
887 if self.list_indices.is_empty() {
889 return;
890 }
891
892 let depth = self.list_indices.len();
893 let indent_width = depth.saturating_sub(1).saturating_mul(4);
894 let indent_str = " ".repeat(indent_width);
895
896 if let Some(last_index) = self.list_indices.last_mut() {
897 let (span, full_marker_width) = match last_index {
898 None => {
899 let full_marker = format!("{indent_str}- ");
901 let width = full_marker.len();
902 (Span::styled(full_marker, self.styles.list_marker), width)
903 }
904 Some(index) => {
905 *index += 1;
907 let full_marker = format!("{}{}. ", indent_str, *index - 1);
908 let width = full_marker.len();
909 (Span::styled(full_marker, self.styles.list_number), width)
910 }
911 };
912
913 self.list_item_indent = full_marker_width;
915 if let Some(current_line) = self.marked_text.lines.last_mut() {
917 current_line.indent_level = self.list_item_indent;
918 }
919 self.push_span(span);
920 }
921 }
922
923 fn task_list_marker(&mut self, checked: bool) {
925 if self.list_indices.is_empty() {
927 return;
928 }
929
930 let depth = self.list_indices.len();
932 let indent_width = depth.saturating_sub(1).saturating_mul(4);
933 let indent_str = " ".repeat(indent_width);
934
935 let checkbox = if checked { "[✓] " } else { "[ ] " };
937
938 let style = if checked {
940 self.styles.task_checked
941 } else {
942 self.styles.task_unchecked
943 };
944
945 let full_marker = format!("{indent_str}- {checkbox}");
946 let marker_width = full_marker.len();
947
948 self.list_item_indent = marker_width;
950
951 let span = Span::styled(full_marker, style);
952 self.push_span(span);
953
954 self.in_list_item_start = false;
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use crate::tui::theme::Theme;
963 use pulldown_cmark::{Event, Options, Parser};
964
965 #[test]
966 fn test_table_parsing() {
967 let markdown = r"## Test Results Table
968
969| Test Suite | Status | Passed | Failed | Skipped | Duration |
970|------------|--------|--------|--------|---------|----------|
971| Unit Tests | ✅ | 247 | 0 | 3 | 2m 15s |
972| Integration Tests | ✅ | 89 | 0 | 1 | 5m 42s |";
973
974 let mut options = Options::empty();
975 options.insert(Options::ENABLE_TABLES);
976 let parser = Parser::new_ext(markdown, options);
977
978 println!("=== Parser Events ===");
979 for (idx, event) in parser.enumerate() {
980 match &event {
981 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
982 Event::End(tag) => println!("{idx}: End {tag:?}"),
983 Event::Text(text) => println!("{idx}: Text: '{text}'"),
984 _ => println!("{idx}: {event:?}"),
985 }
986 }
987 }
988
989 #[test]
990 fn test_simple_table() {
991 let markdown = r"| Col1 | Col2 |
992|------|------|
993| A | B |";
994
995 let mut options = Options::empty();
996 options.insert(Options::ENABLE_TABLES);
997 let parser = Parser::new_ext(markdown, options);
998
999 println!("\n=== Simple Table Events ===");
1000 for (idx, event) in parser.enumerate() {
1001 match &event {
1002 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
1003 Event::End(tag) => println!("{idx}: End {tag:?}"),
1004 Event::Text(text) => println!("{idx}: Text: '{text}'"),
1005 _ => println!("{idx}: {event:?}"),
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn test_table_rendering() {
1012 let markdown = r"## Test Results Table
1013
1014| Test Suite | Status | Passed | Failed | Skipped | Duration |
1015|------------|--------|--------|--------|---------|----------|
1016| Unit Tests | ✅ | 247 | 0 | 3 | 2m 15s |
1017| Integration Tests | ✅ | 89 | 0 | 1 | 5m 42s |";
1018
1019 let theme = Theme::default();
1021 let styles = MarkdownStyles::from_theme(&theme);
1022 let rendered = from_str(markdown, &styles, &theme);
1023
1024 println!("\n=== Rendered Output ===");
1025 for (idx, line) in rendered.lines.iter().enumerate() {
1026 let line_text: String = line
1027 .line
1028 .spans
1029 .iter()
1030 .map(|span| span.content.as_ref())
1031 .collect();
1032 println!("Line {idx}: '{line_text}'");
1033 }
1034 }
1035
1036 #[test]
1037 fn test_table_alignment() {
1038 let markdown = r"| Left | Center | Right |
1039|:-----|:------:|------:|
1040| L | C | R |
1041| Long Left Text | Centered | Right Aligned |";
1042
1043 let mut options = Options::empty();
1044 options.insert(Options::ENABLE_TABLES);
1045 let parser = Parser::new_ext(markdown, options);
1046
1047 println!("\n=== Alignment Test Events ===");
1048 for event in parser {
1049 if let Event::Start(Tag::Table(alignments)) = &event {
1050 println!("Table alignments: {alignments:?}");
1051 }
1052 }
1053
1054 let theme = Theme::default();
1056 let styles = MarkdownStyles::from_theme(&theme);
1057 let rendered = from_str(markdown, &styles, &theme);
1058
1059 println!("\n=== Rendered Table with Alignment ===");
1060 for (idx, line) in rendered.lines.iter().enumerate() {
1061 let line_text: String = line
1062 .line
1063 .spans
1064 .iter()
1065 .map(|span| span.content.as_ref())
1066 .collect();
1067 println!("Line {idx}: '{line_text}'");
1068 }
1069 }
1070
1071 #[test]
1072 fn test_table_edge_cases() {
1073 let markdown = r"| Empty | Unicode | Mixed |
1074|-------|---------|-------|
1075| | 你好 🌍 | Test |
1076| A | | 123 |
1077| | | |";
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=== Table with Edge Cases ===");
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 assert!(!rendered.lines.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_table_with_star_emojis() {
1100 let markdown = r"## Complex Data Table
1101
1102| ID | Product | Price | Stock | Category | Rating |
1103|-----|---------------|---------|-------|--------------|--------|
1104| 001 | MacBook Pro | $2,399 | 12 | Electronics | ⭐⭐⭐⭐⭐ |
1105| 002 | Coffee Mug | $15.99 | 250 | Kitchen | ⭐⭐⭐⭐ |
1106| 003 | Desk Chair | $299.00 | 5 | Furniture | ⭐⭐⭐ |";
1107
1108 let theme = Theme::default();
1109 let styles = MarkdownStyles::from_theme(&theme);
1110 let rendered = from_str(markdown, &styles, &theme);
1111
1112 println!("\n=== Table with Star Emojis ===");
1113 for (idx, line) in rendered.lines.iter().enumerate() {
1114 let line_text: String = line
1115 .line
1116 .spans
1117 .iter()
1118 .map(|span| span.content.as_ref())
1119 .collect();
1120 println!("Line {idx}: '{line_text}'");
1121 }
1122 }
1123
1124 #[test]
1125 fn test_line_breaks() {
1126 let markdown = r"This is a line with a hard break
1127at the end.
1128
1129This is a soft break
1130that should become a space.
1131
1132Multiple
1133soft
1134breaks
1135in
1136a
1137row.";
1138
1139 let theme = Theme::default();
1140 let styles = MarkdownStyles::from_theme(&theme);
1141 let rendered = from_str(markdown, &styles, &theme);
1142
1143 println!("\n=== Line Breaks Test ===");
1144 for (idx, line) in rendered.lines.iter().enumerate() {
1145 let line_text: String = line
1146 .line
1147 .spans
1148 .iter()
1149 .map(|span| span.content.as_ref())
1150 .collect();
1151 println!("Line {idx}: '{line_text}'");
1152 }
1153 }
1154
1155 #[test]
1156 fn test_horizontal_rules() {
1157 let markdown = r"Some text before
1158
1159---
1160
1161Some text after
1162
1163* * *
1164
1165Another section
1166
1167___
1168
1169Final section";
1170
1171 let theme = Theme::default();
1172 let styles = MarkdownStyles::from_theme(&theme);
1173 let rendered = from_str(markdown, &styles, &theme);
1174
1175 println!("\n=== Horizontal Rules Test ===");
1176 for (idx, line) in rendered.lines.iter().enumerate() {
1177 let line_text: String = line
1178 .line
1179 .spans
1180 .iter()
1181 .map(|span| span.content.as_ref())
1182 .collect();
1183 println!("Line {idx}: '{line_text}'");
1184 }
1185
1186 let has_rule = rendered.lines.iter().any(|line| {
1188 line.line
1189 .spans
1190 .iter()
1191 .any(|span| span.content.contains("─"))
1192 });
1193 assert!(has_rule, "Should contain horizontal rules");
1194 }
1195
1196 #[test]
1197 fn test_task_lists() {
1198 let markdown = r"## Todo List
1199
1200- [x] Complete the parser implementation
1201- [ ] Add more tests
1202- [x] Write documentation
1203- [ ] Review code
1204
1205Regular list items:
1206- Item 1
1207- Item 2
1208
1209Mixed list:
12101. [x] First task (done)
12112. [ ] Second task (pending)
12123. Regular numbered item";
1213
1214 let theme = Theme::default();
1215 let styles = MarkdownStyles::from_theme(&theme);
1216 let rendered = from_str(markdown, &styles, &theme);
1217
1218 println!("\n=== Task Lists Test ===");
1219 for (idx, line) in rendered.lines.iter().enumerate() {
1220 let line_text: String = line
1221 .line
1222 .spans
1223 .iter()
1224 .map(|span| span.content.as_ref())
1225 .collect();
1226 println!("Line {idx}: '{line_text}'");
1227 }
1228
1229 let has_checked = rendered.lines.iter().any(|line| {
1231 line.line
1232 .spans
1233 .iter()
1234 .any(|span| span.content.contains("[✓]"))
1235 });
1236 let has_unchecked = rendered.lines.iter().any(|line| {
1237 line.line
1238 .spans
1239 .iter()
1240 .any(|span| span.content.contains("[ ]"))
1241 });
1242 assert!(has_checked, "Should contain checked checkboxes");
1243 assert!(has_unchecked, "Should contain unchecked checkboxes");
1244 }
1245
1246 #[test]
1247 fn test_empty_list_items() {
1248 let markdown = r"Empty list items:
1250-
1251- Item with content
1252-
1253- Another item
1254
1255Empty numbered items:
12561.
12572. Content here
12583. ";
1259
1260 let theme = Theme::default();
1261 let styles = MarkdownStyles::from_theme(&theme);
1262 let rendered = from_str(markdown, &styles, &theme);
1263
1264 println!("\n=== Empty List Items Test ===");
1265 for (idx, line) in rendered.lines.iter().enumerate() {
1266 let line_text: String = line
1267 .line
1268 .spans
1269 .iter()
1270 .map(|span| span.content.as_ref())
1271 .collect();
1272 println!("Line {idx}: '{line_text}'");
1273 }
1274
1275 assert!(!rendered.lines.is_empty());
1277 }
1278
1279 #[test]
1280 fn test_malformed_lists() {
1281 let markdown = r"List interrupted by other content:
1283- Item 1
1284This is a paragraph, not in the list
1285- Item 2
1286
1287Nested list edge cases:
1288- Outer item
1289 - Inner item
1290 Some text here
1291- Back to outer
1292
1293Task list edge cases:
1294- [ ]
1295- [x] Task with content
1296- [ ]
1297
1298Mixed content:
12991. [ ] Task in numbered list
1300Regular text
13012. Another item";
1302
1303 let theme = Theme::default();
1304 let styles = MarkdownStyles::from_theme(&theme);
1305 let rendered = from_str(markdown, &styles, &theme);
1306
1307 println!("\n=== Malformed Lists Test ===");
1308 for (idx, line) in rendered.lines.iter().enumerate() {
1309 let line_text: String = line
1310 .line
1311 .spans
1312 .iter()
1313 .map(|span| span.content.as_ref())
1314 .collect();
1315 println!("Line {idx}: '{line_text}'");
1316 }
1317
1318 assert!(!rendered.lines.is_empty());
1320 }
1321
1322 #[test]
1323 fn test_state_tracking_debug() {
1324 let markdown = r"- Item 1
1326-
1327- [ ] Task item
1328-
1329Regular paragraph
1330
1331- New list";
1332
1333 let mut options = Options::empty();
1334 options.insert(Options::ENABLE_TASKLISTS);
1335 let parser = Parser::new_ext(markdown, options);
1336
1337 println!("\n=== State Tracking Debug ===");
1338
1339 let theme = Theme::default();
1340 let styles = MarkdownStyles::from_theme(&theme);
1341 let mut writer = TextWriter::new(parser, &styles, &theme);
1342
1343 let parser = Parser::new_ext(markdown, options);
1345 for (idx, event) in parser.enumerate() {
1346 println!("Event {idx}: {event:?}");
1347 println!(" list_indices.len() = {}", writer.list_indices.len());
1348 println!(" in_list_item_start = {}", writer.in_list_item_start);
1349 writer.handle_event(event);
1350 }
1351
1352 println!("\nFinal state:");
1354 println!(" list_indices.len() = {}", writer.list_indices.len());
1355 println!(" in_list_item_start = {}", writer.in_list_item_start);
1356
1357 assert_eq!(
1358 writer.list_indices.len(),
1359 0,
1360 "list_indices should be empty at end"
1361 );
1362 assert!(
1363 !writer.in_list_item_start,
1364 "in_list_item_start should be false at end"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_list_item_wrapping_indentation() {
1370 use crate::tui::widgets::formatters::helpers::style_wrap_with_indent;
1371
1372 let markdown = r"- aaaa bbbb cccc dddd eeee ffff gggg";
1374
1375 let theme = Theme::default();
1376 let styles = MarkdownStyles::from_theme(&theme);
1377
1378 let rendered = from_str(markdown, &styles, &theme);
1379 assert_eq!(rendered.lines.len(), 1);
1380 let ml = &rendered.lines[0];
1381
1382 let wrapped = style_wrap_with_indent(ml.line.clone(), 10, ml.indent_level);
1384 let got: Vec<String> = wrapped
1385 .into_iter()
1386 .map(|ln| ln.spans.iter().map(|s| s.content.as_ref()).collect())
1387 .collect();
1388
1389 let expected = vec![
1391 "- aaaa ".to_string(),
1392 " bbbb ".to_string(),
1393 " cccc ".to_string(),
1394 " dddd ".to_string(),
1395 " eeee ".to_string(),
1396 " ffff ".to_string(),
1397 " gggg".to_string(),
1398 ];
1399
1400 assert_eq!(
1401 got, expected,
1402 "wrapped bullet should align under text after '- '"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_nested_list_item_wrapping_indentation_exact() {
1408 use crate::tui::widgets::formatters::helpers::style_wrap_with_indent;
1409
1410 let markdown = r"- outer
1412 - aaaa bbbb cccc dddd eeee ffff";
1413
1414 let theme = Theme::default();
1415 let styles = MarkdownStyles::from_theme(&theme);
1416
1417 let rendered = from_str(markdown, &styles, &theme);
1418 assert_eq!(rendered.lines.len(), 2);
1419
1420 let first: String = rendered.lines[0]
1422 .line
1423 .spans
1424 .iter()
1425 .map(|s| s.content.as_ref())
1426 .collect();
1427 assert_eq!(first, "- outer");
1428
1429 let ml = &rendered.lines[1];
1431 let wrapped = style_wrap_with_indent(ml.line.clone(), 12, ml.indent_level);
1432 let got: Vec<String> = wrapped
1433 .into_iter()
1434 .map(|ln| ln.spans.iter().map(|s| s.content.as_ref()).collect())
1435 .collect();
1436
1437 let expected = vec![
1438 " - aaaa ".to_string(),
1439 " bbbb ".to_string(),
1440 " cccc ".to_string(),
1441 " dddd ".to_string(),
1442 " eeee ".to_string(),
1443 " ffff".to_string(),
1444 ];
1445
1446 assert_eq!(
1447 got, expected,
1448 "wrapped nested bullet should align under text after ' - '"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_syntax_highlighting() {
1454 let markdown = r#"```rust
1455fn main() {
1456 let x = 42;
1457 println!("Hello, world! {}", x);
1458}
1459```
1460
1461```python
1462def hello():
1463 print("Hello from Python")
1464 return 42
1465```"#;
1466 use syntect::highlighting::ThemeSet;
1468 let theme_set = ThemeSet::load_defaults();
1469 let theme = Theme {
1470 syntax_theme: theme_set.themes.get("base16-ocean.dark").cloned(),
1471 ..Default::default()
1472 };
1473
1474 let styles = MarkdownStyles::from_theme(&theme);
1475 let rendered = from_str(markdown, &styles, &theme);
1476
1477 println!("\n=== Syntax Highlighting Test ===");
1478 for (idx, line) in rendered.lines.iter().enumerate() {
1479 println!("Line {}: {} spans", idx, line.line.spans.len());
1480 for (span_idx, span) in line.line.spans.iter().enumerate() {
1481 println!(
1482 " Span {}: '{}' (fg: {:?})",
1483 span_idx,
1484 span.content.as_ref(),
1485 span.style.fg
1486 );
1487 }
1488 }
1489
1490 let has_multiple_spans = rendered.lines.iter().any(|line| line.line.spans.len() > 1);
1493
1494 assert!(
1495 has_multiple_spans,
1496 "Should have lines with multiple colored spans when syntax highlighting is enabled"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_numbered_list_with_formatting() {
1502 let markdown = r"### TUI State Management: A Broader View
1503
1504The TUI's state architecture is a well-defined, multi-layered system that separates data, UI state, and asynchronous process management.
1505
15061. **MessageViewModel**: This is the central nervous system of the TUI's state.
15072. **ChatStore**: This is the canonical data store for the conversation history.
1508 - **Responsibility**: Holds the ground truth of what should be displayed
1509 - **Key Feature**: Its prune_to_thread method is critical
15103. **ToolCallRegistry**: This is the asynchronous state machine.
15114. **ChatListState**: This is the pure UI view state.
1512
1513Also test with other inline formatting:
15141. *Emphasized text*: Should work with emphasis
15152. ~~Strikethrough text~~: Should work with strikethrough
15163. [Link text](https://example.com): Should work with links
15174. `Code text`: Should work with inline code";
1518
1519 let theme = Theme::default();
1520 let styles = MarkdownStyles::from_theme(&theme);
1521 let rendered = from_str(markdown, &styles, &theme);
1522
1523 println!("\n=== Numbered List with Formatting Test ===");
1524 for (idx, line) in rendered.lines.iter().enumerate() {
1525 let line_text: String = line
1526 .line
1527 .spans
1528 .iter()
1529 .map(|span| span.content.as_ref())
1530 .collect();
1531 println!("Line {idx}: '{line_text}'");
1532 }
1533
1534 let has_correct_format = rendered.lines.iter().any(|line| {
1536 let line_text: String = line
1537 .line
1538 .spans
1539 .iter()
1540 .map(|span| span.content.as_ref())
1541 .collect();
1542 line_text.starts_with("1. ") && line_text.contains("MessageViewModel")
1543 });
1544
1545 assert!(
1546 has_correct_format,
1547 "Numbered list with bold text should be formatted as '1. **MessageViewModel**:' not 'MessageViewModel1.'"
1548 );
1549
1550 let has_incorrect_format = rendered.lines.iter().any(|line| {
1552 let line_text: String = line
1553 .line
1554 .spans
1555 .iter()
1556 .map(|span| span.content.as_ref())
1557 .collect();
1558 line_text.contains("MessageViewModel1.")
1559 });
1560
1561 assert!(
1562 !has_incorrect_format,
1563 "Should not have 'MessageViewModel1.' in the output"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_list_item_bullet_rendering() {
1569 let markdown = r"3. A ChatItem is visible when:
1570• it is a Message whose id is in lineage, or
1571• it has a parent_chat_item_id that (recursively) leads to a Message whose id is in lineage.";
1572
1573 let options = Options::empty();
1575 let parser = Parser::new_ext(markdown, options);
1576
1577 println!("\n=== Parser Events for Bullet List ===");
1578 for (idx, event) in parser.enumerate() {
1579 match &event {
1580 Event::Start(tag) => println!("{idx}: Start {tag:?}"),
1581 Event::End(tag) => println!("{idx}: End {tag:?}"),
1582 Event::Text(text) => println!("{idx}: Text: '{text}'"),
1583 Event::SoftBreak => println!("{idx}: SoftBreak"),
1584 Event::HardBreak => println!("{idx}: HardBreak"),
1585 _ => println!("{idx}: {event:?}"),
1586 }
1587 }
1588
1589 let theme = Theme::default();
1590 let styles = MarkdownStyles::from_theme(&theme);
1591 let rendered = from_str(markdown, &styles, &theme);
1592
1593 println!("\n=== List Item Bullet Rendering Test ===");
1594 for (idx, line) in rendered.lines.iter().enumerate() {
1595 let line_text: String = line
1596 .line
1597 .spans
1598 .iter()
1599 .map(|span| span.content.as_ref())
1600 .collect();
1601 println!("Line {idx}: '{line_text}'");
1602 }
1603
1604 let lines: Vec<String> = rendered
1606 .lines
1607 .iter()
1608 .map(|line| {
1609 line.line
1610 .spans
1611 .iter()
1612 .map(|span| span.content.as_ref())
1613 .collect()
1614 })
1615 .collect();
1616
1617 assert!(
1619 lines.len() >= 3,
1620 "Should have at least 3 lines for the list and bullets"
1621 );
1622
1623 assert!(
1625 lines[0].starts_with("3."),
1626 "First line should start with '3.'"
1627 );
1628 }
1629
1630 #[test]
1631 fn test_nested_list_with_soft_breaks() {
1632 let markdown = r"1. First level item
1633 - Nested bullet one
1634 - Nested bullet two
1635 with continuation
1636 - Nested bullet three
16372. Second level item";
1638
1639 let theme = Theme::default();
1640 let styles = MarkdownStyles::from_theme(&theme);
1641 let rendered = from_str(markdown, &styles, &theme);
1642
1643 println!("\n=== Nested List with Soft Breaks Test ===");
1644 for (idx, line) in rendered.lines.iter().enumerate() {
1645 let line_text: String = line
1646 .line
1647 .spans
1648 .iter()
1649 .map(|span| span.content.as_ref())
1650 .collect();
1651 println!("Line {idx}: '{line_text}'");
1652 }
1653
1654 let lines: Vec<String> = rendered
1655 .lines
1656 .iter()
1657 .map(|line| {
1658 line.line
1659 .spans
1660 .iter()
1661 .map(|span| span.content.as_ref())
1662 .collect()
1663 })
1664 .collect();
1665
1666 assert!(
1668 lines.len() >= 6,
1669 "Should have at least 6 lines for nested list"
1670 );
1671
1672 let nested_lines: Vec<&String> = lines
1674 .iter()
1675 .filter(|line| line.trim_start().starts_with('-'))
1676 .collect();
1677 assert!(
1678 nested_lines.len() >= 3,
1679 "Should have at least 3 nested bullet points"
1680 );
1681 }
1682}