Skip to main content

gpui/elements/
img.rs

1use crate::{
2    AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
3    Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement,
4    Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
5    SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px,
6};
7use anyhow::Result;
8
9use futures::Future;
10use gpui_util::ResultExt;
11use image::{
12    AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba,
13    codecs::{gif::GifDecoder, webp::WebPDecoder},
14};
15use scheduler::Instant;
16use smallvec::SmallVec;
17use std::{
18    fs,
19    io::{self, Cursor},
20    ops::{Deref, DerefMut},
21    path::{Path, PathBuf},
22    str::FromStr,
23    sync::Arc,
24    time::Duration,
25};
26use thiserror::Error;
27
28use super::{Stateful, StatefulInteractiveElement};
29
30/// The delay before showing the loading state.
31pub const LOADING_DELAY: Duration = Duration::from_millis(200);
32
33/// A type alias to the resource loader that the `img()` element uses.
34///
35/// Note: that this is only for Resources, like URLs or file paths.
36/// Custom loaders, or external images will not use this asset loader
37pub type ImgResourceLoader = AssetLogger<ImageAssetLoader>;
38
39/// A source of image content.
40#[derive(Clone)]
41pub enum ImageSource {
42    /// The image content will be loaded from some resource location
43    Resource(Resource),
44    /// Cached image data
45    Render(Arc<RenderImage>),
46    /// Cached image data
47    Image(Arc<Image>),
48    /// A custom loading function to use
49    Custom(Arc<dyn Fn(&mut Window, &mut App) -> Option<Result<Arc<RenderImage>, ImageCacheError>>>),
50}
51
52fn is_uri(uri: &str) -> bool {
53    url::Url::from_str(uri).is_ok()
54}
55
56impl From<SharedUri> for ImageSource {
57    fn from(value: SharedUri) -> Self {
58        Self::Resource(Resource::Uri(value))
59    }
60}
61
62impl<'a> From<&'a str> for ImageSource {
63    fn from(s: &'a str) -> Self {
64        if is_uri(s) {
65            Self::Resource(Resource::Uri(s.to_string().into()))
66        } else {
67            Self::Resource(Resource::Embedded(s.to_string().into()))
68        }
69    }
70}
71
72impl From<String> for ImageSource {
73    fn from(s: String) -> Self {
74        if is_uri(&s) {
75            Self::Resource(Resource::Uri(s.into()))
76        } else {
77            Self::Resource(Resource::Embedded(s.into()))
78        }
79    }
80}
81
82impl From<SharedString> for ImageSource {
83    fn from(s: SharedString) -> Self {
84        s.as_ref().into()
85    }
86}
87
88impl From<&Path> for ImageSource {
89    fn from(value: &Path) -> Self {
90        Self::Resource(value.to_path_buf().into())
91    }
92}
93
94impl From<Arc<Path>> for ImageSource {
95    fn from(value: Arc<Path>) -> Self {
96        Self::Resource(value.into())
97    }
98}
99
100impl From<PathBuf> for ImageSource {
101    fn from(value: PathBuf) -> Self {
102        Self::Resource(value.into())
103    }
104}
105
106impl From<Arc<RenderImage>> for ImageSource {
107    fn from(value: Arc<RenderImage>) -> Self {
108        Self::Render(value)
109    }
110}
111
112impl From<Arc<Image>> for ImageSource {
113    fn from(value: Arc<Image>) -> Self {
114        Self::Image(value)
115    }
116}
117
118impl<F> From<F> for ImageSource
119where
120    F: Fn(&mut Window, &mut App) -> Option<Result<Arc<RenderImage>, ImageCacheError>> + 'static,
121{
122    fn from(value: F) -> Self {
123        Self::Custom(Arc::new(value))
124    }
125}
126
127/// The style of an image element.
128pub struct ImageStyle {
129    grayscale: bool,
130    object_fit: ObjectFit,
131    loading: Option<Box<dyn Fn() -> AnyElement>>,
132    fallback: Option<Box<dyn Fn() -> AnyElement>>,
133}
134
135impl Default for ImageStyle {
136    fn default() -> Self {
137        Self {
138            grayscale: false,
139            object_fit: ObjectFit::Contain,
140            loading: None,
141            fallback: None,
142        }
143    }
144}
145
146/// Style an image element.
147pub trait StyledImage: Sized {
148    /// Get a mutable [ImageStyle] from the element.
149    fn image_style(&mut self) -> &mut ImageStyle;
150
151    /// Set the image to be displayed in grayscale.
152    fn grayscale(mut self, grayscale: bool) -> Self {
153        self.image_style().grayscale = grayscale;
154        self
155    }
156
157    /// Set the object fit for the image.
158    fn object_fit(mut self, object_fit: ObjectFit) -> Self {
159        self.image_style().object_fit = object_fit;
160        self
161    }
162
163    /// Set a fallback function that will be invoked to render an error view should
164    /// the image fail to load.
165    fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self {
166        self.image_style().fallback = Some(Box::new(fallback));
167        self
168    }
169
170    /// Set a fallback function that will be invoked to render a view while the image
171    /// is still being loaded.
172    fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self {
173        self.image_style().loading = Some(Box::new(loading));
174        self
175    }
176}
177
178impl StyledImage for Img {
179    fn image_style(&mut self) -> &mut ImageStyle {
180        &mut self.style
181    }
182}
183
184impl StyledImage for Stateful<Img> {
185    fn image_style(&mut self) -> &mut ImageStyle {
186        &mut self.element.style
187    }
188}
189
190/// An image element.
191pub struct Img {
192    interactivity: Interactivity,
193    source: ImageSource,
194    style: ImageStyle,
195    image_cache: Option<AnyImageCache>,
196}
197
198/// Create a new image element.
199#[track_caller]
200pub fn img(source: impl Into<ImageSource>) -> Img {
201    Img {
202        interactivity: Interactivity::new(),
203        source: source.into(),
204        style: ImageStyle::default(),
205        image_cache: None,
206    }
207}
208
209impl Img {
210    /// A list of all format extensions currently supported by this img element
211    pub fn extensions() -> &'static [&'static str] {
212        // This is the list in [image::ImageFormat::from_extension] + `svg`
213        &[
214            "avif", "jpg", "jpeg", "png", "gif", "webp", "tif", "tiff", "tga", "dds", "bmp", "ico",
215            "hdr", "exr", "pbm", "pam", "ppm", "pgm", "ff", "farbfeld", "qoi", "svg",
216        ]
217    }
218
219    /// Sets the image cache for the current node.
220    ///
221    /// If the `image_cache` is not explicitly provided, the function will determine the image cache by:
222    ///
223    /// 1. Checking if any ancestor node of the current node contains an `ImageCacheElement`, If such a node exists, the image cache specified by that ancestor will be used.
224    /// 2. If no ancestor node contains an `ImageCacheElement`, the global image cache will be used as a fallback.
225    ///
226    /// This mechanism provides a flexible way to manage image caching, allowing precise control when needed,
227    /// while ensuring a default behavior when no cache is explicitly specified.
228    #[inline]
229    pub fn image_cache<I: ImageCache>(self, image_cache: &Entity<I>) -> Self {
230        Self {
231            image_cache: Some(image_cache.clone().into()),
232            ..self
233        }
234    }
235}
236
237impl Deref for Stateful<Img> {
238    type Target = Img;
239
240    fn deref(&self) -> &Self::Target {
241        &self.element
242    }
243}
244
245impl DerefMut for Stateful<Img> {
246    fn deref_mut(&mut self) -> &mut Self::Target {
247        &mut self.element
248    }
249}
250
251/// The image state between frames
252struct ImgState {
253    frame_index: usize,
254    last_frame_time: Option<Instant>,
255    started_loading: Option<(Instant, Task<()>)>,
256}
257
258/// The image layout state between frames
259pub struct ImgLayoutState {
260    frame_index: usize,
261    replacement: Option<AnyElement>,
262}
263
264impl Element for Img {
265    type RequestLayoutState = ImgLayoutState;
266    type PrepaintState = Option<Hitbox>;
267
268    fn id(&self) -> Option<ElementId> {
269        self.interactivity.element_id.clone()
270    }
271
272    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
273        self.interactivity.source_location()
274    }
275
276    fn request_layout(
277        &mut self,
278        global_id: Option<&GlobalElementId>,
279        inspector_id: Option<&InspectorElementId>,
280        window: &mut Window,
281        cx: &mut App,
282    ) -> (LayoutId, Self::RequestLayoutState) {
283        let mut layout_state = ImgLayoutState {
284            frame_index: 0,
285            replacement: None,
286        };
287
288        window.with_optional_element_state(global_id, |state, window| {
289            let mut state = state.map(|state| {
290                state.unwrap_or(ImgState {
291                    frame_index: 0,
292                    last_frame_time: None,
293                    started_loading: None,
294                })
295            });
296
297            let mut frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0);
298
299            let layout_id = self.interactivity.request_layout(
300                global_id,
301                inspector_id,
302                window,
303                cx,
304                |mut style, window, cx| {
305                    let mut replacement_id = None;
306
307                    match self.source.use_data(
308                        self.image_cache
309                            .clone()
310                            .or_else(|| window.image_cache_stack.last().cloned()),
311                        window,
312                        cx,
313                    ) {
314                        Some(Ok(data)) => {
315                            let frame_count = data.frame_count();
316                            let max_frame_index = frame_count.saturating_sub(1);
317
318                            if let Some(state) = &mut state {
319                                state.frame_index = state.frame_index.min(max_frame_index);
320                                if frame_count > 1 {
321                                    if window.is_window_active() {
322                                        let current_time = Instant::now();
323                                        if let Some(last_frame_time) = state.last_frame_time {
324                                            let elapsed = current_time - last_frame_time;
325                                            let frame_duration =
326                                                Duration::from(data.delay(state.frame_index));
327
328                                            if elapsed >= frame_duration {
329                                                state.frame_index =
330                                                    (state.frame_index + 1) % frame_count;
331                                                state.last_frame_time =
332                                                    Some(current_time - (elapsed - frame_duration));
333                                            }
334                                        } else {
335                                            state.last_frame_time = Some(current_time);
336                                        }
337                                    } else {
338                                        state.last_frame_time = None;
339                                    }
340                                } else {
341                                    state.last_frame_time = None;
342                                }
343                                state.started_loading = None;
344                                frame_index = state.frame_index;
345                            }
346
347                            let image_size = data.render_size(frame_index);
348                            style.aspect_ratio = Some(image_size.width / image_size.height);
349
350                            if let Length::Auto = style.size.width {
351                                style.size.width = match style.size.height {
352                                    Length::Definite(DefiniteLength::Absolute(abs_length)) => {
353                                        let height_px = abs_length.to_pixels(window.rem_size());
354                                        Length::Definite(
355                                            px(image_size.width.0 * height_px.0
356                                                / image_size.height.0)
357                                            .into(),
358                                        )
359                                    }
360                                    _ => Length::Definite(image_size.width.into()),
361                                };
362                            }
363
364                            if let Length::Auto = style.size.height {
365                                style.size.height = match style.size.width {
366                                    Length::Definite(DefiniteLength::Absolute(abs_length)) => {
367                                        let width_px = abs_length.to_pixels(window.rem_size());
368                                        Length::Definite(
369                                            px(image_size.height.0 * width_px.0
370                                                / image_size.width.0)
371                                            .into(),
372                                        )
373                                    }
374                                    _ => Length::Definite(image_size.height.into()),
375                                };
376                            }
377
378                            if global_id.is_some()
379                                && data.frame_count() > 1
380                                && window.is_window_active()
381                            {
382                                window.request_animation_frame();
383                            }
384                        }
385                        Some(_err) => {
386                            if let Some(fallback) = self.style.fallback.as_ref() {
387                                let mut element = fallback();
388                                replacement_id = Some(element.request_layout(window, cx));
389                                layout_state.replacement = Some(element);
390                            }
391                            if let Some(state) = &mut state {
392                                state.started_loading = None;
393                            }
394                        }
395                        None => {
396                            if let Some(state) = &mut state {
397                                if let Some((started_loading, _)) = state.started_loading {
398                                    if started_loading.elapsed() > LOADING_DELAY
399                                        && let Some(loading) = self.style.loading.as_ref()
400                                    {
401                                        let mut element = loading();
402                                        replacement_id = Some(element.request_layout(window, cx));
403                                        layout_state.replacement = Some(element);
404                                    }
405                                } else {
406                                    let current_view = window.current_view();
407                                    let task = window.spawn(cx, async move |cx| {
408                                        cx.background_executor().timer(LOADING_DELAY).await;
409                                        cx.update(move |_, cx| {
410                                            cx.notify(current_view);
411                                        })
412                                        .ok();
413                                    });
414                                    state.started_loading = Some((Instant::now(), task));
415                                }
416                            }
417                        }
418                    }
419
420                    window.request_layout(style, replacement_id, cx)
421                },
422            );
423
424            layout_state.frame_index = frame_index;
425
426            ((layout_id, layout_state), state)
427        })
428    }
429
430    fn prepaint(
431        &mut self,
432        global_id: Option<&GlobalElementId>,
433        inspector_id: Option<&InspectorElementId>,
434        bounds: Bounds<Pixels>,
435        request_layout: &mut Self::RequestLayoutState,
436        window: &mut Window,
437        cx: &mut App,
438    ) -> Self::PrepaintState {
439        self.interactivity.prepaint(
440            global_id,
441            inspector_id,
442            bounds,
443            bounds.size,
444            window,
445            cx,
446            |_, _, hitbox, window, cx| {
447                if let Some(replacement) = &mut request_layout.replacement {
448                    replacement.prepaint(window, cx);
449                }
450
451                hitbox
452            },
453        )
454    }
455
456    fn paint(
457        &mut self,
458        global_id: Option<&GlobalElementId>,
459        inspector_id: Option<&InspectorElementId>,
460        bounds: Bounds<Pixels>,
461        layout_state: &mut Self::RequestLayoutState,
462        hitbox: &mut Self::PrepaintState,
463        window: &mut Window,
464        cx: &mut App,
465    ) {
466        let source = self.source.clone();
467        self.interactivity.paint(
468            global_id,
469            inspector_id,
470            bounds,
471            hitbox.as_ref(),
472            window,
473            cx,
474            |style, window, cx| {
475                if let Some(Ok(data)) = source.use_data(
476                    self.image_cache
477                        .clone()
478                        .or_else(|| window.image_cache_stack.last().cloned()),
479                    window,
480                    cx,
481                ) {
482                    if data.frame_count() == 0 {
483                        return;
484                    }
485                    let new_bounds = self
486                        .style
487                        .object_fit
488                        .get_bounds(bounds, data.size(layout_state.frame_index));
489                    let corner_radii = style
490                        .corner_radii
491                        .to_pixels(window.rem_size())
492                        .clamp_radii_for_quad_size(new_bounds.size);
493                    window
494                        .paint_image(
495                            new_bounds,
496                            corner_radii,
497                            data,
498                            layout_state.frame_index,
499                            self.style.grayscale,
500                        )
501                        .log_err();
502                } else if let Some(replacement) = &mut layout_state.replacement {
503                    replacement.paint(window, cx);
504                }
505            },
506        )
507    }
508}
509
510impl Styled for Img {
511    fn style(&mut self) -> &mut StyleRefinement {
512        &mut self.interactivity.base_style
513    }
514}
515
516impl InteractiveElement for Img {
517    fn interactivity(&mut self) -> &mut Interactivity {
518        &mut self.interactivity
519    }
520}
521
522impl IntoElement for Img {
523    type Element = Self;
524
525    fn into_element(self) -> Self::Element {
526        self
527    }
528}
529
530impl StatefulInteractiveElement for Img {}
531
532impl ImageSource {
533    pub(crate) fn use_data(
534        &self,
535        cache: Option<AnyImageCache>,
536        window: &mut Window,
537        cx: &mut App,
538    ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
539        match self {
540            ImageSource::Resource(resource) => {
541                if let Some(cache) = cache {
542                    cache.load(resource, window, cx)
543                } else {
544                    window.use_asset::<ImgResourceLoader>(resource, cx)
545                }
546            }
547            ImageSource::Custom(loading_fn) => loading_fn(window, cx),
548            ImageSource::Render(data) => Some(Ok(data.to_owned())),
549            ImageSource::Image(data) => window.use_asset::<AssetLogger<ImageDecoder>>(data, cx),
550        }
551    }
552
553    pub(crate) fn get_data(
554        &self,
555        cache: Option<AnyImageCache>,
556        window: &mut Window,
557        cx: &mut App,
558    ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
559        match self {
560            ImageSource::Resource(resource) => {
561                if let Some(cache) = cache {
562                    cache.load(resource, window, cx)
563                } else {
564                    window.get_asset::<ImgResourceLoader>(resource, cx)
565                }
566            }
567            ImageSource::Custom(loading_fn) => loading_fn(window, cx),
568            ImageSource::Render(data) => Some(Ok(data.to_owned())),
569            ImageSource::Image(data) => window.get_asset::<AssetLogger<ImageDecoder>>(data, cx),
570        }
571    }
572
573    /// Remove this image source from the asset system
574    pub fn remove_asset(&self, cx: &mut App) {
575        match self {
576            ImageSource::Resource(resource) => {
577                cx.remove_asset::<ImgResourceLoader>(resource);
578            }
579            ImageSource::Custom(_) | ImageSource::Render(_) => {}
580            ImageSource::Image(data) => cx.remove_asset::<AssetLogger<ImageDecoder>>(data),
581        }
582    }
583}
584
585#[derive(Clone)]
586enum ImageDecoder {}
587
588impl Asset for ImageDecoder {
589    type Source = Arc<Image>;
590    type Output = Result<Arc<RenderImage>, ImageCacheError>;
591
592    fn load(
593        source: Self::Source,
594        cx: &mut App,
595    ) -> impl Future<Output = Self::Output> + Send + 'static {
596        let renderer = cx.svg_renderer();
597        async move { source.to_image_data(renderer).map_err(Into::into) }
598    }
599}
600
601/// An image loader for the GPUI asset system
602#[derive(Clone)]
603pub enum ImageAssetLoader {}
604
605impl Asset for ImageAssetLoader {
606    type Source = Resource;
607    type Output = Result<Arc<RenderImage>, ImageCacheError>;
608
609    fn load(
610        source: Self::Source,
611        cx: &mut App,
612    ) -> impl Future<Output = Self::Output> + Send + 'static {
613        let client = cx.http_client();
614        // TODO: Can we make SVGs always rescale?
615        // let scale_factor = cx.scale_factor();
616        let svg_renderer = cx.svg_renderer();
617        let asset_source = cx.asset_source().clone();
618        async move {
619            let bytes = match source.clone() {
620                Resource::Path(uri) => fs::read(uri.as_ref())?,
621                Resource::Uri(uri) => {
622                    use anyhow::Context as _;
623                    use futures::AsyncReadExt as _;
624
625                    let mut response = client
626                        .get(uri.as_ref(), ().into(), true)
627                        .await
628                        .with_context(|| format!("loading image asset from {uri:?}"))?;
629                    let mut body = Vec::new();
630                    response.body_mut().read_to_end(&mut body).await?;
631                    if !response.status().is_success() {
632                        let mut body = String::from_utf8_lossy(&body).into_owned();
633                        let first_line = body.lines().next().unwrap_or("").trim_end();
634                        body.truncate(first_line.len());
635                        return Err(ImageCacheError::BadStatus {
636                            uri,
637                            status: response.status(),
638                            body,
639                        });
640                    }
641                    body
642                }
643                Resource::Embedded(path) => {
644                    let data = asset_source.load(&path).ok().flatten();
645                    if let Some(data) = data {
646                        data.to_vec()
647                    } else {
648                        return Err(ImageCacheError::Asset(
649                            format!("Embedded resource not found: {}", path).into(),
650                        ));
651                    }
652                }
653            };
654
655            if let Ok(format) = image::guess_format(&bytes) {
656                let data = match format {
657                    ImageFormat::Gif => {
658                        let decoder = GifDecoder::new(Cursor::new(&bytes))?;
659                        let mut frames = SmallVec::new();
660
661                        for frame in decoder.into_frames() {
662                            match frame {
663                                Ok(mut frame) => {
664                                    // Convert from RGBA to BGRA.
665                                    for pixel in frame.buffer_mut().chunks_exact_mut(4) {
666                                        pixel.swap(0, 2);
667                                    }
668                                    frames.push(frame);
669                                }
670                                Err(err) => {
671                                    log::debug!(
672                                        "Skipping GIF frame in {source:?} due to decode error: {err}"
673                                    );
674                                }
675                            }
676                        }
677
678                        if frames.is_empty() {
679                            return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
680                                "GIF could not be decoded: all frames failed ({source:?})"
681                            ))));
682                        }
683
684                        frames
685                    }
686                    ImageFormat::WebP => {
687                        let mut decoder = WebPDecoder::new(Cursor::new(&bytes))?;
688
689                        if decoder.has_animation() {
690                            let _ = decoder.set_background_color(Rgba([0, 0, 0, 0]));
691                            let mut frames = SmallVec::new();
692
693                            for frame in decoder.into_frames() {
694                                match frame {
695                                    Ok(mut frame) => {
696                                        // Convert from RGBA to BGRA.
697                                        for pixel in frame.buffer_mut().chunks_exact_mut(4) {
698                                            pixel.swap(0, 2);
699                                        }
700                                        frames.push(frame);
701                                    }
702                                    Err(err) => {
703                                        log::debug!(
704                                            "Skipping WebP frame in {source:?} due to decode error: {err}"
705                                        );
706                                    }
707                                }
708                            }
709
710                            if frames.is_empty() {
711                                return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
712                                    "WebP could not be decoded: all frames failed ({source:?})"
713                                ))));
714                            }
715
716                            frames
717                        } else {
718                            let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8();
719
720                            // Convert from RGBA to BGRA.
721                            for pixel in data.chunks_exact_mut(4) {
722                                pixel.swap(0, 2);
723                            }
724
725                            SmallVec::from_elem(Frame::new(data), 1)
726                        }
727                    }
728                    _ => {
729                        let mut data =
730                            image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
731
732                        // Convert from RGBA to BGRA.
733                        for pixel in data.chunks_exact_mut(4) {
734                            pixel.swap(0, 2);
735                        }
736
737                        SmallVec::from_elem(Frame::new(data), 1)
738                    }
739                };
740
741                Ok(Arc::new(RenderImage::new(data)))
742            } else {
743                svg_renderer
744                    .render_single_frame(&bytes, 1.0)
745                    .map_err(Into::into)
746            }
747        }
748    }
749}
750
751/// An error that can occur when interacting with the image cache.
752#[derive(Debug, Error, Clone)]
753pub enum ImageCacheError {
754    /// Some other kind of error occurred
755    #[error("error: {0}")]
756    Other(#[from] Arc<anyhow::Error>),
757    /// An error that occurred while reading the image from disk.
758    #[error("IO error: {0}")]
759    Io(Arc<std::io::Error>),
760    /// An error that occurred while processing an image.
761    #[error("unexpected http status for {uri}: {status}, body: {body}")]
762    BadStatus {
763        /// The URI of the image.
764        uri: SharedUri,
765        /// The HTTP status code.
766        status: http_client::StatusCode,
767        /// The HTTP response body.
768        body: String,
769    },
770    /// An error that occurred while processing an asset.
771    #[error("asset error: {0}")]
772    Asset(SharedString),
773    /// An error that occurred while processing an image.
774    #[error("image error: {0}")]
775    Image(Arc<ImageError>),
776    /// An error that occurred while processing an SVG.
777    #[error("svg error: {0}")]
778    Usvg(Arc<usvg::Error>),
779}
780
781impl From<anyhow::Error> for ImageCacheError {
782    fn from(value: anyhow::Error) -> Self {
783        Self::Other(Arc::new(value))
784    }
785}
786
787impl From<io::Error> for ImageCacheError {
788    fn from(value: io::Error) -> Self {
789        Self::Io(Arc::new(value))
790    }
791}
792
793impl From<usvg::Error> for ImageCacheError {
794    fn from(value: usvg::Error) -> Self {
795        Self::Usvg(Arc::new(value))
796    }
797}
798
799impl From<image::ImageError> for ImageCacheError {
800    fn from(value: image::ImageError) -> Self {
801        Self::Image(Arc::new(value))
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::{ParentElement as _, TestAppContext, canvas, div, point, px, size};
809    use image::{Frame, ImageBuffer, Rgba};
810
811    const TEST_IMG_ID: &str = "test-img";
812
813    fn test_image(frame_count: usize) -> Arc<RenderImage> {
814        let frame = Frame::new(ImageBuffer::from_pixel(1, 1, Rgba([0, 0, 0, 0])));
815        Arc::new(RenderImage::new(SmallVec::from_iter(
816            (0..frame_count).map(|_| frame.clone()),
817        )))
818    }
819
820    /// Overwrites the cached `frame_index` of the sibling `img` during paint.
821    fn seed_frame_index(frame_index: usize) -> impl IntoElement {
822        canvas(
823            |_, _, _| (),
824            move |_, _, window, _| {
825                window.with_global_id(TEST_IMG_ID.into(), |id, window| {
826                    window.with_element_state::<ImgState, _>(id, |state, _| {
827                        let mut state = state.expect("img state should be initialized");
828                        state.frame_index = frame_index;
829                        ((), state)
830                    });
831                });
832            },
833        )
834    }
835
836    #[gpui::test]
837    fn zero_frame_image_does_not_panic_on_paint(cx: &mut TestAppContext) {
838        cx.add_empty_window()
839            .draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
840                img(ImageSource::Render(test_image(0))).into_any_element()
841            });
842    }
843
844    #[gpui::test]
845    fn stale_frame_index_is_clamped_when_image_changes(cx: &mut TestAppContext) {
846        let window = cx.add_empty_window();
847
848        // Assert that a cached frame_index from a previous multi-frame image
849        // does not cause an out-of-bounds panic when the image is replaced
850        // with one that has fewer frames.
851        window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
852            div()
853                .child(img(ImageSource::Render(test_image(5))).id(TEST_IMG_ID))
854                .child(seed_frame_index(4))
855                .into_any_element()
856        });
857        window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
858            img(ImageSource::Render(test_image(1)))
859                .id(TEST_IMG_ID)
860                .into_any_element()
861        });
862    }
863}