Skip to main content

rlvgl_widgets/
anim_image.rs

1//! Tick-driven animated image widget.
2//!
3//! [`AnimImage`] displays a sequence of [`ImageDescriptor`] frames, advancing
4//! them deterministically on [`Event::Tick`] using a local integer frame phase
5//! — exactly the same pattern as [`crate::spinner::Spinner`] (`phase_tick`
6//! incremented on each `Tick`, frame index derived without wall-clock time).
7//!
8//! ## Frame source
9//!
10//! Frames are supplied by the caller as a [`FrameSource`]: either a
11//! heap-resident `Vec<ImageDescriptor<'static>>` obtained by decoding a GIF or
12//! APNG through the `core::plugins::{gif,apng}` decode functions (std-only,
13//! done once at load time outside this widget), or a borrow of a static frame
14//! array for embedded targets.  The widget never decodes image data internally.
15//!
16//! ## Tick model
17//!
18//! Each `Event::Tick` increments an internal `frame_tick` counter.  Every
19//! `ticks_per_frame` ticks (clamped to ≥ 1) the displayed frame index advances.
20//! When `loop_mode` is [`AnimImageLoopMode::Once`], the widget stops at the
21//! last frame and fires the `on_complete` callback.  [`AnimImageLoopMode::Bounce`]
22//! reverses direction at each end.  `set_reverse(true)` permanently reverses
23//! the initial direction.
24//!
25//! ## Drawing
26//!
27//! `Part::MAIN` — widget background.
28//! `Part::INDICATOR` — current frame blitted into `bounds` via
29//! [`Renderer::blit_image`].
30
31use alloc::boxed::Box;
32use alloc::vec::Vec;
33
34use rlvgl_core::draw::draw_widget_bg;
35use rlvgl_core::event::Event;
36use rlvgl_core::image::{BlitOpts, ImageDescriptor};
37use rlvgl_core::renderer::Renderer;
38use rlvgl_core::style::Style;
39use rlvgl_core::widget::{Rect, Widget};
40
41// ──────────────────────────────────────────────────────────────────────────
42// Public enumerations
43// ──────────────────────────────────────────────────────────────────────────
44
45/// Source of pre-decoded [`ImageDescriptor`] frames for [`AnimImage`].
46///
47/// The widget never decodes image data; frames are provided by the caller.
48/// For `std` targets, decode GIF/APNG once using `rlvgl_core::plugins::gif` /
49/// `rlvgl_core::plugins::apng` and convert each frame to an
50/// `ImageDescriptor<'static>` before constructing [`AnimImage`].
51pub enum FrameSource {
52    /// Heap-resident frames decoded at load time.
53    Decoded(Vec<ImageDescriptor<'static>>),
54    /// Statically allocated frame array for `no_std` / ROM targets.
55    Static(&'static [ImageDescriptor<'static>]),
56}
57
58impl FrameSource {
59    /// Return the number of frames available.
60    pub fn frame_count(&self) -> usize {
61        match self {
62            FrameSource::Decoded(v) => v.len(),
63            FrameSource::Static(s) => s.len(),
64        }
65    }
66
67    /// Return the frame at `index`, or `None` when out of bounds.
68    pub fn get(&self, index: usize) -> Option<&ImageDescriptor<'static>> {
69        match self {
70            FrameSource::Decoded(v) => v.get(index),
71            FrameSource::Static(s) => s.get(index),
72        }
73    }
74}
75
76/// Playback state for [`AnimImage`].
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum AnimPlayState {
79    /// Animation is playing; `Event::Tick` advances the frame counter.
80    Running,
81    /// Animation is paused; `Event::Tick` is ignored.
82    Paused,
83}
84
85/// Frame-advance loop mode for [`AnimImage`].
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum AnimImageLoopMode {
88    /// Wrap back to frame 0 after the last frame (infinite loop).
89    Loop,
90    /// Play once and stop at the last frame; fires `on_complete`.
91    Once,
92    /// Ping-pong: reverse direction at each end.
93    Bounce,
94}
95
96// ──────────────────────────────────────────────────────────────────────────
97// AnimImage
98// ──────────────────────────────────────────────────────────────────────────
99
100/// Tick-driven animated image widget.
101///
102/// See the [module-level documentation](self) for the full design contract.
103pub struct AnimImage {
104    bounds: Rect,
105    /// Visual style for the widget background (`Part::MAIN`).
106    pub style: Style,
107    frames: FrameSource,
108    /// Number of `Event::Tick`s that must accumulate before the frame index
109    /// advances.  Always `≥ 1` (zero is clamped on write).
110    ticks_per_frame: u32,
111    /// Local tick counter, reset each full frame period.
112    frame_tick: u32,
113    /// Current displayed frame index.
114    frame_index: usize,
115    play_state: AnimPlayState,
116    loop_mode: AnimImageLoopMode,
117    /// When `true`, frames advance in decreasing index order.
118    reverse: bool,
119    /// Bounce direction: `true` means currently advancing forward.
120    bounce_forward: bool,
121    /// Optional callback fired when `AnimImageLoopMode::Once` reaches the
122    /// last frame.
123    on_complete: Option<Box<dyn FnMut()>>,
124}
125
126impl AnimImage {
127    /// Create an animated image at `bounds` from the provided `frames`.
128    ///
129    /// Default: `Running`, `Loop`, 3 ticks/frame, forward direction.
130    pub fn new(bounds: Rect, frames: FrameSource) -> Self {
131        Self {
132            bounds,
133            style: Style::default(),
134            frames,
135            ticks_per_frame: 3,
136            frame_tick: 0,
137            frame_index: 0,
138            play_state: AnimPlayState::Running,
139            loop_mode: AnimImageLoopMode::Loop,
140            reverse: false,
141            bounce_forward: true,
142            on_complete: None,
143        }
144    }
145
146    // ── playback speed ─────────────────────────────────────────────────────
147
148    /// Set the number of ticks between frame advances.  `0` is clamped to `1`.
149    pub fn set_ticks_per_frame(&mut self, n: u32) {
150        self.ticks_per_frame = n.max(1);
151        // Keep frame_tick in range.
152        self.frame_tick = self.frame_tick.min(self.ticks_per_frame - 1);
153    }
154
155    /// Number of ticks between frame advances (always ≥ 1).
156    pub fn ticks_per_frame(&self) -> u32 {
157        self.ticks_per_frame
158    }
159
160    // ── frame access ───────────────────────────────────────────────────────
161
162    /// Number of frames in the animation (may be zero for an empty source).
163    pub fn frame_count(&self) -> usize {
164        self.frames.frame_count()
165    }
166
167    /// Index of the currently displayed frame.
168    pub fn current_frame_index(&self) -> usize {
169        self.frame_index
170    }
171
172    /// Force-set the displayed frame index.  Out-of-bounds values are clamped
173    /// to `frame_count().saturating_sub(1)`.
174    pub fn set_current_frame(&mut self, index: usize) {
175        let count = self.frames.frame_count();
176        self.frame_index = if count == 0 { 0 } else { index.min(count - 1) };
177    }
178
179    // ── play / pause ───────────────────────────────────────────────────────
180
181    /// Start playback (equivalent to LVGL `lv_animimg_start`).
182    pub fn play(&mut self) {
183        self.play_state = AnimPlayState::Running;
184    }
185
186    /// Pause playback (equivalent to LVGL `lv_animimg_stop`).
187    pub fn pause(&mut self) {
188        self.play_state = AnimPlayState::Paused;
189    }
190
191    /// Set the play state directly.
192    pub fn set_play_state(&mut self, state: AnimPlayState) {
193        self.play_state = state;
194    }
195
196    /// Current play state.
197    pub fn play_state(&self) -> AnimPlayState {
198        self.play_state
199    }
200
201    /// Return `true` when the animation is currently running.
202    pub fn is_playing(&self) -> bool {
203        self.play_state == AnimPlayState::Running
204    }
205
206    // ── loop mode ──────────────────────────────────────────────────────────
207
208    /// Set the loop mode.
209    pub fn set_loop_mode(&mut self, mode: AnimImageLoopMode) {
210        self.loop_mode = mode;
211    }
212
213    /// Current loop mode.
214    pub fn loop_mode(&self) -> AnimImageLoopMode {
215        self.loop_mode
216    }
217
218    // ── direction ──────────────────────────────────────────────────────────
219
220    /// When `true`, the initial playback direction is reversed (frame index
221    /// decrements toward 0 rather than incrementing).
222    pub fn set_reverse(&mut self, reverse: bool) {
223        self.reverse = reverse;
224        // Keep bounce state in sync with the new preferred direction.
225        self.bounce_forward = !reverse;
226    }
227
228    // ── completion callback ────────────────────────────────────────────────
229
230    /// Register a callback fired when `AnimImageLoopMode::Once` reaches the
231    /// last frame.  Replaces any previously registered callback.
232    pub fn on_complete<F: FnMut() + 'static>(mut self, handler: F) -> Self {
233        self.on_complete = Some(Box::new(handler));
234        self
235    }
236
237    // ── internal advance ──────────────────────────────────────────────────
238
239    /// Advance the frame counter by one tick.  Returns `true` when the frame
240    /// index actually changed so the caller can schedule a repaint.
241    fn advance_tick(&mut self) -> bool {
242        let count = self.frames.frame_count();
243        if count == 0 {
244            return false;
245        }
246
247        self.frame_tick += 1;
248        if self.frame_tick < self.ticks_per_frame {
249            return false; // Not yet time for the next frame.
250        }
251        self.frame_tick = 0;
252
253        let prev = self.frame_index;
254        // Determine the effective advance direction for this tick.
255        let forward = if self.loop_mode == AnimImageLoopMode::Bounce {
256            self.bounce_forward
257        } else {
258            !self.reverse
259        };
260
261        if forward {
262            if self.frame_index + 1 < count {
263                self.frame_index += 1;
264            } else {
265                // Reached the last frame.
266                match self.loop_mode {
267                    AnimImageLoopMode::Loop => {
268                        self.frame_index = 0;
269                    }
270                    AnimImageLoopMode::Once => {
271                        // Stay on the last frame; fire completion callback.
272                        self.play_state = AnimPlayState::Paused;
273                        if let Some(cb) = &mut self.on_complete {
274                            cb();
275                        }
276                    }
277                    AnimImageLoopMode::Bounce => {
278                        self.bounce_forward = false;
279                        if count >= 2 {
280                            self.frame_index = count - 2;
281                        }
282                    }
283                }
284            }
285        } else if self.frame_index > 0 {
286            self.frame_index -= 1;
287        } else {
288            // Reached frame 0 going backward.
289            match self.loop_mode {
290                AnimImageLoopMode::Loop => {
291                    self.frame_index = count - 1;
292                }
293                AnimImageLoopMode::Once => {
294                    self.play_state = AnimPlayState::Paused;
295                    if let Some(cb) = &mut self.on_complete {
296                        cb();
297                    }
298                }
299                AnimImageLoopMode::Bounce => {
300                    self.bounce_forward = true;
301                    if count >= 2 {
302                        self.frame_index = 1;
303                    }
304                }
305            }
306        }
307
308        self.frame_index != prev
309    }
310}
311
312impl Widget for AnimImage {
313    fn bounds(&self) -> Rect {
314        self.bounds
315    }
316
317    /// Draw the current animation frame.
318    ///
319    /// `Part::MAIN` — widget background.
320    /// `Part::INDICATOR` — current frame via [`Renderer::blit_image`].
321    fn draw(&self, renderer: &mut dyn Renderer) {
322        draw_widget_bg(renderer, self.bounds, &self.style);
323        if let Some(frame) = self.frames.get(self.frame_index) {
324            renderer.blit_image(self.bounds, frame, &BlitOpts::default());
325        }
326    }
327
328    /// Handle events.  Returns `true` when [`Event::Tick`] is consumed
329    /// (matching the Spinner pattern: Tick is always consumed when `Running`).
330    fn handle_event(&mut self, event: &Event) -> bool {
331        if matches!(event, Event::Tick) && self.play_state == AnimPlayState::Running {
332            self.advance_tick();
333            return true;
334        }
335        false
336    }
337
338    /// Adopt a layout-computed bounds rect.
339    fn set_bounds(&mut self, bounds: Rect) {
340        self.bounds = bounds;
341    }
342}
343
344// ──────────────────────────────────────────────────────────────────────────
345// Tests
346// ──────────────────────────────────────────────────────────────────────────
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use rlvgl_core::image::{ImageData, PixelFormat};
352    use rlvgl_core::widget::Color;
353
354    // ── helpers ────────────────────────────────────────────────────────────
355
356    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
357        Rect {
358            x,
359            y,
360            width: w,
361            height: h,
362        }
363    }
364
365    /// Build a trivially small static frame list (colors are dummies).
366    fn make_frames(n: usize) -> FrameSource {
367        // We need 'static descriptors — use Borrowed(&[]) as empty pixel data.
368        let mut frames: Vec<ImageDescriptor<'static>> = Vec::new();
369        for _ in 0..n {
370            frames.push(ImageDescriptor::new(
371                PixelFormat::Argb8888,
372                1,
373                1,
374                ImageData::Borrowed(&[]),
375                None,
376            ));
377        }
378        FrameSource::Decoded(frames)
379    }
380
381    fn tick(anim: &mut AnimImage) -> bool {
382        anim.handle_event(&Event::Tick)
383    }
384
385    // ── tick cadence ───────────────────────────────────────────────────────
386
387    #[test]
388    fn tick_advances_frame_at_right_cadence() {
389        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
390        a.set_ticks_per_frame(3);
391        // First two ticks should NOT advance the frame.
392        assert!(tick(&mut a)); // consumed, no frame change
393        assert_eq!(a.current_frame_index(), 0);
394        assert!(tick(&mut a));
395        assert_eq!(a.current_frame_index(), 0);
396        // Third tick crosses the period boundary.
397        assert!(tick(&mut a));
398        assert_eq!(a.current_frame_index(), 1);
399    }
400
401    #[test]
402    fn zero_period_is_clamped_to_one() {
403        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
404        a.set_ticks_per_frame(0);
405        assert_eq!(a.ticks_per_frame(), 1);
406        tick(&mut a);
407        assert_eq!(a.current_frame_index(), 1, "advances every tick");
408    }
409
410    // ── loop wrap ──────────────────────────────────────────────────────────
411
412    #[test]
413    fn loop_mode_wraps_at_last_frame() {
414        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
415        a.set_ticks_per_frame(1);
416        a.set_loop_mode(AnimImageLoopMode::Loop);
417        tick(&mut a); // → 1
418        tick(&mut a); // → 2
419        tick(&mut a); // → wraps to 0
420        assert_eq!(a.current_frame_index(), 0);
421        assert!(a.is_playing());
422    }
423
424    // ── once mode ─────────────────────────────────────────────────────────
425
426    #[test]
427    fn once_mode_stops_at_last_frame() {
428        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
429        a.set_ticks_per_frame(1);
430        a.set_loop_mode(AnimImageLoopMode::Once);
431        tick(&mut a); // → 1
432        tick(&mut a); // → 2 (last)
433        tick(&mut a); // stays at 2, pauses
434        assert_eq!(a.current_frame_index(), 2);
435        assert_eq!(a.play_state(), AnimPlayState::Paused);
436    }
437
438    #[test]
439    fn once_mode_fires_on_complete() {
440        use alloc::rc::Rc;
441        use core::cell::Cell;
442        let fired = Rc::new(Cell::new(0u32));
443        let fired_clone = fired.clone();
444        // 3-frame animation: indices 0, 1, 2.
445        // Tick 1: 0→1, Tick 2: 1→2, Tick 3: would go past 2 → fires on_complete.
446        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3))
447            .on_complete(move || fired_clone.set(fired_clone.get() + 1));
448        a.set_ticks_per_frame(1);
449        a.set_loop_mode(AnimImageLoopMode::Once);
450        tick(&mut a); // 0→1
451        tick(&mut a); // 1→2 (last frame)
452        assert_eq!(a.current_frame_index(), 2);
453        assert_eq!(
454            fired.get(),
455            0,
456            "on_complete not fired until attempt to advance past last"
457        );
458        tick(&mut a); // attempt to advance past last → fires on_complete, stays at 2
459        assert_eq!(fired.get(), 1, "on_complete fired exactly once");
460        assert_eq!(a.play_state(), AnimPlayState::Paused);
461    }
462
463    // ── bounce mode ────────────────────────────────────────────────────────
464
465    #[test]
466    fn bounce_mode_reverses_at_endpoints() {
467        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
468        a.set_ticks_per_frame(1);
469        a.set_loop_mode(AnimImageLoopMode::Bounce);
470        // Forward: 0 → 1 → 2 → reverses → 1 → 0 → reverses → 1
471        let expected = [1, 2, 1, 0, 1];
472        for &exp in &expected {
473            tick(&mut a);
474            assert_eq!(a.current_frame_index(), exp, "expected frame {exp}");
475        }
476    }
477
478    // ── play / pause ───────────────────────────────────────────────────────
479
480    #[test]
481    fn paused_does_not_consume_tick() {
482        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
483        a.pause();
484        let consumed = tick(&mut a);
485        assert!(!consumed, "Tick not consumed when Paused");
486        assert_eq!(a.current_frame_index(), 0, "frame unchanged while paused");
487    }
488
489    #[test]
490    fn play_resume_from_paused() {
491        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
492        a.set_ticks_per_frame(1);
493        a.pause();
494        tick(&mut a);
495        assert_eq!(a.current_frame_index(), 0);
496        a.play();
497        tick(&mut a);
498        assert_eq!(a.current_frame_index(), 1);
499    }
500
501    // ── reverse direction ─────────────────────────────────────────────────
502
503    #[test]
504    fn reverse_starts_at_last_frame_going_backward() {
505        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
506        a.set_ticks_per_frame(1);
507        a.set_reverse(true);
508        // Initial frame is 0; first tick moves backward → wraps in Loop → last
509        tick(&mut a);
510        assert_eq!(a.current_frame_index(), 2, "loop backward wraps to last");
511    }
512
513    // ── set_bounds ────────────────────────────────────────────────────────
514
515    #[test]
516    fn set_bounds_repositions() {
517        let mut a = AnimImage::new(rect(0, 0, 50, 50), make_frames(2));
518        a.set_bounds(rect(5, 10, 100, 80));
519        assert_eq!(a.bounds(), rect(5, 10, 100, 80));
520    }
521
522    // ── set_current_frame ─────────────────────────────────────────────────
523
524    #[test]
525    fn set_current_frame_clamps_to_valid_range() {
526        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
527        a.set_current_frame(100);
528        assert_eq!(a.current_frame_index(), 3);
529        a.set_current_frame(0);
530        assert_eq!(a.current_frame_index(), 0);
531    }
532
533    // ── draw dispatches blit_image ─────────────────────────────────────────
534
535    #[test]
536    fn draw_calls_blit_image_for_current_frame() {
537        struct BlitCatcher(bool);
538        impl Renderer for BlitCatcher {
539            fn fill_rect(&mut self, _r: Rect, _c: Color) {}
540            fn draw_text(&mut self, _p: (i32, i32), _t: &str, _c: Color) {}
541            fn blit_image(&mut self, _dest: Rect, _desc: &ImageDescriptor<'_>, _opts: &BlitOpts) {
542                self.0 = true;
543            }
544        }
545        let a = AnimImage::new(rect(0, 0, 10, 10), make_frames(1));
546        let mut r = BlitCatcher(false);
547        a.draw(&mut r);
548        assert!(
549            r.0,
550            "blit_image should be called for non-empty frame source"
551        );
552    }
553}