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::sync::{Arc, Mutex};
38use std::time::Duration;
39use web_time::Instant;
40
41// ── Draw-request provenance trace ─────────────────────────────────────────────
42//
43// A thread-local ring buffer of `&'static str` reason tags, appended by the
44// `*_tagged` request helpers below.  It exists to answer one question that a
45// stack-free thread-local signal otherwise makes impossible: *who* keeps the
46// reactive host awake when the app should be idle?  When the quiescence
47// regression guard (demo-ui) finds the app still wants a draw after settling,
48// it drains this buffer and names the culprits in its failure message.
49//
50// Cost: recording is compiled out entirely in release (`debug_assertions`
51// off) so shipping hosts pay nothing.  In debug/test builds each tagged
52// request pushes one pointer-sized tag into a small capped `Vec`.
53
54#[cfg(debug_assertions)]
55std::thread_local! {
56    static DRAW_TRACE: std::cell::RefCell<Vec<&'static str>> =
57        const { std::cell::RefCell::new(Vec::new()) };
58}
59
60/// Cap on retained trace tags — a soft ring buffer: oldest tags drop once the
61/// cap is hit so a long-running session can't grow the buffer unbounded.
62#[cfg(debug_assertions)]
63const DRAW_TRACE_CAP: usize = 512;
64
65#[cfg(debug_assertions)]
66fn record_draw_trace(reason: &'static str) {
67    DRAW_TRACE.with(|t| {
68        let mut t = t.borrow_mut();
69        if t.len() >= DRAW_TRACE_CAP {
70            t.remove(0);
71        }
72        t.push(reason);
73    });
74}
75
76#[cfg(not(debug_assertions))]
77#[inline(always)]
78fn record_draw_trace(_reason: &'static str) {}
79
80/// Drain and return the recorded draw-request provenance tags (debug builds
81/// only; always empty in release).  Tests call this after driving frames to
82/// name whatever kept the reactive host awake.
83#[doc(hidden)]
84pub fn drain_draw_trace() -> Vec<&'static str> {
85    #[cfg(debug_assertions)]
86    {
87        DRAW_TRACE.with(|t| std::mem::take(&mut *t.borrow_mut()))
88    }
89    #[cfg(not(debug_assertions))]
90    {
91        Vec::new()
92    }
93}
94
95/// [`request_draw`] with a provenance tag — see the trace module docs.  Prefer
96/// this from library call sites so the quiescence guard can attribute wakeups.
97pub fn request_draw_tagged(reason: &'static str) {
98    record_draw_trace(reason);
99    request_draw();
100}
101
102/// [`request_draw_after`] with a provenance tag — see the trace module docs.
103pub fn request_draw_after_tagged(delay: Duration, reason: &'static str) {
104    record_draw_trace(reason);
105    request_draw_after(delay);
106}
107
108std::thread_local! {
109    static NEEDS_DRAW:        Cell<bool>            = Cell::new(false);
110    static NEXT_DRAW_AT:      Cell<Option<Instant>> = Cell::new(None);
111    static INVALIDATION_EPOCH: Cell<u64>             = Cell::new(0);
112    /// Bumped whenever an async source (image fetch + decode, font
113    /// load, etc.) finishes outside the event-dispatch path.  Retained
114    /// backbuffers (Window FBOs, in-process bitmap caches) compare
115    /// their stored value against this epoch on each paint and force
116    /// a re-raster on mismatch — there is no widget reference at the
117    /// callback site to walk the ancestor chain via the usual
118    /// `mark_dirty` route, so without this signal a freshly-decoded
119    /// image draws into the placeholder-sized rect the previous
120    /// layout reserved (the user-visible "wrong scale on first
121    /// frame" bug).
122    static ASYNC_STATE_EPOCH: Cell<u64> = Cell::new(0);
123    /// Per-thread snapshot of `ASYNC_WAKEUP_COUNTER` last observed by
124    /// [`pump_async_wakeup`].  When the global atomic is ahead of this,
125    /// the current thread's [`NEEDS_DRAW`], [`INVALIDATION_EPOCH`] and
126    /// [`ASYNC_STATE_EPOCH`] are bumped — see the module docs above
127    /// `ASYNC_WAKEUP_COUNTER` for why this indirection is required.
128    static LAST_SEEN_ASYNC_WAKEUP: Cell<u64> = Cell::new(0);
129    /// Monotonic counter bumped once per pointer press that reaches the
130    /// widget tree (see [`bump_pointer_press_epoch`]).  A widget that runs
131    /// its own multi-click gesture but no longer sees every press —
132    /// [`Scene`](crate::widgets::Scene), whose hosted children consume
133    /// their own presses before they can bubble — reads this to tell
134    /// whether an *intervening* press (e.g. a click on a hosted button)
135    /// happened between two of its own background clicks, so a
136    /// background double-click that straddles a child interaction does
137    /// not falsely fire.
138    static POINTER_PRESS_EPOCH: Cell<u64> = Cell::new(0);
139}
140
141/// Advance the pointer-press epoch.  Called by [`App`](crate::App) once per
142/// pointer press that is about to be routed into the widget tree.
143pub fn bump_pointer_press_epoch() {
144    POINTER_PRESS_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
145}
146
147/// Current pointer-press epoch — see [`bump_pointer_press_epoch`].  Two
148/// presses are *consecutive* (nothing pressed in between) exactly when their
149/// observed epochs differ by one.
150pub fn pointer_press_epoch() -> u64 {
151    POINTER_PRESS_EPOCH.with(|c| c.get())
152}
153
154/// Process-global counter bumped by [`signal_async_state_change`] from
155/// any thread.  The async fetch / decode runs on a background worker
156/// (e.g. ehttp's `std::thread::spawn`), so thread-locals it sets are
157/// invisible to the main event loop.  The main thread pumps this
158/// atomic into its own thread-local epochs on every
159/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read —
160/// see [`pump_async_wakeup`].
161static ASYNC_WAKEUP_COUNTER: AtomicU64 = AtomicU64::new(0);
162
163// ── Host waker ───────────────────────────────────────────────────────────────
164//
165// Bumping `ASYNC_WAKEUP_COUNTER` is only half the story for a *reactive* host.
166// The main thread merges the bump in `pump_async_wakeup`, but that runs only
167// when something already made the host read `wants_draw()` / an epoch.  A host
168// parked in winit's `ControlFlow::Wait` (or `WaitUntil` with a far deadline) is
169// not executing at all, so a worker-thread `signal_async_state_change` would
170// sit unobserved until an unrelated OS event happened to wake the loop.
171// Consumers otherwise have to paper over this with a per-frame keep-alive
172// repaint, which burns a core to stay idle.
173//
174// The optional host waker closes that gap: the host installs a cheap,
175// thread-safe nudge (typically `EventLoopProxy::send_event`) once at startup,
176// and `signal_async_state_change` calls it right after the atomic bump so the
177// woken loop is guaranteed to observe the new counter value.
178type HostWaker = Arc<dyn Fn() + Send + Sync>;
179
180/// Process-global waker slot.  Locked only to clone the `Arc` out; the waker
181/// itself always runs with the lock released (see [`signal_async_state_change`]).
182static HOST_WAKER: Mutex<Option<HostWaker>> = Mutex::new(None);
183
184/// Install (or replace) the process-global host waker.
185///
186/// Call this once from a reactive host's startup path, passing a closure that
187/// nudges the event loop awake — on winit that is
188/// `move || { let _ = proxy.send_event(UserEvent::Wake); }`.  Whenever any
189/// thread calls [`signal_async_state_change`], the waker fires *after* the
190/// cross-thread counter has been bumped, so the host is guaranteed to see the
191/// pending wakeup on its next `wants_draw()` read.
192///
193/// Requirements on `waker`:
194/// * **Cheap** — it runs inline on whatever worker thread finished an async
195///   load; do no real work in it, just signal.
196/// * **Thread-safe and non-reentrant** — it may be called from any thread and
197///   must not call back into `signal_async_state_change`.
198/// * **Failure-tolerant** — a closed event loop should be ignored (drop the
199///   `send_event` error) rather than panicking.
200///
201/// Hosts that already pump every tick — the wasm `requestAnimationFrame` loop,
202/// game-style continuous redraw hosts, and the headless test harnesses — do not
203/// need a waker; they reach `pump_async_wakeup` on their own schedule.
204pub fn set_host_waker(waker: impl Fn() + Send + Sync + 'static) {
205    let waker: HostWaker = Arc::new(waker);
206    match HOST_WAKER.lock() {
207        Ok(mut slot) => *slot = Some(waker),
208        // Poison-tolerant, like the rest of this module's best-effort signals:
209        // a panic elsewhere must not permanently disable host wakeups.
210        Err(poisoned) => *poisoned.into_inner() = Some(waker),
211    }
212}
213
214/// Remove any installed host waker, restoring the plain counter-only behaviour.
215///
216/// Hosts call this on shutdown so a dangling `EventLoopProxy` isn't retained;
217/// tests call it to reset this process-global slot between cases.
218pub fn clear_host_waker() {
219    match HOST_WAKER.lock() {
220        Ok(mut slot) => *slot = None,
221        Err(poisoned) => *poisoned.into_inner() = None,
222    }
223}
224
225/// Clone the installed waker out of its lock, if any.  The clone exists so the
226/// call site can release the lock before invoking arbitrary host code.
227fn host_waker() -> Option<HostWaker> {
228    match HOST_WAKER.lock() {
229        Ok(slot) => slot.clone(),
230        Err(poisoned) => poisoned.into_inner().clone(),
231    }
232}
233
234/// Merge any pending cross-thread async-wakeup bumps into the calling
235/// thread's draw/invalidation/async-state state.
236///
237/// Without this, an ehttp callback completing on a background thread
238/// bumps thread-locals the main event loop never reads — the markdown
239/// SVG-badge "wrong scale until any other event" bug, where the loop
240/// keeps polling (`needs_draw=true` while `ImageState::Loading`) but
241/// `invalidation_epoch` never changes, so `render_app_frame` skips
242/// the layout pass and paints the freshly-decoded SVG into the
243/// previous layout's placeholder rect.
244fn pump_async_wakeup() {
245    let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
246    let changed = LAST_SEEN_ASYNC_WAKEUP.with(|c| {
247        let prev = c.get();
248        if prev == current {
249            false
250        } else {
251            c.set(current);
252            true
253        }
254    });
255    if changed {
256        NEEDS_DRAW.with(|c| c.set(true));
257        INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
258        ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
259    }
260}
261
262/// Request that the host schedule another draw as soon as possible.
263///
264/// **This is the right default for every widget state mutation that affects
265/// visual output.**  Calling it from inside an `on_event` handler advances
266/// [`invalidation_epoch`]; `dispatch_event` reads that epoch before/after
267/// delivery and automatically calls `mark_dirty` up the ancestor path when
268/// it sees a bump — so a retained ancestor's backbuffer cache invalidates
269/// without the widget needing to know about that ancestor at all.
270///
271/// Without the epoch bump, a `Widget::on_event` that returns `Ignored` (the
272/// common case for `MouseMove`) leaves the ancestor cache thinking
273/// "nothing changed", and the next frame composites a stale bitmap.  Hover
274/// effects, focus rings, and any other appearance change driven by event
275/// state ALL need this hook.
276///
277/// Reach for [`request_draw_without_invalidation`] only when you're certain
278/// no retained widget's *content* changed — overlays, position-only
279/// translations, and similar.  When in doubt, use `request_draw`.
280pub fn request_draw() {
281    NEEDS_DRAW.with(|c| c.set(true));
282    INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
283}
284
285/// Request a frame **without** advancing [`invalidation_epoch`].
286///
287/// `dispatch_event` won't mark retained ancestors dirty for this call, so
288/// any widget that drew its previous frame into a backbuffer cache will
289/// composite the cached bitmap unchanged.  Use this **only** when:
290///
291/// * The change lives in an app-level overlay that paints fresh every
292///   frame outside any retained subtree (inspector hover rectangle, popup
293///   menus rendered via `paint_global_overlay`, scroll-fade decorations).
294/// * The change is position-only — a window drag-move, where the cached
295///   content is reused at a translated origin (see `Window::on_event` for
296///   the canonical example).
297///
298/// **Do NOT call this from a widget that mutated its own state and expects
299/// the next paint to reflect it.**  That's [`request_draw`]'s job.  Hover
300/// indices, focus changes, animation ticks, button-press states — anything
301/// where the *content* of a retained widget differs from the cached
302/// bitmap — must call `request_draw` so the cache invalidates.  The
303/// `MenuBar` hover regression in `widgets/menu/widget/tests_2.rs` exists
304/// precisely because this distinction was missed once already.
305pub fn request_draw_without_invalidation() {
306    NEEDS_DRAW.with(|c| c.set(true));
307}
308
309/// Non-destructive read of the immediate-draw signal, *plus* the promotion
310/// point for a due scheduled deadline.  Hosts call this after drawing to
311/// decide control-flow for the next loop iteration.
312///
313/// Pumps any pending cross-thread async-wakeup bumps first, so a fetch
314/// callback that finished on a worker thread between frames is reflected
315/// in the result.
316///
317/// If no immediate draw is pending but a [`request_draw_after`] deadline has
318/// come due (`Instant::now() >= deadline`), this clears the scheduled cell and
319/// raises [`NEEDS_DRAW`], returning `true`.  That makes a due deadline
320/// indistinguishable from an immediate [`request_draw`]: the normal
321/// request_draw → paint → [`clear_draw_request`] cycle then applies, and
322/// consumers re-arm their next deadline during that paint (so recurring timers
323/// stay alive).  This is what lets a purely reactive host serve scheduled
324/// draws without relying on a `WaitUntil` surviving intact — see the module
325/// docs on the lost-wakeup fix.
326pub fn wants_draw() -> bool {
327    pump_async_wakeup();
328    if NEEDS_DRAW.with(|c| c.get()) {
329        return true;
330    }
331    let due = NEXT_DRAW_AT.with(|c| match c.get() {
332        Some(when) if Instant::now() >= when => {
333            c.set(None);
334            true
335        }
336        _ => false,
337    });
338    if due {
339        NEEDS_DRAW.with(|c| c.set(true));
340    }
341    due
342}
343
344/// Monotonic draw-request epoch used to detect visual changes during dispatch.
345///
346/// Pumps cross-thread wakeups first so a background-thread
347/// [`signal_async_state_change`] is observed here on the next read,
348/// causing layout-key caches keyed on this epoch to re-layout.
349pub fn invalidation_epoch() -> u64 {
350    pump_async_wakeup();
351    INVALIDATION_EPOCH.with(|c| c.get())
352}
353
354/// Note that an async-side state change happened (image loader finished,
355/// font loaded, etc.).  Safe to call from any thread; the main event
356/// loop observes the bump via [`pump_async_wakeup`] on its next
357/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read.
358///
359/// This used to only bump thread-local epochs, which silently broke
360/// when callers ran on background threads (ehttp spawns its own
361/// `std::thread`) — the main thread never observed the change and
362/// `render_app_frame`'s layout-key cache skipped the layout pass that
363/// would have given freshly-decoded SVG badges their natural
364/// dimensions (the user-visible "wrong scale until any other event"
365/// bug).
366///
367/// If a host waker is installed ([`set_host_waker`]), it is invoked after the
368/// cross-thread bump so a reactive host parked in `ControlFlow::Wait` wakes and
369/// observes the already-published counter value.
370pub fn signal_async_state_change() {
371    // Cross-thread visible bump.  Main thread merges via pump_async_wakeup.
372    ASYNC_WAKEUP_COUNTER.fetch_add(1, Ordering::AcqRel);
373    // Best-effort thread-local bump for same-thread callers (most
374    // hosts / tests).  Background threads only set their own
375    // thread-locals here, which is harmless — the atomic above is
376    // what the main thread actually consumes.
377    NEEDS_DRAW.with(|c| c.set(true));
378    INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
379    ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
380    // Nudge a parked reactive host.  Cloned out of the lock first: the waker is
381    // arbitrary host code and must not run while HOST_WAKER is held.
382    if let Some(waker) = host_waker() {
383        waker();
384    }
385}
386
387/// Current async-state epoch.  Backbuffer caches store this and force
388/// a re-raster when it doesn't match.
389///
390/// Pumps cross-thread wakeups first so a worker-thread
391/// [`signal_async_state_change`] surfaces on the next read.
392pub fn async_state_epoch() -> u64 {
393    pump_async_wakeup();
394    ASYNC_STATE_EPOCH.with(|c| c.get())
395}
396
397/// Reset the per-frame draw flags.  The `App::paint` entry point calls
398/// this before delegating to the root widget so each frame starts fresh —
399/// widgets that still need a draw (animation in flight, focus blink, etc.)
400/// must re-arm during their draw, otherwise the loop goes idle.
401///
402/// Pending cross-thread async wakeups are *merged* first (advancing
403/// [`invalidation_epoch`] and [`async_state_epoch`]) and only then is the
404/// immediate flag cleared.  Merging — rather than just marking the counter
405/// seen — means a worker-thread [`signal_async_state_change`] landing after
406/// the host's last `wants_draw()` but before `App::paint` still reaches that
407/// paint's async-state dirty walk instead of being silently swallowed.
408/// Clearing the flag afterwards keeps the old guarantee that a stale bump
409/// cannot reappear on the next `wants_draw` read (parallel tests calling
410/// [`signal_async_state_change`] must not leak wakeups into unrelated tests
411/// that rely on `wants_draw()` returning `false` after a clear).
412pub fn clear_draw_request() {
413    pump_async_wakeup();
414    NEEDS_DRAW.with(|c| c.set(false));
415    NEXT_DRAW_AT.with(|c| c.set(None));
416}
417
418/// Clear **only** the immediate draw request ([`request_draw`]'s flag).
419///
420/// For a host that tried to draw and could not — the surface refused the
421/// frame (swap chain backing off after a failed configure, window occluded).
422/// That frame never reaches `App::paint`, the only other place the flag is
423/// cleared, so left set it keeps `wants_draw()` true and a reactive host
424/// spinning in `ControlFlow::Poll`. The host has already arranged its own
425/// wake for the retry.
426///
427/// Unlike [`clear_draw_request`], this leaves the scheduled deadline
428/// ([`request_draw_after`]) in place — it may be the very wake that retries
429/// the frame.  It also does not pump pending cross-thread
430/// [`signal_async_state_change`] bumps; they merge on the next read, where
431/// they both advance [`async_state_epoch`] and raise a draw request (whereas
432/// `clear_draw_request` merges them into the epochs and then clears the
433/// resulting request, since the paint it precedes consumes them).
434pub fn clear_immediate_draw_request() {
435    NEEDS_DRAW.with(|c| c.set(false));
436}
437
438/// Schedule a future draw.  Keeps the EARLIEST pending deadline, so multiple
439/// widgets asking for different delays will all be served by the soonest one
440/// (each widget re-arms its own deadline on the next draw anyway).
441pub fn request_draw_after(delay: Duration) {
442    let when = Instant::now() + delay;
443    NEXT_DRAW_AT.with(|c| match c.get() {
444        Some(existing) if existing <= when => {}
445        _ => c.set(Some(when)),
446    });
447}
448
449/// Side-effect-free snapshot of the two draw signals for diagnostics.
450///
451/// Returns `(immediate_flag, next_deadline)` read straight from the
452/// thread-local cells.  Unlike [`wants_draw`], this does **not** pump
453/// cross-thread async wakeups and does **not** promote or clear a due
454/// deadline — reading it can never perturb the very runaway a caller is
455/// trying to capture.  Used by [`crate::debug_draw_report`].
456#[doc(hidden)]
457pub fn peek_draw_signals() -> (bool, Option<Instant>) {
458    let flag = NEEDS_DRAW.with(|c| c.get());
459    let deadline = NEXT_DRAW_AT.with(|c| c.get());
460    (flag, deadline)
461}
462
463/// Non-destructive read of the earliest pending scheduled-draw deadline.
464///
465/// Hosts arm `ControlFlow::WaitUntil(t)` from this on every idle iteration.
466/// Because it does **not** clear the cell, re-arming is idempotent: an
467/// intervening event that does not itself repaint cannot strand the scheduled
468/// wake (the reactive-host lost-wakeup bug).  The cell is cleared only when
469/// the deadline actually comes due — [`wants_draw`] promotes it to an
470/// immediate draw — or by [`clear_draw_request`] at the start of a paint,
471/// after which consumers re-arm.
472pub fn peek_next_draw_deadline() -> Option<Instant> {
473    NEXT_DRAW_AT.with(|c| c.get())
474}
475
476// ── Tween ────────────────────────────────────────────────────────────────────
477//
478// Small reusable time-based interpolator for widgets that want a smooth
479// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
480// Ease-out cubic; reversal preserves the current value so rapid toggles
481// don't snap.  Requests a draw automatically while in flight.
482
483/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
484/// caller interprets).  Drives animations such as the scroll-bar hover
485/// expansion and toggle-switch on/off slide.
486#[derive(Clone, Copy)]
487pub struct Tween {
488    current: f64,
489    start_value: f64,
490    target: f64,
491    start_time: Option<Instant>,
492    duration: f64,
493}
494
495impl Tween {
496    /// New tween that starts at `initial` with the same value as its target
497    /// (no animation in flight).
498    pub const fn new(initial: f64, duration_secs: f64) -> Self {
499        Self {
500            current: initial,
501            start_value: initial,
502            target: initial,
503            start_time: None,
504            duration: duration_secs,
505        }
506    }
507
508    /// Update the target.  If it differs from the current target, re-anchors
509    /// the animation at the current interpolated value so reversals are smooth.
510    ///
511    /// Widgets that own a `Tween` must also report `tween.is_animating()` from
512    /// `Widget::needs_draw()` so retained parents repaint every frame until
513    /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
514    /// intentionally does not invalidate because many widgets retarget from
515    /// paint while synchronizing with external state.
516    pub fn set_target(&mut self, new_target: f64) {
517        if (self.target - new_target).abs() > 1e-9 {
518            self.start_value = self.current;
519            self.target = new_target;
520            self.start_time = Some(Instant::now());
521        }
522    }
523
524    /// Advance the animation based on elapsed wall time and return the new
525    /// interpolated value.  Ease-out cubic.  While in flight this also calls
526    /// [`request_draw`] so the host keeps drawing frames until completion.
527    pub fn tick(&mut self) -> f64 {
528        if let Some(start) = self.start_time {
529            let elapsed = start.elapsed().as_secs_f64();
530            let p = (elapsed / self.duration).min(1.0);
531            let eased = 1.0 - (1.0 - p).powi(3);
532            self.current = self.start_value + (self.target - self.start_value) * eased;
533            if p >= 1.0 {
534                self.current = self.target;
535                self.start_time = None;
536            } else {
537                request_draw();
538            }
539        }
540        self.current
541    }
542
543    /// Current interpolated value without advancing.
544    pub fn value(&self) -> f64 {
545        self.current
546    }
547
548    /// Where the tween is animating *towards* — i.e. the value last
549    /// passed to [`Self::set_target`].  Lets tests assert intent
550    /// (`request_lift(0.0)` was called) without waiting for the
551    /// animation to settle, which is otherwise wall-clock-dependent.
552    pub fn target(&self) -> f64 {
553        self.target
554    }
555
556    /// Whether the tween still needs frames to reach its target.
557    pub fn is_animating(&self) -> bool {
558        self.start_time.is_some()
559    }
560}
561
562impl Default for Tween {
563    fn default() -> Self {
564        Self::new(0.0, 0.12)
565    }
566}
567
568#[cfg(test)]
569mod host_waker_tests {
570    //! Coverage for the reactive-host waker hook: a worker-thread
571    //! [`signal_async_state_change`] must be able to nudge a host parked in
572    //! `ControlFlow::Wait`.
573    //!
574    //! `HOST_WAKER` and `ASYNC_WAKEUP_COUNTER` are process-global, and other
575    //! tests in this crate call `signal_async_state_change` concurrently — so
576    //! these tests serialize against each other with a local mutex, count
577    //! *their own* invocations rather than reading the global counter after
578    //! the fact, and assert with `>=` where a foreign signal could add more.
579    use super::*;
580    use std::sync::atomic::AtomicU64;
581
582    /// Serializes only these tests; unrelated tests may still signal in
583    /// parallel, which is why every assertion below tolerates extra fires.
584    static SERIAL: Mutex<()> = Mutex::new(());
585
586    fn serial() -> std::sync::MutexGuard<'static, ()> {
587        SERIAL.lock().unwrap_or_else(|p| p.into_inner())
588    }
589
590    #[test]
591    fn waker_fires_on_signal() {
592        let _guard = serial();
593        let fires = Arc::new(AtomicU64::new(0));
594        let seen = Arc::clone(&fires);
595        set_host_waker(move || {
596            seen.fetch_add(1, Ordering::AcqRel);
597        });
598
599        signal_async_state_change();
600        clear_host_waker();
601
602        assert!(
603            fires.load(Ordering::Acquire) >= 1,
604            "installed waker must run when any thread signals an async change"
605        );
606    }
607
608    #[test]
609    fn waker_sees_counter_already_bumped() {
610        let _guard = serial();
611        let before = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
612        let observed = Arc::new(AtomicU64::new(0));
613        let sink = Arc::clone(&observed);
614        set_host_waker(move || {
615            // Snapshot from inside the waker: the host is woken only after the
616            // bump is published, otherwise it would park again having seen
617            // nothing.
618            sink.store(
619                ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire),
620                Ordering::Release,
621            );
622        });
623
624        signal_async_state_change();
625        clear_host_waker();
626
627        assert!(
628            observed.load(Ordering::Acquire) > before,
629            "the counter must be bumped BEFORE the waker runs"
630        );
631    }
632
633    #[test]
634    fn replacing_the_waker_retires_the_old_one() {
635        let _guard = serial();
636        let old_fires = Arc::new(AtomicU64::new(0));
637        let old_sink = Arc::clone(&old_fires);
638        set_host_waker(move || {
639            old_sink.fetch_add(1, Ordering::AcqRel);
640        });
641
642        let new_fires = Arc::new(AtomicU64::new(0));
643        let new_sink = Arc::clone(&new_fires);
644        set_host_waker(move || {
645            new_sink.fetch_add(1, Ordering::AcqRel);
646        });
647        let old_baseline = old_fires.load(Ordering::Acquire);
648
649        signal_async_state_change();
650        clear_host_waker();
651
652        assert!(
653            new_fires.load(Ordering::Acquire) >= 1,
654            "the replacement waker runs"
655        );
656        assert_eq!(
657            old_fires.load(Ordering::Acquire),
658            old_baseline,
659            "the replaced waker no longer runs"
660        );
661    }
662
663    #[test]
664    fn clear_stops_the_waker() {
665        let _guard = serial();
666        let fires = Arc::new(AtomicU64::new(0));
667        let sink = Arc::clone(&fires);
668        set_host_waker(move || {
669            sink.fetch_add(1, Ordering::AcqRel);
670        });
671        clear_host_waker();
672        let baseline = fires.load(Ordering::Acquire);
673
674        signal_async_state_change();
675
676        assert_eq!(
677            fires.load(Ordering::Acquire),
678            baseline,
679            "a cleared waker must not run again"
680        );
681    }
682
683    #[test]
684    fn signal_from_a_worker_thread_wakes_the_host() {
685        let _guard = serial();
686        let fires = Arc::new(AtomicU64::new(0));
687        let sink = Arc::clone(&fires);
688        set_host_waker(move || {
689            sink.fetch_add(1, Ordering::AcqRel);
690        });
691
692        // The whole point of the hook: the signal originates off the main
693        // thread, where thread-local bumps are invisible to the host.
694        std::thread::spawn(signal_async_state_change)
695            .join()
696            .expect("worker thread signalled without panicking");
697        clear_host_waker();
698
699        assert!(
700            fires.load(Ordering::Acquire) >= 1,
701            "a worker-thread signal must reach the host waker"
702        );
703    }
704
705    #[test]
706    fn signalling_without_a_waker_is_a_no_op() {
707        let _guard = serial();
708        clear_host_waker();
709        // Must not panic and must still publish the cross-thread bump.
710        let before = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
711        signal_async_state_change();
712        assert!(
713            ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire) > before,
714            "counter-only behaviour is unchanged when no waker is installed"
715        );
716    }
717}
718
719#[cfg(test)]
720#[path = "animation_scheduled_draw_tests.rs"]
721mod scheduled_draw_tests;