use hjkl_markdown::Event;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct MdTheme {
pub text: Color,
pub heading1: Color,
pub heading: Color,
pub code_span: Color,
pub code_block: Color,
pub link: Color,
pub list_bullet: Color,
pub bold: Color,
pub italic: Color,
pub rule: Color,
}
impl MdTheme {
#[allow(clippy::too_many_arguments)]
pub fn new(
text: Color,
heading1: Color,
heading: Color,
code_span: Color,
code_block: Color,
link: Color,
list_bullet: Color,
bold: Color,
italic: Color,
rule: Color,
) -> Self {
Self {
text,
heading1,
heading,
code_span,
code_block,
link,
list_bullet,
bold,
italic,
rule,
}
}
}
impl Default for MdTheme {
fn default() -> Self {
Self {
text: Color::Rgb(0xcd, 0xd6, 0xf4),
heading1: Color::Rgb(0xcb, 0xa6, 0xf7),
heading: Color::Rgb(0x89, 0xb4, 0xfa),
code_span: Color::Rgb(0xa6, 0xe3, 0xa1),
code_block: Color::Rgb(0xa6, 0xe3, 0xa1),
link: Color::Rgb(0x89, 0xdc, 0xeb),
list_bullet: Color::Rgb(0xf3, 0x8b, 0xa8),
bold: Color::Rgb(0xfa, 0xb3, 0x87),
italic: Color::Rgb(0xf9, 0xe2, 0xaf),
rule: Color::Rgb(0x58, 0x5b, 0x70),
}
}
}
pub fn to_lines(events: &[Event], theme: &MdTheme, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let flush = |spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
if !spans.is_empty() {
lines.push(Line::from(std::mem::take(spans)));
}
};
for ev in events {
match ev {
Event::Heading { level, text } => {
flush(&mut current_spans, &mut lines);
let fg = if *level == 1 {
theme.heading1
} else {
theme.heading
};
let prefix = "#".repeat(*level as usize);
let label = format!("{prefix} {text}");
let style = Style::default().fg(fg).add_modifier(Modifier::BOLD);
for wrapped in wrap_str(&label, width as usize) {
lines.push(Line::from(vec![Span::styled(wrapped, style)]));
}
}
Event::CodeBlock { lang, content } => {
flush(&mut current_spans, &mut lines);
if !lang.is_empty() {
let lang_line = format!("[{lang}]");
lines.push(Line::from(vec![Span::styled(
lang_line,
Style::default()
.fg(theme.code_block)
.add_modifier(Modifier::DIM),
)]));
}
let style = Style::default().fg(theme.code_block);
for src_line in content.lines() {
for wrapped in wrap_str(src_line, width as usize) {
lines.push(Line::from(vec![Span::styled(wrapped, style)]));
}
}
}
Event::Rule => {
flush(&mut current_spans, &mut lines);
let rule_str = "─".repeat(width.saturating_sub(0) as usize);
lines.push(Line::from(vec![Span::styled(
rule_str,
Style::default().fg(theme.rule),
)]));
}
Event::ListItem { bullet, number } => {
flush(&mut current_spans, &mut lines);
let prefix = if *bullet == '\0' {
format!("{number}. ")
} else {
format!("{bullet} ")
};
current_spans.push(Span::styled(prefix, Style::default().fg(theme.list_bullet)));
}
Event::Blank => {
flush(&mut current_spans, &mut lines);
lines.push(Line::default());
}
Event::Link { text, url } => {
let label = if text.is_empty() {
url.clone()
} else {
format!("{text} <{url}>")
};
let style = Style::default()
.fg(theme.link)
.add_modifier(Modifier::UNDERLINED);
for wrapped in wrap_str(&label, width as usize) {
current_spans.push(Span::styled(wrapped, style));
}
}
Event::Text {
content,
bold,
italic,
code_span,
} => {
let fg = if *code_span {
theme.code_span
} else if *bold {
theme.bold
} else if *italic {
theme.italic
} else {
theme.text
};
let mut style = Style::default().fg(fg);
if *bold {
style = style.add_modifier(Modifier::BOLD);
}
if *italic {
style = style.add_modifier(Modifier::ITALIC);
}
if *code_span {
style = style.add_modifier(Modifier::REVERSED);
}
for (i, part) in content.split('\n').enumerate() {
if i > 0 {
flush(&mut current_spans, &mut lines);
}
if !part.is_empty() {
for wrapped in wrap_str(part, width as usize) {
current_spans.push(Span::styled(wrapped, style));
}
}
}
}
_ => {}
}
}
flush(&mut current_spans, &mut lines);
while lines
.last()
.map(|l: &Line<'_>| l.spans.is_empty())
.unwrap_or(false)
{
lines.pop();
}
lines
}
fn wrap_str(s: &str, width: usize) -> Vec<String> {
if width == 0 || s.len() <= width {
return vec![s.to_string()];
}
let mut out = Vec::new();
let mut current = String::new();
for word in s.split_whitespace() {
let needed = if current.is_empty() {
word.len()
} else {
current.len() + 1 + word.len()
};
if needed > width && !current.is_empty() {
out.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push(' ');
}
if word.len() > width {
for chunk in word.as_bytes().chunks(width) {
let s = String::from_utf8_lossy(chunk).to_string();
if current.len() + s.len() > width && !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.push_str(&s);
}
} else {
current.push_str(word);
}
}
if !current.is_empty() {
out.push(current);
}
if out.is_empty() {
out.push(String::new());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use hjkl_markdown::parse;
#[test]
fn smoke_empty() {
let lines = to_lines(&[], &MdTheme::default(), 80);
assert!(lines.is_empty());
}
#[test]
fn heading_produces_lines() {
let evs = parse("# Hello");
let lines = to_lines(&evs, &MdTheme::default(), 80);
assert!(!lines.is_empty());
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Hello"), "heading text not found: {text:?}");
}
#[test]
fn code_block_lines() {
let evs = parse("```rust\nfn main() {}\n```");
let lines = to_lines(&evs, &MdTheme::default(), 80);
assert!(
lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains("fn main")))
);
}
#[test]
fn wrap_long_line() {
let chunks = wrap_str("hello world foo bar baz", 10);
for c in &chunks {
assert!(c.len() <= 10, "chunk too wide: {c:?}");
}
}
#[test]
fn default_theme_has_colors() {
let t = MdTheme::default();
assert!(matches!(t.text, Color::Rgb(_, _, _)));
assert!(matches!(t.heading1, Color::Rgb(_, _, _)));
}
}