Skip to main content

liora_components/
image.rs

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}