use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::ui::ColorPalette;
pub struct MarkdownStyles {
body: Style,
heading: Style,
subheading: Style,
code: Style,
marker: Style,
quote: Style,
rule: Style,
link: Style,
}
impl MarkdownStyles {
pub fn new(palette: &ColorPalette, body: Style) -> Self {
Self {
body,
heading: body.add_modifier(Modifier::BOLD).fg(palette.accent),
subheading: body.add_modifier(Modifier::BOLD),
code: body.fg(palette.network),
marker: body.fg(palette.accent),
quote: body.fg(palette.dim).add_modifier(Modifier::ITALIC),
rule: body.fg(palette.dim),
link: body.fg(palette.network).add_modifier(Modifier::UNDERLINED),
}
}
}
pub fn render(
content: &str,
styles: &MarkdownStyles,
width: usize,
indent: &str,
) -> Vec<Line<'static>> {
let indent_width = indent.chars().count();
let budget = width.saturating_sub(indent_width).max(1);
let mut out = Vec::new();
let mut fence: Option<char> = None;
for raw in content.split('\n') {
let trimmed = raw.trim_start();
if let Some(marker) = fence_marker(trimmed) {
match fence {
Some(open) if open == marker => fence = None,
Some(_) => push_code(&mut out, raw, styles, budget, indent),
None => fence = Some(marker),
}
continue;
}
if fence.is_some() {
push_code(&mut out, raw, styles, budget, indent);
continue;
}
if trimmed.is_empty() {
out.push(Line::from(Span::raw(indent.to_string())));
continue;
}
if is_rule(trimmed) {
out.push(Line::from(vec![
Span::raw(indent.to_string()),
Span::styled("─".repeat(budget), styles.rule),
]));
continue;
}
if let Some((level, text)) = heading(trimmed) {
let style = if level <= 2 {
styles.heading
} else {
styles.subheading
};
let spans = parse_inline(text, style, styles);
out.extend(wrap_spans(spans, budget, indent, ""));
continue;
}
if let Some(text) = trimmed
.strip_prefix("> ")
.or_else(|| (trimmed == ">").then_some(""))
{
let mut spans = vec![Span::styled("▏ ", styles.rule)];
spans.extend(parse_inline(text, styles.quote, styles));
out.extend(wrap_spans(spans, budget, indent, " "));
continue;
}
let lead = raw.len() - trimmed.len();
if let Some((marker, text)) = list_item(trimmed) {
let pad = " ".repeat(lead);
let mut spans = vec![
Span::raw(pad.clone()),
Span::styled(marker.clone(), styles.marker),
];
spans.extend(parse_inline(text, styles.body, styles));
let hang = format!("{pad}{}", " ".repeat(marker.chars().count()));
out.extend(wrap_spans(spans, budget, indent, &hang));
continue;
}
out.extend(wrap_spans(
parse_inline(raw, styles.body, styles),
budget,
indent,
"",
));
}
out
}
fn fence_marker(trimmed: &str) -> Option<char> {
for marker in ['`', '~'] {
let run: String = std::iter::repeat_n(marker, 3).collect();
if trimmed.starts_with(&run) {
return Some(marker);
}
}
None
}
fn is_rule(trimmed: &str) -> bool {
let t = trimmed.trim_end();
['-', '*', '_']
.iter()
.any(|c| t.len() >= 3 && t.chars().all(|ch| ch == *c))
}
fn heading(trimmed: &str) -> Option<(usize, &str)> {
let hashes = trimmed.chars().take_while(|c| *c == '#').count();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = &trimmed[hashes..];
let text = rest.strip_prefix(' ')?;
Some((hashes, text.trim_end()))
}
fn list_item(trimmed: &str) -> Option<(String, &str)> {
for bullet in ["- ", "* ", "+ "] {
if let Some(rest) = trimmed.strip_prefix(bullet) {
return Some(("• ".to_string(), rest));
}
}
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
if digits == 0 || digits > 3 {
return None;
}
let rest = &trimmed[digits..];
for sep in [". ", ") "] {
if let Some(text) = rest.strip_prefix(sep) {
return Some((format!("{}{}", &trimmed[..digits], sep), text));
}
}
None
}
fn push_code(
out: &mut Vec<Line<'static>>,
raw: &str,
styles: &MarkdownStyles,
budget: usize,
indent: &str,
) {
let inner = budget.saturating_sub(2).max(1);
let chars: Vec<char> = raw.chars().collect();
let chunks: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars.chunks(inner).map(|c| c.iter().collect()).collect()
};
for chunk in chunks {
out.push(Line::from(vec![
Span::raw(indent.to_string()),
Span::styled("▏ ", styles.rule),
Span::styled(chunk, styles.code),
]));
}
}
fn parse_inline(text: &str, base: Style, styles: &MarkdownStyles) -> Vec<Span<'static>> {
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '`' {
if let Some(end) = find(&chars, i + 1, "`") {
flush(&mut buf, base, &mut out);
let inner: String = chars[i + 1..end].iter().collect();
out.push(Span::styled(inner, styles.code));
i = end + 1;
continue;
}
}
let mut emphasised = false;
for (delim, modifier) in [
("**", Modifier::BOLD),
("~~", Modifier::CROSSED_OUT),
("*", Modifier::ITALIC),
] {
if !starts_with(&chars, i, delim) {
continue;
}
let open = i + delim.len();
if let Some(end) = find(&chars, open, delim).filter(|end| *end > open) {
flush(&mut buf, base, &mut out);
let inner: String = chars[open..end].iter().collect();
out.extend(parse_inline(&inner, base.add_modifier(modifier), styles));
i = end + delim.len();
emphasised = true;
}
break;
}
if emphasised {
continue;
}
if chars[i] == '[' {
if let Some(close) = find(&chars, i + 1, "]") {
if starts_with(&chars, close + 1, "(") {
if let Some(paren) = find(&chars, close + 2, ")") {
flush(&mut buf, base, &mut out);
let label: String = chars[i + 1..close].iter().collect();
let url: String = chars[close + 2..paren].iter().collect();
out.extend(parse_inline(&label, base.patch(styles.link), styles));
out.push(Span::styled(format!(" ({url})"), styles.rule));
i = paren + 1;
continue;
}
}
}
}
buf.push(chars[i]);
i += 1;
}
flush(&mut buf, base, &mut out);
if out.is_empty() {
out.push(Span::styled(String::new(), base));
}
out
}
fn flush(buf: &mut String, style: Style, out: &mut Vec<Span<'static>>) {
if !buf.is_empty() {
out.push(Span::styled(std::mem::take(buf), style));
}
}
fn starts_with(chars: &[char], at: usize, needle: &str) -> bool {
let n: Vec<char> = needle.chars().collect();
at + n.len() <= chars.len() && chars[at..at + n.len()] == n[..]
}
fn find(chars: &[char], from: usize, needle: &str) -> Option<usize> {
(from..chars.len()).find(|i| starts_with(chars, *i, needle))
}
fn wrap_spans(
spans: Vec<Span<'static>>,
width: usize,
indent: &str,
hang: &str,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = vec![Span::raw(indent.to_string())];
let mut used = 0usize;
let mut pending: Option<Style> = None;
let hang_width = hang.chars().count();
let mut push_line = |current: &mut Vec<Span<'static>>, used: &mut usize| {
lines.push(Line::from(std::mem::take(current)));
*current = vec![Span::raw(format!("{indent}{hang}"))];
*used = hang_width;
};
for span in spans {
let style = span.style;
let content = span.content.into_owned();
if !content.is_empty() && used == 0 && content.chars().all(|c| c == ' ') {
used += content.chars().count();
current.push(Span::styled(content, style));
continue;
}
for (index, word) in content.split(' ').enumerate() {
if index > 0 {
pending = Some(style);
}
if word.is_empty() {
continue;
}
let mut remaining: Vec<char> = word.chars().collect();
let mut first_chunk = true;
while !remaining.is_empty() {
let gap = usize::from(pending.is_some() && used > 0);
if first_chunk
&& used > 0
&& used + gap + remaining.len() > width
&& remaining.len() + hang_width <= width
{
pending = None;
push_line(&mut current, &mut used);
continue;
}
if let Some(space) = pending.take() {
if used > 0 {
current.push(Span::styled(" ".to_string(), space));
used += 1;
}
}
let room = width.saturating_sub(used);
if room == 0 {
push_line(&mut current, &mut used);
continue;
}
let take = room.min(remaining.len());
let text: String = remaining[..take].iter().collect();
remaining.drain(..take);
current.push(Span::styled(text, style));
used += take;
first_chunk = false;
if !remaining.is_empty() {
push_line(&mut current, &mut used);
}
}
}
}
lines.push(Line::from(current));
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::ColorPalette;
fn styles() -> MarkdownStyles {
MarkdownStyles::new(
&ColorPalette::from_theme(&crate::config::Theme::Dark),
Style::default(),
)
}
fn line_width(line: &Line<'static>) -> usize {
line.spans.iter().map(|s| s.content.chars().count()).sum()
}
fn text(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn heading_loses_its_hashes_and_gains_weight() {
let lines = render("## Deploy", &styles(), 40, "");
assert_eq!(text(&lines), vec!["Deploy"]);
assert!(lines[0].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD));
}
#[test]
fn bullet_becomes_a_glyph_and_wraps_under_itself() {
let lines = render("- alpha beta gamma delta", &styles(), 12, "");
assert_eq!(text(&lines), vec!["• alpha beta", " gamma", " delta"]);
assert!(lines.iter().all(|l| line_width(l) <= 12));
}
#[test]
fn ordered_items_keep_their_numbers() {
let lines = render("1. first\n2. second", &styles(), 40, "");
assert_eq!(text(&lines), vec!["1. first", "2. second"]);
}
#[test]
fn bold_markers_are_consumed_and_the_text_is_bold() {
let lines = render("run **now** please", &styles(), 40, "");
assert_eq!(text(&lines), vec!["run now please"]);
let bold = lines[0]
.spans
.iter()
.find(|s| s.content.contains("now"))
.expect("bold span");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn code_span_keeps_its_content_without_backticks() {
let lines = render("call `stellar keys ls` first", &styles(), 40, "");
assert_eq!(text(&lines), vec!["call stellar keys ls first"]);
}
#[test]
fn fenced_block_is_verbatim_and_the_fences_disappear() {
let src = "```rust\nlet x = **1**;\n```";
let lines = render(src, &styles(), 40, "");
assert_eq!(text(&lines), vec!["▏ let x = **1**;"]);
}
#[test]
fn unclosed_emphasis_is_left_alone() {
let lines = render("2 ** 3 is eight", &styles(), 40, "");
assert_eq!(text(&lines), vec!["2 ** 3 is eight"]);
}
#[test]
fn underscores_are_never_emphasis() {
let lines = render("call __init__ on snake_case_name", &styles(), 60, "");
assert_eq!(text(&lines), vec!["call __init__ on snake_case_name"]);
}
#[test]
fn link_shows_label_and_target() {
let lines = render("see [docs](https://x.dev)", &styles(), 60, "");
assert_eq!(text(&lines), vec!["see docs (https://x.dev)"]);
}
#[test]
fn rule_fills_the_width() {
let lines = render("---", &styles(), 8, "");
assert_eq!(text(&lines), vec!["────────"]);
}
#[test]
fn indent_applies_to_every_wrapped_line() {
let lines = render("alpha beta gamma", &styles(), 12, " ");
assert_eq!(text(&lines), vec![" alpha beta", " gamma"]);
}
#[test]
fn blank_lines_survive() {
let lines = render("one\n\ntwo", &styles(), 20, "");
assert_eq!(text(&lines), vec!["one", "", "two"]);
}
#[test]
fn a_word_longer_than_the_width_is_split_not_dropped() {
let lines = render("CAAAAAAAAAAAAAAAA", &styles(), 8, "");
assert_eq!(text(&lines), vec!["CAAAAAAA", "AAAAAAAA", "A"]);
}
}