agg_gui/animation.rs
1//! Thread-local repaint-request signals.
2//!
3//! Two independent channels feed the host's event loop:
4//!
5//! 1. **Immediate** — [`request_tick`] / [`wants_tick`]. Any widget whose
6//! state just changed calls `request_tick()`; the next iteration of the
7//! host loop paints a frame and clears the flag. This is the "mark
8//! dirty" path: input handlers, hover transitions, tweens mid-animation,
9//! drag movement, continuous capture widgets.
10//!
11//! 2. **Scheduled** — [`request_repaint_after`] / [`next_repaint_at`]. A
12//! widget that needs a redraw *at a future time* (text-cursor blink,
13//! tooltip delay) calls `request_repaint_after(Duration)`; the host's
14//! loop goes to sleep with `ControlFlow::WaitUntil(that_instant)` and
15//! paints when the deadline fires. Successive calls keep the EARLIEST
16//! deadline.
17//!
18//! The host loop paints iff `wants_tick() || now >= next_repaint_at()`.
19//! Between paints it idles; no frames are drawn while nothing has changed.
20
21use std::cell::Cell;
22use std::time::Duration;
23use web_time::Instant;
24
25std::thread_local! {
26 static NEEDS_TICK: Cell<bool> = Cell::new(false);
27 static NEXT_REPAINT_AT: Cell<Option<Instant>> = Cell::new(None);
28}
29
30/// Request that the host schedule another paint as soon as possible. Safe to
31/// call any number of times in a frame. Typically called from `Widget::paint`
32/// while a time-based animation is in progress, from input handlers whose
33/// widget state changed, or from anywhere that mutates visual state.
34pub fn request_tick() {
35 NEEDS_TICK.with(|c| c.set(true));
36}
37
38/// Non-destructive read. Hosts call this after painting to decide control-flow
39/// for the next loop iteration.
40pub fn wants_tick() -> bool {
41 NEEDS_TICK.with(|c| c.get())
42}
43
44/// Reset the per-frame repaint flags. The `App::paint` entry point calls
45/// this before delegating to the root widget so each frame starts fresh —
46/// widgets that still need a redraw (animation in flight, focus blink, etc.)
47/// must re-arm during their paint, otherwise the loop goes idle.
48pub fn clear_tick() {
49 NEEDS_TICK.with(|c| c.set(false));
50 NEXT_REPAINT_AT.with(|c| c.set(None));
51}
52
53/// Schedule a future paint. Keeps the EARLIEST pending deadline, so multiple
54/// widgets asking for different delays will all be served by the soonest one
55/// (each widget re-arms its own deadline on the next paint anyway).
56pub fn request_repaint_after(delay: Duration) {
57 let when = Instant::now() + delay;
58 NEXT_REPAINT_AT.with(|c| {
59 match c.get() {
60 Some(existing) if existing <= when => {}
61 _ => c.set(Some(when)),
62 }
63 });
64}
65
66/// Read-and-clear the scheduled repaint deadline. The host reads this after
67/// painting so the next frame's scheduled wake is determined entirely by what
68/// the fresh paint registered (e.g. a text field re-arms the 500 ms blink
69/// each frame while it remains focused; losing focus means no re-arm and the
70/// loop goes idle).
71pub fn take_next_repaint() -> Option<Instant> {
72 NEXT_REPAINT_AT.with(|c| c.replace(None))
73}
74
75// ── Tween ────────────────────────────────────────────────────────────────────
76//
77// Small reusable time-based interpolator for widgets that want a smooth
78// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
79// Ease-out cubic; reversal preserves the current value so rapid toggles
80// don't snap. Requests an animation tick automatically while in flight.
81
82/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
83/// caller interprets). Drives animations such as the scroll-bar hover
84/// expansion and toggle-switch on/off slide.
85#[derive(Clone, Copy)]
86pub struct Tween {
87 current: f64,
88 start_value: f64,
89 target: f64,
90 start_time: Option<Instant>,
91 duration: f64,
92}
93
94impl Tween {
95 /// New tween that starts at `initial` with the same value as its target
96 /// (no animation in flight).
97 pub const fn new(initial: f64, duration_secs: f64) -> Self {
98 Self {
99 current: initial,
100 start_value: initial,
101 target: initial,
102 start_time: None,
103 duration: duration_secs,
104 }
105 }
106
107 /// Update the target. If it differs from the current target, re-anchors
108 /// the animation at the current interpolated value so reversals are smooth.
109 pub fn set_target(&mut self, new_target: f64) {
110 if (self.target - new_target).abs() > 1e-9 {
111 self.start_value = self.current;
112 self.target = new_target;
113 self.start_time = Some(Instant::now());
114 }
115 }
116
117 /// Advance the animation based on elapsed wall time and return the new
118 /// interpolated value. Ease-out cubic. While in flight this also calls
119 /// [`request_tick`] so the host keeps painting frames until completion.
120 pub fn tick(&mut self) -> f64 {
121 if let Some(start) = self.start_time {
122 let elapsed = start.elapsed().as_secs_f64();
123 let p = (elapsed / self.duration).min(1.0);
124 let eased = 1.0 - (1.0 - p).powi(3);
125 self.current = self.start_value + (self.target - self.start_value) * eased;
126 if p >= 1.0 {
127 self.current = self.target;
128 self.start_time = None;
129 } else {
130 request_tick();
131 }
132 }
133 self.current
134 }
135
136 /// Current interpolated value without advancing.
137 pub fn value(&self) -> f64 { self.current }
138}
139
140impl Default for Tween {
141 fn default() -> Self { Self::new(0.0, 0.12) }
142}