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