Skip to main content

frust_shell_common/
frame_gate.rs

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