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}