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 if line.starts_with("```") {
79 if in_code_block {
80 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 in_code_block = true;
93 code_block_lang = line.trim_start_matches('`').to_string();
94
95 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(); 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 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)); } 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)); }
349
350 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 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 if i + 1 < chars.len() && chars[i + 1] == '(' {
614 in_url = true;
615 i += 1; } else {
617 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 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}