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::sync::atomic::{AtomicU64, Ordering};
24use std::time::Duration;
25use web_time::Instant;
26
27std::thread_local! {
28 static NEEDS_DRAW: Cell<bool> = Cell::new(false);
29 static NEXT_DRAW_AT: Cell<Option<Instant>> = Cell::new(None);
30 static INVALIDATION_EPOCH: Cell<u64> = Cell::new(0);
31 /// Bumped whenever an async source (image fetch + decode, font
32 /// load, etc.) finishes outside the event-dispatch path. Retained
33 /// backbuffers (Window FBOs, in-process bitmap caches) compare
34 /// their stored value against this epoch on each paint and force
35 /// a re-raster on mismatch — there is no widget reference at the
36 /// callback site to walk the ancestor chain via the usual
37 /// `mark_dirty` route, so without this signal a freshly-decoded
38 /// image draws into the placeholder-sized rect the previous
39 /// layout reserved (the user-visible "wrong scale on first
40 /// frame" bug).
41 static ASYNC_STATE_EPOCH: Cell<u64> = Cell::new(0);
42 /// Per-thread snapshot of `ASYNC_WAKEUP_COUNTER` last observed by
43 /// [`pump_async_wakeup`]. When the global atomic is ahead of this,
44 /// the current thread's [`NEEDS_DRAW`], [`INVALIDATION_EPOCH`] and
45 /// [`ASYNC_STATE_EPOCH`] are bumped — see the module docs above
46 /// `ASYNC_WAKEUP_COUNTER` for why this indirection is required.
47 static LAST_SEEN_ASYNC_WAKEUP: Cell<u64> = Cell::new(0);
48 /// Monotonic counter bumped once per pointer press that reaches the
49 /// widget tree (see [`bump_pointer_press_epoch`]). A widget that runs
50 /// its own multi-click gesture but no longer sees every press —
51 /// [`Scene`](crate::widgets::Scene), whose hosted children consume
52 /// their own presses before they can bubble — reads this to tell
53 /// whether an *intervening* press (e.g. a click on a hosted button)
54 /// happened between two of its own background clicks, so a
55 /// background double-click that straddles a child interaction does
56 /// not falsely fire.
57 static POINTER_PRESS_EPOCH: Cell<u64> = Cell::new(0);
58}
59
60/// Advance the pointer-press epoch. Called by [`App`](crate::App) once per
61/// pointer press that is about to be routed into the widget tree.
62pub fn bump_pointer_press_epoch() {
63 POINTER_PRESS_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
64}
65
66/// Current pointer-press epoch — see [`bump_pointer_press_epoch`]. Two
67/// presses are *consecutive* (nothing pressed in between) exactly when their
68/// observed epochs differ by one.
69pub fn pointer_press_epoch() -> u64 {
70 POINTER_PRESS_EPOCH.with(|c| c.get())
71}
72
73/// Process-global counter bumped by [`signal_async_state_change`] from
74/// any thread. The async fetch / decode runs on a background worker
75/// (e.g. ehttp's `std::thread::spawn`), so thread-locals it sets are
76/// invisible to the main event loop. The main thread pumps this
77/// atomic into its own thread-local epochs on every
78/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read —
79/// see [`pump_async_wakeup`].
80static ASYNC_WAKEUP_COUNTER: AtomicU64 = AtomicU64::new(0);
81
82/// Merge any pending cross-thread async-wakeup bumps into the calling
83/// thread's draw/invalidation/async-state state.
84///
85/// Without this, an ehttp callback completing on a background thread
86/// bumps thread-locals the main event loop never reads — the markdown
87/// SVG-badge "wrong scale until any other event" bug, where the loop
88/// keeps polling (`needs_draw=true` while `ImageState::Loading`) but
89/// `invalidation_epoch` never changes, so `render_app_frame` skips
90/// the layout pass and paints the freshly-decoded SVG into the
91/// previous layout's placeholder rect.
92fn pump_async_wakeup() {
93 let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
94 let changed = LAST_SEEN_ASYNC_WAKEUP.with(|c| {
95 let prev = c.get();
96 if prev == current {
97 false
98 } else {
99 c.set(current);
100 true
101 }
102 });
103 if changed {
104 NEEDS_DRAW.with(|c| c.set(true));
105 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
106 ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
107 }
108}
109
110/// Request that the host schedule another draw as soon as possible.
111///
112/// **This is the right default for every widget state mutation that affects
113/// visual output.** Calling it from inside an `on_event` handler advances
114/// [`invalidation_epoch`]; `dispatch_event` reads that epoch before/after
115/// delivery and automatically calls `mark_dirty` up the ancestor path when
116/// it sees a bump — so a retained ancestor's backbuffer cache invalidates
117/// without the widget needing to know about that ancestor at all.
118///
119/// Without the epoch bump, a `Widget::on_event` that returns `Ignored` (the
120/// common case for `MouseMove`) leaves the ancestor cache thinking
121/// "nothing changed", and the next frame composites a stale bitmap. Hover
122/// effects, focus rings, and any other appearance change driven by event
123/// state ALL need this hook.
124///
125/// Reach for [`request_draw_without_invalidation`] only when you're certain
126/// no retained widget's *content* changed — overlays, position-only
127/// translations, and similar. When in doubt, use `request_draw`.
128pub fn request_draw() {
129 NEEDS_DRAW.with(|c| c.set(true));
130 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
131}
132
133/// Request a frame **without** advancing [`invalidation_epoch`].
134///
135/// `dispatch_event` won't mark retained ancestors dirty for this call, so
136/// any widget that drew its previous frame into a backbuffer cache will
137/// composite the cached bitmap unchanged. Use this **only** when:
138///
139/// * The change lives in an app-level overlay that paints fresh every
140/// frame outside any retained subtree (inspector hover rectangle, popup
141/// menus rendered via `paint_global_overlay`, scroll-fade decorations).
142/// * The change is position-only — a window drag-move, where the cached
143/// content is reused at a translated origin (see `Window::on_event` for
144/// the canonical example).
145///
146/// **Do NOT call this from a widget that mutated its own state and expects
147/// the next paint to reflect it.** That's [`request_draw`]'s job. Hover
148/// indices, focus changes, animation ticks, button-press states — anything
149/// where the *content* of a retained widget differs from the cached
150/// bitmap — must call `request_draw` so the cache invalidates. The
151/// `MenuBar` hover regression in `widgets/menu/widget/tests_2.rs` exists
152/// precisely because this distinction was missed once already.
153pub fn request_draw_without_invalidation() {
154 NEEDS_DRAW.with(|c| c.set(true));
155}
156
157/// Non-destructive read. Hosts call this after drawing to decide control-flow
158/// for the next loop iteration.
159///
160/// Pumps any pending cross-thread async-wakeup bumps first, so a fetch
161/// callback that finished on a worker thread between frames is reflected
162/// in the result.
163pub fn wants_draw() -> bool {
164 pump_async_wakeup();
165 NEEDS_DRAW.with(|c| c.get())
166}
167
168/// Monotonic draw-request epoch used to detect visual changes during dispatch.
169///
170/// Pumps cross-thread wakeups first so a background-thread
171/// [`signal_async_state_change`] is observed here on the next read,
172/// causing layout-key caches keyed on this epoch to re-layout.
173pub fn invalidation_epoch() -> u64 {
174 pump_async_wakeup();
175 INVALIDATION_EPOCH.with(|c| c.get())
176}
177
178/// Note that an async-side state change happened (image loader finished,
179/// font loaded, etc.). Safe to call from any thread; the main event
180/// loop observes the bump via [`pump_async_wakeup`] on its next
181/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read.
182///
183/// This used to only bump thread-local epochs, which silently broke
184/// when callers ran on background threads (ehttp spawns its own
185/// `std::thread`) — the main thread never observed the change and
186/// `render_app_frame`'s layout-key cache skipped the layout pass that
187/// would have given freshly-decoded SVG badges their natural
188/// dimensions (the user-visible "wrong scale until any other event"
189/// bug).
190pub fn signal_async_state_change() {
191 // Cross-thread visible bump. Main thread merges via pump_async_wakeup.
192 ASYNC_WAKEUP_COUNTER.fetch_add(1, Ordering::AcqRel);
193 // Best-effort thread-local bump for same-thread callers (most
194 // hosts / tests). Background threads only set their own
195 // thread-locals here, which is harmless — the atomic above is
196 // what the main thread actually consumes.
197 NEEDS_DRAW.with(|c| c.set(true));
198 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
199 ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
200}
201
202/// Current async-state epoch. Backbuffer caches store this and force
203/// a re-raster when it doesn't match.
204///
205/// Pumps cross-thread wakeups first so a worker-thread
206/// [`signal_async_state_change`] surfaces on the next read.
207pub fn async_state_epoch() -> u64 {
208 pump_async_wakeup();
209 ASYNC_STATE_EPOCH.with(|c| c.get())
210}
211
212/// Reset the per-frame draw flags. The `App::paint` entry point calls
213/// this before delegating to the root widget so each frame starts fresh —
214/// widgets that still need a draw (animation in flight, focus blink, etc.)
215/// must re-arm during their draw, otherwise the loop goes idle.
216///
217/// Also syncs this thread's cross-thread async-wakeup bookkeeping so a
218/// stale bump from before this clear cannot reappear on the next
219/// `wants_draw` read. Without that sync, parallel tests calling
220/// [`signal_async_state_change`] would leak wakeups into unrelated
221/// tests that rely on `wants_draw()` returning `false` after a clear.
222pub fn clear_draw_request() {
223 NEEDS_DRAW.with(|c| c.set(false));
224 NEXT_DRAW_AT.with(|c| c.set(None));
225 let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
226 LAST_SEEN_ASYNC_WAKEUP.with(|c| c.set(current));
227}
228
229/// Schedule a future draw. Keeps the EARLIEST pending deadline, so multiple
230/// widgets asking for different delays will all be served by the soonest one
231/// (each widget re-arms its own deadline on the next draw anyway).
232pub fn request_draw_after(delay: Duration) {
233 let when = Instant::now() + delay;
234 NEXT_DRAW_AT.with(|c| match c.get() {
235 Some(existing) if existing <= when => {}
236 _ => c.set(Some(when)),
237 });
238}
239
240/// Read-and-clear the scheduled draw deadline. The host reads this after
241/// drawing so the next frame's scheduled wake is determined entirely by what
242/// the fresh draw registered (e.g. a text field re-arms the 500 ms blink
243/// each frame while it remains focused; losing focus means no re-arm and the
244/// loop goes idle).
245pub fn take_next_draw_deadline() -> Option<Instant> {
246 NEXT_DRAW_AT.with(|c| c.replace(None))
247}
248
249// ── Tween ────────────────────────────────────────────────────────────────────
250//
251// Small reusable time-based interpolator for widgets that want a smooth
252// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
253// Ease-out cubic; reversal preserves the current value so rapid toggles
254// don't snap. Requests a draw automatically while in flight.
255
256/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
257/// caller interprets). Drives animations such as the scroll-bar hover
258/// expansion and toggle-switch on/off slide.
259#[derive(Clone, Copy)]
260pub struct Tween {
261 current: f64,
262 start_value: f64,
263 target: f64,
264 start_time: Option<Instant>,
265 duration: f64,
266}
267
268impl Tween {
269 /// New tween that starts at `initial` with the same value as its target
270 /// (no animation in flight).
271 pub const fn new(initial: f64, duration_secs: f64) -> Self {
272 Self {
273 current: initial,
274 start_value: initial,
275 target: initial,
276 start_time: None,
277 duration: duration_secs,
278 }
279 }
280
281 /// Update the target. If it differs from the current target, re-anchors
282 /// the animation at the current interpolated value so reversals are smooth.
283 ///
284 /// Widgets that own a `Tween` must also report `tween.is_animating()` from
285 /// `Widget::needs_draw()` so retained parents repaint every frame until
286 /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
287 /// intentionally does not invalidate because many widgets retarget from
288 /// paint while synchronizing with external state.
289 pub fn set_target(&mut self, new_target: f64) {
290 if (self.target - new_target).abs() > 1e-9 {
291 self.start_value = self.current;
292 self.target = new_target;
293 self.start_time = Some(Instant::now());
294 }
295 }
296
297 /// Advance the animation based on elapsed wall time and return the new
298 /// interpolated value. Ease-out cubic. While in flight this also calls
299 /// [`request_draw`] so the host keeps drawing frames until completion.
300 pub fn tick(&mut self) -> f64 {
301 if let Some(start) = self.start_time {
302 let elapsed = start.elapsed().as_secs_f64();
303 let p = (elapsed / self.duration).min(1.0);
304 let eased = 1.0 - (1.0 - p).powi(3);
305 self.current = self.start_value + (self.target - self.start_value) * eased;
306 if p >= 1.0 {
307 self.current = self.target;
308 self.start_time = None;
309 } else {
310 request_draw();
311 }
312 }
313 self.current
314 }
315
316 /// Current interpolated value without advancing.
317 pub fn value(&self) -> f64 {
318 self.current
319 }
320
321 /// Where the tween is animating *towards* — i.e. the value last
322 /// passed to [`Self::set_target`]. Lets tests assert intent
323 /// (`request_lift(0.0)` was called) without waiting for the
324 /// animation to settle, which is otherwise wall-clock-dependent.
325 pub fn target(&self) -> f64 {
326 self.target
327 }
328
329 /// Whether the tween still needs frames to reach its target.
330 pub fn is_animating(&self) -> bool {
331 self.start_time.is_some()
332 }
333}
334
335impl Default for Tween {
336 fn default() -> Self {
337 Self::new(0.0, 0.12)
338 }
339}