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::LinkRun;
use super::coord::{click_to_char_offset, rendered_click_to_line_col_on_text};
pub(super) fn is_link_style(style: Style, theme: &Theme) -> bool {
let link_fgs = [
theme.link_text.fg,
theme.link_file.fg,
theme.link_heading.fg,
];
if link_fgs.iter().any(Option::is_some) {
return link_fgs.iter().any(|fg| fg.is_some() && *fg == style.fg);
}
style.add_modifier.contains(Modifier::UNDERLINED)
}
pub fn hovered_link_url(
state: &EditorState,
col: u16,
row: u16,
viewport_width: usize,
) -> Option<String> {
link_at_rendered_pos(state, col as usize, row as usize, viewport_width).map(|(url, _)| url)
}
pub(super) fn follow_link_at_click(
state: &mut EditorState,
col: u16,
row: u16,
viewport_width: usize,
) -> bool {
if let Some((url, _)) = link_at_rendered_pos(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
}
pub(super) fn link_at_rendered_pos(
state: &EditorState,
col: usize,
row: usize,
viewport_width: usize,
) -> Option<(String, Option<String>)> {
let (line_idx, char_col) = rendered_click_to_line_col_on_text(state, col, row, viewport_width)?;
let theme = state.theme();
let line = state.parsed.lines.get(line_idx)?;
let runs = link_run_ranges(line, theme);
let run_in_line = runs
.iter()
.position(|(start, end)| char_col >= *start && char_col < *end)?;
let block_byte = state
.parsed
.source_map
.original_byte_for_rendered_line(line_idx)?;
let block_range = state
.parsed
.source_map
.original_range_for_byte(block_byte)?;
let rendered_range = state
.parsed
.source_map
.rendered_lines_for_byte(block_range.start);
let preceding: usize = (rendered_range.start..line_idx.min(rendered_range.end))
.filter_map(|idx| state.parsed.lines.get(idx))
.map(|earlier| link_run_ranges(earlier, theme).len())
.sum();
let block_src = state
.buffer
.byte_slice_to_string(
block_range.start,
block_range.end.min(state.buffer.len_bytes()),
)
.unwrap_or_default();
let mut runs_in_block: Vec<LinkRun> = Vec::new();
for block in &crate::markdown::parse(&block_src) {
crate::ui::link_view::collect_link_runs_from_block_public(block, &mut runs_in_block);
}
match runs_in_block.into_iter().nth(preceding + run_in_line)? {
LinkRun::Link { url, title } => Some((url, title)),
LinkRun::ImagePlaceholder => None,
}
}
fn link_run_ranges(line: &Line<'_>, theme: &Theme) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut col = 0usize;
let mut run_start: Option<usize> = None;
for span in &line.spans {
let span_len = span.content.chars().count();
if is_link_style(span.style, theme) {
if run_start.is_none() {
run_start = Some(col);
}
} else if let Some(start) = run_start.take() {
out.push((start, col));
}
col += span_len;
}
if let Some(start) = run_start {
out.push((start, col));
}
out
}
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
}