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, wrap_text};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TableAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum BorderKind {
Top,
Middle,
Bottom,
}
#[derive(Debug, Clone)]
pub struct TableContext {
pub start_idx: usize,
pub end_idx: usize,
pub col_widths: Vec<usize>,
pub alignments: Vec<TableAlign>,
}
#[derive(Debug, Clone, Default)]
struct CodeBlockCache {
line_to_block: Vec<Option<(usize, usize)>>,
block_languages: Vec<(usize, usize, String)>, valid: bool,
line_count: usize,
}
impl CodeBlockCache {
fn new() -> Self {
Self::default()
}
fn invalidate(&mut self) {
self.valid = false;
}
fn build(&mut self, lines: &[String]) {
self.line_to_block.clear();
self.block_languages.clear();
self.line_to_block.resize(lines.len(), None);
let mut in_block = false;
let mut block_start = 0;
let mut current_lang = String::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if let Some(stripped) = trimmed.strip_prefix("```") {
if !in_block {
in_block = true;
block_start = i;
current_lang = stripped.trim().to_string();
} else {
self.block_languages
.push((block_start, i, current_lang.clone()));
for j in block_start..=i {
if j < self.line_to_block.len() {
self.line_to_block[j] = Some((block_start, i));
}
}
in_block = false;
}
}
}
self.line_count = lines.len();
self.valid = true;
}
fn get_block_range(&self, line_idx: usize) -> Option<(usize, usize)> {
if line_idx < self.line_to_block.len() {
self.line_to_block[line_idx]
} else {
None
}
}
fn get_language(&self, line_idx: usize) -> Option<&str> {
if let Some((start, end)) = self.get_block_range(line_idx) {
for (s, e, lang) in &self.block_languages {
if *s == start && *e == end {
return Some(lang);
}
}
}
None
}
}
pub struct MarkdownRenderer {
theme: EditorTheme,
horizontal_scroll: usize,
code_block_cache: CodeBlockCache,
highlight_fn: HighlightFn,
}
impl MarkdownRenderer {
pub fn new(theme: EditorTheme, highlight_fn: HighlightFn) -> Self {
Self {
theme,
horizontal_scroll: 0,
code_block_cache: CodeBlockCache::new(),
highlight_fn,
}
}
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 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 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_input)
};
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,
&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.code_bg),
),
Span::styled("│", self.style_code(self.theme.text_dim)),
];
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("│", 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,
);
}
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
}
#[allow(clippy::too_many_arguments)]
fn render_cursor_visual_line(
&self,
text: String,
vl: &VisualLine,
line_num_str: &str,
line_num_style: Style,
cursor_col: Option<usize>,
search: &SearchState,
code_block_max_width: Option<usize>,
is_last_vl: bool,
) -> Line<'static> {
let in_code_block = code_block_max_width.is_some();
let (text_style, line_num_bg) = if in_code_block {
(
Style::default()
.fg(self.theme.text_normal)
.bg(self.theme.code_bg),
self.theme.code_bg,
)
} else {
(
self.style_input(self.theme.text_normal),
self.theme.bg_input,
)
};
let effective_line_num_style = line_num_style.bg(line_num_bg);
let mut spans = vec![Span::styled(
line_num_str.to_string(),
effective_line_num_style,
)];
if in_code_block {
spans.push(Span::styled("│", self.style_code(self.theme.text_dim)));
}
let text_display_width = display_width(&text);
if search.is_searching() && search.match_count() > 0 {
spans.extend(search.highlight_line(vl.logical_line, &text, &self.theme, vl.start_col));
return Line::from(spans).patch_style(Style::default().bg(line_num_bg));
}
if let Some(col) = cursor_col {
let cursor_in_this_vl = if col == vl.end_col {
is_last_vl
} else {
col >= vl.start_col && col < vl.end_col
};
if cursor_in_this_vl {
let chars: Vec<char> = text.chars().collect();
let char_idx_at_cursor = col.saturating_sub(vl.start_col);
if char_idx_at_cursor > 0 {
let before: String = chars.iter().take(char_idx_at_cursor).collect();
spans.push(Span::styled(before, text_style));
}
let cursor_style = Style::default()
.fg(self.theme.cursor_fg)
.bg(self.theme.cursor_bg)
.add_modifier(Modifier::BOLD);
if char_idx_at_cursor < chars.len() {
spans.push(Span::styled(
chars[char_idx_at_cursor].to_string(),
cursor_style,
));
if char_idx_at_cursor + 1 < chars.len() {
let after: String = chars.iter().skip(char_idx_at_cursor + 1).collect();
spans.push(Span::styled(after, text_style));
}
} else {
spans.push(Span::styled(" ", cursor_style));
}
} else {
spans.push(Span::styled(text, text_style));
}
} else {
spans.push(Span::styled(text, text_style));
}
if let Some(max_width) = code_block_max_width {
let fill_width = max_width.saturating_sub(text_display_width);
spans.push(Span::styled(
" ".repeat(fill_width),
Style::default().bg(self.theme.code_bg),
));
spans.push(Span::styled("│", self.style_code(self.theme.text_dim)));
}
Line::from(spans)
}
pub fn is_code_fence_line(line: &str) -> bool {
line.trim_start().starts_with("```")
}
pub fn is_fence_line_paired(&self, fence_line: usize, _lines: &[String]) -> bool {
self.code_block_cache.get_block_range(fence_line).is_some()
}
fn is_line_in_complete_code_block(&self, line_idx: usize, _lines: &[String]) -> bool {
if let Some((start, end)) = self.code_block_cache.get_block_range(line_idx) {
line_idx > start && line_idx < end
} else {
false
}
}
fn get_code_block_language(&self, line_idx: usize, _lines: &[String]) -> Option<String> {
self.code_block_cache
.get_language(line_idx)
.map(|s| s.to_string())
}
fn find_code_block_range(&self, line_idx: usize, _lines: &[String]) -> Option<(usize, usize)> {
self.code_block_cache.get_block_range(line_idx)
}
fn find_code_block_range_for_fence(
&self,
fence_line: usize,
_lines: &[String],
) -> Option<(usize, usize)> {
self.code_block_cache.get_block_range(fence_line)
}
fn calculate_code_block_max_width(
&self,
start_idx: usize,
end_idx: usize,
lines: &[String],
) -> usize {
let mut max_width = 0;
for i in (start_idx + 1)..end_idx {
if let Some(line) = lines.get(i) {
if self.horizontal_scroll == 0 {
max_width = max_width.max(display_width(line));
} else {
let visible: String = line.chars().skip(self.horizontal_scroll).collect();
max_width = max_width.max(display_width(&visible));
}
}
}
max_width.max(10)
}
fn render_code_fence_line(
&self,
line: &str,
line_idx: usize,
lines: &[String],
) -> Line<'static> {
let line_num = format!("{:4} ", line_idx + 1);
let trimmed = line.trim_start();
let is_start = self
.code_block_cache
.get_block_range(line_idx)
.is_some_and(|(start, _)| start == line_idx);
let content_max_width = self
.find_code_block_range_for_fence(line_idx, lines)
.map(|(start, end)| self.calculate_code_block_max_width(start, end, lines))
.unwrap_or(10);
let total_width = content_max_width + 2;
if is_start {
let lang = trimmed[3..].trim();
let (left_part, left_width) = if lang.is_empty() {
("┌─".to_string(), 2)
} else {
let s = format!("┌─ {} ─", lang);
let w = display_width(&s);
(s, w)
};
let dash_count = total_width.saturating_sub(left_width + 1).max(1);
Line::from(vec![
Span::styled(line_num, Style::default().fg(Color::DarkGray)),
Span::styled(left_part, self.style_code(self.theme.text_dim)),
Span::styled("─".repeat(dash_count), self.style_code(self.theme.text_dim)),
Span::styled("┐", self.style_code(self.theme.text_dim)),
])
} else {
let dash_count = total_width.saturating_sub(2).max(1);
Line::from(vec![
Span::styled(line_num, Style::default().fg(Color::DarkGray)),
Span::styled("└", self.style_code(self.theme.text_dim)),
Span::styled("─".repeat(dash_count), self.style_code(self.theme.text_dim)),
Span::styled("┘", self.style_code(self.theme.text_dim)),
])
}
}
fn render_code_block_line(
&self,
line: &str,
line_idx: usize,
lines: &[String],
) -> Line<'static> {
let line_num = format!("{:4} ", line_idx + 1);
let visible_line: String = line.chars().skip(self.horizontal_scroll).collect();
let lang = self
.get_code_block_language(line_idx, lines)
.unwrap_or_default();
let highlighted_spans = (self.highlight_fn)(&visible_line, &lang, &self.theme);
let content_width = display_width(&visible_line);
let max_width = self
.find_code_block_range(line_idx, lines)
.map(|(start, end)| self.calculate_code_block_max_width(start, end, lines))
.unwrap_or(content_width);
let fill_width = max_width.saturating_sub(content_width);
let mut spans = vec![
Span::styled(
line_num,
Style::default().fg(Color::DarkGray).bg(self.theme.code_bg),
),
Span::styled("│", self.style_code(self.theme.text_dim)),
];
for span in highlighted_spans {
spans.push(Span::styled(
span.content,
span.style.bg(self.theme.code_bg),
));
}
spans.push(Span::styled(
" ".repeat(fill_width),
Style::default().bg(self.theme.code_bg),
));
spans.push(Span::styled("│", self.style_code(self.theme.text_dim)));
Line::from(spans)
}
fn calculate_table_render_width_from_col_widths(col_widths: &[usize]) -> usize {
let cols_width: usize = col_widths.iter().map(|cw| cw + 2 + 1).sum();
cols_width + 1 }
fn shrink_col_widths(col_widths: &[usize], wrap_width: usize) -> Vec<usize> {
let current = Self::calculate_table_render_width_from_col_widths(col_widths);
if current <= wrap_width {
return col_widths.to_vec();
}
let excess = current.saturating_sub(wrap_width);
let total_col_width: usize = col_widths.iter().sum();
if total_col_width == 0 {
return col_widths.to_vec();
}
let mut remaining_excess = excess;
let mut result = Vec::with_capacity(col_widths.len());
for (i, &cw) in col_widths.iter().enumerate() {
let is_last = i == col_widths.len() - 1;
let shrink = if is_last {
remaining_excess
} else {
let s = (excess * cw) / total_col_width;
remaining_excess = remaining_excess.saturating_sub(s);
s
};
result.push(cw.saturating_sub(shrink).max(1));
}
result
}
pub fn is_table_separator_line(line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
return false;
}
let inner = trimmed.trim_matches('|');
inner.split('|').all(|cell| {
let cell = cell.trim();
cell.chars().all(|c| c == '-' || c == ':' || c == ' ')
})
}
pub fn is_table_row(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.contains('|')
}
fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
let trimmed = line.trim();
let inner = trimmed.trim_matches('|');
inner
.split('|')
.map(|cell| {
let cell = cell.trim();
let left = cell.starts_with(':');
let right = cell.ends_with(':');
if left && right {
TableAlign::Center
} else if right {
TableAlign::Right
} else {
TableAlign::Left
}
})
.collect()
}
fn parse_table_cells(line: &str) -> Vec<String> {
let trimmed = line.trim();
let inner = trimmed.trim_matches('|');
inner.split('|').map(|s| s.trim().to_string()).collect()
}
fn find_table_context(&self, line_idx: usize, lines: &[String]) -> Option<TableContext> {
let line = lines.get(line_idx)?;
if !Self::is_table_row(line) {
return None;
}
let mut start_idx = line_idx;
while start_idx > 0 {
if let Some(prev) = lines.get(start_idx - 1) {
if Self::is_table_row(prev) {
start_idx -= 1;
} else {
break;
}
} else {
break;
}
}
let mut end_idx = line_idx;
while end_idx < lines.len() - 1 {
if let Some(next) = lines.get(end_idx + 1) {
if Self::is_table_row(next) {
end_idx += 1;
} else {
break;
}
} else {
break;
}
}
if end_idx - start_idx < 1 {
return None;
}
let alignments = if let Some(sep_line) = lines.get(start_idx + 1) {
Self::parse_table_alignments(sep_line)
} else {
return None;
};
let num_cols = alignments.len();
let mut col_widths = vec![1; num_cols];
for row_idx in start_idx..=end_idx {
let row_line = lines.get(row_idx)?;
let cells = Self::parse_table_cells(row_line);
for (i, cell) in cells.iter().enumerate() {
if i < num_cols {
col_widths[i] = col_widths[i].max(display_width(cell));
}
}
}
Some(TableContext {
start_idx,
end_idx,
col_widths,
alignments,
})
}
fn render_table_rows(
&self,
line: &str,
line_idx: usize,
ctx: &TableContext,
_lines: &[String],
wrap_width: usize,
) -> Vec<Line<'static>> {
let line_num = format!("{:4} ", line_idx + 1);
let line_num_width = display_width(&line_num);
let available_width = wrap_width.saturating_sub(line_num_width);
let col_widths = Self::shrink_col_widths(&ctx.col_widths, available_width);
let border_style = Style::default().fg(self.theme.text_dim);
if Self::is_table_separator_line(line) {
return vec![];
}
let is_header = line_idx == ctx.start_idx;
let is_last_data_row = line_idx == ctx.end_idx;
let content_style = if is_header {
Style::default()
.fg(self.theme.text_bold)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.text_normal)
};
let cells = Self::parse_table_cells(line);
let wrapped_cells: Vec<Vec<String>> = col_widths
.iter()
.enumerate()
.map(|(i, cw)| {
let cell_text = cells.get(i).map(|s| s.as_str()).unwrap_or("");
wrap_text(cell_text, *cw)
})
.collect();
let max_rows = wrapped_cells.iter().map(|r| r.len()).max().unwrap_or(1);
let mut result = Vec::new();
if is_header {
result.push(Self::render_table_border(
&line_num,
&col_widths,
border_style,
BorderKind::Top,
));
}
for sub_row in 0..max_rows {
let num_str = if sub_row == 0 {
line_num.clone()
} else {
" ".to_string()
};
let mut spans = vec![Span::styled(num_str, Style::default().fg(Color::DarkGray))];
spans.push(Span::styled("│", border_style));
for (i, cw) in col_widths.iter().enumerate() {
let cell_line = wrapped_cells
.get(i)
.and_then(|lines| lines.get(sub_row))
.map(|s| s.as_str())
.unwrap_or("");
let cell_width = display_width(cell_line);
let fill = cw.saturating_sub(cell_width);
let align = ctx.alignments.get(i).copied().unwrap_or(TableAlign::Left);
let formatted = match align {
TableAlign::Center => {
let left = fill / 2;
let right = fill - left;
format!(" {}{}{} ", " ".repeat(left), cell_line, " ".repeat(right))
}
TableAlign::Right => {
format!(" {}{} ", " ".repeat(fill), cell_line)
}
TableAlign::Left => {
format!(" {}{} ", cell_line, " ".repeat(fill))
}
};
spans.push(Span::styled(formatted, content_style));
spans.push(Span::styled("│", border_style));
}
result.push(Line::from(spans));
}
if is_header {
result.push(Self::render_table_border(
" ",
&col_widths,
border_style,
BorderKind::Middle,
));
}
if !is_header && !is_last_data_row {
result.push(Self::render_table_border(
" ",
&col_widths,
border_style,
BorderKind::Middle,
));
}
if is_last_data_row {
result.push(Self::render_table_border(
" ",
&col_widths,
border_style,
BorderKind::Bottom,
));
}
result
}
fn render_table_border(
line_num: &str,
col_widths: &[usize],
border_style: Style,
kind: BorderKind,
) -> Line<'static> {
let (left, mid, right, fill) = match kind {
BorderKind::Top => ("┌", "┬", "┐", "─"),
BorderKind::Middle => ("├", "┼", "┤", "─"),
BorderKind::Bottom => ("└", "┴", "┘", "─"),
};
let mut spans = vec![Span::styled(
line_num.to_string(),
Style::default().fg(Color::DarkGray),
)];
spans.push(Span::styled(left, border_style));
for (i, cw) in col_widths.iter().enumerate() {
spans.push(Span::styled(fill.repeat(*cw + 2), border_style));
if i < col_widths.len() - 1 {
spans.push(Span::styled(mid, border_style));
}
}
spans.push(Span::styled(right, border_style));
Line::from(spans)
}
fn render_single_line_with_number(
&self,
line: &str,
line_idx: usize,
max_width: usize,
) -> Line<'static> {
let line_num = format!("{:4} ", line_idx + 1);
let visible_line: String = line.chars().skip(self.horizontal_scroll).collect();
let trimmed = visible_line.trim_start();
let indent_chars = visible_line.chars().count() - trimmed.chars().count();
let indent_width: usize = visible_line
.chars()
.take(indent_chars)
.map(char_width)
.sum();
let indent = " ".repeat(indent_width);
if let Some(stripped) = trimmed.strip_prefix("# ") {
let text = stripped.trim();
return Line::from(vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("◆ {}", text), self.style_bold(self.theme.md_h1)),
]);
}
if let Some(stripped) = trimmed.strip_prefix("## ") {
let text = stripped.trim();
return Line::from(vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("◇ {}", text), self.style_bold(self.theme.md_h2)),
]);
}
if let Some(stripped) = trimmed.strip_prefix("### ") {
let text = stripped.trim();
return Line::from(vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("〈 {} 〉", text), self.style_bold(self.theme.md_h3)),
]);
}
if let Some(stripped) = trimmed.strip_prefix("#### ") {
let text = stripped.trim();
return Line::from(vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("› {} ", text), self.style_bold(self.theme.md_h4)),
]);
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
let width = max_width.saturating_sub(indent_width).min(40);
return Line::from(vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled("─".repeat(width), self.style(self.theme.text_dim)),
]);
}
if let Some(stripped) = trimmed.strip_prefix("- [ ]") {
let text = stripped.trim();
let rendered = self.render_inline(text);
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled("○ ", self.style(self.theme.text_dim)),
];
spans.extend(rendered);
return Line::from(spans);
}
if trimmed.starts_with("- [x]") || trimmed.starts_with("- [X]") {
let text = trimmed[5..].trim();
let rendered = self.render_inline(text);
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled("● ", self.style(self.theme.md_list_bullet)),
];
spans.extend(rendered);
return Line::from(spans);
}
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
let text = &trimmed[2..];
let rendered = self.render_inline(text);
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled("• ", self.style(self.theme.text_normal)),
];
spans.extend(rendered);
return Line::from(spans);
}
if let Some(rest) = trimmed.strip_prefix(|c: char| c.is_ascii_digit())
&& let Some(num_end) = rest.find(['.', ')'])
&& (rest.get(num_end..num_end + 2) == Some(". ")
|| rest.get(num_end..num_end + 2) == Some(") "))
{
let num_str = &trimmed[..rest.len() - rest.len() + num_end + 1];
let text = &rest[num_end + 2..];
let rendered = self.render_inline(text);
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("{} ", num_str), self.style(self.theme.text_normal)),
];
spans.extend(rendered);
return Line::from(spans);
}
if trimmed.starts_with('>') {
let mut level = 0;
let mut rest = trimmed;
while rest.starts_with('>') {
level += 1;
rest = rest[1..].trim_start();
}
let text = 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 rendered = self.render_inline(text);
let styled_rendered: Vec<Span<'static>> = rendered
.into_iter()
.map(|span| {
let has_special_bg =
span.style.bg.is_some_and(|bg| bg != self.theme.bg_primary);
if has_special_bg {
span } else {
Span::styled(
span.content,
span.style.fg.map_or(text_style, |fg| {
if fg == self.theme.text_normal {
text_style
} else {
Style::default().fg(fg).bg(self.theme.md_blockquote_bg)
}
}),
)
}
})
.collect();
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
Span::styled(format!("{} ", bar), bar_style),
];
spans.extend(styled_rendered);
return Line::from(spans);
}
let rendered = self.render_inline(trimmed);
let mut spans = vec![
Span::styled(line_num, self.style(Color::DarkGray)),
Span::styled(indent, self.style(self.theme.text_normal)),
];
spans.extend(rendered);
Line::from(spans)
}
fn render_inline(&self, text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
let code_pos = remaining.find('`');
let img_pos = remaining.find("![");
let bold_pos = remaining.find("**");
let strike_pos = remaining.find("~~");
let italic_pos = remaining.find('*');
let link_pos = remaining.find('[');
let min_pos = [
code_pos, img_pos, bold_pos, strike_pos, italic_pos, link_pos,
]
.iter()
.filter_map(|&p| p)
.min();
let Some(pos) = min_pos else {
spans.push(Span::styled(
remaining.to_string(),
self.style(self.theme.text_normal),
));
break;
};
let is_img = img_pos == Some(pos);
let is_code = code_pos == Some(pos) && !is_img;
let is_bold = bold_pos == Some(pos);
let is_strike = strike_pos == Some(pos);
let is_link = link_pos == Some(pos) && !is_img;
let is_italic = italic_pos == Some(pos) && !is_bold && !is_img;
if pos > 0 {
spans.push(Span::styled(
remaining[..pos].to_string(),
self.style(self.theme.text_normal),
));
}
remaining = &remaining[pos..];
if is_code {
remaining = &remaining[1..];
if let Some(end) = remaining.find('`') {
spans.push(Span::styled(
remaining[..end].to_string(),
Style::default()
.fg(self.theme.md_inline_code_fg)
.bg(self.theme.md_inline_code_bg),
));
remaining = &remaining[end + 1..];
} else {
spans.push(Span::styled(
format!("`{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
else if is_img {
remaining = &remaining[2..];
if let Some(alt_end) = remaining.find("](") {
let alt = &remaining[..alt_end];
remaining = &remaining[alt_end + 2..];
if let Some(url_end) = remaining.find(')') {
spans.push(Span::styled(
format!("🖼 {}", alt),
Style::default()
.fg(self.theme.text_dim)
.add_modifier(Modifier::ITALIC),
));
remaining = &remaining[url_end + 1..];
} else {
spans.push(Span::styled(
format!("![{}{}", alt, remaining),
self.style(self.theme.text_normal),
));
break;
}
} else {
spans.push(Span::styled(
format!("![{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
else if is_bold {
remaining = &remaining[2..];
if let Some(end) = remaining.find("**") {
let inner = &remaining[..end];
let inner_spans = self.render_inline(inner);
for span in inner_spans {
spans.push(Span::styled(
span.content,
span.style.add_modifier(Modifier::BOLD),
));
}
remaining = &remaining[end + 2..];
} else {
spans.push(Span::styled(
format!("**{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
else if is_strike {
remaining = &remaining[2..];
if let Some(end) = remaining.find("~~") {
let inner = &remaining[..end];
let inner_spans = self.render_inline(inner);
for span in inner_spans {
spans.push(Span::styled(
span.content,
span.style.add_modifier(Modifier::CROSSED_OUT),
));
}
remaining = &remaining[end + 2..];
} else {
spans.push(Span::styled(
format!("~~{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
else if is_italic {
remaining = &remaining[1..];
if let Some(end) = remaining.find('*') {
let inner = &remaining[..end];
let inner_spans = self.render_inline(inner);
for span in inner_spans {
spans.push(Span::styled(
span.content,
span.style.add_modifier(Modifier::ITALIC),
));
}
remaining = &remaining[end + 1..];
} else {
spans.push(Span::styled(
format!("*{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
else if is_link {
remaining = &remaining[1..];
if let Some(text_end) = remaining.find("](") {
let link_text = &remaining[..text_end];
remaining = &remaining[text_end + 2..];
if let Some(url_end) = remaining.find(')') {
spans.push(Span::styled(
link_text.to_string(),
self.style(self.theme.md_link)
.add_modifier(Modifier::UNDERLINED),
));
spans.push(Span::styled(
" ↗".to_string(),
self.style(self.theme.text_dim),
));
remaining = &remaining[url_end + 1..];
} else {
spans.push(Span::styled(
format!("[{}{}", link_text, remaining),
self.style(self.theme.text_normal),
));
break;
}
} else {
spans.push(Span::styled(
format!("[{}", remaining),
self.style(self.theme.text_normal),
));
break;
}
}
}
spans
}
}