Skip to main content

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}