1use std::time::Duration;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Motion {
8 pub pulse_period: Duration,
10 pub flash: Duration,
12 pub cursor_blink: Duration,
14 pub step: Duration,
16 pub slide: bool,
18 pub enter: Duration,
20 pub spinner: Duration,
22 pub shimmer: Duration,
24 pub hover_delay: Duration,
27 pub page: Duration,
29}
30
31impl Motion {
32 #[must_use]
35 pub fn duration(&self, key: &str) -> Option<Duration> {
36 Some(match key {
37 "pulse-period" => self.pulse_period,
38 "flash" => self.flash,
39 "cursor-blink" => self.cursor_blink,
40 "step" => self.step,
41 "enter" => self.enter,
42 "spinner" => self.spinner,
43 "shimmer" => self.shimmer,
44 "hover-delay" => self.hover_delay,
45 "page" => self.page,
46 _ => return None,
47 })
48 }
49}
50
51pub(crate) const MOTION_KEYS: [&str; 10] =
53 ["pulse-period", "flash", "cursor-blink", "step", "slide", "enter", "spinner", "shimmer", "page", "hover-delay"];
54
55const MAX_DURATION: Duration = Duration::from_secs(3600);
58
59pub(crate) fn parse_duration(text: &str) -> Result<Duration, String> {
61 let invalid = || format!("`{text}` is not a duration; write it like \"90ms\" or \"1.4s\"");
62 let (number, scale) = if let Some(ms) = text.strip_suffix("ms") {
63 (ms, 0.001)
64 } else if let Some(s) = text.strip_suffix('s') {
65 (s, 1.0)
66 } else {
67 return Err(invalid());
68 };
69 let value: f64 = number.trim().parse().map_err(|_| invalid())?;
70 if !value.is_finite() || value < 0.0 {
71 return Err(invalid());
72 }
73 match Duration::try_from_secs_f64(value * scale) {
74 Ok(duration) if duration <= MAX_DURATION => Ok(duration),
75 _ => Err(format!("`{text}` is too long; motion durations are at most one hour")),
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82
83 #[test]
84 fn parses_milliseconds_and_seconds() {
85 assert_eq!(parse_duration("1400ms"), Ok(Duration::from_millis(1400)));
86 assert_eq!(parse_duration("1.4s"), Ok(Duration::from_millis(1400)));
87 assert_eq!(parse_duration("0ms"), Ok(Duration::ZERO));
88 }
89
90 #[test]
91 fn rejects_other_forms() {
92 assert!(parse_duration("1400").is_err());
93 assert!(parse_duration("-5ms").is_err());
94 assert!(parse_duration("fast").is_err());
95 }
96
97 #[test]
98 fn rejects_durations_longer_than_an_hour_without_panicking() {
99 assert_eq!(parse_duration("3600s"), Ok(MAX_DURATION));
100 assert!(parse_duration("3601s").is_err_and(|message| message.contains("at most one hour")));
101 assert!(parse_duration("1e30s").is_err());
103 }
104}