407 lines
11 KiB
Rust
407 lines
11 KiB
Rust
//! 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)
|
|
}
|
|
}
|