From ab88a5b59f2b5b5635abe58bb2ba4d009d512989 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Wed, 26 Jul 2023 16:32:23 -0400 Subject: [PATCH] feat: animated image widget --- Cargo.toml | 4 + src/widget/frames.rs | 407 +++++++++++++++++++++++++++++++++++++++++++ src/widget/mod.rs | 3 + 3 files changed, 414 insertions(+) create mode 100644 src/widget/frames.rs diff --git a/Cargo.toml b/Cargo.toml index 5d1140e4..68649e05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ winit = ["iced/winit", "iced_winit"] winit_tokio = ["iced/winit", "iced_winit", "tokio"] winit_debug = ["iced/winit", "iced_winit", "debug"] winit_wgpu = ["winit", "wgpu"] +animated-image = ["image", "dep:async-fs", "tokio?/io-util", "tokio?/fs"] [dependencies] apply = "0.3.0" @@ -30,6 +31,9 @@ slotmap = "1.0.6" fraction = "0.13.0" cosmic-config = { path = "cosmic-config" } tracing = "0.1" +image = { version = "0.24.6", optional = true } +thiserror = "1.0.44" +async-fs = { version = "1.6", optional = true } [target.'cfg(unix)'.dependencies] freedesktop-icons = "0.2.2" diff --git a/src/widget/frames.rs b/src/widget/frames.rs new file mode 100644 index 00000000..7be04d5d --- /dev/null +++ b/src/widget/frames.rs @@ -0,0 +1,407 @@ +//! Display an animated image in your user interface +//! Based on + +use std::ffi::OsStr; +use std::fmt; +use std::io; +use std::path::Path; +use std::time::{Duration, Instant}; + +use ::image as image_rs; +use iced_core::image::Renderer as ImageRenderer; +use iced_core::mouse::Cursor; +use iced_core::widget::{tree, Tree}; +use iced_core::{ + event, layout, renderer, window, Clipboard, ContentFit, Element, Event, Layout, Length, + Rectangle, Shell, Size, Vector, Widget, +}; +use iced_runtime::Command; +use iced_widget::image::{self, Handle}; +use image_rs::codecs::gif::GifDecoder; +use image_rs::codecs::png::PngDecoder; +use image_rs::codecs::webp::WebPDecoder; +use image_rs::AnimationDecoder; + +#[cfg(not(feature = "tokio"))] +use iced_futures::futures::{AsyncRead, AsyncReadExt}; +#[cfg(feature = "tokio")] +use tokio::io::{AsyncRead, AsyncReadExt}; + +use super::icon::load_icon; + +#[must_use] +/// Creates a new [`AnimatedImage`] with the given [`animated_image::Frames`] +pub fn animated_image(frames: &Frames) -> AnimatedImage { + AnimatedImage::new(frames) +} + +/// Error loading or decoding a animated_image +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Decode error + #[error(transparent)] + Image(#[from] image_rs::ImageError), + /// Load error + #[error(transparent)] + Io(#[from] std::io::Error), + /// Missing image + #[error("The image with the requested name is missing")] + Missing, + /// Unsupported Extension + #[error("The extension is unsupported")] + Extension, +} + +#[derive(Clone)] +/// The frames of a decoded gif +pub struct Frames { + first: Frame, + frames: Vec, + total_bytes: u64, +} + +impl fmt::Debug for Frames { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Frames").finish() + } +} + +impl Frames { + /// Load [`Frames`] from the supplied name + pub fn load_from_name( + name: &str, + size: u16, + theme: Option<&str>, + default_fallbacks: bool, + ) -> Command> { + let mut name_path_buffer = None; + if let Some(path) = load_icon(name, size, theme) { + name_path_buffer = Some(path); + } else if default_fallbacks { + for name in name.rmatch_indices('-').map(|(pos, _)| &name[..pos]) { + if let Some(path) = load_icon(name, size, theme) { + name_path_buffer = Some(path); + break; + } + } + }; + + if let Some(name_path_buffer) = name_path_buffer { + Self::load_from_path(name_path_buffer) + } else { + Command::perform(async { Err(Error::Missing) }, std::convert::identity) + } + } + + /// Load [`Frames`] from the supplied path + pub fn load_from_path(path: impl AsRef) -> Command> { + #[cfg(feature = "tokio")] + use tokio::fs::File; + #[cfg(feature = "tokio")] + use tokio::io::BufReader; + + #[cfg(not(feature = "tokio"))] + use async_fs::File; + #[cfg(not(feature = "tokio"))] + use iced_futures::futures::io::BufReader; + + let path = path.as_ref().to_path_buf(); + + let f = async move { + let image_type = match &path.extension() { + Some(ext) if ext == &OsStr::new("gif") => ImageType::Gif, + Some(ext) if ext == &OsStr::new("apng") => ImageType::Apng, + Some(ext) if ext == &OsStr::new("webp") => ImageType::WebP, + _ => return Err(Error::Extension), + }; + let reader = BufReader::new(File::open(path).await?); + + Self::from_reader(reader, image_type).await + }; + + Command::perform(f, std::convert::identity) + } + + /// Decode [`Frames`] from the supplied async reader + /// # Errors + /// If the type of image is not supported this function will error. IO errors may also occur. + pub async fn from_reader( + reader: R, + image_type: ImageType, + ) -> Result { + use iced_futures::futures::pin_mut; + + pin_mut!(reader); + + let mut bytes = vec![]; + + reader.read_to_end(&mut bytes).await?; + + match image_type { + ImageType::Gif => Self::from_decoder(GifDecoder::new(io::Cursor::new(bytes))?), + ImageType::Apng => Self::from_decoder(PngDecoder::new(io::Cursor::new(bytes))?.apng()), + ImageType::WebP => Self::from_decoder(WebPDecoder::new(io::Cursor::new(bytes))?), + } + } + + /// Decode [`Frames`] from the supplied bytes + /// # Errors + /// + /// IO errors may occur. + /// + /// # Panics + /// + /// If there are no frames in the image, this panics. + pub fn from_decoder<'a, T: AnimationDecoder<'a>>(decoder: T) -> Result { + let frames = decoder + .into_frames() + .map(|result| result.map(Frame::from)) + .collect::, _>>()?; + + let first = frames.first().cloned().unwrap(); + let total_bytes = frames + .iter() + .map(|f| match f.handle.data() { + iced_core::image::Data::Path(_) => 0, + iced_core::image::Data::Bytes(b) => b.len(), + iced_core::image::Data::Rgba { pixels, .. } => pixels.len(), + }) + .sum::() + .try_into() + .unwrap_or_default(); + Ok(Frames { + first, + frames, + total_bytes, + }) + } +} + +#[derive(Clone)] +struct Frame { + delay: Duration, + handle: image::Handle, +} + +impl From for Frame { + fn from(frame: image_rs::Frame) -> Self { + let (width, height) = frame.buffer().dimensions(); + + let delay = frame.delay().into(); + + let handle = image::Handle::from_pixels(width, height, frame.into_buffer().into_vec()); + + Self { delay, handle } + } +} + +struct State { + index: usize, + current: Current, + total_bytes: u64, +} + +struct Current { + frame: Frame, + started: Instant, +} + +impl From for Current { + fn from(frame: Frame) -> Self { + Self { + started: Instant::now(), + frame, + } + } +} + +/// A frame that displays an animated image while keeping aspect ratio +#[derive(Debug)] +pub struct AnimatedImage<'a> { + frames: &'a Frames, + width: Length, + height: Length, + content_fit: ContentFit, +} + +pub enum ImageType { + Gif, + Apng, + WebP, +} + +impl<'a> AnimatedImage<'a> { + #[must_use] + /// Creates a new [`AnimatedImage`] with the given [`Frames`] + pub fn new(frames: &'a Frames) -> Self { + AnimatedImage { + frames, + width: Length::Shrink, + height: Length::Shrink, + content_fit: ContentFit::Contain, + } + } + + #[must_use] + /// Sets the width of the [`AnimatedImage`] boundaries. + pub fn width(mut self, width: Length) -> Self { + self.width = width; + self + } + + #[must_use] + /// Sets the height of the [`AnimatedImage`] boundaries. + pub fn height(mut self, height: Length) -> Self { + self.height = height; + self + } + + #[must_use] + /// Sets the [`ContentFit`] of the [`AnimatedImage`]. + /// + /// Defaults to [`ContentFit::Contain`] + pub fn content_fit(self, content_fit: ContentFit) -> Self { + Self { + content_fit, + ..self + } + } +} + +impl<'a, Message, Renderer> Widget for AnimatedImage<'a> +where + Renderer: ImageRenderer, +{ + fn width(&self) -> Length { + self.width + } + + fn height(&self) -> Length { + self.height + } + + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State { + index: 0, + current: self.frames.first.clone().into(), + total_bytes: self.frames.total_bytes, + }) + } + + fn diff(&mut self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + + // Reset state if new gif Frames is used w/ + // same state tree. + // + // Total bytes of the gif should be a good enough + // proxy for it changing. + if state.total_bytes != self.frames.total_bytes { + *state = State { + index: 0, + current: self.frames.first.clone().into(), + total_bytes: self.frames.total_bytes, + }; + } + } + + fn layout(&self, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { + iced_widget::image::layout( + renderer, + limits, + &self.frames.first.handle, + self.width, + self.height, + self.content_fit, + ) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + _layout: Layout<'_>, + _cursor_position: Cursor, + _renderer: &Renderer, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let state = tree.state.downcast_mut::(); + + if let Event::Window(_, window::Event::RedrawRequested(now)) = event { + let elapsed = now.duration_since(state.current.started); + + if elapsed > state.current.frame.delay { + state.index = (state.index + 1) % self.frames.frames.len(); + + state.current = self.frames.frames[state.index].clone().into(); + + shell.request_redraw(window::RedrawRequest::At(now + state.current.frame.delay)); + } else { + let remaining = state.current.frame.delay - elapsed; + + shell.request_redraw(window::RedrawRequest::At(now + remaining)); + } + } + + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + _theme: &Renderer::Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor_position: Cursor, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + + // Pulled from iced_native::widget::::draw + // + // TODO: export iced_native::widget::image::draw as standalone function + { + let Size { width, height } = renderer.dimensions(&state.current.frame.handle); + let image_size = Size::new(width as f32, height as f32); + + let bounds = layout.bounds(); + let adjusted_fit = self.content_fit.fit(image_size, bounds.size()); + + let render = |renderer: &mut Renderer| { + let offset = Vector::new( + (bounds.width - adjusted_fit.width).max(0.0) / 2.0, + (bounds.height - adjusted_fit.height).max(0.0) / 2.0, + ); + + let drawing_bounds = Rectangle { + width: adjusted_fit.width, + height: adjusted_fit.height, + ..bounds + }; + + renderer.draw(state.current.frame.handle.clone(), drawing_bounds + offset); + }; + + if adjusted_fit.width > bounds.width || adjusted_fit.height > bounds.height { + renderer.with_layer(bounds, render); + } else { + render(renderer); + } + } + } +} + +impl<'a, Message, Renderer> From> for Element<'a, Message, Renderer> +where + Renderer: ImageRenderer + 'a, +{ + fn from(gif: AnimatedImage<'a>) -> Element<'a, Message, Renderer> { + Element::new(gif) + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 461e7251..5e96cdf9 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -20,6 +20,9 @@ pub use header_bar::{header_bar, HeaderBar}; pub mod icon; pub use icon::{icon, Icon, IconSource}; +#[cfg(feature = "animated-image")] +pub mod frames; + pub mod list; pub use list::*;