//! Markdown-to-styled-spans rendering for the chat transcript. //! //! Flow: `render_markdown` walks a `pulldown_cmark` event stream and //! translates each markdown construct into styled `ratatui::text::Span`s, //! then re-wraps the flat span list to a target column width. //! //! Design: code blocks get a dark background with a labeled top bar, //! headings are bold with distinct colors, blockquotes get a vertical //! accent bar prefix, and inline code is highlighted with a background. //! Deliberately adds no leading indentation of its own for paragraphs, //! headings, or list bullets — the caller (`chat.rs`) owns column //! alignment via its `PREFIX_WIDTH` scheme, so any indent added here //! would only apply to a construct's first rendered line and throw //! wrapped continuation lines out of alignment with it. Code-block lines //! are the exception: every line gets its `" "` prefix independently //! and consistently, so there's no first-line-only misalignment there. use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use super::theme::Theme; /// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// /// Flow: `pulldown_cmark` parses `text` into an event stream → each /// Start/End/Text/Code/Break event is translated into styled `Span`s → /// if `width > 0`, a second pass wraps long lines. /// /// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// turns it back into `Line`s for the Paragraph widget. #[allow(clippy::too_many_lines)] pub fn render_markdown(text: &str, width: u16) -> Vec> { let mut spans = Vec::new(); let parser = pulldown_cmark::Parser::new(text); let mut in_code_block = false; let mut in_heading = false; let mut heading_level = 0; for event in parser { match event { pulldown_cmark::Event::Start(tag) => { match tag { pulldown_cmark::Tag::CodeBlock(_) => { in_code_block = true; // Code block top bar spans.push(Span::styled( "\n", Style::default(), )); spans.push(Span::styled( " ┌─ code ", Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), )); spans.push(Span::styled( "\n", Style::default(), )); } pulldown_cmark::Tag::Heading { level, .. } => { in_heading = true; heading_level = match level { pulldown_cmark::HeadingLevel::H1 => 1, pulldown_cmark::HeadingLevel::H2 => 2, pulldown_cmark::HeadingLevel::H3 => 3, _ => 4, }; // No prefix, we'll handle in the text events } pulldown_cmark::Tag::Item => { // List item bullet spans.push(Span::styled( "• ", Style::default().fg(Theme::PRIMARY), )); } pulldown_cmark::Tag::Link { dest_url, .. } => { spans.push(Span::styled( "[", Style::default().fg(Theme::INFO), )); // We push the URL as a tooltip-like suffix // After the link text ends, we'll add the URL spans.push(Span::styled( format!("]({dest_url})"), Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), )); } pulldown_cmark::Tag::BlockQuote(_) => { spans.push(Span::styled( "▎", Style::default().fg(Theme::BLOCKQUOTE_BAR), )); } _ => {} } } pulldown_cmark::Event::End(tag) => { match tag { pulldown_cmark::TagEnd::CodeBlock => { in_code_block = false; // Code block bottom bar spans.push(Span::styled( "\n └─\n", Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), )); } pulldown_cmark::TagEnd::Heading(_) => { in_heading = false; heading_level = 0; spans.push(Span::raw("\n")); } pulldown_cmark::TagEnd::Paragraph => { spans.push(Span::raw("\n\n")); } pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { spans.push(Span::raw("\n")); } _ => {} } } pulldown_cmark::Event::Text(text) => { let s = text.to_string(); if in_code_block { spans.push(Span::styled( format!(" {s}"), Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), )); } else if in_heading { let color = match heading_level { 1 => Theme::PRIMARY, 2 => Theme::INFO, 3 => Theme::ACCENT_PURPLE, _ => Theme::TEXT, }; spans.push(Span::styled( s, Style::default().fg(color).add_modifier(Modifier::BOLD), )); } else { spans.push(Span::raw(s)); } } pulldown_cmark::Event::Code(text) => { // Inline code with background spans.push(Span::styled( format!(" {text} "), Style::default() .fg(Theme::ACCENT_TEAL) .bg(Theme::CODE_BAR) .add_modifier(Modifier::BOLD), )); } pulldown_cmark::Event::SoftBreak => { spans.push(Span::raw(" ")); } pulldown_cmark::Event::HardBreak => { spans.push(Span::raw("\n")); } _ => {} } } if width > 0 { let mut spans_out = Vec::new(); let mut line_len = 0; let effective_width = (width as usize).saturating_sub(2); // leave margin for span in &spans { let style = span.style; let s = span.content.clone(); let text_str = s.as_ref(); let remaining = text_str.len(); if line_len + remaining > effective_width && line_len > 0 { spans_out.push(Span::raw("\n")); line_len = 0; } spans_out.push(Span::styled(text_str.to_string(), style)); if text_str.contains('\n') { line_len = text_str.split('\n').next_back().unwrap_or("").len(); } else { line_len += remaining; } } spans = spans_out; } spans }