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