use ratatui::{
style::Style,
text::{Line, Span},
};
mod code_fence;
mod heading;
mod inline;
mod math;
mod mermaid;
mod panel;
mod stream;
mod table;
mod txm;
#[cfg(test)]
pub(crate) use mermaid::PHASE_CHAIN_FLOWCHART;
pub(in crate::tui) use code_fence::{
is_closing_fence, parse_opening_fence, update_code_block_state, CodeFenceState,
};
use code_fence::{mermaid_opening_fence, CodeFence};
use super::markdown_image::standalone_markdown_image;
use inline::{inline_markdown_stable_prefix_len, markdown_inline_segments, markdown_inline_text};
use panel::ClosedPanel;
pub(in crate::tui) use heading::HeadingLevel;
use heading::{heading_stream_state, parse_atx_heading, HeadingStreamState};
pub(super) use stream::{incremental_markdown_tail_start, markdown_stream_bounds};
#[cfg(test)]
#[path = "markdown/table_tests.rs"]
mod table_tests;
use super::{
render::{
char_display_width, display_width, slice_spans_by_bytes, soft_wrap_visible_ranges,
wrap_line_at_whitespace_ranges_with_protected_prefix, wrap_line_hard,
},
theme::Theme,
};
pub(super) fn push_wrapped_markdown_without_copy_button_from_fence_state(
lines: &mut Vec<Line<'static>>,
text: &str,
width: usize,
state: &mut CodeFenceState,
) {
lines.extend(
render_markdown_from_fence_state(text, width, state, CodeBlockCopyButton::Hidden).lines,
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CodeBlockCopyButton {
Visible,
Hidden,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct MarkdownCodeBlock {
pub(super) top_line: usize,
pub(super) copy_columns: std::ops::Range<usize>,
pub(super) text: String,
}
pub(super) struct RenderedMarkdown {
pub(super) lines: Vec<Line<'static>>,
pub(super) code_blocks: Vec<MarkdownCodeBlock>,
pub(super) image_sources: Vec<super::markdown_image::MarkdownImageSource>,
pub(super) image_rows: Vec<usize>,
}
pub(super) fn markdown_lines(
text: &str,
width: usize,
in_code_block: &mut bool,
) -> Vec<Line<'static>> {
render_markdown(text, width, in_code_block).lines
}
pub(super) fn render_markdown(
text: &str,
width: usize,
in_code_block: &mut bool,
) -> RenderedMarkdown {
render_markdown_with_copy_button(text, width, in_code_block, CodeBlockCopyButton::Visible)
}
fn render_markdown_with_copy_button(
text: &str,
width: usize,
in_code_block: &mut bool,
copy_button: CodeBlockCopyButton,
) -> RenderedMarkdown {
let mut state = CodeFenceState::from_open_flag(*in_code_block);
let rendered = render_markdown_from_fence_state(text, width, &mut state, copy_button);
*in_code_block = state.is_open();
rendered
}
fn render_markdown_from_fence_state(
text: &str,
width: usize,
state: &mut CodeFenceState,
copy_button: CodeBlockCopyButton,
) -> RenderedMarkdown {
let width = width.max(1);
let mut lines = Vec::new();
let mut code_blocks = Vec::new();
let mut image_sources = Vec::new();
let mut image_rows = Vec::new();
let mut active_code_block: Option<(usize, std::ops::Range<usize>, Vec<&str>)> = None;
let raw_lines = text.lines().collect::<Vec<_>>();
let mut line_index = 0;
let mut active_fence = state.active;
while line_index < raw_lines.len() {
let raw_line = raw_lines[line_index];
if active_fence.is_none() {
if let Some(opening) = mermaid_opening_fence(raw_line) {
if let Some(closing_offset) = raw_lines[line_index + 1..]
.iter()
.position(|line| is_closing_fence(line, opening.fence))
{
let closing_index = line_index + 1 + closing_offset;
let source = raw_lines[line_index + 1..closing_index].join("\n");
let panel = mermaid::render_closed_fence(source, width.saturating_sub(4));
push_closed_panel(&mut lines, &mut code_blocks, copy_button, width, panel);
line_index = closing_index + 1;
continue;
}
}
if let Some((source, consumed_lines)) =
math::take_closed_display_math(&raw_lines[line_index..])
{
let panel = math::render_closed_display_math(source, width.saturating_sub(4));
push_closed_panel(&mut lines, &mut code_blocks, copy_button, width, panel);
line_index += consumed_lines;
continue;
}
}
let opening_fence = (active_fence.is_none())
.then(|| parse_opening_fence(raw_line))
.flatten();
let closing_fence = active_fence.is_some_and(|fence| is_closing_fence(raw_line, fence));
if opening_fence.is_some() || closing_fence {
if closing_fence {
lines.push(code_block_border(width, '╰', copy_button, None));
if let Some((top_line, copy_columns, content)) = active_code_block.take() {
code_blocks.push(MarkdownCodeBlock {
top_line,
copy_columns,
text: content.join("\n"),
});
}
active_fence = None;
} else {
active_fence = opening_fence;
let top_line = lines.len();
lines.push(code_block_border(width, '╭', copy_button, None));
if copy_button == CodeBlockCopyButton::Visible {
if let Some(copy_columns) = code_block_copy_columns(width) {
active_code_block = Some((top_line, copy_columns, Vec::new()));
}
}
}
state.active = active_fence;
line_index += 1;
continue;
}
if active_fence.is_some() {
if let Some((_, _, content)) = &mut active_code_block {
content.push(raw_line);
}
lines.extend(code_block_content_lines(raw_line, width));
line_index += 1;
continue;
}
if let Some((table_lines, consumed_lines)) =
table::markdown_table_lines(&raw_lines[line_index..], width)
{
lines.extend(table_lines);
line_index += consumed_lines;
continue;
}
if let Some(heading) = parse_atx_heading(raw_line) {
lines.extend(markdown_heading_lines(heading, width));
line_index += 1;
continue;
}
if is_markdown_divider(raw_line) {
lines.push(markdown_divider(width));
line_index += 1;
continue;
}
if let Some(image) = standalone_markdown_image(raw_line) {
image_rows.push(lines.len());
let fallback = if image.alt.is_empty() {
format!("[image: {}]", image.path)
} else {
format!("[image: {}]", image.alt)
};
lines.push(Line::styled(fallback, Theme::markdown_link()));
image_sources.push(image);
line_index += 1;
continue;
}
lines.extend(wrap_styled_segments(
&markdown_inline_segments(raw_line),
width,
));
line_index += 1;
}
if let Some((top_line, copy_columns, content)) = active_code_block {
code_blocks.push(MarkdownCodeBlock {
top_line,
copy_columns,
text: content.join("\n"),
});
}
if lines.is_empty() && text.is_empty() {
lines.push(Line::from(Span::styled(String::new(), Theme::text())));
}
state.active = active_fence;
RenderedMarkdown {
lines,
code_blocks,
image_sources,
image_rows,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StyledSegment {
text: String,
style: Style,
}
impl StyledSegment {
fn new(text: String, style: Style) -> Self {
Self { text, style }
}
}
fn is_markdown_divider(line: &str) -> bool {
let trimmed = line.trim();
let mut chars = trimmed.chars().filter(|ch| !ch.is_whitespace());
let Some(marker) = chars.next() else {
return false;
};
matches!(marker, '-' | '*' | '_')
&& trimmed.chars().filter(|ch| !ch.is_whitespace()).count() >= 3
&& chars.all(|ch| ch == marker)
}
fn markdown_divider(width: usize) -> Line<'static> {
Line::from(Span::styled("─".repeat(width.max(1)), Theme::dim()))
}
fn push_closed_panel(
lines: &mut Vec<Line<'static>>,
code_blocks: &mut Vec<MarkdownCodeBlock>,
copy_button: CodeBlockCopyButton,
width: usize,
panel: ClosedPanel,
) {
let top_line = lines.len();
let (title, body, source) = match panel {
ClosedPanel::Art {
title,
lines: art,
source,
} => (title, panel::panel_lines(art, width), source),
ClosedPanel::SourceFallback { title, source } => {
let mut body = Vec::new();
for content_line in source.lines() {
body.extend(code_block_content_lines(content_line, width));
}
if body.is_empty() {
body.extend(code_block_content_lines("", width));
}
(title, body, source)
}
};
lines.push(code_block_border(width, '╭', copy_button, Some(title)));
lines.extend(body);
lines.push(code_block_border(width, '╰', copy_button, None));
push_copyable_code_block(code_blocks, copy_button, top_line, width, source);
}
fn push_copyable_code_block(
code_blocks: &mut Vec<MarkdownCodeBlock>,
copy_button: CodeBlockCopyButton,
top_line: usize,
width: usize,
text: String,
) {
if copy_button != CodeBlockCopyButton::Visible {
return;
}
if let Some(copy_columns) = code_block_copy_columns(width) {
code_blocks.push(MarkdownCodeBlock {
top_line,
copy_columns,
text,
});
}
}
fn code_block_border(
width: usize,
corner: char,
copy_button: CodeBlockCopyButton,
title: Option<&str>,
) -> Line<'static> {
let width = width.max(1);
let style = Theme::markdown_code_block();
if width == 1 {
return Line::from(Span::styled(corner.to_string(), style));
}
let closing_corner = if corner == '╭' { '╮' } else { '╯' };
let copy_columns = (corner == '╭' && copy_button == CodeBlockCopyButton::Visible)
.then(|| code_block_copy_columns(width))
.flatten();
let label = copy_columns
.as_ref()
.and_then(|_| code_block_copy_label(width));
let prefix_width = copy_columns
.as_ref()
.map_or(width.saturating_sub(2), |columns| {
columns.start.saturating_sub(1)
});
let title = title
.map(|title| format!("─ {title} "))
.filter(|title| display_width(title) <= prefix_width)
.unwrap_or_default();
let title_width = display_width(&title);
let mut spans = vec![Span::styled(
format!(
"{corner}{title}{}",
"─".repeat(prefix_width.saturating_sub(title_width))
),
style,
)];
if let Some(label) = label {
spans.push(Span::styled(
label,
Theme::markdown_code_copy_button( false),
));
}
spans.push(Span::styled(closing_corner.to_string(), style));
Line::from(spans)
}
fn code_block_copy_label(width: usize) -> Option<&'static str> {
if width >= 9 {
Some(" COPY ")
} else if width >= 6 {
Some("COPY")
} else {
None
}
}
fn code_block_copy_columns(width: usize) -> Option<std::ops::Range<usize>> {
let label_width = display_width(code_block_copy_label(width)?);
let start = width.saturating_sub(label_width + 1);
Some(start..start + label_width)
}
fn code_block_content_lines(line: &str, width: usize) -> Vec<Line<'static>> {
let style = Theme::markdown_code_block();
if width <= 1 {
return wrap_line_hard(line, 1)
.into_iter()
.map(|chunk| Line::from(Span::styled(chunk, style)))
.collect();
}
if width <= 3 {
return wrap_line_hard(line, width.saturating_sub(1).max(1))
.into_iter()
.map(|chunk| Line::from(Span::styled(format!("│{chunk}"), style)))
.collect();
}
let content_width = width - 4;
wrap_line_hard(line, content_width.max(1))
.into_iter()
.map(|chunk| {
let chunk_width = display_width(&chunk);
let padding = " ".repeat(content_width.saturating_sub(chunk_width));
Line::from(Span::styled(format!("│ {chunk}{padding} │"), style))
})
.collect()
}
fn markdown_heading_lines(heading: heading::AtxHeading<'_>, width: usize) -> Vec<Line<'static>> {
let heading_style = Theme::markdown_heading(heading.level);
if heading.content.is_empty() {
return vec![Line::from(Span::styled(String::new(), heading_style))];
}
let segments = markdown_inline_segments(heading.content)
.into_iter()
.map(|segment| StyledSegment::new(segment.text, heading_style.patch(segment.style)))
.collect::<Vec<_>>();
wrap_styled_segments(&segments, width)
}
fn wrap_markdown_line_ranges(line: &str, width: usize) -> Vec<std::ops::Range<usize>> {
let protected_prefix_end = markdown_list_body_start(line).unwrap_or_default();
wrap_line_at_whitespace_ranges_with_protected_prefix(line, width, protected_prefix_end)
}
fn markdown_list_body_start(line: &str) -> Option<usize> {
let trimmed = line.trim_start_matches(char::is_whitespace);
let leading_whitespace_len = line.len() - trimmed.len();
let marker_len = trimmed.find(char::is_whitespace)?;
let marker = &trimmed[..marker_len];
let is_list_marker = matches!(marker, "-" | "+" | "*")
|| marker.strip_suffix(['.', ')']).is_some_and(|digits| {
(1..=9).contains(&digits.len()) && digits.bytes().all(|byte| byte.is_ascii_digit())
});
if !is_list_marker {
return None;
}
let separator_len = trimmed[marker_len..]
.chars()
.take_while(|ch| ch.is_whitespace())
.map(char::len_utf8)
.sum::<usize>();
let body_start = leading_whitespace_len + marker_len + separator_len;
(body_start < line.len()).then_some(body_start)
}
fn wrap_styled_segments(segments: &[StyledSegment], width: usize) -> Vec<Line<'static>> {
let text = segments
.iter()
.map(|segment| segment.text.as_str())
.collect::<String>();
let spans = segments
.iter()
.map(|segment| Span::styled(segment.text.clone(), segment.style))
.collect::<Vec<_>>();
let lines = soft_wrap_visible_ranges(&text, wrap_markdown_line_ranges(&text, width))
.map(|range| {
let chunk = slice_spans_by_bytes(&spans, range.start, range.end);
if chunk.is_empty() {
Line::from(Span::styled(
String::new(),
Style::default().remove_modifier(ratatui::style::Modifier::UNDERLINED),
))
} else {
Line::from(chunk)
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
vec![Line::from(Span::styled(
String::new(),
Style::default().remove_modifier(ratatui::style::Modifier::UNDERLINED),
))]
} else {
lines
}
}
#[cfg(test)]
#[path = "markdown_tests.rs"]
mod tests;