Skip to main content

steer_tui/tui/widgets/
markdown.rs

1//! Theme-aware markdown renderer for the TUI
2//!
3//! This is a modified version of tui-markdown that accepts theme styles
4//! instead of using hardcoded ones.
5
6use 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
18/// Lazy-loaded syntax set for highlighting
19static SYNTAX_SET: std::sync::LazyLock<SyntaxSet> =
20    std::sync::LazyLock::new(SyntaxSet::load_defaults_newlines);
21
22/// Convert a syntect style to ratatui style
23fn 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/// A line with metadata about how it should be rendered
33#[derive(Debug, Clone)]
34pub struct MarkedLine {
35    pub line: Line<'static>,
36    pub no_wrap: bool,       // If true, this line should not be wrapped
37    pub indent_level: usize, // Number of spaces to indent when wrapping
38}
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/// Markdown text with metadata
64#[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/// Markdown styles that can be customized via the theme
76#[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    /// Create markdown styles from a theme
102    pub fn from_theme(theme: &Theme) -> Self {
103        use ratatui::style::Modifier;
104
105        Self {
106            // Headings - add semantic modifiers on top of theme colors
107            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            // Text modifiers - these are purely semantic, ignore theme styles
127            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            // Other elements - add semantic modifiers where appropriate
132            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    /// Iterator supplying events.
176    iter: I,
177
178    /// Text to write to.
179    marked_text: MarkedText,
180
181    /// Current style.
182    ///
183    /// This is a stack of styles, with the top style being the current style.
184    inline_styles: Vec<Style>,
185
186    /// Prefix to add to the start of the each line.
187    line_prefixes: Vec<Span<'a>>,
188
189    /// Stack of line styles.
190    line_styles: Vec<Style>,
191
192    /// Current list index as a stack of indices.
193    list_indices: Vec<Option<u64>>,
194
195    /// A link which will be appended to the current line when the link tag is closed.
196    link: Option<CowStr<'a>>,
197
198    needs_newline: bool,
199
200    /// The markdown styles to use
201    styles: &'a MarkdownStyles,
202
203    /// The theme for syntax highlighting
204    theme: &'a Theme,
205
206    /// Table state
207    table_alignments: Vec<pulldown_cmark::Alignment>,
208    table_rows: Vec<Vec<Vec<Span<'a>>>>, // rows of cells, each cell is a vec of spans
209    in_table_header: bool,
210
211    /// Track if we just started a list item (for task list markers)
212    in_list_item_start: bool,
213
214    /// Track if we're inside a code block to preserve formatting
215    in_code_block: bool,
216
217    /// Current code block language (if any)
218    code_block_language: Option<String>,
219
220    /// Terminal width for rendering full-width elements like horizontal rules
221    terminal_width: Option<u16>,
222
223    /// Current list item indent level (for wrapping)
224    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 we're at the start of a list item, push the list marker before applying inline styles
297                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 we're at the start of a list item, push the list marker before the link
311                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        // Insert an empty line between paragraphs if there is at least one line of text already.
344        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        // Push the heading style so it applies to the text content
368        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        // Pop the heading style we pushed in start_heading
377        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 we're at the start of a list item and haven't seen a task list marker,
398        // push the regular list marker
399        if self.in_list_item_start {
400            self.push_list_marker();
401            self.in_list_item_start = false;
402        }
403
404        // Check if we're in a table cell
405        let in_table = self.table_rows.last().and_then(|row| row.last()).is_some();
406
407        if in_table {
408            // If we're in a table, just add the text as a span to the current cell
409            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            // Special handling for code blocks with syntax highlighting
414            let base_style = self
415                .inline_styles
416                .last()
417                .copied()
418                .unwrap_or_default()
419                .patch(self.styles.code_block);
420
421            // Check if we have syntax highlighting available
422            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                // Find the syntax definition
438                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                // Process the text line by line
446                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                    // Highlight the line
452                    let highlighted = highlighter
453                        .highlight_line(line, &SYNTAX_SET)
454                        .unwrap_or_else(|_| vec![(syntect::highlighting::Style::default(), line)]);
455
456                    // Convert highlighted spans to ratatui spans
457                    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                // Handle case where text ends with a newline
465                self.needs_newline = text.ends_with('\n');
466            } else {
467                // Fallback to non-highlighted rendering
468                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                    // Create a span with the exact line content, preserving all whitespace
475                    let span = Span::styled((*line).to_string(), base_style);
476                    self.push_span(span);
477                }
478
479                // Handle case where text ends with a newline
480                self.needs_newline = text.ends_with('\n') && !lines.is_empty();
481            }
482        } else {
483            // Original behavior for non-table, non-code-block text
484            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 we're at the start of a list item, push the list marker before the code
503        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        // Hard break should add a line break but stay in the same paragraph
514        self.push_span("  ".into()); // Two spaces to visually indicate hard break
515        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        // Mark that we're at the start of a list item
533        self.in_list_item_start = true;
534        // Calculate indent for wrapped lines
535        // We'll set the actual indent when we push the marker, based on its actual width
536        self.list_item_indent = 0;
537        // Don't push the list marker yet - wait for task list marker if present
538        self.needs_newline = false;
539    }
540
541    fn end_item(&mut self) {
542        // If we still have in_list_item_start set, it means we had an empty list item
543        // We need to push the list marker for empty items
544        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        // Treat soft breaks like hard breaks - always create a new line
552        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        // Set flag to preserve formatting
561        self.in_code_block = true;
562
563        // Capture the language for syntax highlighting
564        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        // Clear the flag and language
582        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        // Add line prefixes to the start of the line.
609        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        // Convert line to 'static lifetime by converting all spans to owned
619        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        // Create marked line based on current state
627        let marked_line = if self.in_code_block {
628            MarkedLine::new_no_wrap(static_line)
629        } else {
630            // Apply list item indent if we're in a list
631            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        // Check if we're in a table cell first
645        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            // Convert to owned span for 'static lifetime
665            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    /// Store the link to be appended to the link text
673    #[instrument(level = "trace", skip(self))]
674    fn push_link(&mut self, dest_url: CowStr<'a>) {
675        self.link = Some(dest_url);
676    }
677
678    /// Append the link to the current line
679    #[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    // Table handling methods
689
690    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        // Create a row for the header cells
709        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        // Nothing to do here, row is already added
722    }
723
724    fn start_table_cell(&mut self) {
725        // Push a new cell to the current row
726        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        // Nothing to do here, cell is already added
733    }
734
735    /// Render the accumulated table with proper alignment
736    fn render_table(&mut self) {
737        if self.table_rows.is_empty() {
738            return;
739        }
740
741        // Move rows out of `self` to avoid borrow conflicts during rendering
742        let rows = std::mem::take(&mut self.table_rows);
743
744        // Calculate column widths
745        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        // Add padding to column widths
761        for width in &mut col_widths {
762            *width += 2; // Add 1 space padding on each side
763        }
764
765        // Render the table
766        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        // Top border
771        self.render_table_border(&col_widths, '┌', '┬', '┐', border_style);
772
773        // Render rows
774        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                    // Concatenate cell spans into a single string for alignment
781                    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                    // Apply appropriate style
794                    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            // Add separator after header
803            if is_header {
804                self.render_table_border(&col_widths, '├', '┼', '┤', border_style);
805            }
806        }
807
808        // Bottom border
809        self.render_table_border(&col_widths, '└', '┴', '┘', border_style);
810    }
811
812    /// Render a table border line
813    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    /// Align text within a given width based on alignment
835    fn align_text(text: &str, width: usize, alignment: pulldown_cmark::Alignment) -> String {
836        let text_len = text.width();
837        // Total spaces needed = width - text_len
838        // We already have 2 spaces in the format string (1 before, 1 after)
839        let total_padding = width.saturating_sub(text_len);
840
841        match alignment {
842            pulldown_cmark::Alignment::None | pulldown_cmark::Alignment::Left => {
843                // Left align: 1 space before, remaining spaces after
844                let right_padding = total_padding.saturating_sub(1);
845                format!(" {}{}", text, " ".repeat(right_padding))
846            }
847            pulldown_cmark::Alignment::Center => {
848                // Center: distribute padding evenly
849                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                // Right align: remaining spaces before, 1 space after
860                let left_padding = total_padding.saturating_sub(1);
861                format!("{}{} ", " ".repeat(left_padding), text)
862            }
863        }
864    }
865
866    /// Render a horizontal rule
867    fn rule(&mut self) {
868        if self.needs_newline {
869            self.push_line(Line::default());
870        }
871
872        // Create a horizontal rule using box-drawing characters
873        // We'll use a solid line of dashes or unicode box characters
874        let terminal_width = self.terminal_width.unwrap_or(80) as usize;
875        let rule_char = "─"; // Unicode box drawing character
876        let rule_content = rule_char.repeat(terminal_width);
877
878        // Use the blockquote style for rules (or we could add a dedicated rule style)
879        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    /// Push the appropriate list marker (bullet or number)
886    fn push_list_marker(&mut self) {
887        // If we're not inside a list, there's nothing to render – avoid underflow.
888        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                    // Bullet list
900                    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                    // Numbered list
906                    *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            // Set the indent for wrapped lines to align with text after the marker
914            self.list_item_indent = full_marker_width;
915            // Update the current line's indent metadata (the line created in start_item)
916            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    /// Render a task list marker (checkbox)
924    fn task_list_marker(&mut self, checked: bool) {
925        // If we're not inside a list, there's nothing to render – avoid underflow.
926        if self.list_indices.is_empty() {
927            return;
928        }
929
930        // Push the list indentation and marker
931        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        // Use checkbox characters
936        let checkbox = if checked { "[✓] " } else { "[ ] " };
937
938        // Apply appropriate style based on checked state
939        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        // Update the list item indent to account for the actual marker width
949        self.list_item_indent = marker_width;
950
951        let span = Span::styled(full_marker, style);
952        self.push_span(span);
953
954        // Mark that we've handled the list item start
955        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        // Create a dummy theme for testing
1020        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        // Now test rendering
1055        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        // Test that all lines have content (no panic on empty cells)
1095        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        // Check that rules are present
1187        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        // Check that checkboxes are present
1230        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        // Test #2: Empty list items that might leave in_list_item_start as true
1249        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        // Ensure no panic occurred
1276        assert!(!rendered.lines.is_empty());
1277    }
1278
1279    #[test]
1280    fn test_malformed_lists() {
1281        // Test #3: Various edge cases that might cause state issues
1282        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        // Ensure no panic occurred
1319        assert!(!rendered.lines.is_empty());
1320    }
1321
1322    #[test]
1323    fn test_state_tracking_debug() {
1324        // Test with debug output to track state
1325        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        // Manually process events to see state changes
1344        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        // Check final state
1353        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        // Plain, deterministic words to control wrapping exactly
1373        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        // Wrap to width 10 so we know exact breaking points
1383        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        // Expected exact visual lines (note trailing spaces where present)
1390        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        // Include a parent item so the nested marker is parsed as a sub-list
1411        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        // First line should be the outer item (no wrapping expected)
1421        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        // Second line is the nested one; wrap at width 12
1430        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        // Create a theme with syntax highlighting support
1467        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        // With syntax highlighting enabled, code blocks should have multiple spans
1491        // with different colors for different tokens
1492        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        // Check that the numbered list items are formatted correctly
1535        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        // Also check that we don't have the incorrect format
1551        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        // First, let's see what parser events we get
1574        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        // Check that bullet points are on separate lines
1605        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        // There should be at least 3 lines: the numbered item and 2 bullet points
1618        assert!(
1619            lines.len() >= 3,
1620            "Should have at least 3 lines for the list and bullets"
1621        );
1622
1623        // First line should start with "3."
1624        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        // Check that we have proper line breaks for nested items
1667        assert!(
1668            lines.len() >= 6,
1669            "Should have at least 6 lines for nested list"
1670        );
1671
1672        // Check that nested items have proper indentation
1673        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}