mod code_block;
mod inline;
mod line;
mod table;
mod visual_line;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use super::theme::{EditorTheme, HighlightFn};
use super::wrap_engine::VisualLine;
use super::{search::SearchState, text_buffer::TextBuffer};
use crate::util::text::{char_width, display_width};
use code_block::CodeBlockCache;
pub struct MarkdownRenderer {
theme: EditorTheme,
horizontal_scroll: usize,
code_block_cache: CodeBlockCache,
highlight_fn: HighlightFn,
show_line_numbers: bool,
}
impl MarkdownRenderer {
pub fn new(theme: EditorTheme, highlight_fn: HighlightFn) -> Self {
Self {
theme,
horizontal_scroll: 0,
code_block_cache: CodeBlockCache::new(),
highlight_fn,
show_line_numbers: true,
}
}
pub fn invalidate_cache(&mut self) {
self.code_block_cache.invalidate();
}
pub fn set_theme(&mut self, theme: EditorTheme) {
self.theme = theme;
self.invalidate_cache();
}
pub fn set_show_line_numbers(&mut self, show: bool) {
self.show_line_numbers = show;
}
pub fn is_show_line_numbers(&self) -> bool {
self.show_line_numbers
}
fn format_line_number(&self, line_idx: usize) -> String {
if self.show_line_numbers {
format!("{:4} ", line_idx + 1)
} else {
String::new()
}
}
fn format_continuation_line_number(&self) -> String {
if self.show_line_numbers {
" ".to_string()
} else {
String::new()
}
}
pub fn ensure_cache_valid(&mut self, lines: &[String]) {
if !self.code_block_cache.valid || self.code_block_cache.line_count != lines.len() {
self.code_block_cache.build(lines);
}
}
#[inline]
pub fn style(&self, fg: Color) -> Style {
Style::default().fg(fg).bg(self.theme.bg_primary)
}
#[inline]
pub fn style_input(&self, fg: Color) -> Style {
Style::default().fg(fg).bg(self.theme.bg_input)
}
#[inline]
pub fn style_bold(&self, fg: Color) -> Style {
Style::default()
.fg(fg)
.bg(self.theme.bg_primary)
.add_modifier(Modifier::BOLD)
}
#[inline]
pub fn style_code(&self, fg: Color) -> Style {
Style::default().fg(fg).bg(self.theme.code_bg)
}
#[allow(clippy::too_many_arguments)]
pub fn render_visual_line(
&self,
vl: &VisualLine,
is_cursor_line: bool,
cursor_col: Option<usize>,
search: &SearchState,
buffer: &TextBuffer,
wrap_width: usize,
) -> Vec<Line<'static>> {
let lines = buffer.lines();
let logical_line = vl.logical_line;
let Some(raw_line_content) = lines.get(logical_line) else {
return vec![Line::default()];
};
let line_content = Self::normalize_tabs(raw_line_content);
let vl_text = Self::normalize_tabs(&vl.text);
let is_continuation = vl.start_col > 0;
let line_num_str = if !self.show_line_numbers {
String::new()
} else if is_continuation {
" ".to_string()
} else {
format!("{:>4} ", logical_line + 1)
};
let line_num_style = if is_cursor_line {
Style::default()
.fg(Color::Yellow)
.bg(self.theme.bg_input)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::DarkGray)
.bg(self.theme.bg_primary)
};
let code_block_max_width = if !Self::is_code_fence_line(&line_content)
&& self.is_line_in_complete_code_block(logical_line, lines)
{
self.find_code_block_range(logical_line, lines)
.map(|(start, end)| self.calculate_code_block_max_width(start, end, lines))
} else {
None
};
if is_cursor_line {
let is_last_vl = vl.end_col >= line_content.chars().count();
return vec![self.render_cursor_visual_line(
vl_text,
vl,
&visual_line::CursorLineContext {
line_num_str: &line_num_str,
line_num_style,
cursor_col,
search,
code_block_max_width,
is_last_vl,
},
)];
}
if is_continuation {
let text = &vl_text;
let in_code_block = !Self::is_code_fence_line(&line_content)
&& self.is_line_in_complete_code_block(logical_line, lines);
if in_code_block {
let mut spans = vec![
Span::styled(
line_num_str,
Style::default()
.fg(Color::DarkGray)
.bg(self.theme.bg_primary),
),
Span::styled("│", self.style_code(self.theme.text_dim)),
Span::styled(" ", Style::default().bg(self.theme.code_bg)),
];
spans.push(Span::styled(
text.clone(),
Style::default()
.fg(self.theme.text_normal)
.bg(self.theme.code_bg),
));
let max_width = self
.find_code_block_range(logical_line, lines)
.map(|(start, end)| self.calculate_code_block_max_width(start, end, lines))
.unwrap_or(0);
let content_width = display_width(text);
let fill_width = max_width.saturating_sub(content_width);
spans.push(Span::styled(
" ".repeat(fill_width),
Style::default().bg(self.theme.code_bg),
));
spans.push(Span::styled(" ", Style::default().bg(self.theme.code_bg)));
spans.push(Span::styled("│", self.style_code(self.theme.text_dim)));
return vec![Line::from(spans)];
}
if Self::is_table_row(&line_content) {
let mut spans = vec![Span::styled(line_num_str, line_num_style)];
spans.push(Span::styled(
text.clone(),
self.style(self.theme.text_normal),
));
return vec![Line::from(spans)];
}
let trimmed = line_content.trim_start();
if trimmed.starts_with('>') {
let mut level = 0;
let mut rest = trimmed;
while rest.starts_with('>') {
level += 1;
rest = rest[1..].trim_start();
}
let _ = rest;
let bar: String = (0..level).map(|_| "▎").collect::<Vec<_>>().join("");
let bar_style = Style::default()
.fg(self.theme.md_blockquote_bar)
.bg(self.theme.md_blockquote_bg)
.add_modifier(Modifier::BOLD);
let text_style = Style::default()
.fg(self.theme.md_blockquote_text)
.bg(self.theme.md_blockquote_bg);
let mut spans = vec![Span::styled(line_num_str, line_num_style)];
spans.push(Span::styled(format!("{} ", bar), bar_style));
spans.push(Span::styled(text.clone(), text_style));
return vec![Line::from(spans)];
}
let mut spans = vec![Span::styled(line_num_str, line_num_style)];
if search.is_searching() && search.match_count() > 0 {
spans.extend(search.highlight_line(logical_line, text, &self.theme, vl.start_col));
} else {
spans.push(Span::styled(
text.clone(),
self.style(self.theme.text_normal),
));
}
return vec![Line::from(spans)];
}
let truncated = Self::truncate_to_display_width(&line_content, wrap_width);
if Self::is_code_fence_line(&line_content) {
if self.is_fence_line_paired(logical_line, lines) {
return vec![self.render_code_fence_line(&line_content, logical_line, lines)];
}
let mut spans = vec![Span::styled(line_num_str, line_num_style)];
if search.is_searching() && search.match_count() > 0 {
spans.extend(search.highlight_line(logical_line, &truncated, &self.theme, 0));
} else {
spans.push(Span::styled(truncated, self.style(self.theme.text_normal)));
}
return vec![Line::from(spans)];
}
if self.is_line_in_complete_code_block(logical_line, lines) {
return vec![self.render_code_block_line(&line_content, logical_line, lines)];
}
if let Some(table_ctx) = self.find_table_context(logical_line, lines) {
return self.render_table_rows(
&line_content,
logical_line,
&table_ctx,
lines,
wrap_width,
);
}
if search.is_searching() && search.match_count() > 0 {
let mut spans = vec![Span::styled(line_num_str, line_num_style)];
spans.extend(search.highlight_line(logical_line, &truncated, &self.theme, 0));
vec![Line::from(spans)]
} else {
vec![self.render_single_line_with_number(&truncated, logical_line, wrap_width)]
}
}
fn normalize_tabs(text: &str) -> String {
text.replace('\t', " ")
}
fn truncate_to_display_width(text: &str, max_width: usize) -> String {
let mut result = String::new();
let mut width = 0;
for ch in text.chars() {
let ch_width = char_width(ch);
if width + ch_width > max_width {
break;
}
result.push(ch);
width += ch_width;
}
result
}
pub(super) fn overlay_cursor_on_spans(
spans: Vec<Span<'static>>,
cursor_char_idx: usize,
cursor_style: Style,
) -> Vec<Span<'static>> {
let mut result = Vec::with_capacity(spans.len() + 2);
let mut chars_seen = 0;
let mut placed = false;
for span in spans {
if placed {
result.push(span);
continue;
}
let span_chars: Vec<char> = span.content.chars().collect();
let span_len = span_chars.len();
let span_end = chars_seen + span_len;
if cursor_char_idx >= span_end {
result.push(span);
chars_seen = span_end;
continue;
}
let local = cursor_char_idx - chars_seen;
if local > 0 {
let before: String = span_chars[..local].iter().collect();
result.push(Span::styled(before, span.style));
}
if local < span_len {
result.push(Span::styled(span_chars[local].to_string(), cursor_style));
if local + 1 < span_len {
let after: String = span_chars[local + 1..].iter().collect();
result.push(Span::styled(after, span.style));
}
}
placed = true;
chars_seen = span_end;
}
if !placed {
result.push(Span::styled(" ", cursor_style));
}
result
}
}