use std::any::Any;
use std::sync::Mutex;
use super::component::Component;
use crate::ansi::{bold, italic, underline};
use crate::theme::theme;
use crate::utils::{truncate_to_width, visible_width, wrap_text_with_ansi};
#[derive(Debug, Clone)]
pub struct MarkdownOptions {
pub code_block_indent: usize,
pub max_width: Option<usize>,
}
impl Default for MarkdownOptions {
fn default() -> Self {
Self {
code_block_indent: 2,
max_width: None,
}
}
}
pub struct Markdown {
content: Mutex<String>,
options: MarkdownOptions,
padding_x: usize,
padding_y: usize,
}
impl Markdown {
pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
Self {
content: Mutex::new(content.into()),
options: MarkdownOptions::default(),
padding_x,
padding_y,
}
}
pub fn with_options(content: impl Into<String>, options: MarkdownOptions, padding_x: usize, padding_y: usize) -> Self {
Self {
content: Mutex::new(content.into()),
options,
padding_x,
padding_y,
}
}
pub fn set_content(&self, content: impl Into<String>) {
if let Ok(mut c) = self.content.lock() {
*c = content.into();
}
}
pub fn get_content(&self) -> String {
self.content.lock().map(|c| c.clone()).unwrap_or_default()
}
fn pad(&self) -> String {
" ".repeat(self.padding_x)
}
fn content_width(&self, width: usize) -> usize {
width.saturating_sub(self.padding_x * 2)
}
fn render_markdown(&self, width: usize) -> Vec<String> {
let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
let colors = theme().colors;
let mut lines: Vec<String> = Vec::new();
let indent = " ".repeat(self.options.code_block_indent);
let pad = self.pad();
let cwidth = self.content_width(width);
for _ in 0..self.padding_y {
lines.push(String::new());
}
let raw_lines: Vec<&str> = content.lines().collect();
let mut i = 0;
let mut in_code_block = false;
while i < raw_lines.len() {
let line = raw_lines[i];
let fence = fence_info(line);
if let Some(lang) = fence {
in_code_block = !in_code_block;
if in_code_block {
let label = lang.clone();
lines.push(format!(
"{pad}{indent}{}",
colors.md_code_block_border.fg(&format!("```{}", label))
));
} else {
lines.push(format!("{pad}{indent}{}", colors.md_code_block_border.fg("```")));
}
i += 1;
continue;
}
if in_code_block {
let prefix = format!("{pad}{indent}{} ", colors.md_code_block_border.fg("│"));
let body_width = cwidth.saturating_sub(visible_width(&prefix));
let clipped = truncate_to_width(line, body_width.max(1), "…");
lines.push(format!("{prefix}{}", colors.md_code_block.fg(&clipped)));
i += 1;
continue;
}
if is_table_separator(raw_lines.get(i + 1)) && is_table_row(line) {
let mut table_lines: Vec<&str> = Vec::new();
table_lines.push(line);
i += 1;
table_lines.push(raw_lines[i]);
i += 1;
while i < raw_lines.len() && is_table_row(raw_lines[i]) {
table_lines.push(raw_lines[i]);
i += 1;
}
render_table(&table_lines, &pad, cwidth, &mut lines);
continue;
}
if let Some(rest) = strip_header(line, "######") {
let h = bold(&colors.md_heading.fg(&format!("###### {rest}")));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if let Some(rest) = strip_header(line, "#####") {
let h = bold(&colors.md_heading.fg(&format!("##### {rest}")));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if let Some(rest) = strip_header(line, "####") {
let h = bold(&colors.md_heading.fg(&format!("#### {rest}")));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if let Some(rest) = strip_header(line, "###") {
let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if let Some(rest) = strip_header(line, "##") {
let inner = bold(&underline(&self.render_inline(rest)));
let h = colors.md_heading.fg(&format!("{}{}", bold("## "), inner));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if let Some(rest) = strip_header(line, "#") {
let inner = bold(&underline(&bold(&self.render_inline(rest))));
let h = colors.md_heading.fg(&format!("{}{}", bold("# "), inner));
push_wrapped(&pad, &h, cwidth, &mut lines);
} else if is_task_list_item(line) {
let (marker, body) = task_list_item(line).unwrap();
let bullet = colors.md_list_bullet.fg(&format!("{marker}"));
let rendered = format!("{pad} {bullet} {}", self.render_inline(body));
push_wrapped(&pad, &rendered, cwidth, &mut lines);
} else if line.starts_with("- ") || line.starts_with("* ") {
let bullet = colors.md_list_bullet.fg("•");
let rendered = format!("{pad} {bullet} {}", self.render_inline(line[2..].trim()));
push_wrapped(&pad, &rendered, cwidth, &mut lines);
} else if line.starts_with(|c: char| c.is_ascii_digit()) && line.contains(". ") {
let (num, rest) = split_ordered(line);
let marker = colors.md_list_bullet.fg(&num);
let rendered = format!("{pad} {marker}{}", self.render_inline(rest));
push_wrapped(&pad, &rendered, cwidth, &mut lines);
} else if line.starts_with("> ") {
let border = colors.md_quote_border.fg("│");
let body = colors.md_quote.fg(&italic(&self.render_inline(&line[2..])));
let rendered = format!("{pad} {border} {body}");
push_wrapped(&pad, &rendered, cwidth, &mut lines);
} else if line.trim().starts_with("---") || line.trim().starts_with("***") {
let rule_w = width.min(80).saturating_sub(self.padding_x * 2);
lines.push(format!("{pad}{}", colors.md_hr.fg(&"─".repeat(rule_w.max(1)))));
} else if line.trim().is_empty() {
lines.push(String::new());
} else {
push_wrapped(&pad, &self.render_inline(line), cwidth, &mut lines);
}
i += 1;
}
for _ in 0..self.padding_y {
lines.push(String::new());
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn render_inline(&self, text: &str) -> String {
let colors = theme().colors;
let mut result = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '*' {
if chars.peek() == Some(&'*') {
chars.next(); let bold_text = self.consume_until(&mut chars, "**");
result.push_str(&bold(&bold_text));
} else {
let italic_text = self.consume_until(&mut chars, "*");
result.push_str(&italic(&italic_text));
}
} else if c == '_' {
if chars.peek() == Some(&'_') {
chars.next();
let bold_text = self.consume_until(&mut chars, "__");
result.push_str(&bold(&bold_text));
} else {
let italic_text = self.consume_until(&mut chars, "_");
result.push_str(&italic(&italic_text));
}
} else if c == '`' {
let code_text = self.consume_until(&mut chars, "`");
result.push_str(&colors.md_code.fg(&code_text));
} else if c == '[' {
let link_text = self.consume_until(&mut chars, "]");
if chars.next() == Some('(') {
let url = self.consume_until(&mut chars, ")");
let styled = colors.md_link.fg(&underline(&link_text));
if link_text == url {
result.push_str(&styled);
} else {
result.push_str(&styled);
result.push_str(&colors.md_link_url.fg(&format!(" ({})", url)));
}
} else {
result.push('[');
result.push_str(&link_text);
}
} else if c == '~' && chars.peek() == Some(&'~') {
chars.next(); let del_text = self.consume_until(&mut chars, "~~");
result.push_str(&"\x1b[9m"); result.push_str(&del_text);
result.push_str(&"\x1b[29m"); } else {
result.push(c);
}
}
result
}
fn consume_until(&self, chars: &mut std::iter::Peekable<std::str::Chars<'_>>, delimiter: &str) -> String {
let mut result = String::new();
let delim_chars: Vec<char> = delimiter.chars().collect();
while let Some(c) = chars.peek() {
if *c == delim_chars[0] {
let mut matches = true;
let mut lookahead: Vec<char> = Vec::new();
for (_i, dc) in delim_chars.iter().enumerate() {
if let Some(&next) = chars.peek() {
if next == *dc {
lookahead.push(next);
chars.next();
} else {
matches = false;
break;
}
} else {
matches = false;
break;
}
}
if matches {
break;
} else {
result.extend(lookahead);
}
} else {
result.push(*c);
chars.next();
}
}
result
}
}
fn fence_info(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
Some(trimmed[3..].trim().to_string())
} else if trimmed.starts_with("~~~") {
Some(trimmed[3..].trim().to_string())
} else {
None
}
}
fn split_ordered(line: &str) -> (String, &str) {
let mut idx = 0;
let bytes = line.as_bytes();
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx < bytes.len() && (bytes[idx] == b'.' || bytes[idx] == b')') {
idx += 1;
}
if idx < bytes.len() && bytes[idx] == b' ' {
idx += 1;
}
(line[..idx].to_string(), &line[idx..])
}
fn strip_header<'a>(line: &'a str, hashes: &str) -> Option<&'a str> {
if line.starts_with(hashes) {
let rest = &line[hashes.len()..];
if let Some(stripped) = rest.strip_prefix(' ') {
return Some(stripped);
}
}
None
}
fn is_table_row(line: &str) -> bool {
let t = line.trim();
t.starts_with('|') && t.ends_with('|') && t.len() > 1
}
fn is_table_separator(maybe: Option<&&str>) -> bool {
match maybe {
Some(line) => {
let t = line.trim();
if !t.contains('|') {
return false;
}
let cell = |s: &str| {
s.chars()
.all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
&& s.contains('-')
};
let inner = t.trim_matches('|');
inner.split('|').all(cell)
}
None => false,
}
}
fn render_table(rows: &[&str], pad: &str, cwidth: usize, out: &mut Vec<String>) {
let parse = |row: &str| -> Vec<String> {
row.trim()
.trim_matches('|')
.split('|')
.map(|c| c.trim().to_string())
.collect()
};
if rows.len() < 2 {
return;
}
let header = parse(rows[0]);
let _separator = parse(rows[1]);
let body: Vec<Vec<String>> = rows[2..].iter().map(|r| parse(r)).collect();
let ncols = header.len();
if ncols == 0 {
return;
}
let mut col_w: Vec<usize> = (0..ncols)
.map(|c| {
std::iter::once(&header)
.chain(body.iter())
.map(|r| r.get(c).map(|s| visible_width(s)).unwrap_or(0))
.max()
.unwrap_or(0)
})
.collect();
let overhead = 3 * (ncols - 1) + 4; let budget = cwidth.saturating_sub(overhead);
let total: usize = col_w.iter().sum();
if total > budget {
let scale = budget as f64 / total as f64;
for w in col_w.iter_mut() {
*w = (*w as f64 * scale).round() as usize;
if *w < 1 {
*w = 1;
}
}
}
let join = |cells: &[String]| {
cells
.iter()
.enumerate()
.map(|(i, c)| {
let w = col_w.get(i).copied().unwrap_or(0);
let cell = truncate_to_width(c, w, "…");
pad_cell(&cell, w)
})
.collect::<Vec<_>>()
.join("│")
};
let border = |left: &str, mid: &str, joiner: &str, right: &str| -> String {
let segs: Vec<String> = col_w.iter().map(|w| mid.repeat(*w)).collect();
format!("{left}{}{right}", segs.join(joiner))
};
out.push(format!("{pad}{}", border("┌", "─", "┬", "┐")));
out.push(format!("{pad}│{}│", join(&header)));
out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
for row in &body {
let cells: Vec<String> = (0..ncols)
.map(|c| row.get(c).cloned().unwrap_or_default())
.collect();
out.push(format!("{pad}│{}│", join(&cells)));
}
out.push(format!("{pad}{}", border("└", "─", "┴", "┘")));
}
fn pad_cell(cell: &str, w: usize) -> String {
let vw = visible_width(cell);
if vw >= w {
cell.to_string()
} else {
format!("{cell}{}", " ".repeat(w - vw))
}
}
fn is_task_list_item(line: &str) -> bool {
task_list_item(line).is_some()
}
fn task_list_item(line: &str) -> Option<(char, &str)> {
let t = line.trim_start();
let after = t.get(2..)?;
if let Some(body) = after.strip_prefix("[ ]") {
return Some(('☐', body.trim_start()));
}
if let Some(body) = after.strip_prefix("[x]") {
return Some(('☒', body.trim_start()));
}
if let Some(body) = after.strip_prefix("[X]") {
return Some(('☒', body.trim_start()));
}
None
}
fn push_wrapped(pad: &str, rendered: &str, cwidth: usize, out: &mut Vec<String>) {
if cwidth == 0 {
out.push(rendered.to_string());
return;
}
let wrapped = wrap_text_with_ansi(rendered, cwidth);
if wrapped.is_empty() {
out.push(String::new());
return;
}
for (idx, sub) in wrapped.iter().enumerate() {
if idx == 0 {
out.push(sub.clone());
} else {
out.push(format!("{pad}{sub}"));
}
}
}
impl Component for Markdown {
fn render(&self, width: usize) -> Vec<String> {
self.render_markdown(width)
}
fn invalidate(&self) {
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_headers() {
let md = Markdown::new("# Title\n## Subtitle\n### Heading", 0, 0);
let lines = md.render(80);
assert_eq!(lines.len(), 3);
}
#[test]
fn test_markdown_inline() {
let md = Markdown::new("This is **bold** and *italic* text", 0, 0);
let lines = md.render(80);
assert!(!lines[0].is_empty());
}
#[test]
fn test_markdown_code() {
let md = Markdown::new("Use `code` here", 0, 0);
let lines = md.render(80);
assert!(!lines[0].is_empty());
}
#[test]
fn test_markdown_no_panic_on_wide_cjk() {
let md = Markdown::new("**你好世界**这是一段很长的中文内容需要自动换行", 1, 0);
let lines = md.render(10);
assert!(lines.len() > 1);
}
#[test]
fn test_markdown_wraps_long_line() {
let long = "a".repeat(100);
let md = Markdown::new(long, 0, 0);
let lines = md.render(20);
assert!(lines.len() > 1, "a 100-col line at width 20 should wrap");
}
#[test]
fn test_markdown_code_block_has_closing_border() {
let md = Markdown::new("```rust\nlet x = 1;\n```", 0, 0);
let joined = md.render(80).join("\n");
assert!(joined.contains("```rust"), "missing opening fence label");
assert!(joined.contains("let x = 1;"), "missing code body");
assert_eq!(joined.matches("```").count(), 2, "expected open+close fences");
}
#[test]
fn test_markdown_task_list_checkbox() {
let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0);
let joined = md.render(80).join("\n");
assert!(joined.contains('☒'), "checked marker missing");
assert!(joined.contains('☐'), "unchecked marker missing");
}
#[test]
fn test_markdown_table_renders_borders() {
let md = Markdown::new("| a | b |\n|---|---|\n| 1 | 2 |", 0, 0);
let joined = md.render(80).join("\n");
assert!(joined.contains('┌'), "missing top-left");
assert!(joined.contains('┬'), "missing top-mid");
assert!(joined.contains('┐'), "missing top-right");
assert!(joined.contains('└'), "missing bottom-left");
}
#[test]
fn test_markdown_double_underscore_is_bold() {
let md = Markdown::new("__strong__", 0, 0);
let joined = md.render(80).join("\n");
assert!(joined.contains("\x1b[1m"), "`__` should render bold");
assert!(!joined.contains("\x1b[4m"), "`__` should NOT render underline");
}
#[test]
fn test_markdown_hr_capped() {
let md = Markdown::new("---", 0, 0);
let lines = md.render(200);
assert_eq!(visible_width(&lines[0]), 80);
}
#[test]
fn test_markdown_h4_does_not_fall_through() {
let md = Markdown::new("#### nested", 0, 0);
let joined = md.render(80).join("\n");
assert!(joined.contains("\x1b[1m"), "h4 should be bold");
}
}