drmkit_csd/animator.rs
1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Moving a decoration between states over time.
5
6use std::time::Duration;
7
8use crate::{HoverButton, WindowState};
9
10/// Ease-out cubic, clamped to `0..=1`.
11///
12/// Fast at the start and slow at the end, which is what a transition to a
13/// settled state should feel like: the change is announced immediately and
14/// arrives gently. The clamp is not decoration — an animator that overshoots
15/// its duration would otherwise extrapolate past the target and the window
16/// would visibly bounce.
17#[must_use]
18pub fn ease_out_cubic(t: f32) -> f32 {
19 let clamped = t.clamp(0.0, 1.0);
20 let inverse = 1.0 - clamped;
21 1.0 - (inverse * inverse * inverse)
22}
23
24/// One value moving toward a target.
25#[derive(Debug, Clone, Copy)]
26struct Track {
27 /// Where the current run started, so a retarget mid-flight eases from
28 /// where the value *is* rather than snapping back to zero.
29 start: f32,
30 /// Where it is now.
31 progress: f32,
32 /// How far into the run.
33 elapsed: Duration,
34 /// Whether a run is in progress.
35 running: bool,
36}
37
38impl Track {
39 const fn settled(at: f32) -> Self {
40 Self {
41 start: at,
42 progress: at,
43 elapsed: Duration::ZERO,
44 running: false,
45 }
46 }
47
48 /// Begin moving toward a new target from wherever the value is now.
49 const fn retarget(&mut self, from: f32) {
50 self.start = from;
51 self.elapsed = Duration::ZERO;
52 self.running = true;
53 }
54
55 /// Advance; `running` says afterward whether the run is still going.
56 fn advance(&mut self, dt: Duration, duration: Duration, target: f32) {
57 if !self.running {
58 return;
59 }
60 self.elapsed = self.elapsed.saturating_add(dt);
61 // The clamp is belt-and-braces: `ease_out_cubic` clamps too, and the
62 // `t >= 1.0` settle below catches an overshoot regardless. Removing it
63 // changes no observable behavior -- verified by injection -- and it
64 // stays because the value is compared as well as eased, and a reader
65 // should not have to check both to know `t` is in range.
66 let t = (self.elapsed.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0);
67 self.progress = self.start + (target - self.start) * ease_out_cubic(t);
68 if t >= 1.0 {
69 // Assigned rather than left to the ease, which reaches 1.0 only in
70 // the limit -- a window that settled at 0.999 focused would never
71 // quite look focused.
72 self.progress = target;
73 self.running = false;
74 }
75 }
76}
77
78/// The animations one decorated window is running.
79///
80/// Focus and hover advance independently: a window can gain focus while the
81/// pointer leaves a button, and forcing them onto one timeline would make
82/// whichever started second jump.
83#[derive(Debug, Clone, Copy)]
84pub struct WindowAnim {
85 focus_target: bool,
86 focus: Track,
87 hover_target: HoverButton,
88 /// Which button is being *drawn* highlighted.
89 ///
90 /// Not the same as the target. When the pointer leaves, the target is
91 /// `None` immediately but the button it left keeps being drawn while it
92 /// fades — without this, the highlight would vanish rather than fade.
93 hover_painted: HoverButton,
94 hover: Track,
95}
96
97impl Default for WindowAnim {
98 fn default() -> Self {
99 Self::new(false)
100 }
101}
102
103impl WindowAnim {
104 /// An animator settled at `focused`, with nothing hovered.
105 ///
106 /// Settled, not animating toward it: a window that appears focused should
107 /// be drawn focused, not fade in from unfocused on its first frame.
108 #[must_use]
109 pub const fn new(focused: bool) -> Self {
110 Self {
111 focus_target: focused,
112 focus: Track::settled(if focused { 1.0 } else { 0.0 }),
113 hover_target: HoverButton::None,
114 hover_painted: HoverButton::None,
115 hover: Track::settled(0.0),
116 }
117 }
118
119 /// Aim at a new focus state.
120 ///
121 /// Retargeting to what it is already aiming at does nothing — otherwise a
122 /// caller that reports focus every frame would restart the timeline every
123 /// frame and the window would never finish transitioning.
124 pub const fn retarget_focus(&mut self, focused: bool) {
125 if self.focus_target == focused {
126 return;
127 }
128 self.focus_target = focused;
129 let from = self.focus.progress;
130 self.focus.retarget(from);
131 }
132
133 /// Aim at a new hovered button.
134 ///
135 /// Moving between buttons restarts from zero on the new one rather than
136 /// carrying the old one's progress across: they are different highlights
137 /// in different places, and a value carried over would make the new button
138 /// appear already half-lit.
139 pub const fn retarget_hover(&mut self, hover: HoverButton) {
140 if self.hover_target as u8 == hover as u8 {
141 return;
142 }
143 self.hover_target = hover;
144 let from = if matches!(hover, HoverButton::None) {
145 // Leaving: keep drawing the button being left, and fade it from
146 // where it is.
147 self.hover.progress
148 } else {
149 self.hover_painted = hover;
150 self.hover.progress = 0.0;
151 0.0
152 };
153 self.hover.retarget(from);
154 }
155
156 /// Jump to the targets, running nothing.
157 ///
158 /// For a caller that wants the end state now — a window appearing, a
159 /// theme with animations turned off, a session resuming.
160 pub const fn snap(&mut self) {
161 self.focus = Track::settled(if self.focus_target { 1.0 } else { 0.0 });
162 self.hover_painted = self.hover_target;
163 self.hover = Track::settled(if matches!(self.hover_target, HoverButton::None) {
164 0.0
165 } else {
166 1.0
167 });
168 }
169
170 /// Advance by `dt`, over a transition lasting `duration`.
171 ///
172 /// Returns whether anything is still animating, which is what tells a
173 /// caller to schedule another frame.
174 ///
175 /// A zero duration snaps. That is not an edge case to tolerate but the
176 /// documented way to turn animations off — `glass-minimal` sets it — and
177 /// dividing by it instead would produce an infinity that the clamp turns
178 /// into an instant jump anyway, less legibly. A `Duration` cannot be
179 /// negative, so a caller converting
180 /// [`Theme::animation_duration_ms`](crate::Theme::animation_duration_ms)
181 /// must map a negative value to zero itself to get the same snap.
182 pub fn tick(&mut self, dt: Duration, duration: Duration) -> bool {
183 if duration.is_zero() {
184 self.snap();
185 return false;
186 }
187
188 self.focus
189 .advance(dt, duration, if self.focus_target { 1.0 } else { 0.0 });
190
191 // The hover target is 1.0 only while the painted button is still the
192 // one being aimed at. Once the pointer has left, the same track runs
193 // back down to zero on the button it is still drawing.
194 let hover_target = if !matches!(self.hover_target, HoverButton::None)
195 && self.hover_target as u8 == self.hover_painted as u8
196 {
197 1.0
198 } else {
199 0.0
200 };
201 let was_running = self.hover.running;
202 self.hover.advance(dt, duration, hover_target);
203 if was_running && !self.hover.running && hover_target == 0.0 {
204 // The fade finished: stop drawing the button that was left.
205 self.hover_painted = HoverButton::None;
206 }
207
208 self.is_animating()
209 }
210
211 /// Whether anything is still moving.
212 #[must_use]
213 pub const fn is_animating(&self) -> bool {
214 self.focus.running || self.hover.running
215 }
216
217 /// How far through the focus transition, `0..=1`.
218 #[must_use]
219 pub const fn focus_progress(&self) -> f32 {
220 self.focus.progress
221 }
222
223 /// How far through the hover transition, `0..=1`.
224 #[must_use]
225 pub const fn hover_progress(&self) -> f32 {
226 self.hover.progress
227 }
228
229 /// Which button is being drawn highlighted.
230 #[must_use]
231 pub const fn hover_painted(&self) -> HoverButton {
232 self.hover_painted
233 }
234
235 /// Write the current progress into a state for the renderer.
236 pub const fn apply_to(&self, state: &mut WindowState) {
237 state.focus_progress = self.focus.progress;
238 state.hover = self.hover_painted;
239 state.hover_progress = self.hover.progress;
240 }
241}