#![allow(dead_code)]
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use crate::tui::theme::Theme;
pub fn render(md: &str, theme: &Theme) -> Text<'static> {
let mut r = Renderer::new(theme);
for event in Parser::new(md) {
r.handle(event);
}
r.finish()
}
struct Renderer<'t> {
theme: &'t Theme,
lines: Vec<Line<'static>>,
spans: Vec<Span<'static>>,
style: Style,
style_stack: Vec<Style>,
in_code_block: bool,
list_stack: Vec<Option<u64>>,
in_blockquote: bool,
}
impl<'t> Renderer<'t> {
fn new(theme: &'t Theme) -> Self {
Renderer {
theme,
lines: Vec::new(),
spans: Vec::new(),
style: Style::default().fg(theme.fg),
style_stack: Vec::new(),
in_code_block: false,
list_stack: Vec::new(),
in_blockquote: false,
}
}
fn push_style(&mut self, f: impl FnOnce(Style) -> Style) {
self.style_stack.push(self.style);
self.style = f(self.style);
}
fn pop_style(&mut self) {
if let Some(s) = self.style_stack.pop() {
self.style = s;
}
}
fn flush_line(&mut self) {
if self.spans.is_empty() {
return;
}
let spans = std::mem::take(&mut self.spans);
self.lines.push(Line::from(spans));
}
fn blank_line(&mut self) {
self.flush_line();
let already_blank = self
.lines
.last()
.map(|l| l.spans.is_empty())
.unwrap_or(true);
if !already_blank && !self.lines.is_empty() {
self.lines.push(Line::from(""));
}
}
fn handle(&mut self, event: Event) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(t) => self.text(&t),
Event::Code(t) => {
let style = Style::default().fg(self.theme.accent);
self.spans.push(Span::styled(t.to_string(), style));
}
Event::SoftBreak => self.spans.push(Span::raw(" ")),
Event::HardBreak => self.flush_line(),
Event::Rule => {
self.blank_line();
self.lines.push(Line::from(Span::styled(
"─".repeat(48),
Style::default().fg(self.theme.muted),
)));
self.blank_line();
}
_ => {}
}
}
fn start(&mut self, tag: Tag) {
match tag {
Tag::Heading { level, .. } => {
self.blank_line();
let bump = match level {
HeadingLevel::H1 | HeadingLevel::H2 => Modifier::BOLD,
_ => Modifier::empty(),
};
self.push_style(|_| {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD | bump)
});
}
Tag::Strong => self.push_style(|s| s.add_modifier(Modifier::BOLD)),
Tag::Emphasis => self.push_style(|s| s.add_modifier(Modifier::ITALIC)),
Tag::CodeBlock(_kind) => {
let _ = matches!(_kind, CodeBlockKind::Fenced(_));
self.blank_line();
self.in_code_block = true;
self.push_style(|_| Style::default().fg(self.theme.fg).bg(self.theme.row_alt));
}
Tag::List(start) => self.list_stack.push(start),
Tag::Item => {
self.flush_line();
let marker = match self.list_stack.last_mut() {
Some(Some(n)) => {
let m = format!("{}. ", *n);
*n += 1;
m
}
_ => "• ".to_string(),
};
let indent = " ".repeat(self.list_stack.len().saturating_sub(1));
self.spans.push(Span::styled(
format!("{}{}", indent, marker),
Style::default().fg(self.theme.muted),
));
}
Tag::BlockQuote => {
self.in_blockquote = true;
self.push_style(|s| s.fg(self.theme.muted));
}
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Heading(_) => {
self.flush_line();
self.pop_style();
self.blank_line();
}
TagEnd::Paragraph => {
self.flush_line();
self.blank_line();
}
TagEnd::Strong | TagEnd::Emphasis => self.pop_style(),
TagEnd::CodeBlock => {
self.flush_line();
self.in_code_block = false;
self.pop_style();
self.blank_line();
}
TagEnd::List(_) => {
self.list_stack.pop();
self.blank_line();
}
TagEnd::Item => self.flush_line(),
TagEnd::BlockQuote => {
self.in_blockquote = false;
self.pop_style();
self.blank_line();
}
_ => {}
}
}
fn text(&mut self, t: &str) {
if self.in_code_block {
let mut parts = t.split('\n').peekable();
while let Some(part) = parts.next() {
self.spans.push(Span::styled(part.to_string(), self.style));
if parts.peek().is_some() {
self.flush_line();
}
}
} else {
self.spans.push(Span::styled(t.to_string(), self.style));
}
}
fn finish(mut self) -> Text<'static> {
self.flush_line();
while self
.lines
.last()
.map(|l| l.spans.is_empty())
.unwrap_or(false)
{
self.lines.pop();
}
Text::from(self.lines)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_headings_and_code() {
let t = Theme::dark();
let text = render("# Title\n\nSome `code` here.\n", &t);
assert!(text.lines.len() >= 2);
}
#[test]
fn renders_list() {
let t = Theme::dark();
let text = render("- one\n- two\n", &t);
let joined: String = text
.lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect();
assert!(joined.contains("one"));
assert!(joined.contains("two"));
assert!(joined.contains('•'));
}
#[test]
fn empty_input_is_empty() {
let t = Theme::dark();
let text = render("", &t);
assert!(text.lines.is_empty());
}
}