├── .gitignore ├── assets ├── demo.gif └── rust-lang-ferris.gif ├── src ├── lib.rs ├── widget.rs └── widget │ └── gif.rs ├── example ├── Cargo.toml └── src │ └── main.rs ├── README.md ├── Cargo.toml └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarkah/iced_gif/HEAD/assets/demo.gif -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod widget; 2 | 3 | pub use widget::gif; 4 | pub use widget::gif::{Frames, Gif}; 5 | -------------------------------------------------------------------------------- /assets/rust-lang-ferris.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarkah/iced_gif/HEAD/assets/rust-lang-ferris.gif -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | pub mod gif; 2 | 3 | pub use gif::Gif; 4 | 5 | /// Creates a new [`Gif`] with the given [`gif::Frames`] 6 | pub fn gif(frames: &gif::Frames) -> Gif { 7 | Gif::new(frames) 8 | } 9 | -------------------------------------------------------------------------------- /example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "example" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["tarkah "] 6 | 7 | [features] 8 | default = ["iced_gif/default"] 9 | tokio = ["iced_gif/tokio", "iced/tokio"] 10 | 11 | [dependencies] 12 | iced_gif = { path = "../", default-features = false } 13 | iced = { version = "0.13", features = [ "image" ] } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Iced Gif 4 | 5 | [![Documentation](https://docs.rs/iced_gif/badge.svg)](https://docs.rs/iced_gif) 6 | [![Crates.io](https://img.shields.io/crates/v/iced_gif.svg)](https://crates.io/crates/iced_gif) 7 | [![License](https://img.shields.io/crates/l/iced_gif.svg)](https://github.com/tarkah/iced_gif/blob/master/LICENSE) 8 | 9 | A gif widget for [iced](https://github.com/iced-rs/iced) 10 | 11 | ![](https://github.com/tarkah/iced_gif/blob/master/assets/demo.gif) 12 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iced_gif" 3 | version = "0.13.0" 4 | edition = "2021" 5 | authors = ["tarkah "] 6 | description = "A GIF widget for Iced" 7 | license = "MIT" 8 | repository = "https://github.com/tarkah/iced_gif" 9 | 10 | [workspace] 11 | default-members = [ 12 | "./example" 13 | ] 14 | members = [ 15 | ".", 16 | "./example", 17 | ] 18 | 19 | [features] 20 | default = ["async-fs"] 21 | tokio = ["dep:tokio"] 22 | 23 | [dependencies] 24 | iced_widget = { version = "0.13", features = ["image"] } 25 | iced_futures = "0.13.0" 26 | thiserror = "1.0" 27 | 28 | [dependencies.async-fs] 29 | version = "1.6" 30 | optional = true 31 | 32 | [dependencies.tokio] 33 | version = "1" 34 | optional = true 35 | features = ["fs", "io-util"] 36 | 37 | [dependencies.image_rs] 38 | package = "image" 39 | version = "0.24" 40 | features = ["gif"] 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 tarkah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use iced::widget::{container, row}; 4 | use iced::{window, Element, Length, Size, Task}; 5 | use iced_gif::widget::gif; 6 | 7 | fn main() { 8 | iced::application(App::title, App::update, App::view) 9 | .window(window::Settings { 10 | size: Size::new(498.0, 164.0), 11 | ..Default::default() 12 | }) 13 | .run_with(App::new) 14 | .unwrap() 15 | } 16 | 17 | #[derive(Debug)] 18 | enum Message { 19 | Loaded(Result), 20 | } 21 | 22 | #[derive(Default)] 23 | struct App { 24 | frames: Option, 25 | } 26 | 27 | impl App { 28 | fn new() -> (Self, Task) { 29 | let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../assets/rust-lang-ferris.gif"); 30 | 31 | ( 32 | App::default(), 33 | gif::Frames::load_from_path(path).map(Message::Loaded), 34 | ) 35 | } 36 | 37 | fn title(&self) -> String { 38 | "Iced Gif".into() 39 | } 40 | 41 | fn update(&mut self, message: Message) -> Task { 42 | let Message::Loaded(frames) = message; 43 | 44 | self.frames = frames.ok(); 45 | 46 | Task::none() 47 | } 48 | 49 | fn view(&self) -> Element { 50 | if let Some(frames) = self.frames.as_ref() { 51 | container(gif(frames)) 52 | .center_x(Length::Fill) 53 | .center_y(Length::Fill) 54 | .into() 55 | } else { 56 | row![].into() 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/widget/gif.rs: -------------------------------------------------------------------------------- 1 | //! Display a GIF in your user interface 2 | use std::fmt; 3 | use std::io; 4 | use std::path::Path; 5 | use std::time::{Duration, Instant}; 6 | 7 | #[allow(unused)] 8 | use iced_widget::core::image::Image; 9 | use iced_widget::core::image::{self, FilterMethod, Handle}; 10 | use iced_widget::core::mouse::Cursor; 11 | use iced_widget::core::widget::{tree, Tree}; 12 | use iced_widget::core::{ 13 | event, layout, renderer, window, Clipboard, ContentFit, Element, Event, Layout, Length, Point, 14 | Rectangle, Rotation, Shell, Size, Vector, Widget, 15 | }; 16 | use iced_widget::runtime::Task; 17 | use image_rs::codecs::gif; 18 | use image_rs::{AnimationDecoder, ImageDecoder}; 19 | 20 | #[cfg(not(feature = "tokio"))] 21 | use iced_futures::futures::{AsyncRead, AsyncReadExt}; 22 | #[cfg(feature = "tokio")] 23 | use tokio::io::{AsyncRead, AsyncReadExt}; 24 | 25 | /// Error loading or decoding a gif 26 | #[derive(Debug, thiserror::Error)] 27 | pub enum Error { 28 | /// Decode error 29 | #[error(transparent)] 30 | Image(#[from] image_rs::ImageError), 31 | /// Load error 32 | #[error(transparent)] 33 | Io(#[from] std::io::Error), 34 | } 35 | 36 | /// The frames of a decoded gif 37 | pub struct Frames { 38 | first: Frame, 39 | frames: Vec, 40 | total_bytes: u64, 41 | } 42 | 43 | impl fmt::Debug for Frames { 44 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 | f.debug_struct("Frames").finish() 46 | } 47 | } 48 | 49 | impl Frames { 50 | /// Load [`Frames`] from the supplied path 51 | pub fn load_from_path(path: impl AsRef) -> Task> { 52 | #[cfg(feature = "tokio")] 53 | use tokio::fs::File; 54 | #[cfg(feature = "tokio")] 55 | use tokio::io::BufReader; 56 | 57 | #[cfg(not(feature = "tokio"))] 58 | use async_fs::File; 59 | #[cfg(not(feature = "tokio"))] 60 | use iced_futures::futures::io::BufReader; 61 | 62 | let path = path.as_ref().to_path_buf(); 63 | 64 | let f = async move { 65 | let reader = BufReader::new(File::open(path).await?); 66 | 67 | Self::from_reader(reader).await 68 | }; 69 | 70 | Task::perform(f, std::convert::identity) 71 | } 72 | 73 | /// Decode [`Frames`] from the supplied async reader 74 | pub async fn from_reader(reader: R) -> Result { 75 | use iced_futures::futures::pin_mut; 76 | 77 | pin_mut!(reader); 78 | 79 | let mut bytes = vec![]; 80 | 81 | reader.read_to_end(&mut bytes).await?; 82 | 83 | Self::from_bytes(bytes) 84 | } 85 | 86 | /// Decode [`Frames`] from the supplied bytes 87 | pub fn from_bytes(bytes: Vec) -> Result { 88 | let decoder = gif::GifDecoder::new(io::Cursor::new(bytes))?; 89 | 90 | let total_bytes = decoder.total_bytes(); 91 | 92 | let frames = decoder 93 | .into_frames() 94 | .into_iter() 95 | .map(|result| result.map(Frame::from)) 96 | .collect::, _>>()?; 97 | 98 | let first = frames.first().cloned().unwrap(); 99 | 100 | Ok(Frames { 101 | total_bytes, 102 | first, 103 | frames, 104 | }) 105 | } 106 | } 107 | 108 | #[derive(Clone)] 109 | struct Frame { 110 | delay: Duration, 111 | handle: image::Handle, 112 | } 113 | 114 | impl From for Frame { 115 | fn from(frame: image_rs::Frame) -> Self { 116 | let (width, height) = frame.buffer().dimensions(); 117 | 118 | let delay = frame.delay().into(); 119 | 120 | let handle = image::Handle::from_rgba(width, height, frame.into_buffer().into_vec()); 121 | 122 | Self { delay, handle } 123 | } 124 | } 125 | 126 | struct State { 127 | index: usize, 128 | current: Current, 129 | total_bytes: u64, 130 | } 131 | 132 | struct Current { 133 | frame: Frame, 134 | started: Instant, 135 | } 136 | 137 | impl From for Current { 138 | fn from(frame: Frame) -> Self { 139 | Self { 140 | started: Instant::now(), 141 | frame, 142 | } 143 | } 144 | } 145 | 146 | /// A frame that displays a GIF while keeping aspect ratio 147 | #[derive(Debug)] 148 | pub struct Gif<'a> { 149 | frames: &'a Frames, 150 | width: Length, 151 | height: Length, 152 | content_fit: ContentFit, 153 | filter_method: FilterMethod, 154 | rotation: Rotation, 155 | opacity: f32, 156 | } 157 | 158 | impl<'a> Gif<'a> { 159 | /// Creates a new [`Gif`] with the given [`Frames`] 160 | pub fn new(frames: &'a Frames) -> Self { 161 | Gif { 162 | frames, 163 | width: Length::Shrink, 164 | height: Length::Shrink, 165 | content_fit: ContentFit::default(), 166 | filter_method: FilterMethod::default(), 167 | rotation: Rotation::default(), 168 | opacity: 1.0, 169 | } 170 | } 171 | 172 | /// Sets the width of the [`Gif`] boundaries. 173 | pub fn width(mut self, width: Length) -> Self { 174 | self.width = width; 175 | self 176 | } 177 | 178 | /// Sets the height of the [`Gif`] boundaries. 179 | pub fn height(mut self, height: Length) -> Self { 180 | self.height = height; 181 | self 182 | } 183 | 184 | /// Sets the [`ContentFit`] of the [`Image`]. 185 | /// 186 | /// Defaults to [`ContentFit::Contain`] 187 | pub fn content_fit(mut self, content_fit: ContentFit) -> Self { 188 | self.content_fit = content_fit; 189 | self 190 | } 191 | 192 | /// Sets the [`FilterMethod`] of the [`Image`]. 193 | pub fn filter_method(mut self, filter_method: FilterMethod) -> Self { 194 | self.filter_method = filter_method; 195 | self 196 | } 197 | 198 | /// Applies the given [`Rotation`] to the [`Image`]. 199 | pub fn rotation(mut self, rotation: impl Into) -> Self { 200 | self.rotation = rotation.into(); 201 | self 202 | } 203 | 204 | /// Sets the opacity of the [`Image`]. 205 | /// 206 | /// It should be in the [0.0, 1.0] range—`0.0` meaning completely transparent, 207 | /// and `1.0` meaning completely opaque. 208 | pub fn opacity(mut self, opacity: impl Into) -> Self { 209 | self.opacity = opacity.into(); 210 | self 211 | } 212 | } 213 | 214 | impl<'a, Message, Theme, Renderer> Widget for Gif<'a> 215 | where 216 | Renderer: image::Renderer, 217 | { 218 | fn size(&self) -> Size { 219 | Size::new(self.width, self.height) 220 | } 221 | 222 | fn tag(&self) -> tree::Tag { 223 | tree::Tag::of::() 224 | } 225 | 226 | fn state(&self) -> tree::State { 227 | tree::State::new(State { 228 | index: 0, 229 | current: self.frames.first.clone().into(), 230 | total_bytes: self.frames.total_bytes, 231 | }) 232 | } 233 | 234 | fn diff(&self, tree: &mut Tree) { 235 | let state = tree.state.downcast_mut::(); 236 | 237 | // Reset state if new gif Frames is used w/ 238 | // same state tree. 239 | // 240 | // Total bytes of the gif should be a good enough 241 | // proxy for it changing. 242 | if state.total_bytes != self.frames.total_bytes { 243 | *state = State { 244 | index: 0, 245 | current: self.frames.first.clone().into(), 246 | total_bytes: self.frames.total_bytes, 247 | }; 248 | } 249 | } 250 | 251 | fn layout( 252 | &self, 253 | _tree: &mut Tree, 254 | renderer: &Renderer, 255 | limits: &layout::Limits, 256 | ) -> layout::Node { 257 | iced_widget::image::layout( 258 | renderer, 259 | limits, 260 | &self.frames.first.handle, 261 | self.width, 262 | self.height, 263 | self.content_fit, 264 | self.rotation, 265 | ) 266 | } 267 | 268 | fn on_event( 269 | &mut self, 270 | tree: &mut Tree, 271 | event: Event, 272 | _layout: Layout<'_>, 273 | _cursor: Cursor, 274 | _renderer: &Renderer, 275 | _clipboard: &mut dyn Clipboard, 276 | shell: &mut Shell<'_, Message>, 277 | _viewport: &Rectangle, 278 | ) -> event::Status { 279 | let state = tree.state.downcast_mut::(); 280 | 281 | if let Event::Window(window::Event::RedrawRequested(now)) = event { 282 | let elapsed = now.duration_since(state.current.started); 283 | 284 | if elapsed > state.current.frame.delay { 285 | state.index = (state.index + 1) % self.frames.frames.len(); 286 | 287 | state.current = self.frames.frames[state.index].clone().into(); 288 | 289 | shell.request_redraw(window::RedrawRequest::At(now + state.current.frame.delay)); 290 | } else { 291 | let remaining = state.current.frame.delay - elapsed; 292 | 293 | shell.request_redraw(window::RedrawRequest::At(now + remaining)); 294 | } 295 | } 296 | 297 | event::Status::Ignored 298 | } 299 | 300 | fn draw( 301 | &self, 302 | tree: &Tree, 303 | renderer: &mut Renderer, 304 | _theme: &Theme, 305 | _style: &renderer::Style, 306 | layout: Layout<'_>, 307 | _cursor: Cursor, 308 | _viewport: &Rectangle, 309 | ) { 310 | let state = tree.state.downcast_ref::(); 311 | 312 | // Pulled from iced_native::widget::::draw 313 | // 314 | // TODO: export iced_native::widget::image::draw as standalone function 315 | { 316 | let Size { width, height } = renderer.measure_image(&state.current.frame.handle); 317 | let image_size = Size::new(width as f32, height as f32); 318 | let rotated_size = self.rotation.apply(image_size); 319 | 320 | let bounds = layout.bounds(); 321 | let adjusted_fit = self.content_fit.fit(rotated_size, bounds.size()); 322 | 323 | let scale = Vector::new( 324 | adjusted_fit.width / rotated_size.width, 325 | adjusted_fit.height / rotated_size.height, 326 | ); 327 | 328 | let final_size = image_size * scale; 329 | 330 | let position = match self.content_fit { 331 | ContentFit::None => Point::new( 332 | bounds.x + (rotated_size.width - adjusted_fit.width) / 2.0, 333 | bounds.y + (rotated_size.height - adjusted_fit.height) / 2.0, 334 | ), 335 | _ => Point::new( 336 | bounds.center_x() - final_size.width / 2.0, 337 | bounds.center_y() - final_size.height / 2.0, 338 | ), 339 | }; 340 | 341 | let drawing_bounds = Rectangle::new(position, final_size); 342 | 343 | let render = |renderer: &mut Renderer| { 344 | renderer.draw_image( 345 | image::Image { 346 | handle: state.current.frame.handle.clone(), 347 | filter_method: self.filter_method, 348 | rotation: self.rotation.radians(), 349 | opacity: self.opacity, 350 | snap: true, 351 | }, 352 | drawing_bounds, 353 | ); 354 | }; 355 | 356 | if adjusted_fit.width > bounds.width || adjusted_fit.height > bounds.height { 357 | renderer.with_layer(bounds, render); 358 | } else { 359 | render(renderer); 360 | } 361 | } 362 | } 363 | } 364 | 365 | impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> 366 | where 367 | Renderer: image::Renderer + 'a, 368 | { 369 | fn from(gif: Gif<'a>) -> Element<'a, Message, Theme, Renderer> { 370 | Element::new(gif) 371 | } 372 | } 373 | --------------------------------------------------------------------------------