thoth_cli/
markdown_renderer.rs

1use std::collections::HashMap;
2
3use anyhow::{anyhow, Result};
4use ratatui::{
5    style::{Color, Modifier, Style},
6    text::{Line, Span, Text},
7};
8use syntect::{
9    easy::HighlightLines,
10    highlighting::{Style as SyntectStyle, ThemeSet},
11    parsing::{SyntaxReference, SyntaxSet},
12};
13
14pub struct MarkdownRenderer {
15    syntax_set: SyntaxSet,
16    theme_set: ThemeSet,
17    theme: String,
18    cache: HashMap<String, Text<'static>>,
19}
20const HEADER_COLORS: [Color; 6] = [
21    Color::Red,
22    Color::Green,
23    Color::Yellow,
24    Color::Blue,
25    Color::Magenta,
26    Color::Cyan,
27];
28
29impl Default for MarkdownRenderer {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl MarkdownRenderer {
36    pub fn new() -> Self {
37        MarkdownRenderer {
38            syntax_set: SyntaxSet::load_defaults_newlines(),
39            theme_set: ThemeSet::load_defaults(),
40            theme: "base16-mocha.dark".to_string(),
41            cache: HashMap::new(),
42        }
43    }
44
45    pub fn render_markdown(
46        &mut self,
47        markdown: String,
48        title: String,
49        width: usize,
50    ) -> Result<Text<'static>> {
51        if let Some(lines) = self.cache.get(&format!("{}{}", &title, &markdown)) {
52            return Ok(lines.clone());
53        }
54
55        let md_syntax = self.syntax_set.find_syntax_by_extension("md").unwrap();
56        let mut lines = Vec::new();
57        let mut in_code_block = false;
58        let mut code_block_lang = String::new();
59        let mut code_block_content = Vec::new();
60        let theme = &self.theme_set.themes[&self.theme];
61        let mut h = HighlightLines::new(md_syntax, theme);
62
63        if self.is_json_document(&markdown) {
64            let json_syntax = self.syntax_set.find_syntax_by_extension("json").unwrap();
65            return Ok(Text::from(self.highlight_code_block(
66                &markdown.lines().map(|x| x.to_string()).collect::<Vec<_>>(),
67                "json",
68                json_syntax,
69                theme,
70                width,
71            )?));
72        }
73
74        let mut markdown_lines = markdown.lines().map(|x| x.to_string()).peekable();
75
76        while let Some(line) = markdown_lines.next() {
77            // Code block handling
78            if line.starts_with("```") {
79                if in_code_block {
80                    // End of code block
81                    lines.extend(self.process_code_block_end(
82                        &code_block_content,
83                        &code_block_lang,
84                        md_syntax,
85                        theme,
86                        width,
87                    )?);
88                    code_block_content.clear();
89                    in_code_block = false;
90                } else {
91                    // Start of code block
92                    in_code_block = true;
93                    code_block_lang = line.trim_start_matches('`').to_string();
94
95                    // Check if it's a one-line code block
96                    if let Some(next_line) = markdown_lines.peek() {
97                        if next_line.starts_with("```") {
98                            lines.extend(self.process_empty_code_block(
99                                &code_block_lang,
100                                md_syntax,
101                                theme,
102                                width,
103                            )?);
104                            in_code_block = false;
105                            markdown_lines.next(); // Skip the closing ```
106                            continue;
107                        }
108                    }
109                }
110            } else if in_code_block {
111                code_block_content.push(line.to_string());
112            } else {
113                let processed_line = self.process_markdown_line(&line, &mut h, theme, width)?;
114                lines.push(processed_line);
115            }
116        }
117
118        let markdown_lines = Text::from(lines);
119        let new_key = &format!("{}{}", &title, &markdown);
120        self.cache.insert(new_key.clone(), markdown_lines.clone());
121        Ok(markdown_lines)
122    }
123
124    fn is_json_document(&self, content: &str) -> bool {
125        let trimmed = content.trim();
126        (trimmed.starts_with('{') || trimmed.starts_with('['))
127            && (trimmed.ends_with('}') || trimmed.ends_with(']'))
128    }
129
130    fn process_code_block_end(
131        &self,
132        code_content: &[String],
133        lang: &str,
134        default_syntax: &SyntaxReference,
135        theme: &syntect::highlighting::Theme,
136        width: usize,
137    ) -> Result<Vec<Line<'static>>> {
138        let lang = lang.trim_start_matches('`').trim();
139        let syntax = if !lang.is_empty() {
140            self.syntax_set
141                .find_syntax_by_token(lang)
142                .or_else(|| self.syntax_set.find_syntax_by_extension(lang))
143                .unwrap_or(default_syntax)
144        } else {
145            default_syntax
146        };
147
148        self.highlight_code_block(code_content, lang, syntax, theme, width)
149    }
150
151    fn process_empty_code_block(
152        &self,
153        lang: &str,
154        default_syntax: &SyntaxReference,
155        theme: &syntect::highlighting::Theme,
156        width: usize,
157    ) -> Result<Vec<Line<'static>>> {
158        let lang = lang.trim();
159        let syntax = if !lang.is_empty() {
160            self.syntax_set
161                .find_syntax_by_token(lang)
162                .or_else(|| self.syntax_set.find_syntax_by_extension(lang))
163                .unwrap_or(default_syntax)
164        } else {
165            default_syntax
166        };
167
168        self.highlight_code_block(&["".to_string()], lang, syntax, theme, width)
169    }
170
171    fn highlight_code_block(
172        &self,
173        code: &[String],
174        lang: &str,
175        syntax: &SyntaxReference,
176        theme: &syntect::highlighting::Theme,
177        width: usize,
178    ) -> Result<Vec<Line<'static>>> {
179        let mut h = HighlightLines::new(syntax, theme);
180        let mut result = Vec::new();
181
182        let max_line_num = code.len();
183        let line_num_width = max_line_num.to_string().len().max(1);
184
185        let lang_name = lang.trim();
186        let header_text = if !lang_name.is_empty() {
187            format!("▌ {} ", lang_name)
188        } else {
189            "▌ code ".to_string()
190        };
191
192        let border_width = width.saturating_sub(header_text.len());
193        let header = Span::styled(
194            format!("{}{}", header_text, "─".repeat(border_width)),
195            Style::default()
196                .fg(Color::White)
197                .add_modifier(Modifier::BOLD),
198        );
199
200        if lang != "json" {
201            result.push(Line::from(vec![header]));
202        }
203
204        for (line_number, line) in code.iter().enumerate() {
205            let highlighted = h
206                .highlight_line(line, &self.syntax_set)
207                .map_err(|e| anyhow!("Highlight error: {}", e))?;
208
209            let mut spans = if lang == "json" {
210                vec![Span::styled(
211                    format!("{:>width$} ", line_number + 1, width = line_num_width),
212                    Style::default().fg(Color::DarkGray),
213                )]
214            } else {
215                vec![Span::styled(
216                    format!("{:>width$} │ ", line_number + 1, width = line_num_width),
217                    Style::default().fg(Color::DarkGray),
218                )]
219            };
220            spans.extend(self.process_syntect_highlights(highlighted));
221
222            let line_content: String = spans.iter().map(|span| span.content.clone()).collect();
223            let padding_width = width.saturating_sub(line_content.len());
224            if padding_width > 0 {
225                spans.push(Span::styled(" ".repeat(padding_width), Style::default()));
226            }
227
228            result.push(Line::from(spans));
229        }
230
231        if lang != "json" {
232            result.push(Line::from(Span::styled(
233                "─".repeat(width),
234                Style::default().fg(Color::DarkGray),
235            )));
236        }
237
238        Ok(result)
239    }
240
241    fn process_markdown_line(
242        &self,
243        line: &str,
244        h: &mut HighlightLines,
245        _theme: &syntect::highlighting::Theme,
246        width: usize,
247    ) -> Result<Line<'static>> {
248        let mut spans: Vec<Span<'static>>;
249
250        // Handle header
251        if let Some((is_header, level)) = self.is_header(line) {
252            if is_header {
253                let header_color = if level <= 6 {
254                    HEADER_COLORS[level.saturating_sub(1)]
255                } else {
256                    HEADER_COLORS[0]
257                };
258
259                spans = vec![Span::styled(
260                    line.to_string(),
261                    Style::default()
262                        .fg(header_color)
263                        .add_modifier(Modifier::BOLD),
264                )];
265                return Ok(Line::from(spans));
266            }
267        }
268
269        let (content, is_blockquote) = self.process_blockquote(line);
270
271        if let Some((content, is_checked)) = self.is_checkbox_list_item(&content) {
272            return self.format_checkbox_item(line, content, is_checked, h, width);
273        }
274
275        let (content, is_list, is_ordered, order_num) = self.process_list_item(&content);
276
277        let highlighted = h
278            .highlight_line(&content, &self.syntax_set)
279            .map_err(|e| anyhow!("Highlight error: {}", e))?;
280
281        spans = self.process_syntect_highlights(highlighted);
282
283        if is_blockquote {
284            spans = self.apply_blockquote_styling(spans);
285        }
286
287        if is_list {
288            spans = self.apply_list_styling(line, spans, is_ordered, order_num);
289        } else {
290            let whitespace_prefix = line
291                .chars()
292                .take_while(|c| c.is_whitespace())
293                .collect::<String>();
294
295            if !whitespace_prefix.is_empty() {
296                spans.insert(0, Span::styled(whitespace_prefix, Style::default()));
297            }
298        }
299
300        let line_content: String = spans.iter().map(|span| span.content.clone()).collect();
301        let padding_width = width.saturating_sub(line_content.len());
302        if padding_width > 0 {
303            spans.push(Span::styled(" ".repeat(padding_width), Style::default()));
304        }
305
306        Ok(Line::from(spans))
307    }
308
309    fn is_header(&self, line: &str) -> Option<(bool, usize)> {
310        if let Some(header_level) = line.bytes().position(|b| b != b'#') {
311            if header_level > 0
312                && header_level <= 6
313                && line.as_bytes().get(header_level) == Some(&b' ')
314            {
315                return Some((true, header_level));
316            }
317        }
318        None
319    }
320
321    fn process_blockquote(&self, line: &str) -> (String, bool) {
322        if line.starts_with('>') {
323            let content = line.trim_start_matches('>').trim_start().to_string();
324            (content, true)
325        } else {
326            (line.to_string(), false)
327        }
328    }
329
330    fn is_checkbox_list_item(&self, line: &str) -> Option<(String, bool)> {
331        let trimmed = line.trim_start();
332
333        if trimmed.starts_with("- [ ]")
334            || trimmed.starts_with("+ [ ]")
335            || trimmed.starts_with("* [ ]")
336        {
337            let content = trimmed[5..].to_string();
338            return Some((content, false)); // Unchecked
339        } else if trimmed.starts_with("- [x]")
340            || trimmed.starts_with("- [X]")
341            || trimmed.starts_with("+ [x]")
342            || trimmed.starts_with("+ [X]")
343            || trimmed.starts_with("* [x]")
344            || trimmed.starts_with("* [X]")
345        {
346            let content = trimmed[5..].to_string();
347            return Some((content, true)); // Checked
348        }
349
350        // Also match "- [ x ]" or "- [  ]" style with extra spaces
351        if let Some(list_marker_pos) = ["- [", "+ [", "* ["].iter().find_map(|marker| {
352            if trimmed.starts_with(marker) {
353                Some(marker.len())
354            } else {
355                None
356            }
357        }) {
358            if trimmed.len() > list_marker_pos {
359                let remaining = &trimmed[list_marker_pos..];
360                if remaining.starts_with("  ]") || remaining.starts_with(" ]") {
361                    let content_start = remaining
362                        .find(']')
363                        .map(|pos| list_marker_pos + pos + 1)
364                        .unwrap_or(list_marker_pos);
365
366                    if content_start < trimmed.len() {
367                        let content = trimmed[content_start + 1..].to_string();
368                        return Some((content, false));
369                    }
370                } else if remaining.starts_with(" x ]")
371                    || remaining.starts_with(" X ]")
372                    || remaining.starts_with("x ]")
373                    || remaining.starts_with("X ]")
374                {
375                    let content_start = remaining
376                        .find(']')
377                        .map(|pos| list_marker_pos + pos + 1)
378                        .unwrap_or(list_marker_pos);
379
380                    if content_start < trimmed.len() {
381                        let content = trimmed[content_start + 1..].to_string();
382                        return Some((content, true));
383                    }
384                }
385            }
386        }
387
388        None
389    }
390
391    fn format_checkbox_item(
392        &self,
393        line: &str,
394        content: String,
395        is_checked: bool,
396        h: &mut HighlightLines,
397        width: usize,
398    ) -> Result<Line<'static>> {
399        let whitespace_prefix = line
400            .chars()
401            .take_while(|c| c.is_whitespace())
402            .collect::<String>();
403
404        let checkbox = if is_checked {
405            Span::styled("[X] ".to_string(), Style::default().fg(Color::Green))
406        } else {
407            Span::styled("[ ] ".to_string(), Style::default().fg(Color::Gray))
408        };
409
410        let highlighted = h
411            .highlight_line(&content, &self.syntax_set)
412            .map_err(|e| anyhow!("Highlight error: {}", e))?;
413
414        let mut content_spans = self.process_syntect_highlights(highlighted);
415
416        let mut spans = vec![Span::styled(whitespace_prefix, Style::default()), checkbox];
417        spans.append(&mut content_spans);
418
419        let line_content: String = spans.iter().map(|span| span.content.clone()).collect();
420        let padding_width = width.saturating_sub(line_content.len());
421        if padding_width > 0 {
422            spans.push(Span::styled(" ".repeat(padding_width), Style::default()));
423        }
424
425        Ok(Line::from(spans))
426    }
427
428    fn process_list_item(&self, line: &str) -> (String, bool, bool, usize) {
429        let trimmed = line.trim_start();
430
431        if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
432            let content = trimmed[2..].to_string();
433            return (content, true, false, 0);
434        }
435
436        if let Some(dot_pos) = trimmed.find(". ") {
437            if dot_pos > 0 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
438                let order_num = trimmed[..dot_pos].parse::<usize>().unwrap_or(1);
439                let content = trimmed[(dot_pos + 2)..].to_string();
440                return (content, true, true, order_num);
441            }
442        }
443
444        (line.to_string(), false, false, 0)
445    }
446
447    fn apply_blockquote_styling<'a>(&self, spans: Vec<Span<'a>>) -> Vec<Span<'a>> {
448        let mut result = vec![Span::styled(
449            "▎ ".to_string(),
450            Style::default().fg(Color::Blue),
451        )];
452
453        for span in spans {
454            result.push(Span::styled(span.content, Style::default().fg(Color::Gray)));
455        }
456
457        result
458    }
459
460    fn apply_list_styling<'a>(
461        &self,
462        original_line: &str,
463        spans: Vec<Span<'a>>,
464        is_ordered: bool,
465        order_num: usize,
466    ) -> Vec<Span<'a>> {
467        let whitespace_prefix = original_line
468            .chars()
469            .take_while(|c| c.is_whitespace())
470            .collect::<String>();
471
472        let list_marker = if is_ordered {
473            format!("{}. ", order_num)
474        } else {
475            "• ".to_string()
476        };
477
478        let prefix = Span::styled(
479            format!("{}{}", whitespace_prefix, list_marker),
480            Style::default().fg(Color::Yellow),
481        );
482
483        let mut result = vec![prefix];
484        result.extend(spans);
485        result
486    }
487
488    fn process_syntect_highlights(
489        &self,
490        highlighted: Vec<(SyntectStyle, &str)>,
491    ) -> Vec<Span<'static>> {
492        let mut spans = Vec::new();
493
494        for (style, text) in highlighted {
495            let text_owned = text.to_string();
496
497            if text_owned.contains("~~") && text_owned.matches("~~").count() >= 2 {
498                self.process_strikethrough(&text_owned, style, &mut spans);
499                continue;
500            }
501
502            if text_owned.contains('`') && !text_owned.contains("```") {
503                self.process_inline_code(&text_owned, style, &mut spans);
504                continue;
505            }
506
507            if text_owned.contains('[')
508                && text_owned.contains(']')
509                && text_owned.contains('(')
510                && text_owned.contains(')')
511            {
512                self.process_links(&text_owned, style, &mut spans);
513                continue;
514            }
515
516            spans.push(Span::styled(
517                text_owned,
518                syntect_style_to_ratatui_style(style),
519            ));
520        }
521
522        spans
523    }
524
525    fn process_strikethrough(
526        &self,
527        text: &str,
528        style: SyntectStyle,
529        spans: &mut Vec<Span<'static>>,
530    ) {
531        let parts: Vec<&str> = text.split("~~").collect();
532        let mut in_strikethrough = false;
533
534        for (i, part) in parts.iter().enumerate() {
535            if !part.is_empty() {
536                if in_strikethrough {
537                    spans.push(Span::styled(
538                        part.to_string(),
539                        syntect_style_to_ratatui_style(style).add_modifier(Modifier::CROSSED_OUT),
540                    ));
541                } else {
542                    spans.push(Span::styled(
543                        part.to_string(),
544                        syntect_style_to_ratatui_style(style),
545                    ));
546                }
547            }
548
549            if i < parts.len() - 1 {
550                in_strikethrough = !in_strikethrough;
551            }
552        }
553    }
554
555    fn process_inline_code(&self, text: &str, style: SyntectStyle, spans: &mut Vec<Span<'static>>) {
556        let parts: Vec<&str> = text.split('`').collect();
557        let mut in_code = false;
558
559        for (i, part) in parts.iter().enumerate() {
560            if !part.is_empty() {
561                if in_code {
562                    spans.push(Span::styled(
563                        part.to_string(),
564                        Style::default().fg(Color::White).bg(Color::DarkGray),
565                    ));
566                } else {
567                    spans.push(Span::styled(
568                        part.to_string(),
569                        syntect_style_to_ratatui_style(style),
570                    ));
571                }
572            }
573
574            if i < parts.len() - 1 {
575                in_code = !in_code;
576            }
577        }
578    }
579
580    fn process_links(&self, text: &str, style: SyntectStyle, spans: &mut Vec<Span<'static>>) {
581        let mut in_link = false;
582        let mut in_url = false;
583        let mut current_text = String::new();
584        let mut link_text = String::new();
585
586        let mut i = 0;
587        let chars: Vec<char> = text.chars().collect();
588
589        while i < chars.len() {
590            match chars[i] {
591                '[' => {
592                    if !in_link && !in_url {
593                        // Add any text before the link
594                        if !current_text.is_empty() {
595                            spans.push(Span::styled(
596                                current_text.clone(),
597                                syntect_style_to_ratatui_style(style),
598                            ));
599                            current_text.clear();
600                        }
601                        in_link = true;
602                    } else {
603                        current_text.push('[');
604                    }
605                }
606                ']' => {
607                    if in_link && !in_url {
608                        link_text = current_text.clone();
609                        current_text.clear();
610                        in_link = false;
611
612                        // Check if next char is '('
613                        if i + 1 < chars.len() && chars[i + 1] == '(' {
614                            in_url = true;
615                            i += 1; // Skip the opening paren
616                        } else {
617                            // Not a proper link, just show the text with brackets
618                            spans.push(Span::styled(
619                                format!("[{}]", link_text),
620                                syntect_style_to_ratatui_style(style),
621                            ));
622                            link_text.clear();
623                        }
624                    } else {
625                        current_text.push(']');
626                    }
627                }
628                ')' => {
629                    if in_url {
630                        // URL part is in current_text, link text is in link_text
631                        in_url = false;
632
633                        spans.push(Span::styled(
634                            link_text.clone(),
635                            Style::default()
636                                .fg(Color::Cyan)
637                                .add_modifier(Modifier::UNDERLINED),
638                        ));
639
640                        link_text.clear();
641                        current_text.clear();
642                    } else {
643                        current_text.push(')');
644                    }
645                }
646                _ => {
647                    current_text.push(chars[i]);
648                }
649            }
650
651            i += 1;
652        }
653
654        if !current_text.is_empty() {
655            spans.push(Span::styled(
656                current_text,
657                syntect_style_to_ratatui_style(style),
658            ));
659        }
660    }
661}
662
663fn syntect_style_to_ratatui_style(style: SyntectStyle) -> Style {
664    let mut ratatui_style = Style::default().fg(Color::Rgb(
665        style.foreground.r,
666        style.foreground.g,
667        style.foreground.b,
668    ));
669
670    if style
671        .font_style
672        .contains(syntect::highlighting::FontStyle::BOLD)
673    {
674        ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
675    }
676    if style
677        .font_style
678        .contains(syntect::highlighting::FontStyle::ITALIC)
679    {
680        ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
681    }
682    if style
683        .font_style
684        .contains(syntect::highlighting::FontStyle::UNDERLINE)
685    {
686        ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
687    }
688
689    ratatui_style
690}
691
692#[cfg(test)]
693mod tests {
694    use crate::MIN_TEXTAREA_HEIGHT;
695
696    use super::*;
697
698    #[test]
699    fn test_render_markdown() {
700        let mut renderer = MarkdownRenderer::new();
701        let markdown = "# Header\n\nThis is **bold** and *italic* text.";
702        let rendered = renderer
703            .render_markdown(markdown.to_string(), "".to_string(), 40)
704            .unwrap();
705
706        assert!(rendered.lines.len() >= MIN_TEXTAREA_HEIGHT);
707        assert!(rendered.lines[0]
708            .spans
709            .iter()
710            .any(|span| span.content.contains("Header")));
711        assert!(rendered.lines[2]
712            .spans
713            .iter()
714            .any(|span| span.content.contains("This is")));
715    }
716
717    #[test]
718    fn test_render_markdown_with_code_block() {
719        let mut renderer = MarkdownRenderer::new();
720        let markdown = "# Header\n\n```rust\nfn main() {\n    println!(\"Hello, world!\");\n}\n```";
721
722        let rendered = renderer
723            .render_markdown(markdown.to_string(), "".to_string(), 40)
724            .unwrap();
725        assert!(rendered.lines.len() > 5);
726        assert!(rendered.lines[0]
727            .spans
728            .iter()
729            .any(|span| span.content.contains("Header")));
730        assert!(rendered
731            .lines
732            .iter()
733            .any(|line| line.spans.iter().any(|span| span.content.contains("main"))));
734    }
735
736    #[test]
737    fn test_render_json() {
738        let mut renderer = MarkdownRenderer::new();
739        let json = r#"{
740  "name": "John Doe",
741  "age": 30,
742  "city": "New York"
743}"#;
744
745        let rendered = renderer
746            .render_markdown(json.to_string(), "".to_string(), 40)
747            .unwrap();
748
749        assert!(rendered.lines.len() == 5);
750        assert!(rendered.lines[0]
751            .spans
752            .iter()
753            .any(|span| span.content.contains("{")));
754        assert!(rendered.lines[4]
755            .spans
756            .iter()
757            .any(|span| span.content.contains("}")));
758    }
759
760    #[test]
761    fn test_render_markdown_with_lists() {
762        let mut renderer = MarkdownRenderer::new();
763        let markdown =
764            "# List Test\n\n- Item 1\n- Item 2\n  - Nested item\n\n1. First item\n2. Second item";
765        let rendered = renderer
766            .render_markdown(markdown.to_string(), "".to_string(), 40)
767            .unwrap();
768
769        assert!(rendered
770            .lines
771            .iter()
772            .any(|line| line.spans.iter().any(|span| span.content.contains("•"))));
773        assert!(rendered
774            .lines
775            .iter()
776            .any(|line| line.spans.iter().any(|span| span.content.contains("1."))));
777    }
778
779    #[test]
780    fn test_render_markdown_with_links() {
781        let mut renderer = MarkdownRenderer::new();
782        let markdown = "Visit [Google](https://google.com) for search";
783        let rendered = renderer
784            .render_markdown(markdown.to_string(), "".to_string(), 40)
785            .unwrap();
786
787        assert!(rendered.lines.iter().any(|line| line
788            .spans
789            .iter()
790            .any(|span| span.content.contains("Google"))));
791    }
792
793    #[test]
794    fn test_render_markdown_with_blockquotes() {
795        let mut renderer = MarkdownRenderer::new();
796        let markdown = "> This is a blockquote\n> Another line";
797        let rendered = renderer
798            .render_markdown(markdown.to_string(), "".to_string(), 40)
799            .unwrap();
800
801        assert!(rendered
802            .lines
803            .iter()
804            .any(|line| line.spans.iter().any(|span| span.content.contains("▎"))));
805    }
806
807    #[test]
808    fn test_render_markdown_with_task_lists() {
809        let mut renderer = MarkdownRenderer::new();
810        let markdown = "- [ ] Unchecked task\n- [x] Checked task\n- [ x ] Also checked task\n- [  ] Another unchecked task";
811        let rendered = renderer
812            .render_markdown(markdown.to_string(), "".to_string(), 40)
813            .unwrap();
814
815        assert!(rendered
816            .lines
817            .iter()
818            .any(|line| line.spans.iter().any(|span| span.content.contains("[ ]"))));
819        assert!(rendered
820            .lines
821            .iter()
822            .any(|line| line.spans.iter().any(|span| span.content.contains("[X]"))));
823    }
824
825    #[test]
826    fn test_render_markdown_with_inline_code() {
827        let mut renderer = MarkdownRenderer::new();
828        let markdown = "Some `inline code` here";
829        let rendered = renderer
830            .render_markdown(markdown.to_string(), "".to_string(), 40)
831            .unwrap();
832
833        assert!(rendered.lines.iter().any(|line| line
834            .spans
835            .iter()
836            .any(|span| span.content.contains("inline code"))));
837    }
838
839    #[test]
840    fn test_render_markdown_with_strikethrough() {
841        let mut renderer = MarkdownRenderer::new();
842        let markdown = "This is ~~strikethrough~~ text";
843        let rendered = renderer
844            .render_markdown(markdown.to_string(), "".to_string(), 40)
845            .unwrap();
846
847        let has_strikethrough = rendered.lines.iter().any(|line| {
848            line.spans.iter().any(|span| {
849                let modifiers = span.style.add_modifier;
850                return modifiers.contains(Modifier::CROSSED_OUT);
851            })
852        });
853
854        assert!(has_strikethrough);
855    }
856
857    #[test]
858    fn test_render_markdown_with_one_line_code_block() {
859        let mut renderer = MarkdownRenderer::new();
860        let markdown = "# Header\n\n```rust\n```\n\nText after.".to_string();
861        let rendered = renderer
862            .render_markdown(markdown, "".to_string(), 40)
863            .unwrap();
864
865        assert!(rendered.lines.len() > MIN_TEXTAREA_HEIGHT);
866        assert!(rendered.lines[0]
867            .spans
868            .iter()
869            .any(|span| span.content.contains("Header")));
870        assert!(rendered
871            .lines
872            .iter()
873            .any(|line| line.spans.iter().any(|span| span.content.contains("1 │"))));
874        assert!(rendered
875            .lines
876            .last()
877            .unwrap()
878            .spans
879            .iter()
880            .any(|span| span.content.contains("Text after.")));
881    }
882
883    #[test]
884    fn test_indentation_preservation() {
885        let mut renderer = MarkdownRenderer::new();
886        let markdown = "Regular text\n    Indented text\n        Double indented text";
887        let rendered = renderer
888            .render_markdown(markdown.to_string(), "".to_string(), 50)
889            .unwrap();
890
891        assert_eq!(rendered.lines.len(), 3);
892
893        assert!(rendered.lines[1]
894            .spans
895            .iter()
896            .any(|span| span.content.starts_with("    ")));
897
898        assert!(rendered.lines[2]
899            .spans
900            .iter()
901            .any(|span| span.content.starts_with("        ")));
902    }
903}