use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use crate::foundation::wrapping::adaptive_wrap_lines;
use crate::foundation::wrapping::RtOptions;
use crate::terminal::palette::ColorCapabilities;
pub(super) fn render_markdown(
text: &str,
width: usize,
capabilities: ColorCapabilities,
) -> Vec<Line<'static>> {
let width = width.max(1);
let source = text.lines().collect::<Vec<_>>();
if source.is_empty() {
return vec![Line::default()];
}
let mut out = Vec::new();
let mut index = 0usize;
let mut fence: Option<String> = None;
while index < source.len() {
let line = source[index];
if let Some(marker) = line.trim_start().strip_prefix("```") {
if fence.is_some() {
out.push(Line::from(Span::styled(
"└─".to_string(),
code_border_style(capabilities),
)));
fence = None;
} else {
let language = marker.trim().to_string();
let label = if language.is_empty() {
"┌─ code".into()
} else {
format!("┌─ {language}")
};
out.push(Line::from(Span::styled(
label,
code_border_style(capabilities),
)));
fence = Some(language);
}
index += 1;
continue;
}
if fence.is_some() {
let options = RtOptions::new(width)
.initial_indent(Line::from("│ "))
.subsequent_indent(Line::from("│ "));
out.extend(adaptive_wrap_lines(
[Line::from(Span::styled(
line.to_string(),
Style::default().fg(capabilities.best_color((170, 205, 240))),
))],
options,
));
index += 1;
continue;
}
if index + 1 < source.len()
&& source[index].contains('|')
&& is_table_delimiter(source[index + 1])
{
let start = index;
index += 2;
while index < source.len() && source[index].contains('|') && !source[index].is_empty() {
index += 1;
}
out.extend(render_table(&source[start..index], width, capabilities));
continue;
}
if line.trim().is_empty() {
out.push(Line::default());
index += 1;
continue;
}
if let Some((level, heading)) = heading(line) {
let style = Style::default()
.fg(capabilities.best_color((135, 195, 250)))
.add_modifier(Modifier::BOLD);
let marker = "#".repeat(level.min(3));
out.extend(adaptive_wrap_lines(
[Line::from(vec![
Span::styled(format!("{marker} "), style),
Span::styled(heading.to_string(), style),
])],
RtOptions::new(width),
));
index += 1;
continue;
}
if let Some((prefix, item)) = list_item(line) {
let prefix_width = prefix.width();
let bullet_style = Style::default()
.fg(capabilities.best_color((135, 195, 250)))
.add_modifier(Modifier::BOLD);
let options = RtOptions::new(width)
.initial_indent(Line::from(Span::styled(prefix.clone(), bullet_style)))
.subsequent_indent(Line::from(" ".repeat(prefix_width)));
out.extend(adaptive_wrap_lines(
[Line::from(inline_spans(item, capabilities))],
options,
));
index += 1;
continue;
}
out.extend(adaptive_wrap_lines(
[Line::from(inline_spans(line, capabilities))],
RtOptions::new(width),
));
index += 1;
}
if fence.is_some() {
out.push(Line::from(Span::styled(
"└─".to_string(),
code_border_style(capabilities),
)));
}
out
}
fn inline_spans(text: &str, capabilities: ColorCapabilities) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let bold = remaining.find("**");
let code = remaining.find('`');
let next = match (bold, code) {
(Some(left), Some(right)) => left.min(right),
(Some(index), None) | (None, Some(index)) => index,
(None, None) => {
spans.push(Span::raw(remaining.to_string()));
break;
}
};
if next > 0 {
spans.push(Span::raw(remaining[..next].to_string()));
remaining = &remaining[next..];
continue;
}
if let Some(rest) = remaining.strip_prefix("**") {
if let Some(end) = rest.find("**") {
spans.push(Span::styled(
rest[..end].to_string(),
Style::default().add_modifier(Modifier::BOLD),
));
remaining = &rest[end + 2..];
} else {
spans.push(Span::raw("**"));
remaining = rest;
}
continue;
}
if let Some(rest) = remaining.strip_prefix('`') {
if let Some(end) = rest.find('`') {
spans.push(Span::styled(
rest[..end].to_string(),
Style::default().fg(capabilities.best_color((210, 165, 105))),
));
remaining = &rest[end + 1..];
} else {
spans.push(Span::raw("`"));
remaining = rest;
}
}
}
spans
}
fn render_table(
lines: &[&str],
width: usize,
capabilities: ColorCapabilities,
) -> Vec<Line<'static>> {
let rows = lines
.iter()
.enumerate()
.filter(|(index, _)| *index != 1)
.map(|(_, line)| table_cells(line))
.collect::<Vec<_>>();
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
let separators = columns + 1;
let available = width.saturating_sub(separators).max(columns);
let base = (available / columns).max(1);
let mut widths = vec![base; columns];
for index in 0..available.saturating_sub(base * columns) {
widths[index % columns] += 1;
}
let border = Style::default().fg(capabilities.best_color((100, 130, 155)));
let header = Style::default().add_modifier(Modifier::BOLD);
let mut out = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
let mut spans = vec![Span::styled("│".to_string(), border)];
for (column, cell) in row.iter().enumerate().take(columns) {
let value = truncate_width(cell, widths[column].saturating_sub(2));
let padding = widths[column].saturating_sub(value.width() + 1);
spans.push(Span::styled(
format!(" {value}{}", " ".repeat(padding)),
if row_index == 0 {
header
} else {
Style::default()
},
));
spans.push(Span::styled("│".to_string(), border));
}
for width in widths.iter().skip(row.len()) {
spans.push(Span::raw(" ".repeat(*width)));
spans.push(Span::styled("│".to_string(), border));
}
out.push(Line::from(spans));
if row_index == 0 {
out.push(Line::from(Span::styled(
widths
.iter()
.map(|width| "─".repeat(*width))
.collect::<Vec<_>>()
.join("┼")
.pipe(|middle| format!("├{middle}┤")),
border,
)));
}
}
out
}
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}
fn table_cells(line: &str) -> Vec<String> {
line.trim()
.trim_matches('|')
.split('|')
.map(|cell| cell.trim().to_string())
.collect()
}
fn is_table_delimiter(line: &str) -> bool {
let cells = table_cells(line);
!cells.is_empty()
&& cells.iter().all(|cell| {
let cell = cell.trim_matches(':').trim();
cell.len() >= 3 && cell.chars().all(|ch| ch == '-')
})
}
fn heading(line: &str) -> Option<(usize, &str)> {
let level = line.chars().take_while(|ch| *ch == '#').count();
(level > 0 && line.as_bytes().get(level) == Some(&b' '))
.then(|| (level, line[level + 1..].trim()))
}
fn list_item(line: &str) -> Option<(String, &str)> {
let trimmed = line.trim_start();
let indentation = line.len() - trimmed.len();
for marker in ["- ", "* ", "+ "] {
if let Some(item) = trimmed.strip_prefix(marker) {
return Some((format!("{}• ", " ".repeat(indentation)), item));
}
}
let digit_count = trimmed.chars().take_while(|ch| ch.is_ascii_digit()).count();
if digit_count > 0 && trimmed[digit_count..].starts_with(". ") {
return Some((
format!("{}{} ", " ".repeat(indentation), &trimmed[..=digit_count]),
&trimmed[digit_count + 2..],
));
}
None
}
fn truncate_width(text: &str, width: usize) -> String {
if text.width() <= width {
return text.to_string();
}
if width == 0 {
return String::new();
}
let target = width.saturating_sub(1);
let mut used = 0usize;
let mut out = String::new();
for ch in text.chars() {
let char_width = ch.width().unwrap_or(0);
if used + char_width > target {
break;
}
used += char_width;
out.push(ch);
}
out.push('…');
out
}
fn code_border_style(capabilities: ColorCapabilities) -> Style {
Style::default().fg(capabilities.best_color((100, 150, 190)))
}
#[cfg(test)]
mod tests {
use crate::terminal::palette::ColorLevel;
use super::*;
fn capabilities() -> ColorCapabilities {
ColorCapabilities {
level: ColorLevel::TrueColor,
color_enabled: true,
}
}
fn visible(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn paragraphs_lists_tables_and_fences_have_stable_terminal_shape() {
let markdown = "# Result\nA **bold** paragraph with `code`.\n\n- first item\n- second item wraps across a narrow terminal width\n\n| Name | State |\n| --- | --- |\n| build | green |\n\n```rust\nfn main() { println!(\"hi\"); }\n```";
let rendered = visible(&render_markdown(markdown, 32, capabilities()));
assert_eq!(
rendered,
"# Result\nA bold paragraph with code.\n\n• first item\n• second item wraps across a\n narrow terminal width\n\n│ Name │ State │\n├───────────────┼──────────────┤\n│ build │ green │\n\n┌─ rust\n│ fn main() { println!(\"hi\"); }\n└─"
);
}
#[test]
fn long_unbroken_unicode_text_never_panics_at_tiny_width() {
let lines = render_markdown("東京東京東京_abcdefghijklmnop", 5, capabilities());
assert!(!lines.is_empty());
assert!(lines.iter().all(|line| line.width() <= 5));
}
}