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//
7// AnimatedIcon variant fields are private (via FramesData/TransformData wrapper
8// structs) so that construction must go through validated constructors. This
9// prevents invalid states like empty frame lists or zero durations.
10
11use std::num::NonZeroU32;
12use std::ops::Deref;
13
14use serde::{Deserialize, Serialize};
15
16use super::icons::IconData;
17
18/// Error returned when attempting to create a [`FrameList`] from an empty vec.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct EmptyFrameListError;
21
22impl std::fmt::Display for EmptyFrameListError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.write_str("frame list must not be empty")
25    }
26}
27
28impl std::error::Error for EmptyFrameListError {}
29
30/// A non-empty list of icon frames for animation.
31///
32/// Construction via [`FrameList::new()`] enforces the non-empty invariant.
33/// Derefs to `&[IconData]` for ergonomic slice access.
34///
35/// # Examples
36///
37/// ```
38/// use native_theme::theme::{FrameList, IconData};
39/// use std::borrow::Cow;
40///
41/// let frames = FrameList::new(vec![
42///     IconData::Svg(Cow::Borrowed(b"<svg>frame1</svg>")),
43/// ]);
44/// assert!(frames.is_ok());
45///
46/// let empty = FrameList::new(vec![]);
47/// assert!(empty.is_err());
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct FrameList(Vec<IconData>);
51
52impl FrameList {
53    /// Create a new `FrameList`, returning `Err` if `frames` is empty.
54    pub fn new(frames: Vec<IconData>) -> Result<Self, EmptyFrameListError> {
55        if frames.is_empty() {
56            return Err(EmptyFrameListError);
57        }
58        Ok(Self(frames))
59    }
60
61    /// Returns the first frame (infallible -- list is guaranteed non-empty).
62    pub fn first(&self) -> &IconData {
63        // INVARIANT: FrameList is only constructible via new() which rejects empty,
64        // or via custom Deserialize which also rejects empty. Safe Rust cannot
65        // produce an empty FrameList; only unsafe transmute could violate it.
66        // The `debug_assert!` is defense in depth for debug builds.
67        debug_assert!(
68            !self.0.is_empty(),
69            "FrameList invariant violated: empty list"
70        );
71        #[allow(clippy::indexing_slicing)]
72        {
73            &self.0[0]
74        }
75    }
76
77    /// Returns the number of frames (always >= 1).
78    ///
79    /// `is_empty()` is intentionally not provided because a `FrameList` is
80    /// guaranteed non-empty by construction.
81    #[must_use]
82    #[allow(clippy::len_without_is_empty)]
83    pub fn len(&self) -> usize {
84        self.0.len()
85    }
86}
87
88impl Deref for FrameList {
89    type Target = [IconData];
90    fn deref(&self) -> &[IconData] {
91        &self.0
92    }
93}
94
95// Custom Deserialize that enforces the non-empty invariant at the
96// deserialization boundary (T-87-01 mitigation). Do NOT derive Deserialize.
97impl<'de> serde::Deserialize<'de> for FrameList {
98    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99    where
100        D: serde::Deserializer<'de>,
101    {
102        let frames = Vec::<IconData>::deserialize(deserializer)?;
103        FrameList::new(frames).map_err(|_| serde::de::Error::custom("frame list must not be empty"))
104    }
105}
106
107/// A CSS-like transform animation applied to a single icon.
108///
109/// # Examples
110///
111/// ```
112/// use native_theme::theme::TransformAnimation;
113/// use std::num::NonZeroU32;
114///
115/// let spin = TransformAnimation::Spin {
116///     duration_ms: NonZeroU32::new(1000).unwrap(),
117/// };
118/// ```
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[non_exhaustive]
121pub enum TransformAnimation {
122    /// Continuous 360-degree rotation.
123    Spin {
124        /// Full rotation period in milliseconds (guaranteed non-zero).
125        duration_ms: NonZeroU32,
126    },
127}
128
129/// Data for a frame-based animation. Fields are private; use accessor methods.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct FramesData {
132    frames: FrameList,
133    frame_duration_ms: NonZeroU32,
134}
135
136impl FramesData {
137    /// The animation frames (guaranteed non-empty).
138    #[must_use]
139    pub fn frames(&self) -> &FrameList {
140        &self.frames
141    }
142
143    /// Duration of each frame in milliseconds (guaranteed non-zero).
144    #[must_use]
145    pub fn frame_duration_ms(&self) -> NonZeroU32 {
146        self.frame_duration_ms
147    }
148}
149
150/// Data for a transform-based animation. Fields are private; use accessor methods.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct TransformData {
153    icon: IconData,
154    animation: TransformAnimation,
155}
156
157impl TransformData {
158    /// The icon being animated.
159    pub fn icon(&self) -> &IconData {
160        &self.icon
161    }
162
163    /// The transform animation applied to the icon.
164    #[must_use]
165    pub fn animation(&self) -> &TransformAnimation {
166        &self.animation
167    }
168}
169
170/// An animated icon, either frame-based or transform-based.
171///
172/// `Frames` carries pre-rendered frames with uniform timing (loops infinitely).
173/// `Transform` carries a single icon and a description of the motion.
174///
175/// Variant fields are private (via [`FramesData`] and [`TransformData`] wrapper
176/// structs). Use the constructors [`AnimatedIcon::frames()`] and
177/// [`AnimatedIcon::transform()`] to build instances, and accessor methods to
178/// read fields.
179///
180/// # Examples
181///
182/// ```
183/// use native_theme::theme::{AnimatedIcon, IconData, TransformAnimation};
184/// use std::borrow::Cow;
185/// use std::num::NonZeroU32;
186///
187/// // Frame-based animation (e.g., sprite sheet)
188/// let frames_anim = AnimatedIcon::frames(
189///     vec![
190///         IconData::Svg(Cow::Borrowed(b"<svg>frame1</svg>")),
191///         IconData::Svg(Cow::Borrowed(b"<svg>frame2</svg>")),
192///     ],
193///     NonZeroU32::new(83).unwrap(),
194/// ).expect("non-empty frames");
195///
196/// // Transform-based animation (e.g., spinning icon)
197/// let spin_anim = AnimatedIcon::transform(
198///     IconData::Svg(Cow::Borrowed(b"<svg>spinner</svg>")),
199///     TransformAnimation::Spin { duration_ms: NonZeroU32::new(1000).unwrap() },
200/// );
201///
202/// // Pattern matching uses tuple variants + accessor methods
203/// match &frames_anim {
204///     AnimatedIcon::Frames(data) => {
205///         assert_eq!(data.frames().len(), 2);
206///         assert_eq!(data.frame_duration_ms().get(), 83);
207///     }
208///     AnimatedIcon::Transform(data) => {
209///         let _icon = data.icon();
210///         let _animation = data.animation();
211///     }
212///     _ => {}
213/// }
214///
215/// // first_frame() returns &IconData (infallible, not Option)
216/// let first = frames_anim.first_frame();
217/// assert!(matches!(first, IconData::Svg(_)));
218/// ```
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[non_exhaustive]
221pub enum AnimatedIcon {
222    /// A sequence of pre-rendered frames played at a fixed interval (loops infinitely).
223    Frames(FramesData),
224    /// A single icon with a continuous transform animation.
225    Transform(TransformData),
226}
227
228impl AnimatedIcon {
229    /// Create a frame-based animation.
230    ///
231    /// Returns `Err(EmptyFrameListError)` if `frames` is empty.
232    /// `frame_duration_ms` uses [`NonZeroU32`] so zero duration is prevented at the type level.
233    ///
234    /// # Examples
235    ///
236    /// ```
237    /// use native_theme::theme::{AnimatedIcon, IconData};
238    /// use std::borrow::Cow;
239    /// use std::num::NonZeroU32;
240    ///
241    /// let anim = AnimatedIcon::frames(
242    ///     vec![IconData::Svg(Cow::Borrowed(b"<svg>f1</svg>"))],
243    ///     NonZeroU32::new(83).unwrap(),
244    /// );
245    /// assert!(anim.is_ok());
246    ///
247    /// // Empty frames returns Err:
248    /// let empty = AnimatedIcon::frames(vec![], NonZeroU32::new(83).unwrap());
249    /// assert!(empty.is_err());
250    /// ```
251    pub fn frames(
252        frames: Vec<IconData>,
253        frame_duration_ms: NonZeroU32,
254    ) -> Result<Self, EmptyFrameListError> {
255        Ok(AnimatedIcon::Frames(FramesData {
256            frames: FrameList::new(frames)?,
257            frame_duration_ms,
258        }))
259    }
260
261    /// Create a transform-based animation.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// use native_theme::theme::{AnimatedIcon, IconData, TransformAnimation};
267    /// use std::borrow::Cow;
268    /// use std::num::NonZeroU32;
269    ///
270    /// let anim = AnimatedIcon::transform(
271    ///     IconData::Svg(Cow::Borrowed(b"<svg>spinner</svg>")),
272    ///     TransformAnimation::Spin { duration_ms: NonZeroU32::new(1000).unwrap() },
273    /// );
274    /// ```
275    #[must_use]
276    pub fn transform(icon: IconData, animation: TransformAnimation) -> Self {
277        AnimatedIcon::Transform(TransformData { icon, animation })
278    }
279
280    /// Return a reference to the first displayable frame (infallible).
281    ///
282    /// For `Frames`, returns the first element (guaranteed to exist via [`FrameList`]).
283    /// For `Transform`, returns the underlying icon.
284    ///
285    /// # Examples
286    ///
287    /// ```
288    /// use native_theme::theme::{AnimatedIcon, IconData, TransformAnimation};
289    /// use std::borrow::Cow;
290    /// use std::num::NonZeroU32;
291    ///
292    /// let anim = AnimatedIcon::frames(
293    ///     vec![IconData::Svg(Cow::Borrowed(b"<svg>f1</svg>"))],
294    ///     NonZeroU32::new(83).unwrap(),
295    /// ).unwrap();
296    /// let first: &IconData = anim.first_frame();
297    /// assert!(matches!(first, IconData::Svg(_)));
298    ///
299    /// let spin = AnimatedIcon::transform(
300    ///     IconData::Svg(Cow::Borrowed(b"<svg>spinner</svg>")),
301    ///     TransformAnimation::Spin { duration_ms: NonZeroU32::new(1000).unwrap() },
302    /// );
303    /// let first: &IconData = spin.first_frame();
304    /// assert!(matches!(first, IconData::Svg(_)));
305    /// ```
306    pub fn first_frame(&self) -> &IconData {
307        match self {
308            AnimatedIcon::Frames(data) => data.frames.first(),
309            AnimatedIcon::Transform(data) => &data.icon,
310        }
311    }
312
313    /// Return the frame list (for Frames variant) or None (for Transform).
314    #[must_use]
315    pub fn frame_list(&self) -> Option<&FrameList> {
316        match self {
317            AnimatedIcon::Frames(data) => Some(&data.frames),
318            AnimatedIcon::Transform(_) => None,
319        }
320    }
321
322    /// Return the frame duration in milliseconds (for Frames variant) or None.
323    #[must_use]
324    pub fn frame_duration_ms(&self) -> Option<NonZeroU32> {
325        match self {
326            AnimatedIcon::Frames(data) => Some(data.frame_duration_ms),
327            AnimatedIcon::Transform(_) => None,
328        }
329    }
330
331    /// Return the icon data (for Transform variant) or None.
332    #[must_use]
333    pub fn icon(&self) -> Option<&IconData> {
334        match self {
335            AnimatedIcon::Transform(data) => Some(&data.icon),
336            AnimatedIcon::Frames(_) => None,
337        }
338    }
339
340    /// Return the transform animation (for Transform variant) or None.
341    #[must_use]
342    pub fn animation(&self) -> Option<&TransformAnimation> {
343        match self {
344            AnimatedIcon::Transform(data) => Some(&data.animation),
345            AnimatedIcon::Frames(_) => None,
346        }
347    }
348
349    /// Deprecated: Use [`AnimatedIcon::frames()`] instead.
350    #[deprecated(
351        since = "0.5.7",
352        note = "use AnimatedIcon::frames() which returns Result"
353    )]
354    #[must_use]
355    pub fn new_frames(frames: Vec<IconData>, frame_duration_ms: u32) -> Option<Self> {
356        let dur = NonZeroU32::new(frame_duration_ms)?;
357        Self::frames(frames, dur).ok()
358    }
359}
360
361#[cfg(test)]
362#[allow(clippy::unwrap_used, clippy::expect_used)]
363mod tests {
364    use super::*;
365    use std::borrow::Cow;
366
367    fn test_icon(label: &'static str) -> IconData {
368        IconData::Svg(Cow::Owned(format!("<svg>{label}</svg>").into_bytes()))
369    }
370
371    fn test_icon_borrowed() -> IconData {
372        IconData::Svg(Cow::Borrowed(b"<svg>test</svg>"))
373    }
374
375    fn nz(val: u32) -> NonZeroU32 {
376        NonZeroU32::new(val).unwrap()
377    }
378
379    // === FrameList tests ===
380
381    #[test]
382    fn frame_list_new_with_one_frame() {
383        let fl = FrameList::new(vec![test_icon("f1")]);
384        assert!(fl.is_ok());
385        assert_eq!(fl.unwrap().len(), 1);
386    }
387
388    #[test]
389    fn frame_list_new_with_multiple_frames() {
390        let fl = FrameList::new(vec![test_icon("f1"), test_icon("f2"), test_icon("f3")]).unwrap();
391        assert_eq!(fl.len(), 3);
392    }
393
394    #[test]
395    fn frame_list_new_rejects_empty() {
396        let fl = FrameList::new(vec![]);
397        assert!(fl.is_err());
398        assert_eq!(fl.unwrap_err(), EmptyFrameListError);
399    }
400
401    #[test]
402    fn frame_list_first_returns_first_frame() {
403        let icon = test_icon("first");
404        let fl = FrameList::new(vec![icon.clone(), test_icon("second")]).unwrap();
405        assert_eq!(fl.first(), &icon);
406    }
407
408    #[test]
409    fn frame_list_deref_to_slice() {
410        let fl = FrameList::new(vec![test_icon("a"), test_icon("b")]).unwrap();
411        let slice: &[IconData] = &fl;
412        assert_eq!(slice.len(), 2);
413        // Can iterate via Deref
414        assert_eq!(fl.iter().count(), 2);
415    }
416
417    #[test]
418    fn empty_frame_list_error_display() {
419        let err = EmptyFrameListError;
420        assert_eq!(err.to_string(), "frame list must not be empty");
421    }
422
423    #[test]
424    fn empty_frame_list_error_is_std_error() {
425        let err: Box<dyn std::error::Error> = Box::new(EmptyFrameListError);
426        assert_eq!(err.to_string(), "frame list must not be empty");
427    }
428
429    // === FrameList serde tests ===
430
431    #[test]
432    fn frame_list_deserialize_rejects_empty_json() {
433        let json = "[]";
434        let result: Result<FrameList, _> = serde_json::from_str(json);
435        assert!(result.is_err());
436        let err = result.unwrap_err().to_string();
437        assert!(
438            err.contains("frame list must not be empty"),
439            "expected custom error message, got: {err}"
440        );
441    }
442
443    #[test]
444    fn frame_list_deserialize_non_empty_json_succeeds() {
445        // IconData::Svg is serialized as {"Svg": [bytes...]}, so we need valid JSON
446        let fl = FrameList::new(vec![test_icon_borrowed()]).unwrap();
447        let json = serde_json::to_string(&fl).unwrap();
448        let deserialized: FrameList = serde_json::from_str(&json).unwrap();
449        assert_eq!(deserialized.len(), 1);
450    }
451
452    #[test]
453    fn frame_list_round_trips_through_json() {
454        let fl = FrameList::new(vec![test_icon("a"), test_icon("b")]).unwrap();
455        let json = serde_json::to_string(&fl).unwrap();
456        let back: FrameList = serde_json::from_str(&json).unwrap();
457        assert_eq!(fl, back);
458    }
459
460    // === AnimatedIcon construction tests ===
461
462    #[test]
463    fn frames_variant_constructs() {
464        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(83)).unwrap();
465        assert!(matches!(icon, AnimatedIcon::Frames(_)));
466        if let AnimatedIcon::Frames(data) = &icon {
467            assert_eq!(data.frames().len(), 1);
468            assert_eq!(data.frame_duration_ms().get(), 83);
469        }
470    }
471
472    #[test]
473    fn frames_constructor_rejects_empty() {
474        let result = AnimatedIcon::frames(vec![], nz(83));
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn transform_variant_constructs() {
480        let icon = AnimatedIcon::transform(
481            test_icon("spinner"),
482            TransformAnimation::Spin {
483                duration_ms: nz(1000),
484            },
485        );
486        assert!(matches!(icon, AnimatedIcon::Transform(_)));
487        if let AnimatedIcon::Transform(data) = &icon {
488            assert_eq!(
489                *data.animation(),
490                TransformAnimation::Spin {
491                    duration_ms: nz(1000)
492                }
493            );
494        }
495    }
496
497    #[test]
498    #[allow(clippy::clone_on_copy)]
499    fn transform_animation_is_copy_clone_debug_eq_hash() {
500        let a = TransformAnimation::Spin {
501            duration_ms: nz(500),
502        };
503        let a2 = a; // Copy
504        let a3 = a.clone(); // Clone
505        assert_eq!(a2, a3); // PartialEq + Eq
506        use std::hash::Hash;
507        let mut hasher = std::collections::hash_map::DefaultHasher::new();
508        a.hash(&mut hasher);
509        let _ = format!("{a:?}"); // Debug
510    }
511
512    #[test]
513    fn animated_icon_is_clone_debug_eq_not_copy() {
514        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(100)).unwrap();
515        let cloned = icon.clone(); // Clone
516        assert_eq!(icon, cloned); // PartialEq + Eq
517        let _ = format!("{icon:?}"); // Debug
518    }
519
520    // === first_frame() tests ===
521
522    #[test]
523    fn first_frame_frames_with_items() {
524        let f0 = test_icon("frame0");
525        let icon = AnimatedIcon::frames(vec![f0.clone(), test_icon("frame1")], nz(100)).unwrap();
526        // first_frame() returns &IconData, not Option
527        assert_eq!(icon.first_frame(), &f0);
528    }
529
530    #[test]
531    fn first_frame_transform() {
532        let data = test_icon("spin");
533        let icon = AnimatedIcon::transform(
534            data.clone(),
535            TransformAnimation::Spin {
536                duration_ms: nz(1000),
537            },
538        );
539        assert_eq!(icon.first_frame(), &data);
540    }
541
542    // === Accessor method tests ===
543
544    #[test]
545    fn frame_list_accessor_returns_some_for_frames() {
546        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(83)).unwrap();
547        assert!(icon.frame_list().is_some());
548        assert_eq!(icon.frame_list().unwrap().len(), 1);
549    }
550
551    #[test]
552    fn frame_list_accessor_returns_none_for_transform() {
553        let icon = AnimatedIcon::transform(
554            test_icon("spin"),
555            TransformAnimation::Spin {
556                duration_ms: nz(1000),
557            },
558        );
559        assert!(icon.frame_list().is_none());
560    }
561
562    #[test]
563    fn frame_duration_ms_accessor() {
564        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(42)).unwrap();
565        assert_eq!(icon.frame_duration_ms().unwrap().get(), 42);
566    }
567
568    #[test]
569    fn icon_accessor_returns_some_for_transform() {
570        let data = test_icon("spin");
571        let icon = AnimatedIcon::transform(
572            data.clone(),
573            TransformAnimation::Spin {
574                duration_ms: nz(1000),
575            },
576        );
577        assert_eq!(icon.icon(), Some(&data));
578    }
579
580    #[test]
581    fn icon_accessor_returns_none_for_frames() {
582        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(83)).unwrap();
583        assert!(icon.icon().is_none());
584    }
585
586    #[test]
587    fn animation_accessor_returns_some_for_transform() {
588        let anim = TransformAnimation::Spin {
589            duration_ms: nz(1000),
590        };
591        let icon = AnimatedIcon::transform(test_icon("spin"), anim);
592        assert_eq!(icon.animation(), Some(&anim));
593    }
594
595    #[test]
596    fn animation_accessor_returns_none_for_frames() {
597        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(83)).unwrap();
598        assert!(icon.animation().is_none());
599    }
600
601    // === Deprecated new_frames() compatibility tests ===
602
603    #[test]
604    #[allow(deprecated)]
605    fn new_frames_valid() {
606        let anim = AnimatedIcon::new_frames(vec![test_icon("f1")], 83);
607        assert!(anim.is_some());
608    }
609
610    #[test]
611    #[allow(deprecated)]
612    fn new_frames_rejects_empty() {
613        assert!(AnimatedIcon::new_frames(vec![], 83).is_none());
614    }
615
616    #[test]
617    #[allow(deprecated)]
618    fn new_frames_rejects_zero_duration() {
619        let frames = vec![test_icon("f1")];
620        assert!(AnimatedIcon::new_frames(frames, 0).is_none());
621    }
622
623    // === Serde round-trip tests ===
624
625    #[test]
626    fn animated_icon_frames_round_trips_json() {
627        let icon = AnimatedIcon::frames(vec![test_icon("f1"), test_icon("f2")], nz(83)).unwrap();
628        let json = serde_json::to_string(&icon).unwrap();
629        let back: AnimatedIcon = serde_json::from_str(&json).unwrap();
630        assert_eq!(icon, back);
631    }
632
633    #[test]
634    fn animated_icon_transform_round_trips_json() {
635        let icon = AnimatedIcon::transform(
636            test_icon("spinner"),
637            TransformAnimation::Spin {
638                duration_ms: nz(1000),
639            },
640        );
641        let json = serde_json::to_string(&icon).unwrap();
642        let back: AnimatedIcon = serde_json::from_str(&json).unwrap();
643        assert_eq!(icon, back);
644    }
645
646    #[test]
647    fn transform_animation_spin_uses_non_zero_u32() {
648        // NonZeroU32::new(0) returns None, so zero is impossible
649        assert!(NonZeroU32::new(0).is_none());
650        // Valid duration works
651        let anim = TransformAnimation::Spin {
652            duration_ms: nz(500),
653        };
654        let TransformAnimation::Spin { duration_ms } = anim;
655        assert_eq!(duration_ms.get(), 500);
656    }
657
658    // === FramesData and TransformData accessor tests ===
659
660    #[test]
661    fn frames_data_accessors() {
662        let icon = AnimatedIcon::frames(vec![test_icon("f1")], nz(83)).unwrap();
663        if let AnimatedIcon::Frames(data) = &icon {
664            assert_eq!(data.frames().len(), 1);
665            assert_eq!(data.frame_duration_ms().get(), 83);
666        } else {
667            panic!("expected Frames variant");
668        }
669    }
670
671    #[test]
672    fn transform_data_accessors() {
673        let icon_data = test_icon("spin");
674        let anim = TransformAnimation::Spin {
675            duration_ms: nz(1000),
676        };
677        let icon = AnimatedIcon::transform(icon_data.clone(), anim);
678        if let AnimatedIcon::Transform(data) = &icon {
679            assert_eq!(data.icon(), &icon_data);
680            assert_eq!(*data.animation(), anim);
681        } else {
682            panic!("expected Transform variant");
683        }
684    }
685}