Skip to main content

markdown/app/
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::{path::Path, rc::Rc};
10
11use gpui::{AnyElement, App, Global, Window};
12
13use crate::{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 block of the heading `fragment` (without its `#`) names in `doc`. A
134/// slug already taken by an earlier heading gets `-1`, `-2`, … as on GitHub.
135/// The fragment is compared as written: no percent-decoding.
136pub fn heading(doc: &Doc, fragment: &str) -> Option<usize> {
137    doc.anchors()
138        .into_iter()
139        .find_map(|(anchor, block)| (anchor == fragment).then_some(block))
140}
141
142/// A document's heading anchors and the host's [`OnJump`], taken together at
143/// render so a click resolves against the document it was painted from.
144#[derive(Clone)]
145pub(crate) struct Jump {
146    to: OnJump,
147    anchors: Rc<[(String, usize)]>,
148}
149
150impl Jump {
151    pub(crate) fn new(doc: &Doc, to: OnJump) -> Self {
152        Self {
153            to,
154            anchors: doc.anchors().into(),
155        }
156    }
157}
158
159/// A clicked link: a `#fragment` jumps to its heading, or does nothing when it
160/// names none or no [`Jump`] is given; anything else goes to [`open`].
161pub(crate) fn follow(link: Link<'_>, jump: Option<&Jump>, window: &mut Window, cx: &mut App) {
162    let Some(fragment) = link.url.strip_prefix('#') else {
163        open(link, window, cx);
164        return;
165    };
166    if let Some(jump) = jump
167        && let Some(&(_, block)) = jump.anchors.iter().find(|(anchor, _)| anchor == fragment)
168    {
169        (jump.to)(block, window, cx);
170    }
171}