diff --git a/crates/gpui/examples/gif_viewer.rs b/crates/gpui/examples/gif_viewer.rs new file mode 100644 index 0000000000..455a7d6ba9 --- /dev/null +++ b/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) -> 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); + }); +} diff --git a/crates/gpui/examples/image/black-cat-typing.gif b/crates/gpui/examples/image/black-cat-typing.gif new file mode 100644 index 0000000000..671a102d22 Binary files /dev/null and b/crates/gpui/examples/image/black-cat-typing.gif differ diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index b8b49c9136..0a05fb5adc 100644 --- a/crates/gpui/src/assets.rs +++ b/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>) -> 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 { - let (width, height) = self.data.dimensions(); + /// Get the size of this image, in pixels. + pub fn size(&self, frame_index: usize) -> Size { + 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() } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index c47ed69ac0..f1d7d4ff8d 100644 --- a/crates/gpui/src/elements/img.rs +++ b/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, +} + impl Element for Img { - type RequestLayoutState = (); + type RequestLayoutState = usize; type PrepaintState = Option; fn id(&self) -> Option { @@ -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); + } } } - _ => {} - } - } - cx.request_layout(style, []) - }); - (layout_id, ()) + 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, frame_index), state) + }) } fn prepaint( @@ -283,7 +330,7 @@ impl Element for Img { &mut self, global_id: Option<&GlobalElementId>, bounds: Bounds, - _: &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(); - // Convert from RGBA to BGRA. - for pixel in data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } + 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); + } + + 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)) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 4a717b8594..ca5b26adcc 100644 --- a/crates/gpui/src/window.rs +++ b/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, corner_radii: Corners, data: Arc, + 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(¶ms.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); diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index ab67425f0a..c11984e439 100644 --- a/crates/repl/src/outputs.rs +++ b/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,