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, and report 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 behaviour -- 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 or negative duration snaps. That is not an edge case to tolerate
176 /// but the documented way to turn animations off — `glass-minimal` sets
177 /// it — and dividing by it instead would produce an infinity that the
178 /// clamp turns into an instant jump anyway, less legibly.
179 pub fn tick(&mut self, dt: Duration, duration: Duration) -> bool {
180 if duration.is_zero() {
181 self.snap();
182 return false;
183 }
184
185 self.focus
186 .advance(dt, duration, if self.focus_target { 1.0 } else { 0.0 });
187
188 // The hover target is 1.0 only while the painted button is still the
189 // one being aimed at. Once the pointer has left, the same track runs
190 // back down to zero on the button it is still drawing.
191 let hover_target = if !matches!(self.hover_target, HoverButton::None)
192 && self.hover_target as u8 == self.hover_painted as u8
193 {
194 1.0
195 } else {
196 0.0
197 };
198 let was_running = self.hover.running;
199 self.hover.advance(dt, duration, hover_target);
200 if was_running && !self.hover.running && hover_target == 0.0 {
201 // The fade finished: stop drawing the button that was left.
202 self.hover_painted = HoverButton::None;
203 }
204
205 self.is_animating()
206 }
207
208 /// Whether anything is still moving.
209 #[must_use]
210 pub const fn is_animating(&self) -> bool {
211 self.focus.running || self.hover.running
212 }
213
214 /// How far through the focus transition, `0..=1`.
215 #[must_use]
216 pub const fn focus_progress(&self) -> f32 {
217 self.focus.progress
218 }
219
220 /// How far through the hover transition, `0..=1`.
221 #[must_use]
222 pub const fn hover_progress(&self) -> f32 {
223 self.hover.progress
224 }
225
226 /// Which button is being drawn highlighted.
227 #[must_use]
228 pub const fn hover_painted(&self) -> HoverButton {
229 self.hover_painted
230 }
231
232 /// Write the current progress into a state for the renderer.
233 pub const fn apply_to(&self, state: &mut WindowState) {
234 state.focus_progress = self.focus.progress;
235 state.hover = self.hover_painted;
236 state.hover_progress = self.hover.progress;
237 }
238}