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