Files
zesdex/src/view/markdown.rs
T

294 lines
13 KiB
Rust
Raw Normal View History

//! 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 mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut in_code_block = false;
let mut in_heading = false;
let mut heading_level = 0;
let mut in_table = false;
let mut in_table_cell = false;
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
let mut current_cell: Vec<Span<'static>> = Vec::new();
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::Tag::Table(_) => {
in_table = true;
table_rows.clear();
}
pulldown_cmark::Tag::TableHead | pulldown_cmark::Tag::TableRow => {
current_row.clear();
}
pulldown_cmark::Tag::TableCell => {
in_table_cell = true;
current_cell.clear();
}
_ => {}
}
}
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::TagEnd::TableCell => {
in_table_cell = false;
current_row.push(std::mem::take(&mut current_cell));
}
pulldown_cmark::TagEnd::TableHead | pulldown_cmark::TagEnd::TableRow => {
table_rows.push(std::mem::take(&mut current_row));
}
pulldown_cmark::TagEnd::Table => {
in_table = false;
let mut col_widths = Vec::new();
for row in &table_rows {
for (i, cell) in row.iter().enumerate() {
let width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
if i >= col_widths.len() {
col_widths.push(width);
} else if width > col_widths[i] {
col_widths[i] = width;
}
}
}
spans.push(Span::raw("\n"));
for (r, row) in table_rows.iter().enumerate() {
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
for (i, cell) in row.iter().enumerate() {
let width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
let pad = col_widths.get(i).copied().unwrap_or(0).saturating_sub(width);
for span in cell {
spans.push(span.clone());
}
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER)));
}
spans.push(Span::raw("\n"));
if r == 0 {
spans.push(Span::styled(" |", Style::default().fg(Theme::BORDER)));
for width in &col_widths {
spans.push(Span::styled(format!("{}-|", "-".repeat(*width + 2)), Style::default().fg(Theme::BORDER)));
}
spans.push(Span::raw("\n"));
}
}
spans.push(Span::raw("\n"));
}
_ => {}
}
}
pulldown_cmark::Event::Text(text) => {
let s = text.to_string();
if in_code_block {
let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled(
indented,
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 if in_table_cell {
current_cell.push(Span::raw(s));
} else {
spans.push(Span::raw(s));
}
}
pulldown_cmark::Event::Code(text) => {
let span = Span::styled(
format!(" {text} "),
Style::default()
.fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR)
.add_modifier(Modifier::BOLD),
);
if in_table_cell {
current_cell.push(span);
} else {
spans.push(span);
}
}
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 text = span.content.as_ref();
let mut current = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
tokens.push(" ".to_string());
} else if c == '\n' {
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
tokens.push("\n".to_string());
} else {
current.push(c);
}
}
if !current.is_empty() { tokens.push(current); }
for token in tokens {
if token == "\n" {
spans_out.push(Span::styled("\n", style));
line_len = 0;
} else if token == " " {
if line_len > 0 && line_len < effective_width {
spans_out.push(Span::styled(" ", style));
line_len += 1;
}
} else {
let token_len = token.chars().count();
if line_len + token_len > effective_width && line_len > 0 {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
if token_len > effective_width {
for c in token.chars() {
if line_len >= effective_width {
spans_out.push(Span::raw("\n"));
line_len = 0;
}
spans_out.push(Span::styled(c.to_string(), style));
line_len += 1;
}
} else {
spans_out.push(Span::styled(token, style));
line_len += token_len;
}
}
}
}
spans = spans_out;
}
spans
}