feat(03-01): extend renderer with LinkRecord extraction and link styling
- Add LinkRecord struct (line_index, col_offset, span_len, dest, is_wiki) - Add PendingLink and PendingLinkRecord helpers in RenderState - Enable ENABLE_WIKILINKS option in pulldown-cmark parser - Handle Tag::Link Start: compute col_offset, determine broken wiki-link style - Handle TagEnd::Link: compute span_len, enqueue PendingLinkRecord - Update flush_line() to finalize pending link records with line_index - Update render_markdown() signature to return (Vec<Line>, Vec<LinkRecord>) - Add vault_path: Option<&Path> parameter for render-time broken link detection - Update app.rs handle_resize and main.rs call sites to destructure tuple - Wiki-links render as [Text] in LightCyan; broken wiki-links in Red+CROSSED_OUT
This commit is contained in:
+158
-12
@@ -6,14 +6,58 @@
|
||||
//!
|
||||
//! # Public API
|
||||
//!
|
||||
//! - `render_markdown(input: &str, width: u16) -> Vec<Line<'static>>`
|
||||
//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>)`
|
||||
|
||||
use std::path::Path;
|
||||
use pulldown_cmark::{
|
||||
Alignment, CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd, TextMergeStream,
|
||||
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,
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -54,6 +98,15 @@ struct RenderState {
|
||||
// ── 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>,
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
@@ -76,6 +129,10 @@ impl RenderState {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +155,10 @@ impl RenderState {
|
||||
///
|
||||
/// 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);
|
||||
|
||||
@@ -116,6 +177,19 @@ impl RenderState {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -195,18 +269,18 @@ impl RenderState {
|
||||
|
||||
// ── Finish ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn finish(mut self) -> Vec<Line<'static>> {
|
||||
fn finish(mut self) -> (Vec<Line<'static>>, Vec<LinkRecord>) {
|
||||
// Flush any trailing spans that were not terminated with a paragraph end
|
||||
if !self.current_spans.is_empty() {
|
||||
self.flush_line();
|
||||
}
|
||||
self.lines
|
||||
(self.lines, self.link_records)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_event(state: &mut RenderState, event: Event) {
|
||||
fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>) {
|
||||
match event {
|
||||
// ── Headings ──────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
@@ -257,6 +331,10 @@ fn handle_event(state: &mut RenderState, event: Event) {
|
||||
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));
|
||||
@@ -364,11 +442,69 @@ fn handle_event(state: &mut RenderState, event: Event) {
|
||||
}
|
||||
|
||||
// ── Links ─────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Link { .. }) => {
|
||||
// Phase 2: pass through — link text renders as normal styled text.
|
||||
// Phase 3 will make links interactive.
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::End(TagEnd::Link) => {}
|
||||
|
||||
// ── Tables ────────────────────────────────────────────────────────────
|
||||
Event::Start(Tag::Table(alignments)) => {
|
||||
@@ -672,7 +808,16 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a markdown string into a list of styled ratatui lines.
|
||||
/// Convert a markdown string into styled ratatui lines plus link metadata.
|
||||
///
|
||||
/// Returns a pair `(Vec<Line<'static>>, Vec<LinkRecord>)`:
|
||||
/// - Lines: styled display content for ratatui `Paragraph`
|
||||
/// - LinkRecords: parallel metadata for every link found (line_index, col_offset,
|
||||
/// span_len, dest, is_wiki) — consumed by Plan 02 for Tab-cycling navigation
|
||||
///
|
||||
/// 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)
|
||||
@@ -686,18 +831,19 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
|
||||
///
|
||||
/// 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) -> Vec<Line<'static>> {
|
||||
pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec<Line<'static>>, Vec<LinkRecord>) {
|
||||
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);
|
||||
handle_event(&mut state, event, vault_path);
|
||||
}
|
||||
state.finish()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user