Skip to main content

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}