Skip to main content

frust_shell_common/
platform_view.rs

1//! Platform-agnostic native-sibling compositor logic: turns the raw,
2//! per-paint-pass [`PlatformViewFrame`] collection (`frust-core`) into an
3//! idempotent, generation-stamped command list — [`ViewCommand`] — both mobile
4//! shells' FFI peek getters serve to their platform side
5//! (`docs/SHELLS_ARCHITECTURE.md`'s platform-view embedding flow).
6//!
7//! Pure diffing logic with no FFI, no JSON, and no platform types — the same
8//! "platform-agnostic brain, shell-owned wire format" split this crate draws
9//! elsewhere (`frame_gate`'s skip decision, `resample`'s pointer
10//! interpolation). JSON encoding of a [`ViewCommand`] batch stays hand-rolled
11//! in each shell's own FFI glue (`docs/CODE_STANDARDS.md`'s "hand-roll JSON at
12//! the mobile FFI boundary" rule); this module owns only the typed command
13//! vocabulary, never `serde` or any wire format.
14//!
15//! # The `frust-core` → differ contract
16//!
17//! `frust-core::app::RenderRoot::platform_view_frames()` replaces its whole
18//! `Vec<PlatformViewFrame>` every paint pass and stays deliberately dumb: a
19//! slot absent from one pass's frames might be culled-but-still-alive,
20//! momentarily not repainting, or genuinely torn down — core has no teardown
21//! hook to tell those apart. [`PlatformViewState`] resolves that ambiguity by
22//! watching how long a slot stays missing (`missing_streak`).
23//!
24//! # Command semantics
25//!
26//! - **New `slot_id`** ⇒ [`ViewCommand::Create`] then [`ViewCommand::Update`]
27//!   in the same ingest batch, in that order — the native side never sees an
28//!   `Update` for a view it hasn't been told to create yet.
29//! - **Rect/clip/visible change past [`EPSILON_PX`]** ⇒ `Update`; a smaller
30//!   change (or none at all) emits nothing, so a shell can call
31//!   [`PlatformViewState::commands`] every frame for free when nothing moved.
32//! - **`params_json` change** (detected via `params_generation`, bumped by the
33//!   widget whenever it edits `params_json`) ⇒ [`ViewCommand::UpdateParams`],
34//!   independent of the rect/clip/visible comparison above.
35//! - **`view_type` change** on a live slot ⇒ [`ViewCommand::Dispose`] followed
36//!   by a fresh `Create` + `Update`, **in the same ingest**. A different
37//!   `view_type` is a different native factory, so the old view cannot be
38//!   re-parameterized into the new one; emitting only a `Create` would be
39//!   ignored by a host that already has a view for that slot id, and the
40//!   [`DISPOSE_AFTER_MISSING_FRAMES`] streak would never fire at all (the slot
41//!   is still present every pass). See the view-type-swap arm in
42//!   [`PlatformViewState::ingest`].
43//! - **Missing for [`HIDE_AFTER_MISSING_FRAMES`] consecutive ingests** (while
44//!   the slot was last visible) ⇒ `Update { visible: false }` — a Hide. Only
45//!   fires once per hide (the slot's tracked `last_visible` flips to `false`,
46//!   so the same missing streak never re-emits it).
47//! - **Missing for [`DISPOSE_AFTER_MISSING_FRAMES`] consecutive ingests** ⇒
48//!   [`ViewCommand::Dispose`], and the slot is forgotten — a later
49//!   reappearance of the same `slot_id` is indistinguishable from a brand-new
50//!   one and gets a fresh `Create`. [`PlatformViewState::retire`] is the
51//!   second, explicit path to the same outcome — the one a real widget
52//!   teardown takes, immediately — and both are kept deliberately (Widget
53//!   teardown detection, below).
54//! - **Revive after Hide** (slot reappears in `ingest`'s frames before the
55//!   dispose threshold): since the slot is still tracked, this is just an
56//!   ordinary `Update` — `visible` flips back to `true` like any other
57//!   changed field, no `Create`.
58//! - **Revive after Dispose**: the slot was forgotten, so this is
59//!   indistinguishable from new — fresh `Create` + `Update`.
60//!
61//! # Z-shields (`interactive` slots only)
62//!
63//! An interactive slot's `shields` list — the regions where frust content
64//! painted OVER the slot keeps winning input — is assembled **here**, not by
65//! the widget, from two sources:
66//!
67//! - the pass's auto-collected shield rects
68//!   (`RenderRoot::input_shields()`, reported by `frust-widgets`' `shield(child)`
69//!   wrapper), narrowed to those that overlap the slot's own `rect`; plus
70//! - the slot's own manually declared rects (`PlatformViewView::shield_local`,
71//!   the escape hatch), which arrive on the frame and are always kept.
72//!
73//! A **non-interactive** slot always ships an empty list: shields only mean
74//! anything to a host that is forwarding touches to the native view in the
75//! first place, so carrying them would be noise the host must ignore. The
76//! resulting [`ViewCommand::Update`] shape is the same either way.
77//!
78//! Comparison is epsilon-based, like `rect`/`clip` (and order-sensitive: the
79//! collection order is paint order, which is deterministic for an unchanged
80//! tree), so a shield drifting sub-pixel with its chrome emits nothing.
81//!
82//! # Widget teardown detection
83//!
84//! Two paths converge on the same `Dispose`. A torn-down `platform_view`
85//! widget reports its slot id to `frust-core`'s pending-retire list
86//! (`RenderRoot::take_retired_platform_views`), which each shell drains after
87//! its rebuild and feeds to [`PlatformViewState::retire`] — an immediate
88//! `Dispose`, no streak. [`DISPOSE_AFTER_MISSING_FRAMES`] is the **backstop**
89//! for what that hook cannot see (a widget dropped without `View::teardown`
90//! running): a heuristic streak, since `ingest` alone cannot tell a dropped
91//! widget from a culled or transiently-not-repainting one. Both paths give the
92//! same command and the same "next `Create` is fresh" semantics, and a merely
93//! culled slot reports no retire, so it correctly keeps living behind the
94//! streak.
95//!
96//! # Generation / acknowledgement / compaction
97//!
98//! [`PlatformViewState::commands`] returns `(generation, &[ViewCommand])` — the
99//! **entire** not-yet-acknowledged command backlog, not just the latest
100//! batch. `generation` only advances when [`PlatformViewState::ingest`] (or
101//! [`PlatformViewState::reset_for_surface_recreate`]/[`PlatformViewState::retire`])
102//! actually produces at least one command; a no-change ingest leaves it
103//! untouched, so a shell polling every frame can cheaply tell "nothing new"
104//! apart from "here's more to apply" without diffing the slice itself.
105//! [`PlatformViewState::acknowledge`] tells the state that the native side has
106//! finished applying everything up through a given generation, letting it
107//! **compact** (drop) those entries from the backlog — this is what makes a
108//! missed poll during surface recreation safe: the native side just re-polls
109//! [`commands`](PlatformViewState::commands) and gets the same backlog again
110//! (nothing was dropped until acknowledged), and re-applying an already-applied
111//! prefix is safe because the command stream is a replay of state transitions,
112//! not one-shot deltas.
113//!
114//! # Backlog cap
115//!
116//! The backlog only shrinks on [`acknowledge`](PlatformViewState::acknowledge),
117//! so a native side that stops acking (a wedged host, a lost view hierarchy)
118//! would otherwise grow it for the process lifetime — reachable, since a camera
119//! preview is a genuinely long-lived slot. Past [`MAX_PENDING_COMMANDS`]
120//! entries the backlog is **compacted into its own net effect**: one `Dispose`
121//! per slot the dropped entries tore down, then a full `Create` + `Update`
122//! replay of every live slot — exactly the surface-recreate replay
123//! ([`reset_for_surface_recreate`](PlatformViewState::reset_for_surface_recreate)),
124//! which is already the established "the native side must rebuild from this
125//! alone" batch. Every dropped intermediate is a state transition the replay
126//! supersedes, so a native side that applies only the compacted batch lands in
127//! the same place. The whole compacted batch carries the current generation, so
128//! an ack of an older one drops none of it.
129//!
130//! # Frame pairing (the release gate)
131//!
132//! [`commands`](PlatformViewState::commands) hands the native side the whole
133//! backlog the instant it exists — which is *earlier* than the frust frame that
134//! produced the geometry reaches the screen, so a scrolling hosted view runs
135//! visibly ahead of the frust content it is supposed to be pinned to. A shell
136//! that knows which frust frame each batch came from closes that gap by
137//! releasing only the prefix whose frame is already presented:
138//! [`FramePairing`] keeps the `(generation, frame_id)` bookkeeping and
139//! [`commands_up_to`](PlatformViewState::commands_up_to) serves the prefix.
140//! Holding geometry for a presentation that never comes is the gate's one
141//! failure mode, so it releases anyway once the frame it waits on has fallen far
142//! enough behind — counted in submissions while frames flow and in idle display
143//! ticks ([`FramePairing::note_idle_tick`]) once they stop.
144//!
145//! # Skip-safety
146//!
147//! Nothing here special-cases a gate-skipped frame
148//! (`docs/SHELLS_ARCHITECTURE.md`'s `frame_gate` module); the contract is
149//! entirely "don't call [`ingest`](PlatformViewState::ingest) on a `Skip`".
150//! Paint doesn't run on a skip, so no rect can appear to "move" either
151//! (`PaintCtx::visible_rect`/scroll state can't have changed).
152
153use std::collections::{BTreeMap, BTreeSet, VecDeque};
154
155use frust_core::widget::PlatformViewFrame;
156use kurbo::Rect;
157
158/// Below this many logical px of difference on every edge, a rect/clip change
159/// is not worth an [`ViewCommand::Update`] — see the module docs' Command
160/// semantics section. Chosen to absorb floating-point layout jitter (e.g. a
161/// scroll offset accumulating sub-pixel drift) without visibly lagging a
162/// genuinely moving native sibling view.
163pub const EPSILON_PX: f64 = 0.5;
164
165/// Consecutive `ingest` calls a previously-live, previously-visible slot may
166/// be absent from `frames` before it is Hidden (`Update { visible: false }`).
167/// See the module docs' Command semantics section.
168pub const HIDE_AFTER_MISSING_FRAMES: u32 = 2;
169
170/// Consecutive `ingest` calls a slot may be absent from `frames` before it is
171/// Disposed outright. A heuristic streak, not a real teardown signal — see
172/// the module docs' Widget teardown detection.
173pub const DISPOSE_AFTER_MISSING_FRAMES: u32 = 30;
174
175/// Upper bound on the not-yet-acknowledged command backlog before it is
176/// compacted into its own net effect — see the module docs' Backlog cap.
177///
178/// Sized to be unreachable in normal operation (a steadily-acking native side
179/// keeps the backlog at one frame's worth, single digits) while still bounding
180/// a stuck one: the compaction itself costs `2 × live slots` commands, so the
181/// cap only has to sit comfortably above that for any realistic slot count.
182pub const MAX_PENDING_COMMANDS: usize = 256;
183
184/// How far a batch's frust frame may fall behind the submission cursor before
185/// [`FramePairing`] releases the batch anyway — the release gate's staleness
186/// escape hatch. **Required, not defensive**: the UI→render scene channel is
187/// depth-1 latest-wins, so a scene the UI thread submitted may be overtaken and
188/// never rendered at all, and a dropped frame's id never presents. Without this
189/// arm one dropped scene strands every later batch forever — a submission
190/// counter is not a presented counter.
191///
192/// The same bound counts idle display ticks
193/// ([`FramePairing::note_idle_tick`]), which is what keeps the hatch reachable
194/// once the frame loop stops producing frames and the submission cursor freezes
195/// with it.
196///
197/// Measured pipeline depth on physical test devices was 2–4 frames, so
198/// 12 sits well above the working range while bounding worst-case staleness to
199/// ~100 ms at 120 Hz.
200pub const MAX_FRAMES_IN_FLIGHT: u64 = 12;
201
202/// One native-sibling-compositor instruction — the differ's whole output
203/// vocabulary. `Clone + PartialEq + Debug` so a golden
204/// test can assert an exact command sequence.
205#[derive(Clone, Debug, PartialEq)]
206pub enum ViewCommand {
207    /// Create a new native view for `slot_id`. Always immediately followed,
208    /// in the same batch, by an [`ViewCommand::Update`] placing it.
209    Create {
210        /// Stable per-widget-instance id (see `frust_core::widget::next_slot_id`).
211        slot_id: u64,
212        /// `"dev.frust.<Factory>"` view-factory identifier.
213        view_type: String,
214        /// Opaque creation params for the native factory (may be empty).
215        params_json: String,
216        /// Mode B input forwarding: whether a
217        /// touch-DOWN inside this slot's rect hands the gesture to the
218        /// native sibling. Fixed at create in v1 (no live flip).
219        interactive: bool,
220    },
221    /// Place/resize/clip/show-or-hide an already-created slot. Logical px,
222    /// absolute window coordinates (mirroring [`PlatformViewFrame`]) — the
223    /// receiving shell scales to physical px at its own FFI boundary.
224    Update {
225        /// Which slot this applies to.
226        slot_id: u64,
227        /// Absolute paint bounds.
228        rect: Rect,
229        /// Visible-rect intersection, or `None` when fully visible.
230        clip: Option<Rect>,
231        /// `false` ⇒ hide the native view without disposing it.
232        visible: bool,
233        /// The z-shield list: absolute-coordinate
234        /// regions where frust content over the slot keeps winning input.
235        /// Meaningful only for an interactive slot; empty otherwise.
236        shields: Vec<Rect>,
237    },
238    /// `params_json` changed (`params_generation` advanced) with no
239    /// necessary rect/clip/visible change — a separate command so a shell
240    /// doesn't have to re-place a view just to hand it new creation params.
241    UpdateParams {
242        /// Which slot this applies to.
243        slot_id: u64,
244        /// The new opaque params payload.
245        params_json: String,
246    },
247    /// Tear down a slot's native view entirely. A `slot_id` reused after this
248    /// (the same numeric id reappearing in a later `ingest`) is treated as
249    /// brand-new — see the module docs' Widget teardown detection.
250    Dispose {
251        /// Which slot to tear down.
252        slot_id: u64,
253    },
254}
255
256/// Per-slot last-emitted state the differ compares each `ingest` call
257/// against, to decide whether anything actually changed.
258#[derive(Clone, Debug)]
259struct SlotEntry {
260    view_type: String,
261    params_json: String,
262    params_generation: u64,
263    interactive: bool,
264    last_rect: Rect,
265    last_clip: Option<Rect>,
266    last_visible: bool,
267    last_shields: Vec<Rect>,
268    /// Consecutive `ingest` calls this slot has been absent from `frames`.
269    /// Reset to `0` the instant it reappears.
270    missing_streak: u32,
271}
272
273/// The differ: per-slot last-seen state plus the accumulated, not-yet-acknowledged
274/// [`ViewCommand`] backlog. See the module docs for the full semantics.
275///
276/// `live` is a [`BTreeMap`] (keyed by `slot_id`), not a `HashMap` — iteration
277/// order must be deterministic (ascending `slot_id`) for the "same ingest
278/// sequence ⇒ identical command stream" golden-test guarantee; a frame's own
279/// `Create`+`Update` ordering is separately
280/// guaranteed by iterating `frames` itself in the caller's given order.
281#[derive(Debug, Default)]
282pub struct PlatformViewState {
283    live: BTreeMap<u64, SlotEntry>,
284    /// Flat, contiguous command backlog — kept flat (rather than one `Vec`
285    /// per generation) so [`commands`](Self::commands) can return a zero-copy
286    /// `&[ViewCommand]` slice.
287    pending: Vec<ViewCommand>,
288    /// Parallel to `pending`: the generation each entry was pushed under.
289    /// Monotonically non-decreasing (generations only ever go up), which is
290    /// what lets [`acknowledge`](Self::acknowledge) binary-search the
291    /// compaction boundary.
292    pending_gens: Vec<u64>,
293    generation: u64,
294    acked_generation: u64,
295    /// Whether the backlog-cap overflow has already been logged — once per
296    /// state, so a permanently-unacking native side costs one log line, not one
297    /// per compaction (module docs' Backlog cap).
298    overflow_logged: bool,
299}
300
301impl PlatformViewState {
302    /// A fresh differ with no live slots and generation `0`.
303    pub fn new() -> Self {
304        Self::default()
305    }
306
307    /// Feed one paint pass's frames (`RenderRoot::platform_view_frames()`) and
308    /// that same pass's z-shield rects (`RenderRoot::input_shields()`).
309    /// Returns `true` if this call produced at least one command (i.e. the
310    /// generation advanced) — a caller that only cares "did anything change"
311    /// can skip calling [`commands`](Self::commands) entirely when this is
312    /// `false`.
313    ///
314    /// `input_shields` is a flat, slot-agnostic list (core never associates a
315    /// shield with a slot); this method owns the intersection rule — see the
316    /// module docs' Z-shields section. Pass `&[]` when a caller has no shield
317    /// channel: every slot then ships only its own manually declared rects.
318    ///
319    /// Do not call this on a gate-skipped frame — see the module docs'
320    /// Skip-safety section.
321    pub fn ingest(&mut self, frames: &[PlatformViewFrame], input_shields: &[Rect]) -> bool {
322        let mut batch = Vec::new();
323        let mut seen = std::collections::BTreeSet::new();
324
325        for frame in frames {
326            seen.insert(frame.slot_id);
327            // The shields this slot actually ships this pass (module docs'
328            // Z-shields): its own manual rects plus the auto-collected ones
329            // overlapping it, or nothing at all when it isn't interactive.
330            let shields = resolve_shields(frame, input_shields);
331            // The slot's `view_type` changed under a live id. A different
332            // `view_type` resolves to a different native factory, so the old
333            // view must be torn down and a new one built — in THIS batch. Drop
334            // the tracked entry first, so the `None` arm below emits the fresh
335            // `Create` + `Update` after the `Dispose`, exactly as it would for
336            // an id it had never seen.
337            if let Some(entry) = self.live.get(&frame.slot_id)
338                && entry.view_type != frame.view_type
339            {
340                batch.push(ViewCommand::Dispose {
341                    slot_id: frame.slot_id,
342                });
343                self.live.remove(&frame.slot_id);
344            }
345            match self.live.get_mut(&frame.slot_id) {
346                None => {
347                    batch.push(ViewCommand::Create {
348                        slot_id: frame.slot_id,
349                        view_type: frame.view_type.clone(),
350                        params_json: frame.params_json.clone(),
351                        interactive: frame.interactive,
352                    });
353                    batch.push(ViewCommand::Update {
354                        slot_id: frame.slot_id,
355                        rect: frame.rect,
356                        clip: frame.clip,
357                        visible: frame.visible,
358                        shields: shields.clone(),
359                    });
360                    self.live.insert(
361                        frame.slot_id,
362                        SlotEntry {
363                            view_type: frame.view_type.clone(),
364                            params_json: frame.params_json.clone(),
365                            params_generation: frame.params_generation,
366                            interactive: frame.interactive,
367                            last_rect: frame.rect,
368                            last_clip: frame.clip,
369                            last_visible: frame.visible,
370                            last_shields: shields,
371                            missing_streak: 0,
372                        },
373                    );
374                }
375                Some(entry) => {
376                    entry.missing_streak = 0;
377                    if entry.last_visible != frame.visible
378                        || rect_changed(entry.last_rect, frame.rect)
379                        || clip_changed(entry.last_clip, frame.clip)
380                        || shields_changed(&entry.last_shields, &shields)
381                    {
382                        batch.push(ViewCommand::Update {
383                            slot_id: frame.slot_id,
384                            rect: frame.rect,
385                            clip: frame.clip,
386                            visible: frame.visible,
387                            shields: shields.clone(),
388                        });
389                        entry.last_rect = frame.rect;
390                        entry.last_clip = frame.clip;
391                        entry.last_visible = frame.visible;
392                        entry.last_shields = shields;
393                    }
394                    if entry.params_generation != frame.params_generation {
395                        batch.push(ViewCommand::UpdateParams {
396                            slot_id: frame.slot_id,
397                            params_json: frame.params_json.clone(),
398                        });
399                        entry.params_json = frame.params_json.clone();
400                        entry.params_generation = frame.params_generation;
401                    }
402                }
403            }
404        }
405
406        // Missing-slot bookkeeping: any previously-live slot absent from this
407        // pass's frames. Iterating `self.live` (a BTreeMap) keeps this
408        // deterministic across runs.
409        let mut disposed = Vec::new();
410        for (&slot_id, entry) in self.live.iter_mut() {
411            if seen.contains(&slot_id) {
412                continue;
413            }
414            entry.missing_streak += 1;
415            if entry.missing_streak == HIDE_AFTER_MISSING_FRAMES && entry.last_visible {
416                batch.push(ViewCommand::Update {
417                    slot_id,
418                    rect: entry.last_rect,
419                    clip: entry.last_clip,
420                    visible: false,
421                    shields: entry.last_shields.clone(),
422                });
423                entry.last_visible = false;
424            }
425            if entry.missing_streak >= DISPOSE_AFTER_MISSING_FRAMES {
426                batch.push(ViewCommand::Dispose { slot_id });
427                disposed.push(slot_id);
428            }
429        }
430        for slot_id in disposed {
431            self.live.remove(&slot_id);
432        }
433
434        self.push_batch(batch)
435    }
436
437    /// Backgrounding path: synthesize
438    /// `Update { visible: false }` for every currently-live, currently-visible
439    /// slot **immediately**, regardless of its missing streak. A shell calls
440    /// this on the platform's backgrounding hook (Android's `onPause`, iOS's
441    /// `frust_pause`) — while backgrounded, paint doesn't run, so
442    /// [`ingest`](Self::ingest) is never called to drive the ordinary
443    /// [`HIDE_AFTER_MISSING_FRAMES`]-streak Hide path; without this explicit
444    /// call a backgrounded native sibling view would stay visible (and,
445    /// depending on the platform, keep rendering/consuming resources) until the
446    /// app resumes and repaints. A slot already hidden (`last_visible ==
447    /// false`) emits nothing for it, so calling this on an already-suspended
448    /// state (or with no live slots) is a cheap no-op. The `Update` reuses
449    /// each slot's last-known rect/clip — no `frames` argument, unlike
450    /// [`ingest`](Self::ingest) — since backgrounding doesn't produce a fresh
451    /// paint pass to source one from.
452    pub fn suspend_all(&mut self) -> bool {
453        let mut batch = Vec::new();
454        for (&slot_id, entry) in self.live.iter_mut() {
455            if entry.last_visible {
456                batch.push(ViewCommand::Update {
457                    slot_id,
458                    rect: entry.last_rect,
459                    clip: entry.last_clip,
460                    visible: false,
461                    shields: entry.last_shields.clone(),
462                });
463                entry.last_visible = false;
464            }
465        }
466        self.push_batch(batch)
467    }
468
469    /// Explicit retire: dispose `slot_id` right now regardless of its missing
470    /// streak, for a shell with a real teardown signal (see the module docs'
471    /// Widget teardown detection). A no-op (returns `false`) if
472    /// `slot_id` isn't currently live (already disposed, or never created).
473    pub fn retire(&mut self, slot_id: u64) -> bool {
474        if self.live.remove(&slot_id).is_some() {
475            self.push_batch(vec![ViewCommand::Dispose { slot_id }])
476        } else {
477            false
478        }
479    }
480
481    /// Re-emit `Create` + `Update` for every currently-live slot, using each
482    /// slot's last-known state (including a currently-hidden slot's
483    /// `visible: false`) — the backgrounding/rotation replay a shell calls
484    /// when it knows the native side just lost its whole view hierarchy
485    /// (surface recreation) and needs every native sibling rebuilt from
486    /// scratch, not just the ones that would otherwise change.
487    pub fn reset_for_surface_recreate(&mut self) -> bool {
488        let mut batch = Vec::new();
489        self.replay_live_into(&mut batch);
490        self.push_batch(batch)
491    }
492
493    /// Append a full `Create` + `Update` replay of every live slot (ascending
494    /// `slot_id`, each preserving its last-known rect/clip/visibility) — the
495    /// shared body of [`reset_for_surface_recreate`](Self::reset_for_surface_recreate)
496    /// and the backlog-cap compaction (module docs' Backlog cap), which are the
497    /// same "rebuild everything from this batch alone" statement.
498    fn replay_live_into(&self, batch: &mut Vec<ViewCommand>) {
499        for (&slot_id, entry) in self.live.iter() {
500            batch.push(ViewCommand::Create {
501                slot_id,
502                view_type: entry.view_type.clone(),
503                params_json: entry.params_json.clone(),
504                interactive: entry.interactive,
505            });
506            batch.push(ViewCommand::Update {
507                slot_id,
508                rect: entry.last_rect,
509                clip: entry.last_clip,
510                visible: entry.last_visible,
511                shields: entry.last_shields.clone(),
512            });
513        }
514    }
515
516    /// Snapshot for a shell's peek getter: the current generation plus the
517    /// **entire** not-yet-acknowledged command backlog (not just the latest
518    /// ingest's batch) — see the module docs' Generation/acknowledgement
519    /// section.
520    pub fn commands(&self) -> (u64, &[ViewCommand]) {
521        (self.generation, &self.pending)
522    }
523
524    /// The prefix of the backlog whose generation is `<= max_generation`, plus
525    /// that prefix's own generation — the release-gate half of
526    /// [`commands`](Self::commands) (see the module docs' Frame pairing).
527    ///
528    /// [`commands`](Self::commands) hands over the whole backlog immediately,
529    /// which is what makes a hosted view's geometry run ahead of the frust
530    /// content it belongs to: the geometry lands in the window's next frame
531    /// while the frust buffer it matches is still queued behind the compositor.
532    /// A shell that knows which frust frame produced each batch — and which
533    /// frames have actually been presented ([`FramePairing`]) — releases only
534    /// the batches whose frame is already on screen.
535    ///
536    /// `pending_gens` is monotonically non-decreasing, so the prefix is a
537    /// `partition_point` — the same boundary search
538    /// [`acknowledge`](Self::acknowledge) uses. The **reported** generation is
539    /// the last released entry's, not `self.generation`, so the native side's
540    /// acknowledgement round-trip stays exactly as truthful as before: it only
541    /// ever acks what it was actually handed. An empty prefix reports the
542    /// already-acknowledged generation, which a shell's FFI glue reads as "no
543    /// change" — the same cheap no-op poll as an unchanged frame.
544    pub fn commands_up_to(&self, max_generation: u64) -> (u64, &[ViewCommand]) {
545        let end = self.pending_gens.partition_point(|&g| g <= max_generation);
546        let reported = if end == 0 {
547            self.acked_generation
548        } else {
549            self.pending_gens[end - 1]
550        };
551        (reported, &self.pending[..end])
552    }
553
554    /// The native side has finished applying everything up through
555    /// `generation` (an argument the shell round-trips from a prior
556    /// [`commands`](Self::commands) call) — compacts the backlog, dropping
557    /// every entry whose generation is `<= generation`. Acknowledging a
558    /// generation older than (or equal to) one already acknowledged is a
559    /// no-op.
560    pub fn acknowledge(&mut self, generation: u64) {
561        if generation <= self.acked_generation {
562            return;
563        }
564        self.acked_generation = generation;
565        let keep_from = self
566            .pending_gens
567            .partition_point(|&g| g <= self.acked_generation);
568        self.pending.drain(0..keep_from);
569        self.pending_gens.drain(0..keep_from);
570    }
571
572    fn push_batch(&mut self, batch: Vec<ViewCommand>) -> bool {
573        if batch.is_empty() {
574            return false;
575        }
576        self.generation += 1;
577        let batch_generation = self.generation;
578        self.pending_gens
579            .extend(std::iter::repeat_n(batch_generation, batch.len()));
580        self.pending.extend(batch);
581        if self.pending.len() > MAX_PENDING_COMMANDS {
582            self.compact_to_net_effect();
583        }
584        true
585    }
586
587    /// Backlog-cap overflow (module docs' Backlog cap): replace the whole
588    /// not-yet-acknowledged backlog with the net effect of applying it — one
589    /// `Dispose` per slot the backlog tore down (ascending `slot_id`), then a
590    /// full replay of every live slot.
591    ///
592    /// The `Dispose`s must survive: a slot created *before* the un-acked window
593    /// and disposed inside it is a native view the host already built and would
594    /// otherwise never be told to tear down — a leak. A slot that was disposed
595    /// **and** is live again (a `slot_id` reuse, or a `view_type` swap)
596    /// keeps its `Dispose` too, and the replay's `Create` rebuilds it from the
597    /// current `view_type`/params — the only ordering that survives a factory
598    /// change.
599    ///
600    /// The whole compacted batch carries the current generation, so an ack of
601    /// an older generation drops none of it, and `pending_gens` stays
602    /// non-decreasing for both boundary searches.
603    fn compact_to_net_effect(&mut self) {
604        let disposed: BTreeSet<u64> = self
605            .pending
606            .iter()
607            .filter_map(|cmd| match cmd {
608                ViewCommand::Dispose { slot_id } => Some(*slot_id),
609                _ => None,
610            })
611            .collect();
612        let mut compacted = Vec::with_capacity(disposed.len() + self.live.len() * 2);
613        for slot_id in disposed {
614            compacted.push(ViewCommand::Dispose { slot_id });
615        }
616        self.replay_live_into(&mut compacted);
617
618        if !self.overflow_logged {
619            self.overflow_logged = true;
620            log::warn!(
621                "frust-shell: platform-view command backlog exceeded {MAX_PENDING_COMMANDS} \
622                 un-acknowledged entries (is the native side polling?); compacted {} entries \
623                 into a {}-command replay. Logged once per process.",
624                self.pending.len(),
625                compacted.len()
626            );
627        }
628
629        self.pending_gens.clear();
630        self.pending_gens.resize(compacted.len(), self.generation);
631        self.pending = compacted;
632    }
633}
634
635/// The release gate's bookkeeping: which frust frame produced each command
636/// batch, and therefore which batches may be handed to the native side yet.
637///
638/// A shell records `(generation, frame_id)` for every batch
639/// [`PlatformViewState::ingest`] produces (the frame that is about to be
640/// submitted carries the geometry the batch describes), reads back the id of
641/// the last frame the render side actually **presented**, and serves
642/// [`PlatformViewState::commands_up_to`] the resulting boundary. Pure logic:
643/// no clock, no platform types, no knowledge of how a shell obtains the two
644/// cursors — which is what makes the ordering testable on the host, since a
645/// mobile shell's own frame loop is not.
646///
647/// # Why the frame *id*, not a count
648///
649/// The UI→render scene channel is depth-1 latest-wins, so submissions and
650/// presents are not the same clock — under the measured 120 Hz-submit /
651/// 60 Hz-present regime they diverge by half the frames. Pairing against a
652/// presented *count* over-delays by exactly the dropped frames; pairing against
653/// the id of the frame that actually presented does not. The
654/// price of the id is that a dropped frame's id never arrives, which is what
655/// [`MAX_FRAMES_IN_FLIGHT`] exists for.
656///
657/// # Lifecycle
658///
659/// The pairing is a *smoothing* device, not a correctness barrier: a shell
660/// [`clear`](Self::clear)s it whenever the frames it refers to stop being
661/// meaningful (backgrounding, surface recreation), after which the whole
662/// backlog releases immediately — a hide or a full replay must reach the native
663/// side even though no further frame will ever present to unlock it.
664///
665/// Going *idle* is the third such moment, and the only one with no lifecycle
666/// callback to hang a `clear` on: the loop simply stops producing frames while
667/// the display keeps ticking. A shell reports those ticks
668/// ([`note_idle_tick`](Self::note_idle_tick)) so the staleness hatch stays
669/// reachable there — without them a batch whose frame never presented is held
670/// for the process lifetime (see that method for the full failure mode).
671#[derive(Debug, Default)]
672pub struct FramePairing {
673    /// `(generation, frame_id)` per produced batch, oldest first. Both
674    /// components are monotonically non-decreasing along the queue, which is
675    /// what lets [`releasable_generation`](Self::releasable_generation) stop at
676    /// the first held entry.
677    due: VecDeque<(u64, u64)>,
678    /// Display ticks that produced no frust frame since the last
679    /// [`record`](Self::record) — the idle half of the staleness cursor (see
680    /// [`note_idle_tick`](Self::note_idle_tick)). Reset by every produced batch
681    /// and by [`clear`](Self::clear), so it only ever measures the *current*
682    /// idle stretch: an entry recorded before an earlier stretch is aged by
683    /// fewer ticks than really elapsed, which errs toward holding, never toward
684    /// releasing early.
685    idle_ticks: u64,
686}
687
688/// Upper bound on tracked-but-unreleased batches. Reached only if the native
689/// side stops acking (the same failure mode the backlog cap covers); dropping
690/// the **oldest** entry is the safe direction — the boundary search then
691/// releases it, and the oldest entry is by construction the one closest to
692/// the [`MAX_FRAMES_IN_FLIGHT`] escape hatch anyway.
693const MAX_TRACKED_BATCHES: usize = 256;
694
695impl FramePairing {
696    /// Fresh, empty pairing — releases everything until a batch is recorded.
697    pub fn new() -> Self {
698        Self::default()
699    }
700
701    /// Remember that the batch published under `generation` describes geometry
702    /// painted by frust frame `frame_id`.
703    pub fn record(&mut self, generation: u64, frame_id: u64) {
704        if self.due.len() >= MAX_TRACKED_BATCHES {
705            self.due.pop_front();
706        }
707        self.due.push_back((generation, frame_id));
708        // A produced frame ends the idle stretch: this batch's own frame is
709        // genuinely in flight, so from here the submission cursor is the honest
710        // clock to age every entry by again.
711        self.idle_ticks = 0;
712    }
713
714    /// Report one display tick on which the frame loop produced **no** frust
715    /// frame — the tick a shell's frame gate skipped. Ages every held batch
716    /// exactly as a submission does (see
717    /// [`releasable_generation`](Self::releasable_generation)).
718    ///
719    /// **Why the release gate needs an idle clock at all.** A batch is released
720    /// on one of two events: its own frame is confirmed *presented*, or the
721    /// submission cursor climbs [`MAX_FRAMES_IN_FLIGHT`] past it. A present is
722    /// recorded only for a `Rendered` render outcome, so any other one — an
723    /// encode or acquire skipped against a surface that is not ready, a
724    /// swapchain reconfigure, a lost surface, an encode/acquire error — leaves
725    /// the batch's frame permanently unconfirmed. That is survivable while
726    /// frames keep flowing, because the submission cursor walks past it within
727    /// twelve frames. Once the app settles, though, the submission cursor stops
728    /// too, and *neither* arm can ever fire again: the settled geometry is held
729    /// for the process lifetime and the native sibling stays parked at whatever
730    /// mid-animation rect it last applied — device-observed as a camera preview
731    /// stuck black behind correct-but-never-delivered geometry, healed only by a
732    /// surface recreate (which `clear`s the pairing). The display clock is the
733    /// one cursor still moving at idle, and an idle tick carries exactly the
734    /// evidence the submission cursor does: that frame is not coming.
735    ///
736    /// **Why an idle *bound* and not an immediate release.** Releasing the whole
737    /// backlog on the last painted frame is not expressible: a touch-driven drag
738    /// paints with `needs_frame == false` every frame, so "this paint asked for
739    /// no continuation frame" cannot tell a settle frame from a mid-drag one,
740    /// and keying the release on it would turn the gate off for exactly the
741    /// scrolling case it was built to smooth. Aging by idle ticks costs nothing
742    /// on any path where frames still flow — a present that does arrive still
743    /// releases the batch first, unchanged — and bounds the broken path to
744    /// [`MAX_FRAMES_IN_FLIGHT`] display ticks (~100 ms at 120 Hz).
745    pub fn note_idle_tick(&mut self) {
746        self.idle_ticks = self.idle_ticks.saturating_add(1);
747    }
748
749    /// The highest generation releasable right now, given the id of the last
750    /// **presented** frame and of the last **submitted** one.
751    ///
752    /// A batch is releasable once its own frame is on screen, or once that
753    /// frame has fallen [`MAX_FRAMES_IN_FLIGHT`] behind the staleness cursor
754    /// (it was dropped by the latest-wins channel, or never presented at all,
755    /// and will never reach the screen). The first batch that is neither caps
756    /// the boundary at its own generation minus one, so everything published
757    /// before it — including a lifecycle batch that was never paired with a
758    /// frame at all — still goes out; an empty queue releases everything.
759    ///
760    /// The staleness cursor is the submission cursor plus the current idle
761    /// stretch ([`note_idle_tick`](Self::note_idle_tick)): the two are the same
762    /// "frames have moved on past this one" evidence, and with no idle ticks
763    /// reported this is bit-for-bit the submission-only rule.
764    pub fn releasable_generation(&self, presented_frame_id: u64, submitted_frame_id: u64) -> u64 {
765        let stale_cursor = submitted_frame_id.saturating_add(self.idle_ticks);
766        for &(generation, due_frame) in &self.due {
767            let on_screen = due_frame <= presented_frame_id;
768            let stranded = stale_cursor.saturating_sub(due_frame) >= MAX_FRAMES_IN_FLIGHT;
769            if !on_screen && !stranded {
770                return generation.saturating_sub(1);
771            }
772        }
773        u64::MAX
774    }
775
776    /// Drop the bookkeeping for every batch the native side has acknowledged —
777    /// the same generation the shell hands
778    /// [`PlatformViewState::acknowledge`], so the two stay in step.
779    pub fn acknowledge(&mut self, generation: u64) {
780        while let Some(&(g, _)) = self.due.front() {
781            if g <= generation {
782                self.due.pop_front();
783            } else {
784                break;
785            }
786        }
787    }
788
789    /// Forget every pairing (backgrounding, surface recreation) — see the
790    /// type's Lifecycle note. Also drops the idle stretch, so the ticks counted
791    /// against frames belonging to a surface (or a foreground session) that is
792    /// gone cannot age the first batch recorded after it.
793    pub fn clear(&mut self) {
794        self.due.clear();
795        self.idle_ticks = 0;
796    }
797
798    /// Whether any batch is still waiting to be paired off.
799    pub fn is_empty(&self) -> bool {
800        self.due.is_empty()
801    }
802}
803
804/// The shield list a slot ships this pass (module docs' Z-shields): nothing at
805/// all for a non-interactive slot, else its own manually declared rects
806/// (`PlatformViewView::shield_local`, already absolute) plus every
807/// auto-collected rect overlapping the slot's `rect`.
808///
809/// Overlap (not a positive-area intersection) is the same edge-inclusive test
810/// the widget's own visibility check uses; a duplicate — the same region
811/// declared manually AND painted by a `shield` wrapper — is dropped by the
812/// epsilon comparison, so a host never sees the same rect twice.
813fn resolve_shields(frame: &PlatformViewFrame, input_shields: &[Rect]) -> Vec<Rect> {
814    if !frame.interactive {
815        return Vec::new();
816    }
817    let mut resolved = frame.shields.clone();
818    for shield in input_shields {
819        if shield.overlaps(frame.rect) && !resolved.iter().any(|kept| !rect_changed(*kept, *shield))
820        {
821            resolved.push(*shield);
822        }
823    }
824    resolved
825}
826
827/// Whether two shield lists differ past [`EPSILON_PX`]. Order-sensitive: the
828/// list is built in paint order, which is stable for an unchanged tree, so a
829/// reorder legitimately means the shields moved.
830fn shields_changed(a: &[Rect], b: &[Rect]) -> bool {
831    a.len() != b.len() || a.iter().zip(b).any(|(a, b)| rect_changed(*a, *b))
832}
833
834fn rect_changed(a: Rect, b: Rect) -> bool {
835    (a.x0 - b.x0).abs() >= EPSILON_PX
836        || (a.y0 - b.y0).abs() >= EPSILON_PX
837        || (a.x1 - b.x1).abs() >= EPSILON_PX
838        || (a.y1 - b.y1).abs() >= EPSILON_PX
839}
840
841fn clip_changed(a: Option<Rect>, b: Option<Rect>) -> bool {
842    match (a, b) {
843        (None, None) => false,
844        (Some(a), Some(b)) => rect_changed(a, b),
845        _ => true,
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    fn frame(slot_id: u64, rect: Rect, visible: bool) -> PlatformViewFrame {
854        PlatformViewFrame {
855            slot_id,
856            view_type: "dev.frust.Test".to_string(),
857            params_json: String::new(),
858            params_generation: 0,
859            rect,
860            clip: None,
861            visible,
862            interactive: false,
863            shields: Vec::new(),
864        }
865    }
866
867    fn r(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect {
868        Rect::new(x0, y0, x1, y1)
869    }
870
871    /// A frame for an `interactive` slot (the only kind that ships shields).
872    fn interactive_frame(slot_id: u64, rect: Rect) -> PlatformViewFrame {
873        PlatformViewFrame {
874            interactive: true,
875            ..frame(slot_id, rect, true)
876        }
877    }
878
879    /// The `shields` list of the last [`ViewCommand::Update`] in the backlog.
880    fn last_update_shields(state: &PlatformViewState) -> Vec<Rect> {
881        state
882            .commands()
883            .1
884            .iter()
885            .rev()
886            .find_map(|cmd| match cmd {
887                ViewCommand::Update { shields, .. } => Some(shields.clone()),
888                _ => None,
889            })
890            .expect("an Update command in the backlog")
891    }
892
893    #[test]
894    fn new_slot_emits_create_then_update_in_order() {
895        let mut state = PlatformViewState::new();
896        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
897        assert!(changed);
898
899        let (generation, cmds) = state.commands();
900        assert_eq!(generation, 1);
901        assert_eq!(
902            cmds,
903            &[
904                ViewCommand::Create {
905                    slot_id: 1,
906                    view_type: "dev.frust.Test".to_string(),
907                    params_json: String::new(),
908                    interactive: false,
909                },
910                ViewCommand::Update {
911                    slot_id: 1,
912                    rect: r(0.0, 0.0, 100.0, 50.0),
913                    clip: None,
914                    visible: true,
915                    shields: Vec::new(),
916                },
917            ]
918        );
919    }
920
921    #[test]
922    fn sub_epsilon_rect_change_emits_nothing() {
923        let mut state = PlatformViewState::new();
924        state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
925        state.acknowledge(1);
926
927        // 0.2px drift on every edge — below the 0.5px epsilon.
928        let changed = state.ingest(&[frame(1, r(0.2, 0.2, 100.2, 50.2), true)], &[]);
929        assert!(!changed);
930        let (generation, cmds) = state.commands();
931        assert_eq!(generation, 1); // unchanged — no new batch
932        assert!(cmds.is_empty());
933    }
934
935    #[test]
936    fn past_epsilon_rect_change_emits_update() {
937        let mut state = PlatformViewState::new();
938        state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
939        state.acknowledge(1);
940
941        let changed = state.ingest(&[frame(1, r(1.0, 0.0, 100.0, 50.0), true)], &[]);
942        assert!(changed);
943        let (generation, cmds) = state.commands();
944        assert_eq!(generation, 2);
945        assert_eq!(
946            cmds,
947            &[ViewCommand::Update {
948                slot_id: 1,
949                rect: r(1.0, 0.0, 100.0, 50.0),
950                clip: None,
951                visible: true,
952                shields: Vec::new(),
953            }]
954        );
955    }
956
957    #[test]
958    fn missing_two_consecutive_ingests_hides_a_visible_slot() {
959        let mut state = PlatformViewState::new();
960        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
961        state.acknowledge(1);
962
963        // Missing once: no hide yet.
964        let changed = state.ingest(&[], &[]);
965        assert!(!changed);
966        assert_eq!(state.commands().1, &[]);
967
968        // Missing twice consecutively: Hide.
969        let changed = state.ingest(&[], &[]);
970        assert!(changed);
971        assert_eq!(
972            state.commands().1,
973            &[ViewCommand::Update {
974                slot_id: 1,
975                rect: r(0.0, 0.0, 10.0, 10.0),
976                clip: None,
977                visible: false,
978                shields: Vec::new(),
979            }]
980        );
981
982        // A further missing ingest doesn't re-emit the same Hide.
983        state.acknowledge(state.commands().0);
984        let changed = state.ingest(&[], &[]);
985        assert!(!changed);
986    }
987
988    #[test]
989    fn missing_past_dispose_threshold_disposes_the_slot() {
990        let mut state = PlatformViewState::new();
991        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
992        state.acknowledge(1);
993
994        for _ in 0..(DISPOSE_AFTER_MISSING_FRAMES - 1) {
995            state.ingest(&[], &[]);
996        }
997        // Not yet disposed at streak == DISPOSE_AFTER_MISSING_FRAMES - 1.
998        let (_, cmds) = state.commands();
999        assert!(!cmds.contains(&ViewCommand::Dispose { slot_id: 1 }));
1000
1001        let changed = state.ingest(&[], &[]);
1002        assert!(changed);
1003        let (_, cmds) = state.commands();
1004        assert!(cmds.contains(&ViewCommand::Dispose { slot_id: 1 }));
1005    }
1006
1007    #[test]
1008    fn revive_after_hide_is_a_plain_update_not_a_create() {
1009        let mut state = PlatformViewState::new();
1010        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1011        state.ingest(&[], &[]); // streak 1
1012        state.ingest(&[], &[]); // streak 2: Hide
1013        state.acknowledge(state.commands().0);
1014
1015        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1016        assert!(changed);
1017        let (_, cmds) = state.commands();
1018        assert_eq!(
1019            cmds,
1020            &[ViewCommand::Update {
1021                slot_id: 1,
1022                rect: r(0.0, 0.0, 10.0, 10.0),
1023                clip: None,
1024                visible: true,
1025                shields: Vec::new(),
1026            }]
1027        );
1028        assert!(!cmds.iter().any(|c| matches!(c, ViewCommand::Create { .. })));
1029    }
1030
1031    #[test]
1032    fn revive_after_dispose_creates_fresh() {
1033        let mut state = PlatformViewState::new();
1034        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1035        for _ in 0..DISPOSE_AFTER_MISSING_FRAMES {
1036            state.ingest(&[], &[]);
1037        }
1038        state.acknowledge(state.commands().0);
1039
1040        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1041        assert!(changed);
1042        let (_, cmds) = state.commands();
1043        assert_eq!(
1044            cmds,
1045            &[
1046                ViewCommand::Create {
1047                    slot_id: 1,
1048                    view_type: "dev.frust.Test".to_string(),
1049                    params_json: String::new(),
1050                    interactive: false,
1051                },
1052                ViewCommand::Update {
1053                    slot_id: 1,
1054                    rect: r(0.0, 0.0, 10.0, 10.0),
1055                    clip: None,
1056                    visible: true,
1057                    shields: Vec::new(),
1058                },
1059            ]
1060        );
1061    }
1062
1063    #[test]
1064    fn acknowledge_compacts_the_backlog() {
1065        let mut state = PlatformViewState::new();
1066        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1067        let (gen1, _) = state.commands();
1068        state.ingest(&[frame(2, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1069        let (gen2, cmds) = state.commands();
1070        assert_eq!(cmds.len(), 4); // both slots' Create+Update, un-acked
1071
1072        state.acknowledge(gen1);
1073        let (generation_after_ack, cmds_after_ack) = state.commands();
1074        assert_eq!(generation_after_ack, gen2); // generation itself is untouched
1075        assert_eq!(cmds_after_ack.len(), 2); // only slot 2's batch remains
1076
1077        // Acknowledging an already-acked (or older) generation is a no-op.
1078        state.acknowledge(gen1);
1079        assert_eq!(state.commands().1.len(), 2);
1080    }
1081
1082    #[test]
1083    fn replay_after_reset_reproduces_every_live_slot() {
1084        let mut state = PlatformViewState::new();
1085        state.ingest(
1086            &[
1087                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1088                frame(2, r(20.0, 0.0, 30.0, 10.0), false),
1089            ],
1090            &[],
1091        );
1092        state.acknowledge(state.commands().0);
1093        assert_eq!(state.commands().1, &[]);
1094
1095        let changed = state.reset_for_surface_recreate();
1096        assert!(changed);
1097        let (_, cmds) = state.commands();
1098        // Both slots re-created, each preserving its last-known visibility.
1099        assert_eq!(
1100            cmds,
1101            &[
1102                ViewCommand::Create {
1103                    slot_id: 1,
1104                    view_type: "dev.frust.Test".to_string(),
1105                    params_json: String::new(),
1106                    interactive: false,
1107                },
1108                ViewCommand::Update {
1109                    slot_id: 1,
1110                    rect: r(0.0, 0.0, 10.0, 10.0),
1111                    clip: None,
1112                    visible: true,
1113                    shields: Vec::new(),
1114                },
1115                ViewCommand::Create {
1116                    slot_id: 2,
1117                    view_type: "dev.frust.Test".to_string(),
1118                    params_json: String::new(),
1119                    interactive: false,
1120                },
1121                ViewCommand::Update {
1122                    slot_id: 2,
1123                    rect: r(20.0, 0.0, 30.0, 10.0),
1124                    clip: None,
1125                    visible: false,
1126                    shields: Vec::new(),
1127                },
1128            ]
1129        );
1130    }
1131
1132    #[test]
1133    fn two_slot_interleaving_does_not_cross_contaminate() {
1134        let mut state = PlatformViewState::new();
1135        // Slot 1 created; slot 2 not yet present.
1136        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1137        state.acknowledge(state.commands().0);
1138
1139        // Slot 2 appears; slot 1 unchanged (no rect/clip/visible drift) — only
1140        // slot 2's Create+Update should be emitted.
1141        let changed = state.ingest(
1142            &[
1143                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1144                frame(2, r(50.0, 50.0, 60.0, 60.0), true),
1145            ],
1146            &[],
1147        );
1148        assert!(changed);
1149        let (_, cmds) = state.commands();
1150        assert_eq!(
1151            cmds,
1152            &[
1153                ViewCommand::Create {
1154                    slot_id: 2,
1155                    view_type: "dev.frust.Test".to_string(),
1156                    params_json: String::new(),
1157                    interactive: false,
1158                },
1159                ViewCommand::Update {
1160                    slot_id: 2,
1161                    rect: r(50.0, 50.0, 60.0, 60.0),
1162                    clip: None,
1163                    visible: true,
1164                    shields: Vec::new(),
1165                },
1166            ]
1167        );
1168        state.acknowledge(state.commands().0);
1169
1170        // Now slot 1 moves and slot 2 disappears (one missing frame — not yet
1171        // hidden): only slot 1's Update should appear.
1172        let changed = state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1173        assert!(changed);
1174        assert_eq!(
1175            state.commands().1,
1176            &[ViewCommand::Update {
1177                slot_id: 1,
1178                rect: r(5.0, 0.0, 15.0, 10.0),
1179                clip: None,
1180                visible: true,
1181                shields: Vec::new(),
1182            }]
1183        );
1184    }
1185
1186    #[test]
1187    fn params_generation_change_emits_update_params() {
1188        let mut state = PlatformViewState::new();
1189        let mut f = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1190        f.params_json = "{\"a\":1}".to_string();
1191        state.ingest(&[f], &[]);
1192        state.acknowledge(state.commands().0);
1193
1194        let mut f2 = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1195        f2.params_json = "{\"a\":2}".to_string();
1196        f2.params_generation = 1;
1197        let changed = state.ingest(&[f2], &[]);
1198        assert!(changed);
1199        assert_eq!(
1200            state.commands().1,
1201            &[ViewCommand::UpdateParams {
1202                slot_id: 1,
1203                params_json: "{\"a\":2}".to_string(),
1204            }]
1205        );
1206    }
1207
1208    #[test]
1209    fn suspend_all_hides_every_visible_slot_immediately() {
1210        let mut state = PlatformViewState::new();
1211        state.ingest(
1212            &[
1213                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1214                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1215            ],
1216            &[],
1217        );
1218        state.acknowledge(state.commands().0);
1219
1220        // No missing streak at all — suspend_all fires on the very next call,
1221        // unlike the ordinary ingest-driven Hide path.
1222        let changed = state.suspend_all();
1223        assert!(changed);
1224        let (_, cmds) = state.commands();
1225        assert_eq!(
1226            cmds,
1227            &[
1228                ViewCommand::Update {
1229                    slot_id: 1,
1230                    rect: r(0.0, 0.0, 10.0, 10.0),
1231                    clip: None,
1232                    visible: false,
1233                    shields: Vec::new(),
1234                },
1235                ViewCommand::Update {
1236                    slot_id: 2,
1237                    rect: r(20.0, 0.0, 30.0, 10.0),
1238                    clip: None,
1239                    visible: false,
1240                    shields: Vec::new(),
1241                },
1242            ]
1243        );
1244    }
1245
1246    #[test]
1247    fn suspend_all_skips_already_hidden_slots() {
1248        let mut state = PlatformViewState::new();
1249        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), false)], &[]);
1250        state.acknowledge(state.commands().0);
1251
1252        // The only live slot is already visible:false — nothing to emit.
1253        let changed = state.suspend_all();
1254        assert!(!changed);
1255        assert_eq!(state.commands().1, &[]);
1256    }
1257
1258    #[test]
1259    fn suspend_all_on_no_live_slots_is_a_noop() {
1260        let mut state = PlatformViewState::new();
1261        assert!(!state.suspend_all());
1262        assert_eq!(state.commands().0, 0);
1263    }
1264
1265    #[test]
1266    fn revive_after_suspend_all_is_a_plain_update() {
1267        let mut state = PlatformViewState::new();
1268        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1269        state.acknowledge(state.commands().0);
1270        state.suspend_all();
1271        state.acknowledge(state.commands().0);
1272
1273        // The slot reappears in the next real ingest (e.g. the first frame
1274        // after resume) — an ordinary Update, no re-Create.
1275        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1276        assert!(changed);
1277        let (_, cmds) = state.commands();
1278        assert_eq!(
1279            cmds,
1280            &[ViewCommand::Update {
1281                slot_id: 1,
1282                rect: r(0.0, 0.0, 10.0, 10.0),
1283                clip: None,
1284                visible: true,
1285                shields: Vec::new(),
1286            }]
1287        );
1288        assert!(!cmds.iter().any(|c| matches!(c, ViewCommand::Create { .. })));
1289    }
1290
1291    #[test]
1292    fn retire_disposes_immediately_regardless_of_missing_streak() {
1293        let mut state = PlatformViewState::new();
1294        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1295        state.acknowledge(state.commands().0);
1296
1297        assert!(state.retire(1));
1298        assert_eq!(state.commands().1, &[ViewCommand::Dispose { slot_id: 1 }]);
1299
1300        // Retiring an already-gone (or never-created) slot is a no-op.
1301        assert!(!state.retire(1));
1302        assert!(!state.retire(999));
1303    }
1304
1305    // ---- view_type swap -----------------------------------------------------
1306
1307    #[test]
1308    fn view_type_swap_disposes_and_recreates_in_the_same_ingest() {
1309        let mut state = PlatformViewState::new();
1310        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1311        state.acknowledge(state.commands().0);
1312
1313        let mut swapped = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1314        swapped.view_type = "dev.frust.Other".to_string();
1315        let changed = state.ingest(&[swapped], &[]);
1316        assert!(changed);
1317        assert_eq!(
1318            state.commands().1,
1319            &[
1320                ViewCommand::Dispose { slot_id: 1 },
1321                ViewCommand::Create {
1322                    slot_id: 1,
1323                    view_type: "dev.frust.Other".to_string(),
1324                    params_json: String::new(),
1325                    interactive: false,
1326                },
1327                ViewCommand::Update {
1328                    slot_id: 1,
1329                    rect: r(0.0, 0.0, 10.0, 10.0),
1330                    clip: None,
1331                    visible: true,
1332                    shields: Vec::new(),
1333                },
1334            ]
1335        );
1336    }
1337
1338    #[test]
1339    fn view_type_swap_does_not_disturb_other_slots() {
1340        let mut state = PlatformViewState::new();
1341        state.ingest(
1342            &[
1343                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1344                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1345            ],
1346            &[],
1347        );
1348        state.acknowledge(state.commands().0);
1349
1350        let mut swapped = frame(2, r(20.0, 0.0, 30.0, 10.0), true);
1351        swapped.view_type = "dev.frust.Other".to_string();
1352        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true), swapped], &[]);
1353        let (_, cmds) = state.commands();
1354        assert!(cmds.iter().all(|c| match c {
1355            ViewCommand::Create { slot_id, .. }
1356            | ViewCommand::Update { slot_id, .. }
1357            | ViewCommand::UpdateParams { slot_id, .. }
1358            | ViewCommand::Dispose { slot_id } => *slot_id == 2,
1359        }));
1360    }
1361
1362    #[test]
1363    fn unchanged_view_type_never_disposes() {
1364        let mut state = PlatformViewState::new();
1365        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1366        state.acknowledge(state.commands().0);
1367
1368        state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1369        assert!(
1370            !state
1371                .commands()
1372                .1
1373                .iter()
1374                .any(|c| matches!(c, ViewCommand::Dispose { .. }))
1375        );
1376    }
1377
1378    // ---- backlog cap --------------------------------------------------------
1379
1380    /// Drive `ingest` until the backlog cap trips (detected as the first poll
1381    /// where the backlog got *shorter*), never acknowledging. Returns the
1382    /// geometry offset of the last ingested frame. Panics rather than spinning
1383    /// forever if the cap somehow never trips.
1384    fn ingest_until_compaction(
1385        state: &mut PlatformViewState,
1386        mut frames_at: impl FnMut(f64) -> Vec<PlatformViewFrame>,
1387    ) -> f64 {
1388        let mut x = 0.0;
1389        loop {
1390            x += 1.0;
1391            assert!(x < 10_000.0, "backlog cap never tripped");
1392            let before = state.commands().1.len();
1393            state.ingest(&frames_at(x), &[]);
1394            if state.commands().1.len() < before {
1395                return x;
1396            }
1397        }
1398    }
1399
1400    #[test]
1401    fn backlog_cap_compacts_into_a_live_slot_replay() {
1402        let mut state = PlatformViewState::new();
1403        let x = ingest_until_compaction(&mut state, |x| {
1404            vec![
1405                frame(1, r(x, 0.0, x + 10.0, 10.0), true),
1406                frame(2, r(x + 20.0, 0.0, x + 30.0, 10.0), true),
1407            ]
1408        });
1409
1410        // Compacted into exactly the net effect: both slots re-created at their
1411        // latest geometry, nothing else.
1412        let (generation, cmds) = state.commands();
1413        assert!(cmds.len() <= MAX_PENDING_COMMANDS);
1414        assert_eq!(
1415            cmds,
1416            &[
1417                ViewCommand::Create {
1418                    slot_id: 1,
1419                    view_type: "dev.frust.Test".to_string(),
1420                    params_json: String::new(),
1421                    interactive: false,
1422                },
1423                ViewCommand::Update {
1424                    slot_id: 1,
1425                    rect: r(x, 0.0, x + 10.0, 10.0),
1426                    clip: None,
1427                    visible: true,
1428                    shields: Vec::new(),
1429                },
1430                ViewCommand::Create {
1431                    slot_id: 2,
1432                    view_type: "dev.frust.Test".to_string(),
1433                    params_json: String::new(),
1434                    interactive: false,
1435                },
1436                ViewCommand::Update {
1437                    slot_id: 2,
1438                    rect: r(x + 20.0, 0.0, x + 30.0, 10.0),
1439                    clip: None,
1440                    visible: true,
1441                    shields: Vec::new(),
1442                },
1443            ]
1444        );
1445
1446        // The whole compacted batch rides the current generation, so an ack of
1447        // an older one drops none of it and the current one drops all of it.
1448        state.acknowledge(generation - 1);
1449        assert_eq!(state.commands().1.len(), 4);
1450        state.acknowledge(generation);
1451        assert!(state.commands().1.is_empty());
1452    }
1453
1454    #[test]
1455    fn backlog_cap_keeps_a_dropped_slots_dispose() {
1456        let mut state = PlatformViewState::new();
1457        // Slot 1 lives and dies inside the un-acked window; slot 2 stays live.
1458        state.ingest(
1459            &[
1460                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1461                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1462            ],
1463            &[],
1464        );
1465        for _ in 0..DISPOSE_AFTER_MISSING_FRAMES {
1466            state.ingest(&[frame(2, r(20.0, 0.0, 30.0, 10.0), true)], &[]);
1467        }
1468        assert!(
1469            state
1470                .commands()
1471                .1
1472                .contains(&ViewCommand::Dispose { slot_id: 1 })
1473        );
1474
1475        ingest_until_compaction(&mut state, |x| {
1476            vec![frame(2, r(x + 20.0, 0.0, x + 30.0, 10.0), true)]
1477        });
1478
1479        let (_, cmds) = state.commands();
1480        // The teardown of slot 1 survives compaction (else its native view
1481        // leaks); slot 2 is replayed.
1482        assert_eq!(cmds[0], ViewCommand::Dispose { slot_id: 1 });
1483        assert!(
1484            cmds.iter()
1485                .any(|c| matches!(c, ViewCommand::Create { slot_id: 2, .. }))
1486        );
1487        assert!(
1488            !cmds
1489                .iter()
1490                .any(|c| matches!(c, ViewCommand::Create { slot_id: 1, .. }))
1491        );
1492    }
1493
1494    #[test]
1495    fn a_steadily_acking_native_side_never_trips_the_cap() {
1496        let mut state = PlatformViewState::new();
1497        for i in 0..1_000 {
1498            let x = i as f64;
1499            state.ingest(&[frame(1, r(x, 0.0, x + 10.0, 10.0), true)], &[]);
1500            assert!(state.commands().1.len() <= MAX_PENDING_COMMANDS);
1501            state.acknowledge(state.commands().0);
1502        }
1503        assert!(state.commands().1.is_empty());
1504    }
1505
1506    // ---- The release gate ---------------------------------------------------
1507
1508    #[test]
1509    fn commands_up_to_releases_only_the_due_prefix() {
1510        let mut state = PlatformViewState::new();
1511        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1512        let gen1 = state.commands().0;
1513        state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1514        let gen2 = state.commands().0;
1515        assert_eq!(state.commands().1.len(), 3);
1516
1517        // Nothing due yet: an empty slice reported at the acked generation.
1518        assert_eq!(state.commands_up_to(0), (0, &[][..]));
1519        // The first batch's frame landed.
1520        let (reported, cmds) = state.commands_up_to(gen1);
1521        assert_eq!(reported, gen1);
1522        assert_eq!(cmds.len(), 2);
1523        // Both.
1524        let (reported, cmds) = state.commands_up_to(gen2);
1525        assert_eq!(reported, gen2);
1526        assert_eq!(cmds.len(), 3);
1527    }
1528
1529    #[test]
1530    fn commands_up_to_reports_the_acked_generation_when_it_releases_nothing() {
1531        let mut state = PlatformViewState::new();
1532        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1533        state.acknowledge(state.commands().0);
1534        state.ingest(&[frame(1, r(9.0, 0.0, 19.0, 10.0), true)], &[]);
1535
1536        let (reported, cmds) = state.commands_up_to(1);
1537        assert!(cmds.is_empty());
1538        assert_eq!(reported, 1, "a held poll must not ack anything new");
1539    }
1540
1541    #[test]
1542    fn gate_releases_a_batch_once_its_own_frame_is_presented() {
1543        let mut pairing = FramePairing::new();
1544        pairing.record(1, 10);
1545        pairing.record(2, 11);
1546
1547        // Frame 10 not on screen yet: nothing releasable (generation 1 - 1).
1548        assert_eq!(pairing.releasable_generation(9, 11), 0);
1549        // Frame 10 presented, 11 still in flight: only the first batch.
1550        assert_eq!(pairing.releasable_generation(10, 11), 1);
1551        // Both on screen: everything, including any later unpaired batch.
1552        assert_eq!(pairing.releasable_generation(11, 11), u64::MAX);
1553    }
1554
1555    #[test]
1556    fn gate_releases_everything_before_the_first_held_batch() {
1557        let mut pairing = FramePairing::new();
1558        // A lifecycle batch (generation 1) was never paired with a frame; the
1559        // geometry batch behind it is held.
1560        pairing.record(2, 10);
1561        assert_eq!(pairing.releasable_generation(9, 10), 1);
1562    }
1563
1564    #[test]
1565    fn gate_releases_everything_when_nothing_is_paired() {
1566        let pairing = FramePairing::new();
1567        assert!(pairing.is_empty());
1568        assert_eq!(pairing.releasable_generation(0, 0), u64::MAX);
1569    }
1570
1571    #[test]
1572    fn escape_hatch_releases_a_batch_whose_frame_was_dropped() {
1573        let mut pairing = FramePairing::new();
1574        pairing.record(1, 5); // frame 5's scene was overtaken and never presented
1575        pairing.record(2, 6);
1576
1577        // Still within the in-flight window: held.
1578        let submitted = 5 + MAX_FRAMES_IN_FLIGHT - 1;
1579        assert_eq!(pairing.releasable_generation(4, submitted), 0);
1580        // One frame further and frame 5 is declared never-coming; frame 6 is
1581        // still inside the window, so the boundary stops there.
1582        let submitted = 5 + MAX_FRAMES_IN_FLIGHT;
1583        assert_eq!(pairing.releasable_generation(4, submitted), 1);
1584        // Both stale: everything releases.
1585        let submitted = 6 + MAX_FRAMES_IN_FLIGHT;
1586        assert_eq!(pairing.releasable_generation(4, submitted), u64::MAX);
1587    }
1588
1589    #[test]
1590    fn gate_acknowledge_drops_applied_pairings() {
1591        let mut pairing = FramePairing::new();
1592        pairing.record(1, 10);
1593        pairing.record(2, 11);
1594        pairing.acknowledge(1);
1595        // Generation 1's pairing is gone; generation 2 still gates.
1596        assert_eq!(pairing.releasable_generation(10, 11), 1);
1597        pairing.acknowledge(2);
1598        assert!(pairing.is_empty());
1599        assert_eq!(pairing.releasable_generation(0, 0), u64::MAX);
1600    }
1601
1602    #[test]
1603    fn gate_clear_releases_a_suspend_or_replay_batch() {
1604        let mut pairing = FramePairing::new();
1605        pairing.record(1, 10); // held: frame 10 never presents (app backgrounded)
1606        assert_eq!(pairing.releasable_generation(9, 10), 0);
1607        pairing.clear();
1608        assert_eq!(pairing.releasable_generation(9, 10), u64::MAX);
1609    }
1610
1611    #[test]
1612    fn idle_ticks_release_a_batch_whose_frame_never_presents() {
1613        let mut pairing = FramePairing::new();
1614        // The settle frame: batch 1 rides frame 10, which is submitted and then
1615        // never presented (any non-`Rendered` render outcome records nothing).
1616        pairing.record(1, 10);
1617        assert_eq!(
1618            pairing.releasable_generation(9, 10),
1619            0,
1620            "held while that frame could still land"
1621        );
1622
1623        // The app is now idle — no further submissions, so the display clock is
1624        // the only cursor left moving.
1625        for _ in 0..(MAX_FRAMES_IN_FLIGHT - 1) {
1626            pairing.note_idle_tick();
1627            assert_eq!(
1628                pairing.releasable_generation(9, 10),
1629                0,
1630                "still inside the staleness bound"
1631            );
1632        }
1633        pairing.note_idle_tick();
1634        assert_eq!(
1635            pairing.releasable_generation(9, 10),
1636            u64::MAX,
1637            "the idle stretch strands a frame that will never present"
1638        );
1639    }
1640
1641    #[test]
1642    fn an_idle_released_batch_is_served_exactly_once() {
1643        let mut state = PlatformViewState::new();
1644        let mut pairing = FramePairing::new();
1645        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1646        let generation = state.commands().0;
1647        pairing.record(generation, 10);
1648
1649        // Frame 10 never presented and nothing else was submitted: the poll
1650        // serves nothing at all.
1651        let releasable = pairing.releasable_generation(9, 10);
1652        assert!(state.commands_up_to(releasable).1.is_empty());
1653
1654        for _ in 0..MAX_FRAMES_IN_FLIGHT {
1655            pairing.note_idle_tick();
1656        }
1657        let releasable = pairing.releasable_generation(9, 10);
1658        let (reported, cmds) = state.commands_up_to(releasable);
1659        assert_eq!(cmds.len(), 2, "the held Create+Update finally go out");
1660        assert_eq!(reported, generation);
1661
1662        // The native side applies and acks: both halves compact, so no further
1663        // poll — however many more idle ticks land — re-serves the batch.
1664        state.acknowledge(reported);
1665        pairing.acknowledge(reported);
1666        pairing.note_idle_tick();
1667        let releasable = pairing.releasable_generation(9, 10);
1668        assert!(
1669            state.commands_up_to(releasable).1.is_empty(),
1670            "applied once, never re-applied"
1671        );
1672        assert!(pairing.is_empty());
1673    }
1674
1675    #[test]
1676    fn a_presented_frame_releases_before_the_idle_bound_is_reached() {
1677        let mut pairing = FramePairing::new();
1678        pairing.record(1, 10);
1679        // The render tail runs a tick or two behind the UI thread, so a settle
1680        // frame's present routinely lands after the loop has already idled.
1681        pairing.note_idle_tick();
1682        pairing.note_idle_tick();
1683        assert_eq!(
1684            pairing.releasable_generation(9, 10),
1685            0,
1686            "held: the frame is still well inside the bound"
1687        );
1688        assert_eq!(
1689            pairing.releasable_generation(10, 10),
1690            u64::MAX,
1691            "the present releases it exactly as before"
1692        );
1693    }
1694
1695    #[test]
1696    fn a_produced_batch_resets_the_idle_stretch() {
1697        let mut pairing = FramePairing::new();
1698        pairing.record(1, 10);
1699        for _ in 0..(MAX_FRAMES_IN_FLIGHT - 1) {
1700            pairing.note_idle_tick();
1701        }
1702        // The loop wakes and paints again before the bound trips: frames are
1703        // flowing, so both batches are aged by the submission cursor alone.
1704        pairing.record(2, 11);
1705        assert_eq!(
1706            pairing.releasable_generation(9, 11),
1707            0,
1708            "a spent idle stretch cannot strand a live pipeline"
1709        );
1710
1711        // Idling again ages them from scratch.
1712        for _ in 0..MAX_FRAMES_IN_FLIGHT {
1713            pairing.note_idle_tick();
1714        }
1715        assert_eq!(pairing.releasable_generation(9, 11), u64::MAX);
1716    }
1717
1718    #[test]
1719    fn clear_drops_the_idle_stretch_with_the_pairings() {
1720        let mut pairing = FramePairing::new();
1721        pairing.record(1, 10);
1722        for _ in 0..MAX_FRAMES_IN_FLIGHT {
1723            pairing.note_idle_tick();
1724        }
1725        // Backgrounding / surface recreation: the ticks counted against a
1726        // session whose frames are gone must not age the next session's first
1727        // batch, which is gated normally.
1728        pairing.clear();
1729        pairing.record(2, 1);
1730        assert_eq!(pairing.releasable_generation(0, 1), 1);
1731    }
1732
1733    #[test]
1734    fn gate_tracking_is_bounded_by_an_unacking_native_side() {
1735        let mut pairing = FramePairing::new();
1736        for i in 1..=(MAX_TRACKED_BATCHES as u64 * 2) {
1737            pairing.record(i, i);
1738        }
1739        // Oldest entries were dropped, so the boundary is set by the oldest
1740        // SURVIVING batch rather than by a batch from the start of the run.
1741        let oldest_tracked = MAX_TRACKED_BATCHES as u64 + 1;
1742        assert_eq!(
1743            pairing.releasable_generation(0, oldest_tracked),
1744            oldest_tracked - 1
1745        );
1746    }
1747
1748    // ---- Z-shield auto-collection --------------------
1749
1750    #[test]
1751    fn an_interactive_slot_carries_only_the_intersecting_shields() {
1752        let mut state = PlatformViewState::new();
1753        let slot = r(0.0, 0.0, 100.0, 100.0);
1754        let over = r(10.0, 10.0, 40.0, 40.0); // inside the slot
1755        let elsewhere = r(500.0, 500.0, 540.0, 540.0); // unrelated chrome
1756
1757        state.ingest(&[interactive_frame(1, slot)], &[over, elsewhere]);
1758        assert_eq!(
1759            last_update_shields(&state),
1760            vec![over],
1761            "only the shield overlapping the slot rides its Update"
1762        );
1763    }
1764
1765    #[test]
1766    fn a_non_interactive_slot_carries_no_shields_at_all() {
1767        let mut state = PlatformViewState::new();
1768        let slot = r(0.0, 0.0, 100.0, 100.0);
1769        // The same overlapping shield as above, but the slot forwards no input.
1770        state.ingest(&[frame(1, slot, true)], &[r(10.0, 10.0, 40.0, 40.0)]);
1771        assert!(
1772            last_update_shields(&state).is_empty(),
1773            "shields are meaningless to a host that isn't forwarding touches"
1774        );
1775    }
1776
1777    #[test]
1778    fn a_moving_shield_emits_an_update_and_a_jittering_one_does_not() {
1779        let mut state = PlatformViewState::new();
1780        let slot = r(0.0, 0.0, 100.0, 100.0);
1781        state.ingest(&[interactive_frame(1, slot)], &[r(10.0, 10.0, 40.0, 40.0)]);
1782        state.acknowledge(state.commands().0);
1783
1784        // Sub-epsilon drift: nothing (the same discipline as rect/clip).
1785        let changed = state.ingest(&[interactive_frame(1, slot)], &[r(10.2, 10.2, 40.2, 40.2)]);
1786        assert!(!changed, "sub-epsilon shield drift emits nothing");
1787
1788        // A real move: an Update carrying the new shield, with the slot's own
1789        // rect unchanged.
1790        let moved = r(10.0, 60.0, 40.0, 90.0);
1791        let changed = state.ingest(&[interactive_frame(1, slot)], &[moved]);
1792        assert!(changed);
1793        assert_eq!(
1794            state.commands().1,
1795            &[ViewCommand::Update {
1796                slot_id: 1,
1797                rect: slot,
1798                clip: None,
1799                visible: true,
1800                shields: vec![moved],
1801            }]
1802        );
1803    }
1804
1805    #[test]
1806    fn a_shield_leaving_the_pass_clears_it_from_the_slot() {
1807        // The replace-per-pass contract end to end: chrome that stopped
1808        // painting must stop shielding, or it keeps stealing touches forever.
1809        let mut state = PlatformViewState::new();
1810        let slot = r(0.0, 0.0, 100.0, 100.0);
1811        state.ingest(&[interactive_frame(1, slot)], &[r(10.0, 10.0, 40.0, 40.0)]);
1812        state.acknowledge(state.commands().0);
1813
1814        let changed = state.ingest(&[interactive_frame(1, slot)], &[]);
1815        assert!(changed);
1816        assert!(last_update_shields(&state).is_empty());
1817    }
1818
1819    #[test]
1820    fn manual_shield_local_rects_are_kept_and_unioned_without_duplicates() {
1821        // The escape hatch still works, and a region declared BOTH ways lands
1822        // once.
1823        let mut state = PlatformViewState::new();
1824        let slot = r(0.0, 0.0, 100.0, 100.0);
1825        let manual = r(0.0, 0.0, 20.0, 20.0);
1826        let auto = r(50.0, 50.0, 70.0, 70.0);
1827        let mut f = interactive_frame(1, slot);
1828        f.shields = vec![manual];
1829
1830        state.ingest(&[f], &[manual, auto]);
1831        assert_eq!(
1832            last_update_shields(&state),
1833            vec![manual, auto],
1834            "manual rects first, then the auto-collected ones, deduped"
1835        );
1836    }
1837
1838    #[test]
1839    fn a_replay_preserves_each_slots_resolved_shields() {
1840        // Surface recreation must rebuild the native side from the replay
1841        // alone, shields included.
1842        let mut state = PlatformViewState::new();
1843        let slot = r(0.0, 0.0, 100.0, 100.0);
1844        let over = r(10.0, 10.0, 40.0, 40.0);
1845        state.ingest(&[interactive_frame(1, slot)], &[over]);
1846        state.acknowledge(state.commands().0);
1847
1848        state.reset_for_surface_recreate();
1849        assert_eq!(last_update_shields(&state), vec![over]);
1850    }
1851
1852    // ---- Widget teardown retire -------------------------
1853
1854    #[test]
1855    fn a_retired_slot_disposes_immediately_and_the_next_ingest_is_quiet() {
1856        // The shell drains `RenderRoot::take_retired_platform_views()` after its
1857        // rebuild and retires each id; the paint that follows no longer
1858        // publishes the slot, and that absence must produce nothing further.
1859        let mut state = PlatformViewState::new();
1860        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1861        state.acknowledge(state.commands().0);
1862
1863        assert!(state.retire(1), "teardown disposes on the spot");
1864        assert_eq!(state.commands().1, &[ViewCommand::Dispose { slot_id: 1 }]);
1865        state.acknowledge(state.commands().0);
1866
1867        let changed = state.ingest(&[], &[]);
1868        assert!(
1869            !changed,
1870            "the retired slot is already forgotten — no Hide, no second Dispose"
1871        );
1872    }
1873
1874    #[test]
1875    fn a_merely_culled_slot_is_never_disposed_by_the_retire_path() {
1876        // The keep-alive contract: a scrolled-offscreen slot runs no
1877        // teardown, so no retire arrives; it is Hidden by the streak and stays
1878        // live well past the point a retire would have disposed it.
1879        let mut state = PlatformViewState::new();
1880        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1881        state.acknowledge(state.commands().0);
1882
1883        for _ in 0..(DISPOSE_AFTER_MISSING_FRAMES - 1) {
1884            state.ingest(&[], &[]);
1885        }
1886        let (_, cmds) = state.commands();
1887        assert!(
1888            cmds.iter()
1889                .any(|c| matches!(c, ViewCommand::Update { visible: false, .. })),
1890            "the culled slot is hidden"
1891        );
1892        assert!(
1893            !cmds
1894                .iter()
1895                .any(|c| matches!(c, ViewCommand::Dispose { .. })),
1896            "but never disposed without a real teardown signal"
1897        );
1898
1899        // It revives as a plain Update — the native view (and, for camera, the
1900        // session behind it) was never torn down.
1901        state.acknowledge(state.commands().0);
1902        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1903        assert!(
1904            !state
1905                .commands()
1906                .1
1907                .iter()
1908                .any(|c| matches!(c, ViewCommand::Create { .. }))
1909        );
1910    }
1911
1912    #[test]
1913    fn deterministic_replay_produces_an_identical_command_stream() {
1914        let sequence: Vec<Vec<PlatformViewFrame>> = vec![
1915            vec![frame(1, r(0.0, 0.0, 10.0, 10.0), true)],
1916            vec![
1917                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1918                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1919            ],
1920            vec![frame(2, r(20.0, 0.0, 30.0, 10.0), true)], // slot 1 missing once
1921            vec![frame(2, r(20.0, 0.0, 30.0, 10.0), true)], // slot 1 missing twice: Hide
1922        ];
1923
1924        let run = |sequence: &[Vec<PlatformViewFrame>]| -> Vec<ViewCommand> {
1925            let mut state = PlatformViewState::new();
1926            let mut all = Vec::new();
1927            for frames in sequence {
1928                state.ingest(frames, &[]);
1929                all.extend(state.commands().1.iter().cloned());
1930                // Compact after observing, mirroring a real shell's poll+ack.
1931                state.acknowledge(state.commands().0);
1932            }
1933            all
1934        };
1935
1936        assert_eq!(run(&sequence), run(&sequence));
1937    }
1938}