Skip to main content

retroglyph_ui/animate/
oscillate.rs

1use core::time::Duration;
2
3/// A continuous sine wave sampled at `elapsed`, completing one full cycle every `period`, mapped
4/// from its natural `-1.0..=1.0` range to `0.0..=1.0`.
5///
6/// For a finite transition that starts, runs once, and stops, see [`Tween`](super::Tween)
7/// instead. This is for motion with no start or end (a pulsing indicator, a breathing effect):
8/// keep accumulating `elapsed` every frame and re-sample.
9///
10/// Equivalent to [`oscillate_with_phase`](crate::animate::oscillate_with_phase) with `phase` `0.0`. Two callers driving independent
11/// pulses (say, a row of status dots) that both call plain `oscillate` with the same `elapsed`
12/// and `period` will always be perfectly in sync; use [`oscillate_with_phase`](crate::animate::oscillate_with_phase) to stagger them.
13///
14/// ```
15/// use core::time::Duration;
16/// use retroglyph_ui::oscillate;
17///
18/// let period = Duration::from_secs(2);
19/// assert_eq!(oscillate(Duration::ZERO, period), 0.5); // sin(0) == 0, remapped to the midpoint
20/// assert!((oscillate(Duration::from_millis(1500), period) - 0.0).abs() < 1e-6); // 3/4 through
21/// ```
22#[must_use]
23pub fn oscillate(elapsed: Duration, period: Duration) -> f32 {
24    oscillate_with_phase(elapsed, period, 0.0)
25}
26
27/// [`oscillate`](crate::animate::oscillate), offset by `phase` full cycles.
28///
29/// `phase` shifts where in the cycle sampling starts: `0.0` matches plain [`oscillate`](crate::animate::oscillate), `0.25`
30/// starts a quarter-cycle ahead, and so on. Only `phase`'s fractional part matters (a whole
31/// number of cycles is no offset at all), and negative values are fine. This is what lets
32/// several callers share one clock's `elapsed` and `period` while still pulsing out of sync with
33/// each other, e.g. a row of status dots each given a different `phase`.
34///
35/// ```
36/// use core::time::Duration;
37/// use retroglyph_ui::oscillate_with_phase;
38///
39/// let period = Duration::from_secs(2);
40/// // A quarter cycle ahead of `elapsed = 0` is the same as `elapsed = period / 4` with no phase.
41/// let a = oscillate_with_phase(Duration::ZERO, period, 0.25);
42/// let b = oscillate_with_phase(Duration::from_millis(500), period, 0.0);
43/// assert!((a - b).abs() < 1e-6);
44/// ```
45#[must_use]
46pub fn oscillate_with_phase(elapsed: Duration, period: Duration, phase: f32) -> f32 {
47    if period.is_zero() {
48        return 0.5;
49    }
50    let cycles = elapsed.as_secs_f32() / period.as_secs_f32() + phase; // unbounded; doesn't wrap
51    let radians = cycles * 2.0 * core::f32::consts::PI;
52    retroglyph_core::math::mul_add(0.5, retroglyph_core::math::sin(radians), 0.5)
53}
54
55#[cfg(test)]
56#[allow(clippy::float_cmp)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn oscillate_at_zero_elapsed_is_the_midpoint() {
62        // sin(0) == 0, remapped from -1..=1 to 0..=1 -> 0.5.
63        assert_eq!(oscillate(Duration::ZERO, Duration::from_secs(1)), 0.5);
64    }
65
66    #[test]
67    fn oscillate_completes_a_full_cycle_after_one_period() {
68        let period = Duration::from_secs(4);
69        let start = oscillate(Duration::ZERO, period);
70        let one_cycle_later = oscillate(period, period);
71        assert!((start - one_cycle_later).abs() < 1e-5);
72    }
73
74    #[test]
75    fn oscillate_stays_within_0_and_1() {
76        let period = Duration::from_secs(1);
77        for ms in 0..2000u64 {
78            let v = oscillate(Duration::from_millis(ms), period);
79            assert!((0.0..=1.0).contains(&v), "{v} out of range at {ms}ms");
80        }
81    }
82
83    #[test]
84    fn zero_period_is_a_defined_constant_not_a_panic() {
85        assert_eq!(oscillate(Duration::from_secs(1), Duration::ZERO), 0.5);
86    }
87
88    #[test]
89    fn zero_phase_matches_plain_oscillate() {
90        let period = Duration::from_secs(1);
91        for ms in 0..2000u64 {
92            let elapsed = Duration::from_millis(ms);
93            assert_eq!(
94                oscillate_with_phase(elapsed, period, 0.0),
95                oscillate(elapsed, period)
96            );
97        }
98    }
99
100    #[test]
101    fn phase_offset_shifts_the_sampled_point_in_the_cycle() {
102        let period = Duration::from_secs(2);
103        let a = oscillate_with_phase(Duration::ZERO, period, 0.25);
104        let b = oscillate_with_phase(Duration::from_millis(500), period, 0.0);
105        assert!((a - b).abs() < 1e-6);
106    }
107
108    #[test]
109    fn phase_offset_by_a_whole_number_of_cycles_is_no_offset() {
110        let period = Duration::from_secs(1);
111        let elapsed = Duration::from_millis(137);
112        let a = oscillate_with_phase(elapsed, period, 0.0);
113        let b = oscillate_with_phase(elapsed, period, 3.0);
114        assert!((a - b).abs() < 1e-5);
115    }
116
117    #[test]
118    fn negative_phase_is_defined() {
119        let period = Duration::from_secs(2);
120        let a = oscillate_with_phase(Duration::from_millis(500), period, -0.25);
121        let b = oscillate_with_phase(Duration::ZERO, period, 0.0);
122        assert!((a - b).abs() < 1e-6);
123    }
124
125    #[test]
126    fn phase_offset_stays_within_0_and_1() {
127        let period = Duration::from_secs(1);
128        for ms in 0..2000u64 {
129            let v = oscillate_with_phase(Duration::from_millis(ms), period, 0.37);
130            assert!((0.0..=1.0).contains(&v), "{v} out of range at {ms}ms");
131        }
132    }
133
134    #[test]
135    fn zero_period_with_phase_is_a_defined_constant_not_a_panic() {
136        assert_eq!(
137            oscillate_with_phase(Duration::from_secs(1), Duration::ZERO, 0.25),
138            0.5
139        );
140    }
141}