Skip to main content

native_theme/model/
animated.rs

1// Animated icon types: AnimatedIcon, TransformAnimation
2//
3// These types define the data model for animated icons in the native-theme
4// icon system. Frame-based animations supply pre-rendered frames with timing;
5// transform-based animations describe a CSS-like transform on a single icon.
6
7use serde::{Deserialize, Serialize};
8
9use super::icons::IconData;
10
11/// A CSS-like transform animation applied to a single icon.
12///
13/// # Examples
14///
15/// ```
16/// use native_theme::TransformAnimation;
17///
18/// let spin = TransformAnimation::Spin { duration_ms: 1000 };
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum TransformAnimation {
23    /// Continuous 360-degree rotation.
24    Spin {
25        /// Full rotation period in milliseconds.
26        duration_ms: u32,
27    },
28}
29
30/// An animated icon, either frame-based or transform-based.
31///
32/// `Frames` carries pre-rendered frames with uniform timing (loops infinitely).
33/// `Transform` carries a single icon and a description of the motion.
34///
35/// # Examples
36///
37/// ```
38/// use native_theme::{AnimatedIcon, IconData, TransformAnimation};
39///
40/// // Frame-based animation (e.g., sprite sheet)
41/// let frames_anim = AnimatedIcon::Frames {
42///     frames: vec![
43///         IconData::Svg(b"<svg>frame1</svg>".to_vec()),
44///         IconData::Svg(b"<svg>frame2</svg>".to_vec()),
45///     ],
46///     frame_duration_ms: 83,
47/// };
48///
49/// // Transform-based animation (e.g., spinning icon)
50/// let spin_anim = AnimatedIcon::Transform {
51///     icon: IconData::Svg(b"<svg>spinner</svg>".to_vec()),
52///     animation: TransformAnimation::Spin { duration_ms: 1000 },
53/// };
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[non_exhaustive]
57pub enum AnimatedIcon {
58    /// A sequence of pre-rendered frames played at a fixed interval (loops infinitely).
59    Frames {
60        /// The individual frames, each a complete icon image.
61        frames: Vec<IconData>,
62        /// Duration of each frame in milliseconds.
63        frame_duration_ms: u32,
64    },
65    /// A single icon with a continuous transform animation.
66    Transform {
67        /// The icon to animate.
68        icon: IconData,
69        /// The transform to apply.
70        animation: TransformAnimation,
71    },
72}
73
74impl AnimatedIcon {
75    /// Create a frame-based animation.
76    ///
77    /// Returns `None` if `frames` is empty or `frame_duration_ms` is zero,
78    /// since both would produce an invalid animation (no displayable content
79    /// or a division-by-zero in playback code).
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use native_theme::{AnimatedIcon, IconData};
85    ///
86    /// let anim = AnimatedIcon::new_frames(
87    ///     vec![IconData::Svg(b"<svg>f1</svg>".to_vec())],
88    ///     83,
89    /// );
90    /// assert!(anim.is_some());
91    ///
92    /// // Empty frames or zero duration returns None:
93    /// assert!(AnimatedIcon::new_frames(vec![], 83).is_none());
94    /// assert!(AnimatedIcon::new_frames(vec![IconData::Svg(vec![])], 0).is_none());
95    /// ```
96    #[must_use]
97    pub fn new_frames(frames: Vec<IconData>, frame_duration_ms: u32) -> Option<Self> {
98        if frames.is_empty() || frame_duration_ms == 0 {
99            return None;
100        }
101        Some(AnimatedIcon::Frames {
102            frames,
103            frame_duration_ms,
104        })
105    }
106
107    /// Return a reference to the first displayable frame.
108    ///
109    /// For `Frames`, returns the first element (or `None` if empty).
110    /// For `Transform`, returns the underlying icon.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use native_theme::{AnimatedIcon, IconData, TransformAnimation};
116    ///
117    /// let frames = AnimatedIcon::Frames {
118    ///     frames: vec![IconData::Svg(b"<svg>f1</svg>".to_vec())],
119    ///     frame_duration_ms: 83,
120    /// };
121    /// assert!(frames.first_frame().is_some());
122    ///
123    /// let transform = AnimatedIcon::Transform {
124    ///     icon: IconData::Svg(b"<svg>spinner</svg>".to_vec()),
125    ///     animation: TransformAnimation::Spin { duration_ms: 1000 },
126    /// };
127    /// assert!(transform.first_frame().is_some());
128    /// ```
129    #[must_use]
130    pub fn first_frame(&self) -> Option<&IconData> {
131        match self {
132            AnimatedIcon::Frames { frames, .. } => frames.first(),
133            AnimatedIcon::Transform { icon, .. } => Some(icon),
134        }
135    }
136}
137
138#[cfg(test)]
139#[allow(clippy::unwrap_used, clippy::expect_used)]
140mod tests {
141    use super::*;
142
143    // === Construction tests ===
144
145    #[test]
146    fn frames_variant_constructs() {
147        let icon = AnimatedIcon::Frames {
148            frames: vec![IconData::Svg(b"<svg>f1</svg>".to_vec())],
149            frame_duration_ms: 83,
150        };
151        assert!(matches!(
152            icon,
153            AnimatedIcon::Frames {
154                frame_duration_ms: 83,
155                ..
156            }
157        ));
158    }
159
160    #[test]
161    fn transform_variant_constructs() {
162        let icon = AnimatedIcon::Transform {
163            icon: IconData::Svg(b"<svg>spinner</svg>".to_vec()),
164            animation: TransformAnimation::Spin { duration_ms: 1000 },
165        };
166        assert!(matches!(
167            icon,
168            AnimatedIcon::Transform {
169                animation: TransformAnimation::Spin { duration_ms: 1000 },
170                ..
171            }
172        ));
173    }
174
175    #[test]
176    #[allow(clippy::clone_on_copy)]
177    fn transform_animation_is_copy_clone_debug_eq_hash() {
178        let a = TransformAnimation::Spin { duration_ms: 500 };
179        let a2 = a; // Copy
180        let a3 = a.clone(); // Clone
181        assert_eq!(a2, a3); // PartialEq + Eq
182        use std::hash::Hash;
183        let mut hasher = std::collections::hash_map::DefaultHasher::new();
184        a.hash(&mut hasher);
185        let _ = format!("{a:?}"); // Debug
186    }
187
188    #[test]
189    fn animated_icon_is_clone_debug_eq_not_copy() {
190        let icon = AnimatedIcon::Frames {
191            frames: vec![IconData::Svg(b"<svg/>".to_vec())],
192            frame_duration_ms: 100,
193        };
194        let cloned = icon.clone(); // Clone
195        assert_eq!(icon, cloned); // PartialEq + Eq
196        let _ = format!("{icon:?}"); // Debug
197        // NOT Copy -- contains Vec, so this is inherently non-Copy
198    }
199
200    // === first_frame() tests ===
201
202    #[test]
203    fn first_frame_frames_with_items() {
204        let f0 = IconData::Svg(b"<svg>frame0</svg>".to_vec());
205        let f1 = IconData::Svg(b"<svg>frame1</svg>".to_vec());
206        let icon = AnimatedIcon::Frames {
207            frames: vec![f0.clone(), f1],
208            frame_duration_ms: 100,
209        };
210        assert_eq!(icon.first_frame(), Some(&f0));
211    }
212
213    #[test]
214    fn first_frame_frames_empty() {
215        let icon = AnimatedIcon::Frames {
216            frames: vec![],
217            frame_duration_ms: 100,
218        };
219        assert_eq!(icon.first_frame(), None);
220    }
221
222    #[test]
223    fn first_frame_transform() {
224        let data = IconData::Svg(b"<svg>spin</svg>".to_vec());
225        let icon = AnimatedIcon::Transform {
226            icon: data.clone(),
227            animation: TransformAnimation::Spin { duration_ms: 1000 },
228        };
229        assert_eq!(icon.first_frame(), Some(&data));
230    }
231
232    // === new_frames() constructor tests ===
233
234    #[test]
235    fn new_frames_valid() {
236        let anim = AnimatedIcon::new_frames(vec![IconData::Svg(b"<svg>f1</svg>".to_vec())], 83);
237        assert!(anim.is_some());
238    }
239
240    #[test]
241    fn new_frames_rejects_empty() {
242        assert!(AnimatedIcon::new_frames(vec![], 83).is_none());
243    }
244
245    #[test]
246    fn new_frames_rejects_zero_duration() {
247        let frames = vec![IconData::Svg(b"<svg>f1</svg>".to_vec())];
248        assert!(AnimatedIcon::new_frames(frames, 0).is_none());
249    }
250}