rlvgl-widgets 0.2.5

Built-in widgets for rlvgl.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
//! Tick-driven animated image widget.
//!
//! [`AnimImage`] displays a sequence of [`ImageDescriptor`] frames, advancing
//! them deterministically on [`Event::Tick`] using a local integer frame phase
//! — exactly the same pattern as [`crate::spinner::Spinner`] (`phase_tick`
//! incremented on each `Tick`, frame index derived without wall-clock time).
//!
//! ## Frame source
//!
//! Frames are supplied by the caller as a [`FrameSource`]: either a
//! heap-resident `Vec<ImageDescriptor<'static>>` obtained by decoding a GIF or
//! APNG through the `core::plugins::{gif,apng}` decode functions (std-only,
//! done once at load time outside this widget), or a borrow of a static frame
//! array for embedded targets.  The widget never decodes image data internally.
//!
//! ## Tick model
//!
//! Each `Event::Tick` increments an internal `frame_tick` counter.  Every
//! `ticks_per_frame` ticks (clamped to ≥ 1) the displayed frame index advances.
//! When `loop_mode` is [`AnimImageLoopMode::Once`], the widget stops at the
//! last frame and fires the `on_complete` callback.  [`AnimImageLoopMode::Bounce`]
//! reverses direction at each end.  `set_reverse(true)` permanently reverses
//! the initial direction.
//!
//! ## Drawing
//!
//! `Part::MAIN` — widget background.
//! `Part::INDICATOR` — current frame blitted into `bounds` via
//! [`Renderer::blit_image`].

use alloc::boxed::Box;
use alloc::vec::Vec;

use rlvgl_core::draw::draw_widget_bg;
use rlvgl_core::event::Event;
use rlvgl_core::image::{BlitOpts, ImageDescriptor};
use rlvgl_core::renderer::Renderer;
use rlvgl_core::style::Style;
use rlvgl_core::widget::{Rect, Widget};

// ──────────────────────────────────────────────────────────────────────────
// Public enumerations
// ──────────────────────────────────────────────────────────────────────────

/// Source of pre-decoded [`ImageDescriptor`] frames for [`AnimImage`].
///
/// The widget never decodes image data; frames are provided by the caller.
/// For `std` targets, decode GIF/APNG once using `rlvgl_core::plugins::gif` /
/// `rlvgl_core::plugins::apng` and convert each frame to an
/// `ImageDescriptor<'static>` before constructing [`AnimImage`].
pub enum FrameSource {
    /// Heap-resident frames decoded at load time.
    Decoded(Vec<ImageDescriptor<'static>>),
    /// Statically allocated frame array for `no_std` / ROM targets.
    Static(&'static [ImageDescriptor<'static>]),
}

impl FrameSource {
    /// Return the number of frames available.
    pub fn frame_count(&self) -> usize {
        match self {
            FrameSource::Decoded(v) => v.len(),
            FrameSource::Static(s) => s.len(),
        }
    }

    /// Return the frame at `index`, or `None` when out of bounds.
    pub fn get(&self, index: usize) -> Option<&ImageDescriptor<'static>> {
        match self {
            FrameSource::Decoded(v) => v.get(index),
            FrameSource::Static(s) => s.get(index),
        }
    }
}

/// Playback state for [`AnimImage`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnimPlayState {
    /// Animation is playing; `Event::Tick` advances the frame counter.
    Running,
    /// Animation is paused; `Event::Tick` is ignored.
    Paused,
}

/// Frame-advance loop mode for [`AnimImage`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnimImageLoopMode {
    /// Wrap back to frame 0 after the last frame (infinite loop).
    Loop,
    /// Play once and stop at the last frame; fires `on_complete`.
    Once,
    /// Ping-pong: reverse direction at each end.
    Bounce,
}

// ──────────────────────────────────────────────────────────────────────────
// AnimImage
// ──────────────────────────────────────────────────────────────────────────

/// Tick-driven animated image widget.
///
/// See the [module-level documentation](self) for the full design contract.
pub struct AnimImage {
    bounds: Rect,
    /// Visual style for the widget background (`Part::MAIN`).
    pub style: Style,
    frames: FrameSource,
    /// Number of `Event::Tick`s that must accumulate before the frame index
    /// advances.  Always `≥ 1` (zero is clamped on write).
    ticks_per_frame: u32,
    /// Local tick counter, reset each full frame period.
    frame_tick: u32,
    /// Current displayed frame index.
    frame_index: usize,
    play_state: AnimPlayState,
    loop_mode: AnimImageLoopMode,
    /// When `true`, frames advance in decreasing index order.
    reverse: bool,
    /// Bounce direction: `true` means currently advancing forward.
    bounce_forward: bool,
    /// Optional callback fired when `AnimImageLoopMode::Once` reaches the
    /// last frame.
    on_complete: Option<Box<dyn FnMut()>>,
}

impl AnimImage {
    /// Create an animated image at `bounds` from the provided `frames`.
    ///
    /// Default: `Running`, `Loop`, 3 ticks/frame, forward direction.
    pub fn new(bounds: Rect, frames: FrameSource) -> Self {
        Self {
            bounds,
            style: Style::default(),
            frames,
            ticks_per_frame: 3,
            frame_tick: 0,
            frame_index: 0,
            play_state: AnimPlayState::Running,
            loop_mode: AnimImageLoopMode::Loop,
            reverse: false,
            bounce_forward: true,
            on_complete: None,
        }
    }

    // ── playback speed ─────────────────────────────────────────────────────

    /// Set the number of ticks between frame advances.  `0` is clamped to `1`.
    pub fn set_ticks_per_frame(&mut self, n: u32) {
        self.ticks_per_frame = n.max(1);
        // Keep frame_tick in range.
        self.frame_tick = self.frame_tick.min(self.ticks_per_frame - 1);
    }

    /// Number of ticks between frame advances (always ≥ 1).
    pub fn ticks_per_frame(&self) -> u32 {
        self.ticks_per_frame
    }

    // ── frame access ───────────────────────────────────────────────────────

    /// Number of frames in the animation (may be zero for an empty source).
    pub fn frame_count(&self) -> usize {
        self.frames.frame_count()
    }

    /// Index of the currently displayed frame.
    pub fn current_frame_index(&self) -> usize {
        self.frame_index
    }

    /// Force-set the displayed frame index.  Out-of-bounds values are clamped
    /// to `frame_count().saturating_sub(1)`.
    pub fn set_current_frame(&mut self, index: usize) {
        let count = self.frames.frame_count();
        self.frame_index = if count == 0 { 0 } else { index.min(count - 1) };
    }

    // ── play / pause ───────────────────────────────────────────────────────

    /// Start playback (equivalent to LVGL `lv_animimg_start`).
    pub fn play(&mut self) {
        self.play_state = AnimPlayState::Running;
    }

    /// Pause playback (equivalent to LVGL `lv_animimg_stop`).
    pub fn pause(&mut self) {
        self.play_state = AnimPlayState::Paused;
    }

    /// Set the play state directly.
    pub fn set_play_state(&mut self, state: AnimPlayState) {
        self.play_state = state;
    }

    /// Current play state.
    pub fn play_state(&self) -> AnimPlayState {
        self.play_state
    }

    /// Return `true` when the animation is currently running.
    pub fn is_playing(&self) -> bool {
        self.play_state == AnimPlayState::Running
    }

    // ── loop mode ──────────────────────────────────────────────────────────

    /// Set the loop mode.
    pub fn set_loop_mode(&mut self, mode: AnimImageLoopMode) {
        self.loop_mode = mode;
    }

    /// Current loop mode.
    pub fn loop_mode(&self) -> AnimImageLoopMode {
        self.loop_mode
    }

    // ── direction ──────────────────────────────────────────────────────────

    /// When `true`, the initial playback direction is reversed (frame index
    /// decrements toward 0 rather than incrementing).
    pub fn set_reverse(&mut self, reverse: bool) {
        self.reverse = reverse;
        // Keep bounce state in sync with the new preferred direction.
        self.bounce_forward = !reverse;
    }

    // ── completion callback ────────────────────────────────────────────────

    /// Register a callback fired when `AnimImageLoopMode::Once` reaches the
    /// last frame.  Replaces any previously registered callback.
    pub fn on_complete<F: FnMut() + 'static>(mut self, handler: F) -> Self {
        self.on_complete = Some(Box::new(handler));
        self
    }

    // ── internal advance ──────────────────────────────────────────────────

    /// Advance the frame counter by one tick.  Returns `true` when the frame
    /// index actually changed so the caller can schedule a repaint.
    fn advance_tick(&mut self) -> bool {
        let count = self.frames.frame_count();
        if count == 0 {
            return false;
        }

        self.frame_tick += 1;
        if self.frame_tick < self.ticks_per_frame {
            return false; // Not yet time for the next frame.
        }
        self.frame_tick = 0;

        let prev = self.frame_index;
        // Determine the effective advance direction for this tick.
        let forward = if self.loop_mode == AnimImageLoopMode::Bounce {
            self.bounce_forward
        } else {
            !self.reverse
        };

        if forward {
            if self.frame_index + 1 < count {
                self.frame_index += 1;
            } else {
                // Reached the last frame.
                match self.loop_mode {
                    AnimImageLoopMode::Loop => {
                        self.frame_index = 0;
                    }
                    AnimImageLoopMode::Once => {
                        // Stay on the last frame; fire completion callback.
                        self.play_state = AnimPlayState::Paused;
                        if let Some(cb) = &mut self.on_complete {
                            cb();
                        }
                    }
                    AnimImageLoopMode::Bounce => {
                        self.bounce_forward = false;
                        if count >= 2 {
                            self.frame_index = count - 2;
                        }
                    }
                }
            }
        } else if self.frame_index > 0 {
            self.frame_index -= 1;
        } else {
            // Reached frame 0 going backward.
            match self.loop_mode {
                AnimImageLoopMode::Loop => {
                    self.frame_index = count - 1;
                }
                AnimImageLoopMode::Once => {
                    self.play_state = AnimPlayState::Paused;
                    if let Some(cb) = &mut self.on_complete {
                        cb();
                    }
                }
                AnimImageLoopMode::Bounce => {
                    self.bounce_forward = true;
                    if count >= 2 {
                        self.frame_index = 1;
                    }
                }
            }
        }

        self.frame_index != prev
    }
}

impl Widget for AnimImage {
    fn bounds(&self) -> Rect {
        self.bounds
    }

    /// Draw the current animation frame.
    ///
    /// `Part::MAIN` — widget background.
    /// `Part::INDICATOR` — current frame via [`Renderer::blit_image`].
    fn draw(&self, renderer: &mut dyn Renderer) {
        draw_widget_bg(renderer, self.bounds, &self.style);
        if let Some(frame) = self.frames.get(self.frame_index) {
            renderer.blit_image(self.bounds, frame, &BlitOpts::default());
        }
    }

    /// Handle events.  Returns `true` when [`Event::Tick`] is consumed
    /// (matching the Spinner pattern: Tick is always consumed when `Running`).
    fn handle_event(&mut self, event: &Event) -> bool {
        if matches!(event, Event::Tick) && self.play_state == AnimPlayState::Running {
            self.advance_tick();
            return true;
        }
        false
    }

    /// Adopt a layout-computed bounds rect.
    fn set_bounds(&mut self, bounds: Rect) {
        self.bounds = bounds;
    }
}

// ──────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use rlvgl_core::image::{ImageData, PixelFormat};
    use rlvgl_core::widget::Color;

    // ── helpers ────────────────────────────────────────────────────────────

    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
        Rect {
            x,
            y,
            width: w,
            height: h,
        }
    }

    /// Build a trivially small static frame list (colors are dummies).
    fn make_frames(n: usize) -> FrameSource {
        // We need 'static descriptors — use Borrowed(&[]) as empty pixel data.
        let mut frames: Vec<ImageDescriptor<'static>> = Vec::new();
        for _ in 0..n {
            frames.push(ImageDescriptor::new(
                PixelFormat::Argb8888,
                1,
                1,
                ImageData::Borrowed(&[]),
                None,
            ));
        }
        FrameSource::Decoded(frames)
    }

    fn tick(anim: &mut AnimImage) -> bool {
        anim.handle_event(&Event::Tick)
    }

    // ── tick cadence ───────────────────────────────────────────────────────

    #[test]
    fn tick_advances_frame_at_right_cadence() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
        a.set_ticks_per_frame(3);
        // First two ticks should NOT advance the frame.
        assert!(tick(&mut a)); // consumed, no frame change
        assert_eq!(a.current_frame_index(), 0);
        assert!(tick(&mut a));
        assert_eq!(a.current_frame_index(), 0);
        // Third tick crosses the period boundary.
        assert!(tick(&mut a));
        assert_eq!(a.current_frame_index(), 1);
    }

    #[test]
    fn zero_period_is_clamped_to_one() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
        a.set_ticks_per_frame(0);
        assert_eq!(a.ticks_per_frame(), 1);
        tick(&mut a);
        assert_eq!(a.current_frame_index(), 1, "advances every tick");
    }

    // ── loop wrap ──────────────────────────────────────────────────────────

    #[test]
    fn loop_mode_wraps_at_last_frame() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
        a.set_ticks_per_frame(1);
        a.set_loop_mode(AnimImageLoopMode::Loop);
        tick(&mut a); // → 1
        tick(&mut a); // → 2
        tick(&mut a); // → wraps to 0
        assert_eq!(a.current_frame_index(), 0);
        assert!(a.is_playing());
    }

    // ── once mode ─────────────────────────────────────────────────────────

    #[test]
    fn once_mode_stops_at_last_frame() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
        a.set_ticks_per_frame(1);
        a.set_loop_mode(AnimImageLoopMode::Once);
        tick(&mut a); // → 1
        tick(&mut a); // → 2 (last)
        tick(&mut a); // stays at 2, pauses
        assert_eq!(a.current_frame_index(), 2);
        assert_eq!(a.play_state(), AnimPlayState::Paused);
    }

    #[test]
    fn once_mode_fires_on_complete() {
        use alloc::rc::Rc;
        use core::cell::Cell;
        let fired = Rc::new(Cell::new(0u32));
        let fired_clone = fired.clone();
        // 3-frame animation: indices 0, 1, 2.
        // Tick 1: 0→1, Tick 2: 1→2, Tick 3: would go past 2 → fires on_complete.
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3))
            .on_complete(move || fired_clone.set(fired_clone.get() + 1));
        a.set_ticks_per_frame(1);
        a.set_loop_mode(AnimImageLoopMode::Once);
        tick(&mut a); // 0→1
        tick(&mut a); // 1→2 (last frame)
        assert_eq!(a.current_frame_index(), 2);
        assert_eq!(
            fired.get(),
            0,
            "on_complete not fired until attempt to advance past last"
        );
        tick(&mut a); // attempt to advance past last → fires on_complete, stays at 2
        assert_eq!(fired.get(), 1, "on_complete fired exactly once");
        assert_eq!(a.play_state(), AnimPlayState::Paused);
    }

    // ── bounce mode ────────────────────────────────────────────────────────

    #[test]
    fn bounce_mode_reverses_at_endpoints() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
        a.set_ticks_per_frame(1);
        a.set_loop_mode(AnimImageLoopMode::Bounce);
        // Forward: 0 → 1 → 2 → reverses → 1 → 0 → reverses → 1
        let expected = [1, 2, 1, 0, 1];
        for &exp in &expected {
            tick(&mut a);
            assert_eq!(a.current_frame_index(), exp, "expected frame {exp}");
        }
    }

    // ── play / pause ───────────────────────────────────────────────────────

    #[test]
    fn paused_does_not_consume_tick() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
        a.pause();
        let consumed = tick(&mut a);
        assert!(!consumed, "Tick not consumed when Paused");
        assert_eq!(a.current_frame_index(), 0, "frame unchanged while paused");
    }

    #[test]
    fn play_resume_from_paused() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
        a.set_ticks_per_frame(1);
        a.pause();
        tick(&mut a);
        assert_eq!(a.current_frame_index(), 0);
        a.play();
        tick(&mut a);
        assert_eq!(a.current_frame_index(), 1);
    }

    // ── reverse direction ─────────────────────────────────────────────────

    #[test]
    fn reverse_starts_at_last_frame_going_backward() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(3));
        a.set_ticks_per_frame(1);
        a.set_reverse(true);
        // Initial frame is 0; first tick moves backward → wraps in Loop → last
        tick(&mut a);
        assert_eq!(a.current_frame_index(), 2, "loop backward wraps to last");
    }

    // ── set_bounds ────────────────────────────────────────────────────────

    #[test]
    fn set_bounds_repositions() {
        let mut a = AnimImage::new(rect(0, 0, 50, 50), make_frames(2));
        a.set_bounds(rect(5, 10, 100, 80));
        assert_eq!(a.bounds(), rect(5, 10, 100, 80));
    }

    // ── set_current_frame ─────────────────────────────────────────────────

    #[test]
    fn set_current_frame_clamps_to_valid_range() {
        let mut a = AnimImage::new(rect(0, 0, 10, 10), make_frames(4));
        a.set_current_frame(100);
        assert_eq!(a.current_frame_index(), 3);
        a.set_current_frame(0);
        assert_eq!(a.current_frame_index(), 0);
    }

    // ── draw dispatches blit_image ─────────────────────────────────────────

    #[test]
    fn draw_calls_blit_image_for_current_frame() {
        struct BlitCatcher(bool);
        impl Renderer for BlitCatcher {
            fn fill_rect(&mut self, _r: Rect, _c: Color) {}
            fn draw_text(&mut self, _p: (i32, i32), _t: &str, _c: Color) {}
            fn blit_image(&mut self, _dest: Rect, _desc: &ImageDescriptor<'_>, _opts: &BlitOpts) {
                self.0 = true;
            }
        }
        let a = AnimImage::new(rect(0, 0, 10, 10), make_frames(1));
        let mut r = BlitCatcher(false);
        a.draw(&mut r);
        assert!(
            r.0,
            "blit_image should be called for non-empty frame source"
        );
    }
}