diff --git a/src/app.rs b/src/app.rs index 1e6911c..a9d454f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -201,7 +201,8 @@ impl App { /// We re-render so horizontal rules, code block borders, and table widths adapt. fn handle_resize(&mut self, new_width: u16) { 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(); self.document = DocumentState::Loaded { filename, lines }; // Clamp scroll to new max after re-render diff --git a/src/main.rs b/src/main.rs index 4a0fbe9..bc57a74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,8 @@ fn main() { .file_name() .map(|n| n.to_string_lossy().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 }; (doc, Some(content)) } diff --git a/src/renderer.rs b/src/renderer.rs index 716f350..1015c18 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -6,14 +6,58 @@ //! //! # Public API //! -//! - `render_markdown(input: &str, width: u16) -> Vec>` +//! - `render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec)` +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`. +/// Plan 02 consumes this to wire Tab-cycling navigation and link following. +pub struct LinkRecord { + /// Index into `Vec` 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, + /// 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, + /// All fully resolved link records (line_index filled in at flush time). + link_records: Vec, } 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 = 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> { + fn finish(mut self) -> (Vec>, Vec) { // 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>, Vec)`: +/// - 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> { +pub fn render_markdown(input: &str, width: u16, vault_path: Option<&Path>) -> (Vec>, Vec) { 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() }