use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; /// Render a markdown string into wrapped ratatui lines. pub fn render_markdown(text: &str, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); let mut current_spans: Vec> = Vec::new(); let mut current_text = String::new(); let mut in_bold = false; let mut in_italic = false; let mut in_code_block = false; let mut code_block_language = String::new(); let mut list_stack: Vec = Vec::new(); let flush = |current: &mut String, spans: &mut Vec>, bold, italic| { if current.is_empty() { return; } let style = base_style(bold, italic); let span = Span::styled(std::mem::take(current), style); spans.push(span); }; for event in Parser::new(text) { match event { Event::Start(tag) => match tag { Tag::Paragraph => { if !lines.is_empty() && !current_text.is_empty() { flush(&mut current_text, &mut current_spans, in_bold, in_italic); wrap_spans(&mut lines, &mut current_spans, width); } } Tag::Heading { level, .. } => { let level_num = heading_level_from(level); current_text.push_str(&"#".repeat(level_num as usize)); current_text.push(' '); } Tag::Strong => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); in_bold = true; } Tag::Emphasis => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); in_italic = true; } Tag::List(start) => { list_stack.push(start.unwrap_or(1)); } Tag::Item => { let prefix = if let Some(n) = list_stack.last_mut() { let p = format!("{n}. "); *n += 1; p } else { "• ".to_string() }; current_text.push_str(&prefix); } Tag::CodeBlock(lang) => { in_code_block = true; code_block_language = match lang { CodeBlockKind::Fenced(name) => name.to_string(), CodeBlockKind::Indented => String::new(), }; if !current_text.is_empty() || !current_spans.is_empty() { flush(&mut current_text, &mut current_spans, in_bold, in_italic); wrap_spans(&mut lines, &mut current_spans, width); } let border = if code_block_language.is_empty() { "┌────".to_string() } else { format!("┌──── {code_block_language}") }; lines.push(Line::from(Span::styled(border, code_style()))); } _ => {} }, Event::End(tag_end) => match tag_end { TagEnd::Heading(_) => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); let mut spans = std::mem::take(&mut current_spans); for span in &mut spans { span.style = span.style.add_modifier(Modifier::BOLD); } wrap_spans(&mut lines, &mut spans, width); } TagEnd::Paragraph => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); wrap_spans(&mut lines, &mut current_spans, width); } TagEnd::Strong => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); in_bold = false; } TagEnd::Emphasis => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); in_italic = false; } TagEnd::List(_) => { list_stack.pop(); } TagEnd::CodeBlock => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); if !current_spans.is_empty() { for line in wrap_spans_to_lines(&mut current_spans, width) { lines.push(prefix_code_block_line(line)); } } lines.push(Line::from(Span::styled("└────", code_style()))); in_code_block = false; code_block_language.clear(); } _ => {} }, Event::Text(t) => { if in_code_block { flush(&mut current_text, &mut current_spans, in_bold, in_italic); current_spans.push(Span::styled(t.to_string(), code_style())); } else { current_text.push_str(&t); } } Event::Code(c) => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); current_spans.push(Span::styled(c.to_string(), code_style())); } Event::Html(h) | Event::InlineHtml(h) => { current_text.push_str(&h); } Event::SoftBreak | Event::HardBreak => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); wrap_spans(&mut lines, &mut current_spans, width); } Event::Rule => { flush(&mut current_text, &mut current_spans, in_bold, in_italic); wrap_spans(&mut lines, &mut current_spans, width); lines.push(Line::from("─".repeat(width as usize))); } _ => {} } } flush(&mut current_text, &mut current_spans, in_bold, in_italic); if in_code_block { for line in wrap_spans_to_lines(&mut current_spans, width) { lines.push(prefix_code_block_line(line)); } lines.push(Line::from(Span::styled("└────", code_style()))); } else { wrap_spans(&mut lines, &mut current_spans, width); } if lines.is_empty() { lines.push(Line::default()); } lines } fn heading_level_from(level: HeadingLevel) -> u8 { match level { HeadingLevel::H1 => 1, HeadingLevel::H2 => 2, HeadingLevel::H3 => 3, HeadingLevel::H4 => 4, HeadingLevel::H5 => 5, HeadingLevel::H6 => 6, } } fn base_style(bold: bool, italic: bool) -> Style { let mut style = Style::new(); if bold { style = style.add_modifier(Modifier::BOLD); } if italic { style = style.add_modifier(Modifier::ITALIC); } style } fn code_style() -> Style { Style::new().fg(Color::Yellow) } fn prefix_code_block_line(line: Line<'static>) -> Line<'static> { let mut spans = vec![Span::styled("│ ", code_style())]; spans.extend(line.spans); Line::from(spans) } /// Flush accumulated spans into `lines`, wrapping to `width`. fn wrap_spans(lines: &mut Vec>, spans: &mut Vec>, width: u16) { if spans.is_empty() { return; } for line in wrap_spans_to_lines(spans, width) { lines.push(line); } spans.clear(); } fn wrap_spans_to_lines(spans: &mut Vec>, width: u16) -> Vec> { let width = width.max(1) as usize; let mut out: Vec> = Vec::new(); let mut current_line: Vec> = Vec::new(); let mut current_width = 0usize; for span in spans.drain(..) { for word in span.content.split(' ') { let word_width = word.chars().count(); let sep = if current_width == 0 { 0 } else { 1 }; if current_width + sep + word_width > width && current_width > 0 { out.push(Line::from(std::mem::take(&mut current_line))); current_width = 0; } if current_width > 0 { current_line.push(Span::styled(" ", span.style)); current_width += 1; } current_line.push(Span::styled(word.to_string(), span.style)); current_width += word_width; } } if !current_line.is_empty() { out.push(Line::from(current_line)); } out } #[cfg(test)] mod tests { use super::*; #[test] fn renders_heading_and_bold() { let lines = render_markdown("# Hello\n\n**bold** text", 40); assert!(!lines.is_empty()); let first: String = lines[0] .spans .iter() .map(|s| s.content.to_string()) .collect(); assert!(first.contains("# Hello")); } #[test] fn wraps_long_paragraphs() { let text = "a ".repeat(50); let lines = render_markdown(&text, 20); assert!(lines.len() > 1); } }