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}