1use std::any::Any;
8use std::sync::Mutex;
9
10use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
11
12use super::component::Component;
13use super::mermaid::Mermaid;
14use crate::ansi::{bold, italic, underline};
15use crate::theme::theme;
16use crate::utils::{
17 apply_background_to_line, truncate_to_width, visible_width, wrap_text_with_ansi,
18};
19
20#[derive(Debug, Clone)]
22pub struct MarkdownOptions {
23 pub code_block_indent: usize,
25 pub max_width: Option<usize>,
27 pub base_style: String,
32}
33
34impl Default for MarkdownOptions {
35 fn default() -> Self {
36 Self {
37 code_block_indent: 2,
38 max_width: None,
39 base_style: String::new(),
40 }
41 }
42}
43
44pub struct Markdown {
46 content: Mutex<String>,
47 options: MarkdownOptions,
48 padding_x: usize,
49 padding_y: usize,
50 cache: Mutex<Option<MarkdownCache>>,
56}
57
58#[derive(Clone)]
59struct MarkdownCache {
60 content: String,
61 width: usize,
62 theme_revision: u64,
63 lines: Vec<String>,
64}
65
66impl Markdown {
67 pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
69 Self {
70 content: Mutex::new(content.into()),
71 options: MarkdownOptions::default(),
72 padding_x,
73 padding_y,
74 cache: Mutex::new(None),
75 }
76 }
77
78 pub fn with_options(
80 content: impl Into<String>,
81 options: MarkdownOptions,
82 padding_x: usize,
83 padding_y: usize,
84 ) -> Self {
85 Self {
86 content: Mutex::new(content.into()),
87 options,
88 padding_x,
89 padding_y,
90 cache: Mutex::new(None),
91 }
92 }
93
94 pub fn with_base_style(mut self, base_style: impl Into<String>) -> Self {
99 self.options.base_style = base_style.into();
100 self.clear_cache();
101 self
102 }
103
104 fn apply_base_style(&self, line: &str) -> String {
108 let base = self.options.base_style.as_str();
109 if base.is_empty() {
110 return line.to_string();
111 }
112 let reset = "\x1b[0m";
113 let mut out = String::with_capacity(line.len() + base.len() * 4 + reset.len());
114 out.push_str(base);
115 let mut rest = line;
116 while let Some(idx) = rest.find("\x1b[") {
117 out.push_str(&rest[..idx]);
118 rest = &rest[idx..];
119 let Some(end) = rest
120 .as_bytes()
121 .iter()
122 .enumerate()
123 .skip(2)
124 .find(|(_, byte)| (0x40..=0x7e).contains(*byte))
125 .map(|(index, _)| index + 1)
126 else {
127 break;
128 };
129 let sequence = &rest[..end];
130 out.push_str(sequence);
131 if matches!(sequence, "\x1b[0m" | "\x1b[m" | "\x1b[23m" | "\x1b[39m") {
134 out.push_str(base);
135 }
136 rest = &rest[end..];
137 }
138 out.push_str(rest);
139 out.push_str(reset);
142 out
143 }
144
145 pub fn set_content(&self, content: impl Into<String>) {
147 if let Ok(mut c) = self.content.lock() {
148 *c = content.into();
149 }
150 self.clear_cache();
151 }
152
153 fn clear_cache(&self) {
155 if let Ok(mut cache) = self.cache.lock() {
156 *cache = None;
157 }
158 }
159
160 pub fn get_content(&self) -> String {
162 self.content.lock().map(|c| c.clone()).unwrap_or_default()
163 }
164
165 fn pad(&self) -> String {
167 " ".repeat(self.padding_x)
168 }
169
170 fn content_width(&self, width: usize) -> usize {
172 width.saturating_sub(self.padding_x * 2)
173 }
174
175 fn render_markdown(&self, width: usize) -> Vec<String> {
177 let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
178 let colors = theme().colors;
179 let mut lines: Vec<String> = Vec::new();
180 let indent = " ".repeat(self.options.code_block_indent);
181 let pad = self.pad();
182 let cwidth = self.content_width(width);
183
184 for _ in 0..self.padding_y {
186 lines.push(String::new());
187 }
188
189 let raw_lines: Vec<&str> = content.lines().collect();
191 let mut i = 0;
192 let mut list_continuation_indent: Option<usize> = None;
196
197 let mut in_code_block = false;
200 let mut code_fence_char = '`';
201 let mut code_language = String::new();
202 let mut code_highlighter: Option<crate::syntax_highlight::Highlighter> = None;
206 let mut mermaid_source: Vec<String> = Vec::new();
207 while i < raw_lines.len() {
208 let line = raw_lines[i];
209
210 if is_partial_fence(line) {
217 i += 1;
218 continue;
219 }
220
221 let fence = fence_info(line);
223 if let Some((fence_char, lang)) = fence {
224 list_continuation_indent = None;
225 if !in_code_block {
228 in_code_block = true;
229 code_fence_char = fence_char;
230 code_language = lang.to_ascii_lowercase();
231 code_highlighter =
232 Some(crate::syntax_highlight::Highlighter::new(&code_language));
233 mermaid_source.clear();
234 } else if fence_char == code_fence_char {
239 if code_language == "mermaid" {
240 let component = Mermaid::new(mermaid_source.join("\n"));
241 let mermaid_lines = component.render(cwidth);
242 lines.extend(mermaid_lines.into_iter().map(|line| format!("{pad}{line}")));
243 }
244 in_code_block = false;
245 code_language.clear();
246 code_highlighter = None;
247 mermaid_source.clear();
248 } else {
251 push_code_line(
253 line,
254 &pad,
255 &indent,
256 cwidth,
257 &mut lines,
258 code_highlighter.as_mut(),
259 );
260 }
261 i += 1;
262 continue;
263 }
264
265 if in_code_block {
266 list_continuation_indent = None;
267 if code_language == "mermaid" {
268 mermaid_source.push(line.to_string());
269 i += 1;
270 continue;
271 }
272 push_code_line(
276 line,
277 &pad,
278 &indent,
279 cwidth,
280 &mut lines,
281 code_highlighter.as_mut(),
282 );
283 i += 1;
284 continue;
285 }
286
287 if is_table_separator(raw_lines.get(i + 1)) && is_table_row(line) {
289 list_continuation_indent = None;
290 let mut table_lines: Vec<&str> = Vec::new();
292 table_lines.push(line);
293 i += 1;
294 table_lines.push(raw_lines[i]);
296 i += 1;
297 while i < raw_lines.len() && is_table_row(raw_lines[i]) {
298 table_lines.push(raw_lines[i]);
299 i += 1;
300 }
301 render_table(&table_lines, &pad, cwidth, &mut lines, |cell| {
302 self.render_inline(cell)
303 });
304 continue;
305 }
306
307 if let Some(rest) = strip_header(line, "######") {
311 list_continuation_indent = None;
312 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
313 push_wrapped(&pad, &h, cwidth, &mut lines);
314 } else if let Some(rest) = strip_header(line, "#####") {
315 list_continuation_indent = None;
316 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
317 push_wrapped(&pad, &h, cwidth, &mut lines);
318 } else if let Some(rest) = strip_header(line, "####") {
319 list_continuation_indent = None;
320 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
321 push_wrapped(&pad, &h, cwidth, &mut lines);
322 } else if let Some(rest) = strip_header(line, "###") {
323 list_continuation_indent = None;
324 let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
325 push_wrapped(&pad, &h, cwidth, &mut lines);
326 } else if let Some(rest) = strip_header(line, "##") {
327 list_continuation_indent = None;
328 let h = colors
329 .md_heading
330 .fg(&bold(&underline(&self.render_inline(rest))));
331 push_wrapped(&pad, &h, cwidth, &mut lines);
332 } else if let Some(rest) = strip_header(line, "#") {
333 list_continuation_indent = None;
334 let h = colors
335 .md_heading
336 .fg(&bold(&underline(&self.render_inline(rest))));
337 push_wrapped(&pad, &h, cwidth, &mut lines);
338 } else if let Some(item) = parse_list_item(line) {
339 let marker = match item.marker {
340 ListMarker::Bullet => colors.md_list_bullet.fg("•"),
341 ListMarker::Ordered(marker) => colors.md_list_bullet.fg(&marker),
342 ListMarker::Task(marker) => colors.md_list_bullet.fg(&marker.to_string()),
343 };
344 let prefix = format!("{}{} ", " ".repeat(item.depth + 1), marker);
345 list_continuation_indent = Some(visible_width(&prefix));
346 push_wrapped_with_prefix(
347 &pad,
348 &prefix,
349 &self.render_inline(item.body),
350 cwidth,
351 &mut lines,
352 );
353 } else if let Some((depth, body)) = parse_blockquote(line) {
354 list_continuation_indent = None;
355 let border = colors.md_quote_border.fg("│");
356 let prefix = format!(" {}", format!("{border} ").repeat(depth));
357 let body = colors.md_quote.fg(&italic(&self.render_inline(body)));
358 push_wrapped_with_prefix(&pad, &prefix, &body, cwidth, &mut lines);
359 } else if line.trim().starts_with("---") || line.trim().starts_with("***") {
360 list_continuation_indent = None;
361 let rule_w = width.min(80).saturating_sub(self.padding_x * 2);
363 lines.push(format!(
364 "{pad}{}",
365 colors.md_hr.fg(&"─".repeat(rule_w.max(1)))
366 ));
367 } else if line.trim().is_empty() {
368 list_continuation_indent = None;
369 lines.push(String::new());
371 } else if let Some(indent) =
372 list_continuation_indent.filter(|_| line.starts_with(' ') || line.starts_with('\t'))
373 {
374 push_wrapped_with_prefix(
375 &pad,
376 &" ".repeat(indent),
377 &self.render_inline(line.trim()),
378 cwidth,
379 &mut lines,
380 );
381 } else {
382 list_continuation_indent = None;
383 push_wrapped(&pad, &self.render_inline(line), cwidth, &mut lines);
385 }
386
387 i += 1;
388 }
389
390 for _ in 0..self.padding_y {
392 lines.push(String::new());
393 }
394
395 if lines.is_empty() {
396 lines.push(String::new());
397 }
398
399 if !self.options.base_style.is_empty() {
402 for line in lines.iter_mut() {
403 *line = self.apply_base_style(line);
404 }
405 }
406
407 lines
408 }
409
410 fn render_inline(&self, text: &str) -> String {
412 let colors = theme().colors;
413 let mut options = Options::empty();
414 options.insert(Options::ENABLE_STRIKETHROUGH);
415
416 let source = format!("x {text}");
420 let mut discard_prefix = true;
421 let mut frames = vec![InlineFrame::root()];
422
423 for event in Parser::new_ext(&source, options) {
424 match event {
425 Event::Start(Tag::Strong) => frames.push(InlineFrame::new(InlineKind::Bold)),
426 Event::Start(Tag::Emphasis) => frames.push(InlineFrame::new(InlineKind::Italic)),
427 Event::Start(Tag::Strikethrough) => {
428 frames.push(InlineFrame::new(InlineKind::Strike));
429 }
430 Event::Start(Tag::Link { dest_url, .. }) => {
431 frames.push(InlineFrame::new(InlineKind::Link(dest_url.into_string())))
432 }
433 Event::Start(Tag::Image { dest_url, .. }) => {
434 frames.push(InlineFrame::new(InlineKind::Image(dest_url.into_string())))
435 }
436 Event::End(
437 TagEnd::Strong
438 | TagEnd::Emphasis
439 | TagEnd::Strikethrough
440 | TagEnd::Link
441 | TagEnd::Image,
442 ) => close_inline_frame(&mut frames, &colors),
443 Event::Text(value) => {
444 let mut value = value.as_ref();
445 if discard_prefix {
446 value = value.strip_prefix("x ").unwrap_or(value);
447 discard_prefix = false;
448 }
449 frames
450 .last_mut()
451 .expect("root inline frame")
452 .text
453 .push_str(value);
454 }
455 Event::Code(code) => {
456 let chip = format!(" {} ", colors.md_code.fg(code.trim()));
457 frames
458 .last_mut()
459 .expect("root inline frame")
460 .text
461 .push_str(&colors.md_code_bg.bg(&chip));
462 }
463 Event::InlineMath(value) => frames
464 .last_mut()
465 .expect("root inline frame")
466 .text
467 .push_str(&format!("${value}$")),
468 Event::DisplayMath(value) => frames
469 .last_mut()
470 .expect("root inline frame")
471 .text
472 .push_str(&format!("$${value}$$")),
473 Event::Html(value) | Event::InlineHtml(value) => frames
474 .last_mut()
475 .expect("root inline frame")
476 .text
477 .push_str(&value),
478 Event::FootnoteReference(label) => frames
479 .last_mut()
480 .expect("root inline frame")
481 .text
482 .push_str(&format!("[^{label}]")),
483 Event::SoftBreak => frames.last_mut().expect("root inline frame").text.push(' '),
484 Event::HardBreak => frames
485 .last_mut()
486 .expect("root inline frame")
487 .text
488 .push('\n'),
489 Event::TaskListMarker(checked) => frames
490 .last_mut()
491 .expect("root inline frame")
492 .text
493 .push(if checked { '☒' } else { '☐' }),
494 Event::Rule | Event::Start(_) | Event::End(_) => {}
495 }
496 }
497
498 while frames.len() > 1 {
499 close_inline_frame(&mut frames, &colors);
500 }
501 frames.pop().expect("root inline frame").text
502 }
503}
504
505#[derive(Debug)]
506enum InlineKind {
507 Root,
508 Bold,
509 Italic,
510 Strike,
511 Link(String),
512 Image(String),
513}
514
515#[derive(Debug)]
516struct InlineFrame {
517 kind: InlineKind,
518 text: String,
519}
520
521impl InlineFrame {
522 fn root() -> Self {
523 Self::new(InlineKind::Root)
524 }
525
526 fn new(kind: InlineKind) -> Self {
527 Self {
528 kind,
529 text: String::new(),
530 }
531 }
532}
533
534fn close_inline_frame(frames: &mut Vec<InlineFrame>, colors: &crate::theme::ThemeColors) {
535 let Some(frame) = (frames.len() > 1).then(|| frames.pop()).flatten() else {
536 return;
537 };
538 let rendered = match frame.kind {
539 InlineKind::Root => frame.text,
540 InlineKind::Bold => bold(&frame.text),
541 InlineKind::Italic => italic(&frame.text),
542 InlineKind::Strike => format!("\x1b[9m{}\x1b[29m", frame.text),
543 InlineKind::Link(url) => {
544 let label = colors.md_link.fg(&underline(&frame.text));
545 if crate::ansi::strip_ansi(&frame.text) == url {
546 label
547 } else {
548 format!("{label}{}", colors.md_link_url.fg(&format!(" ({url})")))
549 }
550 }
551 InlineKind::Image(url) => {
552 let alt = if frame.text.is_empty() {
553 "image".to_string()
554 } else {
555 frame.text
556 };
557 format!("{alt}{}", colors.md_link_url.fg(&format!(" ({url})")))
558 }
559 };
560 frames
561 .last_mut()
562 .expect("root inline frame")
563 .text
564 .push_str(&rendered);
565}
566
567enum ListMarker {
568 Bullet,
569 Ordered(String),
570 Task(char),
571}
572
573struct ListItem<'a> {
574 depth: usize,
575 marker: ListMarker,
576 body: &'a str,
577}
578
579fn parse_list_item(line: &str) -> Option<ListItem<'_>> {
583 let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
584 let content = &line[leading..];
585 let depth = line[..leading]
586 .chars()
587 .fold(0usize, |width, ch| width + if ch == '\t' { 4 } else { 1 })
588 / 2;
589
590 let (marker, body) = if let Some(rest) = content
591 .strip_prefix("- ")
592 .or_else(|| content.strip_prefix("* "))
593 .or_else(|| content.strip_prefix("+ "))
594 {
595 if let Some(task_body) = rest.strip_prefix("[ ]") {
596 (ListMarker::Task('☐'), task_body.trim_start())
597 } else if let Some(task_body) = rest
598 .strip_prefix("[x]")
599 .or_else(|| rest.strip_prefix("[X]"))
600 {
601 (ListMarker::Task('☒'), task_body.trim_start())
602 } else {
603 (ListMarker::Bullet, rest)
604 }
605 } else {
606 let marker_end = content
607 .bytes()
608 .take_while(|byte| byte.is_ascii_digit())
609 .count();
610 if marker_end == 0 {
611 return None;
612 }
613 let marker_char = content.as_bytes().get(marker_end)?;
614 if !matches!(marker_char, b'.' | b')')
615 || !content
616 .as_bytes()
617 .get(marker_end + 1)
618 .is_some_and(u8::is_ascii_whitespace)
619 {
620 return None;
621 }
622 (
623 ListMarker::Ordered(content[..marker_end + 1].to_string()),
624 content[marker_end + 2..].trim_start(),
625 )
626 };
627
628 Some(ListItem {
629 depth,
630 marker,
631 body,
632 })
633}
634
635fn parse_blockquote(line: &str) -> Option<(usize, &str)> {
638 let mut body = line.trim_start_matches([' ', '\t']);
639 let mut depth = 0;
640 while let Some(rest) = body.strip_prefix('>') {
641 depth += 1;
642 body = rest.strip_prefix(' ').unwrap_or(rest);
643 }
644 (depth > 0).then_some((depth, body))
645}
646
647fn fence_info(line: &str) -> Option<(char, String)> {
650 let trimmed = line.trim_start();
651 if trimmed.starts_with("```") {
652 Some(('`', trimmed[3..].trim().to_string()))
653 } else if trimmed.starts_with("~~~") {
654 Some(('~', trimmed[3..].trim().to_string()))
655 } else {
656 None
657 }
658}
659
660fn push_code_line(
670 line: &str,
671 pad: &str,
672 indent: &str,
673 cwidth: usize,
674 out: &mut Vec<String>,
675 highlighter: Option<&mut crate::syntax_highlight::Highlighter>,
676) {
677 let colors = theme().colors;
678 let panel_width = cwidth.saturating_sub(visible_width(indent)).max(1);
681 let body_width = panel_width.saturating_sub(2).max(1);
682 let expanded = line.replace('\t', " ");
683 let styled = match highlighter {
686 Some(h) => h.highlight(&expanded, &colors),
687 None => colors.md_code_block.fg(&expanded),
688 };
689 for chunk in wrap_text_with_ansi(&styled, body_width) {
690 let body = format!(" {chunk}");
691 let body =
692 apply_background_to_line(&body, panel_width, |text| colors.md_code_block_bg.bg(text));
693 out.push(format!("{pad}{indent}{body}"));
694 }
695}
696
697fn is_partial_fence(line: &str) -> bool {
702 let t = line.trim();
703 if t.is_empty() || t.len() > 2 {
704 return false;
705 }
706 t.chars().all(|c| c == '`' || c == '~')
707}
708
709fn strip_header<'a>(line: &'a str, hashes: &str) -> Option<&'a str> {
711 if line.starts_with(hashes) {
712 let rest = &line[hashes.len()..];
713 if let Some(stripped) = rest.strip_prefix(' ') {
714 return Some(stripped);
715 }
716 }
717 None
718}
719
720fn is_table_row(line: &str) -> bool {
722 let t = line.trim();
723 t.starts_with('|') && t.ends_with('|') && t.len() > 1
724}
725
726fn is_table_separator(maybe: Option<&&str>) -> bool {
728 match maybe {
729 Some(line) => {
730 let t = line.trim();
731 if !t.contains('|') {
732 return false;
733 }
734 let cell = |s: &str| {
735 s.chars()
736 .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
737 && s.contains('-')
738 };
739 let inner = t.trim_matches('|');
740 inner.split('|').all(cell)
741 }
742 None => false,
743 }
744}
745
746fn render_table<F>(rows: &[&str], pad: &str, cwidth: usize, out: &mut Vec<String>, render_inline: F)
748where
749 F: Fn(&str) -> String,
750{
751 let parse = |row: &str| -> Vec<String> {
753 split_table_cells(row)
754 .into_iter()
755 .map(|cell| render_inline(cell.trim()))
756 .collect()
757 };
758 if rows.len() < 2 {
759 return;
760 }
761 let header = parse(rows[0]);
762 let _separator = parse(rows[1]);
763 let body: Vec<Vec<String>> = rows[2..].iter().map(|r| parse(r)).collect();
764 let ncols = header.len();
765 if ncols == 0 {
766 return;
767 }
768
769 let mut col_w: Vec<usize> = (0..ncols)
771 .map(|c| {
772 std::iter::once(&header)
773 .chain(body.iter())
774 .map(|r| r.get(c).map(|s| visible_width(s)).unwrap_or(0))
775 .max()
776 .unwrap_or(0)
777 })
778 .collect();
779
780 let overhead = 3 * (ncols - 1) + 4; let budget = cwidth.saturating_sub(overhead);
784 let total: usize = col_w.iter().sum();
785 if total > budget {
786 let scale = budget as f64 / total as f64;
788 for w in col_w.iter_mut() {
789 *w = (*w as f64 * scale).round() as usize;
790 if *w < 1 {
791 *w = 1;
792 }
793 }
794 }
795
796 let join = |cells: &[String]| {
797 cells
798 .iter()
799 .enumerate()
800 .map(|(i, c)| {
801 let w = col_w.get(i).copied().unwrap_or(0);
802 let cell = truncate_to_width(c, w, "…");
803 pad_cell(&cell, w)
804 })
805 .collect::<Vec<_>>()
806 .join("│")
807 };
808
809 let border = |left: &str, mid: &str, joiner: &str, right: &str| -> String {
810 let segs: Vec<String> = col_w.iter().map(|w| mid.repeat(*w)).collect();
811 format!("{left}{}{right}", segs.join(joiner))
812 };
813
814 out.push(format!("{pad}{}", border("┌", "─", "┬", "┐")));
815 out.push(format!("{pad}│{}│", join(&header)));
816 out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
817 for (row_index, row) in body.iter().enumerate() {
818 let cells: Vec<String> = (0..ncols)
819 .map(|c| row.get(c).cloned().unwrap_or_default())
820 .collect();
821 out.push(format!("{pad}│{}│", join(&cells)));
822 if row_index + 1 < body.len() {
827 out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
828 }
829 }
830 out.push(format!("{pad}{}", border("└", "─", "┴", "┘")));
831}
832
833fn split_table_cells(row: &str) -> Vec<String> {
836 let inner = row.trim().trim_matches('|');
837 let mut cells = vec![String::new()];
838 let mut escaped = false;
839 let mut code_ticks = 0usize;
840
841 for ch in inner.chars() {
842 if escaped {
843 cells.last_mut().expect("table has one cell").push(ch);
844 escaped = false;
845 } else if ch == '\\' {
846 cells.last_mut().expect("table has one cell").push(ch);
847 escaped = true;
848 } else if ch == '`' {
849 code_ticks ^= 1;
850 cells.last_mut().expect("table has one cell").push(ch);
851 } else if ch == '|' && code_ticks == 0 {
852 cells.push(String::new());
853 } else {
854 cells.last_mut().expect("table has one cell").push(ch);
855 }
856 }
857 cells
858}
859
860fn pad_cell(cell: &str, w: usize) -> String {
862 let vw = visible_width(cell);
863 if vw >= w {
864 cell.to_string()
865 } else {
866 format!("{cell}{}", " ".repeat(w - vw))
867 }
868}
869
870fn push_wrapped(pad: &str, rendered: &str, cwidth: usize, out: &mut Vec<String>) {
873 if cwidth == 0 {
874 out.push(rendered.to_string());
875 return;
876 }
877 let wrapped = wrap_text_with_ansi(rendered, cwidth);
881 if wrapped.is_empty() {
882 out.push(String::new());
883 return;
884 }
885 for sub in wrapped {
886 out.push(format!("{pad}{sub}"));
887 }
888}
889
890fn push_wrapped_with_prefix(
895 pad: &str,
896 prefix: &str,
897 rendered: &str,
898 cwidth: usize,
899 out: &mut Vec<String>,
900) {
901 let prefix_width = visible_width(prefix);
902 let body_width = cwidth.saturating_sub(prefix_width).max(1);
903 let wrapped = wrap_text_with_ansi(rendered, body_width);
904 if wrapped.is_empty() {
905 out.push(format!("{pad}{prefix}"));
906 return;
907 }
908 let continuation = " ".repeat(prefix_width);
909 for (index, line) in wrapped.iter().enumerate() {
910 let line_prefix = if index == 0 { prefix } else { &continuation };
911 let body = if index == 0 {
915 line.as_str()
916 } else {
917 line.trim_start()
918 };
919 out.push(format!("{pad}{line_prefix}{body}"));
920 }
921}
922
923impl Component for Markdown {
924 fn render(&self, width: usize) -> Vec<String> {
925 let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
926 let theme_revision = crate::theme::theme_revision();
927
928 if let Ok(cache) = self.cache.lock() {
929 if let Some(cached) = cache.as_ref() {
930 if cached.width == width
931 && cached.content == content
932 && cached.theme_revision == theme_revision
933 {
934 return cached.lines.clone();
935 }
936 }
937 }
938
939 let lines = self.render_markdown(width);
940 if let Ok(mut cache) = self.cache.lock() {
941 *cache = Some(MarkdownCache {
942 content,
943 width,
944 theme_revision,
945 lines: lines.clone(),
946 });
947 }
948 lines
949 }
950
951 fn invalidate(&self) {
952 self.clear_cache();
953 }
954
955 fn as_any(&self) -> &dyn Any {
956 self
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 #[test]
965 fn test_markdown_headers() {
966 let md = Markdown::new("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6", 0, 0);
967 let lines = md.render(80);
968 let plain: Vec<String> = lines
969 .iter()
970 .map(|line| crate::ansi::strip_ansi(line))
971 .collect();
972 assert_eq!(plain, vec!["H1", "H2", "H3", "H4", "H5", "H6"]);
973 }
974
975 #[test]
976 fn test_commonmark_inline_parser_handles_nested_spans_and_images() {
977 let md = Markdown::new(
978 "This is ***nested***, ``a ` b``, <https://example.com>, ",
979 0,
980 0,
981 );
982 let rendered = md.render(120).join("\n");
983 let plain = crate::ansi::strip_ansi(&rendered);
984
985 assert_eq!(
986 plain,
987 "This is nested, a ` b , https://example.com, plot (plot.png)"
988 );
989 assert!(rendered.contains("\x1b[1m"), "nested strong style missing");
990 assert!(rendered.contains("\x1b[3m"), "nested emphasis missing");
991 assert!(
992 rendered.contains("\x1b[48;2;48;52;59m"),
993 "multi-backtick code span should use the code chip"
994 );
995 }
996
997 #[test]
998 fn test_markdown_inline() {
999 let md = Markdown::new("This is **bold** and *italic* text", 0, 0);
1000 let lines = md.render(80);
1001 assert!(!lines[0].is_empty());
1002 }
1003
1004 #[test]
1005 fn test_markdown_code() {
1006 let md = Markdown::new("Use `code` here", 0, 0);
1007 let lines = md.render(80);
1008 assert!(!lines[0].is_empty());
1009 assert!(
1010 lines[0].contains("\x1b[48;2;48;52;59m"),
1011 "inline code should use the theme chip background"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_markdown_no_panic_on_wide_cjk() {
1017 let md = Markdown::new("**你好世界**这是一段很长的中文内容需要自动换行", 1, 0);
1020 let lines = md.render(10);
1021 assert!(lines.len() > 1);
1023 }
1024
1025 #[test]
1026 fn test_markdown_wraps_long_line() {
1027 let long = "a".repeat(100);
1028 let md = Markdown::new(long, 0, 0);
1029 let lines = md.render(20);
1030 assert!(lines.len() > 1, "a 100-col line at width 20 should wrap");
1031 }
1032
1033 #[test]
1034 fn test_markdown_code_block_has_background_without_frame() {
1035 let md = Markdown::new("```rust\nlet x = 1;\n```", 0, 0);
1036 let joined = md.render(80).join("\n");
1037 let plain = crate::ansi::strip_ansi(&joined);
1038 assert!(!plain.contains("rust"), "language label leaked: {plain}");
1039 assert!(plain.contains(" let x = 1;"), "missing code body: {plain}");
1040 assert!(
1041 !plain.contains(['╭', '╰', '│']),
1042 "code block must not draw an outer frame: {plain}"
1043 );
1044 assert!(
1045 joined.contains("\x1b[48;2;40;44;52m"),
1046 "code panel background missing: {joined:?}"
1047 );
1048 assert!(
1049 !plain.contains("```"),
1050 "literal fences should be hidden: {plain}"
1051 );
1052 }
1053
1054 #[test]
1055 fn code_fence_language_metadata_is_not_rendered() {
1056 let md = Markdown::new(
1057 "```text\nplain block\n```\n\n```bash\necho hello\n```",
1058 0,
1059 0,
1060 );
1061 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1062 assert!(plain.contains("plain block"));
1063 assert!(plain.contains("echo hello"));
1064 assert!(!plain.contains("text"), "text label leaked: {plain}");
1065 assert!(!plain.contains("bash"), "bash label leaked: {plain}");
1066 }
1067
1068 #[test]
1069 fn test_mermaid_fence_uses_diagram_renderer() {
1070 let md = Markdown::new("```mermaid\nflowchart LR\nA[Start] --> B[End]\n```", 0, 0);
1071 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1072 assert!(plain.contains("mermaid"));
1073 assert!(
1074 plain.contains("Start ──▶ End"),
1075 "diagram body missing: {plain}"
1076 );
1077 assert!(
1078 !plain.contains("flowchart LR"),
1079 "raw mermaid header leaked: {plain}"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_markdown_padding_applies_to_first_and_wrapped_lines() {
1085 let md = Markdown::new("abcdefgh", 2, 0);
1086 let lines = md.render(6);
1087 assert_eq!(lines, vec![" ab", " cd", " ef", " gh"]);
1088 }
1089
1090 #[test]
1091 fn test_markdown_code_expands_tabs_and_wraps_long_lines() {
1092 let md = Markdown::new("```\n\tlet value = 123456;\n```", 0, 0);
1093 let lines = md.render(14);
1094 let plain: Vec<String> = lines
1095 .iter()
1096 .map(|line| crate::ansi::strip_ansi(line))
1097 .collect();
1098 assert!(
1099 plain[0].starts_with(" let"),
1100 "tab/indent lost: {plain:?}"
1101 );
1102 assert!(
1106 !plain.iter().any(|line| line.contains('…')),
1107 "long code line must not clip: {plain:?}"
1108 );
1109 assert!(
1110 plain.iter().filter(|line| !line.trim().is_empty()).count() >= 2,
1111 "long code line should wrap: {plain:?}"
1112 );
1113 let compact: String = plain.iter().map(|line| line.replace(' ', "")).collect();
1114 assert!(
1115 compact.contains("letvalue=123456;"),
1116 "wrapped rows lost text: {plain:?}"
1117 );
1118 assert!(plain.iter().all(|line| visible_width(line) <= 14));
1119 }
1120
1121 #[test]
1122 fn test_markdown_task_list_checkbox() {
1123 let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0);
1124 let joined = md.render(80).join("\n");
1125 assert!(joined.contains('☒'), "checked marker missing");
1126 assert!(joined.contains('☐'), "unchecked marker missing");
1127 }
1128
1129 #[test]
1130 fn test_markdown_table_renders_borders() {
1131 let md = Markdown::new("| a | b |\n|---|---|\n| 1 | 2 |", 0, 0);
1132 let joined = md.render(80).join("\n");
1133 assert!(joined.contains('┌'), "missing top-left");
1134 assert!(joined.contains('┬'), "missing top-mid");
1135 assert!(joined.contains('┐'), "missing top-right");
1136 assert!(joined.contains('└'), "missing bottom-left");
1137 }
1138
1139 #[test]
1140 fn test_markdown_table_has_internal_horizontal_lines() {
1141 let md = Markdown::new(
1142 "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |",
1143 0,
1144 0,
1145 );
1146 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1147 assert_eq!(
1148 plain.matches('├').count(),
1149 3,
1150 "missing internal row lines: {plain}"
1151 );
1152 assert_eq!(
1153 plain.matches('┼').count(),
1154 3,
1155 "missing internal separators: {plain}"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_markdown_double_underscore_is_bold() {
1161 let md = Markdown::new("__strong__", 0, 0);
1162 let joined = md.render(80).join("\n");
1163 assert!(joined.contains("\x1b[1m"), "`__` should render bold");
1165 assert!(
1166 !joined.contains("\x1b[4m"),
1167 "`__` should NOT render underline"
1168 );
1169 }
1170
1171 #[test]
1172 fn test_markdown_hr_capped() {
1173 let md = Markdown::new("---", 0, 0);
1174 let lines = md.render(200);
1175 assert_eq!(visible_width(&lines[0]), 80);
1177 }
1178
1179 #[test]
1180 fn test_markdown_h4_does_not_fall_through() {
1181 let md = Markdown::new("#### nested", 0, 0);
1182 let joined = md.render(80).join("\n");
1183 assert!(joined.contains("\x1b[1m"), "h4 should be bold");
1184 }
1185
1186 #[test]
1187 fn test_markdown_nested_lists_keep_their_text_indent_when_wrapped() {
1188 let md = Markdown::new(
1189 "- parent item that wraps over several terminal columns\n - child item\n child continuation",
1190 0,
1191 0,
1192 );
1193 let plain: Vec<String> = md
1194 .render(20)
1195 .iter()
1196 .map(|line| crate::ansi::strip_ansi(line))
1197 .collect();
1198
1199 assert!(
1200 plain[0].starts_with(" • "),
1201 "root marker missing: {plain:?}"
1202 );
1203 assert!(
1204 plain.iter().any(|line| line.starts_with(" wraps")),
1205 "wrapped root text should align after its marker: {plain:?}"
1206 );
1207 assert!(
1208 plain.iter().any(|line| line.starts_with(" • child")),
1209 "nested marker missing: {plain:?}"
1210 );
1211 assert!(
1212 plain.iter().any(|line| line.starts_with(" child")),
1213 "continuation should align after nested marker: {plain:?}"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_markdown_nested_blockquote_and_unclosed_inline_are_literal() {
1219 let md = Markdown::new(
1220 "> > nested quote\ntext with *an unfinished emphasis and `unfinished code",
1221 0,
1222 0,
1223 );
1224 let plain: Vec<String> = md
1225 .render(80)
1226 .iter()
1227 .map(|line| crate::ansi::strip_ansi(line))
1228 .collect();
1229 assert!(plain[0].starts_with(" │ │ nested quote"), "{plain:?}");
1230 assert_eq!(
1231 plain[1],
1232 "text with *an unfinished emphasis and `unfinished code"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_markdown_table_preserves_pipes_in_inline_code() {
1238 let md = Markdown::new("| Name | Value |\n|---|---|\n| **bold** | `a|b` |", 0, 0);
1239 let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1240 assert_eq!(
1241 plain.matches('┬').count(),
1242 1,
1243 "table columns split incorrectly: {plain}"
1244 );
1245 assert!(plain.contains("bold"), "inline formatting lost: {plain}");
1246 assert!(
1247 plain.contains("a|b"),
1248 "code pipe split a table cell: {plain}"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_base_style_reopened_after_inline_code_reset() {
1254 let base = "\x1b[3m\x1b[38;5;244m";
1258 let md = Markdown::new("think `code` after", 0, 0).with_base_style(base);
1259 let rendered = md.render(80).join("\n");
1260 let after = rendered
1261 .split("code")
1262 .nth(1)
1263 .expect("inline code not rendered");
1264 assert!(
1265 after.contains("\x1b[38;5;244m"),
1266 "base style not reopened after inline code: {rendered:?}"
1267 );
1268 assert!(
1269 rendered.ends_with("\x1b[0m"),
1270 "rendered line must be reset-terminated: {rendered:?}"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_base_style_applies_to_every_line() {
1276 let base = "\x1b[3m\x1b[38;5;244m";
1279 let md = Markdown::new("first thought\n\nsecond thought", 0, 0).with_base_style(base);
1280 let lines = md.render(80);
1281 for line in lines.iter().filter(|l| !l.is_empty()) {
1282 assert!(
1283 line.contains("\x1b[38;5;244m"),
1284 "base style missing on line: {line:?}"
1285 );
1286 }
1287 }
1288}