Skip to main content

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}