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;