Skip to main content

frust_widgets/
platform_view.rs

1//! `PlatformViewSlot`: the app-facing leaf that reserves layout space for a
2//! native platform view and publishes a
3//! [`PlatformViewFrame`](frust_core::widget::PlatformViewFrame) every paint.
4//!
5//! [`platform_view`] takes the `"dev.frust.<Factory>"`-style native factory
6//! name and returns a builder ([`PlatformViewView`]) over the params/size
7//! contract:
8//!
9//! ```ignore
10//! platform_view("dev.frust.MapFactory")
11//!     .params_json(r#"{"style":"dark"}"#)   // optional, default ""
12//!     .size(300.0, 400.0)                   // explicit slot size; or:
13//!     .expand()                             // fill given constraints
14//! ```
15//!
16//! # Paint contract (Mode A / Mode B)
17//!
18//! Under **Mode A** (an opaque native sibling view placed on top of the frust
19//! surface) this widget **paints nothing**: the native view physically covers
20//! this region regardless of what frust paints underneath, so punching a hole
21//! would only risk erasing real app content. Under **Mode B** (a translucent
22//! frust surface composited over the native view) the slot must **actively
23//! clear its rect** ([`PaintScene::clear_rect`]) so the hole survives an opaque
24//! app backdrop painted below it (the catalog's `AppBackground`, or any app-root
25//! fill). The earlier "paints nothing in Mode B" contract was
26//! device-disproven: a translucent app whose page fills the screen sealed every
27//! hole opaque, and the hosted native view could never show. The clear is a
28//! real destination-clearing composite, not a skipped paint,
29//! so it erases whatever the backdrop drew beneath the slot.
30//!
31//! The widget can't see the surface mode directly (that lives in the shell); it
32//! reads the threaded [`PaintCtx::is_translucent`] flag instead (the render root
33//! seeds it from the surface's **resolved** translucency — what the GPU backend
34//! actually granted, not the app's one-way surface-mode request, so a platform
35//! that refuses translucency degrades to Mode A rather than punching holes in
36//! an opaque swapchain). Punching is therefore gated on that flag — Mode A
37//! keeps the paints-nothing behavior. In every mode
38//! [`PlatformViewWidget::paint`] also *publishes* the [`PlatformViewFrame`]
39//! describing where/how the native view should be placed; the actual
40//! composition happens downstream (the differ, the per-shell channel). See
41//! `docs/CODE_STANDARDS.md`'s platform-view paint-contract entry for the full
42//! writeup.
43//!
44//! # Identity
45//!
46//! `slot_id` is allocated once per **widget instance**
47//! ([`frust_core::widget::next_slot_id`], called from
48//! [`View::build`](frust_core::View::build)) — never derived from tree
49//! position, so a keyed reorder preserves it automatically along
50//! with the rest of the retained widget. A `view_type` change across a
51//! rebuild is treated like swapping in a widget of a different concrete type
52//! (matching the framework's type-swap reconciliation conventions
53//! elsewhere): a different native factory is a genuinely different slot, not
54//! an update to the existing one, so it gets a fresh `slot_id` and its
55//! `params_generation` resets to 0 as if newly built.
56//!
57//! # No input contract (v1)
58//!
59//! `PlatformViewWidget` has no [`Widget::event`](frust_core::Widget::event)
60//! override — it relies on the trait's `Ignored` default. Mode A routes
61//! input to the native sibling view at the OS level (frust never sees it);
62//! Mode B has frust own the whole surface, but a platform-view slot's own
63//! input contract is out of scope for v1.
64//!
65//! # Z-shields ([`shield`])
66//!
67//! An [`interactive`](PlatformViewView::interactive) slot hands a touch-DOWN
68//! inside its rect to the native sibling — including a DOWN that landed on
69//! frust chrome painted *over* the slot, since the OS-side hit test knows
70//! nothing about the frust scene. [`shield`] is the fix: wrap that chrome, and
71//! it reports its own painted rect through
72//! [`PaintCtx::report_input_shield`](frust_core::PaintCtx::report_input_shield)
73//! every paint. The shell-side differ intersects the pass's shield rects
74//! against each interactive slot and ships the overlapping subset with that
75//! slot's `Update`, so the wire/host contract is unchanged.
76//!
77//! Auto-collection is the ordinary path;
78//! [`shield_local`](PlatformViewView::shield_local) stays as a manual escape
79//! hatch for a region no widget paints (the differ unions the two).
80//!
81//! # Teardown ([`View::teardown`](frust_core::View::teardown))
82//!
83//! A removed slot reports its `slot_id` to `frust-core`'s pending-retire list
84//! ([`frust_core::widget::report_retired_slot`]) so the shell can dispose the
85//! native view on its very next frame, instead of waiting out the differ's
86//! ~30-frame missing-streak heuristic. A merely
87//! *culled* slot never runs `teardown`, so it keeps the missing-streak
88//! backstop — which is exactly what keeps a scrolled-offscreen camera preview
89//! alive.
90
91use frust_core::accesskit::Role;
92use frust_core::widget::{PlatformViewFrame, next_slot_id, report_retired_slot};
93use frust_core::{
94    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
95    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, SliverConstraints, SliverGeometry, SliverWidget,
96    View, Widget, any,
97};
98use kurbo::{Point, Rect, Size};
99#[cfg(debug_assertions)]
100use peniko::Color;
101
102/// How a [`PlatformViewSlot`] resolves its layout size.
103///
104/// `Expand` is the default (see [`platform_view`]): a native view most often
105/// wants to fill whatever space its container gives it (a full-bleed map, a
106/// video player), so an app that forgets to call either builder still gets a
107/// sensible slot rather than a degenerate zero-size one. [`PlatformViewView::size`]
108/// switches to `Explicit` for the common case of a fixed-size embed.
109#[derive(Clone, Copy, Debug, PartialEq)]
110enum SlotSize {
111    /// Fill the incoming constraints' maximum.
112    Expand,
113    /// A fixed `(width, height)`, clamped into the incoming constraints.
114    Explicit(f64, f64),
115}
116
117/// The translucent magenta a [`PlatformViewView::debug_fill`] slot paints —
118/// a named constant (deliberately not theme-resolved: nothing here is
119/// themed, see the module's Notes), invaluable on desktop where no native
120/// host exists to show through the slot.
121#[cfg(debug_assertions)]
122const DEBUG_FILL_COLOR: Color = Color::from_rgba8(0xE0, 0x00, 0xE0, 0x40);
123
124/// A declarative platform-view slot. See the [module docs](self).
125pub struct PlatformViewView {
126    view_type: String,
127    params_json: String,
128    slot_size: SlotSize,
129    #[cfg(debug_assertions)]
130    debug_fill: bool,
131    semantics_label: Option<String>,
132    interactive: bool,
133    shields_local: Vec<Rect>,
134}
135
136/// Reserve layout space for a native platform view created by `view_type`
137/// (the `"dev.frust.<Factory>"` convention), filling its container by
138/// default (see [`SlotSize::Expand`]). See the [module docs](self) for the
139/// full builder contract.
140pub fn platform_view(view_type: impl Into<String>) -> PlatformViewView {
141    PlatformViewView {
142        view_type: view_type.into(),
143        params_json: String::new(),
144        slot_size: SlotSize::Expand,
145        #[cfg(debug_assertions)]
146        debug_fill: false,
147        semantics_label: None,
148        interactive: false,
149        shields_local: Vec::new(),
150    }
151}
152
153impl PlatformViewView {
154    /// Set the opaque creation params handed to the native factory (default
155    /// empty). Changing this across a rebuild bumps the widget's retained
156    /// `params_generation` counter exactly once (see the module's Identity
157    /// notes) — the differ uses the bump to decide whether to
158    /// re-create or merely update the native view.
159    pub fn params_json(mut self, params_json: impl Into<String>) -> Self {
160        self.params_json = params_json.into();
161        self
162    }
163
164    /// Force an explicit `(width, height)` slot size, clamped into whatever
165    /// constraints this widget's parent hands it. Overrides
166    /// [`PlatformViewView::expand`] when called after it (last builder call
167    /// wins, matching the crate's other size-mode builders).
168    pub fn size(mut self, width: f64, height: f64) -> Self {
169        self.slot_size = SlotSize::Explicit(width, height);
170        self
171    }
172
173    /// Fill the incoming constraints' maximum — the default (see
174    /// [`SlotSize::Expand`]), provided as an explicit builder call for
175    /// readability at call sites that want to make the choice visible.
176    pub fn expand(mut self) -> Self {
177        self.slot_size = SlotSize::Expand;
178        self
179    }
180
181    /// Debug aid: paint a translucent magenta slab over the slot's bounds
182    /// (see [`DEBUG_FILL_COLOR`]) so the reserved region is visible even
183    /// with no native host attached — invaluable on desktop, where a
184    /// platform view never actually composites. Compiled only into debug
185    /// builds, so it can never ship live in a release binary.
186    #[cfg(debug_assertions)]
187    pub fn debug_fill(mut self) -> Self {
188        self.debug_fill = true;
189        self
190    }
191
192    /// Attach a plain accessible label over the slot's bounds (a
193    /// [`Role::GenericContainer`] semantics node) — the v1 accessibility-gap
194    /// mitigation: the native view itself carries no frust-visible
195    /// semantics, so this is the only signal an assistive technology gets
196    /// for the region.
197    pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
198        self.semantics_label = Some(label.into());
199        self
200    }
201
202    /// Mode B input forwarding: mark this slot's
203    /// native view as pointer-interactive. A touch-DOWN inside the slot's
204    /// rect — and outside every shield rect ([`Self::shield_local`]) — hands
205    /// the whole gesture to the native sibling instead of the frust surface.
206    /// Default off (the v1 no-input contract).
207    pub fn interactive(mut self) -> Self {
208        self.interactive = true;
209        self
210    }
211
212    /// Declare a slot-relative region where frust content drawn OVER this
213    /// slot must keep receiving input (the z-shield).
214    ///
215    /// **The manual escape hatch, not the ordinary path.** Wrap the frust
216    /// chrome in [`shield`] instead: a `shield(child)` reports the rect it
217    /// actually painted every frame, so it can't drift out of sync with a
218    /// moving/resizing widget the way a hand-written rect does. Reach for this
219    /// builder only for a region no widget paints (a reserved gutter, a
220    /// gesture-only zone). Both sources are honored — the shell-side differ
221    /// unions a slot's manual rects with the intersecting auto-collected ones.
222    ///
223    /// The rect is slot-relative; the widget translates it into absolute
224    /// window coordinates when it publishes its frame.
225    pub fn shield_local(mut self, rect: Rect) -> Self {
226        self.shields_local.push(rect);
227        self
228    }
229}
230
231/// The retained widget for a [`PlatformViewView`]. See the [module docs](self).
232pub struct PlatformViewWidget {
233    /// Stable per-widget-instance id (see the module's Identity notes) —
234    /// allocated once at construction, never derived from tree position.
235    slot_id: u64,
236    view_type: String,
237    params_json: String,
238    /// Bumped whenever `params_json` changes across a rebuild (never on a
239    /// `view_type` swap, which instead resets it to 0 — see the module's
240    /// Identity notes).
241    params_generation: u64,
242    slot_size: SlotSize,
243    #[cfg(debug_assertions)]
244    debug_fill: bool,
245    semantics_label: Option<String>,
246    interactive: bool,
247    shields_local: Vec<Rect>,
248}
249
250impl<State: 'static> View<State> for PlatformViewView {
251    type Element = PlatformViewWidget;
252
253    fn build(&self, _ctx: &mut BuildCtx<'_>) -> PlatformViewWidget {
254        PlatformViewWidget {
255            slot_id: next_slot_id(),
256            view_type: self.view_type.clone(),
257            params_json: self.params_json.clone(),
258            params_generation: 0,
259            slot_size: self.slot_size,
260            #[cfg(debug_assertions)]
261            debug_fill: self.debug_fill,
262            semantics_label: self.semantics_label.clone(),
263            interactive: self.interactive,
264            shields_local: self.shields_local.clone(),
265        }
266    }
267
268    fn rebuild(
269        &self,
270        prev: &Self,
271        element: &mut PlatformViewWidget,
272        _ctx: &mut BuildCtx<'_>,
273    ) -> ChangeFlags {
274        let mut flags = ChangeFlags::NONE;
275
276        if prev.view_type != self.view_type {
277            // A different native factory is a different slot outright — treat
278            // like a type swap (module docs' Identity section): fresh
279            // slot_id, generation resets to 0 as if freshly built.
280            element.slot_id = next_slot_id();
281            element.view_type = self.view_type.clone();
282            element.params_json = self.params_json.clone();
283            element.params_generation = 0;
284            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
285        } else if prev.params_json != self.params_json {
286            element.params_json = self.params_json.clone();
287            element.params_generation += 1;
288            flags |= ChangeFlags::PAINT;
289        }
290
291        if prev.slot_size != self.slot_size {
292            element.slot_size = self.slot_size;
293            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
294        }
295
296        #[cfg(debug_assertions)]
297        if prev.debug_fill != self.debug_fill {
298            element.debug_fill = self.debug_fill;
299            flags |= ChangeFlags::PAINT;
300        }
301
302        if prev.semantics_label != self.semantics_label {
303            // Semantics are recomputed each pass from the widget (mirroring
304            // `IconView`'s label), so adopting the new value needs no
305            // layout/paint dirtiness of its own.
306            element.semantics_label = self.semantics_label.clone();
307        }
308
309        if prev.interactive != self.interactive || prev.shields_local != self.shields_local {
310            element.interactive = self.interactive;
311            element.shields_local = self.shields_local.clone();
312            // The next paint must republish the frame so the differ sees the
313            // new input contract.
314            flags |= ChangeFlags::PAINT;
315        }
316
317        flags
318    }
319
320    fn teardown(&self, element: &mut PlatformViewWidget, _ctx: &mut BuildCtx<'_>) {
321        // Prompt retire (see the module's Teardown section): tell core this slot
322        // is gone for good, so the shell disposes the native view on its next
323        // frame rather than waiting out the differ's missing-streak heuristic —
324        // which cannot distinguish "torn down" from "culled" on its own.
325        report_retired_slot(element.slot_id);
326    }
327}
328
329impl Widget for PlatformViewWidget {
330    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
331        // Nothing platform-y here — plain box-constraint arithmetic, exactly
332        // like any other leaf.
333        match self.slot_size {
334            SlotSize::Explicit(width, height) => bc.constrain(Size::new(width, height)),
335            SlotSize::Expand => bc.max(),
336        }
337    }
338
339    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
340        let rect = Rect::from_origin_size(ctx.origin(), ctx.size());
341
342        // `clip`/`visible`: intersect against a threaded scroll-ancestor
343        // viewport when one exists; no constraint means fully visible (see
344        // `PlatformViewFrame`'s doc comment). `overlaps` (not a zero-area
345        // check on the intersection) is the same edge-inclusive test `Flex`'s
346        // paint-time cull uses, so a slot that merely shares an edge with the
347        // viewport still counts as visible.
348        let (clip, visible) = match ctx.visible_rect() {
349            Some(visible_rect) => (
350                Some(visible_rect.intersect(rect)),
351                visible_rect.overlaps(rect),
352            ),
353            None => (None, true),
354        };
355
356        // Publish exactly once per paint — the whole point of this widget.
357        // `command_index` is read here, BEFORE the Mode B punch below records
358        // its `ClearRect`, so the slot's own punch never counts as frust
359        // content painted above the slot.
360        ctx.publish_platform_view(PlatformViewFrame {
361            slot_id: self.slot_id,
362            view_type: self.view_type.clone(),
363            params_json: self.params_json.clone(),
364            params_generation: self.params_generation,
365            rect,
366            clip,
367            visible,
368            interactive: self.interactive,
369            shields: self
370                .shields_local
371                .iter()
372                .map(|r| *r + ctx.origin().to_vec2())
373                .collect(),
374            command_index: scene.command_index(),
375        });
376
377        // Mode B hole-punch: on a translucent surface,
378        // actively clear the slot's rect so an opaque app backdrop painted below
379        // it (the catalog's `AppBackground`) doesn't seal the hole the hosted
380        // native view shows through. Gated on the threaded translucent flag —
381        // Mode A (opaque) keeps the paints-nothing contract, since a clear there
382        // would erase real app content behind the slot (and be disregarded by an
383        // opaque surface anyway; see `PaintScene::clear_rect`). The clear covers
384        // the full slot rect, not the scroll-intersected `clip`: the shell-side
385        // differ clips the native view to `clip`, so over-clearing beyond the
386        // visible viewport is harmless (nothing composites there) and keeps the
387        // punch geometry identical to the published `rect`.
388        if ctx.is_translucent() {
389            scene.clear_rect(ctx.origin(), ctx.size());
390        }
391
392        // Nothing else is painted here (see the module's Paint contract section)
393        // except the debug-only fill aid, which paints OVER any punch so the
394        // reserved region stays visible on desktop (where no native host and no
395        // translucent surface exist).
396        #[cfg(debug_assertions)]
397        if self.debug_fill {
398            scene.fill_rect(ctx.origin(), ctx.size(), DEBUG_FILL_COLOR);
399        }
400    }
401
402    fn semantics(&self, ctx: &mut SemanticsCtx) {
403        if let Some(label) = &self.semantics_label {
404            ctx.push_node(Role::GenericContainer, |node| {
405                node.set_label(label.as_str());
406            });
407        }
408    }
409}
410
411/// A declarative z-shield wrapper. See [`shield`] and the [module docs](self).
412pub struct ShieldView<State: 'static> {
413    child: AnyView<State>,
414}
415
416/// Mark `child` as frust content that must keep receiving pointer input even
417/// where it paints over an [`interactive`](PlatformViewView::interactive)
418/// platform-view slot (the z-shield — see the [module docs](self)).
419///
420/// Layout/paint/event/semantics transparent: the child is laid out under the
421/// wrapper's own constraints at its own size, painted unchanged, and routed
422/// events unchanged. The only added behavior is one
423/// [`PaintCtx::report_input_shield`](frust_core::PaintCtx::report_input_shield)
424/// call per paint, carrying the rect the wrapper just painted into.
425///
426/// Wrap the *narrowest* chrome that actually needs input — a shield is a hole
427/// in the native view's input region, so an over-wide one silently takes touches
428/// away from it.
429///
430/// ```ignore
431/// Stack((
432///     platform_view("dev.frust.MapFactory").interactive(),
433///     shield(button("recenter", |state: &mut State| state.recenter())),
434/// ))
435/// ```
436pub fn shield<State: 'static, V: View<State>>(child: V) -> ShieldView<State> {
437    ShieldView { child: any(child) }
438}
439
440/// The retained widget for a [`ShieldView`]. See [`shield`].
441pub struct ShieldWidget {
442    child: ChildPod,
443}
444
445impl<State: 'static> View<State> for ShieldView<State> {
446    type Element = ShieldWidget;
447
448    fn build(&self, ctx: &mut BuildCtx<'_>) -> ShieldWidget {
449        ShieldWidget {
450            child: crate::authoring::build_child(&self.child, ctx),
451        }
452    }
453
454    fn rebuild(
455        &self,
456        prev: &Self,
457        element: &mut ShieldWidget,
458        ctx: &mut BuildCtx<'_>,
459    ) -> ChangeFlags {
460        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
461    }
462
463    fn teardown(&self, element: &mut ShieldWidget, ctx: &mut BuildCtx<'_>) {
464        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
465    }
466}
467
468impl Widget for ShieldWidget {
469    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
470        // Fully transparent: the child sees the incoming constraints unchanged
471        // and the wrapper takes exactly the child's size.
472        self.child.set_origin(Point::ZERO);
473        self.child.layout_child(ctx, bc)
474    }
475
476    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
477        // Paint the child FIRST, then report — the reported rect describes what
478        // was just drawn, and a child that culls itself entirely still occupies
479        // the wrapper's layout box (so the shield stands regardless).
480        self.child.paint_child(ctx, scene);
481        ctx.report_input_shield(Rect::from_origin_size(ctx.origin(), ctx.size()));
482    }
483
484    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
485        crate::authoring::route_event_single(&mut self.child, ctx, event)
486    }
487
488    fn semantics(&self, ctx: &mut SemanticsCtx) {
489        // Transparent wrapper: forward to the single child (Semantics
490        // Conventions — a container that skips this drops its whole subtree).
491        self.child.semantics_child(ctx);
492    }
493
494    /// A shield is a sliver exactly when its child is: the transparent layout
495    /// runs through the child pod, so the sliver face is the shield itself and
496    /// the pod stays sized for this wrapper's own paint (the
497    /// [`ComponentWidget`](frust_core::ComponentWidget) pattern).
498    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
499        if self.child.is_sliver() {
500            Some(self)
501        } else {
502            None
503        }
504    }
505
506    crate::authoring::visit_children!(child);
507}
508
509impl SliverWidget for ShieldWidget {
510    /// Pass-through, like [`Widget::layout`]: the child sits at the origin and
511    /// receives `sc` unchanged, under the child pod's layout skip.
512    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
513        self.child.set_origin(Point::ZERO);
514        // A viewport reaches this only through `ChildPod::layout_sliver_child`,
515        // which asks `as_sliver` first, so `None` here means a caller invoked
516        // `layout_sliver` on a wrapper whose `as_sliver` answered `None`: a
517        // contract breach that fails in debug builds; release builds fall back
518        // to an empty sliver.
519        self.child.layout_sliver_child(ctx, sc).unwrap_or_else(|| {
520            debug_assert!(
521                false,
522                "ShieldWidget::layout_sliver called on a wrapper whose as_sliver answered None"
523            );
524            SliverGeometry::ZERO
525        })
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::{EdgeInsets, FlexView, Padding, PaddingView, SizedBox, row};
533    use frust_core::{BuildCtx, ChildPod};
534    use kurbo::Point;
535
536    fn build(view: &PlatformViewView) -> PlatformViewWidget {
537        let mut counter = 0u64;
538        <PlatformViewView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
539    }
540
541    fn rebuild(
542        prev: &PlatformViewView,
543        next: &PlatformViewView,
544        element: &mut PlatformViewWidget,
545    ) -> ChangeFlags {
546        let mut counter = 0u64;
547        <PlatformViewView as View<()>>::rebuild(
548            next,
549            prev,
550            element,
551            &mut BuildCtx::new(&mut counter),
552        )
553    }
554
555    /// A recording scene capturing the draw ops (in order) the punch tests
556    /// assert on; the frame-shape tests ignore `ops` and inspect the published
557    /// `PlatformViewFrame`s instead.
558    #[derive(Default)]
559    struct NullScene {
560        ops: Vec<SceneOp>,
561    }
562
563    #[derive(Debug, PartialEq)]
564    enum SceneOp {
565        Clear { origin: Point, size: Size },
566        Fill { origin: Point, size: Size },
567    }
568
569    impl PaintScene for NullScene {
570        fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
571            self.ops.push(SceneOp::Fill { origin, size });
572        }
573        fn draw_text(&mut self, _origin: Point, _text: &str) {}
574        fn clear_rect(&mut self, origin: Point, size: Size) {
575            self.ops.push(SceneOp::Clear { origin, size });
576        }
577    }
578
579    // -- layout --------------------------------------------------------
580
581    #[test]
582    fn expand_fills_the_incoming_constraints_max() {
583        let view = platform_view("dev.frust.MapFactory");
584        let mut w = build(&view);
585        let mut lctx = LayoutCtx::new();
586        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 200.0)));
587        assert_eq!(size, Size::new(300.0, 200.0));
588    }
589
590    #[test]
591    fn explicit_size_is_clamped_into_constraints() {
592        let view = platform_view("dev.frust.MapFactory").size(1000.0, 1000.0);
593        let mut w = build(&view);
594        let mut lctx = LayoutCtx::new();
595        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 200.0)));
596        assert_eq!(size, Size::new(300.0, 200.0));
597    }
598
599    // -- absolute rect under nesting + scroll offset --------------------
600
601    #[test]
602    fn publishes_absolute_rect_under_padding_flex_nesting_and_scroll_offset() {
603        let mut counter = 0u64;
604        let insets = EdgeInsets::all(10.0);
605        let row: FlexView<()> = row()
606            .child(SizedBox::<()>(Some(50.0), Some(30.0)))
607            .child(platform_view("dev.frust.MapFactory").size(80.0, 60.0));
608        let padded: PaddingView<()> = Padding(insets, row);
609
610        let widget = padded.build(&mut BuildCtx::new(&mut counter));
611        let mut lctx = LayoutCtx::new();
612
613        // Wrap in a `ChildPod` and translate it by (-15, -25) — the same
614        // shape `ScrollView` uses to offset scrolled content
615        // (`self.child.set_origin(Point::new(0.0, -self.offset))`) —
616        // standing in for "a simulated scroll offset".
617        let mut pod = ChildPod::new(Box::new(widget));
618        pod.set_origin(Point::new(-15.0, -25.0));
619        pod.layout_child(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
620
621        let mut scene = NullScene::default();
622        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(2000.0, 2000.0));
623        pod.paint_child(&mut pctx, &mut scene);
624
625        let frames = pctx.take_platform_views();
626        assert_eq!(frames.len(), 1);
627        // Row places the SizedBox(50x30) first, so the platform_view sits at
628        // main-axis offset 50; then the Padding's (10, 10) top-left inset;
629        // then the (-15, -25) scroll translation.
630        let expected_origin = Point::new(-15.0 + 10.0 + 50.0, -25.0 + 10.0);
631        assert_eq!(
632            frames[0].rect,
633            Rect::from_origin_size(expected_origin, Size::new(80.0, 60.0))
634        );
635    }
636
637    // -- clip / visibility ------------------------------------------------
638
639    #[test]
640    fn clip_is_intersected_with_visible_rect_when_straddling_edge() {
641        let view = platform_view("dev.frust.MapFactory").size(100.0, 100.0);
642        let mut w = build(&view);
643        let mut lctx = LayoutCtx::new();
644        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 100.0)));
645
646        let mut scene = NullScene::default();
647        let mut pctx = PaintCtx::new(Point::new(50.0, 0.0), Size::new(100.0, 100.0));
648        pctx.constrain_visible_rect(Rect::from_origin_size(Point::ZERO, Size::new(100.0, 100.0)));
649        w.paint(&mut pctx, &mut scene);
650
651        let frames = pctx.take_platform_views();
652        assert_eq!(frames.len(), 1);
653        let frame: &PlatformViewFrame = &frames[0];
654        assert_eq!(
655            frame.rect,
656            Rect::from_origin_size(Point::new(50.0, 0.0), Size::new(100.0, 100.0))
657        );
658        assert_eq!(frame.clip, Some(Rect::new(50.0, 0.0, 100.0, 100.0)));
659        assert!(frame.visible, "an edge-straddling slot is still visible");
660    }
661
662    #[test]
663    fn fully_outside_visible_rect_publishes_not_visible() {
664        let view = platform_view("dev.frust.MapFactory").size(100.0, 100.0);
665        let mut w = build(&view);
666        let mut lctx = LayoutCtx::new();
667        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 100.0)));
668
669        let mut scene = NullScene::default();
670        // Slot paints at (200, 0)-(300, 100); visible rect covers only
671        // (0,0)-(100,100) — no overlap at all.
672        let mut pctx = PaintCtx::new(Point::new(200.0, 0.0), Size::new(100.0, 100.0));
673        pctx.constrain_visible_rect(Rect::from_origin_size(Point::ZERO, Size::new(100.0, 100.0)));
674        w.paint(&mut pctx, &mut scene);
675
676        let frames = pctx.take_platform_views();
677        assert_eq!(frames.len(), 1);
678        assert!(!frames[0].visible);
679    }
680
681    #[test]
682    fn no_visible_rect_constraint_means_fully_visible_with_no_clip() {
683        let view = platform_view("dev.frust.MapFactory").size(100.0, 100.0);
684        let mut w = build(&view);
685        let mut lctx = LayoutCtx::new();
686        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 100.0)));
687
688        let mut scene = NullScene::default();
689        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 100.0));
690        w.paint(&mut pctx, &mut scene);
691
692        let frames = pctx.take_platform_views();
693        assert_eq!(frames[0].clip, None);
694        assert!(frames[0].visible);
695    }
696
697    #[test]
698    fn parent_culling_of_the_whole_subtree_yields_no_frame_at_all() {
699        let mut counter = 0u64;
700        let row: FlexView<()> = row().child(platform_view("dev.frust.MapFactory").size(50.0, 50.0));
701        let mut widget = row.build(&mut BuildCtx::new(&mut counter));
702        let mut lctx = LayoutCtx::new();
703        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
704
705        let mut scene = NullScene::default();
706        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
707        // Far outside even the one-viewport warm margin `Flex` extends the
708        // cull test by, so the child never paints at all.
709        pctx.constrain_visible_rect(Rect::from_origin_size(
710            Point::new(5000.0, 5000.0),
711            Size::new(10.0, 10.0),
712        ));
713        widget.paint(&mut pctx, &mut scene);
714
715        let frames = pctx.take_platform_views();
716        assert!(
717            frames.is_empty(),
718            "a culled subtree must publish no frame at all, not a hidden one"
719        );
720    }
721
722    // -- Mode B hole-punch ---------------------------------------------
723
724    #[test]
725    fn translucent_surface_punches_the_slot_rect() {
726        // Mode B: the slot actively clears its rect so an opaque backdrop below
727        // it doesn't seal the hole.
728        let view = platform_view("dev.frust.MapFactory").size(100.0, 80.0);
729        let mut w = build(&view);
730        let mut lctx = LayoutCtx::new();
731        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 80.0)));
732
733        let mut scene = NullScene::default();
734        let mut pctx =
735            PaintCtx::new(Point::new(20.0, 30.0), Size::new(100.0, 80.0)).with_translucent(true);
736        w.paint(&mut pctx, &mut scene);
737
738        assert_eq!(
739            scene.ops,
740            vec![SceneOp::Clear {
741                origin: Point::new(20.0, 30.0),
742                size: Size::new(100.0, 80.0),
743            }],
744            "a translucent slot must clear its own absolute rect"
745        );
746    }
747
748    #[test]
749    fn opaque_surface_does_not_punch_mode_a_unchanged() {
750        // Mode A (the default): paints-nothing must be preserved — a punch there
751        // would erase real app content behind the slot.
752        let view = platform_view("dev.frust.MapFactory").size(100.0, 80.0);
753        let mut w = build(&view);
754        let mut lctx = LayoutCtx::new();
755        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 80.0)));
756
757        let mut scene = NullScene::default();
758        // No `.with_translucent(true)` — opaque Mode A.
759        let mut pctx = PaintCtx::new(Point::new(20.0, 30.0), Size::new(100.0, 80.0));
760        w.paint(&mut pctx, &mut scene);
761
762        assert!(
763            scene.ops.is_empty(),
764            "an opaque-surface slot must paint nothing (no punch), Mode A unchanged"
765        );
766        // The frame is still published in both modes.
767        assert_eq!(pctx.take_platform_views().len(), 1);
768    }
769
770    // -- Resolved-translucency flip -----------------------------
771
772    /// Count the `ClearRect` commands in a real display list — the punch as the
773    /// GPU backend will actually see it, one level below the recording
774    /// `PaintScene` the tests above use (a recording-level assertion once
775    /// missed a real defect in this exact path).
776    fn clear_rects(scene: &frust_scene::Scene) -> usize {
777        scene
778            .commands()
779            .iter()
780            .filter(|c| matches!(c, frust_scene::Command::ClearRect { .. }))
781            .count()
782    }
783
784    #[test]
785    fn a_resolved_translucency_downgrade_stops_the_punch_in_the_real_display_list() {
786        // The shells push the surface's RESOLVED
787        // translucency (`SurfaceRenderer::surface_resolved_translucent`), not
788        // the request latch, so a surface that asked for translucency and
789        // fell back to an opaque swapchain flips this to `false` — and the
790        // punch must stop, or the `DestOut` composite zeroes real pixels and
791        // presents black rectangles.
792        fn logic(_: &mut ()) -> PlatformViewView {
793            platform_view("dev.frust.MapFactory").size(100.0, 80.0)
794        }
795
796        let mut root: frust_core::RenderRoot<(), PlatformViewView> = frust_core::RenderRoot::new();
797        let mut state = ();
798        let mut scene = frust_scene::Scene::new();
799
800        // Frame 1: the surface resolved translucent (Mode B) — the slot punches.
801        root.set_surface_translucent(true);
802        root.rebuild(&mut logic, &mut state);
803        root.layout(Size::new(200.0, 200.0));
804        {
805            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
806            root.paint(&mut builder, frust_core::FrameTime::ZERO);
807        }
808        assert_eq!(
809            clear_rects(&scene),
810            1,
811            "a translucent-resolved surface must punch exactly one slot rect"
812        );
813
814        // Frame 2: a (re)install resolved OPAQUE — the shell pushes `false`.
815        root.set_surface_translucent(false);
816        assert!(
817            root.has_pending_change_flags(),
818            "a translucency downgrade must mark the tree dirty so the next \
819             frame actually repaints without the punch"
820        );
821        root.rebuild(&mut logic, &mut state);
822        root.layout(Size::new(200.0, 200.0));
823        scene.reset();
824        {
825            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
826            root.paint(&mut builder, frust_core::FrameTime::ZERO);
827        }
828        assert_eq!(
829            clear_rects(&scene),
830            0,
831            "an opaque-resolved surface must encode no punch at all (Mode A)"
832        );
833    }
834
835    // -- painter-order index ---------------------------------------------
836
837    #[test]
838    fn command_index_counts_the_commands_before_the_slot_and_excludes_its_punch() {
839        let view = platform_view("dev.frust.MapFactory").size(100.0, 80.0);
840        let mut w = build(&view);
841        let mut lctx = LayoutCtx::new();
842        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 80.0)));
843
844        let mut scene = frust_scene::Scene::new();
845        let frame = {
846            let mut builder = frust_scene::SceneBuilder::new(&mut scene);
847            // Three commands painted before the slot: a backdrop fill and a
848            // clip bracket (structural commands still occupy an index).
849            PaintScene::fill_rect(
850                &mut builder,
851                Point::ZERO,
852                Size::new(200.0, 200.0),
853                peniko::Color::BLACK,
854            );
855            PaintScene::push_clip(&mut builder, Point::ZERO, Size::new(200.0, 200.0));
856            PaintScene::pop_clip(&mut builder);
857
858            // A translucent surface, so the slot records its punch too.
859            let mut pctx = PaintCtx::new(Point::new(20.0, 30.0), Size::new(100.0, 80.0))
860                .with_translucent(true);
861            w.paint(&mut pctx, &mut builder);
862            let mut frames = pctx.take_platform_views();
863            assert_eq!(frames.len(), 1);
864            frames.remove(0)
865        };
866
867        assert_eq!(
868            frame.command_index, 3,
869            "the index is the count of commands recorded before the slot published"
870        );
871        // The punch landed AT that index — after the publish, so it is not
872        // counted as content before the slot, and it is the slot's own
873        // command rather than frust content painted above it.
874        assert_eq!(scene.commands().len(), 4);
875        assert!(matches!(
876            scene.commands()[frame.command_index],
877            frust_scene::Command::ClearRect { .. }
878        ));
879    }
880
881    #[test]
882    fn command_index_is_zero_under_a_recorder_scene() {
883        let view = platform_view("dev.frust.MapFactory").size(10.0, 10.0);
884        let mut w = build(&view);
885        let mut lctx = LayoutCtx::new();
886        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(10.0, 10.0)));
887
888        let mut scene = NullScene::default();
889        scene.fill_rect(Point::ZERO, Size::new(10.0, 10.0), peniko::Color::BLACK);
890        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(10.0, 10.0));
891        w.paint(&mut pctx, &mut scene);
892        assert_eq!(pctx.take_platform_views()[0].command_index, 0);
893    }
894
895    #[cfg(debug_assertions)]
896    #[test]
897    fn debug_fill_paints_over_the_punch_on_a_translucent_surface() {
898        // Desktop debug affordance: with the surface translucent AND debug_fill
899        // on, the clear runs first, then the magenta fill over it.
900        let view = platform_view("dev.frust.MapFactory")
901            .size(100.0, 80.0)
902            .debug_fill();
903        let mut w = build(&view);
904        let mut lctx = LayoutCtx::new();
905        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 80.0)));
906
907        let mut scene = NullScene::default();
908        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 80.0)).with_translucent(true);
909        w.paint(&mut pctx, &mut scene);
910
911        assert_eq!(
912            scene.ops,
913            vec![
914                SceneOp::Clear {
915                    origin: Point::ZERO,
916                    size: Size::new(100.0, 80.0),
917                },
918                SceneOp::Fill {
919                    origin: Point::ZERO,
920                    size: Size::new(100.0, 80.0),
921                },
922            ],
923            "debug_fill must paint OVER the punch, not under it"
924        );
925    }
926
927    // -- params generation --------------------------------------------------
928
929    #[test]
930    fn params_json_change_bumps_generation_exactly_once_unchanged_does_not() {
931        let prev = platform_view("dev.frust.MapFactory").params_json("a");
932        let mut w = build(&prev);
933        assert_eq!(w.params_generation, 0);
934
935        let same = platform_view("dev.frust.MapFactory").params_json("a");
936        rebuild(&prev, &same, &mut w);
937        assert_eq!(w.params_generation, 0, "unchanged params must not bump");
938
939        let changed = platform_view("dev.frust.MapFactory").params_json("b");
940        rebuild(&same, &changed, &mut w);
941        assert_eq!(w.params_generation, 1);
942
943        let changed_again = platform_view("dev.frust.MapFactory").params_json("c");
944        rebuild(&changed, &changed_again, &mut w);
945        assert_eq!(w.params_generation, 2);
946    }
947
948    #[test]
949    fn view_type_change_allocates_a_fresh_slot_id_and_resets_generation() {
950        let prev = platform_view("dev.frust.MapFactory").params_json("a");
951        let mut w = build(&prev);
952        w.params_generation = 3; // pretend a couple of param bumps already happened
953        let old_slot_id = w.slot_id;
954
955        let swapped = platform_view("dev.frust.OtherFactory").params_json("a");
956        rebuild(&prev, &swapped, &mut w);
957
958        assert_ne!(
959            w.slot_id, old_slot_id,
960            "a view_type swap must allocate a new slot id"
961        );
962        assert_eq!(
963            w.params_generation, 0,
964            "a swap resets generation like a fresh build"
965        );
966        assert_eq!(w.view_type, "dev.frust.OtherFactory");
967    }
968
969    // -- keyed reorder identity ---------------------------------------------
970
971    #[test]
972    fn slot_id_survives_a_keyed_reorder() {
973        // slot_id lives on the WIDGET (module docs' Identity notes), so the
974        // existing keyed-reconciliation machinery preserves it across a
975        // reorder with no platform-view-specific code needed — this proves
976        // that by keying two slots, swapping their positions across a
977        // rebuild, and checking each keeps its original slot_id.
978        let mut counter = 0u64;
979        let a: FlexView<()> = row()
980            .keyed(1u64, platform_view("dev.frust.A"))
981            .keyed(2u64, platform_view("dev.frust.B"));
982        let mut widget = a.build(&mut BuildCtx::new(&mut counter));
983        let mut lctx = LayoutCtx::new();
984        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
985
986        let mut scene = NullScene::default();
987        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
988        widget.paint(&mut pctx, &mut scene);
989        let before = pctx.take_platform_views();
990        assert_eq!(before.len(), 2);
991        let (id_a, id_b) = (before[0].slot_id, before[1].slot_id);
992        assert_ne!(id_a, id_b);
993
994        // Rebuild with the two keyed children swapped in position.
995        let b: FlexView<()> = row()
996            .keyed(2u64, platform_view("dev.frust.B"))
997            .keyed(1u64, platform_view("dev.frust.A"));
998        b.rebuild(&a, &mut widget, &mut BuildCtx::new(&mut counter));
999        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
1000
1001        let mut pctx2 = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
1002        widget.paint(&mut pctx2, &mut scene);
1003        let after = pctx2.take_platform_views();
1004        assert_eq!(after.len(), 2);
1005        // Position 0 is now key 2 ("B"), position 1 is key 1 ("A") — each
1006        // keeps its original slot_id despite the position swap.
1007        assert_eq!(after[0].slot_id, id_b, "key 2's slot survives the reorder");
1008        assert_eq!(after[1].slot_id, id_a, "key 1's slot survives the reorder");
1009    }
1010
1011    // -- Z-shield auto-collection ------------------------------------
1012
1013    #[test]
1014    fn shield_reports_its_absolute_painted_rect_and_paints_the_child_unchanged() {
1015        // A shield nested under Padding/Row must report the rect it actually
1016        // painted into (absolute window coordinates, the same space
1017        // `PlatformViewFrame::rect` uses), and must not alter the child's paint.
1018        let mut counter = 0u64;
1019        let row: FlexView<()> = row()
1020            .child(SizedBox::<()>(Some(50.0), Some(30.0)))
1021            .child(shield(crate::test_support::leaf(80.0, 60.0)));
1022        let padded: PaddingView<()> = Padding(EdgeInsets::all(10.0), row);
1023
1024        let mut widget = padded.build(&mut BuildCtx::new(&mut counter));
1025        let mut lctx = LayoutCtx::new();
1026        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
1027
1028        let mut scene = NullScene::default();
1029        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
1030        widget.paint(&mut pctx, &mut scene);
1031
1032        let expected = Rect::from_origin_size(Point::new(60.0, 10.0), Size::new(80.0, 60.0));
1033        assert_eq!(pctx.take_input_shields(), vec![expected]);
1034        // Paint-transparent: the child's own fill is the ONLY recorded op, at
1035        // the same rect (a `Leaf` fills its bounds).
1036        assert_eq!(
1037            scene.ops,
1038            vec![SceneOp::Fill {
1039                origin: Point::new(60.0, 10.0),
1040                size: Size::new(80.0, 60.0),
1041            }]
1042        );
1043    }
1044
1045    #[test]
1046    fn two_shields_in_one_pass_both_survive() {
1047        // The `Vec`-extend discipline end to end (an overwrite-shaped channel
1048        // would leave only the last one).
1049        let mut counter = 0u64;
1050        let row: FlexView<()> = row()
1051            .child(shield(crate::test_support::leaf(20.0, 20.0)))
1052            .child(shield(crate::test_support::leaf(30.0, 30.0)));
1053        let mut widget = row.build(&mut BuildCtx::new(&mut counter));
1054        let mut lctx = LayoutCtx::new();
1055        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
1056
1057        let mut scene = NullScene::default();
1058        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
1059        widget.paint(&mut pctx, &mut scene);
1060
1061        let shields = pctx.take_input_shields();
1062        assert_eq!(shields.len(), 2, "both shields must reach the render root");
1063        assert_eq!(shields[0].size(), Size::new(20.0, 20.0));
1064        assert_eq!(shields[1].size(), Size::new(30.0, 30.0));
1065    }
1066
1067    #[test]
1068    fn shield_local_still_publishes_its_absolute_rect_escape_hatch() {
1069        // The manual escape hatch survives auto-collection: a slot-relative
1070        // rect is translated into the slot's absolute space on the published
1071        // frame, and rides the differ's union with whatever `shield` collected.
1072        let view = platform_view("dev.frust.MapFactory")
1073            .size(100.0, 80.0)
1074            .interactive()
1075            .shield_local(Rect::new(0.0, 0.0, 20.0, 10.0));
1076        let mut w = build(&view);
1077        let mut lctx = LayoutCtx::new();
1078        w.layout(&mut lctx, &BoxConstraints::tight(Size::new(100.0, 80.0)));
1079
1080        let mut scene = NullScene::default();
1081        let mut pctx = PaintCtx::new(Point::new(20.0, 30.0), Size::new(100.0, 80.0));
1082        w.paint(&mut pctx, &mut scene);
1083
1084        let frames = pctx.take_platform_views();
1085        assert!(frames[0].interactive);
1086        assert_eq!(frames[0].shields, vec![Rect::new(20.0, 30.0, 40.0, 40.0)]);
1087        assert!(
1088            pctx.take_input_shields().is_empty(),
1089            "a manual rect is NOT reported through the auto-collection channel"
1090        );
1091    }
1092
1093    // -- Teardown retire ---------------------------------------------
1094
1095    /// Serializes the tests draining `frust-core`'s process-wide retire list,
1096    /// which `cargo test`'s parallel threads would otherwise interleave.
1097    static RETIRE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1098
1099    /// Drain and discard whatever a previous test left in the process-wide
1100    /// retire list, so an assertion below sees only its own reports.
1101    fn drain_retired() -> Vec<u64> {
1102        frust_core::widget::take_retired_slots()
1103    }
1104
1105    #[test]
1106    fn removing_a_slot_from_a_container_reports_its_id_for_prompt_retire() {
1107        let _guard = RETIRE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1108        let _ = drain_retired();
1109
1110        // The real removal path: an `AnyView` concrete-type swap inside a Flex
1111        // child, which runs `teardown_child` -> `PlatformViewView::teardown`.
1112        let mut counter = 0u64;
1113        let prev: FlexView<()> = row().child(platform_view("dev.frust.MapFactory"));
1114        let mut widget = prev.build(&mut BuildCtx::new(&mut counter));
1115
1116        let mut scene = NullScene::default();
1117        let mut lctx = LayoutCtx::new();
1118        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(100.0, 100.0)));
1119        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(100.0, 100.0));
1120        widget.paint(&mut pctx, &mut scene);
1121        let slot_id = pctx.take_platform_views()[0].slot_id;
1122        assert!(
1123            drain_retired().is_empty(),
1124            "a live slot reports no retire while it keeps painting"
1125        );
1126
1127        let next: FlexView<()> = row().child(SizedBox::<()>(Some(10.0), Some(10.0)));
1128        next.rebuild(&prev, &mut widget, &mut BuildCtx::new(&mut counter));
1129
1130        assert_eq!(
1131            drain_retired(),
1132            vec![slot_id],
1133            "a torn-down slot reports its id exactly once"
1134        );
1135    }
1136
1137    #[test]
1138    fn a_culled_slot_reports_no_retire() {
1139        // The keep-alive contract: culling is not teardown, so a slot scrolled
1140        // out of the viewport must NOT be retired (it keeps the differ's
1141        // missing-streak backstop instead — camera's A6 keep-alive depends on
1142        // this distinction).
1143        let _guard = RETIRE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1144        let _ = drain_retired();
1145
1146        let mut counter = 0u64;
1147        let row: FlexView<()> = row().child(platform_view("dev.frust.MapFactory").size(50.0, 50.0));
1148        let mut widget = row.build(&mut BuildCtx::new(&mut counter));
1149        let mut lctx = LayoutCtx::new();
1150        widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(1000.0, 1000.0)));
1151
1152        let mut scene = NullScene::default();
1153        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(1000.0, 1000.0));
1154        pctx.constrain_visible_rect(Rect::from_origin_size(
1155            Point::new(5000.0, 5000.0),
1156            Size::new(10.0, 10.0),
1157        ));
1158        widget.paint(&mut pctx, &mut scene);
1159        assert!(pctx.take_platform_views().is_empty(), "the slot was culled");
1160
1161        // A rebuild that keeps the slot in the tree (only its paint was culled).
1162        row.rebuild(&row, &mut widget, &mut BuildCtx::new(&mut counter));
1163        assert!(
1164            drain_retired().is_empty(),
1165            "a culled-but-live slot must never be retired"
1166        );
1167    }
1168}