1use crate::preview::Preview;
2pub use crate::preview::render_image_preview;
3use gpui::{
4 AnyElement, App, Bounds, Component, Corners, Element, ElementId, GlobalElementId, Hsla,
5 InspectorElementId, IntoElement, LayoutId, ObjectFit, Pixels, RenderImage, RenderOnce,
6 SharedString, Style, Window, div, prelude::*, px, relative,
7};
8use liora_core::Config;
9use liora_icons::Icon;
10use liora_icons_lucide::IconName;
11use std::{
12 collections::HashMap,
13 io::Read,
14 path::{Path, PathBuf},
15 sync::{Arc, Mutex, OnceLock},
16};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum ImageFit {
20 Fill,
21 #[default]
22 Contain,
23 Cover,
24 ScaleDown,
25 None,
26}
27
28impl ImageFit {
29 pub fn as_object_fit(self) -> ObjectFit {
30 match self {
31 ImageFit::Fill => ObjectFit::Fill,
32 ImageFit::Contain => ObjectFit::Contain,
33 ImageFit::Cover => ObjectFit::Cover,
34 ImageFit::ScaleDown => ObjectFit::ScaleDown,
35 ImageFit::None => ObjectFit::None,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum ImageRadius {
42 None,
43 Small,
44 #[default]
45 Medium,
46 Large,
47 Round,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct ImageRing {
52 pub width: Pixels,
53 pub color: Hsla,
54}
55
56impl ImageRing {
57 pub fn new(width: impl Into<Pixels>, color: impl Into<Hsla>) -> Self {
58 Self {
59 width: width.into(),
60 color: color.into(),
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub struct ImageRoundOptions {
67 pub crop_to_square: bool,
68 pub ring: Option<ImageRing>,
69}
70
71impl Default for ImageRoundOptions {
72 fn default() -> Self {
73 Self::circle()
74 }
75}
76
77impl ImageRoundOptions {
78 pub fn circle() -> Self {
79 Self {
80 crop_to_square: true,
81 ring: None,
82 }
83 }
84
85 pub fn without_square_crop() -> Self {
86 Self {
87 crop_to_square: false,
88 ring: None,
89 }
90 }
91
92 pub fn ring(mut self, ring: ImageRing) -> Self {
93 self.ring = Some(ring);
94 self
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ImageSource {
100 Url(SharedString),
101 File(PathBuf),
102}
103
104impl ImageSource {
105 pub fn from_input(input: impl Into<SharedString>) -> Self {
106 let input = input.into();
107 if let Some(path) = parse_file_protocol(input.as_ref()) {
108 ImageSource::File(path)
109 } else {
110 ImageSource::Url(input)
111 }
112 }
113
114 pub fn is_file(&self) -> bool {
115 matches!(self, ImageSource::File(_))
116 }
117
118 pub fn is_url(&self) -> bool {
119 matches!(self, ImageSource::Url(_))
120 }
121}
122
123pub struct Image {
124 src: Option<ImageSource>,
125 alt: Option<SharedString>,
126 width: Option<Pixels>,
127 height: Option<Pixels>,
128 fit: ImageFit,
129 radius: ImageRadius,
130 bordered: bool,
131 shadow: bool,
132 grayscale: bool,
133 preview: bool,
134 round_options: ImageRoundOptions,
135 placeholder: Option<Arc<dyn Fn() -> AnyElement + 'static>>,
136 fallback: Option<Arc<dyn Fn() -> AnyElement + 'static>>,
137}
138
139impl Image {
140 pub fn new(src: impl Into<SharedString>) -> Self {
141 Self {
142 src: Some(ImageSource::from_input(src)),
143 alt: None,
144 width: None,
145 height: None,
146 fit: ImageFit::Contain,
147 radius: ImageRadius::Medium,
148 bordered: true,
149 shadow: false,
150 grayscale: false,
151 preview: false,
152 round_options: ImageRoundOptions::default(),
153 placeholder: None,
154 fallback: None,
155 }
156 }
157
158 pub fn empty() -> Self {
159 Self {
160 src: None,
161 alt: None,
162 width: None,
163 height: None,
164 fit: ImageFit::Contain,
165 radius: ImageRadius::Medium,
166 bordered: true,
167 shadow: false,
168 grayscale: false,
169 preview: false,
170 round_options: ImageRoundOptions::default(),
171 placeholder: None,
172 fallback: None,
173 }
174 }
175
176 pub fn src(mut self, src: impl Into<SharedString>) -> Self {
177 self.src = Some(ImageSource::from_input(src));
178 self
179 }
180
181 pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
182 self.src = Some(ImageSource::File(path.into()));
183 self
184 }
185
186 pub fn local(path: impl Into<PathBuf>) -> Self {
187 Self::empty().file(path)
188 }
189
190 pub fn alt(mut self, alt: impl Into<SharedString>) -> Self {
191 self.alt = Some(alt.into());
192 self
193 }
194
195 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
196 self.width = Some(width.into());
197 self
198 }
199
200 pub fn height(mut self, height: impl Into<Pixels>) -> Self {
201 self.height = Some(height.into());
202 self
203 }
204
205 pub fn size(mut self, width: impl Into<Pixels>, height: impl Into<Pixels>) -> Self {
206 self.width = Some(width.into());
207 self.height = Some(height.into());
208 self
209 }
210
211 pub fn thumbnail(self) -> Self {
212 self.size(px(180.0), px(120.0))
213 }
214
215 pub fn thumbnail_sm(self) -> Self {
216 self.size(px(132.0), px(88.0))
217 }
218
219 pub fn square(mut self, size: impl Into<Pixels>) -> Self {
220 let size = size.into();
221 self.width = Some(size);
222 self.height = Some(size);
223 self
224 }
225
226 pub fn square_lg(self) -> Self {
227 self.square(px(96.0))
228 }
229
230 pub fn fit(mut self, fit: ImageFit) -> Self {
231 self.fit = fit;
232 self
233 }
234
235 pub fn fill(mut self) -> Self {
236 self.fit = ImageFit::Fill;
237 self
238 }
239
240 pub fn contain(mut self) -> Self {
241 self.fit = ImageFit::Contain;
242 self
243 }
244
245 pub fn cover(mut self) -> Self {
246 self.fit = ImageFit::Cover;
247 self
248 }
249
250 pub fn scale_down(mut self) -> Self {
251 self.fit = ImageFit::ScaleDown;
252 self
253 }
254
255 pub fn radius(mut self, radius: ImageRadius) -> Self {
256 self.radius = radius;
257 self
258 }
259
260 pub fn no_radius(mut self) -> Self {
261 self.radius = ImageRadius::None;
262 self
263 }
264
265 pub fn round(mut self) -> Self {
266 self.radius = ImageRadius::Round;
267 self.round_options = ImageRoundOptions::default();
268 self
269 }
270
271 pub fn round_options(mut self, options: ImageRoundOptions) -> Self {
272 self.radius = ImageRadius::Round;
273 self.round_options = options;
274 self
275 }
276
277 pub fn round_ring(mut self, ring: ImageRing) -> Self {
278 self.radius = ImageRadius::Round;
279 self.round_options = self.round_options.ring(ring);
280 self
281 }
282
283 pub fn round_sleeve(self) -> Self {
284 self.round_ring(ImageRing::new(px(6.0), gpui::white().opacity(0.72)))
285 }
286
287 pub fn bordered(mut self, bordered: bool) -> Self {
288 self.bordered = bordered;
289 self
290 }
291
292 pub fn no_border(mut self) -> Self {
293 self.bordered = false;
294 self
295 }
296
297 pub fn shadow(mut self, shadow: bool) -> Self {
298 self.shadow = shadow;
299 self
300 }
301
302 pub fn grayscale(mut self, grayscale: bool) -> Self {
303 self.grayscale = grayscale;
304 self
305 }
306
307 pub fn preview(mut self, preview: bool) -> Self {
308 self.preview = preview;
309 self
310 }
311
312 pub fn placeholder<E>(mut self, placeholder: impl Fn() -> E + 'static) -> Self
313 where
314 E: IntoElement,
315 {
316 self.placeholder = Some(Arc::new(move || placeholder().into_any_element()));
317 self
318 }
319
320 pub fn fallback<E>(mut self, fallback: impl Fn() -> E + 'static) -> Self
321 where
322 E: IntoElement,
323 {
324 self.fallback = Some(Arc::new(move || fallback().into_any_element()));
325 self
326 }
327
328 pub fn fit_kind(&self) -> ImageFit {
329 self.fit
330 }
331
332 pub fn radius_kind(&self) -> ImageRadius {
333 self.radius
334 }
335
336 pub fn round_config(&self) -> ImageRoundOptions {
337 self.round_options
338 }
339
340 pub fn dimensions(&self) -> (Option<Pixels>, Option<Pixels>) {
341 (self.width, self.height)
342 }
343
344 pub fn source(&self) -> Option<&ImageSource> {
345 self.src.as_ref()
346 }
347 pub fn preview_enabled(&self) -> bool {
348 self.preview
349 }
350}
351
352impl RenderOnce for Image {
353 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
354 let theme = cx.global::<Config>().theme.clone();
355 let radius = match self.radius {
356 ImageRadius::None => px(0.0),
357 ImageRadius::Small => px(theme.radius.sm),
358 ImageRadius::Medium => px(theme.radius.md),
359 ImageRadius::Large => px(theme.radius.lg),
360 ImageRadius::Round => px(999.0),
361 };
362 let alt = self.alt.clone().unwrap_or_else(|| "Image".into());
363
364 let mut frame = div()
365 .relative()
366 .flex()
367 .items_center()
368 .justify_center()
369 .overflow_hidden()
370 .rounded(radius)
371 .bg(theme.neutral.hover);
372
373 if let Some(width) = self.width {
374 frame = frame.w(width);
375 }
376 if let Some(height) = self.height {
377 frame = frame.h(height);
378 }
379 if self.width.is_none() && self.height.is_none() {
380 frame = frame.w(px(160.0)).h(px(100.0));
381 }
382 if self.bordered {
383 frame = frame.border_1().border_color(theme.neutral.border);
384 }
385 if self.shadow {
386 frame = frame.shadow_md();
387 }
388
389 let preview_src = self.src.clone();
390
391 if let Some(src) = self.src {
392 let raster_image = match &src {
393 ImageSource::File(path) => load_local_render_image(path),
394 ImageSource::Url(url) => load_remote_render_image(url.as_ref(), window, cx),
395 };
396 let loading = self.placeholder.unwrap_or_else({
397 let theme = theme.clone();
398 || Arc::new(move || default_loading(&theme))
399 });
400 let fallback = self.fallback.unwrap_or_else({
401 let theme = theme.clone();
402 || Arc::new(move || default_fallback(&theme, alt.clone()))
403 });
404 if let Some(raster_image) = raster_image {
405 frame = frame.child(div().absolute().top_0().left_0().size_full().child(
406 RasterImageElement {
407 image: raster_image,
408 fit: self.fit.as_object_fit(),
409 grayscale: self.grayscale,
410 radius,
411 round: self.radius == ImageRadius::Round,
412 round_options: self.round_options,
413 },
414 ));
415 } else if matches!(src, ImageSource::Url(_)) {
416 frame = frame.child(loading());
417 } else {
418 frame = frame.child(fallback());
419 }
420 } else {
421 frame = frame.child(
422 self.fallback
423 .map(|fallback| fallback())
424 .unwrap_or_else(|| default_empty(&theme)),
425 );
426 }
427
428 if let Some(ring) = self.round_options.ring
429 && self.radius == ImageRadius::Round
430 {
431 frame = frame.child(
432 div()
433 .absolute()
434 .top_0()
435 .left_0()
436 .size_full()
437 .child(RingSleeveElement { ring }),
438 );
439 }
440
441 if self.preview {
442 frame = frame
443 .cursor_pointer()
444 .hover(|s| s.border_color(theme.primary.base).shadow_lg());
445 return Preview::empty()
446 .src_from_image_source(preview_src)
447 .hover_effect(false)
448 .child(frame)
449 .into_any_element();
450 }
451
452 frame.into_any_element()
453 }
454}
455
456impl IntoElement for Image {
457 type Element = Component<Self>;
458 fn into_element(self) -> Self::Element {
459 Component::new(self)
460 }
461}
462
463fn parse_file_protocol(input: &str) -> Option<PathBuf> {
464 let path = input.strip_prefix("file://")?;
465 if path.is_empty() {
466 return None;
467 }
468 Some(expand_tilde_path(path))
469}
470
471fn expand_tilde_path(path: &str) -> PathBuf {
472 if let Some(rest) = path.strip_prefix("~/")
473 && let Some(home) = std::env::var_os("HOME")
474 {
475 return PathBuf::from(home).join(rest);
476 }
477 PathBuf::from(path)
478}
479
480pub(crate) struct RasterImageElement {
481 pub(crate) image: Arc<RenderImage>,
482 pub(crate) fit: ObjectFit,
483 pub(crate) grayscale: bool,
484 pub(crate) radius: Pixels,
485 pub(crate) round: bool,
486 pub(crate) round_options: ImageRoundOptions,
487}
488
489impl IntoElement for RasterImageElement {
490 type Element = Self;
491
492 fn into_element(self) -> Self::Element {
493 self
494 }
495}
496
497impl Element for RasterImageElement {
498 type RequestLayoutState = ();
499 type PrepaintState = ();
500
501 fn id(&self) -> Option<ElementId> {
502 None
503 }
504
505 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
506 None
507 }
508
509 fn request_layout(
510 &mut self,
511 _: Option<&GlobalElementId>,
512 _: Option<&InspectorElementId>,
513 window: &mut Window,
514 cx: &mut App,
515 ) -> (LayoutId, ()) {
516 let mut style = Style::default();
517 style.size.width = relative(1.0).into();
518 style.size.height = relative(1.0).into();
519 (window.request_layout(style, [], cx), ())
520 }
521
522 fn prepaint(
523 &mut self,
524 _: Option<&GlobalElementId>,
525 _: Option<&InspectorElementId>,
526 _bounds: Bounds<Pixels>,
527 _: &mut (),
528 _window: &mut Window,
529 _cx: &mut App,
530 ) {
531 }
532
533 fn paint(
534 &mut self,
535 _: Option<&GlobalElementId>,
536 _: Option<&InspectorElementId>,
537 bounds: Bounds<Pixels>,
538 _: &mut (),
539 _: &mut (),
540 window: &mut Window,
541 _cx: &mut App,
542 ) {
543 if self.image.frame_count() == 0 {
544 return;
545 }
546 let (image, image_bounds, corner_radii) = if self.round && self.round_options.crop_to_square
547 {
548 let side = bounds.size.width.min(bounds.size.height);
549 let square_bounds = Bounds {
550 origin: gpui::point(
551 bounds.origin.x + (bounds.size.width - side) / 2.0,
552 bounds.origin.y + (bounds.size.height - side) / 2.0,
553 ),
554 size: gpui::size(side, side),
555 };
556 (
557 square_cropped_render_image(&self.image),
558 square_bounds,
559 Corners::all(side / 2.0),
560 )
561 } else {
562 let image_bounds = self.fit.get_bounds(bounds, self.image.size(0));
563 let corner_radii = if self.round {
564 Corners::all(bounds.size.width.min(bounds.size.height) / 2.0)
565 .clamp_radii_for_quad_size(bounds.size)
566 } else {
567 Corners::all(self.radius).clamp_radii_for_quad_size(image_bounds.size)
568 };
569 (self.image.clone(), image_bounds, corner_radii)
570 };
571 let _ = window.paint_image(image_bounds, corner_radii, image, 0, self.grayscale);
572 }
573}
574
575struct RingSleeveElement {
576 ring: ImageRing,
577}
578
579impl IntoElement for RingSleeveElement {
580 type Element = Self;
581
582 fn into_element(self) -> Self::Element {
583 self
584 }
585}
586
587impl Element for RingSleeveElement {
588 type RequestLayoutState = ();
589 type PrepaintState = ();
590
591 fn id(&self) -> Option<ElementId> {
592 None
593 }
594
595 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
596 None
597 }
598
599 fn request_layout(
600 &mut self,
601 _: Option<&GlobalElementId>,
602 _: Option<&InspectorElementId>,
603 window: &mut Window,
604 cx: &mut App,
605 ) -> (LayoutId, ()) {
606 let mut style = Style::default();
607 style.size.width = relative(1.0).into();
608 style.size.height = relative(1.0).into();
609 (window.request_layout(style, [], cx), ())
610 }
611
612 fn prepaint(
613 &mut self,
614 _: Option<&GlobalElementId>,
615 _: Option<&InspectorElementId>,
616 _bounds: Bounds<Pixels>,
617 _: &mut (),
618 _window: &mut Window,
619 _cx: &mut App,
620 ) {
621 }
622
623 fn paint(
624 &mut self,
625 _: Option<&GlobalElementId>,
626 _: Option<&InspectorElementId>,
627 bounds: Bounds<Pixels>,
628 _: &mut (),
629 _: &mut (),
630 window: &mut Window,
631 _cx: &mut App,
632 ) {
633 let side = bounds.size.width.min(bounds.size.height);
634 let sleeve_bounds = Bounds {
635 origin: gpui::point(
636 bounds.origin.x + (bounds.size.width - side) / 2.0,
637 bounds.origin.y + (bounds.size.height - side) / 2.0,
638 ),
639 size: gpui::size(side, side),
640 };
641 window.paint_quad(gpui::PaintQuad {
642 bounds: sleeve_bounds,
643 corner_radii: Corners::all(side / 2.0),
644 background: gpui::transparent_black().into(),
645 border_widths: gpui::Edges::all(self.ring.width),
646 border_color: self.ring.color,
647 border_style: gpui::BorderStyle::Solid,
648 });
649 }
650}
651
652fn square_cropped_image_cache() -> &'static Mutex<HashMap<usize, Arc<RenderImage>>> {
653 static CACHE: OnceLock<Mutex<HashMap<usize, Arc<RenderImage>>>> = OnceLock::new();
654 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
655}
656
657fn square_cropped_render_image(image: &Arc<RenderImage>) -> Arc<RenderImage> {
658 if let Some(cached) = square_cropped_image_cache()
659 .lock()
660 .ok()
661 .and_then(|cache| cache.get(&image.id.0).cloned())
662 {
663 return cached;
664 }
665
666 let Some(bytes) = image.as_bytes(0) else {
667 return image.clone();
668 };
669 let image_size = image.size(0);
670 let width = u32::from(image_size.width);
671 let height = u32::from(image_size.height);
672 let side = width.min(height);
673 if side == 0 {
674 return image.clone();
675 }
676
677 let Some(source) = image::RgbaImage::from_raw(width, height, bytes.to_vec()) else {
678 return image.clone();
679 };
680 let x = (width - side) / 2;
681 let y = (height - side) / 2;
682 let cropped = image::imageops::crop_imm(&source, x, y, side, side).to_image();
683 let cropped = Arc::new(RenderImage::new([image::Frame::new(cropped)]));
684
685 if let Ok(mut cache) = square_cropped_image_cache().lock() {
686 cache.insert(image.id.0, cropped.clone());
687 }
688
689 cropped
690}
691
692#[derive(Clone)]
693enum RemoteImageState {
694 Loading,
695 Ready(Arc<RenderImage>),
696 Failed,
697}
698
699impl RemoteImageState {
700 fn should_request_animation_frame(&self) -> bool {
701 false
702 }
703}
704
705fn remote_image_cache() -> &'static Mutex<HashMap<String, RemoteImageState>> {
706 static CACHE: OnceLock<Mutex<HashMap<String, RemoteImageState>>> = OnceLock::new();
707 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
708}
709
710pub(crate) fn load_remote_render_image(
711 url: &str,
712 window: &mut Window,
713 _cx: &mut App,
714) -> Option<Arc<RenderImage>> {
715 if let Some(cached) = remote_image_cache().lock().ok()?.get(url).cloned() {
716 if cached.should_request_animation_frame() {
717 window.request_animation_frame();
718 }
719 return match cached {
720 RemoteImageState::Ready(image) => Some(image),
721 RemoteImageState::Loading | RemoteImageState::Failed => None,
722 };
723 }
724
725 if let Ok(mut cache) = remote_image_cache().lock() {
726 cache.insert(url.to_string(), RemoteImageState::Loading);
727 }
728
729 let url = url.to_string();
730 let async_cx = _cx.to_async();
731 let executor = _cx.background_executor().clone();
732 _cx.foreground_executor()
733 .spawn(async move {
734 let fetch_url = url.clone();
735 let image = executor
736 .spawn(async move { fetch_remote_render_image(&fetch_url) })
737 .await;
738 let state = image
739 .map(RemoteImageState::Ready)
740 .unwrap_or(RemoteImageState::Failed);
741 if let Ok(mut cache) = remote_image_cache().lock() {
742 cache.insert(url, state);
743 }
744 async_cx.update(|cx| cx.refresh_windows());
745 })
746 .detach();
747 window.request_animation_frame();
748
749 None
750}
751
752fn fetch_remote_render_image(url: &str) -> Option<Arc<RenderImage>> {
753 ureq::get(url).call().ok().and_then(|response| {
754 let mut bytes = Vec::new();
755 response.into_reader().read_to_end(&mut bytes).ok()?;
756 render_image_from_bytes(&bytes)
757 })
758}
759
760fn local_image_cache() -> &'static Mutex<HashMap<PathBuf, Arc<RenderImage>>> {
761 static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<RenderImage>>>> = OnceLock::new();
762 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
763}
764
765pub(crate) fn load_local_render_image(path: &Path) -> Option<Arc<RenderImage>> {
766 if let Some(cached) = local_image_cache()
767 .lock()
768 .ok()
769 .and_then(|cache| cache.get(path).cloned())
770 {
771 return Some(cached);
772 }
773
774 let bytes = std::fs::read(path).ok()?;
775 let image = render_image_from_bytes(&bytes)?;
776 if let Ok(mut cache) = local_image_cache().lock() {
777 cache.insert(path.to_path_buf(), image.clone());
778 }
779 Some(image)
780}
781
782fn render_image_from_bytes(bytes: &[u8]) -> Option<Arc<RenderImage>> {
783 let format = image::guess_format(bytes).ok()?;
784 let mut data = image::load_from_memory_with_format(bytes, format)
785 .ok()?
786 .into_rgba8();
787 for pixel in data.chunks_exact_mut(4) {
788 pixel.swap(0, 2);
789 }
790 Some(Arc::new(RenderImage::new([image::Frame::new(data)])))
791}
792
793fn default_loading(theme: &liora_theme::Theme) -> AnyElement {
794 div()
795 .flex()
796 .flex_col()
797 .items_center()
798 .justify_center()
799 .gap_2()
800 .size_full()
801 .text_color(theme.neutral.text_3)
802 .child(
803 Icon::new(IconName::LoaderCircle)
804 .size(px(20.0))
805 .color(theme.neutral.icon),
806 )
807 .child(div().text_xs().child("Loading"))
808 .into_any_element()
809}
810
811fn default_fallback(theme: &liora_theme::Theme, alt: SharedString) -> AnyElement {
812 div()
813 .flex()
814 .flex_col()
815 .items_center()
816 .justify_center()
817 .gap_2()
818 .size_full()
819 .text_color(theme.neutral.text_3)
820 .child(
821 Icon::new(IconName::ImageOff)
822 .size(px(24.0))
823 .color(theme.neutral.icon),
824 )
825 .child(div().text_xs().child(alt))
826 .into_any_element()
827}
828
829fn default_empty(theme: &liora_theme::Theme) -> AnyElement {
830 div()
831 .flex()
832 .flex_col()
833 .items_center()
834 .justify_center()
835 .gap_2()
836 .size_full()
837 .text_color(theme.neutral.text_3)
838 .child(
839 Icon::new(IconName::Image)
840 .size(px(24.0))
841 .color(theme.neutral.icon),
842 )
843 .child(div().text_xs().child("No image"))
844 .into_any_element()
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850
851 #[test]
852 fn image_thumbnail_sets_preview_dimensions() {
853 let image = Image::new("https://example.com/image.png").thumbnail();
854
855 assert_eq!(image.width, Some(px(180.0)));
856 assert_eq!(image.height, Some(px(120.0)));
857 }
858
859 #[test]
860 fn image_demo_size_helpers_track_common_examples() {
861 let thumbnail_sm = Image::new("https://example.com/image.png").thumbnail_sm();
862 assert_eq!(thumbnail_sm.width, Some(px(132.0)));
863 assert_eq!(thumbnail_sm.height, Some(px(88.0)));
864
865 let square_lg = Image::new("https://example.com/image.png").square_lg();
866 assert_eq!(square_lg.width, Some(px(96.0)));
867 assert_eq!(square_lg.height, Some(px(96.0)));
868 }
869
870 #[test]
871 fn image_round_sleeve_sets_ring_configuration() {
872 let image = Image::new("https://example.com/image.png").round_sleeve();
873
874 assert_eq!(image.radius, ImageRadius::Round);
875 assert_eq!(
876 image.round_options.ring.map(|ring| ring.width),
877 Some(px(6.0))
878 );
879 }
880
881 #[test]
882 fn remote_image_loading_state_is_passive_after_first_fetch() {
883 assert!(
884 !RemoteImageState::Loading.should_request_animation_frame(),
885 "loading remote images should not request animation frames on every render; completion refreshes windows explicitly"
886 );
887 assert!(!RemoteImageState::Failed.should_request_animation_frame());
888 }
889
890 #[test]
891 fn remote_url_rendering_uses_single_liora_fetch_path() {
892 let source = include_str!("image.rs");
893 let production = source.split("#[cfg(test)]").next().unwrap();
894
895 assert!(
896 !production.contains("img(src)"),
897 "remote Image rendering should not start GPUI img loading after scheduling the Liora remote cache fetch"
898 );
899 }
900
901 #[test]
902 fn local_image_loading_uses_render_image_cache() {
903 let source = include_str!("image.rs");
904 let production = source.split("#[cfg(test)]").next().unwrap();
905
906 assert!(production.contains("fn local_image_cache()"));
907 assert!(production.contains("cache.get(path).cloned()"));
908 assert!(production.contains("cache.insert(path.to_path_buf(), image.clone())"));
909 }
910}