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