tempa 0.1.6

A no_std temporal system for animation and simulation, providing time, duration, time ranges, frame rates, and deterministic frame-index stepping.
Documentation
use core::ops::{Add, AddAssign, Sub, SubAssign};

use rinia::FloatScalar;
#[cfg(feature = "zerocopy")]
use zerocopy::*;

use crate::{
    FrameRate, Time, conversion,
    macros::{impl_bytemuck_transparent, impl_inner_op_family_forwarding},
};

/// Represents a discrete, non-negative frame position in a timeline.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "zerocopy", derive(FromBytes, Immutable, IntoBytes, KnownLayout))]
#[repr(transparent)]
pub struct FrameIndex(u32);

impl FrameIndex {
    /// The zero frame index.
    pub const ZERO: Self = Self(0);

    /// Creates a [FrameIndex] from a raw value.
    #[inline]
    pub const fn new(value: u32) -> Self {
        Self(value)
    }

    /// Returns the raw frame value.
    #[inline]
    pub const fn get(&self) -> u32 {
        self.0
    }

    /// Creates a [FrameIndex] from [Time] at the specified [FrameRate].
    #[inline]
    pub fn from_time<T: FloatScalar>(time: Time<T>, rate: FrameRate<T>) -> Self {
        conversion::frame_from_time(time, rate)
    }

    /// Converts this [FrameIndex] to [Time] at the specified [FrameRate].
    #[inline]
    pub fn to_time<T: FloatScalar>(self, rate: FrameRate<T>) -> Time<T> {
        conversion::time_from_frame(self, rate)
    }

    /// Returns the signed frame offset from this index to another index.
    /// The result is positive when self is ahead of other, negative when behind, and zero when
    /// equal.
    #[inline]
    pub fn offset_from(self, other: FrameIndex) -> i32 {
        let a = self.0 as i64;
        let b = other.0 as i64;
        (a - b) as i32
    }

    /// Returns the next [FrameIndex], saturating at [u32::MAX].
    #[inline]
    pub const fn next(self) -> Self {
        Self(self.0.saturating_add(1))
    }

    /// Returns the previous [FrameIndex], saturating at zero.
    #[inline]
    pub const fn prev(self) -> Self {
        Self(self.0.saturating_sub(1))
    }

    // Add+Sub impls (saturating, wrapping, and checked).
    impl_inner_op_family_forwarding!(add, u32, 0);
    impl_inner_op_family_forwarding!(sub, u32, 0);
}

// Raw conversion
impl From<u32> for FrameIndex {
    #[inline]
    fn from(value: u32) -> Self {
        Self::new(value)
    }
}

impl From<FrameIndex> for u32 {
    #[inline]
    fn from(frame: FrameIndex) -> Self {
        frame.get()
    }
}

// FrameIndex + u32 = FrameIndex
impl Add<u32> for FrameIndex {
    type Output = Self;

    #[inline]
    fn add(self, rhs: u32) -> Self::Output {
        self.saturating_add(rhs)
    }
}

// FrameIndex += u32
impl AddAssign<u32> for FrameIndex {
    #[inline]
    fn add_assign(&mut self, rhs: u32) {
        *self = self.saturating_add(rhs);
    }
}

// FrameIndex - u32 = FrameIndex
impl Sub<u32> for FrameIndex {
    type Output = Self;

    #[inline]
    fn sub(self, rhs: u32) -> Self::Output {
        self.saturating_sub(rhs)
    }
}

// FrameIndex -= u32
impl SubAssign<u32> for FrameIndex {
    #[inline]
    fn sub_assign(&mut self, rhs: u32) {
        *self = self.saturating_sub(rhs);
    }
}

// Bytemuck impl
impl_bytemuck_transparent!(FrameIndex, u32);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basics_zero_new_get_next_prev() {
        assert_eq!(FrameIndex::ZERO.get(), 0);
        assert_eq!(FrameIndex::new(10).get(), 10);
        assert_eq!(FrameIndex::new(10).next(), FrameIndex::new(11));
        assert_eq!(FrameIndex::new(10).prev(), FrameIndex::new(9));
    }

    #[test]
    fn next_prev_saturate_at_bounds() {
        assert_eq!(FrameIndex::new(u32::MAX).next(), FrameIndex::new(u32::MAX));
        assert_eq!(FrameIndex::ZERO.prev(), FrameIndex::ZERO);
    }

    #[test]
    fn offset_from_reports_signed_difference() {
        assert_eq!(FrameIndex::new(10).offset_from(FrameIndex::new(4)), 6);
        assert_eq!(FrameIndex::new(4).offset_from(FrameIndex::new(10)), -6);
        assert_eq!(FrameIndex::new(8).offset_from(FrameIndex::new(8)), 0);
    }

    #[test]
    fn saturating_checked_wrapping_add_sub_work() {
        let x = FrameIndex::new(u32::MAX - 1);
        assert_eq!(x.saturating_add(10), FrameIndex::new(u32::MAX));
        assert_eq!(x.checked_add(1), Some(FrameIndex::new(u32::MAX)));
        assert_eq!(x.checked_add(2), None);
        assert_eq!(x.wrapping_add(2), FrameIndex::new(0));

        let y = FrameIndex::new(1);
        assert_eq!(y.saturating_sub(10), FrameIndex::ZERO);
        assert_eq!(y.checked_sub(1), Some(FrameIndex::ZERO));
        assert_eq!(y.checked_sub(2), None);
        assert_eq!(y.wrapping_sub(2), FrameIndex::new(u32::MAX));
    }

    #[test]
    fn add_sub_and_assign_use_saturating_behavior() {
        let mut i = FrameIndex::new(u32::MAX - 1);
        i += 10;
        assert_eq!(i, FrameIndex::new(u32::MAX));

        i -= u32::MAX;
        assert_eq!(i, FrameIndex::ZERO);

        assert_eq!(FrameIndex::new(5) + 10, FrameIndex::new(15));
        assert_eq!(FrameIndex::new(5) - 10, FrameIndex::ZERO);
    }

    #[test]
    fn conversion_to_and_from_time_matches_rate() {
        let rate = FrameRate::from_fps(30.0_f64);
        let frame = FrameIndex::new(45);
        let time = frame.to_time(rate);
        let frame2 = FrameIndex::from_time(time, rate);

        assert_eq!(time, Time::from_seconds(1.5));
        assert_eq!(frame2, frame);
    }

    #[cfg(feature = "bytemuck")]
    #[test]
    fn bytemuck_traits_are_implemented() {
        fn assert_impl<
            T: bytemuck::Pod + bytemuck::Zeroable + bytemuck::TransparentWrapper<u32>,
        >() {
        }

        assert_impl::<FrameIndex>();
    }

    #[cfg(feature = "bytemuck")]
    #[test]
    fn bytemuck_roundtrip_works() {
        let value = FrameIndex::new(1234);
        let bytes = bytemuck::bytes_of(&value);
        let decoded = *bytemuck::from_bytes::<FrameIndex>(bytes);

        assert_eq!(decoded, value);
    }

    #[cfg(feature = "zerocopy")]
    #[test]
    fn zerocopy_traits_are_implemented() {
        fn assert_impl<
            T: zerocopy::FromBytes + zerocopy::IntoBytes + zerocopy::KnownLayout + zerocopy::Immutable,
        >() {
        }

        assert_impl::<FrameIndex>();
    }

    #[cfg(feature = "zerocopy")]
    #[test]
    fn zerocopy_roundtrip_works() {
        let value = FrameIndex::new(5678);
        let bytes = <FrameIndex as zerocopy::IntoBytes>::as_bytes(&value);
        let decoded = <FrameIndex as zerocopy::FromBytes>::ref_from_bytes(bytes)
            .expect("FrameIndex bytes should decode");

        assert_eq!(*decoded, value);
    }
}