Skip to main content

markdown/render/
media.rs

1//! Images and bookmark cards.
2
3use super::*;
4use gpui::ClickEvent;
5use std::sync::Arc;
6
7/// How far a press may travel before its release is a drag, not a click on a
8/// picture.
9const DRAG_SLOP: f64 = 2.0;
10
11/// The last image each picture on disk or the web decoded to.
12#[derive(Default)]
13struct Shown(std::collections::HashMap<gpui::Resource, Arc<gpui::RenderImage>>);
14
15impl gpui::Global for Shown {}
16
17/// `source` as it should paint this frame: what it loaded to, or while it is
18/// loading again — its cached copy dropped because the file changed — or
19/// failing on a half-written file, the image it last loaded to. A picture
20/// never loaded yet paints as gpui paints any loading image.
21fn steady(source: ImageSource, window: &mut Window, cx: &mut App) -> ImageSource {
22    let ImageSource::Resource(resource) = &source else {
23        return source;
24    };
25    match window.use_asset::<gpui::ImgResourceLoader>(resource, cx) {
26        Some(Ok(image)) => {
27            cx.default_global::<Shown>()
28                .0
29                .insert(resource.clone(), image.clone());
30            ImageSource::Render(image)
31        }
32        _ => match cx.default_global::<Shown>().0.get(resource) {
33            Some(image) => ImageSource::Render(image.clone()),
34            None => source,
35        },
36    }
37}
38
39/// A picture and the caption under it, which is the alt text a caret can reach.
40///
41/// The caption row appears when there is something to read or somewhere to
42/// type, so a document being read is not a column of pictures each trailing a
43/// blank line. With no URL yet the picture is a dashed row instead — the shape
44/// the slash menu makes, waiting to be told what to show.
45#[expect(
46    clippy::too_many_arguments,
47    reason = "a picture, its overlay, and what paints them"
48)]
49pub(super) fn image(
50    url: &str,
51    alt: &Text,
52    width: Option<u32>,
53    overlay: Overlay,
54    typography: &Typography,
55    theme: &Theme,
56    window: &mut Window,
57    cx: &mut App,
58) -> AnyElement {
59    let hint = SharedString::new_static(CAPTION_HINT);
60    let overlay = Overlay {
61        placeholder: Some(&hint),
62        ..overlay.at(Part::Caption)
63    };
64    let picture = if url.is_empty() {
65        div()
66            .cursor(CursorStyle::Arrow)
67            .h(px(IMAGE_EMPTY_HEIGHT))
68            .flex()
69            .items_center()
70            .px(px(CARD_PADDING))
71            .rounded(px(Theme::button_radius()))
72            .border_1()
73            .border_dashed()
74            .border_color(theme.border)
75            .text_size(px(typography.body.size()))
76            .text_color(theme.text_muted)
77            .child(IMAGE_EMPTY)
78            .into_any_element()
79    } else {
80        let picture = img(steady(image_source(url, overlay.base), window, cx));
81        let ix = overlay.block;
82        let box_ = div()
83            .id(ElementId::named_usize("md-picture", ix))
84            .cursor(CursorStyle::Arrow)
85            .relative()
86            .rounded(px(Theme::button_radius()))
87            .overflow_hidden()
88            .border_1()
89            .border_color(theme.border)
90            .when_some(overlay.image.cloned(), |el, on_image| {
91                el.on_click(move |event, window, cx| {
92                    if let ClickEvent::Mouse(click) = event
93                        && (click.up.position - click.down.position).magnitude() > DRAG_SLOP
94                    {
95                        return;
96                    }
97                    on_image(ix, window, cx);
98                })
99            })
100            .when_some(overlay.image_menu.cloned(), |el, on_menu| {
101                el.on_mouse_down(MouseButton::Right, move |event, window, cx| {
102                    cx.stop_propagation();
103                    on_menu(ix, event.position, window, cx);
104                })
105            })
106            .children(overlay.layouts.map(|layouts| {
107                let layouts = layouts.clone();
108                canvas(
109                    move |bounds, _, _| layouts.record_picture(ix, bounds),
110                    |_, _, _, _| (),
111                )
112                .absolute()
113                .size_full()
114            }));
115        let box_ = match width {
116            // A stated width is the box's: it hugs, so the border is around
117            // the picture rather than around the column beside it, and the
118            // picture fills what the box settled on — which `max_w_full`
119            // holds inside the page however wide the width was written.
120            Some(width) => box_
121                .self_start()
122                .max_w_full()
123                .w(px(width as f32))
124                .child(picture.w(px(width as f32)).max_w_full()),
125            // Unstated, the box hugs the picture at its own size, held inside
126            // the page.
127            None => box_.self_start().max_w_full().child(picture.max_w_full()),
128        };
129        match overlay
130            .image_overlay
131            .and_then(|build| build(ix, url, window, cx))
132        {
133            Some(control) => {
134                let group = SharedString::from(format!("md-picture-overlay-{ix}"));
135                box_.group(group.clone())
136                    .child(
137                        div()
138                            .id("image-overlay")
139                            .absolute()
140                            .map(|el| match overlay.image_overlay_corner {
141                                ImageOverlayCorner::TopLeft => el.top(px(6.0)).left(px(6.0)),
142                                ImageOverlayCorner::TopRight => el.top(px(6.0)).right(px(6.0)),
143                                ImageOverlayCorner::BottomLeft => el.bottom(px(6.0)).left(px(6.0)),
144                                ImageOverlayCorner::BottomRight => {
145                                    el.bottom(px(6.0)).right(px(6.0))
146                                }
147                            })
148                            .invisible()
149                            .group_hover(group, |style| style.visible())
150                            .on_any_mouse_down(|_, _, cx| cx.stop_propagation())
151                            .on_click(|_, _, cx| cx.stop_propagation())
152                            .child(control),
153                    )
154                    .into_any_element()
155            }
156            None => box_.into_any_element(),
157        }
158    };
159    div()
160        .flex()
161        .flex_col()
162        .gap(px(CAPTION_GAP))
163        .child(picture)
164        // An empty caption still paints while the caret is in it, or there
165        // would be nothing to type into and no hint saying so.
166        .when(
167            overlay.caption == Caption::Shown && (!alt.is_empty() || overlay.caret().is_some()),
168            |el| {
169                el.child(text_element(
170                    alt,
171                    typography.caption.size(),
172                    typography.caption.line_height(),
173                    FontWeight::NORMAL,
174                    overlay,
175                    theme,
176                    cx,
177                ))
178            },
179        )
180        .into_any_element()
181}
182
183/// A bookmark, in Notion's proportions: a fixed-height row with the text on the
184/// left and an image panel of a fixed width on the right, all of it one click
185/// target. [`Form::Embed`] turns the row into a column and gives the image the
186/// card's full width instead.
187///
188/// The row is a fixed height with its footer pinned to the bottom, because a
189/// preview resolves *after* the card has painted — a blurb arriving into a box
190/// that grows would shove every block below it down the page. An embed's cover
191/// holds that height, so its text hugs.
192pub(super) fn bookmark(
193    ix: usize,
194    url: &str,
195    base: Option<&Path>,
196    form: Form,
197    typography: &Typography,
198    theme: &Theme,
199    cx: &App,
200) -> AnyElement {
201    let preview = crate::app::link::preview(crate::Link::new(url, base), cx).unwrap_or_default();
202    let host = SharedString::from(preview::host(url).to_string());
203    let label = preview.label.clone().unwrap_or_else(|| host.clone());
204    let title = preview
205        .title
206        .clone()
207        .unwrap_or_else(|| SharedString::from(url.to_string()));
208
209    // Owned, because the image panel's fallback outlives this call: gpui asks
210    // for the replacement element only once the fetch has failed.
211    let (icon, muted, wash) = (preview.icon.clone(), theme.text_muted, theme.element_hover);
212    let site = host.clone();
213    let mark = move |size: f32| {
214        let host = site.clone();
215        match icon.clone() {
216            Some(icon) => img(icon)
217                .size(px(size))
218                .rounded(px(size / 4.0))
219                .with_fallback(move || initial(&host, size, muted, wash))
220                .into_any_element(),
221            None => initial(&host, size, muted, wash),
222        }
223    };
224
225    let words = div()
226        .flex()
227        .flex_col()
228        .min_w_0()
229        .px(px(CARD_PADDING))
230        .py(px(CARD_PADDING - 2.0))
231        .child(
232            div()
233                .truncate()
234                .text_size(px(typography.body.size()))
235                .line_height(px(typography.body.line_height()))
236                .text_color(theme.text)
237                .child(title),
238        )
239        .children(preview.description.map(|blurb| {
240            div()
241                .line_clamp(2)
242                .text_size(px(typography.card.size()))
243                .line_height(px(typography.card.line_height()))
244                .text_color(theme.text_muted)
245                .child(blurb)
246        }))
247        .child(
248            div()
249                .mt_auto()
250                .pt(px(6.0))
251                .flex()
252                .items_center()
253                .gap(px(6.0))
254                .text_size(px(typography.card.size()))
255                .text_color(theme.text_muted)
256                .child(mark(CARD_ICON))
257                .child(div().truncate().child(label)),
258        );
259
260    let picture = corners(div(), form)
261        .bg(theme.surface)
262        .flex()
263        .items_center()
264        .justify_center()
265        .overflow_hidden()
266        .child(match preview.image {
267            Some(image) => corners(img(image).size_full().object_fit(ObjectFit::Cover), form)
268                .with_fallback(move || mark(CARD_COVER))
269                .into_any_element(),
270            None => mark(CARD_COVER),
271        });
272
273    let open = url.to_string();
274    let base = base.map(Path::to_path_buf);
275    let card = div()
276        .id(ElementId::named_usize("md-bookmark", ix))
277        .flex()
278        .w_full()
279        .overflow_hidden()
280        .rounded(px(Theme::button_radius()))
281        .border(px(CARD_BORDER))
282        .border_color(theme.border)
283        .bg(theme.surface_card)
284        .cursor(CursorStyle::PointingHand)
285        .hover(|el| el.bg(theme::over(theme.element_hover, theme.surface_card)))
286        .on_click(move |_, window, cx| {
287            crate::app::link::open(crate::Link::new(&open, base.as_deref()), window, cx)
288        });
289
290    if matches!(form, Form::Embed(_)) {
291        card.flex_col()
292            .child(picture.w_full().h(px(CARD_COVER_HEIGHT)))
293            .child(words.w_full())
294    } else {
295        card.h(px(CARD_HEIGHT))
296            .child(words.flex_1())
297            .child(picture.flex_none().w(px(CARD_IMAGE_WIDTH)).h_full())
298    }
299    .into_any_element()
300}
301
302/// The card's corners, on the panel that reaches them: a content mask is a
303/// rectangle, so a picture paints square over a rounded card unless it carries
304/// the radius itself, concentric inside the card's border.
305pub(super) fn corners<T: Styled>(element: T, form: Form) -> T {
306    let corner = px(Theme::inset_radius(Theme::button_radius(), CARD_BORDER));
307    match form {
308        Form::Embed(_) => element.rounded_t(corner),
309        _ => element.rounded_r(corner),
310    }
311}
312
313/// The mark a site gets before anyone has fetched its favicon: its host's first
314/// letter, which is a placeholder no icon set has to ship.
315pub(super) fn initial(host: &str, size: f32, color: Hsla, wash: Hsla) -> AnyElement {
316    div()
317        .flex_none()
318        .size(px(size))
319        .rounded(px(size / 4.0))
320        .bg(wash)
321        .flex()
322        .items_center()
323        .justify_center()
324        .text_size(px(size * 0.55))
325        .text_color(color)
326        .child(SharedString::from(
327            host.chars()
328                .next()
329                .unwrap_or('?')
330                .to_uppercase()
331                .to_string(),
332        ))
333        .into_any_element()
334}
335
336/// A web link's hover card: its bookmark, picture on top.
337pub fn hover_bookmark(link: crate::Link<'_>, cx: &App) -> AnyElement {
338    let theme = Theme::of(cx).clone();
339    let card = bookmark(
340        0,
341        link.url,
342        link.base,
343        Form::Embed(None),
344        &cx.typography(),
345        &theme,
346        cx,
347    );
348    div()
349        .w(px(MENTION_CARD_WIDTH))
350        .child(card)
351        .into_any_element()
352}
353
354/// A link's hover card: whatever [`crate::Links::hover`] answers.
355pub(super) struct HoverCard {
356    url: String,
357    base: Option<std::path::PathBuf>,
358}
359
360impl HoverCard {
361    pub(super) fn view(url: &str, base: Option<std::path::PathBuf>, cx: &mut App) -> gpui::AnyView {
362        cx.new(|_| Self {
363            url: url.to_string(),
364            base,
365        })
366        .into()
367    }
368}
369
370impl Render for HoverCard {
371    fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
372        let link = crate::Link::new(&self.url, self.base.as_deref());
373        div().children(crate::app::link::hover(link, window, cx))
374    }
375}