2026-07-12 11:28:39 +07:00
|
|
|
//! Markdown-to-styled-spans rendering for the chat transcript.
|
|
|
|
|
//!
|
|
|
|
|
//! Flow: `render_markdown` walks a `pulldown_cmark` event stream and
|
|
|
|
|
//! translates each markdown construct (headings, code blocks, links,
|
|
|
|
|
//! emphasis, block quotes, lists) into styled `ratatui::text::Span`s,
|
|
|
|
|
//! then optionally re-wraps the flat span list to a target column width.
|
|
|
|
|
//!
|
|
|
|
|
//! Why: ratatui has no built-in markdown renderer, so this module bridges
|
|
|
|
|
//! `pulldown_cmark`'s event-based parser to ratatui's span/line model.
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
|
use ratatui::text::Span;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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
|
|
|
|
|
/// (headings colored by level, code blocks green, links bracketed, etc.)
|
|
|
|
|
/// → if `width > 0`, a second pass inserts manual newline spans whenever
|
|
|
|
|
/// the running line length would exceed `width`.
|
|
|
|
|
///
|
|
|
|
|
/// Why: ratatui has no native markdown renderer, and the built-in `Wrap`
|
|
|
|
|
/// widget wraps on grapheme count without honoring markdown structure,
|
|
|
|
|
/// so wrapping is done manually here in terms of raw span byte length.
|
|
|
|
|
///
|
|
|
|
|
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
|
|
|
|
/// turns it back into `Line`s for the Paragraph widget.
|
2026-07-12 01:25:52 +07:00
|
|
|
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
2026-07-11 13:16:10 +07:00
|
|
|
let mut spans = Vec::new();
|
|
|
|
|
let parser = pulldown_cmark::Parser::new(text);
|
|
|
|
|
let mut in_code_block = false;
|
|
|
|
|
|
|
|
|
|
for event in parser {
|
|
|
|
|
match event {
|
|
|
|
|
pulldown_cmark::Event::Start(tag) => {
|
|
|
|
|
match tag {
|
|
|
|
|
pulldown_cmark::Tag::CodeBlock(_) => {
|
|
|
|
|
in_code_block = true;
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
"```\n",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Tag::Heading { level, .. } => {
|
|
|
|
|
let color = match level {
|
|
|
|
|
pulldown_cmark::HeadingLevel::H1 => Color::LightCyan,
|
|
|
|
|
pulldown_cmark::HeadingLevel::H2 => Color::Cyan,
|
|
|
|
|
_ => Color::White,
|
|
|
|
|
};
|
|
|
|
|
let prefix = match level {
|
|
|
|
|
pulldown_cmark::HeadingLevel::H1 => "# ",
|
|
|
|
|
pulldown_cmark::HeadingLevel::H2 => "## ",
|
|
|
|
|
pulldown_cmark::HeadingLevel::H3 => "### ",
|
|
|
|
|
_ => "# ",
|
|
|
|
|
};
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
prefix,
|
|
|
|
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Tag::Paragraph => {}
|
|
|
|
|
pulldown_cmark::Tag::Emphasis => {}
|
|
|
|
|
pulldown_cmark::Tag::Strong => {}
|
|
|
|
|
pulldown_cmark::Tag::List(_) => {}
|
|
|
|
|
pulldown_cmark::Tag::Item => {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
" * ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
"[",
|
|
|
|
|
Style::default().fg(Color::Cyan),
|
|
|
|
|
));
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
format!("]({})", dest_url),
|
|
|
|
|
Style::default().fg(Color::Blue),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Tag::BlockQuote(_) => {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
"> ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Event::End(tag) => {
|
|
|
|
|
match tag {
|
|
|
|
|
pulldown_cmark::TagEnd::CodeBlock => {
|
|
|
|
|
in_code_block = false;
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
"\n```\n",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::TagEnd::Heading(_) => {
|
|
|
|
|
spans.push(Span::raw("\n"));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::TagEnd::Paragraph => {
|
|
|
|
|
spans.push(Span::raw("\n\n"));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::TagEnd::Emphasis => {}
|
|
|
|
|
pulldown_cmark::TagEnd::Strong => {}
|
|
|
|
|
pulldown_cmark::TagEnd::List(_) => {}
|
|
|
|
|
pulldown_cmark::TagEnd::Item => {
|
|
|
|
|
spans.push(Span::raw("\n"));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::TagEnd::Link => {}
|
|
|
|
|
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(
|
|
|
|
|
s,
|
|
|
|
|
Style::default().fg(Color::Green),
|
|
|
|
|
));
|
|
|
|
|
} else {
|
|
|
|
|
spans.push(Span::raw(s));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Event::Code(text) => {
|
|
|
|
|
spans.push(Span::styled(
|
|
|
|
|
format!("`{}`", text),
|
|
|
|
|
Style::default().fg(Color::Green),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Event::SoftBreak => {
|
|
|
|
|
spans.push(Span::raw("\n"));
|
|
|
|
|
}
|
|
|
|
|
pulldown_cmark::Event::HardBreak => {
|
|
|
|
|
spans.push(Span::raw("\n"));
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if width > 0 {
|
|
|
|
|
let mut spans_out = Vec::new();
|
|
|
|
|
let mut line_len = 0;
|
|
|
|
|
for span in &spans {
|
2026-07-12 01:25:52 +07:00
|
|
|
let style = span.style;
|
2026-07-11 13:16:10 +07:00
|
|
|
let s = span.content.clone();
|
|
|
|
|
let text = s.as_ref();
|
|
|
|
|
let remaining = text.len();
|
|
|
|
|
if line_len + remaining > width as usize && line_len > 0 {
|
|
|
|
|
spans_out.push(Span::raw("\n"));
|
|
|
|
|
line_len = 0;
|
|
|
|
|
}
|
2026-07-12 01:25:52 +07:00
|
|
|
spans_out.push(Span::styled(text.to_string(), style));
|
2026-07-11 13:16:10 +07:00
|
|
|
if !text.contains('\n') {
|
|
|
|
|
line_len += remaining;
|
|
|
|
|
} else {
|
|
|
|
|
line_len = text.split('\n').next_back().unwrap_or("").len();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spans = spans_out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
spans
|
|
|
|
|
}
|