use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use crate::config::Theme;
use crate::editor::link::LinkTarget;
use crate::editor::EditorState;
use crate::ui::line_render;
use super::coord::{click_to_char_offset, rendered_line_at_row};
pub(super) fn is_link_style(style: Style, theme: &Theme) -> bool {
if style.add_modifier.contains(Modifier::UNDERLINED) {
return true;
}
theme.link_heading.fg.is_some()
&& style.fg == theme.link_heading.fg
&& style.bg == theme.link_heading.bg
}
pub(super) fn span_at_col_is_link(line: &Line<'_>, col: usize, theme: &Theme) -> bool {
let mut walk = 0usize;
for span in &line.spans {
let span_len = span.content.chars().count();
if col < walk + span_len {
return is_link_style(span.style, theme);
}
walk += span_len;
}
false
}
pub fn hovered_link_url(
state: &EditorState,
col: u16,
row: u16,
viewport_width: usize,
) -> Option<String> {
let (line, _) = rendered_line_at_row(state, row as usize)?;
if !span_at_col_is_link(&line, col as usize, state.theme()) {
return None;
}
link_url_for_click(state, col as usize, row as usize, viewport_width)
}
pub(super) fn follow_link_at_click(
state: &mut EditorState,
col: u16,
row: u16,
viewport_width: usize,
) -> bool {
if let Some((line, _)) = rendered_line_at_row(state, row as usize) {
if span_at_col_is_link(&line, col as usize, state.theme()) {
if let Some(url) = link_url_for_click(state, col as usize, row as usize, viewport_width)
{
let base_dir = state
.buffer
.path()
.and_then(|p| p.parent())
.map(|p| p.to_owned());
state.pending_link_follow = Some(LinkTarget::parse(&url, base_dir.as_deref()));
return true;
}
}
}
let Some(offset) = click_to_char_offset(state, col as usize, row as usize, viewport_width)
else {
return false;
};
let source = state.buffer.contents();
let click_byte = state.buffer.rope().char_to_byte(offset);
if let Some(url) = link_at_offset(&source, click_byte) {
let base_dir = state
.buffer
.path()
.and_then(|p| p.parent())
.map(|p| p.to_owned());
state.pending_link_follow = Some(LinkTarget::parse(&url, base_dir.as_deref()));
return true;
}
if let Some(target) = super::footnotes::footnote_at_offset(&source, click_byte) {
state.pending_link_follow = Some(target);
return true;
}
if let Some(target) = super::footnotes::back_link_glyph_at_click(state, col, row) {
state.pending_link_follow = Some(target);
return true;
}
false
}
pub(super) fn follow_footnote_at_click(
state: &mut EditorState,
col: u16,
row: u16,
viewport_width: usize,
) -> bool {
if let Some(target) = super::footnotes::back_link_glyph_at_click(state, col, row) {
state.pending_link_follow = Some(target);
return true;
}
let Some(offset) = click_to_char_offset(state, col as usize, row as usize, viewport_width)
else {
return false;
};
let source = state.buffer.contents();
let click_byte = state.buffer.rope().char_to_byte(offset);
if let Some(target) = super::footnotes::footnote_at_offset(&source, click_byte) {
state.pending_link_follow = Some(target);
return true;
}
false
}
fn link_url_for_click(
state: &EditorState,
col: usize,
row: usize,
_viewport_width: usize,
) -> Option<String> {
let (line, _sub_row) = rendered_line_at_row(state, row)?;
let theme = state.theme();
let mut walk = 0usize;
let mut run_index: Option<usize> = None;
let mut link_count = 0usize;
let mut in_run = false;
for span in &line.spans {
let span_len = span.content.chars().count();
let under = is_link_style(span.style, theme);
if under {
if !in_run {
if col >= walk && col < walk + span_len {
run_index = Some(link_count);
}
link_count += 1;
in_run = true;
} else if col >= walk && col < walk + span_len {
run_index.get_or_insert(link_count - 1);
}
} else {
in_run = false;
}
walk += span_len;
}
let target_idx = run_index?;
let cursor_byte = state
.parsed
.source_map
.original_byte_for_rendered_line(index_for_row(state, row)?)?;
let block_range = state
.parsed
.source_map
.original_range_for_byte(cursor_byte)?;
let source = state.buffer.contents();
let block_src = source
.get(block_range.start..block_range.end.min(source.len()))
.unwrap_or("");
let blocks = crate::markdown::parse(block_src);
let mut urls: Vec<(String, Option<String>)> = Vec::new();
for block in &blocks {
crate::ui::link_view::collect_links_from_block_public(block, &mut urls);
}
urls.into_iter().nth(target_idx).map(|(u, _)| u)
}
fn index_for_row(state: &EditorState, row: usize) -> Option<usize> {
let lines = &state.parsed.lines;
let mut y = 0usize;
for (idx, line) in lines.iter().enumerate().skip(state.scroll) {
let rows_used = line_render::visual_rows_for_line(line, usize::MAX).max(1);
if row < y + rows_used {
return Some(idx);
}
y += rows_used;
}
None
}
pub fn link_at_offset(source: &str, click_byte: usize) -> Option<String> {
let click_byte = click_byte.min(source.len());
let line_start = source[..click_byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
let rel_after = source[click_byte..]
.find('\n')
.map(|i| click_byte + i)
.unwrap_or(source.len());
let line = &source[line_start..rel_after];
let col = click_byte.saturating_sub(line_start);
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'[' && !crate::editor::footnote_edit::is_escaped(bytes, i) {
let mut depth = 1usize;
let mut j = i + 1;
while j < bytes.len() {
match bytes[j] {
b'[' => depth += 1,
b']' => {
depth -= 1;
if depth == 0 {
break;
}
}
b'\\' => {
j += 1;
}
_ => {}
}
j += 1;
}
if depth != 0 || j >= bytes.len() {
return None;
}
let close_bracket = j;
if close_bracket + 1 >= bytes.len() || bytes[close_bracket + 1] != b'(' {
i = close_bracket + 1;
continue;
}
let url_start = close_bracket + 2;
let mut pdepth = 1usize;
let mut k = url_start;
while k < bytes.len() {
match bytes[k] {
b'(' => pdepth += 1,
b')' => {
pdepth -= 1;
if pdepth == 0 {
break;
}
}
b'\\' => {
k += 1;
}
_ => {}
}
k += 1;
}
if pdepth != 0 || k >= bytes.len() {
return None;
}
let url_end = k;
if col >= i && col <= url_end {
let url_bytes = &bytes[url_start..url_end];
let url = String::from_utf8_lossy(url_bytes).trim().to_owned();
return if url.is_empty() { None } else { Some(url) };
}
i = url_end + 1;
} else {
i += 1;
}
}
None
}