frust_shell_common/frame_gate.rs
1//! [`FrameGate`]: the shared skip-frame decision the mobile shells consult
2//! each tick.
3//!
4//! # What lives here
5//!
6//! - [`FrameInputs`] — the OR-list of per-frame "something changed" signals a
7//! shell gathers from its own state (see the struct's field docs, each
8//! naming the shell-side source).
9//! - [`FrameGate`] — a plain struct owning the resume-warmup counter and the
10//! kill-switch flag; [`FrameGate::decide`] folds [`FrameInputs`] plus the
11//! warmup into one [`FrameDecision`] (`Run`/`Skip`).
12//!
13//! # Layering choice
14//!
15//! Like [`crate::perf`], this is shell-owned by design and lives in
16//! `frust-shell-common`, not `frust-core`. The gate takes no platform
17//! dependency, no `unsafe`, and — critically — **no `frust-reactive`
18//! dependency**: the reactive "did any tracked signal change" answer arrives
19//! as the plain [`FrameInputs::signals_dirty`] bool, which the shells fill
20//! themselves by calling `frust_reactive::ReactiveRuntime::take_signals_dirty`
21//! after their per-frame `pump_local` (that crate's documented
22//! pump-first ordering contract). This keeps shell-common's compiles-everywhere,
23//! reactive-free charter intact (see `docs/ARCHITECTURE.md`'s Layer
24//! Dependencies).
25//!
26//! # The layout-skip seam
27//!
28//! [`FrameGate`] decides whether a whole frame runs at all. Within a frame it
29//! *does* run, whether the layout pass can be skipped is a second, finer gate
30//! the shell drives off `RenderRoot::take_change_flags` (exposed through
31//! [`crate::AppTree::take_change_flags`]): run `rebuild` → run `layout` iff the
32//! drained `ChangeFlags` need layout (or it's the first frame, or the surface
33//! resized / rescaled) → always `paint`. Every shell now wires this seam
34//! (Android, iOS, desktop and web); iOS drains the flags both to idle the
35//! display link and to decide layout, and the frame gate above still decides
36//! whether the frame runs at all (the layout skip only elides the layout call
37//! inside a running frame). The `set_theme ⇒ LAYOUT|PAINT` contract
38//! (`docs/ARCHITECTURE.md`'s Theme delivery) is the correctness anchor there:
39//! `Text` bakes its themed glyph color at layout time, so a bare theme swap
40//! must force relayout even though no view changed — which
41//! `RenderRoot::set_theme` guarantees by marking `LAYOUT` pending. Encode and
42//! present are frame-level only: a frame the gate runs always presents.
43//!
44//! Beneath that whole-frame skip, `RenderRoot` skips clean pods per
45//! `ChildPod`; its env knobs (plus `FRUST_NO_RETAINED_LAYERS`) are read by the shells through [`LayoutKnobs`]
46//! (`frust-core` reads no environment variable).
47//!
48//! # Animation pacing (frame-gate pacing)
49//!
50//! On top of the whole-frame skip, a frame whose *only* dirtiness source is a
51//! paced ([`frust_core::TickClass::CosmeticLoop`]) frame request — a shimmer,
52//! idle pulse, or spinner with no user-visible endpoint — is throttled to the
53//! active theme's `MotionScheme::cosmetic_loop_rate` rather than reproduced on
54//! every vsync. A paced request may also name its *own*, slower cadence
55//! (`PaintCtx::request_frame_paced_at` — a ~500ms caret blink against a 30Hz
56//! shimmer cap); the paint pass folds every such request onto a MIN-lattice and
57//! the gate resolves the result against the theme cap
58//! ([`FramePacing::effective_interval`]). The shell feeds the paced-only fact
59//! through [`FrameInputs::last_needs_frame_paced_only`] and the per-frame clock +
60//! interval pair through [`FramePacing`] to [`FrameGate::decide_paced`]; any
61//! input/signal/change-flag/transition dirtiness is **never** paced (it runs
62//! immediately, per the default-to-run rule) — with one deliberate exception,
63//! the [`FrameInputs::focus_or_ime_changed`] edge (that field's doc has the
64//! reasoning: a blinking caret in a focused field is exactly the loop this gate
65//! must be able to throttle). Because that edge *can* ride inside a paced
66//! decision, the shells **peek** it rather than drain it: they compare the live
67//! generation against their cache every tick but commit the cache only once the
68//! gate has decided to `Run`, so an edge landing on a skipped tick is deferred
69//! by the pacing — bounded by one cap interval — and never erased. Every other
70//! drained-on-gather latch is an [`FrameInputs::is_paced_only_frame`]
71//! disqualifier and so can never be true on a tick the gate skips. The
72//! `FRUST_NO_FRAME_GATE` kill
73//! switch disables pacing too (a disabled gate always runs), and
74//! [`NO_ANIM_PACING_VAR`] (`FRUST_NO_ANIM_PACING`) disables *only* the pacing
75//! while leaving the skip gate active.
76
77use frust_core::anim::FrameTime;
78
79/// The resume-warmup window length: after a [`FrameGate::note_resumed`] the
80/// next `WARMUP_FRAMES` [`decide`](FrameGate::decide) calls force a `Run`
81/// regardless of [`FrameInputs`].
82///
83/// Three frames is a deliberately small, fixed cushion: a
84/// resume/surface-recreate can leave the first tick's change signals not yet
85/// observable (a surface just became ready, the appearance poll hasn't run,
86/// the first post-resume event hasn't arrived), and painting a couple of
87/// extra frames on resume is far cheaper than showing a stale or blank one.
88/// It is not a published platform constant — it is a Frust tuning choice
89/// (the "correctness beats savings" default: when in doubt, run).
90pub const WARMUP_FRAMES: u8 = 3;
91
92/// The kill-switch environment/compile-time variable: when set to any
93/// non-`"0"` value, [`FrameGate::new`] yields a gate that always [`Run`]s,
94/// matching pre-gate behavior verbatim.
95///
96/// [`Run`]: FrameDecision::Run
97pub const NO_FRAME_GATE_VAR: &str = "FRUST_NO_FRAME_GATE";
98
99/// The animation-pacing kill-switch variable: when set to any non-`"0"` value,
100/// [`FrameGate::new`] disables *only* the paced-loop throttling — the
101/// whole-frame skip gate stays active, but every paced ([`CosmeticLoop`])
102/// request runs on its vsync as before. Narrower than [`NO_FRAME_GATE_VAR`]
103/// (which disables the whole gate), it isolates the pacing behavior for A/B
104/// diagnosis. Parsed with the same compile-time-`option_env!` + runtime-env
105/// family as [`NO_FRAME_GATE_VAR`] / `FRUST_TRACE`.
106///
107/// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
108pub const NO_ANIM_PACING_VAR: &str = "FRUST_NO_ANIM_PACING";
109
110/// The per-frame pacing context a shell hands to [`FrameGate::decide_paced`]:
111/// this tick's frame clock, the active theme's paced-loop cap, and whatever
112/// interval the previous paint's paced request asked for.
113///
114/// All three are shell-owned: [`now`](Self::now) is the platform frame clock
115/// (Choreographer / `CADisplayLink` timestamp — never a wall clock read inside
116/// `frust-core`), [`interval`](Self::interval) is `1 /
117/// MotionScheme::cosmetic_loop_rate` resolved from the *active* theme each
118/// frame (so an app that retunes the token via `ThemeBuilder` re-paces live),
119/// and [`requested_interval`](Self::requested_interval) is the previous paint's
120/// latched `PaintOutcome::paced_interval`. [`effective_interval`](Self::effective_interval)
121/// folds the last two into the one interval this tick actually paces at.
122#[derive(Debug, Clone, Copy)]
123pub struct FramePacing {
124 /// This tick's shell frame-clock reading. Only *differences* of two
125 /// [`FrameTime`]s from the same shell carry meaning (see [`FrameTime`]).
126 pub now: FrameTime,
127 /// The theme's paced-loop **cap**: `1 / cosmetic_loop_rate` from the active
128 /// theme, and so the shortest interval any paced ([`CosmeticLoop`]) frame
129 /// may be produced at. `CosmeticLoopRate` clamps its rate up to
130 /// `FLOOR_HZ` (10Hz), so this is never longer than 100ms.
131 ///
132 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
133 pub interval: std::time::Duration,
134 /// The tightest interval the previous paint's paced requests named
135 /// (`frust_core::PaintOutcome::paced_interval`, latched by the shell beside
136 /// [`FrameInputs::last_needs_frame_paced_only`]), or `None` when that paint
137 /// named none.
138 ///
139 /// `None` and `Some(Duration::ZERO)` both mean "at the theme's own rate" —
140 /// a bare `PaintCtx::request_frame_paced` folds `ZERO` into the core-side
141 /// MIN-lattice — so both resolve to [`interval`](Self::interval). A longer
142 /// value (a ~500ms caret blink against a 30Hz shimmer cap) paces that loop
143 /// slower than the theme's own cadence; see
144 /// [`effective_interval`](Self::effective_interval).
145 pub requested_interval: Option<std::time::Duration>,
146}
147
148impl FramePacing {
149 /// The interval this tick actually paces at: the **longer** of the theme's
150 /// cap ([`interval`](Self::interval)) and the previous paint's requested
151 /// interval ([`requested_interval`](Self::requested_interval)).
152 ///
153 /// The two-sided contract behind that `max` (the core-side half lives on
154 /// `PaintCtx::request_frame_paced_at`):
155 ///
156 /// - **The MIN fold already happened in core.** Every paced request in a
157 /// paint pass folds to the tightest interval there, so this sees one
158 /// value: the fastest cadence anything onscreen asked for. A 30Hz shimmer
159 /// beside a 2Hz caret arrives here as `ZERO` (the shimmer's bare request)
160 /// and paces at 30Hz — the caret is simply repainted more often than it
161 /// needs, which is invisible and costs no frame the shimmer wasn't
162 /// already forcing. A slow request can never starve a fast one.
163 /// - **The theme rate is a ceiling.** `cosmetic_loop_rate` caps decorative
164 /// motion for battery's sake, so a request *tighter* than the cap is
165 /// clamped up to it rather than honored. Motion that must land every
166 /// vsync is not cosmetic — it belongs to `TickClass::Transition`, which
167 /// is never paced at all.
168 pub fn effective_interval(&self) -> std::time::Duration {
169 match self.requested_interval {
170 Some(requested) => self.interval.max(requested),
171 None => self.interval,
172 }
173 }
174}
175
176/// The outcome of [`FrameGate::decide`]: whether the shell should run this
177/// frame's rebuild/layout/paint/encode/present passes, or skip them entirely.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum FrameDecision {
180 /// Run the frame: at least one input signalled a change (or the gate is
181 /// in its resume warmup, or the gate is disabled).
182 Run,
183 /// Skip the frame: nothing changed. The continuous Choreographer/
184 /// `CADisplayLink` loop keeps re-posting callbacks — only frame
185 /// *production* stops (see the module docs).
186 Skip,
187}
188
189impl FrameDecision {
190 /// Whether this decision is [`FrameDecision::Run`].
191 pub fn is_run(self) -> bool {
192 matches!(self, FrameDecision::Run)
193 }
194
195 /// Whether this decision is [`FrameDecision::Skip`].
196 pub fn is_skip(self) -> bool {
197 matches!(self, FrameDecision::Skip)
198 }
199}
200
201/// The per-frame OR-list a shell gathers and hands to [`FrameGate::decide`].
202///
203/// Every field is a "something that needs this frame to run" signal; the gate
204/// runs the frame if **any** is `true`, with three deliberate deltas noted
205/// in the field docs:
206///
207/// - **[`focus_or_ime_changed`](Self::focus_or_ime_changed)** is an *edge*, not
208/// a level: it reports that the focus/IME session moved since the shell last
209/// looked, so a steady focus session no longer forces a frame every vsync
210/// (and, alone among the wake inputs, it does not disqualify pacing).
211///
212/// - **Semantics adapter needs** is *not* a field here:
213/// semantics-publish gating stays shell-side (both Android and iOS are
214/// generation-gated via `AppTree::semantics_if_changed`), so it never gates
215/// whole-frame production.
216/// - **[`resumed_recently`](Self::resumed_recently)** is the added input for
217/// the resume warmup (see [`WARMUP_FRAMES`]); the gate also drives this same
218/// condition internally via [`FrameGate::note_resumed`], so a shell may
219/// leave the field `false` and rely on the counter (both force a `Run`).
220///
221/// `Default` is all-`false` (the "nothing changed" baseline a
222/// [`FrameGate::decide`] turns into a [`FrameDecision::Skip`]).
223#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
224pub struct FrameInputs {
225 /// *tracked-signal dirty*. A tracked signal changed since the last
226 /// frame. Source: `frust_reactive::ReactiveRuntime::take_signals_dirty`,
227 /// read by the shell *after* its per-frame `pump_local` per
228 /// that crate's pump-first ordering contract.
229 pub signals_dirty: bool,
230 /// *events dispatched since last frame*. A pointer/scroll/key/IME
231 /// event reached `RenderRoot::event` between frames. Source: a shell-side
232 /// latch set by the `nativeOnTouch`/IME entry points and cleared each
233 /// frame.
234 pub events_since_last_frame: bool,
235 /// *active pointer capture*. A gesture is mid-drag and the captured
236 /// widget may animate/track the pointer. Source:
237 /// `AppTree`/`RenderRoot::is_pointer_captured`.
238 pub pointer_capture_active: bool,
239 /// *focus/IME session moved*. The root's focus flag or its published IME
240 /// surface changed since the shell last **produced a frame** — an **edge**,
241 /// not a level. Source:
242 /// [`AppTree::focus_ime_generation`](crate::AppTree::focus_ime_generation)
243 /// compared against the shell's cached copy, which the shell commits only
244 /// once the gate has decided to run this frame (a *peek* at gather time —
245 /// see the deferral note at the end of this doc).
246 ///
247 /// **Why an edge.** This was a *level* input
248 /// (`RenderRoot::is_focus_active || ime_state().is_some()`) — the
249 /// original conservative default. Because it forces a `Run` through
250 /// [`any_set`](Self::any_set) *and* disqualified
251 /// [`is_paced_only_frame`](Self::is_paced_only_frame), any screen holding
252 /// root focus rendered every single vsync for as long as the focus lasted,
253 /// and caret pacing was unreachable while a caret blinked: measured at
254 /// 62–120 fps on a static screen whose only live input was focus (Xiaomi
255 /// 12). As an edge it still forces one frame per transition (focus gained /
256 /// lost, IME surface published / cleared) while a *steady* focus session
257 /// leaves the gate free to idle or pace.
258 ///
259 /// **Why one frame is enough.** Every IME event the platform delivers also
260 /// trips [`events_since_last_frame`](Self::events_since_last_frame) (both
261 /// shells latch it in their `ime_apply` entry points), and the per-frame
262 /// platform IME reconcile (Kotlin `doFrame` / Swift `renderFrame`) polls the
263 /// *published Rust state* on its own cadence, independent of whether Rust
264 /// produced a frame — all it needs is that state to be current, which the
265 /// edge guarantees by forcing the frame after every change.
266 ///
267 /// Unlike the other wake inputs this one is **not** an
268 /// [`is_paced_only_frame`](Self::is_paced_only_frame) disqualifier, so an
269 /// edge landing on a tick whose only other dirtiness is a paced loop is
270 /// consumed by whichever tick's pacing decision resolves to `Run`: the
271 /// repaint then lands with the loop's next paced frame (bounded by one
272 /// `cosmetic_loop_rate` interval), and the platform's IME poll is unaffected
273 /// either way. This is exactly why the shells peek the generation rather
274 /// than draining it at gather time — an edge that a Skip erased would make
275 /// the next repaint wait out the loop's *full* effective interval instead of
276 /// the bound below. A *per-request*
277 /// paced interval ([`FramePacing::requested_interval`]) never widens that
278 /// bound — [`FrameGate::decide_paced`] tightens an edge-carrying tick back
279 /// to the theme cap on purpose, so a 500ms caret cannot turn a focus
280 /// transition into a 500ms lag.
281 pub focus_or_ime_changed: bool,
282 /// *last paint's `needs_frame`*. The previous paint advanced an
283 /// animation/transition and asked for another frame. Source:
284 /// `PaintOutcome::needs_frame`, latched by the shell from the prior
285 /// frame's `AppTree::paint` return.
286 pub last_needs_frame: bool,
287 /// *last paint's `needs_frame_paced_only`*: the prior frame's frame request
288 /// aggregated to [`frust_core::TickClass::CosmeticLoop`] alone — a pacable
289 /// decorative loop with no concurrent transition. Latched by the shell from
290 /// `PaintOutcome::needs_frame_paced_only`. Meaningful only alongside
291 /// [`last_needs_frame`](Self::last_needs_frame); with every *other* input
292 /// clear ([`is_paced_only_frame`](Self::is_paced_only_frame)) it is the sole
293 /// signal [`FrameGate::decide_paced`] throttles to the theme's cadence. Any
294 /// concurrent transition/input clears it, so the frame runs immediately.
295 pub last_needs_frame_paced_only: bool,
296 /// *pending `ChangeFlags`*. A rebuild (or `set_theme`) left layout/
297 /// paint dirtiness undrained. Source:
298 /// [`AppTree::has_pending_change_flags`](crate::AppTree::has_pending_change_flags)
299 /// (a non-draining peek, so a skipped frame preserves the flags).
300 pub change_flags_pending: bool,
301 /// *deferred callbacks owed a flush*. A widget queued a state-bearing
302 /// callback during a state-free pass and raised
303 /// [`frust_core::mark_pending_result_flush`], which only
304 /// `RenderRoot::rebuild` can drain (it holds the `&mut State` the callback
305 /// needs). Source: [`frust_core::has_pending_result_flush`], the
306 /// non-draining peek — draining stays the rebuild's job on a frame that
307 /// actually runs.
308 ///
309 /// Hardening under the default-to-run rule rather than a reproduced stall:
310 /// every mark raised today is *also* covered by another input (a mark from
311 /// inside a rebuild is drained by that same rebuild, whose leftover
312 /// `pending |= PAINT` reaches [`change_flags_pending`](Self::change_flags_pending)
313 /// and whose `deferred_frame` reaches [`last_needs_frame`](Self::last_needs_frame);
314 /// `frust-widgets`' paint-time long-press latch pairs its mark with a
315 /// `request_frame`). This input closes the general case those two happen to
316 /// cover — a mark raised with nothing else dirty must never wait for the
317 /// next stray touch.
318 pub deferred_callbacks_pending: bool,
319 /// *theme-override/appearance change*. The app-facing theme override
320 /// or the platform light/dark preference changed this tick. Source: the
321 /// shell's per-frame `ThemeOverrideWatcher`/appearance poll (see
322 /// [`crate::theme_override`]).
323 pub theme_or_appearance_changed: bool,
324 /// *surface resize/recreation*. The GPU surface was created, resized,
325 /// or recreated (rotation/backgrounding). Source: the shell's
326 /// `nativeOnSurfaceChanged`/`frust_resize` path.
327 pub surface_changed_or_resized: bool,
328 /// *accessibility actions*. A platform `accesskit_*` action was
329 /// performed this tick, mutating state. Source: the shell's a11y-action
330 /// drain feeding `AppTree::perform_accessibility_action`.
331 pub a11y_action_performed: bool,
332 /// Added input: *resume warmup*. The app resumed / the surface was
333 /// (re)created within the last [`WARMUP_FRAMES`] frames. A shell may set
334 /// this explicitly, or leave it `false` and let [`FrameGate::note_resumed`]
335 /// drive the same condition through the gate's own countdown — both force
336 /// a `Run`.
337 pub resumed_recently: bool,
338}
339
340impl FrameInputs {
341 /// Whether any input signals that this frame must run. The gate ORs the
342 /// full field set — the single decision rule the whole type exists to
343 /// feed (see [`FrameGate::decide`]).
344 pub fn any_set(&self) -> bool {
345 self.signals_dirty
346 || self.events_since_last_frame
347 || self.pointer_capture_active
348 || self.focus_or_ime_changed
349 || self.last_needs_frame
350 || self.last_needs_frame_paced_only
351 || self.change_flags_pending
352 || self.deferred_callbacks_pending
353 || self.theme_or_appearance_changed
354 || self.surface_changed_or_resized
355 || self.a11y_action_performed
356 || self.resumed_recently
357 }
358
359 /// Whether the *only* dirtiness this frame is a paced ([`CosmeticLoop`])
360 /// frame request — the throttleable case [`FrameGate::decide_paced`] paces.
361 ///
362 /// True iff [`last_needs_frame`](Self::last_needs_frame) and
363 /// [`last_needs_frame_paced_only`](Self::last_needs_frame_paced_only) are
364 /// both set and **every other** OR-list input is clear. Any
365 /// input/signal/change-flag/transition alongside it makes this `false`, so
366 /// the gate runs the frame immediately rather than pacing it (the
367 /// default-to-run rule — see `docs/CODE_STANDARDS.md`'s Frame-Gate
368 /// conventions).
369 ///
370 /// One deliberate exception:
371 /// [`focus_or_ime_changed`](Self::focus_or_ime_changed) is **not** a
372 /// disqualifier. Its level-input predecessor was one, and — being true for
373 /// a whole focus session — that is what made caret pacing unreachable: a
374 /// blinking caret in a focused field is precisely the paced loop this gate
375 /// must be able to throttle (that field's doc has the device measurement).
376 /// The edge that replaced it reports one transition, not a session, so an
377 /// edge arriving mid-loop is simply carried by the loop's next paced frame
378 /// instead of pre-empting it. It is one repaint *per transition*, not
379 /// per tick: the shells peek it, so it keeps reporting across every skipped
380 /// tick in between and is cleared by the frame that finally runs.
381 ///
382 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
383 pub fn is_paced_only_frame(&self) -> bool {
384 self.last_needs_frame
385 && self.last_needs_frame_paced_only
386 && !self.signals_dirty
387 && !self.events_since_last_frame
388 && !self.pointer_capture_active
389 && !self.change_flags_pending
390 && !self.deferred_callbacks_pending
391 && !self.theme_or_appearance_changed
392 && !self.surface_changed_or_resized
393 && !self.a11y_action_performed
394 && !self.resumed_recently
395 }
396}
397
398/// The per-shell skip-frame gate: a plain struct — no
399/// globals — a shell constructs once and drives each frame via
400/// [`decide`](Self::decide).
401///
402/// Owns two pieces of state: whether the gate is enabled at all (the
403/// [`NO_FRAME_GATE_VAR`] kill switch, resolved once at construction) and the
404/// resume-warmup countdown ([`WARMUP_FRAMES`], seeded by
405/// [`note_resumed`](Self::note_resumed)) — the standalone, host-testable
406/// decision type the mobile shells wire in.
407#[derive(Debug)]
408pub struct FrameGate {
409 /// When `false`, [`decide`](Self::decide) always returns
410 /// [`FrameDecision::Run`] — the kill switch and [`disabled`](Self::disabled)
411 /// path, pre-gate behavior verbatim.
412 enabled: bool,
413 /// When `false`, [`decide_paced`](Self::decide_paced) never throttles a
414 /// paced-only frame (it runs on its vsync as before) — the
415 /// [`NO_ANIM_PACING_VAR`] kill switch, resolved once at construction. The
416 /// whole-frame skip gate stays active regardless.
417 anim_pacing: bool,
418 /// Frames left in the resume-warmup window; while `> 0`,
419 /// [`decide`](Self::decide) forces a `Run` and decrements it.
420 warmup_remaining: u8,
421 /// The frame clock reading of the last *produced* frame while pacing, used
422 /// to measure the paced-loop interval. `None` until the first paced
423 /// decision; re-anchored to `now` on every produced frame (see
424 /// [`decide_paced`](Self::decide_paced)).
425 last_paced_run: Option<FrameTime>,
426 /// The paced interval in force at the last produced frame — the cadence
427 /// [`last_paced_run`](Self::last_paced_run) was anchored *for*.
428 ///
429 /// Per-request intervals ([`FramePacing::requested_interval`]) make the
430 /// active interval a per-tick value rather than a constant, and
431 /// [`anchor_paced`](Self::anchor_paced)'s drift-free `last + interval`
432 /// arithmetic is only meaningful while that value holds still. Comparing
433 /// against this is how the anchor detects a changed cadence and re-anchors
434 /// to `now` instead — "the anchor adopts the interval in force at the last
435 /// Run". `None` until the first pacing-aware decision.
436 last_paced_interval: Option<std::time::Duration>,
437}
438
439impl FrameGate {
440 /// A gate honoring the [`NO_FRAME_GATE_VAR`] kill switch — what every
441 /// shell constructs. When the variable is set (compile-time `--define` or
442 /// runtime env, any non-`"0"` value), this is equivalent to
443 /// [`disabled`](Self::disabled).
444 pub fn new() -> Self {
445 Self::with_flags(!kill_switch_engaged(), !anim_pacing_kill_switch_engaged())
446 }
447
448 /// A gate that always [`Run`](FrameDecision::Run)s regardless of inputs —
449 /// the explicit disabled/kill-switch form (and a test seam bypassing the
450 /// env read). Mirrors [`FrameGate::new`]'s behavior when
451 /// [`NO_FRAME_GATE_VAR`] is set.
452 pub fn disabled() -> Self {
453 Self::with_flags(false, false)
454 }
455
456 /// Construct with an explicit enabled flag, bypassing the env read — the
457 /// test/advanced seam (mirrors [`crate::perf::FrameStats::new_enabled`]).
458 /// Animation pacing follows `enabled` (a disabled gate never paces because
459 /// it never skips); use [`with_flags`](Self::with_flags) to vary the two
460 /// independently.
461 pub fn with_enabled(enabled: bool) -> Self {
462 Self::with_flags(enabled, enabled)
463 }
464
465 /// Construct with explicit `enabled` (whole-frame skip) and `anim_pacing`
466 /// (paced-loop throttling) flags, bypassing both env reads — the test seam
467 /// for the pacing behavior in isolation.
468 pub fn with_flags(enabled: bool, anim_pacing: bool) -> Self {
469 Self {
470 enabled,
471 anim_pacing,
472 warmup_remaining: 0,
473 last_paced_run: None,
474 last_paced_interval: None,
475 }
476 }
477
478 /// Whether the gate is active (can ever return [`FrameDecision::Skip`]).
479 /// `false` for a [`disabled`](Self::disabled) gate or when the kill switch
480 /// is engaged.
481 pub fn is_enabled(&self) -> bool {
482 self.enabled
483 }
484
485 /// Open the resume-warmup window: the next [`WARMUP_FRAMES`]
486 /// [`decide`](Self::decide) calls force a [`FrameDecision::Run`].
487 ///
488 /// A shell calls this on resume and on surface (re)creation, where the
489 /// first tick's change signals may not yet be observable (see
490 /// [`WARMUP_FRAMES`]).
491 pub fn note_resumed(&mut self) {
492 self.warmup_remaining = WARMUP_FRAMES;
493 }
494
495 /// Frames left in the resume-warmup window (`0` when not warming up).
496 /// Exposed for tests/diagnostics.
497 pub fn warmup_remaining(&self) -> u8 {
498 self.warmup_remaining
499 }
500
501 /// Decide whether this frame runs.
502 ///
503 /// Returns [`FrameDecision::Run`] when **any** of:
504 /// - the gate is disabled (kill switch / [`disabled`](Self::disabled)),
505 /// - the resume warmup is active (decrementing it by one), or
506 /// - any [`FrameInputs`] field is set ([`FrameInputs::any_set`]) —
507 ///
508 /// otherwise [`FrameDecision::Skip`]. See [`FrameInputs`]'s docs for what
509 /// each input signal means.
510 ///
511 /// Takes `&mut self` because it advances the resume-warmup countdown.
512 ///
513 /// This is the non-paced entry: a paced-only frame runs on every tick (no
514 /// throttling), the conservative pre-pacing behavior. Use
515 /// [`decide_paced`](Self::decide_paced) to honor the theme's cosmetic-loop
516 /// cadence.
517 pub fn decide(&mut self, inputs: FrameInputs) -> FrameDecision {
518 self.decide_impl(inputs, None)
519 }
520
521 /// Decide whether this frame runs, honoring animation pacing.
522 ///
523 /// Identical to [`decide`](Self::decide) except that when the *only*
524 /// dirtiness is a paced ([`CosmeticLoop`]) frame request
525 /// ([`FrameInputs::is_paced_only_frame`]) and pacing is enabled, the frame
526 /// is throttled to [`FramePacing::effective_interval`] (the theme cap folded
527 /// with the previous paint's requested interval): it runs only once
528 /// `pacing.now - last_paced_run >= interval`, otherwise [`Skip`]s. A skip
529 /// leaves `last_needs_frame` alive (the shell doesn't repaint, so it never
530 /// re-latches), so the gate keeps waking and never starves the loop; the
531 /// interval is re-anchored to the clock of every *produced* frame (whatever
532 /// its cause), so a transition frame mid-loop resets the cadence.
533 ///
534 /// Any non-paced input (an event, a signal write, a transition request,
535 /// pending change flags, …) makes [`is_paced_only_frame`] `false`, so the
536 /// frame runs immediately — pacing never delays real work.
537 ///
538 /// **The focus/IME edge tightens the tick back to the theme cap.** The one
539 /// wake input that rides *inside* a paced decision
540 /// ([`FrameInputs::focus_or_ime_changed`]) has its deferral bounded by the
541 /// interval in force, so honoring a long per-request interval on that tick
542 /// would stretch a focus/IME transition's repaint out to (say) a caret's
543 /// 500ms. Instead a tick carrying the edge paces at
544 /// [`FramePacing::interval`] — the theme's own cap — leaving the edge's
545 /// worst-case deferral exactly what it was before per-request intervals
546 /// existed: one `cosmetic_loop_rate` interval (33ms at the 30Hz default;
547 /// ≤100ms at `CosmeticLoopRate::FLOOR_HZ`). The cost is at most one extra
548 /// frame per focus/IME *transition* — an edge reporting one transition, not
549 /// a per-tick level (see `docs/LIMITATIONS.md`'s
550 /// `focus-ime-edge-paced-deferral`).
551 ///
552 /// That bound only holds end-to-end because the shells **peek** the edge:
553 /// the tightening itself resolves most edge-carrying ticks to `Skip` (the
554 /// anchor is typically one vsync old, well inside the cap), so a shell that
555 /// drained its generation cache at gather time would erase the edge on the
556 /// very tick the tightening deferred it, and the repaint would fall back to
557 /// the full [`FramePacing::effective_interval`]. The edge must keep being
558 /// reported until a tick actually runs — see
559 /// [`FrameInputs::focus_or_ime_changed`].
560 ///
561 /// [`CosmeticLoop`]: frust_core::TickClass::CosmeticLoop
562 /// [`Skip`]: FrameDecision::Skip
563 /// [`is_paced_only_frame`]: FrameInputs::is_paced_only_frame
564 pub fn decide_paced(&mut self, inputs: FrameInputs, pacing: FramePacing) -> FrameDecision {
565 self.decide_impl(inputs, Some(pacing))
566 }
567
568 /// The shared decision body behind [`decide`](Self::decide) (no pacing) and
569 /// [`decide_paced`](Self::decide_paced) (pacing context supplied).
570 fn decide_impl(&mut self, inputs: FrameInputs, pacing: Option<FramePacing>) -> FrameDecision {
571 if !self.enabled {
572 return FrameDecision::Run;
573 }
574 // Resume warmup: force a Run for the first WARMUP_FRAMES after a
575 // note_resumed(), independent of the FrameInputs the shell gathered.
576 let warming = self.warmup_remaining > 0;
577 if warming {
578 self.warmup_remaining -= 1;
579 }
580
581 // A paced-only frame (and pacing enabled, and not warming) is the sole
582 // throttleable case; every other Run resets the pace cadence to its
583 // own clock (see the anchor calls below).
584 let paced_case =
585 !warming && self.anim_pacing && pacing.is_some() && inputs.is_paced_only_frame();
586
587 if warming {
588 self.anchor_non_paced(pacing);
589 return FrameDecision::Run;
590 }
591
592 if paced_case {
593 let p = pacing.expect("paced_case implies pacing.is_some()");
594 // The interval in force for THIS tick: the theme cap folded with the
595 // previous paint's requested interval, tightened back to the bare cap
596 // when a focus/IME edge is riding along (see this method's docs — the
597 // edge's deferral bound must not widen with a slow per-request
598 // interval). The edge arm is literally `p.interval`: since
599 // `effective_interval() == max(cap, requested) >= p.interval`,
600 // tightening to the cap and `min`-ing with it are the same value —
601 // "ignore the request on an edge tick".
602 let interval = if inputs.focus_or_ime_changed {
603 p.interval
604 } else {
605 p.effective_interval()
606 };
607 return match self.last_paced_run {
608 // Inside the interval since the last produced frame: throttle.
609 // The shell leaves `last_needs_frame` set across a skip (no
610 // repaint re-latches it), so the gate keeps waking and never
611 // starves the loop.
612 Some(last) if p.now.saturating_sub(last) < interval => FrameDecision::Skip,
613 _ => {
614 self.anchor_paced(p.now, interval);
615 FrameDecision::Run
616 }
617 };
618 }
619
620 if inputs.any_set() {
621 // A non-paced Run (event/signal/transition/change-flag): reset the
622 // pace cadence to this real frame so a paced frame never fires
623 // immediately after one.
624 self.anchor_non_paced(pacing);
625 FrameDecision::Run
626 } else {
627 FrameDecision::Skip
628 }
629 }
630
631 /// Anchor the pace clock to this frame's exact clock — used for every
632 /// *non-paced* produced frame (warmup / event / transition), so the loop's
633 /// next interval is measured from the most recent real frame. Records the
634 /// interval that was in force too, so the next paced fire compares against a
635 /// current cadence rather than a stale one (see
636 /// [`last_paced_interval`](Self::last_paced_interval)).
637 fn anchor_non_paced(&mut self, pacing: Option<FramePacing>) {
638 if let Some(p) = pacing {
639 self.last_paced_run = Some(p.now);
640 self.last_paced_interval = Some(p.effective_interval());
641 }
642 }
643
644 /// Anchor the pace clock for a *paced* fire at `now`, for a loop running at
645 /// `interval`: advance by exactly one interval to hold a drift-free cadence
646 /// on the discrete vsync grid (anchoring to the raw `now`, which lands up to
647 /// a tick past the ideal fire time, would drift the effective rate below the
648 /// cap).
649 ///
650 /// Two cases re-anchor to `now` instead:
651 ///
652 /// - **A long stall** (≥ two intervals — a paused/resumed loop), so the loop
653 /// resumes at cadence rather than firing a catch-up burst.
654 /// - **A changed interval.** The drift-free arithmetic assumes a fixed
655 /// interval; with per-request intervals ([`FramePacing::requested_interval`])
656 /// the active one can change between paced frames, and advancing an anchor
657 /// laid down under the *old* cadence by the *new* interval is what produces
658 /// a double-fire on a shortening flip (the old anchor can already be
659 /// several new intervals in the past). Adopting `now` at the fire is both
660 /// the burst-free and the stall-free answer: the flip costs at most one
661 /// fresh interval of wait, never a missed cadence.
662 ///
663 /// **Overflow ceiling.** `interval` here always traces back to a widget's
664 /// [`frust_core::PaintCtx::request_frame_paced_at`], which clamps to
665 /// [`frust_core::PaintCtx::MAX_PACED_INTERVAL`] (10s) before it is ever
666 /// folded into `PaintOutcome::paced_interval` — so `interval * 2` and
667 /// `interval.as_nanos() as u64` below stay far below `Duration`/`u64`
668 /// overflow or truncation even at the widest legal input. This function
669 /// performs no clamp of its own; it relies entirely on that upstream
670 /// bound, the single entry point every paced interval flows through.
671 fn anchor_paced(&mut self, now: FrameTime, interval: std::time::Duration) {
672 let next = match (self.last_paced_run, self.last_paced_interval) {
673 (Some(last), Some(previous))
674 if previous == interval && now.saturating_sub(last) < interval * 2 =>
675 {
676 FrameTime::from_nanos(last.as_nanos().saturating_add(interval.as_nanos() as u64))
677 }
678 _ => now,
679 };
680 self.last_paced_run = Some(next);
681 self.last_paced_interval = Some(interval);
682 }
683}
684
685impl Default for FrameGate {
686 fn default() -> Self {
687 Self::new()
688 }
689}
690
691/// Disables the per-pod layout skip when set to any value
692/// (`RenderRoot::set_layout_skip(false)`): every pod lays out on every pass.
693pub const NO_LAYOUT_SKIP_VAR: &str = "FRUST_NO_LAYOUT_SKIP";
694
695/// Turns the layout audit on (`RenderRoot::set_layout_audit(true)`) when set to
696/// a value other than `"0"`. A debug tool: it runs a second, full layout pass
697/// per frame.
698pub const LAYOUT_AUDIT_VAR: &str = "FRUST_LAYOUT_AUDIT";
699
700/// Disables retained layers when set to any value
701/// (`RenderRoot::set_retained_layers(false)`): `PaintCtx::subtree_clean` and
702/// `PaintCtx::retained_available` always answer `false`, so a boundary widget
703/// repaints its body every frame.
704pub const NO_RETAINED_LAYERS_VAR: &str = "FRUST_NO_RETAINED_LAYERS";
705
706/// Where one knob's switch variable was found: the compile-time define
707/// (`option_env!`) and/or the process environment. `Default` is "set nowhere".
708#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
709pub struct KnobSource<'a> {
710 /// The compile-time define, if any.
711 pub compile: Option<&'a str>,
712 /// The runtime environment value, if any.
713 pub runtime: Option<&'a str>,
714}
715
716impl<'a> KnobSource<'a> {
717 /// Set only by the compile-time define.
718 pub fn compile(value: &'a str) -> Self {
719 Self {
720 compile: Some(value),
721 runtime: None,
722 }
723 }
724
725 /// Set only by the runtime environment.
726 pub fn runtime(value: &'a str) -> Self {
727 Self {
728 compile: None,
729 runtime: Some(value),
730 }
731 }
732}
733
734/// The layout-skip and retained-layer knobs a shell resolves once at startup and applies to
735/// its `RenderRoot` (directly, or through [`crate::AppTree`]).
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub struct LayoutKnobs {
738 /// Value for `RenderRoot::set_layout_skip` (`false` when
739 /// [`NO_LAYOUT_SKIP_VAR`] is set).
740 pub skip: bool,
741 /// Value for `RenderRoot::set_layout_audit` (`true` when
742 /// [`LAYOUT_AUDIT_VAR`] is set non-`"0"`).
743 pub audit: bool,
744 /// Value for `RenderRoot::set_retained_layers` (`false` when
745 /// [`NO_RETAINED_LAYERS_VAR`] is set).
746 pub retained: bool,
747}
748
749impl LayoutKnobs {
750 /// Resolve the knobs from the compile-time defines and the process
751 /// environment (the same `option_env!` + runtime-env shape as the other
752 /// `FRUST_*` switches; Android/iOS have no useful runtime env, so the
753 /// compile-time define is their route).
754 pub fn from_env() -> Self {
755 let skip_var = std::env::var(NO_LAYOUT_SKIP_VAR).ok();
756 let audit_var = std::env::var(LAYOUT_AUDIT_VAR).ok();
757 let retained_var = std::env::var(NO_RETAINED_LAYERS_VAR).ok();
758 Self::resolve(
759 KnobSource {
760 compile: option_env!("FRUST_NO_LAYOUT_SKIP"),
761 runtime: skip_var.as_deref(),
762 },
763 KnobSource {
764 compile: option_env!("FRUST_LAYOUT_AUDIT"),
765 runtime: audit_var.as_deref(),
766 },
767 KnobSource {
768 compile: option_env!("FRUST_NO_RETAINED_LAYERS"),
769 runtime: retained_var.as_deref(),
770 },
771 )
772 }
773
774 /// The pure resolution [`Self::from_env`] wraps, unit-testable without
775 /// touching the process environment. Each argument names where one knob's
776 /// switch variable was found: `no_skip` ([`NO_LAYOUT_SKIP_VAR`]), `audit`
777 /// ([`LAYOUT_AUDIT_VAR`]) and `no_retained` ([`NO_RETAINED_LAYERS_VAR`]).
778 pub fn resolve(no_skip: KnobSource, audit: KnobSource, no_retained: KnobSource) -> Self {
779 Self {
780 skip: no_skip.compile.is_none() && no_skip.runtime.is_none(),
781 audit: kill_switch(audit.compile, audit.runtime),
782 retained: no_retained.compile.is_none() && no_retained.runtime.is_none(),
783 }
784 }
785
786 /// Apply to a type-erased app and log one info line.
787 pub fn apply_to_app(self, app: &mut dyn crate::AppTree) {
788 app.set_layout_skip(self.skip);
789 app.set_layout_audit(self.audit);
790 app.set_retained_layers(self.retained);
791 self.log();
792 }
793
794 /// Log the resolved knobs at info, once.
795 pub fn log(self) {
796 log::info!(
797 "layout skip={} audit={} retained={}",
798 self.skip,
799 self.audit,
800 self.retained
801 );
802 }
803}
804
805/// Reads the [`NO_FRAME_GATE_VAR`] kill switch from the compile-time define
806/// and the process environment, mirroring [`crate::perf::enabled`]'s
807/// `option_env!` + runtime-env pattern: either source set to a non-`"0"`
808/// value engages the switch.
809fn kill_switch_engaged() -> bool {
810 kill_switch(
811 option_env!("FRUST_NO_FRAME_GATE"),
812 std::env::var(NO_FRAME_GATE_VAR).ok().as_deref(),
813 )
814}
815
816/// Reads the [`NO_ANIM_PACING_VAR`] kill switch from the compile-time define and
817/// the process environment, the same `option_env!` + runtime-env shape as
818/// [`kill_switch_engaged`]. Public so a non-`FrameGate` consumer (the desktop
819/// shell, which paces via a delayed redraw rather than a skip gate) can honor
820/// the same switch. Either source set to a non-`"0"` value engages it.
821pub fn anim_pacing_kill_switch_engaged() -> bool {
822 kill_switch(
823 option_env!("FRUST_NO_ANIM_PACING"),
824 std::env::var(NO_ANIM_PACING_VAR).ok().as_deref(),
825 )
826}
827
828/// The pure decision [`kill_switch_engaged`] wraps: a non-empty, non-`"0"`
829/// value from either the compile-time or runtime source engages the switch.
830/// Split out so it is directly unit-testable without touching the process
831/// environment (see [`crate::perf`]'s `trace_switch`).
832fn kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
833 fn is_set_non_zero(value: Option<&str>) -> bool {
834 matches!(value, Some(v) if v != "0")
835 }
836 is_set_non_zero(compile_time) || is_set_non_zero(runtime)
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn layout_knobs_resolve() {
845 let unset = KnobSource::default();
846 let d = LayoutKnobs::resolve(unset, unset, unset);
847 assert!(d.skip && !d.audit && d.retained);
848 assert!(!LayoutKnobs::resolve(KnobSource::runtime("1"), unset, unset).skip);
849 assert!(!LayoutKnobs::resolve(KnobSource::compile("0"), unset, unset).skip);
850 assert!(LayoutKnobs::resolve(unset, KnobSource::runtime("1"), unset).audit);
851 assert!(!LayoutKnobs::resolve(unset, KnobSource::runtime("0"), unset).audit);
852 }
853
854 #[test]
855 fn retained_knob_resolves_from_either_source() {
856 let unset = KnobSource::default();
857 // Set to any value (like the layout-skip kill switch) turns it off.
858 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::runtime("1")).retained);
859 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::compile("1")).retained);
860 assert!(!LayoutKnobs::resolve(unset, unset, KnobSource::runtime("0")).retained);
861 // The other knobs are independent of it.
862 let k = LayoutKnobs::resolve(unset, unset, KnobSource::runtime("1"));
863 assert!(k.skip && !k.audit);
864 assert!(LayoutKnobs::resolve(KnobSource::runtime("1"), unset, unset).retained);
865 }
866
867 /// A `FrameInputs` with exactly one field set, by name — the driver for
868 /// the exhaustive per-input table test below.
869 fn only(field: &str) -> FrameInputs {
870 let mut i = FrameInputs::default();
871 match field {
872 "signals_dirty" => i.signals_dirty = true,
873 "events_since_last_frame" => i.events_since_last_frame = true,
874 "pointer_capture_active" => i.pointer_capture_active = true,
875 "focus_or_ime_changed" => i.focus_or_ime_changed = true,
876 "last_needs_frame" => i.last_needs_frame = true,
877 "last_needs_frame_paced_only" => i.last_needs_frame_paced_only = true,
878 "change_flags_pending" => i.change_flags_pending = true,
879 "deferred_callbacks_pending" => i.deferred_callbacks_pending = true,
880 "theme_or_appearance_changed" => i.theme_or_appearance_changed = true,
881 "surface_changed_or_resized" => i.surface_changed_or_resized = true,
882 "a11y_action_performed" => i.a11y_action_performed = true,
883 "resumed_recently" => i.resumed_recently = true,
884 other => panic!("unknown FrameInputs field {other:?}"),
885 }
886 i
887 }
888
889 /// Every input field, so the table test below is exhaustive by
890 /// construction — adding a field without listing it here fails the count
891 /// assertion.
892 const ALL_INPUTS: &[&str] = &[
893 "signals_dirty",
894 "events_since_last_frame",
895 "pointer_capture_active",
896 "focus_or_ime_changed",
897 "last_needs_frame",
898 "last_needs_frame_paced_only",
899 "change_flags_pending",
900 "deferred_callbacks_pending",
901 "theme_or_appearance_changed",
902 "surface_changed_or_resized",
903 "a11y_action_performed",
904 "resumed_recently",
905 ];
906
907 // -----------------------------------------------------------------
908 // kill_switch (pure — the env-reading wrapper's cache-free counterpart)
909 // -----------------------------------------------------------------
910
911 #[test]
912 fn kill_switch_off_when_neither_set() {
913 assert!(!kill_switch(None, None));
914 }
915
916 #[test]
917 fn kill_switch_on_when_compile_time_set_non_zero() {
918 assert!(kill_switch(Some("1"), None));
919 }
920
921 #[test]
922 fn kill_switch_on_when_runtime_set_non_zero() {
923 assert!(kill_switch(None, Some("1")));
924 }
925
926 #[test]
927 fn kill_switch_off_when_either_is_literal_zero_and_other_unset() {
928 assert!(!kill_switch(Some("0"), None));
929 assert!(!kill_switch(None, Some("0")));
930 }
931
932 #[test]
933 fn kill_switch_on_when_either_source_wins() {
934 assert!(kill_switch(Some("0"), Some("1")));
935 assert!(kill_switch(Some("1"), Some("0")));
936 }
937
938 // -----------------------------------------------------------------
939 // decide: the OR-list table
940 // -----------------------------------------------------------------
941
942 #[test]
943 fn each_single_input_forces_run() {
944 // Exhaustive: every field, set alone on a fresh (non-warming) gate,
945 // must force a Run — including the two that changed shape here: the
946 // focus/IME EDGE (`focus_or_ime_changed`, which still forces one frame
947 // per transition even though it no longer blocks pacing) and the
948 // deferred-callback peek (`deferred_callbacks_pending`).
949 assert_eq!(
950 ALL_INPUTS.len(),
951 12,
952 "the OR-list must have all eleven wake inputs plus the \
953 paced-only signal"
954 );
955 for field in ALL_INPUTS {
956 let mut gate = FrameGate::with_enabled(true);
957 let decision = gate.decide(only(field));
958 assert_eq!(
959 decision,
960 FrameDecision::Run,
961 "input {field:?} alone must force a Run"
962 );
963 }
964 }
965
966 #[test]
967 fn all_false_inputs_skip() {
968 let mut gate = FrameGate::with_enabled(true);
969 assert_eq!(
970 gate.decide(FrameInputs::default()),
971 FrameDecision::Skip,
972 "no input set (and no warmup) must skip"
973 );
974 }
975
976 #[test]
977 fn any_set_matches_decide_for_all_false() {
978 let inputs = FrameInputs::default();
979 assert!(!inputs.any_set());
980 }
981
982 // -----------------------------------------------------------------
983 // decide: resume warmup countdown
984 // -----------------------------------------------------------------
985
986 #[test]
987 fn warmup_forces_run_for_n_frames_then_skips() {
988 let mut gate = FrameGate::with_enabled(true);
989 gate.note_resumed();
990 assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES);
991
992 // All-false inputs: only the warmup keeps these frames running.
993 for frame in 0..WARMUP_FRAMES {
994 assert_eq!(
995 gate.decide(FrameInputs::default()),
996 FrameDecision::Run,
997 "warmup frame {frame} must run despite no inputs"
998 );
999 }
1000 // Warmup exhausted: the next all-false frame skips.
1001 assert_eq!(gate.warmup_remaining(), 0);
1002 assert_eq!(
1003 gate.decide(FrameInputs::default()),
1004 FrameDecision::Skip,
1005 "after the warmup window, an all-false frame skips again"
1006 );
1007 }
1008
1009 #[test]
1010 fn note_resumed_reopens_the_warmup_window() {
1011 let mut gate = FrameGate::with_enabled(true);
1012 gate.note_resumed();
1013 for _ in 0..WARMUP_FRAMES {
1014 gate.decide(FrameInputs::default());
1015 }
1016 assert_eq!(gate.warmup_remaining(), 0);
1017 // A second resume (e.g. surface recreated after backgrounding) reopens
1018 // the window.
1019 gate.note_resumed();
1020 assert_eq!(
1021 gate.decide(FrameInputs::default()),
1022 FrameDecision::Run,
1023 "a fresh note_resumed reopens the warmup window"
1024 );
1025 }
1026
1027 // -----------------------------------------------------------------
1028 // Kill switch / disabled
1029 // -----------------------------------------------------------------
1030
1031 #[test]
1032 fn disabled_gate_always_runs() {
1033 let mut gate = FrameGate::disabled();
1034 assert!(!gate.is_enabled());
1035 // All-false inputs, no warmup: a disabled gate still runs.
1036 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
1037 // And keeps running frame after frame.
1038 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Run);
1039 }
1040
1041 #[test]
1042 fn enabled_gate_can_skip() {
1043 let mut gate = FrameGate::with_enabled(true);
1044 assert!(gate.is_enabled());
1045 assert_eq!(gate.decide(FrameInputs::default()), FrameDecision::Skip);
1046 }
1047
1048 #[test]
1049 fn frame_decision_predicates() {
1050 assert!(FrameDecision::Run.is_run());
1051 assert!(!FrameDecision::Run.is_skip());
1052 assert!(FrameDecision::Skip.is_skip());
1053 assert!(!FrameDecision::Skip.is_run());
1054 }
1055
1056 // -----------------------------------------------------------------
1057 // decide_paced: animation pacing
1058 // -----------------------------------------------------------------
1059
1060 use std::time::Duration;
1061
1062 /// One vsync step of a `hz`-Hz refresh, in nanoseconds.
1063 fn step_nanos(hz: f64) -> u64 {
1064 (1_000_000_000.0 / hz) as u64
1065 }
1066
1067 /// The paced-only input: a prior paced (CosmeticLoop) frame request with
1068 /// every other OR-list signal clear — the sole case pacing throttles.
1069 fn paced_only() -> FrameInputs {
1070 FrameInputs {
1071 last_needs_frame: true,
1072 last_needs_frame_paced_only: true,
1073 ..FrameInputs::default()
1074 }
1075 }
1076
1077 /// A 30Hz cosmetic-loop interval, the framework default.
1078 fn interval_30hz() -> Duration {
1079 Duration::from_secs_f64(1.0 / 30.0)
1080 }
1081
1082 /// A pacing context naming no per-request interval: the theme cap alone —
1083 /// what every `request_frame_paced` (interval-less) caller produces, and the
1084 /// shape every pre-`request_frame_paced_at` test in this module drives.
1085 fn pacing(now: FrameTime, interval: Duration) -> FramePacing {
1086 FramePacing {
1087 now,
1088 interval,
1089 requested_interval: None,
1090 }
1091 }
1092
1093 /// A pacing context carrying a per-request interval, as a shell latches it
1094 /// from the previous paint's `PaintOutcome::paced_interval`.
1095 fn pacing_at(now: FrameTime, interval: Duration, requested: Duration) -> FramePacing {
1096 FramePacing {
1097 now,
1098 interval,
1099 requested_interval: Some(requested),
1100 }
1101 }
1102
1103 /// A ~2Hz caret blink — the motivating per-request interval, deliberately
1104 /// far slower than any theme's cosmetic-loop cap.
1105 fn interval_500ms() -> Duration {
1106 Duration::from_millis(500)
1107 }
1108
1109 #[test]
1110 fn is_paced_only_frame_requires_last_needs_frame_and_no_other_input() {
1111 assert!(paced_only().is_paced_only_frame());
1112 // paced flag without last_needs_frame is not a paced-only frame.
1113 let only_flag = FrameInputs {
1114 last_needs_frame_paced_only: true,
1115 ..FrameInputs::default()
1116 };
1117 assert!(!only_flag.is_paced_only_frame());
1118 // any concurrent input disqualifies pacing.
1119 let mut with_event = paced_only();
1120 with_event.events_since_last_frame = true;
1121 assert!(!with_event.is_paced_only_frame());
1122 // ...including the deferred-callback peek, which owes a rebuild.
1123 let mut with_flush = paced_only();
1124 with_flush.deferred_callbacks_pending = true;
1125 assert!(!with_flush.is_paced_only_frame());
1126
1127 // THE POINT OF THE EDGE: a STEADY focus session (a caret blinking in a
1128 // focused field — the edge is false because nothing moved since the
1129 // last tick) alongside a paced-only request now PACES. As the old level
1130 // input (`focus_or_ime_active`, true for the whole session) this was
1131 // disqualified, so a focused screen re-rendered every vsync forever and
1132 // the pacing arm was unreachable — 62–120 fps measured on a Xiaomi 12.
1133 let steady_focus = FrameInputs {
1134 focus_or_ime_changed: false,
1135 ..paced_only()
1136 };
1137 assert!(
1138 steady_focus.is_paced_only_frame(),
1139 "a steady focus session must not block paced-loop throttling"
1140 );
1141 let mut gate = FrameGate::with_flags(true, true);
1142 let interval = interval_30hz();
1143 // First paced frame runs (nothing to pace against yet) and anchors...
1144 assert!(
1145 gate.decide_paced(steady_focus, pacing(FrameTime::from_nanos(0), interval),)
1146 .is_run()
1147 );
1148 // ...and the very next 120Hz tick, still inside the 30Hz interval, is
1149 // throttled — the behavior a focused screen could never reach before.
1150 assert_eq!(
1151 gate.decide_paced(
1152 steady_focus,
1153 pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
1154 ),
1155 FrameDecision::Skip,
1156 "a paced loop under a steady focus session must throttle to the cap"
1157 );
1158
1159 // The transition edge itself is deliberately NOT a disqualifier (see
1160 // `is_paced_only_frame`'s docs): it is one-shot, so the repaint it asks
1161 // for lands with the loop's next paced frame rather than immediately.
1162 let edge_mid_loop = FrameInputs {
1163 focus_or_ime_changed: true,
1164 ..paced_only()
1165 };
1166 assert!(edge_mid_loop.is_paced_only_frame());
1167 // With no paced loop in flight it forces a Run like every other input
1168 // (`each_single_input_forces_run` above covers the isolated case).
1169 assert!(
1170 FrameInputs {
1171 focus_or_ime_changed: true,
1172 ..FrameInputs::default()
1173 }
1174 .any_set()
1175 );
1176 }
1177
1178 // -----------------------------------------------------------------
1179 // The focus/IME EDGE, driven the way a shell drives it
1180 // -----------------------------------------------------------------
1181
1182 /// The shells' cache mechanics, verbatim, in the two halves both mobile
1183 /// shells actually run: [`peek`](FocusEdgeCache::peek) at gather time (a
1184 /// non-mutating `generation != last_seen`, the value that feeds
1185 /// [`FrameInputs::focus_or_ime_changed`]) and
1186 /// [`commit`](FocusEdgeCache::commit) as the first statement past the
1187 /// `decide_paced(..).is_skip()` early return, i.e. only on a frame that
1188 /// actually runs.
1189 ///
1190 /// The split is the contract, not an implementation detail: this is the one
1191 /// OR-list input that is both consumed-on-read and *not* an
1192 /// [`FrameInputs::is_paced_only_frame`] disqualifier, so committing it at
1193 /// gather time erases any edge the pacing defers.
1194 ///
1195 /// Both mobile shells are target-gated (`#[cfg(target_os = ...)]`) so their
1196 /// own copies never compile on the host — this stands in for them, with
1197 /// `frust-core`'s generation tests (`focus_ime_generation_*`) pinning the
1198 /// other half: that the generation moves exactly once per real focus/IME
1199 /// transition and never on a same-value write.
1200 struct FocusEdgeCache {
1201 last_seen: u64,
1202 }
1203 impl FocusEdgeCache {
1204 fn new(seed: u64) -> Self {
1205 Self { last_seen: seed }
1206 }
1207 /// Gather-time peek: "has the session moved since the last frame this
1208 /// shell produced?" — never mutates, so a skipped tick keeps reporting
1209 /// the same pending edge.
1210 fn peek(&self, generation: u64) -> bool {
1211 generation != self.last_seen
1212 }
1213 /// Run-time commit: the edge has now been consumed by a frame that is
1214 /// actually being produced.
1215 fn commit(&mut self, generation: u64) {
1216 self.last_seen = generation;
1217 }
1218 }
1219
1220 #[test]
1221 fn focus_edge_fires_once_per_transition_and_goes_quiet() {
1222 // A scripted focus/IME session as `RenderRoot::focus_ime_generation`
1223 // reports it: idle, focus gained, steady blinking, IME published,
1224 // steady typing pause, IME cleared, blur, idle again. Each transition
1225 // bumps the generation exactly once; the steady stretches repeat it.
1226 let script: &[(u64, bool, &str)] = &[
1227 (0, false, "idle before anything is focused"),
1228 (0, false, "still idle"),
1229 (1, true, "focus gained"),
1230 (1, false, "steady focus (caret blinking)"),
1231 (1, false, "still steady"),
1232 (2, true, "IME surface published"),
1233 (2, false, "steady IME session"),
1234 (3, true, "IME surface cleared"),
1235 (4, true, "focus lost (blur)"),
1236 (4, false, "idle again"),
1237 ];
1238 let mut cache = FocusEdgeCache::new(0);
1239 let mut gate = FrameGate::with_enabled(true);
1240 for (generation, expected_edge, what) in script {
1241 let inputs = FrameInputs {
1242 focus_or_ime_changed: cache.peek(*generation),
1243 ..FrameInputs::default()
1244 };
1245 assert_eq!(
1246 inputs.focus_or_ime_changed, *expected_edge,
1247 "edge for {what:?}"
1248 );
1249 // With every other input clear, the decision follows the edge
1250 // exactly: one Run per transition, Skip through each steady stretch
1251 // — where the old level input ran every single tick.
1252 let expect = if *expected_edge {
1253 FrameDecision::Run
1254 } else {
1255 FrameDecision::Skip
1256 };
1257 let decision = gate.decide(inputs);
1258 assert_eq!(decision, expect, "decision for {what:?}");
1259 // The shells' commit site: only a produced frame consumes the edge.
1260 // Here every edge tick Runs (nothing paces it), so the cache
1261 // advances on exactly the transitions — one repaint each.
1262 if decision.is_run() {
1263 cache.commit(*generation);
1264 }
1265 }
1266 }
1267
1268 #[test]
1269 fn a_long_steady_focus_session_produces_no_frames() {
1270 // The regression this task closes, in the shape the device showed it: a
1271 // static screen holding focus, nothing else dirty, over a long tick
1272 // stream. The generation never moves, so the gate must produce ZERO
1273 // frames (it produced one per vsync — 62–120 fps — as a level input).
1274 let mut cache = FocusEdgeCache::new(7);
1275 let mut gate = FrameGate::with_enabled(true);
1276 let mut runs = 0usize;
1277 for _ in 0..600 {
1278 let inputs = FrameInputs {
1279 focus_or_ime_changed: cache.peek(7),
1280 ..FrameInputs::default()
1281 };
1282 if gate.decide(inputs).is_run() {
1283 cache.commit(7);
1284 runs += 1;
1285 }
1286 }
1287 assert_eq!(runs, 0, "a steady focus session must produce no frames");
1288 }
1289
1290 #[test]
1291 fn a_focus_edge_persists_across_paced_skips_and_lands_within_one_cap_interval() {
1292 // The end-to-end contract, driven exactly the way a mobile shell's
1293 // `frame()` drives it: PEEK the generation into `FrameInputs`, decide,
1294 // and commit the cache only past the `is_skip()` early return.
1295 //
1296 // Shape: a 120Hz tick stream, a caret loop naming its own 500ms cadence
1297 // (`PaintCtx::request_frame_paced_at`), and a focus/IME transition
1298 // landing one vsync into that interval. The edge tightening resolves
1299 // that tick to Skip — the loop's anchor is one tick old, far inside the
1300 // 33ms cap — which is precisely why the edge must NOT be consumed there:
1301 // a shell draining its cache at gather time erases it, and the repaint
1302 // then falls through to the caret's full 500ms interval (the pre-fix
1303 // behaviour, contrasted at the end of this test).
1304 let tick = step_nanos(120.0);
1305 let cap = interval_30hz();
1306 let at = |i: u64| FrameTime::from_nanos(i * tick);
1307 let caret = |i: u64| FramePacing {
1308 now: at(i),
1309 interval: cap,
1310 requested_interval: Some(interval_500ms()),
1311 };
1312 let inputs = |edge: bool| FrameInputs {
1313 focus_or_ime_changed: edge,
1314 ..paced_only()
1315 };
1316
1317 let mut gate = FrameGate::with_flags(true, true);
1318 let mut cache = FocusEdgeCache::new(5);
1319
1320 // Tick 0: the caret loop's own first paced frame, no edge — it anchors
1321 // the pace clock at t=0.
1322 assert!(!cache.peek(5));
1323 assert!(gate.decide_paced(inputs(false), caret(0)).is_run());
1324 cache.commit(5);
1325
1326 // The transition happens: the generation moves 5 -> 6. Every tick from
1327 // here until the loop's next produced frame PEEKS the same still-pending
1328 // edge — the persistence a drain-on-gather shell (and the pre-fix
1329 // `gather` helper this suite used) could not express — and the frame the
1330 // edge asked for lands on the first tick that runs, which is where it is
1331 // finally consumed.
1332 let mut landed_tick = None;
1333 for i in 1..120u64 {
1334 assert!(
1335 cache.peek(6),
1336 "the edge must still be pending at tick {i} — a Skip consumes nothing"
1337 );
1338 if gate.decide_paced(inputs(true), caret(i)).is_run() {
1339 cache.commit(6);
1340 landed_tick = Some(i);
1341 break;
1342 }
1343 }
1344 let landed_tick =
1345 landed_tick.expect("the deferred edge must be consumed by a tick that runs");
1346 let landed_ns = landed_tick * tick;
1347 assert!(
1348 landed_ns <= cap.as_nanos() as u64 + tick,
1349 "the edge repaint must land within one cap interval of the loop's \
1350 last produced frame (plus one tick of 120Hz grid rounding); landed \
1351 at {landed_ns}ns"
1352 );
1353 assert!(
1354 landed_ns < interval_500ms().as_nanos() as u64,
1355 "…and nowhere near the caret's own 500ms request"
1356 );
1357
1358 // Consumed exactly once: the next tick reports no edge, and the loop
1359 // goes straight back to its own slow cadence (the tightening is scoped
1360 // to the edge tick alone).
1361 assert!(!cache.peek(6), "a produced frame clears the edge");
1362 assert_eq!(
1363 gate.decide_paced(inputs(false), caret(landed_tick + 1)),
1364 FrameDecision::Skip
1365 );
1366
1367 // The contrast that makes the shell-side split load-bearing: the same
1368 // stream with the pre-fix shape — the generation cache committed at
1369 // GATHER time, whatever the decision — loses the edge on the very first
1370 // skipped tick, so the repaint waits out the caret's 500ms request.
1371 let mut drained_gate = FrameGate::with_flags(true, true);
1372 let mut drained_cache = FocusEdgeCache::new(5);
1373 let mut first_run_after_bump: Option<u64> = None;
1374 for i in 0..120u64 {
1375 let generation = if i >= 1 { 6 } else { 5 };
1376 let edge = drained_cache.peek(generation);
1377 drained_cache.commit(generation); // the pre-fix drain: unconditional
1378 if drained_gate.decide_paced(inputs(edge), caret(i)).is_run() && i >= 1 {
1379 first_run_after_bump = Some(i * tick);
1380 break;
1381 }
1382 }
1383 assert!(
1384 first_run_after_bump.is_some_and(|ns| ns >= interval_500ms().as_nanos() as u64),
1385 "drain-on-gather erases the deferred edge, so the repaint falls \
1386 through to the 500ms per-request interval; observed \
1387 {first_run_after_bump:?}ns"
1388 );
1389 }
1390
1391 #[test]
1392 fn a_warmup_run_commits_the_focus_edge_too() {
1393 // The commit-on-Run rule keys off `is_skip()` and nothing else, so it
1394 // needs no special-casing for the warmup path — which returns `Run`
1395 // *before* `any_set()`/`is_paced_only_frame` are ever consulted. A
1396 // pending edge riding a warmup frame is therefore consumed by it, once.
1397 let cap = interval_30hz();
1398 let mut gate = FrameGate::with_flags(true, true);
1399 let mut cache = FocusEdgeCache::new(1);
1400 gate.note_resumed();
1401
1402 // A focus/IME transition landing on the first post-resume tick.
1403 assert!(cache.peek(2));
1404 let decision = gate.decide_paced(
1405 FrameInputs {
1406 focus_or_ime_changed: cache.peek(2),
1407 ..paced_only()
1408 },
1409 pacing(FrameTime::from_nanos(0), cap),
1410 );
1411 assert_eq!(
1412 decision,
1413 FrameDecision::Run,
1414 "the resume warmup forces this frame to run"
1415 );
1416 assert_eq!(gate.warmup_remaining(), WARMUP_FRAMES - 1);
1417 // A Run is a Run: the shell commits here exactly as on any other.
1418 assert!(decision.is_run());
1419 cache.commit(2);
1420
1421 // Consumed once — the rest of the warmup window still runs (that is the
1422 // warmup's job), but it no longer carries an edge.
1423 assert!(!cache.peek(2), "the warmup frame consumed the edge");
1424 for i in 1..=u64::from(WARMUP_FRAMES - 1) {
1425 assert!(!cache.peek(2));
1426 assert!(
1427 gate.decide_paced(
1428 FrameInputs {
1429 focus_or_ime_changed: cache.peek(2),
1430 ..paced_only()
1431 },
1432 pacing(FrameTime::from_nanos(i * step_nanos(120.0)), cap),
1433 )
1434 .is_run()
1435 );
1436 cache.commit(2);
1437 }
1438 assert_eq!(gate.warmup_remaining(), 0);
1439 }
1440
1441 // -----------------------------------------------------------------
1442 // The deferred-callback flush peek
1443 // -----------------------------------------------------------------
1444
1445 #[test]
1446 fn a_marked_flush_alone_forces_a_run() {
1447 // The stall this input closes: a deferred state-bearing callback is
1448 // owed a `Housekeeping` broadcast that only `RenderRoot::rebuild` can
1449 // dispatch, and NOTHING else is dirty. Without this input the gate
1450 // skips, the rebuild never runs, and the callback waits for whatever
1451 // touch happens to arrive next.
1452 let mut gate = FrameGate::with_enabled(true);
1453 let owed = FrameInputs {
1454 deferred_callbacks_pending: true,
1455 ..FrameInputs::default()
1456 };
1457 assert_eq!(gate.decide(owed), FrameDecision::Run);
1458 // And it keeps forcing frames until the drain clears the mark — the
1459 // shell re-peeks every tick, so the input simply goes false.
1460 assert_eq!(gate.decide(owed), FrameDecision::Run);
1461 assert_eq!(
1462 gate.decide(FrameInputs::default()),
1463 FrameDecision::Skip,
1464 "once the rebuild drained the mark the gate idles again"
1465 );
1466 }
1467
1468 #[test]
1469 fn paced_only_stream_runs_at_the_cap_not_every_vsync() {
1470 // A 120Hz tick stream feeding paced-only inputs, capped at 30Hz, must
1471 // produce ~30 runs per simulated second (one every ~4 ticks).
1472 let mut gate = FrameGate::with_flags(true, true);
1473 let tick = step_nanos(120.0);
1474 let interval = interval_30hz();
1475
1476 let mut runs = 0usize;
1477 // 120 ticks == 1 simulated second.
1478 for i in 0..120u64 {
1479 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1480 if gate.decide_paced(paced_only(), pacing).is_run() {
1481 runs += 1;
1482 }
1483 }
1484 // 30Hz cap over 1s ⇒ ~30 runs. Allow ±2 for boundary rounding of the
1485 // 120→30 tick ratio.
1486 assert!(
1487 (28..=32).contains(&runs),
1488 "paced-only 120Hz stream should run ~30x/s, ran {runs}"
1489 );
1490 }
1491
1492 #[test]
1493 fn transition_input_mid_interval_runs_immediately() {
1494 // Pace a loop, then a transition (paced_only == false) arrives inside
1495 // the interval — it must run immediately, never wait for the cap.
1496 let mut gate = FrameGate::with_flags(true, true);
1497 let interval = interval_30hz();
1498
1499 // First paced frame runs and anchors the pace clock at t=0.
1500 let run0 = gate.decide_paced(paced_only(), pacing(FrameTime::from_nanos(0), interval));
1501 assert!(run0.is_run());
1502 // A tick well inside the 33ms interval, but carrying a transition
1503 // request (paced-only flag cleared) — runs immediately.
1504 let mut transition = FrameInputs {
1505 last_needs_frame: true,
1506 last_needs_frame_paced_only: false,
1507 ..FrameInputs::default()
1508 };
1509 // sanity: this is NOT a paced-only frame
1510 assert!(!transition.is_paced_only_frame());
1511 let decision = gate.decide_paced(
1512 transition,
1513 pacing(FrameTime::from_nanos(step_nanos(120.0)), interval),
1514 );
1515 assert_eq!(
1516 decision,
1517 FrameDecision::Run,
1518 "a transition mid-interval must run immediately, not pace"
1519 );
1520 // A plain event mid-interval likewise runs immediately.
1521 transition = FrameInputs {
1522 events_since_last_frame: true,
1523 ..FrameInputs::default()
1524 };
1525 assert_eq!(
1526 gate.decide_paced(
1527 transition,
1528 pacing(FrameTime::from_nanos(2 * step_nanos(120.0)), interval),
1529 ),
1530 FrameDecision::Run,
1531 "an event mid-interval must run immediately"
1532 );
1533 }
1534
1535 #[test]
1536 fn paced_skip_leaves_the_request_alive_and_never_starves() {
1537 // A long paced-only stream must keep producing frames at the cap — it
1538 // never stops running entirely (starvation guard).
1539 let mut gate = FrameGate::with_flags(true, true);
1540 let tick = step_nanos(120.0);
1541 let interval = interval_30hz();
1542
1543 let mut last_run_tick: Option<u64> = None;
1544 let mut max_gap = 0u64;
1545 let mut total_runs = 0usize;
1546 for i in 0..600u64 {
1547 // 5 simulated seconds
1548 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1549 if gate.decide_paced(paced_only(), pacing).is_run() {
1550 total_runs += 1;
1551 if let Some(prev) = last_run_tick {
1552 max_gap = max_gap.max(i - prev);
1553 }
1554 last_run_tick = Some(i);
1555 }
1556 }
1557 assert!(
1558 total_runs > 0,
1559 "paced stream must keep running (no starvation)"
1560 );
1561 // The gap between runs stays bounded near the 4-tick cap ratio — never
1562 // an unbounded stall.
1563 assert!(
1564 max_gap <= 5,
1565 "paced runs must stay periodic; observed max gap of {max_gap} ticks"
1566 );
1567 }
1568
1569 #[test]
1570 fn anim_pacing_kill_switch_runs_every_tick() {
1571 // Gate enabled (skips still work) but pacing disabled: a paced-only
1572 // stream runs every vsync, pre-pacing behavior.
1573 let mut gate = FrameGate::with_flags(true, false);
1574 let tick = step_nanos(120.0);
1575 let interval = interval_30hz();
1576 for i in 0..120u64 {
1577 let pacing = pacing(FrameTime::from_nanos(i * tick), interval);
1578 assert_eq!(
1579 gate.decide_paced(paced_only(), pacing),
1580 FrameDecision::Run,
1581 "with pacing disabled every paced tick must run"
1582 );
1583 }
1584 }
1585
1586 #[test]
1587 fn disabled_gate_ignores_pacing() {
1588 // FRUST_NO_FRAME_GATE (disabled gate) forces Run regardless of pacing.
1589 let mut gate = FrameGate::disabled();
1590 for i in 0..10u64 {
1591 let pacing = pacing(
1592 FrameTime::from_nanos(i * step_nanos(120.0)),
1593 interval_30hz(),
1594 );
1595 assert_eq!(gate.decide_paced(paced_only(), pacing), FrameDecision::Run);
1596 }
1597 }
1598
1599 #[test]
1600 fn non_paced_decide_runs_paced_only_every_tick() {
1601 // The non-pacing `decide` entry never throttles: a paced-only frame is
1602 // just another `any_set` Run (conservative pre-pacing behavior).
1603 let mut gate = FrameGate::with_flags(true, true);
1604 for _ in 0..10 {
1605 assert_eq!(gate.decide(paced_only()), FrameDecision::Run);
1606 }
1607 }
1608
1609 // -----------------------------------------------------------------
1610 // Per-request paced intervals (`PaintCtx::request_frame_paced_at`)
1611 // -----------------------------------------------------------------
1612
1613 /// Count the runs a constant paced-only stream produces over `ticks` ticks
1614 /// of a 120Hz tick stream, at `interval` (theme cap) and `requested`
1615 /// (per-request, `None` for a bare `request_frame_paced`).
1616 fn paced_runs(
1617 gate: &mut FrameGate,
1618 ticks: u64,
1619 interval: Duration,
1620 requested: Option<Duration>,
1621 ) -> usize {
1622 let tick = step_nanos(120.0);
1623 (0..ticks)
1624 .filter(|i| {
1625 let now = FrameTime::from_nanos(i * tick);
1626 let p = FramePacing {
1627 now,
1628 interval,
1629 requested_interval: requested,
1630 };
1631 gate.decide_paced(paced_only(), p).is_run()
1632 })
1633 .count()
1634 }
1635
1636 #[test]
1637 fn effective_interval_folds_the_request_against_the_theme_cap() {
1638 let cap = interval_30hz();
1639 // No request named: the theme's own cadence, verbatim (every caller
1640 // before `request_frame_paced_at` existed).
1641 assert_eq!(pacing(FrameTime::ZERO, cap).effective_interval(), cap);
1642 // `Duration::ZERO` is what a bare `request_frame_paced` folds in — "at
1643 // the theme's own rate" — so it resolves identically to `None`.
1644 assert_eq!(
1645 pacing_at(FrameTime::ZERO, cap, Duration::ZERO).effective_interval(),
1646 cap
1647 );
1648 // A slower request is honored as asked.
1649 assert_eq!(
1650 pacing_at(FrameTime::ZERO, cap, interval_500ms()).effective_interval(),
1651 interval_500ms()
1652 );
1653 // A request TIGHTER than the cap is clamped to it: `cosmetic_loop_rate`
1654 // is a ceiling on decorative motion, not a target (motion that must
1655 // land every vsync is `TickClass::Transition`, never paced at all).
1656 assert_eq!(
1657 pacing_at(FrameTime::ZERO, cap, Duration::from_millis(8)).effective_interval(),
1658 cap
1659 );
1660 }
1661
1662 #[test]
1663 fn a_frame_requesting_33ms_and_500ms_paces_at_33ms() {
1664 // The MIN-lattice, driven through the REAL core-side fold: two paced
1665 // requests in one paint pass (a 30Hz shimmer and a 2Hz caret) aggregate
1666 // to the tightest, so the gate paces the frame at 33ms — the caret is
1667 // simply repainted more often than it needs (no visual harm, by
1668 // design), and the shimmer is never starved down to 2Hz.
1669 let mut paint =
1670 frust_core::PaintCtx::new(kurbo::Point::ZERO, kurbo::Size::new(100.0, 100.0));
1671 paint.request_frame_paced_at(Duration::from_millis(33));
1672 paint.request_frame_paced_at(interval_500ms());
1673 let requested = paint.paced_interval();
1674 assert_eq!(requested, Some(Duration::from_millis(33)));
1675
1676 // 1 simulated second at 120Hz against the 30Hz framework-default cap.
1677 let mut gate = FrameGate::with_flags(true, true);
1678 let runs = paced_runs(&mut gate, 120, interval_30hz(), requested);
1679 assert!(
1680 (28..=32).contains(&runs),
1681 "a 33ms+500ms frame must pace at 33ms (~30 runs/s), ran {runs}"
1682 );
1683 // The contrast that makes the fold load-bearing: the same stream with
1684 // ONLY the 500ms request paces at 2Hz. Keeping the 33ms requester is
1685 // what holds the frame at 30Hz.
1686 let mut slow_gate = FrameGate::with_flags(true, true);
1687 let slow_runs = paced_runs(&mut slow_gate, 120, interval_30hz(), Some(interval_500ms()));
1688 assert!(
1689 (1..=3).contains(&slow_runs),
1690 "the 500ms request alone should run ~2x/s, ran {slow_runs}"
1691 );
1692 }
1693
1694 #[test]
1695 fn a_500ms_request_paces_far_slower_than_the_theme_cap() {
1696 // The motivating case: a caret blink asking for 2Hz against a 30Hz cap
1697 // must produce ~2 frames per simulated second, not ~30.
1698 let mut gate = FrameGate::with_flags(true, true);
1699 let runs = paced_runs(&mut gate, 120, interval_30hz(), Some(interval_500ms()));
1700 assert!(
1701 (1..=3).contains(&runs),
1702 "a 500ms paced request should run ~2x/s, ran {runs}"
1703 );
1704 // ...and the same stream with no request named still runs at the cap,
1705 // so the slowdown is the request's doing and nothing else's.
1706 let mut cap_gate = FrameGate::with_flags(true, true);
1707 let cap_runs = paced_runs(&mut cap_gate, 120, interval_30hz(), None);
1708 assert!(
1709 (28..=32).contains(&cap_runs),
1710 "an interval-less paced stream still runs at the cap, ran {cap_runs}"
1711 );
1712 }
1713
1714 #[test]
1715 fn a_changed_interval_neither_bursts_nor_stalls_the_cadence() {
1716 // Cadence stability under a VARIABLE interval — the caveat the
1717 // drift-free `last + interval` anchor arithmetic would otherwise trip
1718 // on. The active interval flips 33ms <-> 500ms every simulated second
1719 // across paced frames; at each transition the anchor adopts the interval
1720 // in force at that Run, so:
1721 // * no BURST — two paced runs never land closer than the tighter of
1722 // the two intervals (minus one tick of grid rounding), and
1723 // * no STALL — they never land further apart than the slower interval
1724 // plus one tick.
1725 let tick = step_nanos(120.0);
1726 // The realistic flip: a shimmer (the bare theme-cap request, 33ms at the
1727 // 30Hz default) appearing and disappearing while a 500ms caret blinks —
1728 // the MIN fold hands the gate 33ms while both run, 500ms once only the
1729 // caret is left.
1730 let fast = interval_30hz();
1731 let slow = interval_500ms();
1732
1733 // Swept across every flip PHASE, because the pathological case is
1734 // phase-dependent: it needs the flip to land a *part* of an interval
1735 // after the last produced frame (the residue an anchor advanced under
1736 // the old cadence turns into a double-fire), which only some offsets
1737 // produce. The flip period is deliberately 137 ticks — not a whole
1738 // number of either interval — so the sweep walks the whole phase space
1739 // instead of locking onto one alignment.
1740 const FLIP_PERIOD: u64 = 137;
1741 for offset in 0..FLIP_PERIOD {
1742 let mut gate = FrameGate::with_flags(true, true);
1743 let mut last_run: Option<u64> = None;
1744 let mut min_gap_ns = u64::MAX;
1745 let mut max_gap_ns = 0u64;
1746 // 6 simulated seconds at 120Hz, flipping the request periodically.
1747 for i in 0..720u64 {
1748 let now_ns = i * tick;
1749 let shimmer_onscreen = ((i + offset) / FLIP_PERIOD).is_multiple_of(2);
1750 let requested = if shimmer_onscreen {
1751 Duration::ZERO
1752 } else {
1753 slow
1754 };
1755 let p = FramePacing {
1756 now: FrameTime::from_nanos(now_ns),
1757 interval: fast,
1758 requested_interval: Some(requested),
1759 };
1760 if gate.decide_paced(paced_only(), p).is_run() {
1761 if let Some(prev) = last_run {
1762 let gap = now_ns - prev;
1763 min_gap_ns = min_gap_ns.min(gap);
1764 max_gap_ns = max_gap_ns.max(gap);
1765 }
1766 last_run = Some(now_ns);
1767 }
1768 }
1769
1770 assert!(
1771 min_gap_ns + tick >= fast.as_nanos() as u64,
1772 "no burst (flip offset {offset}): the tightest observed gap \
1773 ({min_gap_ns}ns) must not undercut the fast interval by more \
1774 than one tick"
1775 );
1776 assert!(
1777 max_gap_ns <= slow.as_nanos() as u64 + tick,
1778 "no stall (flip offset {offset}): the widest observed gap \
1779 ({max_gap_ns}ns) must not exceed the slow interval by more than \
1780 one tick"
1781 );
1782 }
1783 }
1784
1785 #[test]
1786 fn a_shortening_interval_flip_never_double_fires() {
1787 // The exact shape the "anchor adopts the interval in force at the last
1788 // Run" rule exists for, on a real 120Hz grid: a 500ms caret loop whose
1789 // shimmer comes back (500ms -> 33ms) at a moment that is *part way*
1790 // through the old cadence. Advancing the old anchor by the NEW interval
1791 // there leaves it up to a full interval in the past, so the frame after
1792 // the flip fires on the very next tick — a visible double-fire.
1793 let cap = interval_30hz();
1794 let slow = interval_500ms();
1795 let mut gate = FrameGate::with_flags(true, true);
1796 let paced_at = |now_ns: u64, requested: Duration| FramePacing {
1797 now: FrameTime::from_nanos(now_ns),
1798 interval: cap,
1799 requested_interval: Some(requested),
1800 };
1801
1802 // A shimmer+caret frame (the MIN fold hands the gate the cap) anchors
1803 // the loop at t=0.
1804 assert!(
1805 gate.decide_paced(paced_only(), paced_at(0, Duration::ZERO))
1806 .is_run()
1807 );
1808 // The shimmer ends: only the 500ms caret is left. Two slow frames.
1809 assert!(
1810 gate.decide_paced(paced_only(), paced_at(500_000_000, slow))
1811 .is_run()
1812 );
1813 assert!(
1814 gate.decide_paced(paced_only(), paced_at(900_000_000, slow))
1815 .is_skip()
1816 );
1817 assert!(
1818 gate.decide_paced(paced_only(), paced_at(1_000_000_000, slow))
1819 .is_run()
1820 );
1821
1822 // The shimmer returns 58ms into the caret's 500ms interval — past the
1823 // cap, so this tick fires.
1824 assert!(
1825 gate.decide_paced(paced_only(), paced_at(1_058_333_333, Duration::ZERO))
1826 .is_run()
1827 );
1828 // The next three 120Hz ticks must all skip: the cadence restarts from
1829 // the frame just produced, not from the stale 500ms anchor.
1830 for (n, now_ns) in [1_066_666_666u64, 1_075_000_000, 1_083_333_333]
1831 .into_iter()
1832 .enumerate()
1833 {
1834 assert_eq!(
1835 gate.decide_paced(paced_only(), paced_at(now_ns, Duration::ZERO)),
1836 FrameDecision::Skip,
1837 "tick {n} after a shortening flip must not double-fire"
1838 );
1839 }
1840 // ...and exactly one cap interval later the loop resumes cadence (no
1841 // stall either).
1842 assert_eq!(
1843 gate.decide_paced(paced_only(), paced_at(1_091_666_666, Duration::ZERO)),
1844 FrameDecision::Run,
1845 "the fast cadence resumes one interval after the flip frame"
1846 );
1847 }
1848
1849 #[test]
1850 fn the_anchor_adopts_the_interval_in_force_at_the_last_run() {
1851 // The rule the test above measures, asserted directly on the two flips.
1852 let cap = Duration::from_millis(10);
1853 let fast = Duration::from_millis(33);
1854 let slow = interval_500ms();
1855 let mut gate = FrameGate::with_flags(true, true);
1856
1857 let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
1858 let paced = |now: FrameTime, requested: Duration| FramePacing {
1859 now,
1860 interval: cap,
1861 requested_interval: Some(requested),
1862 };
1863
1864 // t=0: first paced frame anchors at 33ms cadence.
1865 assert!(gate.decide_paced(paced_only(), paced(at(0), fast)).is_run());
1866 // t=20ms: inside the 33ms interval — throttled.
1867 assert!(
1868 gate.decide_paced(paced_only(), paced(at(20), fast))
1869 .is_skip()
1870 );
1871 // t=40ms: one 33ms interval elapsed — runs, cadence intact.
1872 assert!(
1873 gate.decide_paced(paced_only(), paced(at(40), fast))
1874 .is_run()
1875 );
1876
1877 // The loop now flips to 500ms (the shimmer ended; only a caret is left).
1878 // t=100ms is 60ms past the last run: well inside the NEW interval, so it
1879 // must throttle rather than fire on the stale 33ms cadence.
1880 assert!(
1881 gate.decide_paced(paced_only(), paced(at(100), slow))
1882 .is_skip()
1883 );
1884 // t=545ms — 500ms past the t=40ms(+33) anchor — fires, and re-anchors to
1885 // `now` because the interval changed.
1886 assert!(
1887 gate.decide_paced(paced_only(), paced(at(545), slow))
1888 .is_run()
1889 );
1890 // Next 500ms tick lands one full interval later, not sooner (no burst).
1891 assert!(
1892 gate.decide_paced(paced_only(), paced(at(800), slow))
1893 .is_skip()
1894 );
1895 assert!(
1896 gate.decide_paced(paced_only(), paced(at(1045), slow))
1897 .is_run()
1898 );
1899
1900 // Flip back to 33ms: the very next tick past one fast interval runs (no
1901 // stall waiting out the old 500ms cadence), then holds the fast cadence.
1902 assert!(
1903 gate.decide_paced(paced_only(), paced(at(1060), fast))
1904 .is_skip()
1905 );
1906 assert!(
1907 gate.decide_paced(paced_only(), paced(at(1080), fast))
1908 .is_run()
1909 );
1910 assert!(
1911 gate.decide_paced(paced_only(), paced(at(1090), fast))
1912 .is_skip(),
1913 "no double-fire immediately after a shortening flip"
1914 );
1915 assert!(
1916 gate.decide_paced(paced_only(), paced(at(1115), fast))
1917 .is_run()
1918 );
1919 }
1920
1921 #[test]
1922 fn a_focus_ime_edge_is_bounded_by_the_theme_cap_not_a_long_request() {
1923 // The decided bound for the one wake input that rides INSIDE a paced
1924 // decision: a focus/IME edge landing on a paced-only tick waits at most
1925 // one `cosmetic_loop_rate` interval — never the (much longer) per-request
1926 // interval a caret named. Without the tightening this edge would sit
1927 // behind a 500ms caret blink, a user-visible focus lag.
1928 //
1929 // This pins the GATE half of that contract and holds unchanged: it feeds
1930 // the edge to every tick by hand, which is what a shell must actually do
1931 // for the bound to be real. The shell half — peeking the generation and
1932 // committing it only on a Run, so a deferred edge survives the Skips in
1933 // between — is pinned by
1934 // `a_focus_edge_persists_across_paced_skips_and_lands_within_one_cap_interval`
1935 // above.
1936 let cap = interval_30hz();
1937 let mut gate = FrameGate::with_flags(true, true);
1938 let at = |ms: u64| FrameTime::from_nanos(ms * 1_000_000);
1939 let caret = |now: FrameTime| FramePacing {
1940 now,
1941 interval: cap,
1942 requested_interval: Some(interval_500ms()),
1943 };
1944 let edge = FrameInputs {
1945 focus_or_ime_changed: true,
1946 ..paced_only()
1947 };
1948
1949 // A 500ms caret loop, anchored by its first paced frame at t=0.
1950 assert!(gate.decide_paced(paced_only(), caret(at(0))).is_run());
1951 // t=20ms: still inside the 33ms cap, so even an edge waits (the bound is
1952 // ONE cap interval, not zero — this is the documented deferral).
1953 assert_eq!(
1954 gate.decide_paced(edge, caret(at(20))),
1955 FrameDecision::Skip,
1956 "an edge inside the cap interval is still absorbed by the pacing"
1957 );
1958 // t=40ms: one cap interval past the anchor — the edge's frame lands here
1959 // rather than at t=500ms.
1960 assert_eq!(
1961 gate.decide_paced(edge, caret(at(40))),
1962 FrameDecision::Run,
1963 "a focus/IME edge must not wait out a long per-request interval"
1964 );
1965 // Worst case, stated as the bound: the edge never waits longer than one
1966 // theme-cap interval from the loop's last produced frame.
1967 assert!(cap <= Duration::from_millis(100));
1968
1969 // With no edge, the same loop keeps its own slow cadence — the
1970 // tightening is scoped to the edge tick alone.
1971 assert_eq!(
1972 gate.decide_paced(paced_only(), caret(at(80))),
1973 FrameDecision::Skip,
1974 "the edge tick must not permanently re-tighten the caret's cadence"
1975 );
1976 assert_eq!(
1977 gate.decide_paced(paced_only(), caret(at(560))),
1978 FrameDecision::Run
1979 );
1980 }
1981}