Skip to main content

rdi_core/
duration.rs

1//! Duration policies for a single icon's animation.
2
3use std::time::Duration as StdDuration;
4
5use crate::{DesktopError, Point};
6
7/// Minimum duration returned by [`Duration::resolve`] even when the
8/// distance would compute to zero, so the tick loop never has to divide
9/// by zero.
10const MIN_RESOLVED: StdDuration = StdDuration::from_millis(1);
11
12/// How the animation engine determines how long a single icon takes to
13/// move from its origin to its target.
14#[derive(Copy, Clone, Debug, PartialEq)]
15pub enum Duration {
16    /// A fixed wall-clock duration.
17    Fixed(StdDuration),
18
19    /// Duration derived from movement distance and a constant speed,
20    /// optionally clamped to `min` / `max`.
21    ///
22    /// * `speed_px_per_sec` must be strictly positive and finite.
23    /// * `min` and `max`, if both provided, must satisfy `min <= max`.
24    Distance {
25        speed_px_per_sec: f32,
26        min: Option<StdDuration>,
27        max: Option<StdDuration>,
28    },
29}
30
31impl Duration {
32    /// Convenience constructor for [`Duration::Fixed`].
33    #[inline]
34    pub const fn fixed(d: StdDuration) -> Self {
35        Self::Fixed(d)
36    }
37
38    /// Convenience constructor for [`Duration::Distance`] without clamps.
39    #[inline]
40    pub const fn distance(speed_px_per_sec: f32) -> Self {
41        Self::Distance {
42            speed_px_per_sec,
43            min: None,
44            max: None,
45        }
46    }
47
48    /// Convenience constructor for a clamped distance-based duration.
49    #[inline]
50    pub const fn distance_clamped(
51        speed_px_per_sec: f32,
52        min: StdDuration,
53        max: StdDuration,
54    ) -> Self {
55        Self::Distance {
56            speed_px_per_sec,
57            min: Some(min),
58            max: Some(max),
59        }
60    }
61
62    /// Resolve this policy to a concrete duration for the given movement.
63    ///
64    /// Returns [`DesktopError::InvalidDuration`] if the policy contains
65    /// invalid values (non-positive fixed duration, non-positive speed,
66    /// `min > max`, non-finite parameters).
67    pub fn resolve(&self, from: Point, to: Point) -> Result<StdDuration, DesktopError> {
68        match *self {
69            Duration::Fixed(d) => {
70                if d.is_zero() {
71                    Err(DesktopError::InvalidDuration(
72                        "fixed duration must be non-zero".into(),
73                    ))
74                } else {
75                    Ok(d)
76                }
77            }
78            Duration::Distance {
79                speed_px_per_sec,
80                min,
81                max,
82            } => {
83                if !speed_px_per_sec.is_finite() || speed_px_per_sec <= 0.0 {
84                    return Err(DesktopError::InvalidDuration(format!(
85                        "distance duration requires positive finite speed, got {speed_px_per_sec}"
86                    )));
87                }
88                if let (Some(lo), Some(hi)) = (min, max) {
89                    if lo > hi {
90                        return Err(DesktopError::InvalidDuration(format!(
91                            "distance duration min ({lo:?}) is greater than max ({hi:?})"
92                        )));
93                    }
94                }
95
96                let dist = Point::distance(from, to);
97                let seconds = (dist / speed_px_per_sec).max(0.0);
98                let mut d = StdDuration::try_from_secs_f32(seconds).unwrap_or(StdDuration::ZERO);
99
100                if let Some(lo) = min {
101                    if d < lo {
102                        d = lo;
103                    }
104                }
105                if let Some(hi) = max {
106                    if d > hi {
107                        d = hi;
108                    }
109                }
110
111                // Never return 0 — even a zero-distance move needs to
112                // produce a well-defined tick loop.
113                if d.is_zero() {
114                    d = MIN_RESOLVED;
115                }
116                Ok(d)
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn fixed_returns_value_verbatim() {
128        let d = Duration::fixed(StdDuration::from_millis(500));
129        assert_eq!(
130            d.resolve(Point::ZERO, Point::new(999, 0)).unwrap(),
131            StdDuration::from_millis(500)
132        );
133    }
134
135    #[test]
136    fn fixed_zero_is_rejected() {
137        let d = Duration::fixed(StdDuration::ZERO);
138        let err = d.resolve(Point::ZERO, Point::ZERO).unwrap_err();
139        assert!(matches!(err, DesktopError::InvalidDuration(_)));
140    }
141
142    #[test]
143    fn distance_scales_with_distance() {
144        // 500 px @ 1000 px/s = 0.5 s.
145        let d = Duration::distance(1000.0);
146        let out = d.resolve(Point::ZERO, Point::new(300, 400)).unwrap();
147        // Allow a couple of ms slack for f32 rounding.
148        let expected = StdDuration::from_millis(500);
149        assert!(
150            (out.as_secs_f64() - expected.as_secs_f64()).abs() < 0.01,
151            "got {out:?}"
152        );
153    }
154
155    #[test]
156    fn distance_zero_falls_back_to_min_resolved() {
157        let d = Duration::distance(1000.0);
158        let out = d.resolve(Point::ZERO, Point::ZERO).unwrap();
159        assert_eq!(out, MIN_RESOLVED);
160    }
161
162    #[test]
163    fn distance_min_clamps_short_moves() {
164        let d = Duration::distance_clamped(
165            1000.0,
166            StdDuration::from_millis(300),
167            StdDuration::from_secs(2),
168        );
169        // 10 px / 1000 px/s = 10 ms → clamped up to 300 ms.
170        let out = d.resolve(Point::ZERO, Point::new(10, 0)).unwrap();
171        assert_eq!(out, StdDuration::from_millis(300));
172    }
173
174    #[test]
175    fn distance_max_clamps_long_moves() {
176        let d = Duration::distance_clamped(
177            1.0, // 1 px/s → very slow
178            StdDuration::from_millis(100),
179            StdDuration::from_millis(500),
180        );
181        let out = d.resolve(Point::ZERO, Point::new(5000, 0)).unwrap();
182        assert_eq!(out, StdDuration::from_millis(500));
183    }
184
185    #[test]
186    fn distance_bad_speed_rejected() {
187        for &s in &[0.0f32, -1.0, f32::NAN, f32::INFINITY] {
188            let d = Duration::distance(s);
189            let err = d.resolve(Point::ZERO, Point::new(10, 0)).unwrap_err();
190            assert!(matches!(err, DesktopError::InvalidDuration(_)), "s = {s}");
191        }
192    }
193
194    #[test]
195    fn distance_min_greater_than_max_rejected() {
196        let d = Duration::Distance {
197            speed_px_per_sec: 100.0,
198            min: Some(StdDuration::from_secs(2)),
199            max: Some(StdDuration::from_secs(1)),
200        };
201        assert!(matches!(
202            d.resolve(Point::ZERO, Point::new(10, 0)).unwrap_err(),
203            DesktopError::InvalidDuration(_)
204        ));
205    }
206}