Skip to main content

qframe/theme/
motion.rs

1//! Motion settings: how fast things breathe, flash, blink and step.
2
3use std::time::Duration;
4
5/// Timing values from the theme's `[motion]` table.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct Motion {
8    /// One full breath of `pulse()` paints.
9    pub pulse_period: Duration,
10    /// How long a pressed control flashes.
11    pub flash: Duration,
12    /// Half period of the text cursor blink.
13    pub cursor_blink: Duration,
14    /// Time between frames of cell-stepped animations such as the switch knob.
15    pub step: Duration,
16    /// Whether selected rows slide their leading text one cell to the right.
17    pub slide: bool,
18    /// How long layers such as dropdowns and dialogs take to appear.
19    pub enter: Duration,
20    /// Time each spinner frame is shown.
21    pub spinner: Duration,
22    /// One pass of light across shimmering text.
23    pub shimmer: Duration,
24    /// How long the pointer rests on something before its tooltip appears. Optional in theme
25    /// files; 450ms when missing.
26    pub hover_delay: Duration,
27    /// How long a page change takes. Optional in theme files; twice `enter` when missing.
28    pub page: Duration,
29}
30
31impl Motion {
32    /// The duration stored under a `[motion]` key such as `"step"` or `"spinner"`; `None` for
33    /// `slide`, which is not a duration, and for unknown keys.
34    #[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
51/// The keys accepted in `[motion]`.
52pub(crate) const MOTION_KEYS: [&str; 10] =
53    ["pulse-period", "flash", "cursor-blink", "step", "slide", "enter", "spinner", "shimmer", "page", "hover-delay"];
54
55/// The longest motion duration a theme may set. Widgets multiply durations and add them to the
56/// clock, so an unbounded value from a file could overflow; no animation needs more than this.
57const MAX_DURATION: Duration = Duration::from_secs(3600);
58
59/// Parses `"1400ms"`, `"1.4s"` or `"0ms"`, up to one hour.
60pub(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        // Beyond what `Duration` can hold, which must be an error rather than a panic.
102        assert!(parse_duration("1e30s").is_err());
103    }
104}