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