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/// Also syncs this thread's cross-thread async-wakeup bookkeeping so a
403/// stale bump from before this clear cannot reappear on the next
404/// `wants_draw` read. Without that sync, parallel tests calling
405/// [`signal_async_state_change`] would leak wakeups into unrelated
406/// tests that rely on `wants_draw()` returning `false` after a clear.
407pub fn clear_draw_request() {
408 NEEDS_DRAW.with(|c| c.set(false));
409 NEXT_DRAW_AT.with(|c| c.set(None));
410 let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
411 LAST_SEEN_ASYNC_WAKEUP.with(|c| c.set(current));
412}
413
414/// Schedule a future draw. Keeps the EARLIEST pending deadline, so multiple
415/// widgets asking for different delays will all be served by the soonest one
416/// (each widget re-arms its own deadline on the next draw anyway).
417pub fn request_draw_after(delay: Duration) {
418 let when = Instant::now() + delay;
419 NEXT_DRAW_AT.with(|c| match c.get() {
420 Some(existing) if existing <= when => {}
421 _ => c.set(Some(when)),
422 });
423}
424
425/// Side-effect-free snapshot of the two draw signals for diagnostics.
426///
427/// Returns `(immediate_flag, next_deadline)` read straight from the
428/// thread-local cells. Unlike [`wants_draw`], this does **not** pump
429/// cross-thread async wakeups and does **not** promote or clear a due
430/// deadline — reading it can never perturb the very runaway a caller is
431/// trying to capture. Used by [`crate::debug_draw_report`].
432#[doc(hidden)]
433pub fn peek_draw_signals() -> (bool, Option<Instant>) {
434 let flag = NEEDS_DRAW.with(|c| c.get());
435 let deadline = NEXT_DRAW_AT.with(|c| c.get());
436 (flag, deadline)
437}
438
439/// Non-destructive read of the earliest pending scheduled-draw deadline.
440///
441/// Hosts arm `ControlFlow::WaitUntil(t)` from this on every idle iteration.
442/// Because it does **not** clear the cell, re-arming is idempotent: an
443/// intervening event that does not itself repaint cannot strand the scheduled
444/// wake (the reactive-host lost-wakeup bug). The cell is cleared only when
445/// the deadline actually comes due — [`wants_draw`] promotes it to an
446/// immediate draw — or by [`clear_draw_request`] at the start of a paint,
447/// after which consumers re-arm.
448pub fn peek_next_draw_deadline() -> Option<Instant> {
449 NEXT_DRAW_AT.with(|c| c.get())
450}
451
452// ── Tween ────────────────────────────────────────────────────────────────────
453//
454// Small reusable time-based interpolator for widgets that want a smooth
455// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
456// Ease-out cubic; reversal preserves the current value so rapid toggles
457// don't snap. Requests a draw automatically while in flight.
458
459/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
460/// caller interprets). Drives animations such as the scroll-bar hover
461/// expansion and toggle-switch on/off slide.
462#[derive(Clone, Copy)]
463pub struct Tween {
464 current: f64,
465 start_value: f64,
466 target: f64,
467 start_time: Option<Instant>,
468 duration: f64,
469}
470
471impl Tween {
472 /// New tween that starts at `initial` with the same value as its target
473 /// (no animation in flight).
474 pub const fn new(initial: f64, duration_secs: f64) -> Self {
475 Self {
476 current: initial,
477 start_value: initial,
478 target: initial,
479 start_time: None,
480 duration: duration_secs,
481 }
482 }
483
484 /// Update the target. If it differs from the current target, re-anchors
485 /// the animation at the current interpolated value so reversals are smooth.
486 ///
487 /// Widgets that own a `Tween` must also report `tween.is_animating()` from
488 /// `Widget::needs_draw()` so retained parents repaint every frame until
489 /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
490 /// intentionally does not invalidate because many widgets retarget from
491 /// paint while synchronizing with external state.
492 pub fn set_target(&mut self, new_target: f64) {
493 if (self.target - new_target).abs() > 1e-9 {
494 self.start_value = self.current;
495 self.target = new_target;
496 self.start_time = Some(Instant::now());
497 }
498 }
499
500 /// Advance the animation based on elapsed wall time and return the new
501 /// interpolated value. Ease-out cubic. While in flight this also calls
502 /// [`request_draw`] so the host keeps drawing frames until completion.
503 pub fn tick(&mut self) -> f64 {
504 if let Some(start) = self.start_time {
505 let elapsed = start.elapsed().as_secs_f64();
506 let p = (elapsed / self.duration).min(1.0);
507 let eased = 1.0 - (1.0 - p).powi(3);
508 self.current = self.start_value + (self.target - self.start_value) * eased;
509 if p >= 1.0 {
510 self.current = self.target;
511 self.start_time = None;
512 } else {
513 request_draw();
514 }
515 }
516 self.current
517 }
518
519 /// Current interpolated value without advancing.
520 pub fn value(&self) -> f64 {
521 self.current
522 }
523
524 /// Where the tween is animating *towards* — i.e. the value last
525 /// passed to [`Self::set_target`]. Lets tests assert intent
526 /// (`request_lift(0.0)` was called) without waiting for the
527 /// animation to settle, which is otherwise wall-clock-dependent.
528 pub fn target(&self) -> f64 {
529 self.target
530 }
531
532 /// Whether the tween still needs frames to reach its target.
533 pub fn is_animating(&self) -> bool {
534 self.start_time.is_some()
535 }
536}
537
538impl Default for Tween {
539 fn default() -> Self {
540 Self::new(0.0, 0.12)
541 }
542}
543
544#[cfg(test)]
545mod host_waker_tests {
546 //! Coverage for the reactive-host waker hook: a worker-thread
547 //! [`signal_async_state_change`] must be able to nudge a host parked in
548 //! `ControlFlow::Wait`.
549 //!
550 //! `HOST_WAKER` and `ASYNC_WAKEUP_COUNTER` are process-global, and other
551 //! tests in this crate call `signal_async_state_change` concurrently — so
552 //! these tests serialize against each other with a local mutex, count
553 //! *their own* invocations rather than reading the global counter after
554 //! the fact, and assert with `>=` where a foreign signal could add more.
555 use super::*;
556 use std::sync::atomic::AtomicU64;
557
558 /// Serializes only these tests; unrelated tests may still signal in
559 /// parallel, which is why every assertion below tolerates extra fires.
560 static SERIAL: Mutex<()> = Mutex::new(());
561
562 fn serial() -> std::sync::MutexGuard<'static, ()> {
563 SERIAL.lock().unwrap_or_else(|p| p.into_inner())
564 }
565
566 #[test]
567 fn waker_fires_on_signal() {
568 let _guard = serial();
569 let fires = Arc::new(AtomicU64::new(0));
570 let seen = Arc::clone(&fires);
571 set_host_waker(move || {
572 seen.fetch_add(1, Ordering::AcqRel);
573 });
574
575 signal_async_state_change();
576 clear_host_waker();
577
578 assert!(
579 fires.load(Ordering::Acquire) >= 1,
580 "installed waker must run when any thread signals an async change"
581 );
582 }
583
584 #[test]
585 fn waker_sees_counter_already_bumped() {
586 let _guard = serial();
587 let before = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
588 let observed = Arc::new(AtomicU64::new(0));
589 let sink = Arc::clone(&observed);
590 set_host_waker(move || {
591 // Snapshot from inside the waker: the host is woken only after the
592 // bump is published, otherwise it would park again having seen
593 // nothing.
594 sink.store(
595 ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire),
596 Ordering::Release,
597 );
598 });
599
600 signal_async_state_change();
601 clear_host_waker();
602
603 assert!(
604 observed.load(Ordering::Acquire) > before,
605 "the counter must be bumped BEFORE the waker runs"
606 );
607 }
608
609 #[test]
610 fn replacing_the_waker_retires_the_old_one() {
611 let _guard = serial();
612 let old_fires = Arc::new(AtomicU64::new(0));
613 let old_sink = Arc::clone(&old_fires);
614 set_host_waker(move || {
615 old_sink.fetch_add(1, Ordering::AcqRel);
616 });
617
618 let new_fires = Arc::new(AtomicU64::new(0));
619 let new_sink = Arc::clone(&new_fires);
620 set_host_waker(move || {
621 new_sink.fetch_add(1, Ordering::AcqRel);
622 });
623 let old_baseline = old_fires.load(Ordering::Acquire);
624
625 signal_async_state_change();
626 clear_host_waker();
627
628 assert!(
629 new_fires.load(Ordering::Acquire) >= 1,
630 "the replacement waker runs"
631 );
632 assert_eq!(
633 old_fires.load(Ordering::Acquire),
634 old_baseline,
635 "the replaced waker no longer runs"
636 );
637 }
638
639 #[test]
640 fn clear_stops_the_waker() {
641 let _guard = serial();
642 let fires = Arc::new(AtomicU64::new(0));
643 let sink = Arc::clone(&fires);
644 set_host_waker(move || {
645 sink.fetch_add(1, Ordering::AcqRel);
646 });
647 clear_host_waker();
648 let baseline = fires.load(Ordering::Acquire);
649
650 signal_async_state_change();
651
652 assert_eq!(
653 fires.load(Ordering::Acquire),
654 baseline,
655 "a cleared waker must not run again"
656 );
657 }
658
659 #[test]
660 fn signal_from_a_worker_thread_wakes_the_host() {
661 let _guard = serial();
662 let fires = Arc::new(AtomicU64::new(0));
663 let sink = Arc::clone(&fires);
664 set_host_waker(move || {
665 sink.fetch_add(1, Ordering::AcqRel);
666 });
667
668 // The whole point of the hook: the signal originates off the main
669 // thread, where thread-local bumps are invisible to the host.
670 std::thread::spawn(signal_async_state_change)
671 .join()
672 .expect("worker thread signalled without panicking");
673 clear_host_waker();
674
675 assert!(
676 fires.load(Ordering::Acquire) >= 1,
677 "a worker-thread signal must reach the host waker"
678 );
679 }
680
681 #[test]
682 fn signalling_without_a_waker_is_a_no_op() {
683 let _guard = serial();
684 clear_host_waker();
685 // Must not panic and must still publish the cross-thread bump.
686 let before = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
687 signal_async_state_change();
688 assert!(
689 ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire) > before,
690 "counter-only behaviour is unchanged when no waker is installed"
691 );
692 }
693}
694
695#[cfg(test)]
696mod scheduled_draw_tests {
697 //! Regression coverage for the reactive-host lost-wakeup fix: the
698 //! scheduled-draw cell must be readable non-destructively, and a due
699 //! deadline must surface through `wants_draw`. Uses short real sleeps
700 //! (`web_time::Instant` has no injectable clock here); each test clears
701 //! shared thread-local state up front so it can't inherit a pending
702 //! deadline from a prior test on the same worker thread.
703 use super::*;
704 use std::thread::sleep;
705
706 /// (a) The lost-wakeup repro. A pending deadline read once must still be
707 /// visible on the SECOND read — the "intervening AboutToWait" that the
708 /// read-and-clear design silently dropped.
709 #[test]
710 fn peek_is_non_destructive() {
711 clear_draw_request();
712 request_draw_after(Duration::from_millis(50));
713 let first = peek_next_draw_deadline();
714 assert!(first.is_some(), "first peek sees the pending deadline");
715 let second = peek_next_draw_deadline();
716 assert_eq!(
717 first, second,
718 "second peek still sees the SAME pending deadline (lost-wakeup fix)"
719 );
720 }
721
722 /// (b) Once due, `wants_draw` returns true; after the paint-clear cycle
723 /// consumes it, a subsequent `wants_draw` is false absent a re-arm.
724 #[test]
725 fn due_deadline_surfaces_then_clears() {
726 clear_draw_request();
727 assert!(!wants_draw(), "baseline: nothing pending after clear");
728 request_draw_after(Duration::from_millis(20));
729 sleep(Duration::from_millis(40));
730 assert!(wants_draw(), "a due deadline makes wants_draw() true");
731 // Simulate the frame that honours it: paint clears the draw flags.
732 clear_draw_request();
733 assert!(
734 !wants_draw(),
735 "without a re-arm the loop goes idle again after the draw"
736 );
737 }
738
739 /// (c) A future (not-yet-due) deadline is peekable but does NOT make
740 /// `wants_draw` true — the host idles on `WaitUntil` instead of polling.
741 #[test]
742 fn future_deadline_peeks_but_does_not_want_draw() {
743 clear_draw_request();
744 request_draw_after(Duration::from_millis(500));
745 assert!(
746 peek_next_draw_deadline().is_some(),
747 "future deadline is visible to the host's WaitUntil"
748 );
749 assert!(
750 !wants_draw(),
751 "a future deadline must not force continuous polling"
752 );
753 // It also stays pending after that wants_draw() read.
754 assert!(
755 peek_next_draw_deadline().is_some(),
756 "a non-due wants_draw() must not consume the deadline"
757 );
758 }
759
760 /// (d) Earliest-deadline-wins still holds regardless of arm order.
761 #[test]
762 fn earliest_deadline_wins() {
763 clear_draw_request();
764 request_draw_after(Duration::from_millis(400));
765 let after_long = peek_next_draw_deadline().expect("long deadline armed");
766 request_draw_after(Duration::from_millis(20));
767 let after_short = peek_next_draw_deadline().expect("short deadline armed");
768 assert!(
769 after_short < after_long,
770 "a nearer deadline replaces a farther one"
771 );
772 // Reverse order: a farther deadline does not push the nearer one out.
773 request_draw_after(Duration::from_millis(400));
774 assert_eq!(
775 peek_next_draw_deadline(),
776 Some(after_short),
777 "arming a farther deadline keeps the earliest"
778 );
779 }
780}