1use super::*;
4use gpui::ClickEvent;
5use std::sync::Arc;
6
7const DRAG_SLOP: f64 = 2.0;
10
11#[derive(Default)]
13struct Shown(std::collections::HashMap<gpui::Resource, Arc<gpui::RenderImage>>);
14
15impl gpui::Global for Shown {}
16
17fn 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#[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 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 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 .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 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 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
356pub(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
367pub(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
390pub 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
408pub(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}