Skip to main content

frust_native_widgets/api/
mount.rs

1//! The generic mounting builder:
2//! [`native_component`] turns a registered
3//! [`NativeComponent`](crate::component::NativeComponent) plus its typed
4//! `Props` into a frust [`View`], composing exactly the one `platform_view`
5//! slot [`crate::api::builders`]' built-in controls compose by hand.
6//!
7//! # Why this exists
8//!
9//! An earlier design shipped the public trait — define a component, register
10//! it — and flagged its own gap: *there was no public way to MOUNT one into a frust
11//! view tree*. A third party could implement `NativeComponent` and never use
12//! it. This module closes that loop, and is what makes
13//!
14//! ```text
15//! define (impl NativeComponent) → register_component::<C>(KIND) → native_component(KIND, c, props)
16//! ```
17//!
18//! a path from pure Rust to a real platform view, with no per-component
19//! Kotlin or Swift anywhere in it.
20//!
21//! **Who can walk it, and how far.** An app crate cannot implement the trait
22//! today: `create` has to name `jni::objects::JObject` on Android and
23//! `objc2-ui-kit`'s classes on iOS *in the implementing crate*, and this
24//! plugin re-exports neither FFI crate, so the practical audience today is
25//! plugin authors, not app authors (the only implementing type in this repo is this
26//! crate's own non-default `demo-components` composite); `crate::component`'s
27//! module doc states that limit in full. What the mounted view reports back is
28//! the component's to decide: it attaches the platform's one listener to any
29//! view it built (`ComponentCtx::attach_listener`), and whatever its
30//! `NativeComponent::on_event` answers reaches the hook below.
31//!
32//! # Events: the builders' idiom, one hook
33//!
34//! [`NativeComponentView::on_event`] is the component counterpart of the
35//! builders' `.on_press`/`.on_toggle`/`.on_change`: the handler fires on the
36//! platform main thread with each [`NativeEvent`] the component answers, and
37//! writing an `RwSignal` from inside it wakes exactly one frust frame
38//! (`crate::api::signals`' module doc). Registration is the builders' own
39//! path, not a parallel one — [`Component::build`] calls the runtime's
40//! `set_callback` for this slot on every rebuild (a registration made before
41//! the native `create` arrives is parked, and the instance is born with it),
42//! and `Component::init` registers the `forget_pending_callback` reaper beside
43//! the staging table's. The component's answer rides the built-in controls'
44//! `EventPayload` table and is handed back here as the public pair
45//! (`NativeEvent::from_payload`), so the hook sees exactly what the component
46//! answered.
47//!
48//! # It reuses the built-in controls' plumbing rather than re-deriving it
49//!
50//! Everything load-bearing here is `crate::api::builders`' own, imported
51//! rather than copied: the one factory [`VIEW_TYPE`], the private slot
52//! counter ([`next_local_slot`], so a component and a built-in control can
53//! never collide on an id), [`resolve_size`]'s no-measure sizing rule, and
54//! [`placeholder`] — the refusal fallback *and* its clip wrapper. The two
55//! tests that pin the refusal contract for the built-in controls
56//! (`a_refused_slot_publishes_no_platform_view_frame` and its clip-wrapper
57//! sibling) therefore describe this path too, and
58//! [`tests::a_refused_component_slot_publishes_no_platform_view_frame`]
59//! asserts it directly.
60//!
61//! **The built-in controls are deliberately NOT rewritten to route through
62//! here** (a standing decision, unchanged): their whole wire is
63//! `params_json` decoded inside an internal `NativeWidget`, while a
64//! component's props are typed Rust values staged beside the wire — one
65//! builder cannot be both without changing every already-shipped control
66//! file, whose "behaviour must not change" guarantee rests on their code not
67//! moving. Sharing the *helpers* buys the reuse without the risk.
68//!
69//! # What a component's builder does NOT carry
70//!
71//! No L2/L3 theme folding (`crate::api::theme`'s ladder is the built-in
72//! controls' wire-side mechanism; a component's props are typed, so an app reads
73//! `use_context::<Theme>()` itself and puts whatever it wants in them). Events
74//! are carried — through the one [`NativeComponentView::on_event`] hook above,
75//! never through `crate::api::signals`' per-control wrappers, whose
76//! click/toggle/value split is a built-in control's shape, not a component's.
77//!
78//! **L1 (brightness) is the one exception, and it rides the wire, not a
79//! component's typed props.** [`ambient_dark`] resolves the same
80//! `use_context::<Theme>()` the builders' own `ambient_theme_tokens`
81//! reads, and [`component_api::component_params`] folds the result straight
82//! into the identity payload every registered kind's params already carry.
83//! It has to: `crate::appkit::theme`'s and `crate::apple::theme`'s per-view
84//! re-pin both read that bit off a slot's raw wire unconditionally, before
85//! any per-kind decode runs, so a `NativeComponent` root with no `dark` key
86//! at all is permanently pinned to the light appearance regardless of the
87//! app's theme — the wire needs the bit even though the component's own
88//! typed `Props` never do.
89
90use std::rc::Rc;
91use std::sync::Arc;
92
93use frust::{
94    ResolvedSurfaceMode, Theme, on_cleanup, platform_view, resolved_surface_mode, use_context,
95};
96use frust_core::{
97    AnyView, BuildCtx, ChangeFlags, Component, ComponentWidget, View, any, component,
98};
99
100// Aliased: `frust_core`'s own `component` view-fn (imported above, used by the
101// `View<Outer>` delegate at the bottom) already owns that name here.
102use crate::component as component_api;
103use crate::component::{NativeComponent, NativeEvent};
104use crate::registry::SlotId;
105use crate::runtime::with_runtime;
106
107use super::builders::{VIEW_TYPE, next_local_slot, placeholder, resolve_size};
108use super::theme;
109
110/// Mount the registered [`NativeComponent`] `C` as one native slot, driven by
111/// `props`.
112///
113/// `kind` must be the same string the component was registered under
114/// ([`register_component`](crate::component::register_component)); a slot
115/// naming a kind nothing registered is reported dead by the runtime rather
116/// than rendering. `component` is the value the app constructs fresh every
117/// rebuild — the value [`NativeComponent::on_event`] runs against when a
118/// listener the component attached fires — and `props` is the typed
119/// create/update payload the runtime diffs with `PartialEq` before any FFI
120/// crossing.
121///
122/// ```ignore
123/// register_component::<Gauge>("gauge");          // once, at app init
124/// // …then, in a rebuild:
125/// native_component("gauge", Gauge::new(), GaugeProps { value: 42 })
126///     .size(160.0, 48.0)
127///     .interactive()
128///     .on_event(move |event| {
129///         if event.is_click() {
130///             taps.set(taps.get_untracked() + 1);
131///         }
132///     })
133/// ```
134///
135/// The returned view is a [`View<Outer>`](View) for every outer app-state
136/// type, exactly like the built-in builders.
137pub fn native_component<C: NativeComponent>(
138    kind: &'static str,
139    component: C,
140    props: C::Props,
141) -> NativeComponentView<C> {
142    NativeComponentView {
143        kind,
144        // `Rc` so a rebuild costs a refcount bump rather than a deep clone,
145        // and so the public trait needs no `Clone` bound of its own.
146        component: Rc::new(component),
147        props,
148        size: None,
149        interactive: false,
150        semantics_label: None,
151        on_event: None,
152    }
153}
154
155/// One mounted [`NativeComponent`] — build it with [`native_component`].
156pub struct NativeComponentView<C: NativeComponent> {
157    kind: &'static str,
158    component: Rc<C>,
159    props: C::Props,
160    size: Option<(f64, f64)>,
161    interactive: bool,
162    semantics_label: Option<String>,
163    on_event: Option<Arc<dyn Fn(NativeEvent) + Send + Sync>>,
164}
165
166/// Hand-written (rather than derived) so the builder is `Clone` whatever `C`
167/// is: `#[derive(Clone)]` would demand `C: Clone` + `C::Props: Clone`, and
168/// only the second is a trait bound this crate can rely on.
169impl<C: NativeComponent> Clone for NativeComponentView<C> {
170    fn clone(&self) -> Self {
171        Self {
172            kind: self.kind,
173            component: Rc::clone(&self.component),
174            props: self.props.clone(),
175            size: self.size,
176            interactive: self.interactive,
177            semantics_label: self.semantics_label.clone(),
178            on_event: self.on_event.clone(),
179        }
180    }
181}
182
183impl<C: NativeComponent> NativeComponentView<C> {
184    /// Explicit slot size — see `crate::api::builders`' `resolve_size` doc for
185    /// the no-call fallback (fill the parent; there is no measure step in v1,
186    /// and a native subtree's own content size is invisible to frust by
187    /// design).
188    pub fn size(mut self, width: f64, height: f64) -> Self {
189        self.size = Some((width, height));
190        self
191    }
192
193    /// Mark the slot interactive, so the host routes touches to the native
194    /// view (and the differ collects the frust chrome overlapping it as
195    /// input shields). Off by default: a component the user never touches
196    /// wants neither.
197    ///
198    /// **A `platform_view` slot never receives `Widget::event`**
199    /// (`docs/CODE_STANDARDS.md`'s Platform-View Conventions) — the platform
200    /// owns this input end to end. It reaches Rust only through a listener the
201    /// component attached (`ComponentCtx::attach_listener`), as
202    /// [`NativeComponent::on_event`] and then [`Self::on_event`] — never
203    /// `EventCtx`. A touch on a view the component attached nothing to still
204    /// behaves natively (a button highlights) and reports nothing.
205    pub fn interactive(mut self) -> Self {
206        self.interactive = true;
207        self
208    }
209
210    /// Fires on the platform main thread with every [`NativeEvent`] the
211    /// component's [`NativeComponent::on_event`] answers — the builders'
212    /// events-as-signals idiom (write an `RwSignal` from inside for the
213    /// one-frame wake; the module doc's *Events*).
214    ///
215    /// Nothing fires unless the component attached a listener for the event's
216    /// family, and the event is whatever the component answered (by default,
217    /// the event its listener reported). `Send + Sync` for the same reason the
218    /// builders' handlers carry it: the runtime's callback table is typed that
219    /// way, though every call arrives on the main thread.
220    pub fn on_event(mut self, handler: impl Fn(NativeEvent) + Send + Sync + 'static) -> Self {
221        self.on_event = Some(Arc::new(handler));
222        self
223    }
224
225    /// The slot's frust-side accessibility label. A native subtree already
226    /// gets the platform's own a11y traversal for free (the platform owns it);
227    /// this labels the *slot* for frust's semantics pass, which publishes
228    /// nothing for an unlabelled one.
229    pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
230        self.semantics_label = Some(label.into());
231        self
232    }
233
234    /// [`Component::build`]'s real body, with `mode`/`dark` threaded
235    /// explicitly so a test can force the
236    /// [`ResolvedSurfaceMode::RefusedTranslucent`] branch or an exact
237    /// brightness without touching the process-global resolved-mode slot
238    /// (whose writer is pinned to the two shells' own FFI glue) or a live
239    /// reactive context — the same shape every builder in
240    /// `crate::api::builders` uses.
241    fn build_with_mode(
242        &self,
243        slot: SlotId,
244        mode: ResolvedSurfaceMode,
245        dark: bool,
246    ) -> AnyView<SlotId> {
247        if mode.translucency_refused() {
248            // Before publishing anything: a refused slot mounts no native
249            // view, so it must stage no props and trigger no factory lookup
250            // either (the built-in controls take exactly this branch, in
251            // exactly this order).
252            return placeholder(self.size, self.kind);
253        }
254        // Stage this rebuild's component value and props beside the wire, and
255        // carry only the generation it answers with — which changes if and
256        // only if the props actually changed, so the differ emits an
257        // `UpdateParams` exactly then (`crate::component`'s *Props travel
258        // beside the wire*). `dark` rides the wire independently of that
259        // generation (module doc's *L1 is the one exception*), so a
260        // brightness-only flip still changes `params_json` and reaches the
261        // per-update re-pin even when the generation itself does not move.
262        let generation =
263            component_api::publish(slot, Rc::clone(&self.component), self.props.clone());
264        let params = component_api::component_params(self.kind, slot, generation, dark);
265        // The builders' registration, the same shape: every rebuild, after
266        // the refusal branch, parked until the native `create` arrives (module
267        // doc's *Events*). The component's answer arrives as the built-in
268        // controls' `EventPayload` and is handed back as the public pair it
269        // started as.
270        if let Some(handler) = self.on_event.clone() {
271            with_runtime(|runtime| {
272                runtime.set_callback(
273                    slot,
274                    Arc::new(move |payload| handler(NativeEvent::from_payload(payload))),
275                )
276            });
277        }
278        let view = platform_view(VIEW_TYPE).params_json(params);
279        let view = if self.interactive {
280            view.interactive()
281        } else {
282            view
283        };
284        let view = match self.semantics_label.clone() {
285            Some(label) => view.semantics_label(label),
286            None => view,
287        };
288        any(resolve_size(self.size, view))
289    }
290}
291
292impl<C: NativeComponent> Component for NativeComponentView<C> {
293    type State = SlotId;
294
295    fn init(&self) -> SlotId {
296        let slot = next_local_slot();
297        // The staging table's reaper (`crate::component::forget`), tied to
298        // this Component's own lifetime rather than to the native
299        // create/dispose lifecycle — the same leak shape, one table over:
300        // a culled slot's dispose resolves by native-view identity and never
301        // names a slot id, so disposal alone would strand the staged entry.
302        // `init` runs exactly once, under this component's own `Owner`, so
303        // this fires exactly once when that owner disposes.
304        //
305        // The `forget_pending_callback` reaper beside it is
306        // `NativeButtonView::init`'s, for the same leak shape: `build`
307        // registers the `.on_event` hook through `set_callback`, and a slot
308        // culled and re-registered while still mounted parks an entry
309        // `dispose_slot` never sees.
310        on_cleanup(move || {
311            component_api::forget(slot);
312            with_runtime(|runtime| runtime.forget_pending_callback(slot));
313        });
314        slot
315    }
316
317    fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
318        self.build_with_mode(*state, resolved_surface_mode(), ambient_dark())
319    }
320}
321
322/// The app's active brightness (theme ladder L1's [`crate::controls::DARK`]
323/// wire bit) — `false` (light) when no [`Theme`] has been threaded, the same
324/// degrade `crate::api::builders::ambient_theme_tokens` uses for its own
325/// `None` case (a bare-core test, a build running outside any reactive
326/// `Owner`). A component's own typed props carry no brightness at all (the
327/// module doc's *L1 is the one exception*) — this is the wire-only read
328/// [`NativeComponentView::build_with_mode`] needs so
329/// `crate::appkit::theme`'s/`crate::apple::theme`'s shared
330/// `brightness_is_dark` — which read this same key off a slot's raw params
331/// unconditionally, for every registered kind, not only the built-in
332/// controls — see the app's real brightness rather than always finding the
333/// key absent.
334fn ambient_dark() -> bool {
335    use_context::<Theme>().as_ref().is_some_and(theme::is_dark)
336}
337
338/// `View<Outer>` for every `Outer`, by delegating to `frust_core::component`
339/// — the generic counterpart of `crate::api::builders`' `impl_native_view!`
340/// macro, hand-written here because that macro takes a concrete type and this
341/// one carries a parameter. Same contract, same rationale: each call clones
342/// `self`/`prev` into a throwaway `ComponentView` (cheap — an `Rc` bump plus
343/// the props), and it is correct because `ComponentView::rebuild` never reads
344/// its `prev` argument, always re-running `Component::build` against the
345/// retained `State`.
346impl<C: NativeComponent, Outer: 'static> View<Outer> for NativeComponentView<C> {
347    type Element = ComponentWidget<Self>;
348
349    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
350        <frust_core::ComponentView<Self> as View<Outer>>::build(&component(self.clone()), ctx)
351    }
352
353    fn rebuild(
354        &self,
355        prev: &Self,
356        element: &mut Self::Element,
357        ctx: &mut BuildCtx<'_>,
358    ) -> ChangeFlags {
359        <frust_core::ComponentView<Self> as View<Outer>>::rebuild(
360            &component(self.clone()),
361            &component(prev.clone()),
362            element,
363            ctx,
364        )
365    }
366
367    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
368        <frust_core::ComponentView<Self> as View<Outer>>::teardown(
369            &component(self.clone()),
370            element,
371            ctx,
372        );
373    }
374}
375
376// Gated on the host arm, not merely on `test` (the gate `crate::component`'s
377// own tests carry): these define a real component through the public trait,
378// whose `create` builds against the host stand-in context a platform build
379// (macOS included, since it has a real AppKit arm) replaces with the
380// platform-only types.
381#[cfg(all(
382    test,
383    not(any(target_os = "android", target_os = "ios", target_os = "macos"))
384))]
385mod tests {
386    use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
387    use kurbo::{Point, Size};
388    use peniko::Color;
389
390    use super::*;
391    use crate::component::{ComponentCtx, NativeRoot, register_component};
392    use crate::runtime::{DisposeOutcome, NativeCtx as PlatformCtx, with_runtime};
393
394    /// A component defined **outside the built-in controls**, implementing
395    /// nothing but the public trait — a third-party crate's shape exactly.
396    struct Meter;
397
398    #[derive(Clone, Debug, PartialEq)]
399    struct MeterProps {
400        value: i32,
401    }
402
403    const METER_KIND: &str = "test-meter";
404
405    impl NativeComponent for Meter {
406        type Props = MeterProps;
407        type State = u64;
408
409        fn create(
410            &self,
411            ctx: &mut ComponentCtx<'_, '_, '_>,
412            props: &Self::Props,
413        ) -> Option<(NativeRoot, Self::State)> {
414            let identity = 1_000;
415            ctx.record(format!("meter create {identity} = {}", props.value));
416            Some((ctx.root(identity)?, identity))
417        }
418
419        fn update(
420            &self,
421            ctx: &mut ComponentCtx<'_, '_, '_>,
422            state: &mut Self::State,
423            _old: &Self::Props,
424            new: &Self::Props,
425        ) {
426            ctx.record(format!("meter update {state} = {}", new.value));
427        }
428    }
429
430    /// A minimal `PaintScene` — only `fill_rect`/`draw_text` have no default
431    /// (mirroring `crate::api::builders`' own test scene).
432    #[derive(Default)]
433    struct NullScene;
434
435    impl PaintScene for NullScene {
436        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
437        fn draw_text(&mut self, _origin: Point, _text: &str) {}
438    }
439
440    fn build_any<State: 'static>(view: AnyView<State>) -> Box<dyn Widget> {
441        let mut counter = 0u64;
442        view.build(&mut BuildCtx::new(&mut counter))
443    }
444
445    /// Paint `view` at a 120x44 slot and return whatever platform-view frames
446    /// it published.
447    fn frames_of(view: AnyView<SlotId>) -> Vec<frust_core::widget::PlatformViewFrame> {
448        let mut element = build_any(view);
449        let mut lctx = LayoutCtx::new();
450        element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 44.0)));
451        let mut scene = NullScene;
452        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
453        element.paint(&mut pctx, &mut scene);
454        pctx.take_platform_views()
455    }
456
457    #[test]
458    fn a_component_defined_outside_the_built_in_controls_mounts_and_publishes_exactly_one_slot() {
459        // The acceptance bar: define → register → mount → create,
460        // end to end, through the public surface alone.
461        assert!(register_component::<Meter>(METER_KIND));
462
463        let mounted = native_component(METER_KIND, Meter, MeterProps { value: 42 })
464            .size(120.0, 44.0)
465            .interactive()
466            .semantics_label("meter");
467        let frames = frames_of(mounted.build_with_mode(31, ResolvedSurfaceMode::Opaque, false));
468
469        assert_eq!(frames.len(), 1, "one component == one slot");
470        assert_eq!(frames[0].view_type, VIEW_TYPE);
471        assert!(frames[0].interactive);
472        assert_eq!(
473            frames[0].rect,
474            kurbo::Rect::from_origin_size(Point::ZERO, Size::new(120.0, 44.0)),
475            "the slot took the size the builder declared"
476        );
477
478        // The params the mount actually published are the ones the runtime
479        // dispatches on — feed the very same string back in, which is what
480        // the host's post-frame poll does a frame later.
481        let params = frames[0].params_json.clone();
482        assert!(
483            params.contains("\"__frustControl\":\"test-meter\"") && params.contains("31"),
484            "{params}"
485        );
486
487        let mut calls = Vec::new();
488        {
489            let mut ctx = PlatformCtx::new(&mut calls);
490            with_runtime(|runtime| {
491                assert_eq!(runtime.create(&mut ctx, &params).unwrap(), 31);
492                assert_eq!(runtime.live_count(), 1);
493                assert_eq!(runtime.dispose_slot(&mut ctx, 31), DisposeOutcome::Disposed);
494                assert_eq!(runtime.live_count(), 0);
495            })
496            .expect("the thread's runtime");
497        }
498        assert_eq!(calls, vec!["meter create 1000 = 42".to_string()]);
499
500        component_api::forget(31);
501        assert_eq!(component_api::staged_count(), 0);
502    }
503
504    #[test]
505    fn an_unchanged_rebuild_republishes_byte_identical_params() {
506        // The diff gate's wire half: only a real props change may move the
507        // generation, or the differ would emit an `UpdateParams` every frame
508        // and the whole zero-FFI property would be forfeit.
509        register_component::<Meter>(METER_KIND);
510
511        let first = native_component(METER_KIND, Meter, MeterProps { value: 7 }).build_with_mode(
512            32,
513            ResolvedSurfaceMode::Opaque,
514            false,
515        );
516        let again = native_component(METER_KIND, Meter, MeterProps { value: 7 }).build_with_mode(
517            32,
518            ResolvedSurfaceMode::Opaque,
519            false,
520        );
521        let changed = native_component(METER_KIND, Meter, MeterProps { value: 8 }).build_with_mode(
522            32,
523            ResolvedSurfaceMode::Opaque,
524            false,
525        );
526
527        let params = |view| frames_of(view).remove(0).params_json;
528        let (first, again, changed) = (params(first), params(again), params(changed));
529        assert_eq!(
530            first, again,
531            "an unchanged rebuild changes nothing on the wire"
532        );
533        assert_ne!(again, changed);
534
535        component_api::forget(32);
536    }
537
538    #[test]
539    fn a_brightness_flip_reaches_the_wire_even_with_unchanged_props() {
540        // Theme ladder L1: unlike every other field in the params, `dark`
541        // does not ride the staged/diffed `Props` at all — it is threaded
542        // straight from `build_with_mode`'s own parameter into
543        // `component_params`, so a brightness-only rebuild must still change
544        // `params_json`, the same wire-level signal
545        // `an_unchanged_rebuild_republishes_byte_identical_params` pins for a
546        // real props change.
547        register_component::<Meter>(METER_KIND);
548
549        let light = native_component(METER_KIND, Meter, MeterProps { value: 9 }).build_with_mode(
550            34,
551            ResolvedSurfaceMode::Opaque,
552            false,
553        );
554        let dark = native_component(METER_KIND, Meter, MeterProps { value: 9 }).build_with_mode(
555            34,
556            ResolvedSurfaceMode::Opaque,
557            true,
558        );
559
560        let params = |view| frames_of(view).remove(0).params_json;
561        let (light, dark) = (params(light), params(dark));
562        assert_ne!(
563            light, dark,
564            "an unchanged props republish with a flipped brightness must still change the wire"
565        );
566        assert!(light.contains("\"dark\":false"), "{light}");
567        assert!(dark.contains("\"dark\":true"), "{dark}");
568
569        component_api::forget(34);
570    }
571
572    /// A component that listens: `create` attaches a click listener to its
573    /// root, and the default `on_event` forwards what it reports.
574    struct Clicker;
575
576    const CLICKER_KIND: &str = "test-clicker";
577
578    impl NativeComponent for Clicker {
579        type Props = MeterProps;
580        type State = crate::component::ListenerHandle;
581
582        fn create(
583            &self,
584            ctx: &mut ComponentCtx<'_, '_, '_>,
585            _props: &Self::Props,
586        ) -> Option<(NativeRoot, Self::State)> {
587            let identity = 2_000;
588            let listener = ctx.attach_listener(identity, crate::component::ListenerKinds::CLICK)?;
589            Some((ctx.root(identity)?, listener))
590        }
591
592        fn update(
593            &self,
594            _ctx: &mut ComponentCtx<'_, '_, '_>,
595            _state: &mut Self::State,
596            _old: &Self::Props,
597            _new: &Self::Props,
598        ) {
599        }
600    }
601
602    #[test]
603    fn the_on_event_hook_hears_a_click_the_component_attached_for() {
604        // The app half of the retired display-only gap: mounting with
605        // `.on_event` registers the slot callback during the rebuild (before
606        // the native create, which then inherits it), and a click from the
607        // listener the component attached reaches the hook as the public pair.
608        use std::sync::Mutex;
609
610        use crate::runtime::NativeEvent as WireEvent;
611
612        assert!(register_component::<Clicker>(CLICKER_KIND));
613        let heard: Arc<Mutex<Vec<NativeEvent>>> = Arc::new(Mutex::new(Vec::new()));
614        let recorder = Arc::clone(&heard);
615
616        let mounted = native_component(CLICKER_KIND, Clicker, MeterProps { value: 1 })
617            .size(120.0, 44.0)
618            .interactive()
619            .on_event(move |event| recorder.lock().unwrap().push(event));
620        let frames = frames_of(mounted.build_with_mode(35, ResolvedSurfaceMode::Opaque, false));
621        let params = frames[0].params_json.clone();
622
623        let mut calls = Vec::new();
624        {
625            let mut ctx = PlatformCtx::new(&mut calls);
626            with_runtime(|runtime| {
627                runtime.create(&mut ctx, &params).unwrap();
628                runtime.on_event(35, WireEvent { kind: 1, detail: 0 });
629                // A toggle this component never attached for is gated out.
630                runtime.on_event(35, WireEvent { kind: 2, detail: 1 });
631                assert_eq!(runtime.dispose_slot(&mut ctx, 35), DisposeOutcome::Disposed);
632            })
633            .expect("the thread's runtime");
634        }
635        component_api::forget(35);
636
637        let heard = heard.lock().unwrap();
638        assert_eq!(heard.len(), 1, "exactly the one attached click: {heard:?}");
639        assert!(heard[0].is_click());
640        assert_eq!(calls, vec!["attachListener 2000 click".to_string()]);
641    }
642
643    #[test]
644    fn a_refused_component_slot_publishes_no_platform_view_frame() {
645        // The refusal contract the built-in controls are pinned to
646        // (`a_refused_slot_publishes_no_platform_view_frame` and its
647        // clip-wrapper sibling), asserted for the generic path — which
648        // degrades through the very same `placeholder`.
649        register_component::<Meter>(METER_KIND);
650
651        let mounted =
652            native_component(METER_KIND, Meter, MeterProps { value: 42 }).size(120.0, 44.0);
653        let refused = mounted.build_with_mode(33, ResolvedSurfaceMode::RefusedTranslucent, false);
654
655        let mut element = build_any(refused);
656        let mut scene = NullScene;
657        let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
658        element.paint(&mut pctx, &mut scene);
659        assert!(
660            pctx.take_platform_views().is_empty(),
661            "a refused slot must render no native platform_view frame"
662        );
663        assert_eq!(
664            component_api::staged_count(),
665            0,
666            "a refused slot stages no props either — there is nothing to create"
667        );
668    }
669}