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:
2026-02-28 23:04:20 +01:00
parent cf58e31bcb
commit a63f4115f9
3 changed files with 162 additions and 14 deletions
+2 -1
View File
@@ -201,7 +201,8 @@ impl App {
/// We re-render so horizontal rules, code block borders, and table widths adapt. /// We re-render so horizontal rules, code block borders, and table widths adapt.
fn handle_resize(&mut self, new_width: u16) { fn handle_resize(&mut self, new_width: u16) {
if let Some(ref content) = self.raw_content.clone() { if let Some(ref content) = self.raw_content.clone() {
let lines = crate::renderer::render_markdown(content, new_width); // TODO(03-02): use link_records from render_markdown for Tab-cycling navigation
let (lines, _link_records) = crate::renderer::render_markdown(content, new_width, None);
let filename = self.filename.clone(); let filename = self.filename.clone();
self.document = DocumentState::Loaded { filename, lines }; self.document = DocumentState::Loaded { filename, lines };
// Clamp scroll to new max after re-render // Clamp scroll to new max after re-render
+2 -1
View File
@@ -44,7 +44,8 @@ fn main() {
.file_name() .file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "index.md".to_string()); .unwrap_or_else(|| "index.md".to_string());
let lines = renderer::render_markdown(&content, initial_width); // TODO(03-02): use link_records for Tab-cycling navigation
let (lines, _link_records) = renderer::render_markdown(&content, initial_width, None);
let doc = app::DocumentState::Loaded { filename, lines }; let doc = app::DocumentState::Loaded { filename, lines };
(doc, Some(content)) (doc, Some(content))
} }
+158 -12
View File
@@ -6,14 +6,58 @@
//! //!
//! # Public API //! # 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::{ 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::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; 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 ─────────────────────────────────────────────────────────────── // ── RenderState ───────────────────────────────────────────────────────────────
/// Internal mutable state threaded through the event handler. /// Internal mutable state threaded through the event handler.
@@ -54,6 +98,15 @@ struct RenderState {
// ── Width ──────────────────────────────────────────────────────────────── // ── Width ────────────────────────────────────────────────────────────────
/// Terminal width; used for horizontal rules and code block sizing. /// Terminal width; used for horizontal rules and code block sizing.
width: u16, 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 { impl RenderState {
@@ -76,6 +129,10 @@ impl RenderState {
current_cell: String::new(), current_cell: String::new(),
in_table_head: false, in_table_head: false,
width, 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 /// If inside a blockquote, the content spans are colored gray and preceded
/// by a yellow `│ ` border for each nesting level. /// 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) { fn flush_line(&mut self) {
let mut spans = std::mem::take(&mut self.current_spans); let mut spans = std::mem::take(&mut self.current_spans);
@@ -116,6 +177,19 @@ impl RenderState {
} }
self.lines.push(Line::from(spans)); 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. /// Push a blank (empty) line.
@@ -195,18 +269,18 @@ impl RenderState {
// ── Finish ──────────────────────────────────────────────────────────────── // ── 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 // Flush any trailing spans that were not terminated with a paragraph end
if !self.current_spans.is_empty() { if !self.current_spans.is_empty() {
self.flush_line(); self.flush_line();
} }
self.lines (self.lines, self.link_records)
} }
} }
// ── Event dispatcher ────────────────────────────────────────────────────────── // ── Event dispatcher ──────────────────────────────────────────────────────────
fn handle_event(state: &mut RenderState, event: Event) { fn handle_event(state: &mut RenderState, event: Event, vault_path: Option<&Path>) {
match event { match event {
// ── Headings ────────────────────────────────────────────────────────── // ── Headings ──────────────────────────────────────────────────────────
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => {
@@ -257,6 +331,10 @@ fn handle_event(state: &mut RenderState, event: Event) {
state.image_alt.push_str(&text); state.image_alt.push_str(&text);
} else if state.in_table { } else if state.in_table {
state.current_cell.push_str(&text); 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 { } else {
let style = state.current_style(); let style = state.current_style();
state.current_spans.push(Span::styled(text.to_string(), style)); state.current_spans.push(Span::styled(text.to_string(), style));
@@ -364,11 +442,69 @@ fn handle_event(state: &mut RenderState, event: Event) {
} }
// ── Links ───────────────────────────────────────────────────────────── // ── Links ─────────────────────────────────────────────────────────────
Event::Start(Tag::Link { .. }) => { Event::Start(Tag::Link { link_type, dest_url, .. }) => {
// Phase 2: pass through — link text renders as normal styled text. let is_wiki = matches!(link_type, LinkType::WikiLink { .. });
// Phase 3 will make links interactive.
// 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 ──────────────────────────────────────────────────────────── // ── Tables ────────────────────────────────────────────────────────────
Event::Start(Tag::Table(alignments)) => { Event::Start(Tag::Table(alignments)) => {
@@ -672,7 +808,16 @@ fn pad_cell(text: &str, width: usize, alignment: Alignment) -> String {
// ── Public API ──────────────────────────────────────────────────────────────── // ── 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: /// The `width` parameter controls the width used for:
/// - Horizontal rule `─` characters (full-width) /// - 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 /// Panics if `crate::highlighter::init_highlighter()` has not been called before
/// the first code block is encountered. /// 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(); let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES); opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH); opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS); opts.insert(Options::ENABLE_TASKLISTS);
opts.insert(Options::ENABLE_GFM); opts.insert(Options::ENABLE_GFM);
opts.insert(Options::ENABLE_WIKILINKS);
let parser = TextMergeStream::new(Parser::new_ext(input, opts)); let parser = TextMergeStream::new(Parser::new_ext(input, opts));
let mut state = RenderState::new(width); let mut state = RenderState::new(width);
for event in parser { for event in parser {
handle_event(&mut state, event); handle_event(&mut state, event, vault_path);
} }
state.finish() state.finish()
} }