400 lines
11 KiB
Rust
400 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::Task;
|
|
use iced::mouse;
|
|
use iced_core::image::Renderer as ImageRenderer;
|
|
use iced_core::mouse::Cursor;
|
|
use iced_core::widget::{Tree, tree};
|
|
use iced_core::{
|
|
Clipboard, ContentFit, Element, Event, Layout, Length, Rectangle, Rotation, Shell, Size,
|
|
Widget, event, layout, renderer, window,
|
|
};
|
|
use iced_widget::image::{self, FilterMethod, Handle};
|
|
use image_rs::AnimationDecoder;
|
|
use image_rs::codecs::gif::GifDecoder;
|
|
use image_rs::codecs::png::PngDecoder;
|
|
use image_rs::codecs::webp::WebPDecoder;
|
|
|
|
#[cfg(not(feature = "tokio"))]
|
|
use iced_futures::futures::{AsyncRead, AsyncReadExt};
|
|
#[cfg(feature = "tokio")]
|
|
use tokio::io::{AsyncRead, AsyncReadExt};
|
|
|
|
use crate::widget::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 {
|
|
#[cold]
|
|
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,
|
|
) -> Task<Result<Frames, Error>> {
|
|
let mut name_path_buffer = None;
|
|
if let Some(path) = icon::Named::new(name).size(size).path() {
|
|
name_path_buffer = Some(path);
|
|
} else if default_fallbacks {
|
|
for name in name.rmatch_indices('-').map(|(pos, _)| &name[..pos]) {
|
|
if let Some(path) = icon::Named::new(name).size(size).path() {
|
|
name_path_buffer = Some(path);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Some(name_path_buffer) = name_path_buffer {
|
|
Self::load_from_path(name_path_buffer)
|
|
} else {
|
|
Task::perform(async { Err(Error::Missing) }, std::convert::identity)
|
|
}
|
|
}
|
|
|
|
/// Load [`Frames`] from the supplied path
|
|
pub fn load_from_path(path: impl AsRef<Path>) -> Task<Result<Frames, Error>> {
|
|
#[inline(never)]
|
|
fn inner(path: &Path) -> Task<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.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?);
|
|
|
|
Frames::from_reader(reader, image_type).await
|
|
};
|
|
|
|
Task::perform(f, std::convert::identity)
|
|
}
|
|
|
|
inner(path.as_ref())
|
|
}
|
|
|
|
/// 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 {
|
|
Handle::Path(..) => 0,
|
|
Handle::Bytes(_, b) => b.len(),
|
|
Handle::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_rgba(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, crate::Theme, Renderer> for AnimatedImage<'a>
|
|
where
|
|
Renderer: ImageRenderer<Handle = Handle>,
|
|
{
|
|
fn size(&self) -> Size<Length> {
|
|
Size::new(self.width.into(), self.height.into())
|
|
}
|
|
|
|
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(
|
|
&mut self,
|
|
tree: &mut Tree,
|
|
renderer: &Renderer,
|
|
limits: &layout::Limits,
|
|
) -> layout::Node {
|
|
iced_widget::image::layout(
|
|
renderer,
|
|
limits,
|
|
&self.frames.first.handle,
|
|
self.width,
|
|
self.height,
|
|
None,
|
|
self.content_fit,
|
|
Rotation::default(),
|
|
false,
|
|
[0.0; 4],
|
|
)
|
|
}
|
|
|
|
fn update(
|
|
&mut self,
|
|
tree: &mut Tree,
|
|
event: &Event,
|
|
layout: Layout<'_>,
|
|
cursor_position: mouse::Cursor,
|
|
renderer: &Renderer,
|
|
clipboard: &mut dyn Clipboard,
|
|
shell: &mut Shell<'_, Message>,
|
|
viewport: &Rectangle,
|
|
) {
|
|
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_at(window::RedrawRequest::At(*now + state.current.frame.delay));
|
|
} else {
|
|
let remaining = state.current.frame.delay - elapsed;
|
|
|
|
shell.request_redraw_at(window::RedrawRequest::At(*now + remaining));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw(
|
|
&self,
|
|
tree: &Tree,
|
|
renderer: &mut Renderer,
|
|
_theme: &crate::Theme,
|
|
_style: &renderer::Style,
|
|
layout: Layout<'_>,
|
|
_cursor_position: Cursor,
|
|
_viewport: &Rectangle,
|
|
) {
|
|
let state = tree.state.downcast_ref::<State>();
|
|
|
|
iced_widget::image::draw(
|
|
renderer,
|
|
layout,
|
|
&state.current.frame.handle,
|
|
None,
|
|
iced_core::border::Radius::default(),
|
|
self.content_fit,
|
|
FilterMethod::default(),
|
|
Rotation::default(),
|
|
1.0,
|
|
1.0,
|
|
);
|
|
}
|
|
}
|
|
|
|
impl<'a, Message, Renderer> From<AnimatedImage<'a>> for Element<'a, Message, crate::Theme, Renderer>
|
|
where
|
|
Renderer: ImageRenderer<Handle = Handle> + 'a,
|
|
{
|
|
fn from(gif: AnimatedImage<'a>) -> Element<'a, Message, crate::Theme, Renderer> {
|
|
Element::new(gif)
|
|
}
|
|
}
|