use super::{appearance as theme, *};
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
#[derive(Clone, Debug, Default)]
struct ListContext {
next_index: Option<u64>,
}
#[derive(Clone, Debug)]
struct ItemContext {
prefix: String,
continuation: String,
used_prefix: bool,
}
#[derive(Clone, Debug)]
struct CodeBlockContext {
language: String,
text: String,
}
#[derive(Clone, Debug)]
struct InlineTarget {
destination: String,
is_image: bool,
}
struct CellContent {
spans: Vec<Span<'static>>,
}
impl CellContent {
fn visual_width(&self) -> usize {
spans_visual_width(&self.spans)
}
fn into_wrapped_lines(self, width: usize) -> Vec<Vec<Span<'static>>> {
if width == 0 {
return vec![Vec::new()];
}
let wrapped = word_wrap_spans(self.spans, width, width);
if wrapped.is_empty() {
vec![Vec::new()]
} else {
wrapped
}
}
}
struct TableRowBuffer {
cells: Vec<CellContent>,
is_head: bool,
}
struct TableState {
rows: Vec<TableRowBuffer>,
current_cells: Vec<CellContent>,
current_cell_spans: Vec<Span<'static>>,
in_head: bool,
}
impl TableState {
fn new() -> Self {
Self {
rows: Vec::new(),
current_cells: Vec::new(),
current_cell_spans: Vec::new(),
in_head: false,
}
}
}
struct MarkdownRenderer {
palette: theme::Palette,
lines: Vec<Line<'static>>,
current: Vec<Span<'static>>,
prefix_span_count: usize,
styles: Vec<Style>,
list_stack: Vec<ListContext>,
current_item: Option<ItemContext>,
blockquote_depth: usize,
heading_level: Option<HeadingLevel>,
code_block: Option<CodeBlockContext>,
inline_targets: Vec<InlineTarget>,
in_table_head: bool,
table_state: Option<TableState>,
details_depth: usize,
}
pub(super) fn render_markdown_preview(text: &str) -> Vec<Line<'static>> {
let mut renderer = MarkdownRenderer::new(theme::palette());
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_TABLES);
for event in Parser::new_ext(text, options) {
if renderer.is_full() {
break;
}
renderer.handle_event(event);
}
renderer.finish()
}
impl MarkdownRenderer {
fn new(palette: theme::Palette) -> Self {
Self {
palette,
lines: Vec::new(),
current: Vec::new(),
prefix_span_count: 0,
styles: Vec::new(),
list_stack: Vec::new(),
current_item: None,
blockquote_depth: 0,
heading_level: None,
code_block: None,
inline_targets: Vec::new(),
in_table_head: false,
table_state: None,
details_depth: 0,
}
}
fn finish(mut self) -> Vec<Line<'static>> {
self.flush_line_if_content();
self.trim_trailing_blank_lines();
if self.lines.is_empty() {
self.lines.push(Line::from("File is empty"));
}
self.lines.truncate(PREVIEW_RENDER_LINE_LIMIT);
self.lines
}
fn is_full(&self) -> bool {
self.lines.len() >= PREVIEW_RENDER_LINE_LIMIT
}
fn handle_event(&mut self, event: Event<'_>) {
if let Some(code_block) = &mut self.code_block {
match event {
Event::End(TagEnd::CodeBlock) => {
let code_block = self.code_block.take().expect("code block should exist");
self.render_code_block(code_block);
self.push_blank_line();
}
Event::Text(text)
| Event::Code(text)
| Event::Html(text)
| Event::InlineHtml(text) => {
code_block.text.push_str(&text);
}
Event::SoftBreak | Event::HardBreak => code_block.text.push('\n'),
_ => {}
}
return;
}
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => self.push_text(&text),
Event::Code(text) => self.push_inline_code(&text),
Event::Html(html) | Event::InlineHtml(html) => self.handle_html(&html),
Event::SoftBreak => self.line_break(),
Event::HardBreak => self.line_break(),
Event::Rule => self.push_rule(),
Event::TaskListMarker(checked) => self.set_task_marker(checked),
Event::FootnoteReference(label) => self.push_text(&format!("[{label}]")),
Event::InlineMath(text) | Event::DisplayMath(text) => self.push_inline_code(&text),
}
}
fn start_tag(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => {
if self.current_item.is_none() {
self.ensure_block_gap();
}
}
Tag::Heading { level, .. } => {
self.ensure_block_gap();
self.heading_level = Some(level);
}
Tag::BlockQuote(_) => {
self.ensure_block_gap();
self.blockquote_depth += 1;
}
Tag::List(start) => {
self.ensure_block_gap();
self.list_stack.push(ListContext { next_index: start });
}
Tag::Item => {
self.flush_line_if_content();
self.current_item = Some(self.make_item_context());
}
Tag::CodeBlock(kind) => {
self.ensure_block_gap();
self.code_block = Some(CodeBlockContext {
language: code_block_label(kind),
text: String::new(),
});
}
Tag::Emphasis => self
.styles
.push(Style::default().add_modifier(Modifier::ITALIC)),
Tag::Strong => self
.styles
.push(Style::default().add_modifier(Modifier::BOLD)),
Tag::Strikethrough => self
.styles
.push(Style::default().add_modifier(Modifier::CROSSED_OUT)),
Tag::Link { dest_url, .. } => {
self.styles.push(
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
);
self.inline_targets.push(InlineTarget {
destination: dest_url.to_string(),
is_image: false,
});
}
Tag::Image { .. } => {
self.push_inline_image_icon();
self.styles.push(
Style::default()
.fg(self.palette.muted)
.add_modifier(Modifier::ITALIC),
);
self.inline_targets.push(InlineTarget {
destination: String::new(),
is_image: true,
});
}
Tag::Table(_) => {
self.ensure_block_gap();
self.table_state = Some(TableState::new());
}
Tag::TableHead => {
self.in_table_head = true;
if let Some(ts) = &mut self.table_state {
ts.in_head = true;
ts.current_cells.clear();
}
}
Tag::TableRow => {
self.in_table_head = false;
if let Some(ts) = &mut self.table_state {
ts.in_head = false;
ts.current_cells.clear();
}
}
Tag::TableCell => {
if let Some(ts) = &mut self.table_state {
ts.current_cell_spans.clear();
}
}
_ => {}
}
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
self.flush_line_if_content();
if self.current_item.is_none() {
self.push_blank_line();
}
}
TagEnd::Heading(_) => {
self.flush_line_if_content();
self.heading_level = None;
self.push_blank_line();
}
TagEnd::BlockQuote(_) => {
self.flush_line_if_content();
self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
self.push_blank_line();
}
TagEnd::List(_) => {
self.flush_line_if_content();
self.list_stack.pop();
self.push_blank_line();
}
TagEnd::Item => {
self.flush_line_if_content();
self.current_item = None;
}
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Image => {
self.styles.pop();
if let Some(target) = self.inline_targets.pop() {
self.push_inline_destination(&target);
}
}
TagEnd::TableCell => {
if let Some(ts) = &mut self.table_state {
let spans = std::mem::take(&mut ts.current_cell_spans);
ts.current_cells.push(CellContent { spans });
}
}
TagEnd::TableHead => {
self.in_table_head = false;
if let Some(ts) = &mut self.table_state {
ts.in_head = false;
let cells = std::mem::take(&mut ts.current_cells);
ts.rows.push(TableRowBuffer {
cells,
is_head: true,
});
}
}
TagEnd::TableRow => {
if let Some(ts) = &mut self.table_state {
let cells = std::mem::take(&mut ts.current_cells);
ts.rows.push(TableRowBuffer {
cells,
is_head: false,
});
}
}
TagEnd::Table => {
if let Some(table_state) = self.table_state.take() {
self.render_table(table_state);
}
self.push_blank_line();
}
_ => {}
}
}
fn make_item_context(&mut self) -> ItemContext {
let depth = self.list_stack.len().saturating_sub(1);
let indent = " ".repeat(depth);
let marker = if let Some(list) = self.list_stack.last_mut() {
if let Some(next_index) = list.next_index.as_mut() {
let marker = format!("{next_index}. ");
*next_index += 1;
marker
} else {
"• ".to_string()
}
} else {
"• ".to_string()
};
let continuation = format!("{}{}", indent, " ".repeat(marker.len()));
ItemContext {
prefix: format!("{indent}{marker}"),
continuation,
used_prefix: false,
}
}
fn set_task_marker(&mut self, checked: bool) {
let Some(item) = &mut self.current_item else {
return;
};
let indent_len = item.prefix.len().saturating_sub(2);
let indent = " ".repeat(indent_len);
let marker = if checked { " " } else { " " };
item.prefix = format!("{indent}{marker}");
item.continuation = format!("{indent} ");
}
fn push_rule(&mut self) {
self.ensure_block_gap();
self.push_line(Line::from(Span::styled(
"────────────────",
Style::default().fg(self.palette.border),
)));
self.push_blank_line();
}
fn push_inline_code(&mut self, text: &str) {
let span = Span::styled(
text.to_string(),
Style::default()
.fg(self.palette.accent_text)
.bg(self.palette.accent_soft)
.add_modifier(Modifier::BOLD),
);
if let Some(ts) = &mut self.table_state {
ts.current_cell_spans.push(span);
return;
}
self.ensure_prefix();
self.current.push(span);
}
fn push_text(&mut self, text: &str) {
for (index, segment) in text.split('\n').enumerate() {
if index > 0 {
self.line_break();
}
if segment.is_empty() {
continue;
}
self.push_styled_text(segment, self.current_style());
}
}
fn push_styled_text(&mut self, text: &str, style: Style) {
if let Some(ts) = &mut self.table_state {
ts.current_cell_spans
.push(Span::styled(text.to_string(), style));
return;
}
self.ensure_prefix();
self.current.push(Span::styled(text.to_string(), style));
}
fn push_inline_image_icon(&mut self) {
let style = self.current_style().patch(
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD),
);
self.push_styled_text(" ", style);
}
fn current_style(&self) -> Style {
let mut style = Style::default().fg(self.palette.text);
if let Some(level) = self.heading_level {
style = style.patch(heading_style(level, self.palette));
}
if self.blockquote_depth > 0 {
style = style.patch(Style::default().fg(self.palette.muted));
}
if self.in_table_head {
style = style.patch(Style::default().add_modifier(Modifier::BOLD));
}
for extra in &self.styles {
style = style.patch(*extra);
}
style
}
fn ensure_prefix(&mut self) {
if !self.current.is_empty() {
return;
}
for _ in 0..self.blockquote_depth {
self.current
.push(Span::styled("▎ ", Style::default().fg(self.palette.accent)));
}
for _ in 0..self.details_depth {
self.current
.push(Span::styled("╎ ", Style::default().fg(self.palette.muted)));
}
if let Some(item) = &mut self.current_item {
let prefix = if item.used_prefix {
item.continuation.clone()
} else {
item.used_prefix = true;
item.prefix.clone()
};
if !prefix.is_empty() {
self.current.push(Span::styled(
prefix,
Style::default().fg(self.palette.accent),
));
}
}
self.prefix_span_count = self.current.len();
}
fn ensure_block_gap(&mut self) {
self.flush_line_if_content();
self.push_blank_line();
}
fn push_blank_line(&mut self) {
if self.lines.is_empty() || self.last_line_is_blank() {
return;
}
self.push_line(Line::from(String::new()));
}
fn last_line_is_blank(&self) -> bool {
self.lines
.last()
.is_some_and(|line| line.spans.iter().all(|span| span.content.is_empty()))
}
fn trim_trailing_blank_lines(&mut self) {
while self.last_line_is_blank() {
self.lines.pop();
}
}
fn push_inline_destination(&mut self, target: &InlineTarget) {
if target.is_image {
return;
}
if self.table_state.is_some() {
return;
}
let destination = target.destination.trim();
if destination.is_empty() {
return;
}
self.push_styled_text(" ", Style::default().fg(self.palette.muted));
self.push_styled_text(
&format!("({destination})"),
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::UNDERLINED),
);
}
fn line_break(&mut self) {
if let Some(ts) = &mut self.table_state {
if !ts.current_cell_spans.is_empty() {
ts.current_cell_spans.push(Span::raw(" "));
}
return;
}
if !self.current.is_empty() {
self.flush_wrapped_prose();
} else {
self.flush_line();
}
}
fn flush_line_if_content(&mut self) {
if !self.current.is_empty() {
self.flush_wrapped_prose();
}
}
fn flush_wrapped_prose(&mut self) {
let total_width: usize = self.current.iter().map(|s| s.content.chars().count()).sum();
if total_width <= MARKDOWN_CONTENT_WIDTH {
self.flush_line();
return;
}
let prefix_count = self.prefix_span_count.min(self.current.len());
let all_spans = std::mem::take(&mut self.current);
let prefix_spans = all_spans[..prefix_count].to_vec();
let content_spans = all_spans[prefix_count..].to_vec();
let prefix_width: usize = prefix_spans.iter().map(|s| s.content.chars().count()).sum();
let first_max = MARKDOWN_CONTENT_WIDTH.saturating_sub(prefix_width).max(20);
let mut cont_spans = self.gutter_prefix_spans();
if let Some(item) = &self.current_item {
let cont = item.continuation.clone();
if !cont.is_empty() {
cont_spans.push(Span::styled(cont, Style::default().fg(self.palette.accent)));
}
}
let cont_width: usize = cont_spans.iter().map(|s| s.content.chars().count()).sum();
let rest_max = MARKDOWN_CONTENT_WIDTH.saturating_sub(cont_width).max(20);
let wrapped = word_wrap_spans(content_spans, first_max, rest_max);
for (i, line_spans) in wrapped.into_iter().enumerate() {
if self.is_full() {
break;
}
let mut spans = if i == 0 {
prefix_spans.clone()
} else {
cont_spans.clone()
};
spans.extend(line_spans);
self.push_line(Line::from(spans));
}
}
fn flush_line(&mut self) {
if self.is_full() {
self.current.clear();
return;
}
let line = if self.current.is_empty() {
Line::from(String::new())
} else {
Line::from(std::mem::take(&mut self.current))
};
self.push_line(line);
}
fn push_line(&mut self, line: Line<'static>) {
if self.is_full() {
return;
}
self.lines.push(line);
}
fn gutter_prefix_spans(&self) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for _ in 0..self.blockquote_depth {
spans.push(Span::styled("▎ ", Style::default().fg(self.palette.accent)));
}
for _ in 0..self.details_depth {
spans.push(Span::styled("╎ ", Style::default().fg(self.palette.muted)));
}
spans
}
fn push_line_with_gutter(&mut self, line_spans: Vec<Span<'static>>) {
let mut spans = self.gutter_prefix_spans();
spans.extend(line_spans);
self.push_line(Line::from(spans));
}
fn render_table(&mut self, table: TableState) {
if table.rows.is_empty() {
return;
}
let col_count = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
if col_count == 0 {
return;
}
let col_widths = self.measure_table_column_widths(&table, col_count);
let border_style = Style::default().fg(self.palette.border);
let make_rule = |left: &'static str,
mid: &'static str,
right: &'static str,
widths: &[usize]|
-> Vec<Span<'static>> {
let mut spans = vec![Span::styled(left, border_style)];
for (i, &w) in widths.iter().enumerate() {
spans.push(Span::styled("─".repeat(w + 2), border_style));
if i + 1 < widths.len() {
spans.push(Span::styled(mid, border_style));
}
}
spans.push(Span::styled(right, border_style));
spans
};
self.push_line_with_gutter(make_rule("┌", "┬", "┐", &col_widths));
for row in table.rows {
if self.is_full() {
break;
}
let is_head = row.is_head;
let cell_count = row.cells.len();
let wrapped_cells: Vec<Vec<Vec<Span<'static>>>> = row
.cells
.into_iter()
.enumerate()
.map(|(i, cell)| {
let col_w = col_widths.get(i).copied().unwrap_or(0);
cell.into_wrapped_lines(col_w)
})
.collect();
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
for line_index in 0..row_height {
if self.is_full() {
break;
}
let mut spans = vec![Span::styled("│", border_style)];
for (i, cell_lines) in wrapped_cells.iter().enumerate() {
let col_w = col_widths.get(i).copied().unwrap_or(0);
let line = cell_lines.get(line_index).cloned().unwrap_or_default();
spans.push(Span::raw(" "));
spans.extend(pad_spans_to_width(line, col_w));
spans.push(Span::raw(" "));
spans.push(Span::styled("│", border_style));
}
for &col_w in col_widths.iter().take(col_count).skip(cell_count) {
spans.push(Span::raw(" "));
spans.push(Span::raw(" ".repeat(col_w)));
spans.push(Span::raw(" "));
spans.push(Span::styled("│", border_style));
}
self.push_line_with_gutter(spans);
}
if is_head {
self.push_line_with_gutter(make_rule("├", "┼", "┤", &col_widths));
}
}
self.push_line_with_gutter(make_rule("└", "┴", "┘", &col_widths));
}
fn measure_table_column_widths(&self, table: &TableState, col_count: usize) -> Vec<usize> {
let mut natural_widths = vec![1usize; col_count];
for row in &table.rows {
for (i, cell) in row.cells.iter().enumerate() {
natural_widths[i] = natural_widths[i].max(cell.visual_width());
}
}
let border_width = col_count.saturating_mul(3).saturating_add(1);
let gutter_width = spans_visual_width(&self.gutter_prefix_spans());
let available_total_width = MARKDOWN_CONTENT_WIDTH
.saturating_sub(gutter_width)
.max(border_width.saturating_add(col_count));
let available_cell_width = available_total_width.saturating_sub(border_width);
fit_column_widths_to_total(natural_widths, available_cell_width)
}
fn handle_html(&mut self, html: &str) {
let lower = html.trim().to_lowercase();
if lower == "<br>" || lower == "<br/>" || lower == "<br />" {
self.line_break();
return;
}
if lower == "<details>" || lower.starts_with("<details ") {
return;
}
if lower == "</details>" {
self.details_depth = self.details_depth.saturating_sub(1);
return;
}
let text = strip_html_tags(html);
let text = text.trim().to_string();
if text.is_empty() {
return;
}
if lower.contains("<summary") {
self.ensure_block_gap();
self.push_styled_text(
"▶ ",
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD),
);
self.push_styled_text(
&text,
Style::default()
.fg(self.palette.text)
.add_modifier(Modifier::BOLD),
);
self.flush_line_if_content();
self.details_depth += 1;
return;
}
let muted = Style::default().fg(self.palette.muted);
for (i, line) in text.lines().enumerate() {
if i > 0 {
self.line_break();
}
let line = line.trim();
if !line.is_empty() {
self.push_styled_text(line, muted);
}
}
}
fn render_code_block(&mut self, code_block: CodeBlockContext) {
self.flush_line_if_content();
self.push_line(Line::from(vec![
Span::styled(
" ",
Style::default()
.fg(self.palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
code_block.language.clone(),
Style::default()
.fg(self.palette.muted)
.add_modifier(Modifier::ITALIC),
),
]));
let preview_spec = super::code::registry::language_for_markdown_fence(&code_block.language)
.map(|language| language.preview_spec())
.unwrap_or(crate::file_info::PreviewSpec {
kind: crate::file_info::PreviewKind::Source,
language_hint: None,
code_syntax: None,
code_backend: crate::file_info::CodeBackend::Plain,
structured_format: None,
document_format: None,
});
let rendered = super::code::render_code_preview(
preview_spec,
&code_block.text,
false,
PREVIEW_RENDER_LINE_LIMIT,
&|| false,
);
for line in rendered {
if self.is_full() {
break;
}
self.push_line(line);
}
}
}
fn heading_style(level: HeadingLevel, palette: theme::Palette) -> Style {
let color = match level {
HeadingLevel::H1 => palette.accent_text,
HeadingLevel::H2 => palette.accent,
HeadingLevel::H3 => palette.text,
_ => palette.muted,
};
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
fn strip_html_tags(html: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
result
}
fn code_block_label(kind: CodeBlockKind<'_>) -> String {
match kind {
CodeBlockKind::Indented => "text".to_string(),
CodeBlockKind::Fenced(language) => {
let trimmed = language.trim();
if trimmed.is_empty() {
"text".to_string()
} else {
trimmed.to_string()
}
}
}
}
fn word_wrap_spans(
spans: Vec<Span<'static>>,
first_width: usize,
rest_width: usize,
) -> Vec<Vec<Span<'static>>> {
let chars: Vec<(char, Style)> = spans
.iter()
.flat_map(|s| {
let style = s.style;
s.content
.chars()
.map(move |c| (c, style))
.collect::<Vec<_>>()
})
.collect();
if chars.is_empty() {
return vec![vec![]];
}
let total = chars.len();
let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
let mut start = 0;
while start < total {
let max_w = if lines.is_empty() {
first_width
} else {
rest_width
};
let end = (start + max_w).min(total);
if end >= total {
lines.push(chars_to_spans(&chars[start..]));
break;
}
let break_pos = chars[start..end]
.iter()
.enumerate()
.rev()
.find(|(_, (c, _))| *c == ' ')
.map(|(i, _)| start + i);
start = if let Some(bp) = break_pos {
lines.push(chars_to_spans(&chars[start..bp]));
let mut next = bp + 1;
while next < total && chars[next].0 == ' ' {
next += 1;
}
next
} else {
lines.push(chars_to_spans(&chars[start..end]));
end
};
}
lines
}
fn spans_visual_width(spans: &[Span<'static>]) -> usize {
spans.iter().map(|span| span.content.chars().count()).sum()
}
fn pad_spans_to_width(mut spans: Vec<Span<'static>>, width: usize) -> Vec<Span<'static>> {
let padding = width.saturating_sub(spans_visual_width(&spans));
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
spans
}
fn fit_column_widths_to_total(mut widths: Vec<usize>, total: usize) -> Vec<usize> {
if widths.is_empty() {
return widths;
}
for width in &mut widths {
*width = (*width).max(1);
}
let min_total = widths.len();
let target = total.max(min_total);
let mut current_total: usize = widths.iter().sum();
while current_total > target {
let Some((_, width)) = widths
.iter_mut()
.enumerate()
.max_by_key(|(_, width)| **width)
else {
break;
};
if *width <= 1 {
break;
}
*width -= 1;
current_total -= 1;
}
widths
}
fn chars_to_spans(chars: &[(char, Style)]) -> Vec<Span<'static>> {
if chars.is_empty() {
return vec![];
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut text = String::new();
let mut style = chars[0].1;
for &(c, s) in chars {
if s == style {
text.push(c);
} else {
spans.push(Span::styled(std::mem::take(&mut text), style));
style = s;
text.push(c);
}
}
if !text.is_empty() {
spans.push(Span::styled(text, style));
}
spans
}