use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::sync::LazyLock;
use termimad::{FmtText, MadSkin};
static COLOR_SKIN: LazyLock<MadSkin> = LazyLock::new(MadSkin::default);
static PLAIN_SKIN: LazyLock<MadSkin> = LazyLock::new(MadSkin::no_style);
pub(crate) fn render_body(markdown: &str, width: usize, color: bool) -> String {
let width = width.max(1);
let skin = if color { &COLOR_SKIN } else { &PLAIN_SKIN };
let rendered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
FmtText::from(skin, markdown, Some(width)).to_string()
}));
rendered.unwrap_or_else(|_| markdown.to_string())
}
pub(crate) fn render_lines(markdown: &str, width: usize) -> Vec<Line<'static>> {
render_body(markdown, width, true)
.lines()
.map(ansi_to_line)
.collect()
}
fn ansi_to_line(src: &str) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut style = Style::default();
let mut text = String::new();
let mut chars = src.chars().peekable();
while let Some(c) = chars.next() {
if c != '\u{1b}' {
text.push(c);
continue;
}
if chars.peek() != Some(&'[') {
continue;
}
chars.next(); let mut params = String::new();
let mut final_byte = None;
for pc in chars.by_ref() {
if pc.is_ascii_alphabetic() {
final_byte = Some(pc);
break;
}
params.push(pc);
}
if final_byte == Some('m') {
if !text.is_empty() {
spans.push(Span::styled(std::mem::take(&mut text), style));
}
style = apply_sgr(style, ¶ms);
}
}
if !text.is_empty() {
spans.push(Span::styled(text, style));
}
if spans.is_empty() {
spans.push(Span::raw(String::new()));
}
Line::from(spans)
}
fn apply_sgr(mut style: Style, params: &str) -> Style {
let codes: Vec<&str> = if params.is_empty() {
vec!["0"]
} else {
params.split(';').collect()
};
let mut i = 0;
while i < codes.len() {
let Ok(code) = codes[i].parse::<u16>() else {
i += 1;
continue;
};
match code {
0 => style = Style::default(),
1 => style = style.add_modifier(Modifier::BOLD),
2 => style = style.add_modifier(Modifier::DIM),
3 => style = style.add_modifier(Modifier::ITALIC),
4 => style = style.add_modifier(Modifier::UNDERLINED),
7 => style = style.add_modifier(Modifier::REVERSED),
9 => style = style.add_modifier(Modifier::CROSSED_OUT),
22 => style = style.remove_modifier(Modifier::BOLD | Modifier::DIM),
23 => style = style.remove_modifier(Modifier::ITALIC),
24 => style = style.remove_modifier(Modifier::UNDERLINED),
27 => style = style.remove_modifier(Modifier::REVERSED),
29 => style = style.remove_modifier(Modifier::CROSSED_OUT),
30..=37 => style = style.fg(basic_color(code - 30)),
90..=97 => style = style.fg(bright_color(code - 90)),
39 => style = style.fg(Color::Reset),
40..=47 => style = style.bg(basic_color(code - 40)),
100..=107 => style = style.bg(bright_color(code - 100)),
49 => style = style.bg(Color::Reset),
38 => {
if let Some((color, advance)) = parse_extended(&codes[i + 1..]) {
style = style.fg(color);
i += advance;
}
}
48 => {
if let Some((color, advance)) = parse_extended(&codes[i + 1..]) {
style = style.bg(color);
i += advance;
}
}
_ => {}
}
i += 1;
}
style
}
fn parse_extended(rest: &[&str]) -> Option<(Color, usize)> {
match rest.first()?.parse::<u16>().ok()? {
5 => Some((Color::Indexed(rest.get(1)?.parse().ok()?), 2)),
2 => Some((
Color::Rgb(
rest.get(1)?.parse().ok()?,
rest.get(2)?.parse().ok()?,
rest.get(3)?.parse().ok()?,
),
4,
)),
_ => None,
}
}
fn basic_color(index: u16) -> Color {
match index {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
_ => Color::Gray,
}
}
fn bright_color(index: u16) -> Color {
match index {
0 => Color::DarkGray,
1 => Color::LightRed,
2 => Color::LightGreen,
3 => Color::LightYellow,
4 => Color::LightBlue,
5 => Color::LightMagenta,
6 => Color::LightCyan,
_ => Color::White,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_body_strips_heading_syntax() {
let out = render_body("# Hello", 80, true);
assert!(out.contains("Hello"), "keeps heading text: {out:?}");
assert!(!out.contains('#'), "drops markdown syntax: {out:?}");
}
#[test]
fn render_body_strips_emphasis_syntax() {
let out = render_body("**bold** and `code`", 80, true);
assert!(out.contains("bold"), "keeps bold text: {out:?}");
assert!(!out.contains("**"), "drops emphasis markers: {out:?}");
assert!(out.contains("code"), "keeps code text: {out:?}");
}
#[test]
fn render_body_falls_back_without_panicking_on_malformed_markdown() {
let malformed = "```rust\nfn main() {\n| a | b |\n| - |\n> quote";
let out = render_body(malformed, 40, true);
assert!(!out.is_empty(), "renders something: {out:?}");
assert!(out.contains("quote"), "preserves content: {out:?}");
}
#[test]
fn ansi_to_line_plain_text_is_single_span() {
let line = ansi_to_line("just text");
assert_eq!(line.spans.len(), 1);
assert_eq!(line.spans[0].content, "just text");
assert_eq!(line.spans[0].style, Style::default());
}
#[test]
fn ansi_to_line_parses_bold_then_reset() {
let line = ansi_to_line("\u{1b}[1mBold\u{1b}[0m plain");
assert_eq!(line.spans.len(), 2);
assert_eq!(line.spans[0].content, "Bold");
assert!(line.spans[0].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(line.spans[1].content, " plain");
assert!(!line.spans[1].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn ansi_to_line_parses_8bit_foreground() {
let line = ansi_to_line("\u{1b}[38;5;240mX\u{1b}[39mY");
assert_eq!(line.spans[0].style.fg, Some(Color::Indexed(240)));
assert_eq!(line.spans[1].style.fg, Some(Color::Reset));
}
#[test]
fn ansi_to_line_parses_24bit_background() {
let line = ansi_to_line("\u{1b}[48;2;10;20;30mX");
assert_eq!(line.spans[0].style.bg, Some(Color::Rgb(10, 20, 30)));
}
#[test]
fn ansi_to_line_handles_all_supported_sgr_attributes_and_malformed_sequences() {
let source = concat!(
"\u{1b}[1;2;3;4;7;9mattrs",
"\u{1b}[22;23;24;27;29mremoved",
"\u{1b}[30;31;32;33;34;35;36;37mcolors",
"\u{1b}[90;91;92;93;94;95;96;97mbright",
"\u{1b}[39;40;41;42;43;44;45;46;47mbackground",
"\u{1b}[100;101;102;103;104;105;106;107mbright-bg",
"\u{1b}[49;38;5;123mindexed",
"\u{1b}[48;2;1;2;3mrgb",
"\u{1b}[38;5;not-a-numbermbad",
"\u{1b}[38;2;1;2mshort",
"\u{1b}[999munknown",
"\u{1b}[2Jcursor",
"\u{1b}7other",
);
let line = ansi_to_line(source);
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
assert!(text.contains("attrs"));
assert!(text.contains("indexed"));
assert!(text.contains("cursor"));
}
#[test]
fn ansi_to_line_preserves_blank_line() {
let line = ansi_to_line("");
assert_eq!(line.spans.len(), 1);
assert_eq!(line.spans[0].content, "");
}
#[test]
fn render_lines_renders_heading_text() {
let lines = render_lines("# Title\n\nbody text", 80);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("Title"), "has heading: {joined:?}");
assert!(joined.contains("body text"), "has body: {joined:?}");
}
}