Skip to main content

markdown/
link.rs

1//! What a link is to the app: what opens it, what it shows on hover, and
2//! what its chip and its card say.
3//!
4//! Installed once at boot with [`crate::AppExt::set_links`]. Without it a
5//! link goes to [`App::open_url`] and shows nothing on hover. A `#fragment`
6//! link reaches none of it: it names a heading of the document it is in — see
7//! [`heading`].
8
9use std::{collections::HashSet, path::Path, rc::Rc};
10
11use gpui::{AnyElement, App, Global, Window};
12
13use crate::{BlockKind, Doc, Form, Preview, render::OnJump};
14
15/// A link where it is written.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct Link<'a> {
19    /// As written.
20    pub url: &'a str,
21    /// The document's [`crate::render::Editing::base`].
22    pub base: Option<&'a Path>,
23}
24
25impl<'a> Link<'a> {
26    pub fn new(url: &'a str, base: Option<&'a Path>) -> Self {
27        Self { url, base }
28    }
29
30    /// Whether it names a web page: `http://` or `https://`.
31    pub fn is_web(&self) -> bool {
32        let scheme = self.url.split_once("://").map_or("", |(scheme, _)| scheme);
33        scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
34    }
35}
36
37/// The app's side of every link a document paints.
38///
39/// Associated functions rather than methods: [`crate::AppExt::set_links`]
40/// keeps each one as a plain function pointer.
41pub trait Links: 'static {
42    /// Opens a link a reader clicked: an inline link or a bookmark card.
43    fn open(link: Link<'_>, _window: &mut Window, cx: &mut App) {
44        cx.open_url(link.url);
45    }
46
47    /// What a chip, a bookmark and an embed say about the link. Asked at
48    /// paint. `None` for a link the app has nothing for *yet*: the bookmark
49    /// paints its host, and repaints when the app notifies.
50    fn preview(_link: Link<'_>, _cx: &App) -> Option<Preview> {
51        None
52    }
53
54    /// The card painted for a bookmark block, in place of the bookmark.
55    /// `None` leaves the bookmark.
56    ///
57    /// The element sits inside [`crate::render::PAINTED_CONTEXT`]. A press it
58    /// does not stop reaches the editor.
59    fn card(
60        _link: Link<'_>,
61        _form: Form,
62        _window: &mut Window,
63        _cx: &mut App,
64    ) -> Option<AnyElement> {
65        None
66    }
67
68    /// The card shown while the pointer rests on the link, in a tooltip:
69    /// nothing in it scrolls or takes a press. `None` shows nothing.
70    fn hover(link: Link<'_>, _window: &mut Window, cx: &mut App) -> Option<AnyElement> {
71        link.is_web()
72            .then(|| crate::render::hover_bookmark(link, cx))
73    }
74}
75
76/// [`Links`] with nothing installed.
77struct Unhandled;
78
79impl Links for Unhandled {}
80
81#[derive(Clone, Copy)]
82struct Installed {
83    open: fn(Link<'_>, &mut Window, &mut App),
84    preview: fn(Link<'_>, &App) -> Option<Preview>,
85    card: fn(Link<'_>, Form, &mut Window, &mut App) -> Option<AnyElement>,
86    hover: fn(Link<'_>, &mut Window, &mut App) -> Option<AnyElement>,
87}
88
89impl Global for Installed {}
90
91impl Installed {
92    fn of<L: Links>() -> Self {
93        Self {
94            open: L::open,
95            preview: L::preview,
96            card: L::card,
97            hover: L::hover,
98        }
99    }
100
101    fn get(cx: &App) -> Self {
102        cx.try_global::<Self>()
103            .copied()
104            .unwrap_or_else(Self::of::<Unhandled>)
105    }
106}
107
108pub(crate) fn set_links<L: Links>(cx: &mut App) {
109    cx.set_global(Installed::of::<L>());
110}
111
112pub(crate) fn open(link: Link<'_>, window: &mut Window, cx: &mut App) {
113    (Installed::get(cx).open)(link, window, cx)
114}
115
116pub(crate) fn preview(link: Link<'_>, cx: &App) -> Option<Preview> {
117    (Installed::get(cx).preview)(link, cx)
118}
119
120pub(crate) fn card(
121    link: Link<'_>,
122    form: Form,
123    window: &mut Window,
124    cx: &mut App,
125) -> Option<AnyElement> {
126    (Installed::get(cx).card)(link, form, window, cx)
127}
128
129pub(crate) fn hover(link: Link<'_>, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
130    (Installed::get(cx).hover)(link, window, cx)
131}
132
133/// The anchor GitHub gives a heading: lowercased, punctuation dropped but for
134/// `-` and `_`, each space a `-`.
135pub fn slug(text: &str) -> String {
136    text.chars()
137        .flat_map(char::to_lowercase)
138        .filter_map(|c| match c {
139            ' ' => Some('-'),
140            '-' | '_' => Some(c),
141            c if c.is_alphanumeric() => Some(c),
142            _ => None,
143        })
144        .collect()
145}
146
147/// The block of the heading `fragment` (without its `#`) names in `doc`. A
148/// slug already taken by an earlier heading gets `-1`, `-2`, … as on GitHub.
149/// The fragment is compared as written: no percent-decoding.
150pub fn heading(doc: &Doc, fragment: &str) -> Option<usize> {
151    anchors(doc)
152        .into_iter()
153        .find_map(|(anchor, block)| (anchor == fragment).then_some(block))
154}
155
156fn anchors(doc: &Doc) -> Vec<(String, usize)> {
157    let mut taken = HashSet::new();
158    doc.blocks
159        .iter()
160        .enumerate()
161        .filter_map(|(ix, block)| match &block.kind {
162            BlockKind::Heading { text, .. } => Some((slug(&text.text), ix)),
163            _ => None,
164        })
165        .map(|(base, ix)| {
166            let anchor = std::iter::once(base.clone())
167                .chain((1..).map(|n| format!("{base}-{n}")))
168                .find(|anchor| !taken.contains(anchor))
169                .expect("an unbounded run of suffixes");
170            taken.insert(anchor.clone());
171            (anchor, ix)
172        })
173        .collect()
174}
175
176/// A document's heading anchors and the host's [`OnJump`], taken together at
177/// render so a click resolves against the document it was painted from.
178#[derive(Clone)]
179pub(crate) struct Jump {
180    to: OnJump,
181    anchors: Rc<[(String, usize)]>,
182}
183
184impl Jump {
185    pub(crate) fn new(doc: &Doc, to: OnJump) -> Self {
186        Self {
187            to,
188            anchors: anchors(doc).into(),
189        }
190    }
191}
192
193/// A clicked link: a `#fragment` jumps to its heading, or does nothing when it
194/// names none or no [`Jump`] is given; anything else goes to [`open`].
195pub(crate) fn follow(link: Link<'_>, jump: Option<&Jump>, window: &mut Window, cx: &mut App) {
196    let Some(fragment) = link.url.strip_prefix('#') else {
197        open(link, window, cx);
198        return;
199    };
200    if let Some(jump) = jump
201        && let Some(&(_, block)) = jump.anchors.iter().find(|(anchor, _)| anchor == fragment)
202    {
203        (jump.to)(block, window, cx);
204    }
205}