use std::path::Path;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::Line;
use crate::editor::link::LinkTarget;
use crate::editor::EditorState;
use crate::markdown::{Block, Inline};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkRun {
Link {
url: String,
title: Option<String>,
},
ImagePlaceholder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkLayoutSnapshot {
pub rect: Rect,
pub target: LinkTarget,
pub url: String,
pub title: Option<String>,
}
impl LinkLayoutSnapshot {
#[allow(dead_code)]
pub fn hit_test(&self, col: u16, row: u16) -> Option<&Self> {
if col >= self.rect.x
&& col < self.rect.x + self.rect.width
&& row >= self.rect.y
&& row < self.rect.y + self.rect.height
{
Some(self)
} else {
None
}
}
}
pub fn build_snapshots_cached(
state: &EditorState,
area: Rect,
scroll: usize,
snapshots: &mut Vec<LinkLayoutSnapshot>,
cache_key: &mut Option<(usize, Rect, u64)>,
) {
let key = (scroll, area, state.parsed_version);
if *cache_key == Some(key) {
return;
}
*snapshots = build_snapshots(state, area, scroll);
*cache_key = Some(key);
}
pub fn build_snapshots(state: &EditorState, area: Rect, scroll: usize) -> Vec<LinkLayoutSnapshot> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let base_dir = state
.buffer
.path()
.and_then(|p| p.parent())
.map(Path::to_owned);
let mut out = Vec::new();
for (block, range) in state
.parsed
.blocks
.iter()
.zip(state.parsed.real_ranges.iter())
{
let rendered_range = state.parsed.source_map.rendered_lines_for_byte(range.start);
if rendered_range.is_empty() {
continue;
}
let block_end_rows = state
.parsed
.visual_rows_before(rendered_range.end, area.width as usize);
if block_end_rows <= scroll {
continue;
}
extract_block_links(
block,
&rendered_range,
state,
area,
scroll,
base_dir.as_deref(),
&mut out,
);
}
out
}
fn extract_block_links(
block: &Block,
rendered_range: &std::ops::Range<usize>,
state: &EditorState,
area: Rect,
scroll: usize,
base_dir: Option<&Path>,
out: &mut Vec<LinkLayoutSnapshot>,
) {
let mut link_runs: Vec<LinkRun> = Vec::new();
collect_link_runs_from_block(block, &mut link_runs);
if !link_runs
.iter()
.any(|run| matches!(run, LinkRun::Link { .. }))
{
return;
}
let width = area.width as usize;
let total = state.parsed.lines.len();
let block_start_rows = state
.parsed
.visual_rows_before(rendered_range.start.min(total), width);
let mut y_cursor: isize = block_start_rows as isize - scroll as isize;
let mut line_positions: Vec<(usize, u16, u16)> = Vec::new(); for idx in rendered_range.start..rendered_range.end.min(total) {
let rows_used = state.parsed.visual_rows_for_line_at(idx, width).max(1);
let y_start = y_cursor;
y_cursor += rows_used as isize;
if y_cursor <= 0 {
continue;
}
if y_start >= area.height as isize {
break;
}
line_positions.push((idx, y_start.max(0) as u16, rows_used as u16));
}
let mut link_iter = link_runs.into_iter().peekable();
for (line_idx, y_start, rows_used) in line_positions {
let Some(line) = state.parsed.lines.get(line_idx) else {
continue;
};
let underlined_ranges = underlined_char_ranges(line);
for (start_col, end_col) in underlined_ranges {
let Some(run) = link_iter.next() else {
return;
};
let LinkRun::Link { url, title } = run else {
continue;
};
let target = LinkTarget::parse(&url, base_dir);
let width = end_col.saturating_sub(start_col);
if width == 0 {
continue;
}
let rect = Rect {
x: area.x + start_col as u16,
y: area.y + y_start,
width: width as u16,
height: rows_used,
};
out.push(LinkLayoutSnapshot {
rect,
target,
url,
title,
});
}
}
}
fn underlined_char_ranges(line: &Line<'_>) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut col = 0usize;
let mut in_link: Option<usize> = None;
for span in &line.spans {
let span_len = span.content.chars().count();
let underlined = span.style.add_modifier.contains(Modifier::UNDERLINED);
if underlined {
if in_link.is_none() {
in_link = Some(col);
}
} else if let Some(start) = in_link.take() {
out.push((start, col));
}
col += span_len;
}
if let Some(start) = in_link {
out.push((start, col));
}
out
}
pub fn collect_link_runs_from_block_public(block: &Block, out: &mut Vec<LinkRun>) {
collect_link_runs_from_block(block, out);
}
fn collect_link_runs_from_block(block: &Block, out: &mut Vec<LinkRun>) {
match block {
Block::Heading { inlines, .. } | Block::Paragraph { inlines } => {
collect_link_runs_from_inlines(inlines, out);
}
Block::BlockQuote { blocks } | Block::FootnoteDefinition { blocks, .. } => {
for inner in blocks {
collect_link_runs_from_block(inner, out);
}
}
Block::List { items, .. } => {
for item in items {
for inner in &item.blocks {
collect_link_runs_from_block(inner, out);
}
}
}
Block::Table { headers, rows, .. } => {
for cell in headers {
collect_link_runs_from_inlines(cell, out);
}
for row in rows {
for cell in row {
collect_link_runs_from_inlines(cell, out);
}
}
}
Block::CodeBlock { .. }
| Block::HorizontalRule
| Block::Html(_)
| Block::HtmlComment(_)
| Block::MetadataBlock { .. }
| Block::ImageBlock { .. } => {}
}
}
fn collect_link_runs_from_inlines(inlines: &[Inline], out: &mut Vec<LinkRun>) {
for inline in inlines {
match inline {
Inline::Link { url, title, .. } => {
out.push(LinkRun::Link {
url: url.clone(),
title: title.clone(),
});
}
Inline::Image { .. } => out.push(LinkRun::ImagePlaceholder),
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => {
collect_link_runs_from_inlines(inner, out);
}
Inline::Text(_)
| Inline::Code(_)
| Inline::HtmlComment(_)
| Inline::FootnoteReference { .. }
| Inline::Math { .. }
| Inline::SoftBreak
| Inline::HardBreak => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::document::Buffer;
use crate::editor::EditorState;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn state(src: &str) -> EditorState {
EditorState::new(Buffer::from_str(src), theme())
}
#[test]
fn one_link_in_a_paragraph_produces_one_snapshot() {
let src = "See [docs](https://example.com) for more.\n";
let st = state(src);
let area = Rect::new(0, 0, 80, 10);
let snaps = build_snapshots(&st, area, 0);
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].url, "https://example.com");
assert!(matches!(&snaps[0].target, LinkTarget::Url(u) if u == "https://example.com"));
}
#[test]
fn two_links_in_document_order_produce_two_snapshots() {
let src = "[first](a.md) and [second](b.md)\n";
let st = state(src);
let area = Rect::new(0, 0, 80, 10);
let snaps = build_snapshots(&st, area, 0);
assert_eq!(snaps.len(), 2);
assert_eq!(snaps[0].url, "a.md");
assert_eq!(snaps[1].url, "b.md");
}
#[test]
fn no_snapshots_for_plain_paragraph() {
let src = "no links here, just text\n";
let st = state(src);
let area = Rect::new(0, 0, 80, 10);
assert!(build_snapshots(&st, area, 0).is_empty());
}
#[test]
fn link_below_scroll_lands_on_correct_row() {
let mut src = String::new();
for i in 0..12 {
src.push_str(&format!("Paragraph {i}.\n\n"));
}
src.push_str("Read [this](https://example.com)\n");
let st = state(&src);
let area = Rect::new(0, 0, 80, 6);
let snaps = build_snapshots(&st, area, 20);
assert_eq!(snaps.len(), 1, "exactly one link snapshot expected");
assert_eq!(snaps[0].rect.y, 4, "link y must equal line_index - scroll");
}
#[test]
fn underlined_char_ranges_merge_adjacent_spans() {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
let line = Line::from(vec![
Span::raw("plain "),
Span::styled("link", Style::default().add_modifier(Modifier::UNDERLINED)),
Span::styled(" text", Style::default().add_modifier(Modifier::UNDERLINED)),
Span::raw(" after"),
]);
let ranges = underlined_char_ranges(&line);
assert_eq!(ranges, vec![(6, 15)]);
}
}