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}