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//! # Z-order and overlays
146//!
147//! Every [`PlatformViewFrame`] carries its `command_index` — how many commands
148//! the frame's scene had recorded when the slot published — so the frame's
149//! slots have a painter order. [`ViewCommand::Update`]'s `z_index` is the
150//! slot's rank in that order among the pass's slots (ties keep publication
151//! order), so a host can stack its native views; on the overlay-aware path
152//! ([`PlatformViewState::ingest_scene`]) a change of rank alone emits an
153//! `Update`. Plain [`PlatformViewState::ingest`] leaves rank out of the change
154//! test, so its stream is the pre-overlay stream.
155//!
156//! Every command recorded at or past a slot's index paints **over** that
157//! slot's native view, which a host compositing the native view above or
158//! below the one frust surface cannot show. [`PlatformViewState::ingest_scene`]
159//! therefore cuts the frame's command list at its *visible* slots' indices,
160//! ordered `s0..sn`: overlay `i` covers the half-open range
161//! `s_i.command_index..s_{i+1}.command_index` (the last one runs to the end of
162//! the list), and exists only while that range holds at least one **drawing**
163//! command ([`frust_scene::Command::is_drawing`]) — a range of clip/layer
164//! brackets alone puts no pixels above the slot. The slot's own Mode B
165//! hole-punch (the `ClearRect` recorded right after it publishes, so at its
166//! own index) stays on the main surface: a leading punch is not part of the
167//! range, which opens one command later, so it never makes the overlay exist
168//! and never moves into it. Content painted before the
169//! first visible slot stays on the main surface. Each live overlay is keyed by
170//! the slot it sits above and announced with [`ViewCommand::Overlay`]
171//! (its id, the slot, its rect — v1: the whole window — and its range), re-sent
172//! whenever its range or rect changes. A hidden slot is not a cut: nothing
173//! native shows there, so the content after it belongs to the overlay below it.
174//!
175//! Overlays share the slots' backlog, generation and acknowledgement, so the
176//! release gate ([`FramePairing`]) pairs them with their frame exactly as it
177//! does slot geometry. An overlay whose content disappears is re-sent once
178//! with an **empty** range (draw nothing — its range must never point at a
179//! later frame's unrelated commands) and is disposed
180//! ([`ViewCommand::DisposeOverlay`]) after [`DISPOSE_AFTER_MISSING_FRAMES`]
181//! consecutive ingests without content, or at once when the slot under it is
182//! disposed. A frame with no platform views, or a shell calling plain
183//! [`PlatformViewState::ingest`] (no command list), produces no overlays at all.
184//!
185//! A Mode B host (translucent main surface, native views composited *below*
186//! frust) already shows frust content over a slot; it needs an overlay only for
187//! the sandwich case — native view A above frust content X **and** frust
188//! content Y above A. The differ computes overlays the same way regardless of
189//! mode; the host decides which to realise. Input is unaffected: the z-shields
190//! above already express "frust content above a slot" for hit-testing.
191//!
192//! # Skip-safety
193//!
194//! Nothing here special-cases a gate-skipped frame
195//! (`docs/SHELLS_ARCHITECTURE.md`'s `frame_gate` module); the contract is
196//! entirely "don't call [`ingest`](PlatformViewState::ingest) on a `Skip`".
197//! Paint doesn't run on a skip, so no rect can appear to "move" either
198//! (`PaintCtx::visible_rect`/scroll state can't have changed).
199
200use std::collections::{BTreeMap, BTreeSet, VecDeque};
201use std::ops::Range;
202
203use frust_core::widget::PlatformViewFrame;
204use frust_scene::Command;
205use kurbo::Rect;
206
207/// Below this many logical px of difference on every edge, a rect/clip change
208/// is not worth an [`ViewCommand::Update`] — see the module docs' Command
209/// semantics section. Chosen to absorb floating-point layout jitter (e.g. a
210/// scroll offset accumulating sub-pixel drift) without visibly lagging a
211/// genuinely moving native sibling view.
212pub const EPSILON_PX: f64 = 0.5;
213
214/// Consecutive `ingest` calls a previously-live, previously-visible slot may
215/// be absent from `frames` before it is Hidden (`Update { visible: false }`).
216/// See the module docs' Command semantics section.
217pub const HIDE_AFTER_MISSING_FRAMES: u32 = 2;
218
219/// Consecutive `ingest` calls a slot may be absent from `frames` before it is
220/// Disposed outright. A heuristic streak, not a real teardown signal — see
221/// the module docs' Widget teardown detection.
222pub const DISPOSE_AFTER_MISSING_FRAMES: u32 = 30;
223
224/// Upper bound on the not-yet-acknowledged command backlog before it is
225/// compacted into its own net effect — see the module docs' Backlog cap.
226///
227/// Sized to be unreachable in normal operation (a steadily-acking native side
228/// keeps the backlog at one frame's worth, single digits) while still bounding
229/// a stuck one: the compaction itself costs `2 × live slots` commands, so the
230/// cap only has to sit comfortably above that for any realistic slot count.
231pub const MAX_PENDING_COMMANDS: usize = 256;
232
233/// How far a batch's frust frame may fall behind the submission cursor before
234/// [`FramePairing`] releases the batch anyway — the release gate's staleness
235/// escape hatch. **Required, not defensive**: the UI→render scene channel is
236/// depth-1 latest-wins, so a scene the UI thread submitted may be overtaken and
237/// never rendered at all, and a dropped frame's id never presents. Without this
238/// arm one dropped scene strands every later batch forever — a submission
239/// counter is not a presented counter.
240///
241/// The same bound counts idle display ticks
242/// ([`FramePairing::note_idle_tick`]), which is what keeps the hatch reachable
243/// once the frame loop stops producing frames and the submission cursor freezes
244/// with it.
245///
246/// Measured pipeline depth on physical test devices was 2–4 frames, so
247/// 12 sits well above the working range while bounding worst-case staleness to
248/// ~100 ms at 120 Hz.
249pub const MAX_FRAMES_IN_FLIGHT: u64 = 12;
250
251/// One native-sibling-compositor instruction — the differ's whole output
252/// vocabulary. `Clone + PartialEq + Debug` so a golden
253/// test can assert an exact command sequence.
254#[derive(Clone, Debug, PartialEq)]
255pub enum ViewCommand {
256    /// Create a new native view for `slot_id`. Always immediately followed,
257    /// in the same batch, by an [`ViewCommand::Update`] placing it.
258    Create {
259        /// Stable per-widget-instance id (see `frust_core::widget::next_slot_id`).
260        slot_id: u64,
261        /// `"dev.frust.<Factory>"` view-factory identifier.
262        view_type: String,
263        /// Opaque creation params for the native factory (may be empty).
264        params_json: String,
265        /// Mode B input forwarding: whether a
266        /// touch-DOWN inside this slot's rect hands the gesture to the
267        /// native sibling. Fixed at create in v1 (no live flip).
268        interactive: bool,
269    },
270    /// Place/resize/clip/show-or-hide an already-created slot. Logical px,
271    /// absolute window coordinates (mirroring [`PlatformViewFrame`]) — the
272    /// receiving shell scales to physical px at its own FFI boundary.
273    Update {
274        /// Which slot this applies to.
275        slot_id: u64,
276        /// Absolute paint bounds.
277        rect: Rect,
278        /// Visible-rect intersection, or `None` when fully visible.
279        clip: Option<Rect>,
280        /// `false` ⇒ hide the native view without disposing it.
281        visible: bool,
282        /// The z-shield list: absolute-coordinate
283        /// regions where frust content over the slot keeps winning input.
284        /// Meaningful only for an interactive slot; empty otherwise.
285        shields: Vec<Rect>,
286        /// The slot's rank in painter order among the pass's slots (by
287        /// `PlatformViewFrame::command_index`, ties in publication order):
288        /// `0` is painted first, so a host stacks a higher `z_index` above a
289        /// lower one. See the module docs' Z-order and overlays.
290        z_index: u32,
291    },
292    /// `params_json` changed (`params_generation` advanced) with no
293    /// necessary rect/clip/visible change — a separate command so a shell
294    /// doesn't have to re-place a view just to hand it new creation params.
295    UpdateParams {
296        /// Which slot this applies to.
297        slot_id: u64,
298        /// The new opaque params payload.
299        params_json: String,
300    },
301    /// Tear down a slot's native view entirely. A `slot_id` reused after this
302    /// (the same numeric id reappearing in a later `ingest`) is treated as
303    /// brand-new — see the module docs' Widget teardown detection.
304    Dispose {
305        /// Which slot to tear down.
306        slot_id: u64,
307    },
308    /// Create or re-place the overlay carrying the frust content painted above
309    /// `above_slot_id` — see the module docs' Z-order and overlays. A host
310    /// that has no overlay `overlay_id` yet creates one; otherwise it adopts
311    /// the new rect and range. An overlay stacks directly above its slot's
312    /// native view and below any slot with a higher `z_index`.
313    Overlay {
314        /// Stable id for this overlay, allocated by the differ; never reused
315        /// within one [`PlatformViewState`].
316        overlay_id: u64,
317        /// The slot this overlay sits directly above.
318        above_slot_id: u64,
319        /// Where the overlay covers, logical px, absolute window coordinates.
320        /// v1: the whole window (the viewport the shell passed to
321        /// [`PlatformViewState::ingest_scene`]).
322        rect: Rect,
323        /// The half-open range of the frame's command list (the slice passed
324        /// to [`PlatformViewState::ingest_scene`]) the overlay renders, in
325        /// painter order. Empty when the overlay's content has disappeared —
326        /// it then draws nothing until it is re-sent or disposed.
327        command_range: Range<usize>,
328    },
329    /// Tear an overlay down. A `Dispose`-style no-op for an id the host does
330    /// not have.
331    DisposeOverlay {
332        /// Which overlay to tear down.
333        overlay_id: u64,
334    },
335}
336
337/// Per-slot last-emitted state the differ compares each `ingest` call
338/// against, to decide whether anything actually changed.
339#[derive(Clone, Debug)]
340struct SlotEntry {
341    view_type: String,
342    params_json: String,
343    params_generation: u64,
344    interactive: bool,
345    last_rect: Rect,
346    last_clip: Option<Rect>,
347    last_visible: bool,
348    last_shields: Vec<Rect>,
349    last_z_index: u32,
350    /// Consecutive `ingest` calls this slot has been absent from `frames`.
351    /// Reset to `0` the instant it reappears.
352    missing_streak: u32,
353}
354
355/// Per-overlay last-emitted state, keyed in [`PlatformViewState`] by the slot
356/// the overlay sits above.
357#[derive(Clone, Debug)]
358struct OverlayEntry {
359    overlay_id: u64,
360    last_rect: Rect,
361    last_range: Range<usize>,
362    /// Consecutive ingests this overlay has had no drawing content.
363    missing_streak: u32,
364}
365
366/// The differ: per-slot last-seen state plus the accumulated, not-yet-acknowledged
367/// [`ViewCommand`] backlog. See the module docs for the full semantics.
368///
369/// `live` is a [`BTreeMap`] (keyed by `slot_id`), not a `HashMap` — iteration
370/// order must be deterministic (ascending `slot_id`) for the "same ingest
371/// sequence ⇒ identical command stream" golden-test guarantee; a frame's own
372/// `Create`+`Update` ordering is separately
373/// guaranteed by iterating `frames` itself in the caller's given order.
374#[derive(Debug, Default)]
375pub struct PlatformViewState {
376    live: BTreeMap<u64, SlotEntry>,
377    /// Live overlays keyed by the slot they sit above (a `BTreeMap` for the
378    /// same determinism `live` needs).
379    overlays: BTreeMap<u64, OverlayEntry>,
380    /// The last overlay id handed out; ids start at `1`.
381    last_overlay_id: u64,
382    /// Flat, contiguous command backlog — kept flat (rather than one `Vec`
383    /// per generation) so [`commands`](Self::commands) can return a zero-copy
384    /// `&[ViewCommand]` slice.
385    pending: Vec<ViewCommand>,
386    /// Parallel to `pending`: the generation each entry was pushed under.
387    /// Monotonically non-decreasing (generations only ever go up), which is
388    /// what lets [`acknowledge`](Self::acknowledge) binary-search the
389    /// compaction boundary.
390    pending_gens: Vec<u64>,
391    generation: u64,
392    acked_generation: u64,
393    /// Whether the backlog-cap overflow has already been logged — once per
394    /// state, so a permanently-unacking native side costs one log line, not one
395    /// per compaction (module docs' Backlog cap).
396    overflow_logged: bool,
397}
398
399impl PlatformViewState {
400    /// A fresh differ with no live slots and generation `0`.
401    pub fn new() -> Self {
402        Self::default()
403    }
404
405    /// Feed one paint pass's frames (`RenderRoot::platform_view_frames()`) and
406    /// that same pass's z-shield rects (`RenderRoot::input_shields()`).
407    /// Returns `true` if this call produced at least one command (i.e. the
408    /// generation advanced) — a caller that only cares "did anything change"
409    /// can skip calling [`commands`](Self::commands) entirely when this is
410    /// `false`.
411    ///
412    /// `input_shields` is a flat, slot-agnostic list (core never associates a
413    /// shield with a slot); this method owns the intersection rule — see the
414    /// module docs' Z-shields section. Pass `&[]` when a caller has no shield
415    /// channel: every slot then ships only its own manually declared rects.
416    ///
417    /// Do not call this on a gate-skipped frame — see the module docs'
418    /// Skip-safety section.
419    ///
420    /// Knows nothing of the frame's commands, so it computes no overlays, and
421    /// a change of rank alone emits no `Update`: its stream is the pre-overlay
422    /// stream, which a host ignoring `z_index` relies on. Any overlay a
423    /// previous `ingest_scene` created lapses as content-less.
424    pub fn ingest(&mut self, frames: &[PlatformViewFrame], input_shields: &[Rect]) -> bool {
425        self.ingest_pass(frames, input_shields, &[], Rect::ZERO, false)
426    }
427
428    /// [`ingest`](Self::ingest) plus the overlay pass (module docs' Z-order and
429    /// overlays): `commands` is the painted scene's command list
430    /// (`Scene::commands()`, the same frame `frames` came from) and `viewport`
431    /// the window's logical bounds, which every v1 overlay covers.
432    ///
433    /// The command slice is borrowed, not copied: the overlay pass scans only
434    /// the ranges past the first visible slot, stopping at each range's first
435    /// drawing command, so no per-frame classification buffer is built. A
436    /// frame without platform views scans nothing.
437    pub fn ingest_scene(
438        &mut self,
439        frames: &[PlatformViewFrame],
440        input_shields: &[Rect],
441        commands: &[Command],
442        viewport: Rect,
443    ) -> bool {
444        self.ingest_pass(frames, input_shields, commands, viewport, true)
445    }
446
447    /// The pass behind [`ingest`](Self::ingest) and
448    /// [`ingest_scene`](Self::ingest_scene). `overlay_aware` gates the rank
449    /// comparison in the change test: only the overlay-aware path announces a
450    /// rank-only change. Either way `last_z_index` tracks the current rank, so
451    /// an `Update` emitted for any other reason carries it.
452    fn ingest_pass(
453        &mut self,
454        frames: &[PlatformViewFrame],
455        input_shields: &[Rect],
456        commands: &[Command],
457        viewport: Rect,
458        overlay_aware: bool,
459    ) -> bool {
460        let mut batch = Vec::new();
461        let mut seen = std::collections::BTreeSet::new();
462        let order = paint_order(frames);
463        let mut z_of = vec![0u32; frames.len()];
464        for (rank, &i) in order.iter().enumerate() {
465            z_of[i] = u32::try_from(rank).unwrap_or(u32::MAX);
466        }
467
468        for (i, frame) in frames.iter().enumerate() {
469            let z_index = z_of[i];
470            seen.insert(frame.slot_id);
471            // The shields this slot actually ships this pass (module docs'
472            // Z-shields): its own manual rects plus the auto-collected ones
473            // overlapping it, or nothing at all when it isn't interactive.
474            let shields = resolve_shields(frame, input_shields);
475            // The slot's `view_type` changed under a live id. A different
476            // `view_type` resolves to a different native factory, so the old
477            // view must be torn down and a new one built — in THIS batch. Drop
478            // the tracked entry first, so the `None` arm below emits the fresh
479            // `Create` + `Update` after the `Dispose`, exactly as it would for
480            // an id it had never seen.
481            if let Some(entry) = self.live.get(&frame.slot_id)
482                && entry.view_type != frame.view_type
483            {
484                // The overlay above the old view goes with it; the overlay
485                // pass below re-creates one (fresh id) if content still sits
486                // above the new view.
487                dispose_overlay_above(&mut self.overlays, frame.slot_id, &mut batch);
488                batch.push(ViewCommand::Dispose {
489                    slot_id: frame.slot_id,
490                });
491                self.live.remove(&frame.slot_id);
492            }
493            match self.live.get_mut(&frame.slot_id) {
494                None => {
495                    batch.push(ViewCommand::Create {
496                        slot_id: frame.slot_id,
497                        view_type: frame.view_type.clone(),
498                        params_json: frame.params_json.clone(),
499                        interactive: frame.interactive,
500                    });
501                    batch.push(ViewCommand::Update {
502                        slot_id: frame.slot_id,
503                        rect: frame.rect,
504                        clip: frame.clip,
505                        visible: frame.visible,
506                        shields: shields.clone(),
507                        z_index,
508                    });
509                    self.live.insert(
510                        frame.slot_id,
511                        SlotEntry {
512                            view_type: frame.view_type.clone(),
513                            params_json: frame.params_json.clone(),
514                            params_generation: frame.params_generation,
515                            interactive: frame.interactive,
516                            last_rect: frame.rect,
517                            last_clip: frame.clip,
518                            last_visible: frame.visible,
519                            last_shields: shields,
520                            last_z_index: z_index,
521                            missing_streak: 0,
522                        },
523                    );
524                }
525                Some(entry) => {
526                    entry.missing_streak = 0;
527                    if entry.last_visible != frame.visible
528                        || rect_changed(entry.last_rect, frame.rect)
529                        || clip_changed(entry.last_clip, frame.clip)
530                        || shields_changed(&entry.last_shields, &shields)
531                        || (overlay_aware && entry.last_z_index != z_index)
532                    {
533                        batch.push(ViewCommand::Update {
534                            slot_id: frame.slot_id,
535                            rect: frame.rect,
536                            clip: frame.clip,
537                            visible: frame.visible,
538                            shields: shields.clone(),
539                            z_index,
540                        });
541                        entry.last_rect = frame.rect;
542                        entry.last_clip = frame.clip;
543                        entry.last_visible = frame.visible;
544                        entry.last_shields = shields;
545                    }
546                    entry.last_z_index = z_index;
547                    if entry.params_generation != frame.params_generation {
548                        batch.push(ViewCommand::UpdateParams {
549                            slot_id: frame.slot_id,
550                            params_json: frame.params_json.clone(),
551                        });
552                        entry.params_json = frame.params_json.clone();
553                        entry.params_generation = frame.params_generation;
554                    }
555                }
556            }
557        }
558
559        // Missing-slot bookkeeping: any previously-live slot absent from this
560        // pass's frames. Iterating `self.live` (a BTreeMap) keeps this
561        // deterministic across runs.
562        let mut disposed = Vec::new();
563        for (&slot_id, entry) in self.live.iter_mut() {
564            if seen.contains(&slot_id) {
565                continue;
566            }
567            entry.missing_streak += 1;
568            if entry.missing_streak == HIDE_AFTER_MISSING_FRAMES && entry.last_visible {
569                batch.push(ViewCommand::Update {
570                    slot_id,
571                    rect: entry.last_rect,
572                    clip: entry.last_clip,
573                    visible: false,
574                    shields: entry.last_shields.clone(),
575                    z_index: entry.last_z_index,
576                });
577                entry.last_visible = false;
578            }
579            if entry.missing_streak >= DISPOSE_AFTER_MISSING_FRAMES {
580                dispose_overlay_above(&mut self.overlays, slot_id, &mut batch);
581                batch.push(ViewCommand::Dispose { slot_id });
582                disposed.push(slot_id);
583            }
584        }
585        for slot_id in disposed {
586            self.live.remove(&slot_id);
587        }
588
589        self.ingest_overlays(frames, &order, commands, viewport, &mut batch);
590
591        self.push_batch(batch)
592    }
593
594    /// The overlay pass (module docs' Z-order and overlays): cut `commands` at
595    /// the visible slots' indices in painter order, (re)announce every overlay
596    /// whose range holds a drawing command, and age the rest toward disposal.
597    fn ingest_overlays(
598        &mut self,
599        frames: &[PlatformViewFrame],
600        order: &[usize],
601        commands: &[Command],
602        viewport: Rect,
603        batch: &mut Vec<ViewCommand>,
604    ) {
605        let total = commands.len();
606        let mut cuts = order
607            .iter()
608            .map(|&i| &frames[i])
609            .filter(|frame| frame.visible)
610            .peekable();
611        let mut with_content = BTreeSet::new();
612        while let Some(slot) = cuts.next() {
613            let start = slot.command_index.min(total);
614            let end = cuts
615                .peek()
616                .map_or(total, |next| next.command_index.min(total))
617                .max(start);
618            // A Mode B slot's own hole-punch is recorded right after it
619            // publishes, so it sits AT the slot's index (a `ClearRect` has no
620            // other producer). It erases what is beneath the slot rather than
621            // painting over it, so it never makes an overlay exist on its own,
622            // and the range opens after it: the punch stays in the main
623            // segment, where it erases what the main surface painted there.
624            let content_from = match commands.get(start) {
625                Some(Command::ClearRect { .. }) => (start + 1).min(end),
626                _ => start,
627            };
628            if !commands[content_from..end].iter().any(Command::is_drawing) {
629                continue;
630            }
631            let range = content_from..end;
632            with_content.insert(slot.slot_id);
633            match self.overlays.get_mut(&slot.slot_id) {
634                Some(entry) => {
635                    entry.missing_streak = 0;
636                    if entry.last_range != range || rect_changed(entry.last_rect, viewport) {
637                        batch.push(ViewCommand::Overlay {
638                            overlay_id: entry.overlay_id,
639                            above_slot_id: slot.slot_id,
640                            rect: viewport,
641                            command_range: range.clone(),
642                        });
643                        entry.last_range = range;
644                        entry.last_rect = viewport;
645                    }
646                }
647                None => {
648                    self.last_overlay_id += 1;
649                    let overlay_id = self.last_overlay_id;
650                    batch.push(ViewCommand::Overlay {
651                        overlay_id,
652                        above_slot_id: slot.slot_id,
653                        rect: viewport,
654                        command_range: range.clone(),
655                    });
656                    self.overlays.insert(
657                        slot.slot_id,
658                        OverlayEntry {
659                            overlay_id,
660                            last_rect: viewport,
661                            last_range: range,
662                            missing_streak: 0,
663                        },
664                    );
665                }
666            }
667        }
668
669        let mut disposed = Vec::new();
670        for (&slot_id, entry) in self.overlays.iter_mut() {
671            if with_content.contains(&slot_id) {
672                continue;
673            }
674            entry.missing_streak += 1;
675            if entry.missing_streak >= DISPOSE_AFTER_MISSING_FRAMES {
676                batch.push(ViewCommand::DisposeOverlay {
677                    overlay_id: entry.overlay_id,
678                });
679                disposed.push(slot_id);
680            } else if !entry.last_range.is_empty() {
681                // Content gone: empty the range at once so the overlay never
682                // renders a later frame's unrelated commands at stale indices.
683                let empty = entry.last_range.start..entry.last_range.start;
684                batch.push(ViewCommand::Overlay {
685                    overlay_id: entry.overlay_id,
686                    above_slot_id: slot_id,
687                    rect: entry.last_rect,
688                    command_range: empty.clone(),
689                });
690                entry.last_range = empty;
691            }
692        }
693        for slot_id in disposed {
694            self.overlays.remove(&slot_id);
695        }
696    }
697
698    /// Backgrounding path: synthesize
699    /// `Update { visible: false }` for every currently-live, currently-visible
700    /// slot **immediately**, regardless of its missing streak. A shell calls
701    /// this on the platform's backgrounding hook (Android's `onPause`, iOS's
702    /// `frust_pause`) — while backgrounded, paint doesn't run, so
703    /// [`ingest`](Self::ingest) is never called to drive the ordinary
704    /// [`HIDE_AFTER_MISSING_FRAMES`]-streak Hide path; without this explicit
705    /// call a backgrounded native sibling view would stay visible (and,
706    /// depending on the platform, keep rendering/consuming resources) until the
707    /// app resumes and repaints. A slot already hidden (`last_visible ==
708    /// false`) emits nothing for it, so calling this on an already-suspended
709    /// state (or with no live slots) is a cheap no-op. The `Update` reuses
710    /// each slot's last-known rect/clip — no `frames` argument, unlike
711    /// [`ingest`](Self::ingest) — since backgrounding doesn't produce a fresh
712    /// paint pass to source one from.
713    pub fn suspend_all(&mut self) -> bool {
714        let mut batch = Vec::new();
715        for (&slot_id, entry) in self.live.iter_mut() {
716            if entry.last_visible {
717                batch.push(ViewCommand::Update {
718                    slot_id,
719                    rect: entry.last_rect,
720                    clip: entry.last_clip,
721                    visible: false,
722                    shields: entry.last_shields.clone(),
723                    z_index: entry.last_z_index,
724                });
725                entry.last_visible = false;
726            }
727        }
728        self.push_batch(batch)
729    }
730
731    /// Explicit retire: dispose `slot_id` right now regardless of its missing
732    /// streak, for a shell with a real teardown signal (see the module docs'
733    /// Widget teardown detection). A no-op (returns `false`) if
734    /// `slot_id` isn't currently live (already disposed, or never created).
735    /// The overlay above the slot, if any, is disposed with it, first.
736    pub fn retire(&mut self, slot_id: u64) -> bool {
737        if self.live.remove(&slot_id).is_some() {
738            let mut batch = Vec::new();
739            dispose_overlay_above(&mut self.overlays, slot_id, &mut batch);
740            batch.push(ViewCommand::Dispose { slot_id });
741            self.push_batch(batch)
742        } else {
743            false
744        }
745    }
746
747    /// Re-emit `Create` + `Update` for every currently-live slot, using each
748    /// slot's last-known state (including a currently-hidden slot's
749    /// `visible: false`) — the backgrounding/rotation replay a shell calls
750    /// when it knows the native side just lost its whole view hierarchy
751    /// (surface recreation) and needs every native sibling rebuilt from
752    /// scratch, not just the ones that would otherwise change.
753    pub fn reset_for_surface_recreate(&mut self) -> bool {
754        let mut batch = Vec::new();
755        self.replay_live_into(&mut batch);
756        self.push_batch(batch)
757    }
758
759    /// Append a full `Create` + `Update` replay of every live slot (ascending
760    /// `slot_id`, each preserving its last-known rect/clip/visibility), then an
761    /// `Overlay` for every live overlay (ascending above-slot id) — the
762    /// shared body of [`reset_for_surface_recreate`](Self::reset_for_surface_recreate)
763    /// and the backlog-cap compaction (module docs' Backlog cap), which are the
764    /// same "rebuild everything from this batch alone" statement.
765    fn replay_live_into(&self, batch: &mut Vec<ViewCommand>) {
766        for (&slot_id, entry) in self.live.iter() {
767            batch.push(ViewCommand::Create {
768                slot_id,
769                view_type: entry.view_type.clone(),
770                params_json: entry.params_json.clone(),
771                interactive: entry.interactive,
772            });
773            batch.push(ViewCommand::Update {
774                slot_id,
775                rect: entry.last_rect,
776                clip: entry.last_clip,
777                visible: entry.last_visible,
778                shields: entry.last_shields.clone(),
779                z_index: entry.last_z_index,
780            });
781        }
782        for (&above_slot_id, entry) in self.overlays.iter() {
783            batch.push(ViewCommand::Overlay {
784                overlay_id: entry.overlay_id,
785                above_slot_id,
786                rect: entry.last_rect,
787                command_range: entry.last_range.clone(),
788            });
789        }
790    }
791
792    /// Snapshot for a shell's peek getter: the current generation plus the
793    /// **entire** not-yet-acknowledged command backlog (not just the latest
794    /// ingest's batch) — see the module docs' Generation/acknowledgement
795    /// section.
796    pub fn commands(&self) -> (u64, &[ViewCommand]) {
797        (self.generation, &self.pending)
798    }
799
800    /// The prefix of the backlog whose generation is `<= max_generation`, plus
801    /// that prefix's own generation — the release-gate half of
802    /// [`commands`](Self::commands) (see the module docs' Frame pairing).
803    ///
804    /// [`commands`](Self::commands) hands over the whole backlog immediately,
805    /// which is what makes a hosted view's geometry run ahead of the frust
806    /// content it belongs to: the geometry lands in the window's next frame
807    /// while the frust buffer it matches is still queued behind the compositor.
808    /// A shell that knows which frust frame produced each batch — and which
809    /// frames have actually been presented ([`FramePairing`]) — releases only
810    /// the batches whose frame is already on screen.
811    ///
812    /// `pending_gens` is monotonically non-decreasing, so the prefix is a
813    /// `partition_point` — the same boundary search
814    /// [`acknowledge`](Self::acknowledge) uses. The **reported** generation is
815    /// the last released entry's, not `self.generation`, so the native side's
816    /// acknowledgement round-trip stays exactly as truthful as before: it only
817    /// ever acks what it was actually handed. An empty prefix reports the
818    /// already-acknowledged generation, which a shell's FFI glue reads as "no
819    /// change" — the same cheap no-op poll as an unchanged frame.
820    pub fn commands_up_to(&self, max_generation: u64) -> (u64, &[ViewCommand]) {
821        let end = self.pending_gens.partition_point(|&g| g <= max_generation);
822        let reported = if end == 0 {
823            self.acked_generation
824        } else {
825            self.pending_gens[end - 1]
826        };
827        (reported, &self.pending[..end])
828    }
829
830    /// The native side has finished applying everything up through
831    /// `generation` (an argument the shell round-trips from a prior
832    /// [`commands`](Self::commands) call) — compacts the backlog, dropping
833    /// every entry whose generation is `<= generation`. Acknowledging a
834    /// generation older than (or equal to) one already acknowledged is a
835    /// no-op.
836    pub fn acknowledge(&mut self, generation: u64) {
837        if generation <= self.acked_generation {
838            return;
839        }
840        self.acked_generation = generation;
841        let keep_from = self
842            .pending_gens
843            .partition_point(|&g| g <= self.acked_generation);
844        self.pending.drain(0..keep_from);
845        self.pending_gens.drain(0..keep_from);
846    }
847
848    fn push_batch(&mut self, batch: Vec<ViewCommand>) -> bool {
849        if batch.is_empty() {
850            return false;
851        }
852        self.generation += 1;
853        let batch_generation = self.generation;
854        self.pending_gens
855            .extend(std::iter::repeat_n(batch_generation, batch.len()));
856        self.pending.extend(batch);
857        if self.pending.len() > MAX_PENDING_COMMANDS {
858            self.compact_to_net_effect();
859        }
860        true
861    }
862
863    /// Backlog-cap overflow (module docs' Backlog cap): replace the whole
864    /// not-yet-acknowledged backlog with the net effect of applying it — one
865    /// `DisposeOverlay` per overlay the backlog tore down (ascending id), one
866    /// `Dispose` per slot the backlog tore down (ascending `slot_id`), then a
867    /// full replay of every live slot and overlay.
868    ///
869    /// The `Dispose`s must survive: a slot created *before* the un-acked window
870    /// and disposed inside it is a native view the host already built and would
871    /// otherwise never be told to tear down — a leak. A slot that was disposed
872    /// **and** is live again (a `slot_id` reuse, or a `view_type` swap)
873    /// keeps its `Dispose` too, and the replay's `Create` rebuilds it from the
874    /// current `view_type`/params — the only ordering that survives a factory
875    /// change.
876    ///
877    /// The whole compacted batch carries the current generation, so an ack of
878    /// an older generation drops none of it, and `pending_gens` stays
879    /// non-decreasing for both boundary searches.
880    fn compact_to_net_effect(&mut self) {
881        let disposed: BTreeSet<u64> = self
882            .pending
883            .iter()
884            .filter_map(|cmd| match cmd {
885                ViewCommand::Dispose { slot_id } => Some(*slot_id),
886                _ => None,
887            })
888            .collect();
889        let disposed_overlays: BTreeSet<u64> = self
890            .pending
891            .iter()
892            .filter_map(|cmd| match cmd {
893                ViewCommand::DisposeOverlay { overlay_id } => Some(*overlay_id),
894                _ => None,
895            })
896            .collect();
897        let mut compacted = Vec::with_capacity(
898            disposed_overlays.len() + disposed.len() + self.live.len() * 2 + self.overlays.len(),
899        );
900        for overlay_id in disposed_overlays {
901            compacted.push(ViewCommand::DisposeOverlay { overlay_id });
902        }
903        for slot_id in disposed {
904            compacted.push(ViewCommand::Dispose { slot_id });
905        }
906        self.replay_live_into(&mut compacted);
907
908        if !self.overflow_logged {
909            self.overflow_logged = true;
910            log::warn!(
911                "frust-shell: platform-view command backlog exceeded {MAX_PENDING_COMMANDS} \
912                 un-acknowledged entries (is the native side polling?); compacted {} entries \
913                 into a {}-command replay. Logged once per process.",
914                self.pending.len(),
915                compacted.len()
916            );
917        }
918
919        self.pending_gens.clear();
920        self.pending_gens.resize(compacted.len(), self.generation);
921        self.pending = compacted;
922    }
923}
924
925/// The release gate's bookkeeping: which frust frame produced each command
926/// batch, and therefore which batches may be handed to the native side yet.
927///
928/// A shell records `(generation, frame_id)` for every batch
929/// [`PlatformViewState::ingest`] produces (the frame that is about to be
930/// submitted carries the geometry the batch describes), reads back the id of
931/// the last frame the render side actually **presented**, and serves
932/// [`PlatformViewState::commands_up_to`] the resulting boundary. Pure logic:
933/// no clock, no platform types, no knowledge of how a shell obtains the two
934/// cursors — which is what makes the ordering testable on the host, since a
935/// mobile shell's own frame loop is not.
936///
937/// # Why the frame *id*, not a count
938///
939/// The UI→render scene channel is depth-1 latest-wins, so submissions and
940/// presents are not the same clock — under the measured 120 Hz-submit /
941/// 60 Hz-present regime they diverge by half the frames. Pairing against a
942/// presented *count* over-delays by exactly the dropped frames; pairing against
943/// the id of the frame that actually presented does not. The
944/// price of the id is that a dropped frame's id never arrives, which is what
945/// [`MAX_FRAMES_IN_FLIGHT`] exists for.
946///
947/// # Lifecycle
948///
949/// The pairing is a *smoothing* device, not a correctness barrier: a shell
950/// [`clear`](Self::clear)s it whenever the frames it refers to stop being
951/// meaningful (backgrounding, surface recreation), after which the whole
952/// backlog releases immediately — a hide or a full replay must reach the native
953/// side even though no further frame will ever present to unlock it.
954///
955/// Going *idle* is the third such moment, and the only one with no lifecycle
956/// callback to hang a `clear` on: the loop simply stops producing frames while
957/// the display keeps ticking. A shell reports those ticks
958/// ([`note_idle_tick`](Self::note_idle_tick)) so the staleness hatch stays
959/// reachable there — without them a batch whose frame never presented is held
960/// for the process lifetime (see that method for the full failure mode).
961#[derive(Debug, Default)]
962pub struct FramePairing {
963    /// `(generation, frame_id)` per produced batch, oldest first. Both
964    /// components are monotonically non-decreasing along the queue, which is
965    /// what lets [`releasable_generation`](Self::releasable_generation) stop at
966    /// the first held entry.
967    due: VecDeque<(u64, u64)>,
968    /// Display ticks that produced no frust frame since the last
969    /// [`record`](Self::record) — the idle half of the staleness cursor (see
970    /// [`note_idle_tick`](Self::note_idle_tick)). Reset by every produced batch
971    /// and by [`clear`](Self::clear), so it only ever measures the *current*
972    /// idle stretch: an entry recorded before an earlier stretch is aged by
973    /// fewer ticks than really elapsed, which errs toward holding, never toward
974    /// releasing early.
975    idle_ticks: u64,
976}
977
978/// Upper bound on tracked-but-unreleased batches. Reached only if the native
979/// side stops acking (the same failure mode the backlog cap covers); dropping
980/// the **oldest** entry is the safe direction — the boundary search then
981/// releases it, and the oldest entry is by construction the one closest to
982/// the [`MAX_FRAMES_IN_FLIGHT`] escape hatch anyway.
983const MAX_TRACKED_BATCHES: usize = 256;
984
985impl FramePairing {
986    /// Fresh, empty pairing — releases everything until a batch is recorded.
987    pub fn new() -> Self {
988        Self::default()
989    }
990
991    /// Remember that the batch published under `generation` describes geometry
992    /// painted by frust frame `frame_id`.
993    pub fn record(&mut self, generation: u64, frame_id: u64) {
994        if self.due.len() >= MAX_TRACKED_BATCHES {
995            self.due.pop_front();
996        }
997        self.due.push_back((generation, frame_id));
998        // A produced frame ends the idle stretch: this batch's own frame is
999        // genuinely in flight, so from here the submission cursor is the honest
1000        // clock to age every entry by again.
1001        self.idle_ticks = 0;
1002    }
1003
1004    /// Report one display tick on which the frame loop produced **no** frust
1005    /// frame — the tick a shell's frame gate skipped. Ages every held batch
1006    /// exactly as a submission does (see
1007    /// [`releasable_generation`](Self::releasable_generation)).
1008    ///
1009    /// **Why the release gate needs an idle clock at all.** A batch is released
1010    /// on one of two events: its own frame is confirmed *presented*, or the
1011    /// submission cursor climbs [`MAX_FRAMES_IN_FLIGHT`] past it. A present is
1012    /// recorded only for a `Rendered` render outcome, so any other one — an
1013    /// encode or acquire skipped against a surface that is not ready, a
1014    /// swapchain reconfigure, a lost surface, an encode/acquire error — leaves
1015    /// the batch's frame permanently unconfirmed. That is survivable while
1016    /// frames keep flowing, because the submission cursor walks past it within
1017    /// twelve frames. Once the app settles, though, the submission cursor stops
1018    /// too, and *neither* arm can ever fire again: the settled geometry is held
1019    /// for the process lifetime and the native sibling stays parked at whatever
1020    /// mid-animation rect it last applied — device-observed as a camera preview
1021    /// stuck black behind correct-but-never-delivered geometry, healed only by a
1022    /// surface recreate (which `clear`s the pairing). The display clock is the
1023    /// one cursor still moving at idle, and an idle tick carries exactly the
1024    /// evidence the submission cursor does: that frame is not coming.
1025    ///
1026    /// **Why an idle *bound* and not an immediate release.** Releasing the whole
1027    /// backlog on the last painted frame is not expressible: a touch-driven drag
1028    /// paints with `needs_frame == false` every frame, so "this paint asked for
1029    /// no continuation frame" cannot tell a settle frame from a mid-drag one,
1030    /// and keying the release on it would turn the gate off for exactly the
1031    /// scrolling case it was built to smooth. Aging by idle ticks costs nothing
1032    /// on any path where frames still flow — a present that does arrive still
1033    /// releases the batch first, unchanged — and bounds the broken path to
1034    /// [`MAX_FRAMES_IN_FLIGHT`] display ticks (~100 ms at 120 Hz).
1035    pub fn note_idle_tick(&mut self) {
1036        self.idle_ticks = self.idle_ticks.saturating_add(1);
1037    }
1038
1039    /// The highest generation releasable right now, given the id of the last
1040    /// **presented** frame and of the last **submitted** one.
1041    ///
1042    /// A batch is releasable once its own frame is on screen, or once that
1043    /// frame has fallen [`MAX_FRAMES_IN_FLIGHT`] behind the staleness cursor
1044    /// (it was dropped by the latest-wins channel, or never presented at all,
1045    /// and will never reach the screen). The first batch that is neither caps
1046    /// the boundary at its own generation minus one, so everything published
1047    /// before it — including a lifecycle batch that was never paired with a
1048    /// frame at all — still goes out; an empty queue releases everything.
1049    ///
1050    /// The staleness cursor is the submission cursor plus the current idle
1051    /// stretch ([`note_idle_tick`](Self::note_idle_tick)): the two are the same
1052    /// "frames have moved on past this one" evidence, and with no idle ticks
1053    /// reported this is bit-for-bit the submission-only rule.
1054    pub fn releasable_generation(&self, presented_frame_id: u64, submitted_frame_id: u64) -> u64 {
1055        let stale_cursor = submitted_frame_id.saturating_add(self.idle_ticks);
1056        for &(generation, due_frame) in &self.due {
1057            let on_screen = due_frame <= presented_frame_id;
1058            let stranded = stale_cursor.saturating_sub(due_frame) >= MAX_FRAMES_IN_FLIGHT;
1059            if !on_screen && !stranded {
1060                return generation.saturating_sub(1);
1061            }
1062        }
1063        u64::MAX
1064    }
1065
1066    /// Drop the bookkeeping for every batch the native side has acknowledged —
1067    /// the same generation the shell hands
1068    /// [`PlatformViewState::acknowledge`], so the two stay in step.
1069    pub fn acknowledge(&mut self, generation: u64) {
1070        while let Some(&(g, _)) = self.due.front() {
1071            if g <= generation {
1072                self.due.pop_front();
1073            } else {
1074                break;
1075            }
1076        }
1077    }
1078
1079    /// Forget every pairing (backgrounding, surface recreation) — see the
1080    /// type's Lifecycle note. Also drops the idle stretch, so the ticks counted
1081    /// against frames belonging to a surface (or a foreground session) that is
1082    /// gone cannot age the first batch recorded after it.
1083    pub fn clear(&mut self) {
1084        self.due.clear();
1085        self.idle_ticks = 0;
1086    }
1087
1088    /// Whether any batch is still waiting to be paired off.
1089    pub fn is_empty(&self) -> bool {
1090        self.due.is_empty()
1091    }
1092}
1093
1094/// Indices into `frames` in painter order: ascending `command_index`, ties in
1095/// publication order (a stable sort). Allocates nothing for an empty pass.
1096fn paint_order(frames: &[PlatformViewFrame]) -> Vec<usize> {
1097    let mut order: Vec<usize> = (0..frames.len()).collect();
1098    order.sort_by_key(|&i| frames[i].command_index);
1099    order
1100}
1101
1102/// Drop the overlay sitting above `slot_id`, if any, queueing its
1103/// `DisposeOverlay` — the overlay half of a slot disposal (module docs'
1104/// Z-order and overlays).
1105fn dispose_overlay_above(
1106    overlays: &mut BTreeMap<u64, OverlayEntry>,
1107    slot_id: u64,
1108    batch: &mut Vec<ViewCommand>,
1109) {
1110    if let Some(entry) = overlays.remove(&slot_id) {
1111        batch.push(ViewCommand::DisposeOverlay {
1112            overlay_id: entry.overlay_id,
1113        });
1114    }
1115}
1116
1117/// The shield list a slot ships this pass (module docs' Z-shields): nothing at
1118/// all for a non-interactive slot, else its own manually declared rects
1119/// (`PlatformViewView::shield_local`, already absolute) plus every
1120/// auto-collected rect overlapping the slot's `rect`.
1121///
1122/// Overlap (not a positive-area intersection) is the same edge-inclusive test
1123/// the widget's own visibility check uses; a duplicate — the same region
1124/// declared manually AND painted by a `shield` wrapper — is dropped by the
1125/// epsilon comparison, so a host never sees the same rect twice.
1126fn resolve_shields(frame: &PlatformViewFrame, input_shields: &[Rect]) -> Vec<Rect> {
1127    if !frame.interactive {
1128        return Vec::new();
1129    }
1130    let mut resolved = frame.shields.clone();
1131    for shield in input_shields {
1132        if shield.overlaps(frame.rect) && !resolved.iter().any(|kept| !rect_changed(*kept, *shield))
1133        {
1134            resolved.push(*shield);
1135        }
1136    }
1137    resolved
1138}
1139
1140/// Whether two shield lists differ past [`EPSILON_PX`]. Order-sensitive: the
1141/// list is built in paint order, which is stable for an unchanged tree, so a
1142/// reorder legitimately means the shields moved.
1143fn shields_changed(a: &[Rect], b: &[Rect]) -> bool {
1144    a.len() != b.len() || a.iter().zip(b).any(|(a, b)| rect_changed(*a, *b))
1145}
1146
1147fn rect_changed(a: Rect, b: Rect) -> bool {
1148    (a.x0 - b.x0).abs() >= EPSILON_PX
1149        || (a.y0 - b.y0).abs() >= EPSILON_PX
1150        || (a.x1 - b.x1).abs() >= EPSILON_PX
1151        || (a.y1 - b.y1).abs() >= EPSILON_PX
1152}
1153
1154fn clip_changed(a: Option<Rect>, b: Option<Rect>) -> bool {
1155    match (a, b) {
1156        (None, None) => false,
1157        (Some(a), Some(b)) => rect_changed(a, b),
1158        _ => true,
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    fn frame(slot_id: u64, rect: Rect, visible: bool) -> PlatformViewFrame {
1167        PlatformViewFrame {
1168            slot_id,
1169            view_type: "dev.frust.Test".to_string(),
1170            params_json: String::new(),
1171            params_generation: 0,
1172            rect,
1173            clip: None,
1174            visible,
1175            interactive: false,
1176            shields: Vec::new(),
1177            command_index: 0,
1178        }
1179    }
1180
1181    fn r(x0: f64, y0: f64, x1: f64, y1: f64) -> Rect {
1182        Rect::new(x0, y0, x1, y1)
1183    }
1184
1185    /// A frame for an `interactive` slot (the only kind that ships shields).
1186    fn interactive_frame(slot_id: u64, rect: Rect) -> PlatformViewFrame {
1187        PlatformViewFrame {
1188            interactive: true,
1189            ..frame(slot_id, rect, true)
1190        }
1191    }
1192
1193    /// The `shields` list of the last [`ViewCommand::Update`] in the backlog.
1194    fn last_update_shields(state: &PlatformViewState) -> Vec<Rect> {
1195        state
1196            .commands()
1197            .1
1198            .iter()
1199            .rev()
1200            .find_map(|cmd| match cmd {
1201                ViewCommand::Update { shields, .. } => Some(shields.clone()),
1202                _ => None,
1203            })
1204            .expect("an Update command in the backlog")
1205    }
1206
1207    #[test]
1208    fn new_slot_emits_create_then_update_in_order() {
1209        let mut state = PlatformViewState::new();
1210        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
1211        assert!(changed);
1212
1213        let (generation, cmds) = state.commands();
1214        assert_eq!(generation, 1);
1215        assert_eq!(
1216            cmds,
1217            &[
1218                ViewCommand::Create {
1219                    slot_id: 1,
1220                    view_type: "dev.frust.Test".to_string(),
1221                    params_json: String::new(),
1222                    interactive: false,
1223                },
1224                ViewCommand::Update {
1225                    slot_id: 1,
1226                    rect: r(0.0, 0.0, 100.0, 50.0),
1227                    clip: None,
1228                    visible: true,
1229                    shields: Vec::new(),
1230                    z_index: 0,
1231                },
1232            ]
1233        );
1234    }
1235
1236    #[test]
1237    fn sub_epsilon_rect_change_emits_nothing() {
1238        let mut state = PlatformViewState::new();
1239        state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
1240        state.acknowledge(1);
1241
1242        // 0.2px drift on every edge — below the 0.5px epsilon.
1243        let changed = state.ingest(&[frame(1, r(0.2, 0.2, 100.2, 50.2), true)], &[]);
1244        assert!(!changed);
1245        let (generation, cmds) = state.commands();
1246        assert_eq!(generation, 1); // unchanged — no new batch
1247        assert!(cmds.is_empty());
1248    }
1249
1250    #[test]
1251    fn past_epsilon_rect_change_emits_update() {
1252        let mut state = PlatformViewState::new();
1253        state.ingest(&[frame(1, r(0.0, 0.0, 100.0, 50.0), true)], &[]);
1254        state.acknowledge(1);
1255
1256        let changed = state.ingest(&[frame(1, r(1.0, 0.0, 100.0, 50.0), true)], &[]);
1257        assert!(changed);
1258        let (generation, cmds) = state.commands();
1259        assert_eq!(generation, 2);
1260        assert_eq!(
1261            cmds,
1262            &[ViewCommand::Update {
1263                slot_id: 1,
1264                rect: r(1.0, 0.0, 100.0, 50.0),
1265                clip: None,
1266                visible: true,
1267                shields: Vec::new(),
1268                z_index: 0,
1269            }]
1270        );
1271    }
1272
1273    #[test]
1274    fn missing_two_consecutive_ingests_hides_a_visible_slot() {
1275        let mut state = PlatformViewState::new();
1276        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1277        state.acknowledge(1);
1278
1279        // Missing once: no hide yet.
1280        let changed = state.ingest(&[], &[]);
1281        assert!(!changed);
1282        assert_eq!(state.commands().1, &[]);
1283
1284        // Missing twice consecutively: Hide.
1285        let changed = state.ingest(&[], &[]);
1286        assert!(changed);
1287        assert_eq!(
1288            state.commands().1,
1289            &[ViewCommand::Update {
1290                slot_id: 1,
1291                rect: r(0.0, 0.0, 10.0, 10.0),
1292                clip: None,
1293                visible: false,
1294                shields: Vec::new(),
1295                z_index: 0,
1296            }]
1297        );
1298
1299        // A further missing ingest doesn't re-emit the same Hide.
1300        state.acknowledge(state.commands().0);
1301        let changed = state.ingest(&[], &[]);
1302        assert!(!changed);
1303    }
1304
1305    #[test]
1306    fn missing_past_dispose_threshold_disposes_the_slot() {
1307        let mut state = PlatformViewState::new();
1308        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1309        state.acknowledge(1);
1310
1311        for _ in 0..(DISPOSE_AFTER_MISSING_FRAMES - 1) {
1312            state.ingest(&[], &[]);
1313        }
1314        // Not yet disposed at streak == DISPOSE_AFTER_MISSING_FRAMES - 1.
1315        let (_, cmds) = state.commands();
1316        assert!(!cmds.contains(&ViewCommand::Dispose { slot_id: 1 }));
1317
1318        let changed = state.ingest(&[], &[]);
1319        assert!(changed);
1320        let (_, cmds) = state.commands();
1321        assert!(cmds.contains(&ViewCommand::Dispose { slot_id: 1 }));
1322    }
1323
1324    #[test]
1325    fn revive_after_hide_is_a_plain_update_not_a_create() {
1326        let mut state = PlatformViewState::new();
1327        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1328        state.ingest(&[], &[]); // streak 1
1329        state.ingest(&[], &[]); // streak 2: Hide
1330        state.acknowledge(state.commands().0);
1331
1332        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1333        assert!(changed);
1334        let (_, cmds) = state.commands();
1335        assert_eq!(
1336            cmds,
1337            &[ViewCommand::Update {
1338                slot_id: 1,
1339                rect: r(0.0, 0.0, 10.0, 10.0),
1340                clip: None,
1341                visible: true,
1342                shields: Vec::new(),
1343                z_index: 0,
1344            }]
1345        );
1346        assert!(!cmds.iter().any(|c| matches!(c, ViewCommand::Create { .. })));
1347    }
1348
1349    #[test]
1350    fn revive_after_dispose_creates_fresh() {
1351        let mut state = PlatformViewState::new();
1352        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1353        for _ in 0..DISPOSE_AFTER_MISSING_FRAMES {
1354            state.ingest(&[], &[]);
1355        }
1356        state.acknowledge(state.commands().0);
1357
1358        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1359        assert!(changed);
1360        let (_, cmds) = state.commands();
1361        assert_eq!(
1362            cmds,
1363            &[
1364                ViewCommand::Create {
1365                    slot_id: 1,
1366                    view_type: "dev.frust.Test".to_string(),
1367                    params_json: String::new(),
1368                    interactive: false,
1369                },
1370                ViewCommand::Update {
1371                    slot_id: 1,
1372                    rect: r(0.0, 0.0, 10.0, 10.0),
1373                    clip: None,
1374                    visible: true,
1375                    shields: Vec::new(),
1376                    z_index: 0,
1377                },
1378            ]
1379        );
1380    }
1381
1382    #[test]
1383    fn acknowledge_compacts_the_backlog() {
1384        let mut state = PlatformViewState::new();
1385        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1386        let (gen1, _) = state.commands();
1387        state.ingest(&[frame(2, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1388        let (gen2, cmds) = state.commands();
1389        assert_eq!(cmds.len(), 4); // both slots' Create+Update, un-acked
1390
1391        state.acknowledge(gen1);
1392        let (generation_after_ack, cmds_after_ack) = state.commands();
1393        assert_eq!(generation_after_ack, gen2); // generation itself is untouched
1394        assert_eq!(cmds_after_ack.len(), 2); // only slot 2's batch remains
1395
1396        // Acknowledging an already-acked (or older) generation is a no-op.
1397        state.acknowledge(gen1);
1398        assert_eq!(state.commands().1.len(), 2);
1399    }
1400
1401    #[test]
1402    fn replay_after_reset_reproduces_every_live_slot() {
1403        let mut state = PlatformViewState::new();
1404        state.ingest(
1405            &[
1406                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1407                frame(2, r(20.0, 0.0, 30.0, 10.0), false),
1408            ],
1409            &[],
1410        );
1411        state.acknowledge(state.commands().0);
1412        assert_eq!(state.commands().1, &[]);
1413
1414        let changed = state.reset_for_surface_recreate();
1415        assert!(changed);
1416        let (_, cmds) = state.commands();
1417        // Both slots re-created, each preserving its last-known visibility.
1418        assert_eq!(
1419            cmds,
1420            &[
1421                ViewCommand::Create {
1422                    slot_id: 1,
1423                    view_type: "dev.frust.Test".to_string(),
1424                    params_json: String::new(),
1425                    interactive: false,
1426                },
1427                ViewCommand::Update {
1428                    slot_id: 1,
1429                    rect: r(0.0, 0.0, 10.0, 10.0),
1430                    clip: None,
1431                    visible: true,
1432                    shields: Vec::new(),
1433                    z_index: 0,
1434                },
1435                ViewCommand::Create {
1436                    slot_id: 2,
1437                    view_type: "dev.frust.Test".to_string(),
1438                    params_json: String::new(),
1439                    interactive: false,
1440                },
1441                ViewCommand::Update {
1442                    slot_id: 2,
1443                    rect: r(20.0, 0.0, 30.0, 10.0),
1444                    clip: None,
1445                    visible: false,
1446                    shields: Vec::new(),
1447                    z_index: 1,
1448                },
1449            ]
1450        );
1451    }
1452
1453    #[test]
1454    fn two_slot_interleaving_does_not_cross_contaminate() {
1455        let mut state = PlatformViewState::new();
1456        // Slot 1 created; slot 2 not yet present.
1457        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1458        state.acknowledge(state.commands().0);
1459
1460        // Slot 2 appears; slot 1 unchanged (no rect/clip/visible drift) — only
1461        // slot 2's Create+Update should be emitted.
1462        let changed = state.ingest(
1463            &[
1464                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1465                frame(2, r(50.0, 50.0, 60.0, 60.0), true),
1466            ],
1467            &[],
1468        );
1469        assert!(changed);
1470        let (_, cmds) = state.commands();
1471        assert_eq!(
1472            cmds,
1473            &[
1474                ViewCommand::Create {
1475                    slot_id: 2,
1476                    view_type: "dev.frust.Test".to_string(),
1477                    params_json: String::new(),
1478                    interactive: false,
1479                },
1480                ViewCommand::Update {
1481                    slot_id: 2,
1482                    rect: r(50.0, 50.0, 60.0, 60.0),
1483                    clip: None,
1484                    visible: true,
1485                    shields: Vec::new(),
1486                    z_index: 1,
1487                },
1488            ]
1489        );
1490        state.acknowledge(state.commands().0);
1491
1492        // Now slot 1 moves and slot 2 disappears (one missing frame — not yet
1493        // hidden): only slot 1's Update should appear.
1494        let changed = state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1495        assert!(changed);
1496        assert_eq!(
1497            state.commands().1,
1498            &[ViewCommand::Update {
1499                slot_id: 1,
1500                rect: r(5.0, 0.0, 15.0, 10.0),
1501                clip: None,
1502                visible: true,
1503                shields: Vec::new(),
1504                z_index: 0,
1505            }]
1506        );
1507    }
1508
1509    #[test]
1510    fn params_generation_change_emits_update_params() {
1511        let mut state = PlatformViewState::new();
1512        let mut f = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1513        f.params_json = "{\"a\":1}".to_string();
1514        state.ingest(&[f], &[]);
1515        state.acknowledge(state.commands().0);
1516
1517        let mut f2 = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1518        f2.params_json = "{\"a\":2}".to_string();
1519        f2.params_generation = 1;
1520        let changed = state.ingest(&[f2], &[]);
1521        assert!(changed);
1522        assert_eq!(
1523            state.commands().1,
1524            &[ViewCommand::UpdateParams {
1525                slot_id: 1,
1526                params_json: "{\"a\":2}".to_string(),
1527            }]
1528        );
1529    }
1530
1531    #[test]
1532    fn suspend_all_hides_every_visible_slot_immediately() {
1533        let mut state = PlatformViewState::new();
1534        state.ingest(
1535            &[
1536                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1537                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1538            ],
1539            &[],
1540        );
1541        state.acknowledge(state.commands().0);
1542
1543        // No missing streak at all — suspend_all fires on the very next call,
1544        // unlike the ordinary ingest-driven Hide path.
1545        let changed = state.suspend_all();
1546        assert!(changed);
1547        let (_, cmds) = state.commands();
1548        assert_eq!(
1549            cmds,
1550            &[
1551                ViewCommand::Update {
1552                    slot_id: 1,
1553                    rect: r(0.0, 0.0, 10.0, 10.0),
1554                    clip: None,
1555                    visible: false,
1556                    shields: Vec::new(),
1557                    z_index: 0,
1558                },
1559                ViewCommand::Update {
1560                    slot_id: 2,
1561                    rect: r(20.0, 0.0, 30.0, 10.0),
1562                    clip: None,
1563                    visible: false,
1564                    shields: Vec::new(),
1565                    z_index: 1,
1566                },
1567            ]
1568        );
1569    }
1570
1571    #[test]
1572    fn suspend_all_skips_already_hidden_slots() {
1573        let mut state = PlatformViewState::new();
1574        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), false)], &[]);
1575        state.acknowledge(state.commands().0);
1576
1577        // The only live slot is already visible:false — nothing to emit.
1578        let changed = state.suspend_all();
1579        assert!(!changed);
1580        assert_eq!(state.commands().1, &[]);
1581    }
1582
1583    #[test]
1584    fn suspend_all_on_no_live_slots_is_a_noop() {
1585        let mut state = PlatformViewState::new();
1586        assert!(!state.suspend_all());
1587        assert_eq!(state.commands().0, 0);
1588    }
1589
1590    #[test]
1591    fn revive_after_suspend_all_is_a_plain_update() {
1592        let mut state = PlatformViewState::new();
1593        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1594        state.acknowledge(state.commands().0);
1595        state.suspend_all();
1596        state.acknowledge(state.commands().0);
1597
1598        // The slot reappears in the next real ingest (e.g. the first frame
1599        // after resume) — an ordinary Update, no re-Create.
1600        let changed = state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1601        assert!(changed);
1602        let (_, cmds) = state.commands();
1603        assert_eq!(
1604            cmds,
1605            &[ViewCommand::Update {
1606                slot_id: 1,
1607                rect: r(0.0, 0.0, 10.0, 10.0),
1608                clip: None,
1609                visible: true,
1610                shields: Vec::new(),
1611                z_index: 0,
1612            }]
1613        );
1614        assert!(!cmds.iter().any(|c| matches!(c, ViewCommand::Create { .. })));
1615    }
1616
1617    #[test]
1618    fn retire_disposes_immediately_regardless_of_missing_streak() {
1619        let mut state = PlatformViewState::new();
1620        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1621        state.acknowledge(state.commands().0);
1622
1623        assert!(state.retire(1));
1624        assert_eq!(state.commands().1, &[ViewCommand::Dispose { slot_id: 1 }]);
1625
1626        // Retiring an already-gone (or never-created) slot is a no-op.
1627        assert!(!state.retire(1));
1628        assert!(!state.retire(999));
1629    }
1630
1631    // ---- view_type swap -----------------------------------------------------
1632
1633    #[test]
1634    fn view_type_swap_disposes_and_recreates_in_the_same_ingest() {
1635        let mut state = PlatformViewState::new();
1636        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1637        state.acknowledge(state.commands().0);
1638
1639        let mut swapped = frame(1, r(0.0, 0.0, 10.0, 10.0), true);
1640        swapped.view_type = "dev.frust.Other".to_string();
1641        let changed = state.ingest(&[swapped], &[]);
1642        assert!(changed);
1643        assert_eq!(
1644            state.commands().1,
1645            &[
1646                ViewCommand::Dispose { slot_id: 1 },
1647                ViewCommand::Create {
1648                    slot_id: 1,
1649                    view_type: "dev.frust.Other".to_string(),
1650                    params_json: String::new(),
1651                    interactive: false,
1652                },
1653                ViewCommand::Update {
1654                    slot_id: 1,
1655                    rect: r(0.0, 0.0, 10.0, 10.0),
1656                    clip: None,
1657                    visible: true,
1658                    shields: Vec::new(),
1659                    z_index: 0,
1660                },
1661            ]
1662        );
1663    }
1664
1665    #[test]
1666    fn view_type_swap_does_not_disturb_other_slots() {
1667        let mut state = PlatformViewState::new();
1668        state.ingest(
1669            &[
1670                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1671                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1672            ],
1673            &[],
1674        );
1675        state.acknowledge(state.commands().0);
1676
1677        let mut swapped = frame(2, r(20.0, 0.0, 30.0, 10.0), true);
1678        swapped.view_type = "dev.frust.Other".to_string();
1679        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true), swapped], &[]);
1680        let (_, cmds) = state.commands();
1681        assert!(cmds.iter().all(|c| match c {
1682            ViewCommand::Create { slot_id, .. }
1683            | ViewCommand::Update { slot_id, .. }
1684            | ViewCommand::UpdateParams { slot_id, .. }
1685            | ViewCommand::Dispose { slot_id } => *slot_id == 2,
1686            ViewCommand::Overlay { .. } | ViewCommand::DisposeOverlay { .. } => false,
1687        }));
1688    }
1689
1690    #[test]
1691    fn unchanged_view_type_never_disposes() {
1692        let mut state = PlatformViewState::new();
1693        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1694        state.acknowledge(state.commands().0);
1695
1696        state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1697        assert!(
1698            !state
1699                .commands()
1700                .1
1701                .iter()
1702                .any(|c| matches!(c, ViewCommand::Dispose { .. }))
1703        );
1704    }
1705
1706    // ---- backlog cap --------------------------------------------------------
1707
1708    /// Drive `ingest` until the backlog cap trips (detected as the first poll
1709    /// where the backlog got *shorter*), never acknowledging. Returns the
1710    /// geometry offset of the last ingested frame. Panics rather than spinning
1711    /// forever if the cap somehow never trips.
1712    fn ingest_until_compaction(
1713        state: &mut PlatformViewState,
1714        mut frames_at: impl FnMut(f64) -> Vec<PlatformViewFrame>,
1715    ) -> f64 {
1716        let mut x = 0.0;
1717        loop {
1718            x += 1.0;
1719            assert!(x < 10_000.0, "backlog cap never tripped");
1720            let before = state.commands().1.len();
1721            state.ingest(&frames_at(x), &[]);
1722            if state.commands().1.len() < before {
1723                return x;
1724            }
1725        }
1726    }
1727
1728    #[test]
1729    fn backlog_cap_compacts_into_a_live_slot_replay() {
1730        let mut state = PlatformViewState::new();
1731        let x = ingest_until_compaction(&mut state, |x| {
1732            vec![
1733                frame(1, r(x, 0.0, x + 10.0, 10.0), true),
1734                frame(2, r(x + 20.0, 0.0, x + 30.0, 10.0), true),
1735            ]
1736        });
1737
1738        // Compacted into exactly the net effect: both slots re-created at their
1739        // latest geometry, nothing else.
1740        let (generation, cmds) = state.commands();
1741        assert!(cmds.len() <= MAX_PENDING_COMMANDS);
1742        assert_eq!(
1743            cmds,
1744            &[
1745                ViewCommand::Create {
1746                    slot_id: 1,
1747                    view_type: "dev.frust.Test".to_string(),
1748                    params_json: String::new(),
1749                    interactive: false,
1750                },
1751                ViewCommand::Update {
1752                    slot_id: 1,
1753                    rect: r(x, 0.0, x + 10.0, 10.0),
1754                    clip: None,
1755                    visible: true,
1756                    shields: Vec::new(),
1757                    z_index: 0,
1758                },
1759                ViewCommand::Create {
1760                    slot_id: 2,
1761                    view_type: "dev.frust.Test".to_string(),
1762                    params_json: String::new(),
1763                    interactive: false,
1764                },
1765                ViewCommand::Update {
1766                    slot_id: 2,
1767                    rect: r(x + 20.0, 0.0, x + 30.0, 10.0),
1768                    clip: None,
1769                    visible: true,
1770                    shields: Vec::new(),
1771                    z_index: 1,
1772                },
1773            ]
1774        );
1775
1776        // The whole compacted batch rides the current generation, so an ack of
1777        // an older one drops none of it and the current one drops all of it.
1778        state.acknowledge(generation - 1);
1779        assert_eq!(state.commands().1.len(), 4);
1780        state.acknowledge(generation);
1781        assert!(state.commands().1.is_empty());
1782    }
1783
1784    #[test]
1785    fn backlog_cap_keeps_a_dropped_slots_dispose() {
1786        let mut state = PlatformViewState::new();
1787        // Slot 1 lives and dies inside the un-acked window; slot 2 stays live.
1788        state.ingest(
1789            &[
1790                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
1791                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
1792            ],
1793            &[],
1794        );
1795        for _ in 0..DISPOSE_AFTER_MISSING_FRAMES {
1796            state.ingest(&[frame(2, r(20.0, 0.0, 30.0, 10.0), true)], &[]);
1797        }
1798        assert!(
1799            state
1800                .commands()
1801                .1
1802                .contains(&ViewCommand::Dispose { slot_id: 1 })
1803        );
1804
1805        ingest_until_compaction(&mut state, |x| {
1806            vec![frame(2, r(x + 20.0, 0.0, x + 30.0, 10.0), true)]
1807        });
1808
1809        let (_, cmds) = state.commands();
1810        // The teardown of slot 1 survives compaction (else its native view
1811        // leaks); slot 2 is replayed.
1812        assert_eq!(cmds[0], ViewCommand::Dispose { slot_id: 1 });
1813        assert!(
1814            cmds.iter()
1815                .any(|c| matches!(c, ViewCommand::Create { slot_id: 2, .. }))
1816        );
1817        assert!(
1818            !cmds
1819                .iter()
1820                .any(|c| matches!(c, ViewCommand::Create { slot_id: 1, .. }))
1821        );
1822    }
1823
1824    #[test]
1825    fn a_steadily_acking_native_side_never_trips_the_cap() {
1826        let mut state = PlatformViewState::new();
1827        for i in 0..1_000 {
1828            let x = i as f64;
1829            state.ingest(&[frame(1, r(x, 0.0, x + 10.0, 10.0), true)], &[]);
1830            assert!(state.commands().1.len() <= MAX_PENDING_COMMANDS);
1831            state.acknowledge(state.commands().0);
1832        }
1833        assert!(state.commands().1.is_empty());
1834    }
1835
1836    // ---- The release gate ---------------------------------------------------
1837
1838    #[test]
1839    fn commands_up_to_releases_only_the_due_prefix() {
1840        let mut state = PlatformViewState::new();
1841        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1842        let gen1 = state.commands().0;
1843        state.ingest(&[frame(1, r(5.0, 0.0, 15.0, 10.0), true)], &[]);
1844        let gen2 = state.commands().0;
1845        assert_eq!(state.commands().1.len(), 3);
1846
1847        // Nothing due yet: an empty slice reported at the acked generation.
1848        assert_eq!(state.commands_up_to(0), (0, &[][..]));
1849        // The first batch's frame landed.
1850        let (reported, cmds) = state.commands_up_to(gen1);
1851        assert_eq!(reported, gen1);
1852        assert_eq!(cmds.len(), 2);
1853        // Both.
1854        let (reported, cmds) = state.commands_up_to(gen2);
1855        assert_eq!(reported, gen2);
1856        assert_eq!(cmds.len(), 3);
1857    }
1858
1859    #[test]
1860    fn commands_up_to_reports_the_acked_generation_when_it_releases_nothing() {
1861        let mut state = PlatformViewState::new();
1862        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1863        state.acknowledge(state.commands().0);
1864        state.ingest(&[frame(1, r(9.0, 0.0, 19.0, 10.0), true)], &[]);
1865
1866        let (reported, cmds) = state.commands_up_to(1);
1867        assert!(cmds.is_empty());
1868        assert_eq!(reported, 1, "a held poll must not ack anything new");
1869    }
1870
1871    #[test]
1872    fn gate_releases_a_batch_once_its_own_frame_is_presented() {
1873        let mut pairing = FramePairing::new();
1874        pairing.record(1, 10);
1875        pairing.record(2, 11);
1876
1877        // Frame 10 not on screen yet: nothing releasable (generation 1 - 1).
1878        assert_eq!(pairing.releasable_generation(9, 11), 0);
1879        // Frame 10 presented, 11 still in flight: only the first batch.
1880        assert_eq!(pairing.releasable_generation(10, 11), 1);
1881        // Both on screen: everything, including any later unpaired batch.
1882        assert_eq!(pairing.releasable_generation(11, 11), u64::MAX);
1883    }
1884
1885    #[test]
1886    fn gate_releases_everything_before_the_first_held_batch() {
1887        let mut pairing = FramePairing::new();
1888        // A lifecycle batch (generation 1) was never paired with a frame; the
1889        // geometry batch behind it is held.
1890        pairing.record(2, 10);
1891        assert_eq!(pairing.releasable_generation(9, 10), 1);
1892    }
1893
1894    #[test]
1895    fn gate_releases_everything_when_nothing_is_paired() {
1896        let pairing = FramePairing::new();
1897        assert!(pairing.is_empty());
1898        assert_eq!(pairing.releasable_generation(0, 0), u64::MAX);
1899    }
1900
1901    #[test]
1902    fn escape_hatch_releases_a_batch_whose_frame_was_dropped() {
1903        let mut pairing = FramePairing::new();
1904        pairing.record(1, 5); // frame 5's scene was overtaken and never presented
1905        pairing.record(2, 6);
1906
1907        // Still within the in-flight window: held.
1908        let submitted = 5 + MAX_FRAMES_IN_FLIGHT - 1;
1909        assert_eq!(pairing.releasable_generation(4, submitted), 0);
1910        // One frame further and frame 5 is declared never-coming; frame 6 is
1911        // still inside the window, so the boundary stops there.
1912        let submitted = 5 + MAX_FRAMES_IN_FLIGHT;
1913        assert_eq!(pairing.releasable_generation(4, submitted), 1);
1914        // Both stale: everything releases.
1915        let submitted = 6 + MAX_FRAMES_IN_FLIGHT;
1916        assert_eq!(pairing.releasable_generation(4, submitted), u64::MAX);
1917    }
1918
1919    #[test]
1920    fn gate_acknowledge_drops_applied_pairings() {
1921        let mut pairing = FramePairing::new();
1922        pairing.record(1, 10);
1923        pairing.record(2, 11);
1924        pairing.acknowledge(1);
1925        // Generation 1's pairing is gone; generation 2 still gates.
1926        assert_eq!(pairing.releasable_generation(10, 11), 1);
1927        pairing.acknowledge(2);
1928        assert!(pairing.is_empty());
1929        assert_eq!(pairing.releasable_generation(0, 0), u64::MAX);
1930    }
1931
1932    #[test]
1933    fn gate_clear_releases_a_suspend_or_replay_batch() {
1934        let mut pairing = FramePairing::new();
1935        pairing.record(1, 10); // held: frame 10 never presents (app backgrounded)
1936        assert_eq!(pairing.releasable_generation(9, 10), 0);
1937        pairing.clear();
1938        assert_eq!(pairing.releasable_generation(9, 10), u64::MAX);
1939    }
1940
1941    #[test]
1942    fn idle_ticks_release_a_batch_whose_frame_never_presents() {
1943        let mut pairing = FramePairing::new();
1944        // The settle frame: batch 1 rides frame 10, which is submitted and then
1945        // never presented (any non-`Rendered` render outcome records nothing).
1946        pairing.record(1, 10);
1947        assert_eq!(
1948            pairing.releasable_generation(9, 10),
1949            0,
1950            "held while that frame could still land"
1951        );
1952
1953        // The app is now idle — no further submissions, so the display clock is
1954        // the only cursor left moving.
1955        for _ in 0..(MAX_FRAMES_IN_FLIGHT - 1) {
1956            pairing.note_idle_tick();
1957            assert_eq!(
1958                pairing.releasable_generation(9, 10),
1959                0,
1960                "still inside the staleness bound"
1961            );
1962        }
1963        pairing.note_idle_tick();
1964        assert_eq!(
1965            pairing.releasable_generation(9, 10),
1966            u64::MAX,
1967            "the idle stretch strands a frame that will never present"
1968        );
1969    }
1970
1971    #[test]
1972    fn an_idle_released_batch_is_served_exactly_once() {
1973        let mut state = PlatformViewState::new();
1974        let mut pairing = FramePairing::new();
1975        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
1976        let generation = state.commands().0;
1977        pairing.record(generation, 10);
1978
1979        // Frame 10 never presented and nothing else was submitted: the poll
1980        // serves nothing at all.
1981        let releasable = pairing.releasable_generation(9, 10);
1982        assert!(state.commands_up_to(releasable).1.is_empty());
1983
1984        for _ in 0..MAX_FRAMES_IN_FLIGHT {
1985            pairing.note_idle_tick();
1986        }
1987        let releasable = pairing.releasable_generation(9, 10);
1988        let (reported, cmds) = state.commands_up_to(releasable);
1989        assert_eq!(cmds.len(), 2, "the held Create+Update finally go out");
1990        assert_eq!(reported, generation);
1991
1992        // The native side applies and acks: both halves compact, so no further
1993        // poll — however many more idle ticks land — re-serves the batch.
1994        state.acknowledge(reported);
1995        pairing.acknowledge(reported);
1996        pairing.note_idle_tick();
1997        let releasable = pairing.releasable_generation(9, 10);
1998        assert!(
1999            state.commands_up_to(releasable).1.is_empty(),
2000            "applied once, never re-applied"
2001        );
2002        assert!(pairing.is_empty());
2003    }
2004
2005    #[test]
2006    fn a_presented_frame_releases_before_the_idle_bound_is_reached() {
2007        let mut pairing = FramePairing::new();
2008        pairing.record(1, 10);
2009        // The render tail runs a tick or two behind the UI thread, so a settle
2010        // frame's present routinely lands after the loop has already idled.
2011        pairing.note_idle_tick();
2012        pairing.note_idle_tick();
2013        assert_eq!(
2014            pairing.releasable_generation(9, 10),
2015            0,
2016            "held: the frame is still well inside the bound"
2017        );
2018        assert_eq!(
2019            pairing.releasable_generation(10, 10),
2020            u64::MAX,
2021            "the present releases it exactly as before"
2022        );
2023    }
2024
2025    #[test]
2026    fn a_produced_batch_resets_the_idle_stretch() {
2027        let mut pairing = FramePairing::new();
2028        pairing.record(1, 10);
2029        for _ in 0..(MAX_FRAMES_IN_FLIGHT - 1) {
2030            pairing.note_idle_tick();
2031        }
2032        // The loop wakes and paints again before the bound trips: frames are
2033        // flowing, so both batches are aged by the submission cursor alone.
2034        pairing.record(2, 11);
2035        assert_eq!(
2036            pairing.releasable_generation(9, 11),
2037            0,
2038            "a spent idle stretch cannot strand a live pipeline"
2039        );
2040
2041        // Idling again ages them from scratch.
2042        for _ in 0..MAX_FRAMES_IN_FLIGHT {
2043            pairing.note_idle_tick();
2044        }
2045        assert_eq!(pairing.releasable_generation(9, 11), u64::MAX);
2046    }
2047
2048    #[test]
2049    fn clear_drops_the_idle_stretch_with_the_pairings() {
2050        let mut pairing = FramePairing::new();
2051        pairing.record(1, 10);
2052        for _ in 0..MAX_FRAMES_IN_FLIGHT {
2053            pairing.note_idle_tick();
2054        }
2055        // Backgrounding / surface recreation: the ticks counted against a
2056        // session whose frames are gone must not age the next session's first
2057        // batch, which is gated normally.
2058        pairing.clear();
2059        pairing.record(2, 1);
2060        assert_eq!(pairing.releasable_generation(0, 1), 1);
2061    }
2062
2063    #[test]
2064    fn gate_tracking_is_bounded_by_an_unacking_native_side() {
2065        let mut pairing = FramePairing::new();
2066        for i in 1..=(MAX_TRACKED_BATCHES as u64 * 2) {
2067            pairing.record(i, i);
2068        }
2069        // Oldest entries were dropped, so the boundary is set by the oldest
2070        // SURVIVING batch rather than by a batch from the start of the run.
2071        let oldest_tracked = MAX_TRACKED_BATCHES as u64 + 1;
2072        assert_eq!(
2073            pairing.releasable_generation(0, oldest_tracked),
2074            oldest_tracked - 1
2075        );
2076    }
2077
2078    // ---- Z-shield auto-collection --------------------
2079
2080    #[test]
2081    fn an_interactive_slot_carries_only_the_intersecting_shields() {
2082        let mut state = PlatformViewState::new();
2083        let slot = r(0.0, 0.0, 100.0, 100.0);
2084        let over = r(10.0, 10.0, 40.0, 40.0); // inside the slot
2085        let elsewhere = r(500.0, 500.0, 540.0, 540.0); // unrelated chrome
2086
2087        state.ingest(&[interactive_frame(1, slot)], &[over, elsewhere]);
2088        assert_eq!(
2089            last_update_shields(&state),
2090            vec![over],
2091            "only the shield overlapping the slot rides its Update"
2092        );
2093    }
2094
2095    #[test]
2096    fn a_non_interactive_slot_carries_no_shields_at_all() {
2097        let mut state = PlatformViewState::new();
2098        let slot = r(0.0, 0.0, 100.0, 100.0);
2099        // The same overlapping shield as above, but the slot forwards no input.
2100        state.ingest(&[frame(1, slot, true)], &[r(10.0, 10.0, 40.0, 40.0)]);
2101        assert!(
2102            last_update_shields(&state).is_empty(),
2103            "shields are meaningless to a host that isn't forwarding touches"
2104        );
2105    }
2106
2107    #[test]
2108    fn a_moving_shield_emits_an_update_and_a_jittering_one_does_not() {
2109        let mut state = PlatformViewState::new();
2110        let slot = r(0.0, 0.0, 100.0, 100.0);
2111        state.ingest(&[interactive_frame(1, slot)], &[r(10.0, 10.0, 40.0, 40.0)]);
2112        state.acknowledge(state.commands().0);
2113
2114        // Sub-epsilon drift: nothing (the same discipline as rect/clip).
2115        let changed = state.ingest(&[interactive_frame(1, slot)], &[r(10.2, 10.2, 40.2, 40.2)]);
2116        assert!(!changed, "sub-epsilon shield drift emits nothing");
2117
2118        // A real move: an Update carrying the new shield, with the slot's own
2119        // rect unchanged.
2120        let moved = r(10.0, 60.0, 40.0, 90.0);
2121        let changed = state.ingest(&[interactive_frame(1, slot)], &[moved]);
2122        assert!(changed);
2123        assert_eq!(
2124            state.commands().1,
2125            &[ViewCommand::Update {
2126                slot_id: 1,
2127                rect: slot,
2128                clip: None,
2129                visible: true,
2130                shields: vec![moved],
2131                z_index: 0,
2132            }]
2133        );
2134    }
2135
2136    #[test]
2137    fn a_shield_leaving_the_pass_clears_it_from_the_slot() {
2138        // The replace-per-pass contract end to end: chrome that stopped
2139        // painting must stop shielding, or it keeps stealing touches forever.
2140        let mut state = PlatformViewState::new();
2141        let slot = r(0.0, 0.0, 100.0, 100.0);
2142        state.ingest(&[interactive_frame(1, slot)], &[r(10.0, 10.0, 40.0, 40.0)]);
2143        state.acknowledge(state.commands().0);
2144
2145        let changed = state.ingest(&[interactive_frame(1, slot)], &[]);
2146        assert!(changed);
2147        assert!(last_update_shields(&state).is_empty());
2148    }
2149
2150    #[test]
2151    fn manual_shield_local_rects_are_kept_and_unioned_without_duplicates() {
2152        // The escape hatch still works, and a region declared BOTH ways lands
2153        // once.
2154        let mut state = PlatformViewState::new();
2155        let slot = r(0.0, 0.0, 100.0, 100.0);
2156        let manual = r(0.0, 0.0, 20.0, 20.0);
2157        let auto = r(50.0, 50.0, 70.0, 70.0);
2158        let mut f = interactive_frame(1, slot);
2159        f.shields = vec![manual];
2160
2161        state.ingest(&[f], &[manual, auto]);
2162        assert_eq!(
2163            last_update_shields(&state),
2164            vec![manual, auto],
2165            "manual rects first, then the auto-collected ones, deduped"
2166        );
2167    }
2168
2169    #[test]
2170    fn a_replay_preserves_each_slots_resolved_shields() {
2171        // Surface recreation must rebuild the native side from the replay
2172        // alone, shields included.
2173        let mut state = PlatformViewState::new();
2174        let slot = r(0.0, 0.0, 100.0, 100.0);
2175        let over = r(10.0, 10.0, 40.0, 40.0);
2176        state.ingest(&[interactive_frame(1, slot)], &[over]);
2177        state.acknowledge(state.commands().0);
2178
2179        state.reset_for_surface_recreate();
2180        assert_eq!(last_update_shields(&state), vec![over]);
2181    }
2182
2183    // ---- Widget teardown retire -------------------------
2184
2185    #[test]
2186    fn a_retired_slot_disposes_immediately_and_the_next_ingest_is_quiet() {
2187        // The shell drains `RenderRoot::take_retired_platform_views()` after its
2188        // rebuild and retires each id; the paint that follows no longer
2189        // publishes the slot, and that absence must produce nothing further.
2190        let mut state = PlatformViewState::new();
2191        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
2192        state.acknowledge(state.commands().0);
2193
2194        assert!(state.retire(1), "teardown disposes on the spot");
2195        assert_eq!(state.commands().1, &[ViewCommand::Dispose { slot_id: 1 }]);
2196        state.acknowledge(state.commands().0);
2197
2198        let changed = state.ingest(&[], &[]);
2199        assert!(
2200            !changed,
2201            "the retired slot is already forgotten — no Hide, no second Dispose"
2202        );
2203    }
2204
2205    #[test]
2206    fn a_merely_culled_slot_is_never_disposed_by_the_retire_path() {
2207        // The keep-alive contract: a scrolled-offscreen slot runs no
2208        // teardown, so no retire arrives; it is Hidden by the streak and stays
2209        // live well past the point a retire would have disposed it.
2210        let mut state = PlatformViewState::new();
2211        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
2212        state.acknowledge(state.commands().0);
2213
2214        for _ in 0..(DISPOSE_AFTER_MISSING_FRAMES - 1) {
2215            state.ingest(&[], &[]);
2216        }
2217        let (_, cmds) = state.commands();
2218        assert!(
2219            cmds.iter()
2220                .any(|c| matches!(c, ViewCommand::Update { visible: false, .. })),
2221            "the culled slot is hidden"
2222        );
2223        assert!(
2224            !cmds
2225                .iter()
2226                .any(|c| matches!(c, ViewCommand::Dispose { .. })),
2227            "but never disposed without a real teardown signal"
2228        );
2229
2230        // It revives as a plain Update — the native view (and, for camera, the
2231        // session behind it) was never torn down.
2232        state.acknowledge(state.commands().0);
2233        state.ingest(&[frame(1, r(0.0, 0.0, 10.0, 10.0), true)], &[]);
2234        assert!(
2235            !state
2236                .commands()
2237                .1
2238                .iter()
2239                .any(|c| matches!(c, ViewCommand::Create { .. }))
2240        );
2241    }
2242
2243    #[test]
2244    fn deterministic_replay_produces_an_identical_command_stream() {
2245        let sequence: Vec<Vec<PlatformViewFrame>> = vec![
2246            vec![frame(1, r(0.0, 0.0, 10.0, 10.0), true)],
2247            vec![
2248                frame(1, r(0.0, 0.0, 10.0, 10.0), true),
2249                frame(2, r(20.0, 0.0, 30.0, 10.0), true),
2250            ],
2251            vec![frame(2, r(20.0, 0.0, 30.0, 10.0), true)], // slot 1 missing once
2252            vec![frame(2, r(20.0, 0.0, 30.0, 10.0), true)], // slot 1 missing twice: Hide
2253        ];
2254
2255        let run = |sequence: &[Vec<PlatformViewFrame>]| -> Vec<ViewCommand> {
2256            let mut state = PlatformViewState::new();
2257            let mut all = Vec::new();
2258            for frames in sequence {
2259                state.ingest(frames, &[]);
2260                all.extend(state.commands().1.iter().cloned());
2261                // Compact after observing, mirroring a real shell's poll+ack.
2262                state.acknowledge(state.commands().0);
2263            }
2264            all
2265        };
2266
2267        assert_eq!(run(&sequence), run(&sequence));
2268    }
2269
2270    // ---- z-order and overlays ---------------------------------------------
2271
2272    /// The whole-window viewport every v1 overlay covers in these tests.
2273    const VIEWPORT: Rect = Rect::new(0.0, 0.0, 400.0, 800.0);
2274
2275    /// A drawing command (a backdrop blur needs no brush, so the tests stay
2276    /// free of a `peniko` dependency).
2277    fn draw() -> Command {
2278        Command::BackdropBlur {
2279            rect: r(0.0, 0.0, 10.0, 10.0),
2280            radius: 4.0,
2281            transform: kurbo::Affine::IDENTITY,
2282        }
2283    }
2284
2285    /// A Mode B slot's hole-punch, as `PaintScene::clear_rect` records it.
2286    fn punch() -> Command {
2287        Command::ClearRect {
2288            rect: r(0.0, 0.0, 100.0, 100.0),
2289            transform: kurbo::Affine::IDENTITY,
2290        }
2291    }
2292
2293    fn push_clip() -> Command {
2294        Command::PushClip {
2295            rect: r(0.0, 0.0, 10.0, 10.0),
2296            transform: kurbo::Affine::IDENTITY,
2297        }
2298    }
2299
2300    /// A slot frame published at painter-order index `command_index`.
2301    fn frame_at(slot_id: u64, command_index: usize) -> PlatformViewFrame {
2302        PlatformViewFrame {
2303            command_index,
2304            ..frame(slot_id, r(0.0, 0.0, 100.0, 100.0), true)
2305        }
2306    }
2307
2308    /// Every `Overlay`/`DisposeOverlay` in the backlog, in order.
2309    fn overlay_commands(state: &PlatformViewState) -> Vec<ViewCommand> {
2310        state
2311            .commands()
2312            .1
2313            .iter()
2314            .filter(|c| {
2315                matches!(
2316                    c,
2317                    ViewCommand::Overlay { .. } | ViewCommand::DisposeOverlay { .. }
2318                )
2319            })
2320            .cloned()
2321            .collect()
2322    }
2323
2324    /// `(slot_id, z_index)` of every `Update` in the backlog, in order.
2325    fn update_z_indices(state: &PlatformViewState) -> Vec<(u64, u32)> {
2326        state
2327            .commands()
2328            .1
2329            .iter()
2330            .filter_map(|c| match c {
2331                ViewCommand::Update {
2332                    slot_id, z_index, ..
2333                } => Some((*slot_id, *z_index)),
2334                _ => None,
2335            })
2336            .collect()
2337    }
2338
2339    #[test]
2340    fn one_slot_with_drawing_content_after_it_gets_one_overlay() {
2341        let mut state = PlatformViewState::new();
2342        // Backdrop, then the slot at index 1, then two drawn commands over it
2343        // (wrapped in a clip bracket).
2344        let commands = [draw(), push_clip(), draw(), draw(), Command::PopClip];
2345        assert!(state.ingest_scene(&[frame_at(1, 1)], &[], &commands, VIEWPORT));
2346
2347        assert_eq!(
2348            overlay_commands(&state),
2349            vec![ViewCommand::Overlay {
2350                overlay_id: 1,
2351                above_slot_id: 1,
2352                rect: VIEWPORT,
2353                command_range: 1..5,
2354            }]
2355        );
2356        assert_eq!(update_z_indices(&state), vec![(1, 0)]);
2357        // The overlay follows the slot's own Create + Update.
2358        assert!(matches!(
2359            state.commands().1.last(),
2360            Some(ViewCommand::Overlay { .. })
2361        ));
2362    }
2363
2364    #[test]
2365    fn a_slots_own_punch_is_not_content_above_it() {
2366        // Mode B: the slot's punch is recorded at its own index. Alone, it
2367        // makes no overlay.
2368        let mut state = PlatformViewState::new();
2369        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), punch()], VIEWPORT);
2370        assert!(overlay_commands(&state).is_empty());
2371
2372        // Real content after the punch does; the range opens one past the
2373        // punch, which stays on the main surface.
2374        let mut state = PlatformViewState::new();
2375        state.ingest_scene(
2376            &[frame_at(1, 1)],
2377            &[],
2378            &[draw(), punch(), push_clip(), draw(), Command::PopClip],
2379            VIEWPORT,
2380        );
2381        assert_eq!(
2382            overlay_commands(&state),
2383            vec![ViewCommand::Overlay {
2384                overlay_id: 1,
2385                above_slot_id: 1,
2386                rect: VIEWPORT,
2387                command_range: 2..5,
2388            }]
2389        );
2390    }
2391
2392    #[test]
2393    fn structural_only_content_after_a_slot_gets_no_overlay() {
2394        let mut state = PlatformViewState::new();
2395        let commands = [
2396            draw(),
2397            push_clip(),
2398            Command::PopClip,
2399            Command::PushLayer {
2400                rect: r(0.0, 0.0, 10.0, 10.0),
2401                alpha: 0.5,
2402                transform: kurbo::Affine::IDENTITY,
2403            },
2404            Command::PopLayer,
2405        ];
2406        state.ingest_scene(&[frame_at(1, 1)], &[], &commands, VIEWPORT);
2407        assert!(overlay_commands(&state).is_empty());
2408
2409        // Content painted only BEFORE the slot stays on the main surface.
2410        let mut state = PlatformViewState::new();
2411        state.ingest_scene(&[frame_at(1, 3)], &[], &[draw(), draw(), draw()], VIEWPORT);
2412        assert!(overlay_commands(&state).is_empty());
2413    }
2414
2415    #[test]
2416    fn two_slots_get_two_overlays_with_ranges_and_z_order() {
2417        let mut state = PlatformViewState::new();
2418        // draw | slot 1 @1 | draw draw | slot 2 @3 | push_clip draw pop
2419        let commands = [
2420            draw(),
2421            draw(),
2422            draw(),
2423            push_clip(),
2424            draw(),
2425            Command::PopClip,
2426        ];
2427        // Frames handed over out of painter order: z order follows
2428        // `command_index`, not the slice order.
2429        state.ingest_scene(&[frame_at(2, 3), frame_at(1, 1)], &[], &commands, VIEWPORT);
2430
2431        assert_eq!(update_z_indices(&state), vec![(2, 1), (1, 0)]);
2432        assert_eq!(
2433            overlay_commands(&state),
2434            vec![
2435                ViewCommand::Overlay {
2436                    overlay_id: 1,
2437                    above_slot_id: 1,
2438                    rect: VIEWPORT,
2439                    command_range: 1..3,
2440                },
2441                ViewCommand::Overlay {
2442                    overlay_id: 2,
2443                    above_slot_id: 2,
2444                    rect: VIEWPORT,
2445                    command_range: 3..6,
2446                },
2447            ]
2448        );
2449    }
2450
2451    #[test]
2452    fn content_only_between_two_slots_overlays_the_lower_one() {
2453        let mut state = PlatformViewState::new();
2454        // slot 1 @0 | draw | slot 2 @1 | (structural only) push_clip pop
2455        let commands = [draw(), push_clip(), Command::PopClip];
2456        state.ingest_scene(&[frame_at(1, 0), frame_at(2, 1)], &[], &commands, VIEWPORT);
2457        assert_eq!(
2458            overlay_commands(&state),
2459            vec![ViewCommand::Overlay {
2460                overlay_id: 1,
2461                above_slot_id: 1,
2462                rect: VIEWPORT,
2463                command_range: 0..1,
2464            }]
2465        );
2466    }
2467
2468    #[test]
2469    fn a_hidden_slot_is_not_a_cut() {
2470        let mut state = PlatformViewState::new();
2471        // slot 1 @0 | draw | hidden slot 2 @1 | draw
2472        let mut hidden = frame_at(2, 1);
2473        hidden.visible = false;
2474        state.ingest_scene(&[frame_at(1, 0), hidden], &[], &[draw(), draw()], VIEWPORT);
2475        assert_eq!(
2476            overlay_commands(&state),
2477            vec![ViewCommand::Overlay {
2478                overlay_id: 1,
2479                above_slot_id: 1,
2480                rect: VIEWPORT,
2481                command_range: 0..2,
2482            }]
2483        );
2484    }
2485
2486    #[test]
2487    fn an_unchanged_overlay_emits_nothing_and_a_moved_range_is_resent() {
2488        let mut state = PlatformViewState::new();
2489        let commands = [draw(), draw(), draw()];
2490        state.ingest_scene(&[frame_at(1, 1)], &[], &commands, VIEWPORT);
2491        state.acknowledge(state.commands().0);
2492
2493        assert!(!state.ingest_scene(&[frame_at(1, 1)], &[], &commands, VIEWPORT));
2494        assert!(state.commands().1.is_empty());
2495
2496        // One more command before the slot shifts its cut: same overlay id,
2497        // new range.
2498        let commands = [draw(), draw(), draw(), draw()];
2499        assert!(state.ingest_scene(&[frame_at(1, 2)], &[], &commands, VIEWPORT));
2500        assert_eq!(
2501            overlay_commands(&state),
2502            vec![ViewCommand::Overlay {
2503                overlay_id: 1,
2504                above_slot_id: 1,
2505                rect: VIEWPORT,
2506                command_range: 2..4,
2507            }]
2508        );
2509    }
2510
2511    #[test]
2512    fn overlay_is_disposed_after_its_content_is_gone_for_the_dispose_streak() {
2513        let mut state = PlatformViewState::new();
2514        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2515        state.acknowledge(state.commands().0);
2516
2517        // The content above the slot disappears: the overlay is emptied at
2518        // once (it must not render stale indices) but kept alive.
2519        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw()], VIEWPORT);
2520        assert_eq!(
2521            overlay_commands(&state),
2522            vec![ViewCommand::Overlay {
2523                overlay_id: 1,
2524                above_slot_id: 1,
2525                rect: VIEWPORT,
2526                command_range: 1..1,
2527            }]
2528        );
2529        state.acknowledge(state.commands().0);
2530
2531        for _ in 1..(DISPOSE_AFTER_MISSING_FRAMES - 1) {
2532            state.ingest_scene(&[frame_at(1, 1)], &[], &[draw()], VIEWPORT);
2533        }
2534        assert!(
2535            overlay_commands(&state).is_empty(),
2536            "nothing more until the dispose streak is reached"
2537        );
2538
2539        assert!(state.ingest_scene(&[frame_at(1, 1)], &[], &[draw()], VIEWPORT));
2540        assert_eq!(
2541            overlay_commands(&state),
2542            vec![ViewCommand::DisposeOverlay { overlay_id: 1 }]
2543        );
2544        // The slot itself is untouched — it kept publishing all along.
2545        assert!(
2546            !state
2547                .commands()
2548                .1
2549                .iter()
2550                .any(|c| matches!(c, ViewCommand::Dispose { .. }))
2551        );
2552
2553        // Content returning later gets a fresh overlay id.
2554        state.acknowledge(state.commands().0);
2555        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2556        assert_eq!(
2557            overlay_commands(&state),
2558            vec![ViewCommand::Overlay {
2559                overlay_id: 2,
2560                above_slot_id: 1,
2561                rect: VIEWPORT,
2562                command_range: 1..2,
2563            }]
2564        );
2565    }
2566
2567    #[test]
2568    fn content_returning_before_disposal_reuses_the_overlay() {
2569        let mut state = PlatformViewState::new();
2570        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2571        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw()], VIEWPORT);
2572        state.acknowledge(state.commands().0);
2573
2574        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2575        assert_eq!(
2576            overlay_commands(&state),
2577            vec![ViewCommand::Overlay {
2578                overlay_id: 1,
2579                above_slot_id: 1,
2580                rect: VIEWPORT,
2581                command_range: 1..2,
2582            }]
2583        );
2584    }
2585
2586    #[test]
2587    fn a_frame_without_platform_views_emits_no_overlays() {
2588        let mut state = PlatformViewState::new();
2589        assert!(!state.ingest_scene(&[], &[], &[draw(), draw()], VIEWPORT));
2590        assert_eq!(state.commands(), (0, &[][..]));
2591    }
2592
2593    #[test]
2594    fn plain_ingest_rank_only_change_emits_nothing() {
2595        let mut state = PlatformViewState::new();
2596        state.ingest(&[frame_at(1, 0), frame_at(2, 1)], &[]);
2597        state.acknowledge(state.commands().0);
2598
2599        // Swapped painter order with the same geometry: no Update on the
2600        // plain stream.
2601        assert!(!state.ingest(&[frame_at(1, 1), frame_at(2, 0)], &[]));
2602        assert!(state.commands().1.is_empty());
2603    }
2604
2605    #[test]
2606    fn plain_ingest_culled_sibling_keeps_survivor_quiet() {
2607        let mut state = PlatformViewState::new();
2608        state.ingest(&[frame_at(1, 0), frame_at(2, 1), frame_at(3, 2)], &[]);
2609        state.acknowledge(state.commands().0);
2610
2611        // Slot 2 is culled: slot 3 drops from rank 2 to rank 1 with nothing
2612        // else changed, so the plain stream stays silent for it.
2613        assert!(!state.ingest(&[frame_at(1, 0), frame_at(3, 2)], &[]));
2614        assert!(update_z_indices(&state).is_empty());
2615    }
2616
2617    #[test]
2618    fn plain_ingest_update_for_another_reason_carries_the_current_rank() {
2619        let mut state = PlatformViewState::new();
2620        state.ingest(&[frame_at(1, 0), frame_at(2, 1)], &[]);
2621        state.acknowledge(state.commands().0);
2622
2623        // Slot 1 moves and its rank flips to 1; slot 2's rank flips to 0 and
2624        // stays quiet. Slot 1's Update carries the rank it now has.
2625        let moved = PlatformViewFrame {
2626            rect: r(10.0, 0.0, 110.0, 100.0),
2627            ..frame_at(1, 1)
2628        };
2629        assert!(state.ingest(&[moved, frame_at(2, 0)], &[]));
2630        assert_eq!(update_z_indices(&state), vec![(1, 1)]);
2631    }
2632
2633    #[test]
2634    fn overlay_aware_rank_change_is_announced_with_the_current_rank() {
2635        let mut state = PlatformViewState::new();
2636        state.ingest_scene(&[frame_at(1, 0), frame_at(2, 1)], &[], &[], VIEWPORT);
2637        state.acknowledge(state.commands().0);
2638
2639        assert!(state.ingest_scene(&[frame_at(1, 1), frame_at(2, 0)], &[], &[], VIEWPORT));
2640        assert_eq!(update_z_indices(&state), vec![(1, 1), (2, 0)]);
2641    }
2642
2643    #[test]
2644    fn plain_ingest_never_emits_overlays() {
2645        let mut state = PlatformViewState::new();
2646        state.ingest(&[frame_at(1, 1)], &[]);
2647        assert!(overlay_commands(&state).is_empty());
2648        assert_eq!(update_z_indices(&state), vec![(1, 0)]);
2649    }
2650
2651    #[test]
2652    fn a_z_order_change_alone_emits_an_update() {
2653        let mut state = PlatformViewState::new();
2654        state.ingest_scene(&[frame_at(1, 0), frame_at(2, 1)], &[], &[], VIEWPORT);
2655        state.acknowledge(state.commands().0);
2656
2657        // Same geometry, swapped painter order, on the overlay-aware path.
2658        assert!(state.ingest_scene(&[frame_at(1, 1), frame_at(2, 0)], &[], &[], VIEWPORT));
2659        assert_eq!(update_z_indices(&state), vec![(1, 1), (2, 0)]);
2660    }
2661
2662    #[test]
2663    fn retiring_a_slot_disposes_its_overlay_first() {
2664        let mut state = PlatformViewState::new();
2665        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2666        state.acknowledge(state.commands().0);
2667
2668        assert!(state.retire(1));
2669        assert_eq!(
2670            state.commands().1,
2671            &[
2672                ViewCommand::DisposeOverlay { overlay_id: 1 },
2673                ViewCommand::Dispose { slot_id: 1 },
2674            ]
2675        );
2676    }
2677
2678    #[test]
2679    fn a_slot_disposed_by_its_missing_streak_takes_its_overlay_with_it() {
2680        let mut state = PlatformViewState::new();
2681        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2682        for _ in 0..DISPOSE_AFTER_MISSING_FRAMES {
2683            state.ingest_scene(&[], &[], &[draw()], VIEWPORT);
2684        }
2685        let cmds = state.commands().1;
2686        let dispose_overlay = cmds
2687            .iter()
2688            .position(|c| *c == ViewCommand::DisposeOverlay { overlay_id: 1 })
2689            .expect("the overlay is disposed");
2690        let dispose_slot = cmds
2691            .iter()
2692            .position(|c| *c == ViewCommand::Dispose { slot_id: 1 })
2693            .expect("the slot is disposed");
2694        assert!(dispose_overlay < dispose_slot);
2695        assert_eq!(
2696            cmds.iter()
2697                .filter(|c| matches!(c, ViewCommand::DisposeOverlay { .. }))
2698                .count(),
2699            1
2700        );
2701    }
2702
2703    #[test]
2704    fn surface_recreate_replays_live_overlays_after_the_slots() {
2705        let mut state = PlatformViewState::new();
2706        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2707        state.acknowledge(state.commands().0);
2708
2709        assert!(state.reset_for_surface_recreate());
2710        let cmds = state.commands().1;
2711        assert_eq!(cmds.len(), 3);
2712        assert!(matches!(cmds[0], ViewCommand::Create { slot_id: 1, .. }));
2713        assert!(matches!(
2714            cmds[1],
2715            ViewCommand::Update {
2716                slot_id: 1,
2717                z_index: 0,
2718                ..
2719            }
2720        ));
2721        assert_eq!(
2722            cmds[2],
2723            ViewCommand::Overlay {
2724                overlay_id: 1,
2725                above_slot_id: 1,
2726                rect: VIEWPORT,
2727                command_range: 1..2,
2728            }
2729        );
2730    }
2731
2732    #[test]
2733    fn overlays_ride_the_frame_pairing_release_gate() {
2734        let mut state = PlatformViewState::new();
2735        let mut pairing = FramePairing::new();
2736        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw()], VIEWPORT);
2737        pairing.record(state.commands().0, 1);
2738        // Frame 2 adds content above the slot: its overlay is a new batch.
2739        state.ingest_scene(&[frame_at(1, 1)], &[], &[draw(), draw()], VIEWPORT);
2740        pairing.record(state.commands().0, 2);
2741
2742        // Only frame 1 is on screen: the overlay is held with its frame.
2743        let (_, released) = state.commands_up_to(pairing.releasable_generation(1, 2));
2744        assert!(
2745            !released
2746                .iter()
2747                .any(|c| matches!(c, ViewCommand::Overlay { .. }))
2748        );
2749        // Frame 2 presents: the overlay goes out.
2750        let (_, released) = state.commands_up_to(pairing.releasable_generation(2, 2));
2751        assert!(
2752            released
2753                .iter()
2754                .any(|c| matches!(c, ViewCommand::Overlay { .. }))
2755        );
2756    }
2757}