Skip to main content

fret_ui_kit/primitives/
avatar.rs

1//! Avatar primitives (Radix-aligned outcomes).
2//!
3//! Upstream reference:
4//! - `repo-ref/primitives/packages/react/avatar/src/avatar.tsx`
5//!
6//! Radix Avatar tracks image loading status (`idle`/`loading`/`loaded`/`error`) and uses an
7//! optional delay before rendering fallback content.
8//!
9//! In Fret, `ImageId` represents an already-registered renderer resource. For apps that load
10//! images asynchronously (e.g. decode/upload, network fetch), a common pattern is to store
11//! `Option<ImageId>` (or an enum) in a model and update it when the image becomes available. This
12//! facade provides the Radix-named status enum and a small, frame-based fallback delay helper.
13use std::time::Duration;
14
15/// Radix-like image loading status for avatars.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum AvatarImageLoadingStatus {
18    #[default]
19    Idle,
20    Loading,
21    Loaded,
22    Error,
23}
24
25/// A duration-driven fallback delay gate (Radix `delayMs` outcome).
26///
27/// This is driven by the caller once per frame (no timers). When a delay is configured, the
28/// fallback becomes renderable only after the delay duration has elapsed since the first time the
29/// caller requested it.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct AvatarFallbackDelay {
32    start_frame_id: Option<u64>,
33    last_frame_id: Option<u64>,
34    elapsed: Duration,
35}
36
37impl AvatarFallbackDelay {
38    /// Drives the delay gate.
39    ///
40    /// - `frame_id`: current monotonic frame id (`App::frame_id().0`).
41    /// - `dt`: effective per-frame delta (clamped; recommended: `motion::effective_frame_delta_for_cx`).
42    /// - `delay`: `None` means render immediately (no delay).
43    /// - `want_render`: whether fallback would be desired (e.g. image not loaded).
44    pub fn drive(
45        &mut self,
46        frame_id: u64,
47        dt: Duration,
48        delay: Option<Duration>,
49        want_render: bool,
50    ) -> bool {
51        let Some(delay) = delay else {
52            *self = Self::default();
53            return want_render;
54        };
55
56        if !want_render {
57            *self = Self::default();
58            return false;
59        }
60
61        if delay == Duration::ZERO {
62            return true;
63        }
64
65        let _ = self.start_frame_id.get_or_insert(frame_id);
66        match self.last_frame_id {
67            None => {
68                self.last_frame_id = Some(frame_id);
69            }
70            Some(prev) if prev != frame_id => {
71                self.last_frame_id = Some(frame_id);
72                self.elapsed = self.elapsed.saturating_add(dt);
73            }
74            Some(_) => {}
75        }
76
77        self.elapsed >= delay
78    }
79}
80
81/// Returns `true` when avatar fallback content should be visible, matching Radix's
82/// `canRender && status !== 'loaded'` outcome.
83pub fn fallback_visible(status: AvatarImageLoadingStatus, delay_ready: bool) -> bool {
84    delay_ready && status != AvatarImageLoadingStatus::Loaded
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    const DT_16MS: Duration = Duration::from_millis(16);
92
93    #[test]
94    fn fallback_delay_gate_renders_immediately_without_delay() {
95        let mut gate = AvatarFallbackDelay::default();
96        assert!(!gate.drive(1, DT_16MS, None, false));
97        assert!(gate.drive(1, DT_16MS, None, true));
98    }
99
100    #[test]
101    fn fallback_delay_gate_waits_until_delay_elapses() {
102        let mut gate = AvatarFallbackDelay::default();
103        let delay = Some(Duration::from_millis(32));
104        assert!(!gate.drive(10, DT_16MS, delay, true));
105        assert!(!gate.drive(11, DT_16MS, delay, true));
106        assert!(gate.drive(12, DT_16MS, delay, true));
107    }
108
109    #[test]
110    fn fallback_delay_gate_resets_when_not_wanted() {
111        let mut gate = AvatarFallbackDelay::default();
112        let delay = Some(Duration::from_millis(32));
113        assert!(!gate.drive(10, DT_16MS, delay, true));
114        assert!(!gate.drive(11, DT_16MS, delay, false));
115        assert!(!gate.drive(12, DT_16MS, delay, true));
116        assert!(!gate.drive(13, DT_16MS, delay, true));
117        assert!(gate.drive(14, DT_16MS, delay, true));
118    }
119
120    #[test]
121    fn fallback_visible_hides_when_loaded() {
122        assert!(!fallback_visible(AvatarImageLoadingStatus::Loaded, true));
123        assert!(fallback_visible(AvatarImageLoadingStatus::Loading, true));
124        assert!(!fallback_visible(AvatarImageLoadingStatus::Loading, false));
125    }
126}