use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use super::{
inline::parse_inline_formatting,
types::{MarkdownBlock, TextToken},
MarkdownRenderer,
};
use crate::{
constants::list_prefix::{
BOTTOM_MID, CORNER_BL, CORNER_BR, CORNER_TL, CORNER_TR, CROSS_MID, HLINE, MID_LEFT,
MID_RIGHT, ROUNDED_BL, ROUNDED_TL, TOP_MID, VLINE,
},
theme::RichTextTheme,
};
const LANG_MERMAID: &str = "mermaid";
impl MarkdownRenderer {
pub fn render(
&self,
blocks: &[MarkdownBlock],
theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for block in blocks {
match block {
MarkdownBlock::Heading1(text) => {
let parsed = parse_inline_formatting(text, theme);
let style = Style::default()
.fg(theme.get_primary_color())
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
if parsed.is_empty() {
lines.push(Line::from(Span::styled(text.clone(), style)));
} else {
let styled: Vec<Span<'static>> = parsed
.into_iter()
.map(|mut s| {
s.style = style.patch(s.style);
s
})
.collect();
lines.push(Line::from(styled));
}
}
MarkdownBlock::Heading2(text) => {
let parsed = parse_inline_formatting(text, theme);
let style = Style::default()
.fg(theme.get_text_color())
.add_modifier(Modifier::BOLD);
if parsed.is_empty() {
lines.push(Line::from(Span::styled(text.clone(), style)));
} else {
let styled: Vec<Span<'static>> = parsed
.into_iter()
.map(|mut s| {
s.style = style.patch(s.style);
s
})
.collect();
lines.push(Line::from(styled));
}
}
MarkdownBlock::Heading3(text) => {
let parsed = parse_inline_formatting(text, theme);
let style = Style::default()
.fg(theme.get_secondary_color())
.add_modifier(Modifier::BOLD);
if parsed.is_empty() {
lines.push(Line::from(Span::styled(text.clone(), style)));
} else {
let styled: Vec<Span<'static>> = parsed
.into_iter()
.map(|mut s| {
s.style = style.patch(s.style);
s
})
.collect();
lines.push(Line::from(styled));
}
}
MarkdownBlock::Paragraph(paragraph_lines) => {
for pline in paragraph_lines {
let wrapped = self.wrap_text_with_inline_formatting(pline, theme);
lines.extend(wrapped);
}
}
MarkdownBlock::CodeBlock(lang, content) => {
if lang == LANG_MERMAID {
} else {
if !lang.is_empty() {
lines.push(Line::from(Span::styled(
format!("{ROUNDED_TL}{HLINE} {} {HLINE}", lang),
Style::default().fg(theme.get_muted_text_color()),
)));
} else {
lines.push(Line::from(Span::styled(
format!("{ROUNDED_TL}{HLINE}"),
Style::default().fg(theme.get_muted_text_color()),
)));
}
for code_line in content.lines() {
lines.push(Line::from(vec![
Span::styled(
format!("{VLINE} "),
Style::default().fg(theme.get_muted_text_color()),
),
Span::styled(
code_line.to_string(),
Style::default().fg(theme.get_accent_yellow()),
),
]));
}
lines.push(Line::from(Span::styled(
format!("{ROUNDED_BL}{HLINE}"),
Style::default().fg(theme.get_muted_text_color()),
)));
}
}
MarkdownBlock::InlineCode(code) => {
lines.push(Line::from(Span::styled(
format!("`{}`", code),
Style::default().fg(theme.get_accent_yellow()),
)));
}
MarkdownBlock::ListItem(text, indent) => {
let indent_str = " ".repeat(*indent as usize);
let wrapped = self.wrap_text_with_inline_formatting(
&format!("{}\u{2022} {}", indent_str, text),
theme,
);
lines.extend(wrapped);
}
MarkdownBlock::Blockquote(text) => {
let wrapped = self.wrap_text_with_inline_formatting(text, theme);
for mut wline in wrapped {
wline.spans.insert(
0,
Span::styled("> ", Style::default().fg(theme.get_muted_text_color())),
);
for span in wline.spans.iter_mut().skip(1) {
let new_style = span
.style
.fg(theme.get_muted_text_color())
.add_modifier(Modifier::ITALIC);
span.style = new_style;
}
lines.push(wline);
}
}
MarkdownBlock::HorizontalRule => {
lines.push(Line::from(Span::styled(
HLINE.repeat(self.max_width.min(80)),
Style::default().fg(theme.get_muted_text_color()),
)));
}
MarkdownBlock::BlankLine => {
lines.push(Line::raw(""));
}
MarkdownBlock::Table { headers, rows } => {
let table_lines = self.render_table(headers, rows, theme);
lines.extend(table_lines);
}
}
}
lines
}
fn render_table(
&self,
headers: &[String],
rows: &[Vec<String>],
theme: &impl RichTextTheme,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let col_count = headers
.len()
.max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
let padding_per_cell: usize = 2;
let border_overhead = col_count + 1;
let total_padding = col_count * padding_per_cell;
let available = if self.max_width > border_overhead + total_padding {
self.max_width
.saturating_sub(border_overhead + total_padding)
} else {
80_usize.saturating_sub(border_overhead + total_padding)
};
fn longest_token_width(text: &str) -> usize {
MarkdownRenderer::tokenize(text)
.into_iter()
.filter_map(|t| match t {
TextToken::Word(w) => Some(MarkdownRenderer::string_width(&w)),
_ => None,
})
.max()
.unwrap_or(0)
}
let header_widths: Vec<usize> = (0..col_count)
.map(|c| headers.get(c).map(|h| Self::string_width(h)).unwrap_or(0))
.collect();
let min_widths: Vec<usize> = (0..col_count)
.map(|c| {
let h_longest = headers.get(c).map(|h| longest_token_width(h)).unwrap_or(0);
let d_longest = rows
.iter()
.filter_map(|r| r.get(c))
.map(|cell| longest_token_width(cell))
.max()
.unwrap_or(0);
h_longest.max(d_longest).max(3)
})
.collect();
let natural_widths: Vec<usize> = (0..col_count)
.map(|c| {
let hw = header_widths[c];
let rw = rows
.iter()
.filter_map(|r| r.get(c))
.map(|cell| Self::string_width(cell))
.max()
.unwrap_or(0);
hw.max(rw)
})
.collect();
let target_lines: usize = 3;
let ideal_widths: Vec<usize> = (0..col_count)
.map(|c| {
let natural = natural_widths[c];
if natural == 0 {
return min_widths[c];
}
let wrapped = natural.div_ceil(target_lines);
wrapped.max(min_widths[c])
})
.collect();
let total_ideal: usize = ideal_widths.iter().sum();
let mut col_widths: Vec<usize> = if total_ideal <= available {
(0..col_count)
.map(|c| ideal_widths[c].max(min_widths[c]))
.collect()
} else {
(0..col_count)
.map(|c| {
let proportional =
(available as u64 * ideal_widths[c] as u64 / total_ideal as u64) as usize;
proportional.max(min_widths[c])
})
.collect()
};
let mut total_allocated: usize = col_widths.iter().sum();
if total_allocated > available {
let deficit = total_allocated - available;
let mut remaining = deficit;
let mut sorted: Vec<usize> = (0..col_count).collect();
sorted.sort_by_key(|&i| std::cmp::Reverse(col_widths[i] - min_widths[i]));
for idx in sorted {
if remaining == 0 {
break;
}
let shrinkable = col_widths[idx] - min_widths[idx];
let take = shrinkable.min(remaining);
col_widths[idx] -= take;
remaining -= take;
}
total_allocated = col_widths.iter().sum();
}
if total_allocated < available {
let mut surplus = available - total_allocated;
let total_natural: usize = natural_widths.iter().sum::<usize>().max(1);
while surplus > 0 {
let mut gave_this_round = false;
for idx in 0..col_count {
if surplus == 0 {
break;
}
let share = ((surplus * natural_widths[idx]) / total_natural).max(1);
let take = share.min(surplus);
col_widths[idx] += take;
surplus -= take;
gave_this_round = true;
}
if !gave_this_round {
break;
}
}
}
let border_style = Style::default().fg(theme.get_muted_text_color());
lines.push(Line::from(Span::styled(
Self::build_table_hline(&col_widths, CORNER_TL, TOP_MID, CORNER_TR),
border_style,
)));
let header_line_spans: Vec<Vec<Vec<Span<'static>>>> = (0..col_count)
.map(|c| {
let text = headers.get(c).map(|s| s.as_str()).unwrap_or("");
let inner = col_widths[c].saturating_sub(2);
let base_style = Style::default()
.fg(theme.get_text_color())
.add_modifier(Modifier::BOLD);
let parsed = parse_inline_formatting(text, theme);
let spans = if parsed.is_empty() {
vec![Span::styled(text.to_string(), base_style)]
} else {
parsed
.into_iter()
.map(|mut s| {
s.style = base_style.patch(s.style);
s
})
.collect()
};
Self::wrap_styled_spans_to_width(spans, inner)
})
.collect();
let header_height = header_line_spans
.iter()
.map(|l| l.len().max(1))
.max()
.unwrap_or(1);
for line_idx in 0..header_height {
let line_cells: Vec<Vec<Span<'static>>> = (0..col_count)
.map(|c| {
if line_idx < header_line_spans[c].len() {
header_line_spans[c][line_idx].clone()
} else {
vec![]
}
})
.collect();
lines.push(Self::build_table_row_from_spans(
&col_widths,
&line_cells,
theme,
true,
));
}
lines.push(Line::from(Span::styled(
Self::build_table_hline(&col_widths, MID_LEFT, CROSS_MID, MID_RIGHT),
border_style,
)));
for row in rows {
let cell_line_spans: Vec<Vec<Vec<Span<'static>>>> = (0..col_count)
.map(|c| {
let text = row.get(c).map(|s| s.as_str()).unwrap_or("");
let inner = col_widths[c].saturating_sub(2);
let base_style = Style::default().fg(theme.get_text_color());
let parsed = parse_inline_formatting(text, theme);
let spans = if parsed.is_empty() {
vec![Span::styled(text.to_string(), base_style)]
} else {
parsed
};
Self::wrap_styled_spans_to_width(spans, inner)
})
.collect();
let row_height = cell_line_spans
.iter()
.map(|l| l.len().max(1))
.max()
.unwrap_or(1);
for line_idx in 0..row_height {
let line_cells: Vec<Vec<Span<'static>>> = (0..col_count)
.map(|c| {
if line_idx < cell_line_spans[c].len() {
cell_line_spans[c][line_idx].clone()
} else {
vec![]
}
})
.collect();
lines.push(Self::build_table_row_from_spans(
&col_widths,
&line_cells,
theme,
false,
));
}
lines.push(Line::from(Span::styled(
Self::build_table_hline(&col_widths, MID_LEFT, CROSS_MID, MID_RIGHT),
border_style,
)));
}
let last_sep_idx = lines.len() - 1;
let last_hline = Self::build_table_hline(&col_widths, CORNER_BL, BOTTOM_MID, CORNER_BR);
lines[last_sep_idx] = Line::from(Span::styled(last_hline, border_style));
lines
}
pub(crate) fn build_table_hline(
col_widths: &[usize],
left: &str,
mid: &str,
right: &str,
) -> String {
let mut parts = vec![left.to_string()];
for width in col_widths.iter() {
parts.push(HLINE.repeat(*width));
parts.push(mid.to_string());
}
parts.pop();
parts.push(right.to_string());
parts.join("")
}
pub(crate) fn wrap_styled_spans_to_width(
spans: Vec<Span<'static>>,
max_w: usize,
) -> Vec<Vec<Span<'static>>> {
if max_w == 0 || spans.is_empty() {
return if spans.is_empty() {
vec![vec![]]
} else {
vec![spans]
};
}
let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
let mut current_line: Vec<Span<'static>> = Vec::new();
let mut current_width: usize = 0;
let mut pending_space = false;
for span in spans {
let style = span.style;
let text = span.content.to_string();
let tokens = Self::tokenize(&text);
for token in tokens {
match token {
TextToken::Newline => {
lines.push(std::mem::take(&mut current_line));
current_width = 0;
pending_space = false;
}
TextToken::Space => {
pending_space = true;
}
TextToken::Word(word) => {
let word_w = Self::string_width(&word);
let space_w: usize = if pending_space && current_width > 0 {
1
} else {
0
};
let needs_wrap = if current_width == 0 {
false
} else if space_w > 0 && current_width + space_w >= max_w {
true
} else {
current_width + space_w + word_w > max_w
};
if needs_wrap && !current_line.is_empty() {
lines.push(std::mem::take(&mut current_line));
current_width = 0;
pending_space = false;
}
let final_space = if pending_space && current_width > 0 {
pending_space = false;
1
} else {
0
};
if final_space > 0 {
current_line.push(Span::styled(" ".to_string(), style));
current_width += final_space;
}
if current_width == 0 && word_w > max_w && max_w > 0 {
let mut chars: Vec<char> = word.chars().collect();
let mut char_w = 0;
let mut chunk = String::new();
for ch in chars.drain(..) {
let cw = Self::string_width(&ch.to_string());
if char_w + cw > max_w && !chunk.is_empty() {
current_line.push(Span::styled(chunk, style));
lines.push(std::mem::take(&mut current_line));
chunk = String::new();
char_w = 0;
}
chunk.push(ch);
char_w += cw;
}
if !chunk.is_empty() {
current_line.push(Span::styled(chunk, style));
current_width += char_w;
}
} else {
current_line.push(Span::styled(word, style));
current_width += word_w;
}
}
}
}
}
if !current_line.is_empty() || lines.is_empty() {
lines.push(current_line);
}
lines
}
pub(crate) fn build_table_row_from_spans(
col_widths: &[usize],
cell_spans: &[Vec<Span<'static>>],
theme: &impl RichTextTheme,
is_header: bool,
) -> Line<'static> {
let base_style = if is_header {
Style::default()
.fg(theme.get_text_color())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.get_text_color())
};
let mut spans = Vec::new();
spans.push(Span::styled(
VLINE.to_string(),
Style::default().fg(theme.get_muted_text_color()),
));
for (i, width) in col_widths.iter().enumerate() {
let cell_spans_ref = cell_spans.get(i).map(|v| v.as_slice()).unwrap_or(&[]);
let total_cell_w: usize = cell_spans_ref
.iter()
.map(|s| Self::string_width(&s.content))
.sum();
let inner_w = width.saturating_sub(2);
spans.push(Span::styled(" ".to_string(), base_style));
spans.extend_from_slice(cell_spans_ref);
let padding = inner_w.saturating_sub(total_cell_w);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), base_style));
}
spans.push(Span::styled(
VLINE.to_string(),
Style::default().fg(theme.get_muted_text_color()),
));
}
Line::from(spans)
}
}