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::time::Duration;
24use web_time::Instant;
25
26std::thread_local! {
27 static NEEDS_DRAW: Cell<bool> = Cell::new(false);
28 static NEXT_DRAW_AT: Cell<Option<Instant>> = Cell::new(None);
29 static INVALIDATION_EPOCH: Cell<u64> = Cell::new(0);
30 /// Bumped whenever an async source (image fetch + decode, font
31 /// load, etc.) finishes outside the event-dispatch path. Retained
32 /// backbuffers (Window FBOs, in-process bitmap caches) compare
33 /// their stored value against this epoch on each paint and force
34 /// a re-raster on mismatch — there is no widget reference at the
35 /// callback site to walk the ancestor chain via the usual
36 /// `mark_dirty` route, so without this signal a freshly-decoded
37 /// image draws into the placeholder-sized rect the previous
38 /// layout reserved (the user-visible "wrong scale on first
39 /// frame" bug).
40 static ASYNC_STATE_EPOCH: Cell<u64> = Cell::new(0);
41}
42
43/// Request that the host schedule another draw as soon as possible.
44///
45/// **This is the right default for every widget state mutation that affects
46/// visual output.** Calling it from inside an `on_event` handler advances
47/// [`invalidation_epoch`]; `dispatch_event` reads that epoch before/after
48/// delivery and automatically calls `mark_dirty` up the ancestor path when
49/// it sees a bump — so a retained ancestor's backbuffer cache invalidates
50/// without the widget needing to know about that ancestor at all.
51///
52/// Without the epoch bump, a `Widget::on_event` that returns `Ignored` (the
53/// common case for `MouseMove`) leaves the ancestor cache thinking
54/// "nothing changed", and the next frame composites a stale bitmap. Hover
55/// effects, focus rings, and any other appearance change driven by event
56/// state ALL need this hook.
57///
58/// Reach for [`request_draw_without_invalidation`] only when you're certain
59/// no retained widget's *content* changed — overlays, position-only
60/// translations, and similar. When in doubt, use `request_draw`.
61pub fn request_draw() {
62 NEEDS_DRAW.with(|c| c.set(true));
63 INVALIDATION_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
64}
65
66/// Request a frame **without** advancing [`invalidation_epoch`].
67///
68/// `dispatch_event` won't mark retained ancestors dirty for this call, so
69/// any widget that drew its previous frame into a backbuffer cache will
70/// composite the cached bitmap unchanged. Use this **only** when:
71///
72/// * The change lives in an app-level overlay that paints fresh every
73/// frame outside any retained subtree (inspector hover rectangle, popup
74/// menus rendered via `paint_global_overlay`, scroll-fade decorations).
75/// * The change is position-only — a window drag-move, where the cached
76/// content is reused at a translated origin (see `Window::on_event` for
77/// the canonical example).
78///
79/// **Do NOT call this from a widget that mutated its own state and expects
80/// the next paint to reflect it.** That's [`request_draw`]'s job. Hover
81/// indices, focus changes, animation ticks, button-press states — anything
82/// where the *content* of a retained widget differs from the cached
83/// bitmap — must call `request_draw` so the cache invalidates. The
84/// `MenuBar` hover regression in `widgets/menu/widget/tests_2.rs` exists
85/// precisely because this distinction was missed once already.
86pub fn request_draw_without_invalidation() {
87 NEEDS_DRAW.with(|c| c.set(true));
88}
89
90/// Non-destructive read. Hosts call this after drawing to decide control-flow
91/// for the next loop iteration.
92pub fn wants_draw() -> bool {
93 NEEDS_DRAW.with(|c| c.get())
94}
95
96/// Monotonic draw-request epoch used to detect visual changes during dispatch.
97pub fn invalidation_epoch() -> u64 {
98 INVALIDATION_EPOCH.with(|c| c.get())
99}
100
101/// Note that an async-side state change happened (image loader finished,
102/// font loaded, etc.). Calls `request_draw()` so the next frame fires,
103/// AND bumps the [`async_state_epoch`] so retained backbuffers
104/// re-rasterise — without the latter, the freshly-loaded data gets
105/// drawn into whatever placeholder rect the previous layout reserved
106/// (the markdown SVG-badge "wrong scale on first frame" bug).
107pub fn signal_async_state_change() {
108 request_draw();
109 ASYNC_STATE_EPOCH.with(|c| c.set(c.get().wrapping_add(1)));
110}
111
112/// Current async-state epoch. Backbuffer caches store this and force
113/// a re-raster when it doesn't match.
114pub fn async_state_epoch() -> u64 {
115 ASYNC_STATE_EPOCH.with(|c| c.get())
116}
117
118/// Reset the per-frame draw flags. The `App::paint` entry point calls
119/// this before delegating to the root widget so each frame starts fresh —
120/// widgets that still need a draw (animation in flight, focus blink, etc.)
121/// must re-arm during their draw, otherwise the loop goes idle.
122pub fn clear_draw_request() {
123 NEEDS_DRAW.with(|c| c.set(false));
124 NEXT_DRAW_AT.with(|c| c.set(None));
125}
126
127/// Schedule a future draw. Keeps the EARLIEST pending deadline, so multiple
128/// widgets asking for different delays will all be served by the soonest one
129/// (each widget re-arms its own deadline on the next draw anyway).
130pub fn request_draw_after(delay: Duration) {
131 let when = Instant::now() + delay;
132 NEXT_DRAW_AT.with(|c| match c.get() {
133 Some(existing) if existing <= when => {}
134 _ => c.set(Some(when)),
135 });
136}
137
138/// Read-and-clear the scheduled draw deadline. The host reads this after
139/// drawing so the next frame's scheduled wake is determined entirely by what
140/// the fresh draw registered (e.g. a text field re-arms the 500 ms blink
141/// each frame while it remains focused; losing focus means no re-arm and the
142/// loop goes idle).
143pub fn take_next_draw_deadline() -> Option<Instant> {
144 NEXT_DRAW_AT.with(|c| c.replace(None))
145}
146
147// ── Tween ────────────────────────────────────────────────────────────────────
148//
149// Small reusable time-based interpolator for widgets that want a smooth
150// transition between two scalar states (hover ↔ dormant, off ↔ on, etc.).
151// Ease-out cubic; reversal preserves the current value so rapid toggles
152// don't snap. Requests a draw automatically while in flight.
153
154/// Smooth scalar tween between `0.0` and `1.0` (or any pair of values the
155/// caller interprets). Drives animations such as the scroll-bar hover
156/// expansion and toggle-switch on/off slide.
157#[derive(Clone, Copy)]
158pub struct Tween {
159 current: f64,
160 start_value: f64,
161 target: f64,
162 start_time: Option<Instant>,
163 duration: f64,
164}
165
166impl Tween {
167 /// New tween that starts at `initial` with the same value as its target
168 /// (no animation in flight).
169 pub const fn new(initial: f64, duration_secs: f64) -> Self {
170 Self {
171 current: initial,
172 start_value: initial,
173 target: initial,
174 start_time: None,
175 duration: duration_secs,
176 }
177 }
178
179 /// Update the target. If it differs from the current target, re-anchors
180 /// the animation at the current interpolated value so reversals are smooth.
181 ///
182 /// Widgets that own a `Tween` must also report `tween.is_animating()` from
183 /// `Widget::needs_draw()` so retained parents repaint every frame until
184 /// the tween settles. [`Tween::tick`] is the draw-request point; `set_target`
185 /// intentionally does not invalidate because many widgets retarget from
186 /// paint while synchronizing with external state.
187 pub fn set_target(&mut self, new_target: f64) {
188 if (self.target - new_target).abs() > 1e-9 {
189 self.start_value = self.current;
190 self.target = new_target;
191 self.start_time = Some(Instant::now());
192 }
193 }
194
195 /// Advance the animation based on elapsed wall time and return the new
196 /// interpolated value. Ease-out cubic. While in flight this also calls
197 /// [`request_draw`] so the host keeps drawing frames until completion.
198 pub fn tick(&mut self) -> f64 {
199 if let Some(start) = self.start_time {
200 let elapsed = start.elapsed().as_secs_f64();
201 let p = (elapsed / self.duration).min(1.0);
202 let eased = 1.0 - (1.0 - p).powi(3);
203 self.current = self.start_value + (self.target - self.start_value) * eased;
204 if p >= 1.0 {
205 self.current = self.target;
206 self.start_time = None;
207 } else {
208 request_draw();
209 }
210 }
211 self.current
212 }
213
214 /// Current interpolated value without advancing.
215 pub fn value(&self) -> f64 {
216 self.current
217 }
218
219 /// Whether the tween still needs frames to reach its target.
220 pub fn is_animating(&self) -> bool {
221 self.start_time.is_some()
222 }
223}
224
225impl Default for Tween {
226 fn default() -> Self {
227 Self::new(0.0, 0.12)
228 }
229}