use ratatui_core::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::Widget,
};
use crate::Elements;
use crate::components::Canvas;
#[derive(Default, typed_builder::TypedBuilder)]
pub struct Markdown {
#[builder(default, setter(into))]
pub source: String,
}
impl Markdown {
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
}
}
}
pub struct MarkdownState {
pub base_style: Style,
pub code_style: Style,
pub block_code_style: Style,
pub bold_style: Style,
pub italic_style: Style,
pub heading_style: Style,
pub marker_style: Style,
}
impl MarkdownState {
pub fn new() -> Self {
let base = Style::default();
Self {
base_style: base,
code_style: Style::default().fg(Color::Yellow),
block_code_style: Style::default().fg(Color::Green),
bold_style: base.add_modifier(Modifier::BOLD),
italic_style: base.add_modifier(Modifier::ITALIC),
heading_style: Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
marker_style: Style::default().fg(Color::DarkGray),
}
}
}
impl Default for MarkdownState {
fn default() -> Self {
Self::new()
}
}
#[eye_declare_macros::component(props = Markdown, state = MarkdownState, initial_state = MarkdownState::new(), crate_path = crate)]
fn markdown(props: &Markdown, state: &MarkdownState) -> Elements {
if props.source.is_empty() {
return Elements::new();
}
let text = render_markdown(&props.source, state);
let text_for_height = text.clone();
let mut els = Elements::new();
els.add(
Canvas::builder()
.render_fn(move |area: Rect, buf: &mut Buffer| {
crate::wrap::wrapping_paragraph(text.clone()).render(area, buf);
})
.desired_height_fn(move |width: u16| {
crate::wrap::wrapped_line_count(&text_for_height, width)
})
.build(),
);
els
}
fn render_markdown(source: &str, styles: &MarkdownState) -> Text<'static> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut in_code_block = false;
for line in source.lines() {
if line.starts_with("```") {
in_code_block = !in_code_block;
if in_code_block {
let lang = line.trim_start_matches('`').trim();
if !lang.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", lang),
styles.marker_style,
)));
}
}
continue;
}
if in_code_block {
lines.push(Line::from(Span::styled(
format!(" {}", line),
styles.block_code_style,
)));
continue;
}
if let Some(content) = line.strip_prefix("### ") {
lines.push(Line::from(Span::styled(
content.to_string(),
styles.heading_style,
)));
continue;
}
if let Some(content) = line.strip_prefix("## ") {
lines.push(Line::from(Span::styled(
content.to_string(),
styles.heading_style,
)));
continue;
}
if let Some(content) = line.strip_prefix("# ") {
lines.push(Line::from(Span::styled(
content.to_string(),
styles.heading_style.add_modifier(Modifier::UNDERLINED),
)));
continue;
}
let list_prefix = if line.starts_with("- ") || line.starts_with("* ") {
Some(&line[..2])
} else if line.starts_with(" - ") || line.starts_with(" * ") {
Some(&line[..4])
} else {
None
};
if let Some(prefix) = list_prefix {
let content = &line[prefix.len()..];
let mut spans = vec![Span::styled(prefix.to_string(), styles.marker_style)];
spans.extend(parse_inline_formatting(content, styles));
lines.push(Line::from(spans));
continue;
}
if line.trim().is_empty() {
lines.push(Line::from(""));
continue;
}
let spans = parse_inline_formatting(line, styles);
lines.push(Line::from(spans));
}
Text::from(lines)
}
fn parse_inline_formatting(line: &str, styles: &MarkdownState) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut current = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '`' {
if !current.is_empty() {
spans.push(Span::styled(
std::mem::take(&mut current),
styles.base_style,
));
}
let mut code_text = String::new();
let mut closed = false;
for next in chars.by_ref() {
if next == '`' {
closed = true;
break;
}
code_text.push(next);
}
if closed {
spans.push(Span::styled(code_text, styles.code_style));
} else {
current.push('`');
current.push_str(&code_text);
}
} else if ch == '*' && chars.peek() == Some(&'*') {
chars.next(); if !current.is_empty() {
spans.push(Span::styled(
std::mem::take(&mut current),
styles.base_style,
));
}
let mut bold_text = String::new();
let mut closed = false;
while let Some(next) = chars.next() {
if next == '*' && chars.peek() == Some(&'*') {
chars.next();
closed = true;
break;
}
bold_text.push(next);
}
if closed {
spans.push(Span::styled(bold_text, styles.bold_style));
} else {
current.push_str("**");
current.push_str(&bold_text);
}
} else if ch == '*' {
if !current.is_empty() {
spans.push(Span::styled(
std::mem::take(&mut current),
styles.base_style,
));
}
let mut italic_text = String::new();
let mut closed = false;
for next in chars.by_ref() {
if next == '*' {
closed = true;
break;
}
italic_text.push(next);
}
if closed {
spans.push(Span::styled(italic_text, styles.italic_style));
} else {
current.push('*');
current.push_str(&italic_text);
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
spans.push(Span::styled(current, styles.base_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_renders() {
let md = Markdown::new("# Title");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
assert_eq!(text.lines.len(), 1);
assert!(
text.lines[0]
.spans
.iter()
.any(|s| s.content.contains("Title"))
);
}
#[test]
fn code_block_indented() {
let md = Markdown::new("```rust\nfn main() {}\n```");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
assert!(text.lines.len() >= 2);
assert!(text.lines.last().unwrap().to_string().contains("fn main"));
}
#[test]
fn inline_bold() {
let md = Markdown::new("This is **bold** text");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
let spans = &text.lines[0].spans;
assert!(spans.len() >= 3);
let bold_span = spans.iter().find(|s| s.content.contains("bold")).unwrap();
assert!(bold_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn inline_italic() {
let md = Markdown::new("This is *italic* text");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
let spans = &text.lines[0].spans;
let italic_span = spans.iter().find(|s| s.content.contains("italic")).unwrap();
assert!(italic_span.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn inline_code() {
let md = Markdown::new("Use `println!` here");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
let spans = &text.lines[0].spans;
let code_span = spans
.iter()
.find(|s| s.content.contains("println!"))
.unwrap();
assert_eq!(code_span.style.fg, Some(Color::Yellow));
}
#[test]
fn list_items() {
let md = Markdown::new("- item one\n- item two");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
assert_eq!(text.lines.len(), 2);
}
#[test]
fn unclosed_markers_render_as_text() {
let md = Markdown::new("This has an unclosed **bold");
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
let full_text: String = text.lines[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(full_text.contains("**bold"));
}
#[test]
fn mixed_formatting() {
let md = Markdown::new(
"# Welcome\n\nThis is **bold** and *italic* with `code`.\n\n```\nlet x = 1;\n```\n\n- item",
);
let state = MarkdownState::new();
let text = render_markdown(&md.source, &state);
assert!(text.lines.len() >= 5);
}
}