Files
bbs-md/src/renderer.rs
T
ruohki c8d4754340 feat(quick-3): wire remote link navigation with history, error screens, and LightMagenta styling
- Detect HTTP/HTTPS links in follow_selected_link and dispatch to navigate_to_remote()
- Add navigate_to_remote() method with whitelist check, fetch, render, history push, and error screens
- Handle DomainNotAllowed/FetchError/NotMarkdown with BBS-themed error screens (no history push)
- Update navigate_back/forward to re-fetch remote URLs from history (consistent with disk-reload pattern)
- Update handle_resize() to pass None vault_path for remote pages
- Skip live-reload for remote pages in reload_current_document()
- Clear current_url when navigating to local pages in navigate_to()
- Style HTTP/HTTPS links in LightMagenta to visually distinguish from local LightCyan links
2026-03-01 13:15:23 +01:00

996 lines
43 KiB
Rust

//! Markdown-to-styled-lines renderer for bbs-md.
//!
//! Converts a markdown string into `Vec<Line<'static>>` using pulldown-cmark
//! event processing, CGA-themed styling, syntax-highlighted code blocks, and
//! box-drawing table grids.
//!
//! # Public API
//!
//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>)`
use std::path::Path;
use pulldown_cmark::{
Alignment, CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
TextMergeStream,
};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
// ── LinkRecord ─────────────────────────────────────────────────────────────────
/// Metadata for a single link discovered during rendering.
///
/// Produced by `render_markdown` as a parallel structure alongside `Vec<Line>`.
/// Plan 02 consumes this to wire Tab-cycling navigation and link following.
pub struct LinkRecord {
/// Index into `Vec<Line>` where this link appears.
pub line_index: usize,
/// Character column offset within that line where the link bracket `[` starts.
/// Uses `chars().count()` for multi-byte Unicode correctness (Pitfall 4).
pub col_offset: usize,
/// Display length of the full `[Link Text]` span (including brackets).
pub span_len: usize,
/// Raw destination — vault-relative path for inline links, wiki target for wiki-links.
pub dest: String,
/// True if this is a `[[wiki-link]]` (needs `vault::resolve_wiki_link` at nav time).
/// False for inline `[text](path.md)` links.
pub is_wiki: bool,
}
// ── CopyableBlock ────────────────────────────────────────────────────────────
/// The kind of copyable block discovered during rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Code,
Table,
Blockquote,
Text,
}
/// Metadata for a single copyable block discovered during rendering.
///
/// Produced by `render_markdown` alongside `Vec<Line>` and `Vec<LinkRecord>`.
/// Used to support OSC 52 copy-to-clipboard in copy mode or on right-click.
pub struct CopyableBlock {
/// Index into `Vec<Line>` where this block begins.
pub start_line: usize,
/// Index into `Vec<Line>` where this block ends (inclusive).
pub end_line: usize,
/// Raw content suitable for clipboard (plain text).
pub raw_content: String,
/// What kind of block this is.
pub kind: BlockKind,
}
// ── Internal pending link helpers ─────────────────────────────────────────────
/// Captured at `Tag::Link` Start — records link metadata before the text spans are pushed.
struct PendingLink {
dest: String,
is_wiki: bool,
/// Character column offset computed from current_spans at the moment Start fires.
col_offset: usize,
/// Style chosen for this link's brackets and text.
link_style: Style,
}
/// Created at `TagEnd::Link` — fully formed except for `line_index` (known only at flush).
struct PendingLinkRecord {
dest: String,
is_wiki: bool,
col_offset: usize,
/// Display length including brackets: computed from spans pushed between Start and End.
span_len: usize,
}
// ── RenderState ───────────────────────────────────────────────────────────────
/// Internal mutable state threaded through the event handler.
struct RenderState {
/// Accumulated output lines.
lines: Vec<Line<'static>>,
/// Spans for the current (in-progress) line.
current_spans: Vec<Span<'static>>,
/// Style stack — push on block/inline Start, pop on End.
style_stack: Vec<Style>,
/// True while inside a fenced or indented code block.
in_code_block: bool,
/// Language token for the current code block (empty = no language).
code_lang: String,
/// Accumulated raw text inside a code block.
code_buf: String,
/// List nesting: `None` = unordered, `Some(n)` = ordered starting at n.
list_counters: Vec<Option<u64>>,
/// Nesting depth of blockquotes (0 = not in blockquote).
blockquote_depth: u32,
/// True when we are currently inside at least one blockquote.
in_blockquote: bool,
/// True while collecting alt text for an image.
in_image: bool,
/// Accumulated image alt text.
image_alt: String,
// ── Table state ──────────────────────────────────────────────────────────
/// True while inside a table element.
in_table: bool,
/// Column alignment from the `Table` tag.
table_alignments: Vec<Alignment>,
/// All rows collected (rows[0] = header row).
table_rows: Vec<Vec<String>>,
/// Text accumulated for the current table cell.
current_cell: String,
/// True while inside the `<thead>` section.
in_table_head: bool,
// ── Width ────────────────────────────────────────────────────────────────
/// Terminal width; used for horizontal rules and code block sizing.
width: u16,
// ── Link state ───────────────────────────────────────────────────────────
/// Active link being rendered (set at Tag::Link Start, cleared at TagEnd::Link).
pending_link: Option<PendingLink>,
/// Span count at the moment Tag::Link Start fired — used to compute span_len at End.
link_span_start_count: usize,
/// Finalized link records awaiting line_index from the next flush_line call.
pending_link_records: Vec<PendingLinkRecord>,
/// All fully resolved link records (line_index filled in at flush time).
link_records: Vec<LinkRecord>,
/// All copyable block records discovered during rendering.
copyable_blocks: Vec<CopyableBlock>,
/// Line index where the current text section began (None = not in a text section).
text_section_start: Option<usize>,
/// Accumulated raw text for the current text section.
text_section_raw: String,
/// Line index where the outermost blockquote began (None = not in a blockquote block).
blockquote_start_line: Option<usize>,
/// Accumulated raw text for the current blockquote.
blockquote_raw: String,
}
impl RenderState {
fn new(width: u16) -> Self {
RenderState {
lines: Vec::new(),
current_spans: Vec::new(),
style_stack: Vec::new(),
in_code_block: false,
code_lang: String::new(),
code_buf: String::new(),
list_counters: Vec::new(),
blockquote_depth: 0,
in_blockquote: false,
in_image: false,
image_alt: String::new(),
in_table: false,
table_alignments: Vec::new(),
table_rows: Vec::new(),
current_cell: String::new(),
in_table_head: false,
width,
pending_link: None,
link_span_start_count: 0,
pending_link_records: Vec::new(),
link_records: Vec::new(),
copyable_blocks: Vec::new(),
text_section_start: None,
text_section_raw: String::new(),
blockquote_start_line: None,
blockquote_raw: String::new(),
}
}
// ── Style helpers ─────────────────────────────────────────────────────────
/// Return the topmost style from the stack, or `Style::default()` if empty.
fn current_style(&self) -> Style {
self.style_stack.last().copied().unwrap_or_default()
}
/// Push a new style derived from the current top by adding a modifier.
fn push_modifier(&mut self, modifier: Modifier) {
let new_style = self.current_style().add_modifier(modifier);
self.style_stack.push(new_style);
}
// ── Line flushing ─────────────────────────────────────────────────────────
/// Flush `current_spans` into a `Line`, optionally prepending blockquote borders.
///
/// If inside a blockquote, the content spans are colored gray and preceded
/// by a yellow `│ ` border for each nesting level.
///
/// Also finalizes any `pending_link_records` by setting their `line_index` to the
/// index of the line being pushed (Pitfall 1: line_index recorded at flush time,
/// not at TagEnd::Link, because flush can happen from heading/item/paragraph ends).
fn flush_line(&mut self) {
let mut spans = std::mem::take(&mut self.current_spans);
// Capture raw text from current spans for copyable block tracking
let raw_text: String = spans.iter().map(|s| s.content.as_ref()).collect();
if self.in_blockquote {
if !raw_text.is_empty() {
if !self.blockquote_raw.is_empty() {
self.blockquote_raw.push('\n');
}
self.blockquote_raw.push_str(&raw_text);
}
} else if self.text_section_start.is_some() && !raw_text.is_empty() {
if !self.text_section_raw.is_empty() {
self.text_section_raw.push('\n');
}
self.text_section_raw.push_str(&raw_text);
}
if self.in_blockquote && self.blockquote_depth > 0 {
// Re-color content spans to Gray
for span in spans.iter_mut() {
span.style = span.style.fg(Color::Gray);
}
// Prepend the blockquote border(s)
let border_str = "│ ".repeat(self.blockquote_depth as usize);
let border_span =
Span::styled(border_str, Style::default().fg(Color::Yellow));
let mut prefixed = vec![border_span];
prefixed.extend(spans);
spans = prefixed;
}
self.lines.push(Line::from(spans));
let line_index = self.lines.len() - 1;
// Finalize any pending link records with the just-pushed line index
let drained: Vec<PendingLinkRecord> = self.pending_link_records.drain(..).collect();
for record in drained {
self.link_records.push(LinkRecord {
line_index,
col_offset: record.col_offset,
span_len: record.span_len,
dest: record.dest,
is_wiki: record.is_wiki,
});
}
}
/// Push a blank (empty) line.
fn push_blank(&mut self) {
self.lines.push(Line::default());
}
// ── Text section helpers ───────────────────────────────────────────────────
/// Finalize the current text section, pushing a CopyableBlock if it has content.
fn finalize_text_section(&mut self) {
if let Some(start) = self.text_section_start.take() {
let raw = std::mem::take(&mut self.text_section_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Text,
});
}
}
}
/// Start a text section if none is active and we are not inside a blockquote.
fn ensure_text_section(&mut self) {
if self.text_section_start.is_none() && !self.in_blockquote {
self.text_section_start = Some(self.lines.len());
}
}
// ── Heading handling ──────────────────────────────────────────────────────
fn start_heading(&mut self, level: HeadingLevel) {
let style = match level {
HeadingLevel::H1 => {
Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD)
}
HeadingLevel::H2 => {
Style::default().fg(Color::LightYellow).add_modifier(Modifier::BOLD)
}
HeadingLevel::H3 => {
Style::default().fg(Color::LightGreen).add_modifier(Modifier::BOLD)
}
HeadingLevel::H4 => Style::default().fg(Color::Green),
HeadingLevel::H5 => Style::default().fg(Color::Cyan),
HeadingLevel::H6 => Style::default().fg(Color::DarkGray),
};
self.style_stack.push(style);
}
fn end_heading(&mut self, level: HeadingLevel) {
// Flush the heading text
self.flush_line();
// Pop the heading style
self.style_stack.pop();
// Emit decorator for H1 and H2
let w = self.width as usize;
match level {
HeadingLevel::H1 => {
self.lines.push(Line::from(Span::styled(
"═".repeat(w),
Style::default().fg(Color::LightCyan),
)));
}
HeadingLevel::H2 => {
self.lines.push(Line::from(Span::styled(
"─".repeat(w),
Style::default().fg(Color::LightYellow),
)));
}
_ => {}
}
// Always add a blank line after any heading
self.push_blank();
}
// ── Code block emitter ────────────────────────────────────────────────────
/// Flush the accumulated code buffer and emit a bordered, syntax-highlighted
/// code block into `self.lines`, recording a `CopyableBlock`.
fn emit_code_block_now(&mut self) {
let code = std::mem::take(&mut self.code_buf);
let lang = std::mem::take(&mut self.code_lang);
let width = self.width;
let start_line = self.lines.len(); // index of blank line before top border
emit_code_block(&code, &lang, width, &mut self.lines);
let end_line = self.lines.len().saturating_sub(1); // index of blank line after bottom border
self.copyable_blocks.push(CopyableBlock {
start_line,
end_line,
raw_content: code,
kind: BlockKind::Code,
});
}
// ── Table emitter ─────────────────────────────────────────────────────────
/// Flush the accumulated table rows and emit a full box-drawing grid table
/// into `self.lines`, recording a `CopyableBlock`.
fn emit_table_now(&mut self) {
let alignments = std::mem::take(&mut self.table_alignments);
let rows = std::mem::take(&mut self.table_rows);
let start_line = self.lines.len();
// Build TSV raw content for clipboard
let raw_content: String = rows.iter()
.map(|r| r.join("\t"))
.collect::<Vec<_>>()
.join("\n");
emit_table(&alignments, &rows, &mut self.lines);
let end_line = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line,
end_line,
raw_content,
kind: BlockKind::Table,
});
}
// ── Finish ────────────────────────────────────────────────────────────────
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>) {
// Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() {
self.flush_line();
}
// Finalize any pending text section or blockquote
self.finalize_text_section();
if let Some(start) = self.blockquote_start_line.take() {
let raw = std::mem::take(&mut self.blockquote_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = self.lines.len().saturating_sub(1);
self.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Blockquote,
});
}
}
(self.lines, self.link_records, self.copyable_blocks)
}
}
// ── Event dispatcher ──────────────────────────────────────────────────────────
fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>) {
match event {
// ── Headings ──────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => {
state.finalize_text_section();
state.text_section_start = Some(state.lines.len());
state.start_heading(level);
}
Event::End(TagEnd::Heading(level)) => {
state.end_heading(level);
}
// ── Paragraphs ────────────────────────────────────────────────────────
Event::Start(Tag::Paragraph) => {
state.ensure_text_section();
}
Event::End(TagEnd::Paragraph) => {
state.flush_line();
state.push_blank();
}
// ── Inline formatting ─────────────────────────────────────────────────
Event::Start(Tag::Strong) => {
state.push_modifier(Modifier::BOLD);
}
Event::End(TagEnd::Strong) => {
state.style_stack.pop();
}
Event::Start(Tag::Emphasis) => {
state.push_modifier(Modifier::ITALIC);
}
Event::End(TagEnd::Emphasis) => {
state.style_stack.pop();
}
// ── Inline code ───────────────────────────────────────────────────────
Event::Code(text) => {
// Inline code: LightCyan color, no background color available in 16-color
let span = Span::styled(
text.to_string(),
Style::default().fg(Color::LightCyan),
);
state.current_spans.push(span);
}
// ── Text ──────────────────────────────────────────────────────────────
Event::Text(text) => {
if state.in_code_block {
state.code_buf.push_str(&text);
} else if state.in_image {
state.image_alt.push_str(&text);
} else if state.in_table {
state.current_cell.push_str(&text);
} else if let Some(ref pending) = state.pending_link {
// Inside a link: render text with the link's style
let link_style = pending.link_style;
state.current_spans.push(Span::styled(text.to_string(), link_style));
} else {
let style = state.current_style();
state.current_spans.push(Span::styled(text.to_string(), style));
}
}
// ── Code blocks ───────────────────────────────────────────────────────
Event::Start(Tag::CodeBlock(kind)) => {
state.finalize_text_section();
state.code_lang = match kind {
CodeBlockKind::Fenced(lang) => lang.to_string(),
CodeBlockKind::Indented => String::new(),
};
state.in_code_block = true;
state.code_buf.clear();
}
Event::End(TagEnd::CodeBlock) => {
state.in_code_block = false;
state.emit_code_block_now();
}
// ── Lists ─────────────────────────────────────────────────────────────
Event::Start(Tag::List(start)) => {
state.ensure_text_section();
state.list_counters.push(start);
}
Event::End(TagEnd::List(_)) => {
state.list_counters.pop();
// Blank line after top-level list only
if state.list_counters.is_empty() {
state.push_blank();
}
}
Event::Start(Tag::Item) => {
let depth = state.list_counters.len();
let indent = " ".repeat(depth.saturating_sub(1));
let bullet_str = match state.list_counters.last_mut() {
Some(Some(n)) => {
let s = format!("{}{}. ", indent, n);
*n += 1;
s
}
Some(None) => {
let bullet = match depth {
1 => "• ",
2 => "◦ ",
_ => "▪ ",
};
format!("{}{}", indent, bullet)
}
None => format!("{}• ", indent),
};
state.current_spans.push(Span::styled(
bullet_str,
Style::default().fg(Color::Cyan),
));
}
Event::End(TagEnd::Item) => {
state.flush_line();
}
// ── Blockquotes ───────────────────────────────────────────────────────
Event::Start(Tag::BlockQuote(_)) => {
if state.blockquote_depth == 0 {
state.finalize_text_section();
state.push_blank();
state.blockquote_start_line = Some(state.lines.len());
}
state.blockquote_depth += 1;
state.in_blockquote = true;
}
Event::End(TagEnd::BlockQuote(_)) => {
if state.blockquote_depth > 0 {
state.blockquote_depth -= 1;
}
if state.blockquote_depth == 0 {
state.in_blockquote = false;
// Finalize blockquote as a copyable block
if let Some(start) = state.blockquote_start_line.take() {
let raw = std::mem::take(&mut state.blockquote_raw);
let trimmed = raw.trim();
if !trimmed.is_empty() {
let end = state.lines.len().saturating_sub(1);
state.copyable_blocks.push(CopyableBlock {
start_line: start,
end_line: end,
raw_content: trimmed.to_string(),
kind: BlockKind::Blockquote,
});
}
}
state.push_blank();
}
}
// ── Horizontal rules ──────────────────────────────────────────────────
Event::Rule => {
state.finalize_text_section();
let w = state.width as usize;
state.lines.push(Line::from(Span::styled(
"─".repeat(w),
Style::default().fg(Color::DarkGray),
)));
state.push_blank();
}
// ── Images ────────────────────────────────────────────────────────────
Event::Start(Tag::Image { .. }) => {
state.in_image = true;
state.image_alt.clear();
}
Event::End(TagEnd::Image) => {
let alt = std::mem::take(&mut state.image_alt);
let placeholder = format!("[IMAGE: {}]", alt);
state.current_spans.push(Span::styled(
placeholder,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
state.in_image = false;
}
// ── Links ─────────────────────────────────────────────────────────────
Event::Start(Tag::Link { link_type, dest_url, .. }) => {
let is_wiki = matches!(link_type, LinkType::WikiLink { .. });
// Determine link style: broken wiki-links get Red+CROSSED_OUT,
// all other links get LightCyan.
// Per plan: check wiki-link validity at render time using vault_path.
// Standard links (inline) are always LightCyan — broken state revealed at nav time only.
let link_style = if is_wiki {
// Check whether this wiki-link resolves in the vault
let resolves = vault_path.map(|vp| {
crate::vault::resolve_wiki_link(vp, &dest_url).is_some()
}).unwrap_or(true); // if no vault_path, assume valid (non-breaking)
if resolves {
Style::default().fg(Color::LightCyan)
} else {
Style::default().fg(Color::Red).add_modifier(Modifier::CROSSED_OUT)
}
} else if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
// Remote HTTP/HTTPS links are visually distinct from local links
Style::default().fg(Color::LightMagenta)
} else {
Style::default().fg(Color::LightCyan)
};
// Record column offset before pushing any link spans
let col_offset: usize = state.current_spans.iter()
.map(|s| s.content.chars().count())
.sum();
// Record span count to compute span_len at TagEnd::Link
state.link_span_start_count = state.current_spans.len();
// Set pending link state
state.pending_link = Some(PendingLink {
dest: dest_url.to_string(),
is_wiki,
col_offset,
link_style,
});
// Push opening bracket "[" with the link style
state.current_spans.push(Span::styled("[".to_string(), link_style));
}
Event::End(TagEnd::Link) => {
if let Some(pending) = state.pending_link.take() {
// Push closing bracket "]" with the same link style
state.current_spans.push(Span::styled("]".to_string(), pending.link_style));
// Compute span_len: total chars in all spans pushed for this link
// (from link_span_start_count to current end, inclusive of brackets)
let span_len: usize = state.current_spans[state.link_span_start_count..]
.iter()
.map(|s| s.content.chars().count())
.sum();
// Enqueue pending link record — line_index will be set at flush_line
state.pending_link_records.push(PendingLinkRecord {
dest: pending.dest,
is_wiki: pending.is_wiki,
col_offset: pending.col_offset,
span_len,
});
}
}
// ── Tables ────────────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => {
state.finalize_text_section();
state.table_alignments = alignments;
state.table_rows.clear();
state.in_table = true;
}
Event::End(TagEnd::Table) => {
state.in_table = false;
state.emit_table_now();
}
Event::Start(Tag::TableHead) => {
state.in_table_head = true;
// Start a new row for the header
state.table_rows.push(Vec::new());
}
Event::End(TagEnd::TableHead) => {
state.in_table_head = false;
}
Event::Start(Tag::TableRow) => {
if !state.in_table_head {
state.table_rows.push(Vec::new());
}
}
Event::End(TagEnd::TableRow) => {}
Event::Start(Tag::TableCell) => {
state.current_cell.clear();
}
Event::End(TagEnd::TableCell) => {
let cell = std::mem::take(&mut state.current_cell);
if let Some(row) = state.table_rows.last_mut() {
row.push(cell);
}
}
// ── Breaks ────────────────────────────────────────────────────────────
Event::SoftBreak => {
state.current_spans.push(Span::raw(" "));
}
Event::HardBreak => {
state.flush_line();
}
// ── Strikethrough (enabled but treated as plain text for now) ─────────
Event::Start(Tag::Strikethrough) => {
state.push_modifier(Modifier::CROSSED_OUT);
}
Event::End(TagEnd::Strikethrough) => {
state.style_stack.pop();
}
// ── Everything else ────────────────────────────────────────────────────
_ => {}
}
}
// ── Code block emitter ────────────────────────────────────────────────────────
/// Emit a fenced code block with rounded box-drawing borders and syntax
/// highlighting via `crate::highlighter::highlight_code()`.
///
/// Layout:
/// ```text
/// ╭─ rust ─────────────────────╮
/// │ let x = 1; │
/// ╰────────────────────────────╯
/// ```
fn emit_code_block(
code_buf: &str,
lang: &str,
width: u16,
lines: &mut Vec<Line<'static>>,
) {
let highlighted = crate::highlighter::highlight_code(code_buf, lang);
// Compute box width: at least lang.len() + 6, capped at terminal width
let max_content_width = highlighted.iter()
.map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::<usize>())
.max()
.unwrap_or(0);
let min_width_for_lang = if lang.is_empty() { 4 } else { lang.chars().count() + 6 };
let box_width = (max_content_width + 4)
.max(min_width_for_lang)
.min(width as usize);
// ── Top border ───────────────────────────────────────────────────────────
// ╭─ rust ───────╮ or ╭──────────╮
let top_line = if lang.is_empty() {
// No language label
let fill = "─".repeat(box_width.saturating_sub(2));
Line::from(vec![
Span::styled("╭".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(fill, Style::default().fg(Color::DarkGray)),
Span::styled("╮".to_string(), Style::default().fg(Color::DarkGray)),
])
} else {
// With language label: ╭─ {lang} ─...─╮
let label = format!("─ {} ", lang);
let used = label.chars().count() + 2; // ╭ + label + ╮
let fill_len = box_width.saturating_sub(used);
let fill = "─".repeat(fill_len);
Line::from(vec![
Span::styled("╭".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled("─ ".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(lang.to_string(), Style::default().fg(Color::Yellow)),
Span::styled(format!(" {}", fill), Style::default().fg(Color::DarkGray)),
Span::styled("╮".to_string(), Style::default().fg(Color::DarkGray)),
])
};
lines.push(Line::default()); // blank before
lines.push(top_line);
// ── Content lines ─────────────────────────────────────────────────────────
// │ {highlighted spans} {padding} │
if highlighted.is_empty() {
// Empty code block
let inner_width = box_width.saturating_sub(4); // "│ " + " │"
let padding = " ".repeat(inner_width);
lines.push(Line::from(vec![
Span::styled("│ ".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(padding, Style::default().fg(Color::Gray)),
Span::styled(" │".to_string(), Style::default().fg(Color::DarkGray)),
]));
} else {
for hl_line in highlighted {
let content_len: usize = hl_line.spans.iter().map(|s| s.content.chars().count()).sum();
let inner_width = box_width.saturating_sub(4);
let pad_len = inner_width.saturating_sub(content_len);
let padding = " ".repeat(pad_len);
let mut row_spans = vec![
Span::styled("│ ".to_string(), Style::default().fg(Color::DarkGray)),
];
for span in hl_line.spans {
row_spans.push(span);
}
row_spans.push(Span::styled(padding, Style::default()));
row_spans.push(Span::styled(" │".to_string(), Style::default().fg(Color::DarkGray)));
lines.push(Line::from(row_spans));
}
}
// ── Bottom border ─────────────────────────────────────────────────────────
let fill = "─".repeat(box_width.saturating_sub(2));
lines.push(Line::from(vec![
Span::styled("╰".to_string(), Style::default().fg(Color::DarkGray)),
Span::styled(fill, Style::default().fg(Color::DarkGray)),
Span::styled("╯".to_string(), Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::default()); // blank after
}
// ── Table emitter ─────────────────────────────────────────────────────────────
/// Emit a GFM table with full box-drawing grid.
///
/// Layout:
/// ```text
/// ┌──────┬──────┐
/// │ Col1 │ Col2 │
/// ├──────┼──────┤
/// │ data │ data │
/// └──────┴──────┘
/// ```
fn emit_table(
alignments: &[Alignment],
rows: &[Vec<String>],
lines: &mut Vec<Line<'static>>,
) {
if rows.is_empty() {
return;
}
let n_cols = alignments.len().max(
rows.iter().map(|r| r.len()).max().unwrap_or(0)
);
if n_cols == 0 {
return;
}
// ── Compute column widths ─────────────────────────────────────────────────
// Minimum 5 chars; each cell gets +2 for surrounding spaces
let mut col_widths: Vec<usize> = vec![5; n_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < n_cols {
col_widths[i] = col_widths[i].max(cell.chars().count() + 2);
}
}
}
// ── Border builder ────────────────────────────────────────────────────────
let build_border = |left: char, mid: char, right: char, fill: char| -> String {
let mut s = String::new();
s.push(left);
for (i, &w) in col_widths.iter().enumerate() {
s.extend(std::iter::repeat(fill).take(w));
if i + 1 < n_cols {
s.push(mid);
}
}
s.push(right);
s
};
let border_style = Style::default().fg(Color::Cyan);
// ── Top border ────────────────────────────────────────────────────────────
let top = build_border('┌', '┬', '┐', '─');
lines.push(Line::from(Span::styled(top, border_style)));
// ── Header row ────────────────────────────────────────────────────────────
let header_style = Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD);
emit_table_row(
&rows[0],
&col_widths,
alignments,
header_style,
border_style,
lines,
);
// ── Header separator ──────────────────────────────────────────────────────
let sep = build_border('├', '┼', '┤', '─');
lines.push(Line::from(Span::styled(sep, border_style)));
// ── Data rows ─────────────────────────────────────────────────────────────
let data_style = Style::default();
for row in rows.iter().skip(1) {
emit_table_row(row, &col_widths, alignments, data_style, border_style, lines);
}
// ── Bottom border ─────────────────────────────────────────────────────────
let bottom = build_border('└', '┴', '┘', '─');
lines.push(Line::from(Span::styled(bottom, border_style)));
lines.push(Line::default()); // spacing after table
}
/// Emit a single table data row (header or body) as a `Line`.
fn emit_table_row(
cells: &[String],
col_widths: &[usize],
alignments: &[Alignment],
cell_style: Style,
border_style: Style,
lines: &mut Vec<Line<'static>>,
) {
let n_cols = col_widths.len();
let mut spans: Vec<Span<'static>> = Vec::new();
for col in 0..n_cols {
spans.push(Span::styled("│".to_string(), border_style));
let cell_text = cells.get(col).map(|s| s.as_str()).unwrap_or("");
let width = col_widths[col];
let alignment = alignments.get(col).copied().unwrap_or(Alignment::None);
let padded = pad_cell(cell_text, width, alignment);
spans.push(Span::styled(padded, cell_style));
}
spans.push(Span::styled("│".to_string(), border_style));
lines.push(Line::from(spans));
}
/// Pad a cell string to `width` according to the column alignment.
///
/// - `Left` / `None`: one leading space, right-pad to width
/// - `Right`: left-pad to width, one trailing space
/// - `Center`: center the text within width
fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// width includes the surrounding single-space padding on each side
let content_width = width.saturating_sub(2);
let text_len = text.chars().count();
match alignment {
Alignment::Right => {
// Right-align: spaces on left, 1 trailing space
let pad = content_width.saturating_sub(text_len);
format!("{}{} ", " ".repeat(pad + 1), text)
}
Alignment::Center => {
let pad = content_width.saturating_sub(text_len);
let left_pad = pad / 2 + 1;
let right_pad = pad - (pad / 2) + 1;
format!(
"{}{}{}",
" ".repeat(left_pad),
text,
" ".repeat(right_pad)
)
}
// Alignment::Left and Alignment::None
_ => {
let pad = content_width.saturating_sub(text_len);
format!(" {}{}", text, " ".repeat(pad + 1))
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Convert a markdown string into styled ratatui lines plus link and copyable block metadata.
///
/// Returns a 3-tuple `(Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>)`:
/// - Lines: styled display content for ratatui `Paragraph`
/// - LinkRecords: parallel metadata for every link found (line_index, col_offset,
/// span_len, dest, is_wiki) — consumed for Tab-cycling navigation
/// - CopyableBlocks: metadata for every copyable block (start_line, end_line,
/// raw_content, kind) — consumed for copy mode and OSC 52 clipboard
///
/// The `vault_path` parameter is used to resolve wiki-links at render time,
/// enabling broken wiki-links to be shown in red/strikethrough inline. Pass
/// `None` when vault_path is unavailable (e.g. during resize before first doc load).
///
/// The `width` parameter controls the width used for:
/// - Horizontal rule `─` characters (full-width)
/// - Code block border sizing
///
/// All output spans are `'static` (owned strings) — no lifetime dependency on
/// the input `&str`. The result can be stored in `App` state and rendered
/// indefinitely via `Paragraph::new(lines).scroll((offset, 0))`.
///
/// # Panics
///
/// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// the first code block is encountered.
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>, Vec<CopyableBlock>) {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_GFM);
opts.insert(Options::ENABLE_WIKILINKS);
let parser = TextMergeStream::new(Parser::new_ext(input, opts));
let mut state = RenderState::new(width);
for event in parser {
handle_event(&mut state, event, vault_path);
}
state.finish()
}