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 if line.starts_with("```") {
95 if in_code_block {
96 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 in_code_block = true;
109 code_block_lang = line.trim_start_matches('`').to_string();
110
111 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(); 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 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)); } 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)); }
365
366 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 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 if i + 1 < chars.len() && chars[i + 1] == '(' {
630 in_url = true;
631 i += 1; } else {
633 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 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}