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}