agg_gui/animation.rs
1//! Thread-local draw-request and invalidation signals.
2//!
3//! Two independent channels feed the host's event loop:
4//!
5//! 1. **Immediate draw request** — [`request_draw`] / [`wants_draw`]. Any
6//! widget whose visual output just changed calls `request_draw()`; the next
7//! iteration of the host loop draws a frame and clears the flag. The same
8//! call advances [`invalidation_epoch`], letting event dispatch dirty the
9//! affected retained ancestor path even when the event bubbles as ignored.
10//!
11//! 2. **Scheduled draw** — [`request_draw_after`] /
12//! [`take_next_draw_deadline`]. A
13//! widget that needs a draw *at a future time* (text-cursor blink,
14//! tooltip delay) calls `request_draw_after(Duration)`; the host's
15//! loop goes to sleep with `ControlFlow::WaitUntil(that_instant)` and
16//! draws when the deadline fires. Successive calls keep the EARLIEST
17//! deadline.
18//!
19//! The host loop draws iff `wants_draw() || now >= take_next_draw_deadline()`.
20//! Between draws it idles; no frames are drawn while nothing has changed.
21
22use std::cell::Cell;
23use std::time::Duration;
24use web_time::Instant;
25
26std::thread_local! {
27 static NEEDS_DRAW: Cell<bool> = Cell::new(false);
28 static NEXT_DRAW_AT: Cell<Option<Instant>> = Cell::new(None);
29 static INVALIDATION_EPOCH: Cell<u64> = Cell::new(0);
30}
31
32/// Request that the host schedule another draw as soon as possible.
33///
34/// This is also the canonical visual invalidation hook: event dispatch compares
35/// [`invalidation_epoch`] before/after delivery and dirties the affected
36/// retained ancestor path when a widget requested a draw.
37pub fn request_draw() {
38 NEEDS_DRAW.with(|c| c.set(true));
39 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
40}
41
42/// Request a frame without dirtying retained widget backbuffers.
43///
44/// Use this for app-level overlays whose source state changed outside a
45/// retained subtree. The inspector hover rectangle is the canonical case:
46/// it must redraw, but the inspected/inspector windows do not need their FBOs
47/// rebuilt just because the overlay target moved.
48pub fn request_draw_without_invalidation() {
49 NEEDS_DRAW.with(|c| c.set(true));
50}
51
52/// Non-destructive read. Hosts call this after drawing to decide control-flow
53/// for the next loop iteration.
54pub fn wants_draw() -> bool {
55 NEEDS_DRAW.with(|c| c.get())
56}
57
58/// Monotonic draw-request epoch used to detect visual changes during dispatch.
59pub fn invalidation_epoch() -> u64 {
60 INVALIDATION_EPOCH.with(|c| c.get())
61}
62
63/// Reset the per-frame draw flags. The `App::paint` entry point calls
64/// this before delegating to the root widget so each frame starts fresh —
65/// widgets that still need a draw (animation in flight, focus blink, etc.)
66/// must re-arm during their draw, otherwise the loop goes idle.
67pub fn clear_draw_request() {
68 NEEDS_DRAW.with(|c| c.set(false));
69 NEXT_DRAW_AT.with(|c| c.set(None));
70}
71
72/// Schedule a future draw. Keeps the EARLIEST pending deadline, so multiple
73/// widgets asking for different delays will all be served by the soonest one
74/// (each widget re-arms its own deadline on the next draw anyway).
75pub fn request_draw_after(delay: Duration) {
76 let when = Instant::now() + delay;
77 NEXT_DRAW_AT.with(|c| match c.get() {
78 Some(existing) if existing <= when => {}
79 _ => c.set(Some(when)),
80 });
81}
82
83/// Read-and-clear the scheduled draw deadline. The host reads this after
84/// drawing so the next frame's scheduled wake is determined entirely by what
85/// the fresh draw registered (e.g. a text field re-arms the 500 ms blink
86/// each frame while it remains focused; losing focus means no re-arm and the
87/// loop goes idle).
88pub fn take_next_draw_deadline() -> Option<Instant> {
89 NEXT_DRAW_AT.with(|c| c.replace(None))
90}
91
92// ── Tween ────────────────────────────────────────────────────────────────────
93//
94// Small reusable time-based interpolator for widgets that want a smooth
95// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
96// Ease-out cubic; reversal preserves the current value so rapid toggles
97// don't snap. Requests a draw automatically while in flight.
98
99/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
100/// caller interprets). Drives animations such as the scroll-bar hover
101/// expansion and toggle-switch on/off slide.
102#[derive(Clone, Copy)]
103pub struct Tween {
104 current: f64,
105 start_value: f64,
106 target: f64,
107 start_time: Option<Instant>,
108 duration: f64,
109}
110
111impl Tween {
112 /// New tween that starts at `initial` with the same value as its target
113 /// (no animation in flight).
114 pub const fn new(initial: f64, duration_secs: f64) -> Self {
115 Self {
116 current: initial,
117 start_value: initial,
118 target: initial,
119 start_time: None,
120 duration: duration_secs,
121 }
122 }
123
124 /// Update the target. If it differs from the current target, re-anchors
125 /// the animation at the current interpolated value so reversals are smooth.
126 ///
127 /// Widgets that own a `Tween` must also report `tween.is_animating()` from
128 /// `Widget::needs_draw()` so retained parents repaint every frame until
129 /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
130 /// intentionally does not invalidate because many widgets retarget from
131 /// paint while synchronizing with external state.
132 pub fn set_target(&mut self, new_target: f64) {
133 if (self.target - new_target).abs() > 1e-9 {
134 self.start_value = self.current;
135 self.target = new_target;
136 self.start_time = Some(Instant::now());
137 }
138 }
139
140 /// Advance the animation based on elapsed wall time and return the new
141 /// interpolated value. Ease-out cubic. While in flight this also calls
142 /// [`request_draw`] so the host keeps drawing frames until completion.
143 pub fn tick(&mut self) -> f64 {
144 if let Some(start) = self.start_time {
145 let elapsed = start.elapsed().as_secs_f64();
146 let p = (elapsed / self.duration).min(1.0);
147 let eased = 1.0 - (1.0 - p).powi(3);
148 self.current = self.start_value + (self.target - self.start_value) * eased;
149 if p >= 1.0 {
150 self.current = self.target;
151 self.start_time = None;
152 } else {
153 request_draw();
154 }
155 }
156 self.current
157 }
158
159 /// Current interpolated value without advancing.
160 pub fn value(&self) -> f64 {
161 self.current
162 }
163
164 /// Whether the tween still needs frames to reach its target.
165 pub fn is_animating(&self) -> bool {
166 self.start_time.is_some()
167 }
168}
169
170impl Default for Tween {
171 fn default() -> Self {
172 Self::new(0.0, 0.12)
173 }
174}