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
183pub(super) fn bookmark(
184    ix: usize,
185    url: &str,
186    base: Option<&Path>,
187    form: Form,
188    typography: &Typography,
189    theme: &Theme,
190    cx: &App,
191) -> AnyElement {
192    Bookmark {
193        link: crate::Link::new(url, base),
194        form,
195        typography: *typography,
196        height: None,
197    }
198    .element(ix, theme, cx)
199}
200
201pub struct Bookmark<'a> {
202    pub link: crate::Link<'a>,
203    pub form: Form,
204    pub typography: Typography,
205    /// A bounded card's total height in screen pixels.
206    pub height: Option<Pixels>,
207}
208
209impl Bookmark<'_> {
210    pub fn render(self, cx: &App) -> AnyElement {
211        self.element(0, Theme::of(cx), cx)
212    }
213
214    fn element(self, ix: usize, theme: &Theme, cx: &App) -> AnyElement {
215        let Self {
216            link,
217            form,
218            typography,
219            height,
220        } = self;
221        let crate::Link { url, base, .. } = link;
222        let scale = typography.body.size() / cx.typography().body.size();
223        let preview =
224            crate::app::link::preview(crate::Link::new(url, base), cx).unwrap_or_default();
225        let host = SharedString::from(preview::host(url).to_string());
226        let label = preview.label.clone().unwrap_or_else(|| host.clone());
227        let title = preview
228            .title
229            .clone()
230            .unwrap_or_else(|| SharedString::from(url.to_string()));
231
232        // Owned, because the image panel's fallback outlives this call: gpui asks
233        // for the replacement element only once the fetch has failed.
234        let (icon, muted, wash) = (preview.icon.clone(), theme.text_muted, theme.element_hover);
235        let site = host.clone();
236        let mark = move |size: f32| {
237            let host = site.clone();
238            match icon.clone() {
239                Some(icon) => img(icon)
240                    .size(px(size))
241                    .rounded(px(size / 4.0))
242                    .with_fallback(move || initial(&host, size, muted, wash))
243                    .into_any_element(),
244                None => initial(&host, size, muted, wash),
245            }
246        };
247
248        let padding = (CARD_PADDING - 2.0) * scale * 2.0 + CARD_BORDER * 2.0;
249        let footer = typography.card.line_height() + 6.0 * scale;
250        let title_lines = if height.is_some_and(|height| {
251            height.as_f32() < padding + footer + typography.body.line_height() * 2.0
252        }) {
253            1
254        } else {
255            2
256        };
257        let show_description = height.is_none_or(|height| {
258            height.as_f32()
259                >= padding
260                    + footer
261                    + typography.body.line_height() * title_lines as f32
262                    + typography.card.line_height() * 2.0
263        });
264        let words = div()
265            .flex()
266            .flex_col()
267            .min_w_0()
268            .px(px(CARD_PADDING * scale))
269            .py(px((CARD_PADDING - 2.0) * scale))
270            .child(
271                div()
272                    .when(height.is_none(), |el| el.truncate())
273                    .when(height.is_some(), |el| el.line_clamp(title_lines))
274                    .text_size(px(typography.body.size()))
275                    .line_height(px(typography.body.line_height()))
276                    .text_color(theme.text)
277                    .child(title),
278            )
279            .children(
280                preview
281                    .description
282                    .filter(|_| show_description)
283                    .map(|blurb| {
284                        div()
285                            .line_clamp(2)
286                            .text_size(px(typography.card.size()))
287                            .line_height(px(typography.card.line_height()))
288                            .text_color(theme.text_muted)
289                            .child(blurb)
290                    }),
291            )
292            .child(
293                div()
294                    .mt_auto()
295                    .flex_none()
296                    .pt(px(6.0 * scale))
297                    .flex()
298                    .items_center()
299                    .gap(px(6.0 * scale))
300                    .text_size(px(typography.card.size()))
301                    .text_color(theme.text_muted)
302                    .child(mark(CARD_ICON * scale))
303                    .child(div().truncate().child(label)),
304            );
305
306        let picture = corners(div(), form)
307            .bg(theme.surface)
308            .flex()
309            .items_center()
310            .justify_center()
311            .overflow_hidden()
312            .child(match preview.image {
313                Some(image) => corners(img(image).size_full().object_fit(ObjectFit::Cover), form)
314                    .with_fallback(move || mark(CARD_COVER * scale))
315                    .into_any_element(),
316                None => mark(CARD_COVER * scale),
317            });
318
319        let open = url.to_string();
320        let base = base.map(Path::to_path_buf);
321        let card = div()
322            .id(ElementId::named_usize("md-bookmark", ix))
323            .flex()
324            .w_full()
325            .overflow_hidden()
326            .rounded(px(Theme::button_radius()))
327            .border(px(CARD_BORDER))
328            .border_color(theme.border)
329            .bg(theme.surface_card)
330            .cursor(CursorStyle::PointingHand)
331            .hover(|el| el.bg(theme::over(theme.element_hover, theme.surface_card)))
332            .on_click(move |_, window, cx| {
333                crate::app::link::open(crate::Link::new(&open, base.as_deref()), window, cx)
334            });
335
336        if matches!(form, Form::Embed(_)) {
337            card.flex_col()
338                .when_some(height, |el, height| el.h(height))
339                .child(
340                    picture
341                        .w_full()
342                        .min_h_0()
343                        .when(height.is_some(), |el| el.flex_1())
344                        .when(height.is_none(), |el| el.h(px(CARD_COVER_HEIGHT * scale))),
345                )
346                .child(words.w_full().flex_none())
347        } else {
348            card.h(height.unwrap_or_else(|| px(CARD_HEIGHT * scale)))
349                .child(words.flex_1())
350                .child(picture.flex_none().w(px(CARD_IMAGE_WIDTH * scale)).h_full())
351        }
352        .into_any_element()
353    }
354}
355
356/// The card's corners, on the panel that reaches them: a content mask is a
357/// rectangle, so a picture paints square over a rounded card unless it carries
358/// the radius itself, concentric inside the card's border.
359pub(super) fn corners<T: Styled>(element: T, form: Form) -> T {
360    let corner = px(Theme::inset_radius(Theme::button_radius(), CARD_BORDER));
361    match form {
362        Form::Embed(_) => element.rounded_t(corner),
363        _ => element.rounded_r(corner),
364    }
365}
366
367/// The mark a site gets before anyone has fetched its favicon: its host's first
368/// letter, which is a placeholder no icon set has to ship.
369pub(super) fn initial(host: &str, size: f32, color: Hsla, wash: Hsla) -> AnyElement {
370    div()
371        .flex_none()
372        .size(px(size))
373        .rounded(px(size / 4.0))
374        .bg(wash)
375        .flex()
376        .items_center()
377        .justify_center()
378        .text_size(px(size * 0.55))
379        .text_color(color)
380        .child(SharedString::from(
381            host.chars()
382                .next()
383                .unwrap_or('?')
384                .to_uppercase()
385                .to_string(),
386        ))
387        .into_any_element()
388}
389
390/// A web link's hover card: its bookmark, picture on top.
391pub fn hover_bookmark(link: crate::Link<'_>, cx: &App) -> AnyElement {
392    let theme = Theme::of(cx).clone();
393    let card = bookmark(
394        0,
395        link.url,
396        link.base,
397        Form::Embed(None),
398        &cx.typography(),
399        &theme,
400        cx,
401    );
402    div()
403        .w(px(MENTION_CARD_WIDTH))
404        .child(card)
405        .into_any_element()
406}
407
408/// A link's hover card: whatever [`crate::Links::hover`] answers.
409pub(super) struct HoverCard {
410    url: String,
411    base: Option<std::path::PathBuf>,
412}
413
414impl HoverCard {
415    pub(super) fn view(url: &str, base: Option<std::path::PathBuf>, cx: &mut App) -> gpui::AnyView {
416        cx.new(|_| Self {
417            url: url.to_string(),
418            base,
419        })
420        .into()
421    }
422}
423
424impl Render for HoverCard {
425    fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
426        let link = crate::Link::new(&self.url, self.base.as_deref());
427        div().children(crate::app::link::hover(link, window, cx))
428    }
429}