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