Skip to main content

ashlar_motion/
class.rs

1//! What kind of value moves: its settle thresholds and its reduced-motion rule.
2
3use crate::curve::Curve;
4
5/// Settle thresholds: a motion is settled once its distance to the target stays within `pos`
6/// and its speed within `vel` (units per second) for good.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct Eps {
9    /// Largest distance from the target, in the value's units.
10    pub pos: f32,
11    /// Largest speed, in the value's units per second.
12    pub vel: f32,
13}
14
15const MIN_EPS: f32 = 1e-7;
16
17impl Eps {
18    /// Thresholds, clamped to be positive and finite (NaN takes 10⁻³).
19    pub const fn new(pos: f32, vel: f32) -> Eps {
20        Eps { pos: eps_value(pos), vel: eps_value(vel) }
21    }
22
23    /// The same thresholds in range.
24    pub const fn sanitized(self) -> Eps {
25        Eps::new(self.pos, self.vel)
26    }
27}
28
29const fn eps_value(v: f32) -> f32 {
30    if v.is_nan() { 1e-3 } else { v.clamp(MIN_EPS, f32::MAX) }
31}
32
33/// The kind of a moving value. It sets how close counts as settled and what reduced motion does.
34/// Effect classes (opacity, colour) never overshoot: their samples stay between the start and
35/// the target.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
37pub enum Class {
38    /// Positions and sizes in points.
39    Spatial,
40    /// A scale factor around 1.
41    Scale,
42    /// An alpha 0..=1. An effect: never overshoots.
43    Opacity,
44    /// Colour lanes (OKLab). An effect: never overshoots.
45    Color,
46    /// A value under the user's hand (a drag or fling): reduced motion leaves it alone.
47    Gesture,
48    /// Anything else (a progress number…).
49    #[default]
50    Other,
51}
52
53/// The pixels-per-point range [`Class::eps`] accepts.
54const PPP_RANGE: (f32, f32) = (0.25, 16.0);
55
56impl Class {
57    /// Settle thresholds for this class on a display with `pixels_per_point` (NaN is 1, clamped
58    /// to 0.25..=16). Spatial and gesture values settle within a quarter of a physical pixel.
59    pub fn eps(self, pixels_per_point: f32) -> Eps {
60        let ppp = if pixels_per_point.is_nan() { 1.0 } else { pixels_per_point.clamp(PPP_RANGE.0, PPP_RANGE.1) };
61        match self {
62            Class::Spatial | Class::Gesture => Eps::new(0.25 / ppp, 4.0 / ppp),
63            Class::Scale => Eps::new(5e-4, 5e-3),
64            Class::Opacity | Class::Color => Eps::new(1.0 / 1024.0, 1.0 / 64.0),
65            Class::Other => Eps::new(1e-3, 1e-2),
66        }
67    }
68
69    /// Whether this class is an effect (opacity, colour) that must not overshoot.
70    pub const fn is_effect(self) -> bool {
71        matches!(self, Class::Opacity | Class::Color)
72    }
73
74    /// The curve to use instead of `curve` under reduced motion: spatial, scale and other values
75    /// jump; opacity and colour take a short fade ([`crate::tokens::REDUCED_FADE`]); a gesture
76    /// keeps its curve (the user's own hand is never slowed or removed).
77    pub const fn reduce(self, curve: Curve) -> Curve {
78        match self {
79            Class::Spatial | Class::Scale | Class::Other => Curve::Instant,
80            Class::Opacity | Class::Color => {
81                if curve.is_instant() {
82                    Curve::Instant
83                } else {
84                    crate::tokens::REDUCED_FADE
85                }
86            }
87            Class::Gesture => curve,
88        }
89    }
90}
91
92/// App-wide motion policy, plain data. The UI layer reads it; `Animated` takes it per retarget.
93#[derive(Clone, Copy, Debug, PartialEq)]
94pub struct MotionPrefs {
95    /// Reduce motion (the OS accessibility setting or a user override): see [`Class::reduce`].
96    pub reduced: bool,
97    /// Clock speed for motion: 1 normal, 0 paused, 0.25 slow motion. The caller's clock applies
98    /// it; read it through [`MotionPrefs::scale`], which keeps it within 0..=10.
99    pub time_scale: f32,
100}
101
102impl Default for MotionPrefs {
103    fn default() -> Self {
104        MotionPrefs::DEFAULT
105    }
106}
107
108impl MotionPrefs {
109    /// Full motion at normal speed.
110    pub const DEFAULT: MotionPrefs = MotionPrefs { reduced: false, time_scale: 1.0 };
111    /// Reduced motion at normal speed.
112    pub const REDUCED: MotionPrefs = MotionPrefs { reduced: true, time_scale: 1.0 };
113    /// The largest time scale.
114    pub const MAX_TIME_SCALE: f32 = 10.0;
115
116    /// Preferences with `time_scale` clamped to 0..=10 (NaN is 1).
117    pub const fn new(reduced: bool, time_scale: f32) -> MotionPrefs {
118        MotionPrefs { reduced, time_scale: scale(time_scale) }
119    }
120
121    /// The time scale in range.
122    pub const fn scale(self) -> f32 {
123        scale(self.time_scale)
124    }
125}
126
127const fn scale(s: f32) -> f32 {
128    if s.is_nan() { 1.0 } else { s.clamp(0.0, MotionPrefs::MAX_TIME_SCALE) }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::{Easing, Spring, tokens};
135
136    #[test]
137    fn reduced_rules() {
138        let s = Curve::Spring(Spring::new(0.4, 0.2));
139        assert_eq!(Class::Spatial.reduce(s), Curve::Instant);
140        assert_eq!(Class::Scale.reduce(s), Curve::Instant);
141        assert_eq!(Class::Opacity.reduce(s), tokens::REDUCED_FADE);
142        assert_eq!(Class::Color.reduce(s), tokens::REDUCED_FADE);
143        assert_eq!(Class::Opacity.reduce(Curve::Instant), Curve::Instant);
144        assert_eq!(Class::Gesture.reduce(s), s);
145        assert!(matches!(tokens::REDUCED_FADE, Curve::Tween { duration, easing } if (duration - 0.18).abs() < 1e-6 && easing == Easing::EASE_IN_OUT));
146    }
147
148    #[test]
149    fn eps_and_prefs_clamp() {
150        let e = Class::Spatial.eps(f32::NAN);
151        assert_eq!(e, Class::Spatial.eps(1.0));
152        assert!(Class::Spatial.eps(2.0).pos < e.pos);
153        assert!(Class::Spatial.eps(f32::INFINITY).pos > 0.0);
154        assert_eq!(Eps::new(f32::NAN, -1.0), Eps { pos: 1e-3, vel: MIN_EPS });
155        assert_eq!(MotionPrefs::new(false, f32::NAN).time_scale, 1.0);
156        assert_eq!(MotionPrefs::new(false, 99.0).scale(), 10.0);
157        assert_eq!(MotionPrefs { reduced: false, time_scale: -1.0 }.scale(), 0.0);
158    }
159}