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, ¶ms).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, ¶ms).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}