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}