Skip to main content

blockworx_paint/
easing.rs

1//! The keyed easings the render path polls, as a table the host ticks.
2//!
3//! egui's `AnimationManager::animate_value` (© Rerun / emilk, dual-licensed
4//! MIT OR Apache-2.0), reimplemented over [`Duration`] and [`AnimKey`]. It
5//! lives here rather than in a backend so every backend animates identically,
6//! and so the clock a view depends on is an argument rather than a global.
7
8use core::time::Duration;
9use std::collections::HashMap;
10
11use blockworx_geom::remap_clamp;
12
13use crate::AnimKey;
14
15/// One easing in flight: where it started, where it is heading, and when it
16/// was last aimed somewhere new.
17#[derive(Clone, Copy)]
18struct Eased {
19    from: f32,
20    to: f32,
21    toggled_at: Duration,
22}
23
24/// The frame clock an easing is read against: when this frame is, and how
25/// long the next one is expected to take.
26///
27/// A host with a frame rate of its own states the prediction
28/// ([`Tick::predicting`]); one that knows only what time it is says
29/// [`Tick::at`] and whoever holds the previous tick fills it in
30/// ([`Tick::after`]).
31#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
32pub struct Tick {
33    now: Duration,
34    predicted_dt: Option<Duration>,
35}
36
37impl Tick {
38    /// The clock as a host that knows how long its frames take states it.
39    #[must_use]
40    pub const fn predicting(now: Duration, predicted_dt: Duration) -> Self {
41        Self {
42            now,
43            predicted_dt: Some(predicted_dt),
44        }
45    }
46
47    /// The clock as a host that knows only the time states it.
48    #[must_use]
49    pub const fn at(now: Duration) -> Self {
50        Self {
51            now,
52            predicted_dt: None,
53        }
54    }
55
56    /// This tick with an unstated prediction taken from the interval since
57    /// `previous`, which is what a host reading its own frame history would
58    /// have predicted.
59    #[must_use]
60    pub fn after(self, previous: Self) -> Self {
61        Self {
62            now: self.now,
63            predicted_dt: Some(
64                self.predicted_dt
65                    .unwrap_or_else(|| self.now.saturating_sub(previous.now)),
66            ),
67        }
68    }
69
70    #[must_use]
71    pub const fn now(self) -> Duration {
72        self.now
73    }
74
75    /// How long the next frame is expected to take: what the host stated, or
76    /// what [`Tick::after`] derived, and nothing on a tick that has neither.
77    #[must_use]
78    pub const fn predicted_dt(self) -> Duration {
79        match self.predicted_dt {
80            Some(predicted) => predicted,
81            None => Duration::ZERO,
82        }
83    }
84}
85
86/// What one poll of an easing answered.
87#[derive(Clone, Copy, PartialEq, Debug)]
88pub struct Animated {
89    pub value: f32,
90    /// Whether the easing has further to go. A host that draws on demand
91    /// asks for another frame while this holds.
92    pub in_progress: bool,
93}
94
95/// Every keyed easing in the session.
96#[derive(Clone, Default)]
97pub struct Easing {
98    values: HashMap<AnimKey, Eased>,
99}
100
101impl Easing {
102    /// Where `key`'s easing stands, having aimed it at `goal` over `over`.
103    ///
104    /// The first sight of a key answers the goal itself, so an affordance
105    /// appears where it belongs rather than sliding in from nowhere. After
106    /// that the value is read half a predicted frame ahead, which is what
107    /// keeps the frame a goal changes on from answering the old value.
108    pub fn animate(&mut self, tick: Tick, key: AnimKey, goal: f32, over: Duration) -> Animated {
109        let Some(eased) = self.values.get_mut(&key) else {
110            self.values.insert(
111                key,
112                Eased {
113                    from: goal,
114                    to: goal,
115                    toggled_at: Duration::ZERO,
116                },
117            );
118            return Animated {
119                value: goal,
120                in_progress: false,
121            };
122        };
123        let since_toggle = tick.now.saturating_sub(eased.toggled_at).as_secs_f32()
124            + tick.predicted_dt().as_secs_f32() / 2.0;
125        let value = remap_clamp(
126            since_toggle,
127            (0.0, over.as_secs_f32()),
128            (eased.from, eased.to),
129        );
130        if eased.to != goal {
131            // A goal that moved mid-flight restarts from wherever the easing
132            // had got to, so the mark never jumps.
133            eased.from = value;
134            eased.to = goal;
135            eased.toggled_at = tick.now;
136        }
137        if over.is_zero() {
138            eased.from = goal;
139            eased.to = goal;
140        }
141        Animated {
142            value,
143            in_progress: value != goal,
144        }
145    }
146}