diff --git a/Cargo.lock b/Cargo.lock index 6f2de1d9f2d..1cfa6c70ba4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -819,6 +819,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecdffb913a326b6c642290a0d0ec8e8d6597291acdc07cc4c9cb4b3635d44cf9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "com" version = "0.6.0" @@ -1734,6 +1740,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.28.0" @@ -2079,6 +2095,8 @@ checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645" dependencies = [ "bytemuck", "byteorder", + "color_quant", + "gif", "num-traits", "png", "zune-core", @@ -4240,6 +4258,12 @@ version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wgpu" version = "0.20.1" diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index a2b29327b04..24b0252114d 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -319,8 +319,11 @@ impl Widget for Button<'_> { image.show_loading_spinner, image.image_options(), ); - response = - widgets::image::texture_load_result_response(image.source(), &tlr, response); + response = widgets::image::texture_load_result_response( + &image.source(ui.ctx()), + &tlr, + response, + ); } if image.is_some() && galley.is_some() { diff --git a/crates/egui/src/widgets/image.rs b/crates/egui/src/widgets/image.rs index a5aecdce931..7783f02fed9 100644 --- a/crates/egui/src/widgets/image.rs +++ b/crates/egui/src/widgets/image.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, sync::Arc, time::Duration}; use emath::{Float as _, Rot2}; use epaint::RectShape; @@ -40,6 +40,7 @@ use crate::{ /// .paint_at(ui, rect); /// # }); /// ``` +/// #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] #[derive(Debug, Clone)] pub struct Image<'a> { @@ -288,8 +289,20 @@ impl<'a> Image<'a> { } #[inline] - pub fn source(&self) -> &ImageSource<'a> { - &self.source + pub fn source(&'a self, ctx: &Context) -> ImageSource<'a> { + match &self.source { + ImageSource::Uri(uri) if is_gif_uri(uri) => { + let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); + ImageSource::Uri(Cow::Owned(frame_uri)) + } + + ImageSource::Bytes { uri, bytes } if is_gif_uri(uri) || has_gif_magic_header(bytes) => { + let frame_uri = encode_gif_uri(uri, gif_frame_index(ctx, uri)); + ctx.include_bytes(uri.clone(), bytes.clone()); + ImageSource::Uri(Cow::Owned(frame_uri)) + } + _ => self.source.clone(), + } } /// Load the image from its [`Image::source`], returning the resulting [`SizedTexture`]. @@ -300,7 +313,7 @@ impl<'a> Image<'a> { /// May fail if they underlying [`Context::try_load_texture`] call fails. pub fn load_for_size(&self, ctx: &Context, available_size: Vec2) -> TextureLoadResult { let size_hint = self.size.hint(available_size); - self.source + self.source(ctx) .clone() .load(ctx, self.texture_options, size_hint) } @@ -344,7 +357,7 @@ impl<'a> Widget for Image<'a> { &self.image_options, ); } - texture_load_result_response(&self.source, &tlr, response) + texture_load_result_response(&self.source(ui.ctx()), &tlr, response) } } @@ -769,3 +782,58 @@ pub fn paint_texture_at( } } } + +/// gif uris contain the uri & the frame that will be displayed +fn encode_gif_uri(uri: &str, frame_index: usize) -> String { + format!("{uri}#{frame_index}") +} + +/// extracts uri and frame index +/// # Errors +/// Will return `Err` if `uri` does not match pattern {uri}-{frame_index} +pub fn decode_gif_uri(uri: &str) -> Result<(&str, usize), String> { + let (uri, index) = uri + .rsplit_once('#') + .ok_or("Failed to find index separator '#'")?; + let index: usize = index + .parse() + .map_err(|_err| format!("Failed to parse gif frame index: {index:?} is not an integer"))?; + Ok((uri, index)) +} + +/// checks if uri is a gif file +fn is_gif_uri(uri: &str) -> bool { + uri.ends_with(".gif") || uri.contains(".gif#") +} + +/// checks if bytes are gifs +pub fn has_gif_magic_header(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + +/// calculates at which frame the gif is +fn gif_frame_index(ctx: &Context, uri: &str) -> usize { + let now = ctx.input(|i| Duration::from_secs_f64(i.time)); + + let durations: Option = ctx.data(|data| data.get_temp(Id::new(uri))); + if let Some(durations) = durations { + let frames: Duration = durations.0.iter().sum(); + let pos_ms = now.as_millis() % frames.as_millis().max(1); + let mut cumulative_ms = 0; + for (i, duration) in durations.0.iter().enumerate() { + cumulative_ms += duration.as_millis(); + if pos_ms < cumulative_ms { + let ms_until_next_frame = cumulative_ms - pos_ms; + ctx.request_repaint_after(Duration::from_millis(ms_until_next_frame as u64)); + return i; + } + } + 0 + } else { + 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +/// Stores the durations between each frame of a gif +pub struct GifFrameDurations(pub Arc>); diff --git a/crates/egui/src/widgets/image_button.rs b/crates/egui/src/widgets/image_button.rs index 65ef3072807..8b206757efa 100644 --- a/crates/egui/src/widgets/image_button.rs +++ b/crates/egui/src/widgets/image_button.rs @@ -125,6 +125,6 @@ impl<'a> Widget for ImageButton<'a> { .rect_stroke(rect.expand2(expansion), rounding, stroke); } - widgets::image::texture_load_result_response(self.image.source(), &tlr, response) + widgets::image::texture_load_result_response(&self.image.source(ui.ctx()), &tlr, response) } } diff --git a/crates/egui/src/widgets/mod.rs b/crates/egui/src/widgets/mod.rs index 86bfbc7c213..9900117062e 100644 --- a/crates/egui/src/widgets/mod.rs +++ b/crates/egui/src/widgets/mod.rs @@ -27,7 +27,10 @@ pub use self::{ checkbox::Checkbox, drag_value::DragValue, hyperlink::{Hyperlink, Link}, - image::{paint_texture_at, Image, ImageFit, ImageOptions, ImageSize, ImageSource}, + image::{ + decode_gif_uri, has_gif_magic_header, paint_texture_at, GifFrameDurations, Image, ImageFit, + ImageOptions, ImageSize, ImageSource, + }, image_button::ImageButton, label::Label, progress_bar::ProgressBar, diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 88d65652b5d..89de6abfe49 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -30,7 +30,7 @@ all-features = true default = ["dep:mime_guess2"] ## Shorthand for enabling the different types of image loaders (`file`, `http`, `image`, `svg`). -all_loaders = ["file", "http", "image", "svg"] +all_loaders = ["file", "http", "image", "svg", "gif"] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] @@ -38,6 +38,9 @@ datepicker = ["chrono"] ## Add support for loading images from `file://` URIs. file = ["dep:mime_guess2"] +## Support loading gif images. +gif = ["image", "image/gif"] + ## Add support for loading images via HTTP. http = ["dep:ehttp"] diff --git a/crates/egui_extras/src/loaders.rs b/crates/egui_extras/src/loaders.rs index d66ea483071..c119d217cee 100644 --- a/crates/egui_extras/src/loaders.rs +++ b/crates/egui_extras/src/loaders.rs @@ -78,6 +78,12 @@ pub fn install_image_loaders(ctx: &egui::Context) { log::trace!("installed ImageCrateLoader"); } + #[cfg(feature = "gif")] + if !ctx.is_loader_installed(self::gif_loader::GifLoader::ID) { + ctx.add_image_loader(std::sync::Arc::new(self::gif_loader::GifLoader::default())); + log::trace!("installed GifLoader"); + } + #[cfg(feature = "svg")] if !ctx.is_loader_installed(self::svg_loader::SvgLoader::ID) { ctx.add_image_loader(std::sync::Arc::new(self::svg_loader::SvgLoader::default())); @@ -101,8 +107,9 @@ mod file_loader; #[cfg(feature = "http")] mod ehttp_loader; +#[cfg(feature = "gif")] +mod gif_loader; #[cfg(feature = "image")] mod image_loader; - #[cfg(feature = "svg")] mod svg_loader; diff --git a/crates/egui_extras/src/loaders/gif_loader.rs b/crates/egui_extras/src/loaders/gif_loader.rs new file mode 100644 index 00000000000..4f8a120e854 --- /dev/null +++ b/crates/egui_extras/src/loaders/gif_loader.rs @@ -0,0 +1,134 @@ +use egui::{ + ahash::HashMap, + decode_gif_uri, has_gif_magic_header, + load::{BytesPoll, ImageLoadResult, ImageLoader, ImagePoll, LoadError, SizeHint}, + mutex::Mutex, + ColorImage, GifFrameDurations, Id, +}; +use image::AnimationDecoder as _; +use std::{io::Cursor, mem::size_of, sync::Arc, time::Duration}; + +/// Array of Frames and the duration for how long each frame should be shown +#[derive(Debug, Clone)] +pub struct AnimatedImage { + frames: Vec>, + frame_durations: GifFrameDurations, +} + +impl AnimatedImage { + fn load_gif(data: &[u8]) -> Result { + let decoder = image::codecs::gif::GifDecoder::new(Cursor::new(data)) + .map_err(|err| format!("Failed to decode gif: {err}"))?; + let mut images = vec![]; + let mut durations = vec![]; + for frame in decoder.into_frames() { + let frame = frame.map_err(|err| format!("Failed to decode gif: {err}"))?; + let img = frame.buffer(); + let pixels = img.as_flat_samples(); + + let delay: Duration = frame.delay().into(); + images.push(Arc::new(ColorImage::from_rgba_unmultiplied( + [img.width() as usize, img.height() as usize], + pixels.as_slice(), + ))); + durations.push(delay); + } + Ok(Self { + frames: images, + frame_durations: GifFrameDurations(Arc::new(durations)), + }) + } +} + +impl AnimatedImage { + pub fn byte_len(&self) -> usize { + size_of::() + + self + .frames + .iter() + .map(|image| { + image.pixels.len() * size_of::() + size_of::() + }) + .sum::() + } + + /// Gets image at index + pub fn get_image(&self, index: usize) -> Arc { + self.frames[index % self.frames.len()].clone() + } +} +type Entry = Result, String>; + +#[derive(Default)] +pub struct GifLoader { + cache: Mutex>, +} + +impl GifLoader { + pub const ID: &'static str = egui::generate_loader_id!(GifLoader); +} + +impl ImageLoader for GifLoader { + fn id(&self) -> &str { + Self::ID + } + + fn load(&self, ctx: &egui::Context, frame_uri: &str, _: SizeHint) -> ImageLoadResult { + let (image_uri, frame_index) = + decode_gif_uri(frame_uri).map_err(|_err| LoadError::NotSupported)?; + let mut cache = self.cache.lock(); + if let Some(entry) = cache.get(image_uri).cloned() { + match entry { + Ok(image) => Ok(ImagePoll::Ready { + image: image.get_image(frame_index), + }), + Err(err) => Err(LoadError::Loading(err)), + } + } else { + match ctx.try_load_bytes(image_uri) { + Ok(BytesPoll::Ready { bytes, .. }) => { + if !has_gif_magic_header(&bytes) { + return Err(LoadError::NotSupported); + } + log::trace!("started loading {image_uri:?}"); + let result = AnimatedImage::load_gif(&bytes).map(Arc::new); + if let Ok(v) = &result { + ctx.data_mut(|data| { + *data.get_temp_mut_or_default(Id::new(image_uri)) = + v.frame_durations.clone(); + }); + } + log::trace!("finished loading {image_uri:?}"); + cache.insert(image_uri.into(), result.clone()); + match result { + Ok(image) => Ok(ImagePoll::Ready { + image: image.get_image(frame_index), + }), + Err(err) => Err(LoadError::Loading(err)), + } + } + Ok(BytesPoll::Pending { size }) => Ok(ImagePoll::Pending { size }), + Err(err) => Err(err), + } + } + } + + fn forget(&self, uri: &str) { + let _ = self.cache.lock().remove(uri); + } + + fn forget_all(&self) { + self.cache.lock().clear(); + } + + fn byte_size(&self) -> usize { + self.cache + .lock() + .values() + .map(|v| match v { + Ok(v) => v.byte_len(), + Err(e) => e.len(), + }) + .sum() + } +} diff --git a/examples/images/src/ferris.gif b/examples/images/src/ferris.gif new file mode 100644 index 00000000000..55784ef4de0 Binary files /dev/null and b/examples/images/src/ferris.gif differ diff --git a/examples/images/src/main.rs b/examples/images/src/main.rs index a1a15a17c47..1f53734e05c 100644 --- a/examples/images/src/main.rs +++ b/examples/images/src/main.rs @@ -27,6 +27,7 @@ impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::CentralPanel::default().show(ctx, |ui| { egui::ScrollArea::both().show(ui, |ui| { + ui.image(egui::include_image!("ferris.gif")); ui.add( egui::Image::new("https://picsum.photos/seed/1.759706314/1024").rounding(10.0), );