Skip to main content

liora_components/
preview.rs

1use crate::gpui_compat::PixelsExt;
2use crate::image::{
3    ImageRoundOptions, ImageSource, RasterImageElement, load_local_render_image,
4    load_remote_render_image,
5};
6use crate::motion::{FadeDirection, MotionDuration, fade, pop_in};
7use gpui::{
8    AnyElement, App, BoxShadow, Component, Global, IntoElement, KeyBinding, ObjectFit, Pixels,
9    RenderImage, RenderOnce, SharedString, Size, Window, actions, div, prelude::*, px, size,
10};
11use liora_core::{Config, push_portal};
12use std::{path::PathBuf, sync::Arc, time::Duration};
13
14actions!(preview, [PreviewClose]);
15
16pub struct Preview {
17    src: Option<ImageSource>,
18    trigger: Option<AnyElement>,
19    hover_effect: bool,
20    close_on_click_outside: bool,
21    close_on_escape: bool,
22}
23
24pub struct ActiveImagePreview {
25    image: Option<Arc<RenderImage>>,
26    closing: bool,
27    close_on_click_outside: bool,
28    close_on_escape: bool,
29}
30
31impl Global for ActiveImagePreview {}
32
33impl Preview {
34    pub fn new(src: impl Into<SharedString>) -> Self {
35        Self {
36            src: Some(ImageSource::from_input(src)),
37            trigger: None,
38            hover_effect: true,
39            close_on_click_outside: true,
40            close_on_escape: true,
41        }
42    }
43
44    pub fn empty() -> Self {
45        Self {
46            src: None,
47            trigger: None,
48            hover_effect: true,
49            close_on_click_outside: true,
50            close_on_escape: true,
51        }
52    }
53
54    pub fn src(mut self, src: impl Into<SharedString>) -> Self {
55        self.src = Some(ImageSource::from_input(src));
56        self
57    }
58
59    pub(crate) fn src_from_image_source(mut self, src: Option<ImageSource>) -> Self {
60        self.src = src;
61        self
62    }
63
64    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
65        self.src = Some(ImageSource::File(path.into()));
66        self
67    }
68
69    pub fn local(path: impl Into<PathBuf>) -> Self {
70        Self::empty().file(path)
71    }
72
73    pub fn child(mut self, trigger: impl IntoElement) -> Self {
74        self.trigger = Some(trigger.into_any_element());
75        self
76    }
77
78    pub fn hover_effect(mut self, enabled: bool) -> Self {
79        self.hover_effect = enabled;
80        self
81    }
82
83    pub fn close_on_escape(mut self, close: bool) -> Self {
84        self.close_on_escape = close;
85        self
86    }
87
88    pub fn close_on_click_outside(mut self, close: bool) -> Self {
89        self.close_on_click_outside = close;
90        self
91    }
92
93    pub fn source(&self) -> Option<&ImageSource> {
94        self.src.as_ref()
95    }
96
97    pub fn has_trigger(&self) -> bool {
98        self.trigger.is_some()
99    }
100
101    pub fn register_key_bindings(cx: &mut App) {
102        cx.bind_keys([KeyBinding::new("escape", PreviewClose, None)]);
103        cx.on_action(|_: &PreviewClose, cx| {
104            close_active_preview_if_escape_enabled(cx);
105        });
106    }
107}
108
109pub fn render_image_preview(window: &mut Window, cx: &mut App) {
110    let Some((image, closing, close_on_click_outside, close_on_escape)) =
111        cx.try_global::<ActiveImagePreview>().and_then(|preview| {
112            preview.image.clone().map(|image| {
113                (
114                    image,
115                    preview.closing,
116                    preview.close_on_click_outside,
117                    preview.close_on_escape,
118                )
119            })
120        })
121    else {
122        return;
123    };
124
125    let theme = cx.global::<Config>().theme.clone();
126    push_portal(
127        move |window, _cx| {
128            let viewport = window.viewport_size();
129            let preview_size =
130                preview_image_box_size(&image, viewport.width * 0.72, viewport.height * 0.72);
131            let overlay_motion = if closing {
132                FadeDirection::Out
133            } else {
134                FadeDirection::In
135            };
136            fade(
137                if closing {
138                    "liora-preview-overlay-exit"
139                } else {
140                    "liora-preview-overlay-enter"
141                },
142                overlay_motion,
143                div()
144                    .absolute()
145                    .top_0()
146                    .left_0()
147                    .size_full()
148                    .flex()
149                    .items_center()
150                    .justify_center()
151                    .bg(gpui::black().opacity(0.55))
152                    .when(close_on_escape, |s| {
153                        s.on_action(|_: &PreviewClose, _, cx| {
154                            close_active_preview(cx);
155                        })
156                    })
157                    .when(close_on_click_outside, |s| {
158                        s.on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
159                            close_active_preview(cx);
160                            cx.stop_propagation();
161                        })
162                    })
163                    .child(pop_in(
164                        if closing {
165                            "liora-preview-frame-exit"
166                        } else {
167                            "liora-preview-frame-enter"
168                        },
169                        div()
170                            .w(preview_size.width)
171                            .h(preview_size.height)
172                            .rounded(px(theme.radius.lg))
173                            .border_1()
174                            .border_color(gpui::white().opacity(0.28))
175                            .overflow_hidden()
176                            .shadow(preview_image_frame_shadow())
177                            .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
178                                cx.stop_propagation();
179                            })
180                            .child(RasterImageElement {
181                                image,
182                                fit: ObjectFit::Contain,
183                                grayscale: false,
184                                radius: px(theme.radius.lg),
185                                round: false,
186                                round_options: ImageRoundOptions::without_square_crop(),
187                            }),
188                    )),
189            )
190            .into_any_element()
191        },
192        cx,
193    );
194
195    let _ = window;
196}
197
198fn close_active_preview_if_escape_enabled(cx: &mut App) {
199    let close_on_escape = cx
200        .try_global::<ActiveImagePreview>()
201        .is_some_and(|preview| preview.close_on_escape);
202    if close_on_escape {
203        close_active_preview(cx);
204    }
205}
206
207fn close_active_preview(cx: &mut App) {
208    if cx.has_global::<ActiveImagePreview>() {
209        let preview = cx.global_mut::<ActiveImagePreview>();
210        if preview.image.is_none() || preview.closing {
211            return;
212        }
213        preview.closing = true;
214        cx.refresh_windows();
215
216        let async_cx = cx.to_async();
217        let executor = cx.background_executor().clone();
218        cx.foreground_executor()
219            .spawn(async move {
220                executor.timer(preview_close_duration()).await;
221                async_cx.update(|cx| {
222                    if cx.has_global::<ActiveImagePreview>() {
223                        let preview = cx.global_mut::<ActiveImagePreview>();
224                        if preview.closing {
225                            preview.image = None;
226                            preview.closing = false;
227                            cx.refresh_windows();
228                        }
229                    }
230                });
231            })
232            .detach();
233    }
234}
235
236fn preview_close_duration() -> Duration {
237    MotionDuration::Fast.as_duration()
238}
239
240fn preview_image_box_size(
241    image: &RenderImage,
242    max_width: Pixels,
243    max_height: Pixels,
244) -> Size<Pixels> {
245    let image_size = image.size(0);
246    let image_width = i32::from(image_size.width).max(1) as f32;
247    let image_height = i32::from(image_size.height).max(1) as f32;
248    let scale = (max_width.as_f32() / image_width).min(max_height.as_f32() / image_height);
249
250    size(px(image_width * scale), px(image_height * scale))
251}
252
253fn preview_image_frame_shadow() -> Vec<BoxShadow> {
254    vec![
255        BoxShadow {
256            color: gpui::black().opacity(0.48),
257            offset: gpui::point(px(0.0), px(28.0)),
258            blur_radius: px(64.0),
259            spread_radius: px(4.0),
260        },
261        BoxShadow {
262            color: gpui::black().opacity(0.34),
263            offset: gpui::point(px(0.0), px(10.0)),
264            blur_radius: px(24.0),
265            spread_radius: px(-2.0),
266        },
267        BoxShadow {
268            color: gpui::white().opacity(0.22),
269            offset: gpui::point(px(0.0), px(-2.0)),
270            blur_radius: px(8.0),
271            spread_radius: px(-4.0),
272        },
273    ]
274}
275
276fn load_preview_image(
277    src: &Option<ImageSource>,
278    window: &mut Window,
279    cx: &mut App,
280) -> Option<Arc<RenderImage>> {
281    match src {
282        Some(ImageSource::File(path)) => load_local_render_image(path),
283        Some(ImageSource::Url(url)) => load_remote_render_image(url.as_ref(), window, cx),
284        None => None,
285    }
286}
287
288impl RenderOnce for Preview {
289    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
290        let theme = cx.global::<Config>().theme.clone();
291        let preview_image = load_preview_image(&self.src, window, cx);
292        let mut trigger = div()
293            .relative()
294            .cursor_pointer()
295            .child(self.trigger.unwrap_or_else(|| div().into_any_element()))
296            .on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
297                if let Some(image) = preview_image.clone() {
298                    if !cx.has_global::<ActiveImagePreview>() {
299                        cx.set_global(ActiveImagePreview {
300                            image: None,
301                            closing: false,
302                            close_on_click_outside: self.close_on_click_outside,
303                            close_on_escape: self.close_on_escape,
304                        });
305                    }
306                    let preview = cx.global_mut::<ActiveImagePreview>();
307                    preview.image = Some(image);
308                    preview.closing = false;
309                    preview.close_on_click_outside = self.close_on_click_outside;
310                    preview.close_on_escape = self.close_on_escape;
311                    cx.refresh_windows();
312                }
313                cx.stop_propagation();
314            });
315
316        if self.hover_effect {
317            trigger = trigger.hover(|s| s.border_color(theme.primary.base).shadow_lg());
318        }
319
320        trigger
321    }
322}
323
324impl IntoElement for Preview {
325    type Element = Component<Self>;
326
327    fn into_element(self) -> Self::Element {
328        Component::new(self)
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn test_render_image(width: u32, height: u32) -> RenderImage {
337        RenderImage::new([::image::Frame::new(::image::RgbaImage::new(width, height))])
338    }
339
340    #[test]
341    fn preview_image_box_size_matches_contained_image_bounds() {
342        let wide = test_render_image(400, 200);
343        let wide_size = preview_image_box_size(&wide, px(300.0), px(300.0));
344        assert_eq!(wide_size.width, px(300.0));
345        assert_eq!(wide_size.height, px(150.0));
346
347        let tall = test_render_image(200, 400);
348        let tall_size = preview_image_box_size(&tall, px(300.0), px(300.0));
349        assert_eq!(tall_size.width, px(150.0));
350        assert_eq!(tall_size.height, px(300.0));
351    }
352
353    #[test]
354    fn preview_overlay_has_escape_close_action_and_image_sized_hitbox() {
355        let source = include_str!("preview.rs");
356        let production = source.split("#[cfg(test)]").next().unwrap();
357
358        assert!(production.contains("actions!(preview, [PreviewClose])"));
359        assert!(production.contains("KeyBinding::new(\"escape\", PreviewClose, None)"));
360        assert!(production.contains("cx.on_action(|_: &PreviewClose"));
361        assert!(production.contains(".on_action(|_: &PreviewClose"));
362        assert!(production.contains("fn close_active_preview"));
363        assert!(production.contains("close_on_click_outside"));
364        assert!(production.contains("pub fn close_on_click_outside("));
365        assert!(production.contains("preview_close_duration"));
366        assert!(production.contains("closing: bool"));
367        assert!(production.contains("fn preview_image_box_size"));
368        assert!(production.contains(".w(preview_size.width)"));
369        assert!(production.contains(".h(preview_size.height)"));
370        assert!(production.contains(".shadow(preview_image_frame_shadow())"));
371        assert!(production.contains("FadeDirection::Out"));
372        assert!(production.contains("pop_in("));
373        assert!(
374            !production.contains(
375                ".w(viewport.width * 0.72)\n                        .h(viewport.height * 0.72)"
376            ),
377            "preview should not consume clicks in the whole max viewport box; only the fitted image box should stop backdrop close"
378        );
379    }
380
381    #[test]
382    fn preview_frame_shadow_keeps_3d_border_depth() {
383        let shadow = preview_image_frame_shadow();
384
385        assert_eq!(shadow.len(), 3);
386        assert_eq!(shadow[0].offset.y, px(28.0));
387        assert_eq!(shadow[0].blur_radius, px(64.0));
388        assert_eq!(shadow[1].offset.y, px(10.0));
389        assert_eq!(shadow[2].offset.y, px(-2.0));
390    }
391}