feat: animated image widget

This commit is contained in:
Ashley Wulber 2023-07-26 16:32:23 -04:00 committed by Ashley Wulber
parent dd3f421c72
commit ab88a5b59f
3 changed files with 414 additions and 0 deletions

View file

@ -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"

407
src/widget/frames.rs Normal file
View file

@ -0,0 +1,407 @@
//! Display an animated image in your user interface
//! Based on <https://github.com/tarkah/iced_gif/>
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<Frame>,
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<Result<Frames, Error>> {
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<Path>) -> Command<Result<Frames, Error>> {
#[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<R: AsyncRead>(
reader: R,
image_type: ImageType,
) -> Result<Self, Error> {
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<Self, Error> {
let frames = decoder
.into_frames()
.map(|result| result.map(Frame::from))
.collect::<Result<Vec<_>, _>>()?;
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::<usize>()
.try_into()
.unwrap_or_default();
Ok(Frames {
first,
frames,
total_bytes,
})
}
}
#[derive(Clone)]
struct Frame {
delay: Duration,
handle: image::Handle,
}
impl From<image_rs::Frame> 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<Frame> 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<Message, Renderer> for AnimatedImage<'a>
where
Renderer: ImageRenderer<Handle = Handle>,
{
fn width(&self) -> Length {
self.width
}
fn height(&self) -> Length {
self.height
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
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::<State>();
// 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::<State>();
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::<State>();
// Pulled from iced_native::widget::<Image as 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<AnimatedImage<'a>> for Element<'a, Message, Renderer>
where
Renderer: ImageRenderer<Handle = Handle> + 'a,
{
fn from(gif: AnimatedImage<'a>) -> Element<'a, Message, Renderer> {
Element::new(gif)
}
}

View file

@ -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::*;