c8d4754340
- 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
996 lines
43 KiB
Rust
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()
|
|
}
|