use image::codecs::gif::GifDecoder;
use image::{AnimationDecoder, DynamicImage, ImageDecoder};
use ratatui::layout::Rect;
use ratatui::Frame as RtFrame;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::{Resize, StatefulImage};
use std::fs::File;
use std::io::{self, BufReader};
use std::path::Path;
use std::time::{Duration, Instant};
pub struct Frame {
pub img: DynamicImage,
pub delay: Duration,
}
pub const MAX_FRAMES: usize = 300;
pub const MIN_DELAY: Duration = Duration::from_millis(20);
pub struct ImagePane {
picker: Picker,
proto: Option<StatefulProtocol>,
frames: Vec<Frame>,
idx: usize,
last_advance: Option<Instant>,
}
impl ImagePane {
pub fn new() -> io::Result<Self> {
let picker = Picker::from_query_stdio()
.map_err(|e| io::Error::other(format!("graphics probe failed: {e}")))?;
Ok(Self {
picker,
proto: None,
frames: Vec::new(),
idx: 0,
last_advance: None,
})
}
pub fn set(&mut self, img: DynamicImage) {
self.proto = Some(self.picker.new_resize_protocol(img.clone()));
self.frames = vec![Frame {
img,
delay: Duration::ZERO,
}];
self.idx = 0;
self.last_advance = None;
}
pub fn set_animation(&mut self, frames: Vec<Frame>) {
if frames.is_empty() {
return;
}
self.proto = Some(self.picker.new_resize_protocol(frames[0].img.clone()));
self.frames = frames;
self.idx = 0;
self.last_advance = None;
}
pub fn tick(&mut self, now: Instant) -> bool {
if self.frames.len() <= 1 {
return false;
}
let Some(last) = self.last_advance else {
self.last_advance = Some(now);
return false;
};
if should_advance(
now.saturating_duration_since(last),
self.frames[self.idx].delay,
) {
self.idx = (self.idx + 1) % self.frames.len();
self.last_advance = Some(now);
self.proto = Some(
self.picker
.new_resize_protocol(self.frames[self.idx].img.clone()),
);
true
} else {
false
}
}
pub fn is_animated(&self) -> bool {
self.frames.len() > 1
}
pub fn render(&mut self, f: &mut RtFrame, area: Rect) {
if let Some(p) = self.proto.as_mut() {
let widget = StatefulImage::<StatefulProtocol>::default().resize(Resize::Fit(None));
f.render_stateful_widget(widget, area, p);
}
}
}
fn should_advance(elapsed: Duration, delay: Duration) -> bool {
elapsed >= delay
}
fn frame_delay(numer_ms: u32, denom_ms: u32) -> Duration {
let ms = if denom_ms == 0 {
0
} else {
u64::from(numer_ms) / u64::from(denom_ms)
};
Duration::from_millis(ms).max(MIN_DELAY)
}
pub fn decode_frames(path: &Path) -> Option<Vec<Frame>> {
let file = File::open(path).ok()?;
let mut decoder = GifDecoder::new(BufReader::new(file)).ok()?;
decoder.set_limits(crate::util::image_limits()).ok()?;
let raw = decoder.into_frames().collect_frames().ok()?;
if raw.len() < 2 || raw.len() > MAX_FRAMES {
return None;
}
let frames = raw
.into_iter()
.map(|f| {
let (numer, denom) = f.delay().numer_denom_ms();
Frame {
img: DynamicImage::ImageRgba8(f.into_buffer()),
delay: frame_delay(numer, denom),
}
})
.collect();
Some(frames)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn advance_only_when_delay_elapsed() {
let delay = Duration::from_millis(50);
assert!(should_advance(delay, delay));
assert!(should_advance(Duration::from_millis(51), delay));
assert!(!should_advance(Duration::from_millis(49), delay));
assert!(should_advance(Duration::ZERO, Duration::ZERO));
}
#[test]
fn delay_floors_to_min_and_survives_zero_denominator() {
assert_eq!(frame_delay(0, 100), MIN_DELAY);
assert_eq!(frame_delay(10, 1), MIN_DELAY);
assert_eq!(frame_delay(100, 0), MIN_DELAY);
assert_eq!(frame_delay(100, 1), Duration::from_millis(100));
assert_eq!(frame_delay(1000, 3), Duration::from_millis(333));
}
}