gpui: Add support for animated images (#13809)

Matin Aniss , Antonio Scandurra , and Nathan created

This PR adds support for animated images. The image requires a id for it
to actually animate across frames.

Currently it only has support for `GIF`, I tried adding decoding a
animated `WebP` into frames but it seems to error. This issue in the
image crate seems to document this
https://github.com/image-rs/image/issues/2263.

Not sure if this is the best way or the desired way for animated images
to work in GPUI but I would really like support for animated images.
Open to feedback.

Example Video:


https://github.com/zed-industries/zed/assets/76515905/011f790f-d070-499b-96c9-bbff141fb002



Closes https://github.com/zed-industries/zed/issues/9993

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/gpui/examples/gif_viewer.rs              |  50 ++++++
crates/gpui/examples/image/black-cat-typing.gif |   0 
crates/gpui/src/assets.rs                       |  32 ++-
crates/gpui/src/elements/img.rs                 | 149 ++++++++++++++----
crates/gpui/src/window.rs                       |  28 +++
crates/repl/src/outputs.rs                      |   2 
6 files changed, 211 insertions(+), 50 deletions(-)

Detailed changes

crates/gpui/examples/gif_viewer.rs 🔗

@@ -0,0 +1,50 @@
+use gpui::{
+    div, img, prelude::*, App, AppContext, ImageSource, Render, ViewContext, WindowOptions,
+};
+use std::path::PathBuf;
+
+struct GifViewer {
+    gif_path: PathBuf,
+}
+
+impl GifViewer {
+    fn new(gif_path: PathBuf) -> Self {
+        Self { gif_path }
+    }
+}
+
+impl Render for GifViewer {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div().size_full().child(
+            img(ImageSource::File(self.gif_path.clone().into()))
+                .size_full()
+                .object_fit(gpui::ObjectFit::Contain)
+                .id("gif"),
+        )
+    }
+}
+
+fn main() {
+    env_logger::init();
+    App::new().run(|cx: &mut AppContext| {
+        let cwd = std::env::current_dir().expect("Failed to get current working directory");
+        let gif_path = cwd.join("crates/gpui/examples/image/black-cat-typing.gif");
+
+        if !gif_path.exists() {
+            eprintln!("Image file not found at {:?}", gif_path);
+            eprintln!("Make sure you're running this example from the root of the gpui crate");
+            cx.quit();
+            return;
+        }
+
+        cx.open_window(
+            WindowOptions {
+                focus: true,
+                ..Default::default()
+            },
+            |cx| cx.new_view(|_cx| GifViewer::new(gif_path)),
+        )
+        .unwrap();
+        cx.activate(true);
+    });
+}

crates/gpui/src/assets.rs 🔗

@@ -1,6 +1,7 @@
 use crate::{size, DevicePixels, Result, SharedString, Size};
+use smallvec::SmallVec;
 
-use image::RgbaImage;
+use image::{Delay, Frame};
 use std::{
     borrow::Cow,
     fmt,
@@ -34,43 +35,54 @@ pub struct ImageId(usize);
 #[derive(PartialEq, Eq, Hash, Clone)]
 pub(crate) struct RenderImageParams {
     pub(crate) image_id: ImageId,
+    pub(crate) frame_index: usize,
 }
 
 /// A cached and processed image.
 pub struct ImageData {
     /// The ID associated with this image
     pub id: ImageId,
-    data: RgbaImage,
+    data: SmallVec<[Frame; 1]>,
 }
 
 impl ImageData {
     /// Create a new image from the given data.
-    pub fn new(data: RgbaImage) -> Self {
+    pub fn new(data: impl Into<SmallVec<[Frame; 1]>>) -> Self {
         static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
 
         Self {
             id: ImageId(NEXT_ID.fetch_add(1, SeqCst)),
-            data,
+            data: data.into(),
         }
     }
 
     /// Convert this image into a byte slice.
-    pub fn as_bytes(&self) -> &[u8] {
-        &self.data
+    pub fn as_bytes(&self, frame_index: usize) -> &[u8] {
+        &self.data[frame_index].buffer()
     }
 
-    /// Get the size of this image, in pixels
-    pub fn size(&self) -> Size<DevicePixels> {
-        let (width, height) = self.data.dimensions();
+    /// Get the size of this image, in pixels.
+    pub fn size(&self, frame_index: usize) -> Size<DevicePixels> {
+        let (width, height) = self.data[frame_index].buffer().dimensions();
         size(width.into(), height.into())
     }
+
+    /// Get the delay of this frame from the previous
+    pub fn delay(&self, frame_index: usize) -> Delay {
+        self.data[frame_index].delay()
+    }
+
+    /// Get the number of frames for this image.
+    pub fn frame_count(&self) -> usize {
+        self.data.len()
+    }
 }
 
 impl fmt::Debug for ImageData {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("ImageData")
             .field("id", &self.id)
-            .field("size", &self.data.dimensions())
+            .field("size", &self.size(0))
             .finish()
     }
 }

crates/gpui/src/elements/img.rs 🔗

@@ -1,7 +1,3 @@
-use std::fs;
-use std::path::PathBuf;
-use std::sync::Arc;
-
 use crate::{
     point, px, size, AbsoluteLength, Asset, Bounds, DefiniteLength, DevicePixels, Element,
     ElementId, GlobalElementId, Hitbox, ImageData, InteractiveElement, Interactivity, IntoElement,
@@ -9,11 +5,20 @@ use crate::{
     WindowContext,
 };
 use futures::{AsyncReadExt, Future};
-use image::{ImageBuffer, ImageError};
+use http_client;
+use image::{
+    codecs::gif::GifDecoder, AnimationDecoder, Frame, ImageBuffer, ImageError, ImageFormat,
+};
 #[cfg(target_os = "macos")]
 use media::core_video::CVImageBuffer;
-
-use http_client;
+use smallvec::SmallVec;
+use std::{
+    fs,
+    io::Cursor,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, Instant},
+};
 use thiserror::Error;
 use util::ResultExt;
 
@@ -230,8 +235,14 @@ impl Img {
     }
 }
 
+/// The image state between frames
+struct ImgState {
+    frame_index: usize,
+    last_frame_time: Option<Instant>,
+}
+
 impl Element for Img {
-    type RequestLayoutState = ();
+    type RequestLayoutState = usize;
     type PrepaintState = Option<Hitbox>;
 
     fn id(&self) -> Option<ElementId> {
@@ -243,29 +254,65 @@ impl Element for Img {
         global_id: Option<&GlobalElementId>,
         cx: &mut WindowContext,
     ) -> (LayoutId, Self::RequestLayoutState) {
-        let layout_id = self
-            .interactivity
-            .request_layout(global_id, cx, |mut style, cx| {
-                if let Some(data) = self.source.data(cx) {
-                    let image_size = data.size();
-                    match (style.size.width, style.size.height) {
-                        (Length::Auto, Length::Auto) => {
-                            style.size = Size {
-                                width: Length::Definite(DefiniteLength::Absolute(
-                                    AbsoluteLength::Pixels(px(image_size.width.0 as f32)),
-                                )),
-                                height: Length::Definite(DefiniteLength::Absolute(
-                                    AbsoluteLength::Pixels(px(image_size.height.0 as f32)),
-                                )),
+        cx.with_optional_element_state(global_id, |state, cx| {
+            let mut state = state.map(|state| {
+                state.unwrap_or(ImgState {
+                    frame_index: 0,
+                    last_frame_time: None,
+                })
+            });
+
+            let frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0);
+
+            let layout_id = self
+                .interactivity
+                .request_layout(global_id, cx, |mut style, cx| {
+                    if let Some(data) = self.source.data(cx) {
+                        if let Some(state) = &mut state {
+                            let frame_count = data.frame_count();
+                            if frame_count > 1 {
+                                let current_time = Instant::now();
+                                if let Some(last_frame_time) = state.last_frame_time {
+                                    let elapsed = current_time - last_frame_time;
+                                    let frame_duration =
+                                        Duration::from(data.delay(state.frame_index));
+
+                                    if elapsed >= frame_duration {
+                                        state.frame_index = (state.frame_index + 1) % frame_count;
+                                        state.last_frame_time =
+                                            Some(current_time - (elapsed - frame_duration));
+                                    }
+                                } else {
+                                    state.last_frame_time = Some(current_time);
+                                }
                             }
                         }
-                        _ => {}
+
+                        let image_size = data.size(frame_index);
+                        match (style.size.width, style.size.height) {
+                            (Length::Auto, Length::Auto) => {
+                                style.size = Size {
+                                    width: Length::Definite(DefiniteLength::Absolute(
+                                        AbsoluteLength::Pixels(px(image_size.width.0 as f32)),
+                                    )),
+                                    height: Length::Definite(DefiniteLength::Absolute(
+                                        AbsoluteLength::Pixels(px(image_size.height.0 as f32)),
+                                    )),
+                                }
+                            }
+                            _ => {}
+                        }
+
+                        if global_id.is_some() && data.frame_count() > 1 {
+                            cx.request_animation_frame();
+                        }
                     }
-                }
 
-                cx.request_layout(style, [])
-            });
-        (layout_id, ())
+                    cx.request_layout(style, [])
+                });
+
+            ((layout_id, frame_index), state)
+        })
     }
 
     fn prepaint(
@@ -283,7 +330,7 @@ impl Element for Img {
         &mut self,
         global_id: Option<&GlobalElementId>,
         bounds: Bounds<Pixels>,
-        _: &mut Self::RequestLayoutState,
+        frame_index: &mut Self::RequestLayoutState,
         hitbox: &mut Self::PrepaintState,
         cx: &mut WindowContext,
     ) {
@@ -293,9 +340,15 @@ impl Element for Img {
                 let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size());
 
                 if let Some(data) = source.data(cx) {
-                    let new_bounds = self.object_fit.get_bounds(bounds, data.size());
-                    cx.paint_image(new_bounds, corner_radii, data.clone(), self.grayscale)
-                        .log_err();
+                    let new_bounds = self.object_fit.get_bounds(bounds, data.size(*frame_index));
+                    cx.paint_image(
+                        new_bounds,
+                        corner_radii,
+                        data.clone(),
+                        *frame_index,
+                        self.grayscale,
+                    )
+                    .log_err();
                 }
 
                 match source {
@@ -385,12 +438,34 @@ impl Asset for Image {
             };
 
             let data = if let Ok(format) = image::guess_format(&bytes) {
-                let mut data = image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
+                let data = match format {
+                    ImageFormat::Gif => {
+                        let decoder = GifDecoder::new(Cursor::new(&bytes))?;
+                        let mut frames = SmallVec::new();
+
+                        for frame in decoder.into_frames() {
+                            let mut frame = frame?;
+                            // Convert from RGBA to BGRA.
+                            for pixel in frame.buffer_mut().chunks_exact_mut(4) {
+                                pixel.swap(0, 2);
+                            }
+                            frames.push(frame);
+                        }
 
-                // Convert from RGBA to BGRA.
-                for pixel in data.chunks_exact_mut(4) {
-                    pixel.swap(0, 2);
-                }
+                        frames
+                    }
+                    _ => {
+                        let mut data =
+                            image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
+
+                        // Convert from RGBA to BGRA.
+                        for pixel in data.chunks_exact_mut(4) {
+                            pixel.swap(0, 2);
+                        }
+
+                        SmallVec::from_elem(Frame::new(data), 1)
+                    }
+                };
 
                 ImageData::new(data)
             } else {
@@ -400,7 +475,7 @@ impl Asset for Image {
                 let buffer =
                     ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
 
-                ImageData::new(buffer)
+                ImageData::new(SmallVec::from_elem(Frame::new(buffer), 1))
             };
 
             Ok(Arc::new(data))

crates/gpui/src/window.rs 🔗

@@ -1158,6 +1158,23 @@ impl<'a> WindowContext<'a> {
         RefCell::borrow_mut(&self.window.next_frame_callbacks).push(Box::new(callback));
     }
 
+    /// Schedule a frame to be drawn on the next animation frame.
+    ///
+    /// This is useful for elements that need to animate continuously, such as a video player or an animated GIF.
+    /// It will cause the window to redraw on the next frame, even if no other changes have occurred.
+    ///
+    /// If called from within a view, it will notify that view on the next frame. Otherwise, it will refresh the entire window.
+    pub fn request_animation_frame(&mut self) {
+        let parent_id = self.parent_view_id();
+        self.on_next_frame(move |cx| {
+            if let Some(parent_id) = parent_id {
+                cx.notify(parent_id)
+            } else {
+                cx.refresh()
+            }
+        });
+    }
+
     /// Spawn the future returned by the given closure on the application thread pool.
     /// The closure is provided a handle to the current window and an `AsyncWindowContext` for
     /// use within your future.
@@ -2602,6 +2619,7 @@ impl<'a> WindowContext<'a> {
         bounds: Bounds<Pixels>,
         corner_radii: Corners<Pixels>,
         data: Arc<ImageData>,
+        frame_index: usize,
         grayscale: bool,
     ) -> Result<()> {
         debug_assert_eq!(
@@ -2612,13 +2630,19 @@ impl<'a> WindowContext<'a> {
 
         let scale_factor = self.scale_factor();
         let bounds = bounds.scale(scale_factor);
-        let params = RenderImageParams { image_id: data.id };
+        let params = RenderImageParams {
+            image_id: data.id,
+            frame_index,
+        };
 
         let tile = self
             .window
             .sprite_atlas
             .get_or_insert_with(&params.clone().into(), &mut || {
-                Ok(Some((data.size(), Cow::Borrowed(data.as_bytes()))))
+                Ok(Some((
+                    data.size(frame_index),
+                    Cow::Borrowed(data.as_bytes(frame_index)),
+                )))
             })?
             .expect("Callback above only returns Some");
         let content_mask = self.content_mask().scale(scale_factor);

crates/repl/src/outputs.rs 🔗

@@ -77,7 +77,7 @@ impl ImageView {
         let height = data.height();
         let width = data.width();
 
-        let gpui_image_data = ImageData::new(data);
+        let gpui_image_data = ImageData::new(vec![image::Frame::new(data)]);
 
         return Ok(ImageView {
             height,