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//!    [`take_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 host loop draws iff `wants_draw() || now >= take_next_draw_deadline()`.
20//! Between draws it idles; no frames are drawn while nothing has changed.
21
22use std::cell::Cell;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::time::Duration;
25use web_time::Instant;
26
27std::thread_local! {
28    static NEEDS_DRAW:        Cell<bool>            = Cell::new(false);
29    static NEXT_DRAW_AT:      Cell<Option<Instant>> = Cell::new(None);
30    static INVALIDATION_EPOCH: Cell<u64>             = Cell::new(0);
31    /// Bumped whenever an async source (image fetch + decode, font
32    /// load, etc.) finishes outside the event-dispatch path.  Retained
33    /// backbuffers (Window FBOs, in-process bitmap caches) compare
34    /// their stored value against this epoch on each paint and force
35    /// a re-raster on mismatch — there is no widget reference at the
36    /// callback site to walk the ancestor chain via the usual
37    /// `mark_dirty` route, so without this signal a freshly-decoded
38    /// image draws into the placeholder-sized rect the previous
39    /// layout reserved (the user-visible "wrong scale on first
40    /// frame" bug).
41    static ASYNC_STATE_EPOCH: Cell<u64> = Cell::new(0);
42    /// Per-thread snapshot of `ASYNC_WAKEUP_COUNTER` last observed by
43    /// [`pump_async_wakeup`].  When the global atomic is ahead of this,
44    /// the current thread's [`NEEDS_DRAW`], [`INVALIDATION_EPOCH`] and
45    /// [`ASYNC_STATE_EPOCH`] are bumped — see the module docs above
46    /// `ASYNC_WAKEUP_COUNTER` for why this indirection is required.
47    static LAST_SEEN_ASYNC_WAKEUP: Cell<u64> = Cell::new(0);
48}
49
50/// Process-global counter bumped by [`signal_async_state_change`] from
51/// any thread.  The async fetch / decode runs on a background worker
52/// (e.g. ehttp's `std::thread::spawn`), so thread-locals it sets are
53/// invisible to the main event loop.  The main thread pumps this
54/// atomic into its own thread-local epochs on every
55/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read —
56/// see [`pump_async_wakeup`].
57static ASYNC_WAKEUP_COUNTER: AtomicU64 = AtomicU64::new(0);
58
59/// Merge any pending cross-thread async-wakeup bumps into the calling
60/// thread's draw/invalidation/async-state state.
61///
62/// Without this, an ehttp callback completing on a background thread
63/// bumps thread-locals the main event loop never reads — the markdown
64/// SVG-badge "wrong scale until any other event" bug, where the loop
65/// keeps polling (`needs_draw=true` while `ImageState::Loading`) but
66/// `invalidation_epoch` never changes, so `render_app_frame` skips
67/// the layout pass and paints the freshly-decoded SVG into the
68/// previous layout's placeholder rect.
69fn pump_async_wakeup() {
70    let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
71    let changed = LAST_SEEN_ASYNC_WAKEUP.with(|c| {
72        let prev = c.get();
73        if prev == current {
74            false
75        } else {
76            c.set(current);
77            true
78        }
79    });
80    if changed {
81        NEEDS_DRAW.with(|c| c.set(true));
82        INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
83        ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
84    }
85}
86
87/// Request that the host schedule another draw as soon as possible.
88///
89/// **This is the right default for every widget state mutation that affects
90/// visual output.**  Calling it from inside an `on_event` handler advances
91/// [`invalidation_epoch`]; `dispatch_event` reads that epoch before/after
92/// delivery and automatically calls `mark_dirty` up the ancestor path when
93/// it sees a bump — so a retained ancestor's backbuffer cache invalidates
94/// without the widget needing to know about that ancestor at all.
95///
96/// Without the epoch bump, a `Widget::on_event` that returns `Ignored` (the
97/// common case for `MouseMove`) leaves the ancestor cache thinking
98/// "nothing changed", and the next frame composites a stale bitmap.  Hover
99/// effects, focus rings, and any other appearance change driven by event
100/// state ALL need this hook.
101///
102/// Reach for [`request_draw_without_invalidation`] only when you're certain
103/// no retained widget's *content* changed — overlays, position-only
104/// translations, and similar.  When in doubt, use `request_draw`.
105pub fn request_draw() {
106    NEEDS_DRAW.with(|c| c.set(true));
107    INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
108}
109
110/// Request a frame **without** advancing [`invalidation_epoch`].
111///
112/// `dispatch_event` won't mark retained ancestors dirty for this call, so
113/// any widget that drew its previous frame into a backbuffer cache will
114/// composite the cached bitmap unchanged.  Use this **only** when:
115///
116/// * The change lives in an app-level overlay that paints fresh every
117///   frame outside any retained subtree (inspector hover rectangle, popup
118///   menus rendered via `paint_global_overlay`, scroll-fade decorations).
119/// * The change is position-only — a window drag-move, where the cached
120///   content is reused at a translated origin (see `Window::on_event` for
121///   the canonical example).
122///
123/// **Do NOT call this from a widget that mutated its own state and expects
124/// the next paint to reflect it.**  That's [`request_draw`]'s job.  Hover
125/// indices, focus changes, animation ticks, button-press states — anything
126/// where the *content* of a retained widget differs from the cached
127/// bitmap — must call `request_draw` so the cache invalidates.  The
128/// `MenuBar` hover regression in `widgets/menu/widget/tests_2.rs` exists
129/// precisely because this distinction was missed once already.
130pub fn request_draw_without_invalidation() {
131    NEEDS_DRAW.with(|c| c.set(true));
132}
133
134/// Non-destructive read.  Hosts call this after drawing to decide control-flow
135/// for the next loop iteration.
136///
137/// Pumps any pending cross-thread async-wakeup bumps first, so a fetch
138/// callback that finished on a worker thread between frames is reflected
139/// in the result.
140pub fn wants_draw() -> bool {
141    pump_async_wakeup();
142    NEEDS_DRAW.with(|c| c.get())
143}
144
145/// Monotonic draw-request epoch used to detect visual changes during dispatch.
146///
147/// Pumps cross-thread wakeups first so a background-thread
148/// [`signal_async_state_change`] is observed here on the next read,
149/// causing layout-key caches keyed on this epoch to re-layout.
150pub fn invalidation_epoch() -> u64 {
151    pump_async_wakeup();
152    INVALIDATION_EPOCH.with(|c| c.get())
153}
154
155/// Note that an async-side state change happened (image loader finished,
156/// font loaded, etc.).  Safe to call from any thread; the main event
157/// loop observes the bump via [`pump_async_wakeup`] on its next
158/// `wants_draw` / `invalidation_epoch` / `async_state_epoch` read.
159///
160/// This used to only bump thread-local epochs, which silently broke
161/// when callers ran on background threads (ehttp spawns its own
162/// `std::thread`) — the main thread never observed the change and
163/// `render_app_frame`'s layout-key cache skipped the layout pass that
164/// would have given freshly-decoded SVG badges their natural
165/// dimensions (the user-visible "wrong scale until any other event"
166/// bug).
167pub fn signal_async_state_change() {
168    // Cross-thread visible bump.  Main thread merges via pump_async_wakeup.
169    ASYNC_WAKEUP_COUNTER.fetch_add(1, Ordering::AcqRel);
170    // Best-effort thread-local bump for same-thread callers (most
171    // hosts / tests).  Background threads only set their own
172    // thread-locals here, which is harmless — the atomic above is
173    // what the main thread actually consumes.
174    NEEDS_DRAW.with(|c| c.set(true));
175    INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
176    ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
177}
178
179/// Current async-state epoch.  Backbuffer caches store this and force
180/// a re-raster when it doesn't match.
181///
182/// Pumps cross-thread wakeups first so a worker-thread
183/// [`signal_async_state_change`] surfaces on the next read.
184pub fn async_state_epoch() -> u64 {
185    pump_async_wakeup();
186    ASYNC_STATE_EPOCH.with(|c| c.get())
187}
188
189/// Reset the per-frame draw flags.  The `App::paint` entry point calls
190/// this before delegating to the root widget so each frame starts fresh —
191/// widgets that still need a draw (animation in flight, focus blink, etc.)
192/// must re-arm during their draw, otherwise the loop goes idle.
193///
194/// Also syncs this thread's cross-thread async-wakeup bookkeeping so a
195/// stale bump from before this clear cannot reappear on the next
196/// `wants_draw` read.  Without that sync, parallel tests calling
197/// [`signal_async_state_change`] would leak wakeups into unrelated
198/// tests that rely on `wants_draw()` returning `false` after a clear.
199pub fn clear_draw_request() {
200    NEEDS_DRAW.with(|c| c.set(false));
201    NEXT_DRAW_AT.with(|c| c.set(None));
202    let current = ASYNC_WAKEUP_COUNTER.load(Ordering::Acquire);
203    LAST_SEEN_ASYNC_WAKEUP.with(|c| c.set(current));
204}
205
206/// Schedule a future draw.  Keeps the EARLIEST pending deadline, so multiple
207/// widgets asking for different delays will all be served by the soonest one
208/// (each widget re-arms its own deadline on the next draw anyway).
209pub fn request_draw_after(delay: Duration) {
210    let when = Instant::now() + delay;
211    NEXT_DRAW_AT.with(|c| match c.get() {
212        Some(existing) if existing <= when => {}
213        _ => c.set(Some(when)),
214    });
215}
216
217/// Read-and-clear the scheduled draw deadline.  The host reads this after
218/// drawing so the next frame's scheduled wake is determined entirely by what
219/// the fresh draw registered (e.g. a text field re-arms the 500 ms blink
220/// each frame while it remains focused; losing focus means no re-arm and the
221/// loop goes idle).
222pub fn take_next_draw_deadline() -> Option<Instant> {
223    NEXT_DRAW_AT.with(|c| c.replace(None))
224}
225
226// ── Tween ────────────────────────────────────────────────────────────────────
227//
228// Small reusable time-based interpolator for widgets that want a smooth
229// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
230// Ease-out cubic; reversal preserves the current value so rapid toggles
231// don't snap.  Requests a draw automatically while in flight.
232
233/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
234/// caller interprets).  Drives animations such as the scroll-bar hover
235/// expansion and toggle-switch on/off slide.
236#[derive(Clone, Copy)]
237pub struct Tween {
238    current: f64,
239    start_value: f64,
240    target: f64,
241    start_time: Option<Instant>,
242    duration: f64,
243}
244
245impl Tween {
246    /// New tween that starts at `initial` with the same value as its target
247    /// (no animation in flight).
248    pub const fn new(initial: f64, duration_secs: f64) -> Self {
249        Self {
250            current: initial,
251            start_value: initial,
252            target: initial,
253            start_time: None,
254            duration: duration_secs,
255        }
256    }
257
258    /// Update the target.  If it differs from the current target, re-anchors
259    /// the animation at the current interpolated value so reversals are smooth.
260    ///
261    /// Widgets that own a `Tween` must also report `tween.is_animating()` from
262    /// `Widget::needs_draw()` so retained parents repaint every frame until
263    /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
264    /// intentionally does not invalidate because many widgets retarget from
265    /// paint while synchronizing with external state.
266    pub fn set_target(&mut self, new_target: f64) {
267        if (self.target - new_target).abs() > 1e-9 {
268            self.start_value = self.current;
269            self.target = new_target;
270            self.start_time = Some(Instant::now());
271        }
272    }
273
274    /// Advance the animation based on elapsed wall time and return the new
275    /// interpolated value.  Ease-out cubic.  While in flight this also calls
276    /// [`request_draw`] so the host keeps drawing frames until completion.
277    pub fn tick(&mut self) -> f64 {
278        if let Some(start) = self.start_time {
279            let elapsed = start.elapsed().as_secs_f64();
280            let p = (elapsed / self.duration).min(1.0);
281            let eased = 1.0 - (1.0 - p).powi(3);
282            self.current = self.start_value + (self.target - self.start_value) * eased;
283            if p >= 1.0 {
284                self.current = self.target;
285                self.start_time = None;
286            } else {
287                request_draw();
288            }
289        }
290        self.current
291    }
292
293    /// Current interpolated value without advancing.
294    pub fn value(&self) -> f64 {
295        self.current
296    }
297
298    /// Where the tween is animating *towards* — i.e. the value last
299    /// passed to [`Self::set_target`].  Lets tests assert intent
300    /// (`request_lift(0.0)` was called) without waiting for the
301    /// animation to settle, which is otherwise wall-clock-dependent.
302    pub fn target(&self) -> f64 {
303        self.target
304    }
305
306    /// Whether the tween still needs frames to reach its target.
307    pub fn is_animating(&self) -> bool {
308        self.start_time.is_some()
309    }
310}
311
312impl Default for Tween {
313    fn default() -> Self {
314        Self::new(0.0, 0.12)
315    }
316}