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//! [`peek_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 scheduled channel is read **non-destructively** via
20//! [`peek_next_draw_deadline`]: a host re-arms its `WaitUntil` from the same
21//! pending deadline on every idle iteration, so an intervening event that
22//! does not itself repaint can no longer strand the wake (the reactive-host
23//! "lost wakeup" that stalled tooltips, cursor blink, and scrollbar fades).
24//! Once a pending deadline comes due, [`wants_draw`] observes it, clears the
25//! cell, and raises the immediate-draw flag — a due deadline is deliberately
26//! made indistinguishable from a plain [`request_draw`], upholding the
27//! framework invariant that *anything needing a future draw eventually makes
28//! `wants_draw()` true by itself*. Consumers re-arm during the ensuing paint,
29//! which keeps recurring timers alive.
30//!
31//! The host loop draws iff `wants_draw()` (now inclusive of due deadlines).
32//! Between draws it idles with `WaitUntil(peek_next_draw_deadline())`; no
33//! frames are drawn while nothing has changed.
34
35use std::cell::Cell;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::time::Duration;
38use web_time::Instant;
39
40// ── Draw-request provenance trace ─────────────────────────────────────────────
41//
42// A thread-local ring buffer of `&'static str` reason tags, appended by the
43// `*_tagged` request helpers below. It exists to answer one question that a
44// stack-free thread-local signal otherwise makes impossible: *who* keeps the
45// reactive host awake when the app should be idle? When the quiescence
46// regression guard (demo-ui) finds the app still wants a draw after settling,
47// it drains this buffer and names the culprits in its failure message.
48//
49// Cost: recording is compiled out entirely in release (`debug_assertions`
50// off) so shipping hosts pay nothing. In debug/test builds each tagged
51// request pushes one pointer-sized tag into a small capped `Vec`.
52
53#[cfg(debug_assertions)]
54std::thread_local! {
55 static DRAW_TRACE: std::cell::RefCell<Vec<&'static str>> =
56 const { std::cell::RefCell::new(Vec::new()) };
57}
58
59/// Cap on retained trace tags — a soft ring buffer: oldest tags drop once the
60/// cap is hit so a long-running session can't grow the buffer unbounded.
61#[cfg(debug_assertions)]
62const DRAW_TRACE_CAP: usize = 512;
63
64#[cfg(debug_assertions)]
65fn record_draw_trace(reason: &'static str) {
66 DRAW_TRACE.with(|t| {
67 let mut t = t.borrow_mut();
68 if t.len() >= DRAW_TRACE_CAP {
69 t.remove(0);
70 }
71 t.push(reason);
72 });
73}
74
75#[cfg(not(debug_assertions))]
76#[inline(always)]
77fn record_draw_trace(_reason: &'static str) {}
78
79/// Drain and return the recorded draw-request provenance tags (debug builds
80/// only; always empty in release). Tests call this after driving frames to
81/// name whatever kept the reactive host awake.
82#[doc(hidden)]
83pub fn drain_draw_trace() -> Vec<&'static str> {
84 #[cfg(debug_assertions)]
85 {
86 DRAW_TRACE.with(|t| std::mem::take(&mut *t.borrow_mut()))
87 }
88 #[cfg(not(debug_assertions))]
89 {
90 Vec::new()
91 }
92}
93
94/// [`request_draw`] with a provenance tag — see the trace module docs. Prefer
95/// this from library call sites so the quiescence guard can attribute wakeups.
96pub fn request_draw_tagged(reason: &'static str) {
97 record_draw_trace(reason);
98 request_draw();
99}
100
101/// [`request_draw_after`] with a provenance tag — see the trace module docs.
102pub fn request_draw_after_tagged(delay: Duration, reason: &'static str) {
103 record_draw_trace(reason);
104 request_draw_after(delay);
105}
106
107std::thread_local! {
108 static NEEDS_DRAW: Cell<bool> = Cell::new(false);
109 static NEXT_DRAW_AT: Cell<Option<Instant>> = Cell::new(None);
110 static INVALIDATION_EPOCH: Cell<u64> = Cell::new(0);
111 /// Bumped whenever an async source (image fetch + decode, font
112 /// load, etc.) finishes outside the event-dispatch path. Retained
113 /// backbuffers (Window FBOs, in-process bitmap caches) compare
114 /// their stored value against this epoch on each paint and force
115 /// a re-raster on mismatch — there is no widget reference at the
116 /// callback site to walk the ancestor chain via the usual
117 /// `mark_dirty` route, so without this signal a freshly-decoded
118 /// image draws into the placeholder-sized rect the previous
119 /// layout reserved (the user-visible "wrong scale on first
120 /// frame" bug).
121 static ASYNC_STATE_EPOCH: Cell<u64> = Cell::new(0);
122 /// Per-thread snapshot of `ASYNC_WAKEUP_COUNTER` last observed by
123 /// [`pump_async_wakeup`]. When the global atomic is ahead of this,
124 /// the current thread's [`NEEDS_DRAW`], [`INVALIDATION_EPOCH`] and
125 /// [`ASYNC_STATE_EPOCH`] are bumped — see the module docs above
126 /// `ASYNC_WAKEUP_COUNTER` for why this indirection is required.
127 static LAST_SEEN_ASYNC_WAKEUP: Cell<u64> = Cell::new(0);
128 /// Monotonic counter bumped once per pointer press that reaches the
129 /// widget tree (see [`bump_pointer_press_epoch`]). A widget that runs
130 /// its own multi-click gesture but no longer sees every press —
131 /// [`Scene`](crate::widgets::Scene), whose hosted children consume
132 /// their own presses before they can bubble — reads this to tell
133 /// whether an *intervening* press (e.g. a click on a hosted button)
134 /// happened between two of its own background clicks, so a
135 /// background double-click that straddles a child interaction does
136 /// not falsely fire.
137 static POINTER_PRESS_EPOCH: Cell<u64> = Cell::new(0);
138}
139
140/// Advance the pointer-press epoch. Called by [`App`](crate::App) once per
141/// pointer press that is about to be routed into the widget tree.
142pub fn bump_pointer_press_epoch() {
143 POINTER_PRESS_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
144}
145
146/// Current pointer-press epoch — see [`bump_pointer_press_epoch`]. Two
147/// presses are *consecutive* (nothing pressed in between) exactly when their
148/// observed epochs differ by one.
149pub fn pointer_press_epoch() -> u64 {
150 POINTER_PRESS_EPOCH.with(|c| c.get())
151}
152
153/// Process-global counter bumped by [`signal_async_state_change`] from
154/// any thread. The async fetch / decode runs on a background worker
155/// (e.g. ehttp's `std::thread::spawn`), so thread-locals it sets are
156/// invisible to the main event loop. The main thread pumps this
157/// atomic into its own thread-local epochs on every
158/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read —
159/// see [`pump_async_wakeup`].
160static ASYNC_WAKEUP_COUNTER: AtomicU64 = AtomicU64::new(0);
161
162/// Merge any pending cross-thread async-wakeup bumps into the calling
163/// thread's draw/invalidation/async-state state.
164///
165/// Without this, an ehttp callback completing on a background thread
166/// bumps thread-locals the main event loop never reads — the markdown
167/// SVG-badge "wrong scale until any other event" bug, where the loop
168/// keeps polling (`needs_draw=true` while `ImageState::Loading`) but
169/// `invalidation_epoch` never changes, so `render_app_frame` skips
170/// the layout pass and paints the freshly-decoded SVG into the
171/// previous layout's placeholder rect.
172fn pump_async_wakeup() {
173 let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
174 let changed = LAST_SEEN_ASYNC_WAKEUP.with(|c| {
175 let prev = c.get();
176 if prev == current {
177 false
178 } else {
179 c.set(current);
180 true
181 }
182 });
183 if changed {
184 NEEDS_DRAW.with(|c| c.set(true));
185 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
186 ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
187 }
188}
189
190/// Request that the host schedule another draw as soon as possible.
191///
192/// **This is the right default for every widget state mutation that affects
193/// visual output.** Calling it from inside an `on_event` handler advances
194/// [`invalidation_epoch`]; `dispatch_event` reads that epoch before/after
195/// delivery and automatically calls `mark_dirty` up the ancestor path when
196/// it sees a bump — so a retained ancestor's backbuffer cache invalidates
197/// without the widget needing to know about that ancestor at all.
198///
199/// Without the epoch bump, a `Widget::on_event` that returns `Ignored` (the
200/// common case for `MouseMove`) leaves the ancestor cache thinking
201/// "nothing changed", and the next frame composites a stale bitmap. Hover
202/// effects, focus rings, and any other appearance change driven by event
203/// state ALL need this hook.
204///
205/// Reach for [`request_draw_without_invalidation`] only when you're certain
206/// no retained widget's *content* changed — overlays, position-only
207/// translations, and similar. When in doubt, use `request_draw`.
208pub fn request_draw() {
209 NEEDS_DRAW.with(|c| c.set(true));
210 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
211}
212
213/// Request a frame **without** advancing [`invalidation_epoch`].
214///
215/// `dispatch_event` won't mark retained ancestors dirty for this call, so
216/// any widget that drew its previous frame into a backbuffer cache will
217/// composite the cached bitmap unchanged. Use this **only** when:
218///
219/// * The change lives in an app-level overlay that paints fresh every
220/// frame outside any retained subtree (inspector hover rectangle, popup
221/// menus rendered via `paint_global_overlay`, scroll-fade decorations).
222/// * The change is position-only — a window drag-move, where the cached
223/// content is reused at a translated origin (see `Window::on_event` for
224/// the canonical example).
225///
226/// **Do NOT call this from a widget that mutated its own state and expects
227/// the next paint to reflect it.** That's [`request_draw`]'s job. Hover
228/// indices, focus changes, animation ticks, button-press states — anything
229/// where the *content* of a retained widget differs from the cached
230/// bitmap — must call `request_draw` so the cache invalidates. The
231/// `MenuBar` hover regression in `widgets/menu/widget/tests_2.rs` exists
232/// precisely because this distinction was missed once already.
233pub fn request_draw_without_invalidation() {
234 NEEDS_DRAW.with(|c| c.set(true));
235}
236
237/// Non-destructive read of the immediate-draw signal, *plus* the promotion
238/// point for a due scheduled deadline. Hosts call this after drawing to
239/// decide control-flow for the next loop iteration.
240///
241/// Pumps any pending cross-thread async-wakeup bumps first, so a fetch
242/// callback that finished on a worker thread between frames is reflected
243/// in the result.
244///
245/// If no immediate draw is pending but a [`request_draw_after`] deadline has
246/// come due (`Instant::now() >= deadline`), this clears the scheduled cell and
247/// raises [`NEEDS_DRAW`], returning `true`. That makes a due deadline
248/// indistinguishable from an immediate [`request_draw`]: the normal
249/// request_draw → paint → [`clear_draw_request`] cycle then applies, and
250/// consumers re-arm their next deadline during that paint (so recurring timers
251/// stay alive). This is what lets a purely reactive host serve scheduled
252/// draws without relying on a `WaitUntil` surviving intact — see the module
253/// docs on the lost-wakeup fix.
254pub fn wants_draw() -> bool {
255 pump_async_wakeup();
256 if NEEDS_DRAW.with(|c| c.get()) {
257 return true;
258 }
259 let due = NEXT_DRAW_AT.with(|c| match c.get() {
260 Some(when) if Instant::now() >= when => {
261 c.set(None);
262 true
263 }
264 _ => false,
265 });
266 if due {
267 NEEDS_DRAW.with(|c| c.set(true));
268 }
269 due
270}
271
272/// Monotonic draw-request epoch used to detect visual changes during dispatch.
273///
274/// Pumps cross-thread wakeups first so a background-thread
275/// [`signal_async_state_change`] is observed here on the next read,
276/// causing layout-key caches keyed on this epoch to re-layout.
277pub fn invalidation_epoch() -> u64 {
278 pump_async_wakeup();
279 INVALIDATION_EPOCH.with(|c| c.get())
280}
281
282/// Note that an async-side state change happened (image loader finished,
283/// font loaded, etc.). Safe to call from any thread; the main event
284/// loop observes the bump via [`pump_async_wakeup`] on its next
285/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read.
286///
287/// This used to only bump thread-local epochs, which silently broke
288/// when callers ran on background threads (ehttp spawns its own
289/// `std::thread`) — the main thread never observed the change and
290/// `render_app_frame`'s layout-key cache skipped the layout pass that
291/// would have given freshly-decoded SVG badges their natural
292/// dimensions (the user-visible "wrong scale until any other event"
293/// bug).
294pub fn signal_async_state_change() {
295 // Cross-thread visible bump. Main thread merges via pump_async_wakeup.
296 ASYNC_WAKEUP_COUNTER.fetch_add(1, Ordering::AcqRel);
297 // Best-effort thread-local bump for same-thread callers (most
298 // hosts / tests). Background threads only set their own
299 // thread-locals here, which is harmless — the atomic above is
300 // what the main thread actually consumes.
301 NEEDS_DRAW.with(|c| c.set(true));
302 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
303 ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
304}
305
306/// Current async-state epoch. Backbuffer caches store this and force
307/// a re-raster when it doesn't match.
308///
309/// Pumps cross-thread wakeups first so a worker-thread
310/// [`signal_async_state_change`] surfaces on the next read.
311pub fn async_state_epoch() -> u64 {
312 pump_async_wakeup();
313 ASYNC_STATE_EPOCH.with(|c| c.get())
314}
315
316/// Reset the per-frame draw flags. The `App::paint` entry point calls
317/// this before delegating to the root widget so each frame starts fresh —
318/// widgets that still need a draw (animation in flight, focus blink, etc.)
319/// must re-arm during their draw, otherwise the loop goes idle.
320///
321/// Also syncs this thread's cross-thread async-wakeup bookkeeping so a
322/// stale bump from before this clear cannot reappear on the next
323/// `wants_draw` read. Without that sync, parallel tests calling
324/// [`signal_async_state_change`] would leak wakeups into unrelated
325/// tests that rely on `wants_draw()` returning `false` after a clear.
326pub fn clear_draw_request() {
327 NEEDS_DRAW.with(|c| c.set(false));
328 NEXT_DRAW_AT.with(|c| c.set(None));
329 let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
330 LAST_SEEN_ASYNC_WAKEUP.with(|c| c.set(current));
331}
332
333/// Schedule a future draw. Keeps the EARLIEST pending deadline, so multiple
334/// widgets asking for different delays will all be served by the soonest one
335/// (each widget re-arms its own deadline on the next draw anyway).
336pub fn request_draw_after(delay: Duration) {
337 let when = Instant::now() + delay;
338 NEXT_DRAW_AT.with(|c| match c.get() {
339 Some(existing) if existing <= when => {}
340 _ => c.set(Some(when)),
341 });
342}
343
344/// Side-effect-free snapshot of the two draw signals for diagnostics.
345///
346/// Returns `(immediate_flag, next_deadline)` read straight from the
347/// thread-local cells. Unlike [`wants_draw`], this does **not** pump
348/// cross-thread async wakeups and does **not** promote or clear a due
349/// deadline — reading it can never perturb the very runaway a caller is
350/// trying to capture. Used by [`crate::debug_draw_report`].
351#[doc(hidden)]
352pub fn peek_draw_signals() -> (bool, Option<Instant>) {
353 let flag = NEEDS_DRAW.with(|c| c.get());
354 let deadline = NEXT_DRAW_AT.with(|c| c.get());
355 (flag, deadline)
356}
357
358/// Non-destructive read of the earliest pending scheduled-draw deadline.
359///
360/// Hosts arm `ControlFlow::WaitUntil(t)` from this on every idle iteration.
361/// Because it does **not** clear the cell, re-arming is idempotent: an
362/// intervening event that does not itself repaint cannot strand the scheduled
363/// wake (the reactive-host lost-wakeup bug). The cell is cleared only when
364/// the deadline actually comes due — [`wants_draw`] promotes it to an
365/// immediate draw — or by [`clear_draw_request`] at the start of a paint,
366/// after which consumers re-arm.
367pub fn peek_next_draw_deadline() -> Option<Instant> {
368 NEXT_DRAW_AT.with(|c| c.get())
369}
370
371// ── Tween ────────────────────────────────────────────────────────────────────
372//
373// Small reusable time-based interpolator for widgets that want a smooth
374// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
375// Ease-out cubic; reversal preserves the current value so rapid toggles
376// don't snap. Requests a draw automatically while in flight.
377
378/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
379/// caller interprets). Drives animations such as the scroll-bar hover
380/// expansion and toggle-switch on/off slide.
381#[derive(Clone, Copy)]
382pub struct Tween {
383 current: f64,
384 start_value: f64,
385 target: f64,
386 start_time: Option<Instant>,
387 duration: f64,
388}
389
390impl Tween {
391 /// New tween that starts at `initial` with the same value as its target
392 /// (no animation in flight).
393 pub const fn new(initial: f64, duration_secs: f64) -> Self {
394 Self {
395 current: initial,
396 start_value: initial,
397 target: initial,
398 start_time: None,
399 duration: duration_secs,
400 }
401 }
402
403 /// Update the target. If it differs from the current target, re-anchors
404 /// the animation at the current interpolated value so reversals are smooth.
405 ///
406 /// Widgets that own a `Tween` must also report `tween.is_animating()` from
407 /// `Widget::needs_draw()` so retained parents repaint every frame until
408 /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
409 /// intentionally does not invalidate because many widgets retarget from
410 /// paint while synchronizing with external state.
411 pub fn set_target(&mut self, new_target: f64) {
412 if (self.target - new_target).abs() > 1e-9 {
413 self.start_value = self.current;
414 self.target = new_target;
415 self.start_time = Some(Instant::now());
416 }
417 }
418
419 /// Advance the animation based on elapsed wall time and return the new
420 /// interpolated value. Ease-out cubic. While in flight this also calls
421 /// [`request_draw`] so the host keeps drawing frames until completion.
422 pub fn tick(&mut self) -> f64 {
423 if let Some(start) = self.start_time {
424 let elapsed = start.elapsed().as_secs_f64();
425 let p = (elapsed / self.duration).min(1.0);
426 let eased = 1.0 - (1.0 - p).powi(3);
427 self.current = self.start_value + (self.target - self.start_value) * eased;
428 if p >= 1.0 {
429 self.current = self.target;
430 self.start_time = None;
431 } else {
432 request_draw();
433 }
434 }
435 self.current
436 }
437
438 /// Current interpolated value without advancing.
439 pub fn value(&self) -> f64 {
440 self.current
441 }
442
443 /// Where the tween is animating *towards* — i.e. the value last
444 /// passed to [`Self::set_target`]. Lets tests assert intent
445 /// (`request_lift(0.0)` was called) without waiting for the
446 /// animation to settle, which is otherwise wall-clock-dependent.
447 pub fn target(&self) -> f64 {
448 self.target
449 }
450
451 /// Whether the tween still needs frames to reach its target.
452 pub fn is_animating(&self) -> bool {
453 self.start_time.is_some()
454 }
455}
456
457impl Default for Tween {
458 fn default() -> Self {
459 Self::new(0.0, 0.12)
460 }
461}
462
463#[cfg(test)]
464mod scheduled_draw_tests {
465 //! Regression coverage for the reactive-host lost-wakeup fix: the
466 //! scheduled-draw cell must be readable non-destructively, and a due
467 //! deadline must surface through `wants_draw`. Uses short real sleeps
468 //! (`web_time::Instant` has no injectable clock here); each test clears
469 //! shared thread-local state up front so it can't inherit a pending
470 //! deadline from a prior test on the same worker thread.
471 use super::*;
472 use std::thread::sleep;
473
474 /// (a) The lost-wakeup repro. A pending deadline read once must still be
475 /// visible on the SECOND read — the "intervening AboutToWait" that the
476 /// read-and-clear design silently dropped.
477 #[test]
478 fn peek_is_non_destructive() {
479 clear_draw_request();
480 request_draw_after(Duration::from_millis(50));
481 let first = peek_next_draw_deadline();
482 assert!(first.is_some(), "first peek sees the pending deadline");
483 let second = peek_next_draw_deadline();
484 assert_eq!(
485 first, second,
486 "second peek still sees the SAME pending deadline (lost-wakeup fix)"
487 );
488 }
489
490 /// (b) Once due, `wants_draw` returns true; after the paint-clear cycle
491 /// consumes it, a subsequent `wants_draw` is false absent a re-arm.
492 #[test]
493 fn due_deadline_surfaces_then_clears() {
494 clear_draw_request();
495 assert!(!wants_draw(), "baseline: nothing pending after clear");
496 request_draw_after(Duration::from_millis(20));
497 sleep(Duration::from_millis(40));
498 assert!(wants_draw(), "a due deadline makes wants_draw() true");
499 // Simulate the frame that honours it: paint clears the draw flags.
500 clear_draw_request();
501 assert!(
502 !wants_draw(),
503 "without a re-arm the loop goes idle again after the draw"
504 );
505 }
506
507 /// (c) A future (not-yet-due) deadline is peekable but does NOT make
508 /// `wants_draw` true — the host idles on `WaitUntil` instead of polling.
509 #[test]
510 fn future_deadline_peeks_but_does_not_want_draw() {
511 clear_draw_request();
512 request_draw_after(Duration::from_millis(500));
513 assert!(
514 peek_next_draw_deadline().is_some(),
515 "future deadline is visible to the host's WaitUntil"
516 );
517 assert!(
518 !wants_draw(),
519 "a future deadline must not force continuous polling"
520 );
521 // It also stays pending after that wants_draw() read.
522 assert!(
523 peek_next_draw_deadline().is_some(),
524 "a non-due wants_draw() must not consume the deadline"
525 );
526 }
527
528 /// (d) Earliest-deadline-wins still holds regardless of arm order.
529 #[test]
530 fn earliest_deadline_wins() {
531 clear_draw_request();
532 request_draw_after(Duration::from_millis(400));
533 let after_long = peek_next_draw_deadline().expect("long deadline armed");
534 request_draw_after(Duration::from_millis(20));
535 let after_short = peek_next_draw_deadline().expect("short deadline armed");
536 assert!(
537 after_short < after_long,
538 "a nearer deadline replaces a farther one"
539 );
540 // Reverse order: a farther deadline does not push the nearer one out.
541 request_draw_after(Duration::from_millis(400));
542 assert_eq!(
543 peek_next_draw_deadline(),
544 Some(after_short),
545 "arming a farther deadline keeps the earliest"
546 );
547 }
548}