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:
+2
-1
@@ -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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user