Skip to main content

markdown/
link.rs

1//! Who opens a link.
2//!
3//! Installed once at boot like the highlighter, and read at click. Without it a
4//! link goes to [`App::open_url`]. A `#fragment` link reaches neither: it names
5//! a heading of the document it is in — see [`heading`].
6
7use std::{collections::HashSet, path::Path, rc::Rc};
8
9use gpui::{App, Global, Window};
10
11use crate::{BlockKind, Doc, render::OnJump};
12
13/// Opens a link a reader clicked: an inline link or a bookmark card. `base` is
14/// the document's [`crate::render::Editing::base`]; the URL is as written.
15pub type LinkHandler = fn(url: &str, base: Option<&Path>, &mut Window, &mut App);
16
17struct Installed(LinkHandler);
18
19impl Global for Installed {}
20
21/// `cx.set_link_handler(my_links)` — call once at boot.
22pub(crate) fn set_link_handler(cx: &mut App, handler: LinkHandler) {
23    cx.set_global(Installed(handler));
24}
25
26pub(crate) fn open(url: &str, base: Option<&Path>, window: &mut Window, cx: &mut App) {
27    match cx.try_global::<Installed>().map(|installed| installed.0) {
28        Some(handler) => handler(url, base, window, cx),
29        None => cx.open_url(url),
30    }
31}
32
33/// The anchor GitHub gives a heading: lowercased, punctuation dropped but for
34/// `-` and `_`, each space a `-`.
35pub fn slug(text: &str) -> String {
36    text.chars()
37        .flat_map(char::to_lowercase)
38        .filter_map(|c| match c {
39            ' ' => Some('-'),
40            '-' | '_' => Some(c),
41            c if c.is_alphanumeric() => Some(c),
42            _ => None,
43        })
44        .collect()
45}
46
47/// The block of the heading `fragment` (without its `#`) names in `doc`. A
48/// slug already taken by an earlier heading gets `-1`, `-2`, … as on GitHub.
49/// The fragment is compared as written: no percent-decoding.
50pub fn heading(doc: &Doc, fragment: &str) -> Option<usize> {
51    anchors(doc)
52        .into_iter()
53        .find_map(|(anchor, block)| (anchor == fragment).then_some(block))
54}
55
56fn anchors(doc: &Doc) -> Vec<(String, usize)> {
57    let mut taken = HashSet::new();
58    doc.blocks
59        .iter()
60        .enumerate()
61        .filter_map(|(ix, block)| match &block.kind {
62            BlockKind::Heading { text, .. } => Some((slug(&text.text), ix)),
63            _ => None,
64        })
65        .map(|(base, ix)| {
66            let anchor = std::iter::once(base.clone())
67                .chain((1..).map(|n| format!("{base}-{n}")))
68                .find(|anchor| !taken.contains(anchor))
69                .expect("an unbounded run of suffixes");
70            taken.insert(anchor.clone());
71            (anchor, ix)
72        })
73        .collect()
74}
75
76/// A document's heading anchors and the host's [`OnJump`], taken together at
77/// render so a click resolves against the document it was painted from.
78#[derive(Clone)]
79pub(crate) struct Jump {
80    to: OnJump,
81    anchors: Rc<[(String, usize)]>,
82}
83
84impl Jump {
85    pub(crate) fn new(doc: &Doc, to: OnJump) -> Self {
86        Self {
87            to,
88            anchors: anchors(doc).into(),
89        }
90    }
91}
92
93/// A clicked link: a `#fragment` jumps to its heading, or does nothing when it
94/// names none or no [`Jump`] is given; anything else goes to [`open`].
95pub(crate) fn follow(
96    url: &str,
97    base: Option<&Path>,
98    jump: Option<&Jump>,
99    window: &mut Window,
100    cx: &mut App,
101) {
102    let Some(fragment) = url.strip_prefix('#') else {
103        open(url, base, window, cx);
104        return;
105    };
106    if let Some(jump) = jump
107        && let Some(&(_, block)) = jump.anchors.iter().find(|(anchor, _)| anchor == fragment)
108    {
109        (jump.to)(block, window, cx);
110    }
111}