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