gpui: Add support for animated images (#13809)
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>
This commit is contained in:
parent
c0df1e1846
commit
4bd935b409
6 changed files with 212 additions and 51 deletions
50
crates/gpui/examples/gif_viewer.rs
Normal file
50
crates/gpui/examples/gif_viewer.rs
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
BIN
crates/gpui/examples/image/black-cat-typing.gif
Normal file
BIN
crates/gpui/examples/image/black-cat-typing.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.3 MiB |
|
@ -1,6 +1,7 @@
|
||||||
use crate::{size, DevicePixels, Result, SharedString, Size};
|
use crate::{size, DevicePixels, Result, SharedString, Size};
|
||||||
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
use image::RgbaImage;
|
use image::{Delay, Frame};
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
fmt,
|
fmt,
|
||||||
|
@ -34,43 +35,54 @@ pub struct ImageId(usize);
|
||||||
#[derive(PartialEq, Eq, Hash, Clone)]
|
#[derive(PartialEq, Eq, Hash, Clone)]
|
||||||
pub(crate) struct RenderImageParams {
|
pub(crate) struct RenderImageParams {
|
||||||
pub(crate) image_id: ImageId,
|
pub(crate) image_id: ImageId,
|
||||||
|
pub(crate) frame_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cached and processed image.
|
/// A cached and processed image.
|
||||||
pub struct ImageData {
|
pub struct ImageData {
|
||||||
/// The ID associated with this image
|
/// The ID associated with this image
|
||||||
pub id: ImageId,
|
pub id: ImageId,
|
||||||
data: RgbaImage,
|
data: SmallVec<[Frame; 1]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageData {
|
impl ImageData {
|
||||||
/// Create a new image from the given data.
|
/// 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);
|
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: ImageId(NEXT_ID.fetch_add(1, SeqCst)),
|
id: ImageId(NEXT_ID.fetch_add(1, SeqCst)),
|
||||||
data,
|
data: data.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert this image into a byte slice.
|
/// Convert this image into a byte slice.
|
||||||
pub fn as_bytes(&self) -> &[u8] {
|
pub fn as_bytes(&self, frame_index: usize) -> &[u8] {
|
||||||
&self.data
|
&self.data[frame_index].buffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the size of this image, in pixels
|
/// Get the size of this image, in pixels.
|
||||||
pub fn size(&self) -> Size<DevicePixels> {
|
pub fn size(&self, frame_index: usize) -> Size<DevicePixels> {
|
||||||
let (width, height) = self.data.dimensions();
|
let (width, height) = self.data[frame_index].buffer().dimensions();
|
||||||
size(width.into(), height.into())
|
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 {
|
impl fmt::Debug for ImageData {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("ImageData")
|
f.debug_struct("ImageData")
|
||||||
.field("id", &self.id)
|
.field("id", &self.id)
|
||||||
.field("size", &self.data.dimensions())
|
.field("size", &self.size(0))
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
point, px, size, AbsoluteLength, Asset, Bounds, DefiniteLength, DevicePixels, Element,
|
point, px, size, AbsoluteLength, Asset, Bounds, DefiniteLength, DevicePixels, Element,
|
||||||
ElementId, GlobalElementId, Hitbox, ImageData, InteractiveElement, Interactivity, IntoElement,
|
ElementId, GlobalElementId, Hitbox, ImageData, InteractiveElement, Interactivity, IntoElement,
|
||||||
|
@ -9,11 +5,20 @@ use crate::{
|
||||||
WindowContext,
|
WindowContext,
|
||||||
};
|
};
|
||||||
use futures::{AsyncReadExt, Future};
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
use media::core_video::CVImageBuffer;
|
use media::core_video::CVImageBuffer;
|
||||||
|
use smallvec::SmallVec;
|
||||||
use http_client;
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::Cursor,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use util::ResultExt;
|
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 {
|
impl Element for Img {
|
||||||
type RequestLayoutState = ();
|
type RequestLayoutState = usize;
|
||||||
type PrepaintState = Option<Hitbox>;
|
type PrepaintState = Option<Hitbox>;
|
||||||
|
|
||||||
fn id(&self) -> Option<ElementId> {
|
fn id(&self) -> Option<ElementId> {
|
||||||
|
@ -243,11 +254,41 @@ impl Element for Img {
|
||||||
global_id: Option<&GlobalElementId>,
|
global_id: Option<&GlobalElementId>,
|
||||||
cx: &mut WindowContext,
|
cx: &mut WindowContext,
|
||||||
) -> (LayoutId, Self::RequestLayoutState) {
|
) -> (LayoutId, Self::RequestLayoutState) {
|
||||||
|
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
|
let layout_id = self
|
||||||
.interactivity
|
.interactivity
|
||||||
.request_layout(global_id, cx, |mut style, cx| {
|
.request_layout(global_id, cx, |mut style, cx| {
|
||||||
if let Some(data) = self.source.data(cx) {
|
if let Some(data) = self.source.data(cx) {
|
||||||
let image_size = data.size();
|
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) {
|
match (style.size.width, style.size.height) {
|
||||||
(Length::Auto, Length::Auto) => {
|
(Length::Auto, Length::Auto) => {
|
||||||
style.size = Size {
|
style.size = Size {
|
||||||
|
@ -261,11 +302,17 @@ impl Element for Img {
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if global_id.is_some() && data.frame_count() > 1 {
|
||||||
|
cx.request_animation_frame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.request_layout(style, [])
|
cx.request_layout(style, [])
|
||||||
});
|
});
|
||||||
(layout_id, ())
|
|
||||||
|
((layout_id, frame_index), state)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepaint(
|
fn prepaint(
|
||||||
|
@ -283,7 +330,7 @@ impl Element for Img {
|
||||||
&mut self,
|
&mut self,
|
||||||
global_id: Option<&GlobalElementId>,
|
global_id: Option<&GlobalElementId>,
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
_: &mut Self::RequestLayoutState,
|
frame_index: &mut Self::RequestLayoutState,
|
||||||
hitbox: &mut Self::PrepaintState,
|
hitbox: &mut Self::PrepaintState,
|
||||||
cx: &mut WindowContext,
|
cx: &mut WindowContext,
|
||||||
) {
|
) {
|
||||||
|
@ -293,8 +340,14 @@ impl Element for Img {
|
||||||
let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size());
|
let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size());
|
||||||
|
|
||||||
if let Some(data) = source.data(cx) {
|
if let Some(data) = source.data(cx) {
|
||||||
let new_bounds = self.object_fit.get_bounds(bounds, data.size());
|
let new_bounds = self.object_fit.get_bounds(bounds, data.size(*frame_index));
|
||||||
cx.paint_image(new_bounds, corner_radii, data.clone(), self.grayscale)
|
cx.paint_image(
|
||||||
|
new_bounds,
|
||||||
|
corner_radii,
|
||||||
|
data.clone(),
|
||||||
|
*frame_index,
|
||||||
|
self.grayscale,
|
||||||
|
)
|
||||||
.log_err();
|
.log_err();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -385,13 +438,35 @@ impl Asset for Image {
|
||||||
};
|
};
|
||||||
|
|
||||||
let data = if let Ok(format) = image::guess_format(&bytes) {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
frames
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut data =
|
||||||
|
image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
|
||||||
|
|
||||||
// Convert from RGBA to BGRA.
|
// Convert from RGBA to BGRA.
|
||||||
for pixel in data.chunks_exact_mut(4) {
|
for pixel in data.chunks_exact_mut(4) {
|
||||||
pixel.swap(0, 2);
|
pixel.swap(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SmallVec::from_elem(Frame::new(data), 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ImageData::new(data)
|
ImageData::new(data)
|
||||||
} else {
|
} else {
|
||||||
let pixmap =
|
let pixmap =
|
||||||
|
@ -400,7 +475,7 @@ impl Asset for Image {
|
||||||
let buffer =
|
let buffer =
|
||||||
ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
|
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))
|
Ok(Arc::new(data))
|
||||||
|
|
|
@ -1158,6 +1158,23 @@ impl<'a> WindowContext<'a> {
|
||||||
RefCell::borrow_mut(&self.window.next_frame_callbacks).push(Box::new(callback));
|
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.
|
/// 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
|
/// The closure is provided a handle to the current window and an `AsyncWindowContext` for
|
||||||
/// use within your future.
|
/// use within your future.
|
||||||
|
@ -2602,6 +2619,7 @@ impl<'a> WindowContext<'a> {
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
corner_radii: Corners<Pixels>,
|
corner_radii: Corners<Pixels>,
|
||||||
data: Arc<ImageData>,
|
data: Arc<ImageData>,
|
||||||
|
frame_index: usize,
|
||||||
grayscale: bool,
|
grayscale: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
debug_assert_eq!(
|
debug_assert_eq!(
|
||||||
|
@ -2612,13 +2630,19 @@ impl<'a> WindowContext<'a> {
|
||||||
|
|
||||||
let scale_factor = self.scale_factor();
|
let scale_factor = self.scale_factor();
|
||||||
let bounds = bounds.scale(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
|
let tile = self
|
||||||
.window
|
.window
|
||||||
.sprite_atlas
|
.sprite_atlas
|
||||||
.get_or_insert_with(¶ms.clone().into(), &mut || {
|
.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");
|
.expect("Callback above only returns Some");
|
||||||
let content_mask = self.content_mask().scale(scale_factor);
|
let content_mask = self.content_mask().scale(scale_factor);
|
||||||
|
|
|
@ -77,7 +77,7 @@ impl ImageView {
|
||||||
let height = data.height();
|
let height = data.height();
|
||||||
let width = data.width();
|
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 {
|
return Ok(ImageView {
|
||||||
height,
|
height,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue