root/src/gif_paintable/mod.rs

// adapted from (previous MIT version, now GPL3):
// https://github.com/gtk-rs/gtk4-rs/blob/main/examples/gif_paintable/gif_paintable/mod.rs
//
// Copyright 2026 nee <nee-git@hidamari.blue>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later

mod frame;
mod imp;

use std::io::Cursor;

pub use frame::Frame;
use gtk::{gdk, glib, prelude::*, subclass::prelude::*};
use image::{AnimationDecoder, codecs::gif::GifDecoder};

glib::wrapper! {
    pub struct GifPaintable(ObjectSubclass<imp::GifPaintable>) @implements gdk::Paintable;
}

impl Default for GifPaintable {
    fn default() -> Self {
        glib::Object::new()
    }
}

impl GifPaintable {
    // /// Loads the bytes of a GIF into the paintable.
    // ///
    // /// The loading consists of decoding the gif with a GIFDecoder, then storing
    // /// the frames so that the paintable can render them.
    // pub fn from_gif(bytes: &[u8]) -> Result<Self, Box<dyn std::error::Error>> {
    //     let this = Self::default();
    //     let imp = this.imp();
    //     imp.current_idx.set(0);

    //     if let Some(source_id) = imp.timeout_source_id.take() {
    //         source_id.remove();
    //     }

    //     let frames =
    //     imp.frames.replace(Some(frames));

    //     // make sure the first frame is queued to play
    //     this.setup_next_frame();

    //     Ok(this)
    // }

    pub fn gif_to_frames(bytes: &[u8]) -> Result<Vec<Frame>, Box<dyn std::error::Error>> {
        let read = Cursor::new(bytes);
        // Images from unknown origins make a program vulnerable to
        // decompression bombs. That is, malicious images crafted specifically
        // to require an enormous amount of memory to process while having a
        // disproportionately small file size.
        //
        // By default, `GifDecoder::new()` limits the allocation of a single
        // frame to 50MB, but it can be restricted further with
        // `GifDecoder::with_limits()`.
        //
        // An safety measure to guard against that would be to process each
        // frame as needed instead of loading them all with `collect_frames()`.
        let decoder = GifDecoder::new(read)?;

        let frames = decoder
            .into_frames()
            .collect_frames()?
            .into_iter()
            .map(Frame::from)
            .collect::<Vec<Frame>>();
        Ok(frames)
    }

    pub fn from_frames(frames: Vec<Frame>) -> Self {
        let this = Self::default();
        this.imp().frames.replace(Some(frames));
        this.setup_next_frame();
        this
    }

    fn setup_next_frame(&self) {
        let imp = self.imp();
        let idx = imp.current_idx.get();
        let frames_ref = imp.frames.borrow();

        // if we have stored no frames then we early return early
        // and instead render a default frame in `imp::GifPaintable::snapshot`
        let frames = match &*frames_ref {
            Some(frames) => frames,
            None => return,
        };

        let next_frame = frames.get(idx).unwrap();
        imp.next_frame.replace(Some(next_frame.texture.clone()));

        // invalidate the contents so that the new frame will be rendered
        self.invalidate_contents();

        // setup a callback to this function once the frame has finished so that
        // we can play the next frame
        let update_next_frame_callback = glib::clone!(
            #[strong(rename_to = paintable)]
            self,
            move || {
                paintable.imp().timeout_source_id.take();
                paintable.setup_next_frame();
            }
        );

        // setup the index for the next call to setup_next_frame
        let mut new_idx = idx + 1;
        if new_idx >= frames.len() {
            new_idx = 0;
        }
        imp.current_idx.set(new_idx);

        // TODO
        // actual timeouts are not a good fit for animation since they are fairly imprecise and are not synced with frame clock, so the frame pacing will be atrocious
        // use tick callback/AdwAnimation/etc instead!
        let source_id =
            glib::timeout_add_local_once(next_frame.frame_duration, update_next_frame_callback);
        imp.timeout_source_id.replace(Some(source_id));
    }
}