markdown.rs juga disesuaikan: indentasi paragraf/heading bawaannya dilepas supaya tidak bentrok dengan indent PREFIX_WIDTH di chat.rs (baris pertama vs baris wrap lanjutan jadi sejajar). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
7.7 KiB
Rust
191 lines
7.7 KiB
Rust
//! 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<Span<'static>> {
|
|
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
|
|
}
|