Skip to main content

qframe/
motion.rs

1//! Motion: easing, values that move over time, and cell-stepped progress.
2//!
3//! Terminal motion happens in whole cells, so movement is expressed as progress from 0 to 1
4//! that widgets turn into cell positions with [`steps`], while colours blend continuously with
5//! the same progress. Durations come from the theme's `[motion]` table; when motion is reduced
6//! every animation lands on its end state at once.
7
8use std::time::Duration;
9
10/// How progress accelerates over time.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum Easing {
13    /// Constant speed.
14    Linear,
15    /// Starts slowly.
16    EaseIn,
17    /// Ends slowly; the natural choice for things arriving.
18    #[default]
19    EaseOut,
20    /// Starts and ends slowly.
21    EaseInOut,
22}
23
24impl Easing {
25    /// Every easing.
26    pub const ALL: [Self; 4] = [Self::Linear, Self::EaseIn, Self::EaseOut, Self::EaseInOut];
27
28    /// A short name, e.g. for settings screens and locale keys.
29    #[must_use]
30    pub fn name(self) -> &'static str {
31        match self {
32            Self::Linear => "linear",
33            Self::EaseIn => "ease-in",
34            Self::EaseOut => "ease-out",
35            Self::EaseInOut => "ease-in-out",
36        }
37    }
38
39    /// Eased progress for linear progress `t` in `0..=1`.
40    #[must_use]
41    pub fn apply(self, t: f32) -> f32 {
42        let t = t.clamp(0.0, 1.0);
43        match self {
44            Self::Linear => t,
45            Self::EaseIn => t * t * t,
46            Self::EaseOut => 1.0 - (1.0 - t).powi(3),
47            Self::EaseInOut => {
48                if t < 0.5 {
49                    4.0 * t * t * t
50                } else {
51                    1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
52                }
53            }
54        }
55    }
56}
57
58/// A number moving from one value to another over a duration.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct Tween {
61    from: f32,
62    to: f32,
63    start: Duration,
64    duration: Duration,
65    easing: Easing,
66}
67
68impl Tween {
69    /// A value resting at `value`.
70    #[must_use]
71    pub fn settled(value: f32) -> Self {
72        Self { from: value, to: value, start: Duration::ZERO, duration: Duration::ZERO, easing: Easing::Linear }
73    }
74
75    /// The value at time `now`.
76    #[must_use]
77    pub fn value(&self, now: Duration) -> f32 {
78        if self.duration.is_zero() || now >= self.end() {
79            return self.to;
80        }
81        let elapsed = now.saturating_sub(self.start).as_secs_f32() / self.duration.as_secs_f32();
82        self.from + (self.to - self.from) * self.easing.apply(elapsed)
83    }
84
85    /// The value the tween is heading to.
86    #[must_use]
87    pub fn target(&self) -> f32 {
88        self.to
89    }
90
91    /// Whether the value is still moving at `now`.
92    #[must_use]
93    pub fn is_running(&self, now: Duration) -> bool {
94        now < self.end() && self.from != self.to
95    }
96
97    /// When the movement ends; saturates rather than overflowing the clock.
98    fn end(&self) -> Duration {
99        self.start.saturating_add(self.duration)
100    }
101
102    /// Moves towards `to` from wherever the value is at `now`.
103    pub fn retarget(&mut self, to: f32, now: Duration, duration: Duration, easing: Easing) {
104        let current = self.value(now);
105        *self = Self { from: current, to, start: now, duration, easing };
106    }
107}
108
109/// Turns progress in `0..=1` into one of `count + 1` cell positions, `0..=count`.
110#[must_use]
111pub fn steps(progress: f32, count: u16) -> u16 {
112    let position = (progress.clamp(0.0, 1.0) * f32::from(count)).round();
113    // `position` lies in 0..=count, so it fits in u16.
114    position as u16
115}
116
117/// Animated values of one widget, by name.
118#[derive(Debug, Default)]
119pub(crate) struct Tweens {
120    values: Vec<(&'static str, Tween)>,
121}
122
123impl Tweens {
124    /// The current value of `name`, retargeted to `target` when it changed. A value seen for
125    /// the first time starts at its target, so nothing animates on first paint.
126    pub(crate) fn drive(
127        &mut self,
128        name: &'static str,
129        target: f32,
130        now: Duration,
131        duration: Duration,
132        easing: Easing,
133    ) -> Tween {
134        match self.values.iter_mut().find(|(n, _)| *n == name) {
135            Some((_, tween)) => {
136                if (tween.target() - target).abs() > f32::EPSILON {
137                    tween.retarget(target, now, duration, easing);
138                }
139                *tween
140            }
141            None => {
142                let tween = Tween::settled(target);
143                self.values.push((name, tween));
144                tween
145            }
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn easings_start_at_zero_and_end_at_one() {
156        for easing in [Easing::Linear, Easing::EaseIn, Easing::EaseOut, Easing::EaseInOut] {
157            assert!(easing.apply(0.0).abs() < 1e-6);
158            assert!((easing.apply(1.0) - 1.0).abs() < 1e-6);
159        }
160        assert!(Easing::EaseOut.apply(0.5) > 0.5);
161        assert!(Easing::EaseIn.apply(0.5) < 0.5);
162    }
163
164    #[test]
165    fn tween_moves_and_retargets_from_current_value() {
166        let mut tween = Tween::settled(0.0);
167        tween.retarget(10.0, Duration::ZERO, Duration::from_millis(100), Easing::Linear);
168        assert!((tween.value(Duration::from_millis(50)) - 5.0).abs() < 1e-4);
169        assert!(tween.is_running(Duration::from_millis(50)));
170        tween.retarget(0.0, Duration::from_millis(50), Duration::from_millis(100), Easing::Linear);
171        assert!((tween.value(Duration::from_millis(50)) - 5.0).abs() < 1e-4);
172        assert_eq!(tween.value(Duration::from_millis(200)), 0.0);
173        assert!(!tween.is_running(Duration::from_millis(200)));
174    }
175
176    #[test]
177    fn endless_tween_does_not_overflow_the_clock() {
178        let mut tween = Tween::settled(0.0);
179        tween.retarget(1.0, Duration::from_secs(5), Duration::MAX, Easing::Linear);
180        assert!(tween.is_running(Duration::from_secs(6)));
181        assert!(tween.value(Duration::from_secs(6)) < 1e-6);
182    }
183
184    #[test]
185    fn steps_round_to_cells() {
186        assert_eq!(steps(0.0, 3), 0);
187        assert_eq!(steps(0.49, 3), 1);
188        assert_eq!(steps(1.0, 3), 3);
189        assert_eq!(steps(7.0, 3), 3);
190    }
191
192    #[test]
193    fn first_sight_does_not_animate() {
194        let mut tweens = Tweens::default();
195        let first = tweens.drive("x", 1.0, Duration::ZERO, Duration::from_millis(100), Easing::Linear);
196        assert!(!first.is_running(Duration::ZERO));
197        let moving = tweens.drive("x", 0.0, Duration::from_millis(10), Duration::from_millis(100), Easing::Linear);
198        assert!(moving.is_running(Duration::from_millis(20)));
199    }
200}