use std::rc::Rc;
use std::sync::Arc;
use frust::{
ResolvedSurfaceMode, Theme, on_cleanup, platform_view, resolved_surface_mode, use_context,
};
use frust_core::{
AnyView, BuildCtx, ChangeFlags, Component, ComponentWidget, View, any, component,
};
use crate::component as component_api;
use crate::component::{NativeComponent, NativeEvent};
use crate::registry::SlotId;
use crate::runtime::with_runtime;
use super::builders::{VIEW_TYPE, next_local_slot, placeholder, resolve_size};
use super::theme;
pub fn native_component<C: NativeComponent>(
kind: &'static str,
component: C,
props: C::Props,
) -> NativeComponentView<C> {
NativeComponentView {
kind,
component: Rc::new(component),
props,
size: None,
interactive: false,
semantics_label: None,
on_event: None,
}
}
pub struct NativeComponentView<C: NativeComponent> {
kind: &'static str,
component: Rc<C>,
props: C::Props,
size: Option<(f64, f64)>,
interactive: bool,
semantics_label: Option<String>,
on_event: Option<Arc<dyn Fn(NativeEvent) + Send + Sync>>,
}
impl<C: NativeComponent> Clone for NativeComponentView<C> {
fn clone(&self) -> Self {
Self {
kind: self.kind,
component: Rc::clone(&self.component),
props: self.props.clone(),
size: self.size,
interactive: self.interactive,
semantics_label: self.semantics_label.clone(),
on_event: self.on_event.clone(),
}
}
}
impl<C: NativeComponent> NativeComponentView<C> {
pub fn size(mut self, width: f64, height: f64) -> Self {
self.size = Some((width, height));
self
}
pub fn interactive(mut self) -> Self {
self.interactive = true;
self
}
pub fn on_event(mut self, handler: impl Fn(NativeEvent) + Send + Sync + 'static) -> Self {
self.on_event = Some(Arc::new(handler));
self
}
pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
self.semantics_label = Some(label.into());
self
}
fn build_with_mode(
&self,
slot: SlotId,
mode: ResolvedSurfaceMode,
dark: bool,
) -> AnyView<SlotId> {
if mode.translucency_refused() {
return placeholder(self.size, self.kind);
}
let generation =
component_api::publish(slot, Rc::clone(&self.component), self.props.clone());
let params = component_api::component_params(self.kind, slot, generation, dark);
if let Some(handler) = self.on_event.clone() {
with_runtime(|runtime| {
runtime.set_callback(
slot,
Arc::new(move |payload| handler(NativeEvent::from_payload(payload))),
)
});
}
let view = platform_view(VIEW_TYPE).params_json(params);
let view = if self.interactive {
view.interactive()
} else {
view
};
let view = match self.semantics_label.clone() {
Some(label) => view.semantics_label(label),
None => view,
};
any(resolve_size(self.size, view))
}
}
impl<C: NativeComponent> Component for NativeComponentView<C> {
type State = SlotId;
fn init(&self) -> SlotId {
let slot = next_local_slot();
on_cleanup(move || {
component_api::forget(slot);
with_runtime(|runtime| runtime.forget_pending_callback(slot));
});
slot
}
fn build(&self, state: &mut SlotId) -> AnyView<SlotId> {
self.build_with_mode(*state, resolved_surface_mode(), ambient_dark())
}
}
fn ambient_dark() -> bool {
use_context::<Theme>().as_ref().is_some_and(theme::is_dark)
}
impl<C: NativeComponent, Outer: 'static> View<Outer> for NativeComponentView<C> {
type Element = ComponentWidget<Self>;
fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
<frust_core::ComponentView<Self> as View<Outer>>::build(&component(self.clone()), ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut Self::Element,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
<frust_core::ComponentView<Self> as View<Outer>>::rebuild(
&component(self.clone()),
&component(prev.clone()),
element,
ctx,
)
}
fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
<frust_core::ComponentView<Self> as View<Outer>>::teardown(
&component(self.clone()),
element,
ctx,
);
}
}
#[cfg(all(
test,
not(any(target_os = "android", target_os = "ios", target_os = "macos"))
))]
mod tests {
use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
use kurbo::{Point, Size};
use peniko::Color;
use super::*;
use crate::component::{ComponentCtx, NativeRoot, register_component};
use crate::runtime::{DisposeOutcome, NativeCtx as PlatformCtx, with_runtime};
struct Meter;
#[derive(Clone, Debug, PartialEq)]
struct MeterProps {
value: i32,
}
const METER_KIND: &str = "test-meter";
impl NativeComponent for Meter {
type Props = MeterProps;
type State = u64;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = 1_000;
ctx.record(format!("meter create {identity} = {}", props.value));
Some((ctx.root(identity)?, identity))
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
_old: &Self::Props,
new: &Self::Props,
) {
ctx.record(format!("meter update {state} = {}", new.value));
}
}
#[derive(Default)]
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
fn build_any<State: 'static>(view: AnyView<State>) -> Box<dyn Widget> {
let mut counter = 0u64;
view.build(&mut BuildCtx::new(&mut counter))
}
fn frames_of(view: AnyView<SlotId>) -> Vec<frust_core::widget::PlatformViewFrame> {
let mut element = build_any(view);
let mut lctx = LayoutCtx::new();
element.layout(&mut lctx, &BoxConstraints::tight(Size::new(120.0, 44.0)));
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
element.paint(&mut pctx, &mut scene);
pctx.take_platform_views()
}
#[test]
fn a_component_defined_outside_the_built_in_controls_mounts_and_publishes_exactly_one_slot() {
assert!(register_component::<Meter>(METER_KIND));
let mounted = native_component(METER_KIND, Meter, MeterProps { value: 42 })
.size(120.0, 44.0)
.interactive()
.semantics_label("meter");
let frames = frames_of(mounted.build_with_mode(31, ResolvedSurfaceMode::Opaque, false));
assert_eq!(frames.len(), 1, "one component == one slot");
assert_eq!(frames[0].view_type, VIEW_TYPE);
assert!(frames[0].interactive);
assert_eq!(
frames[0].rect,
kurbo::Rect::from_origin_size(Point::ZERO, Size::new(120.0, 44.0)),
"the slot took the size the builder declared"
);
let params = frames[0].params_json.clone();
assert!(
params.contains("\"__frustControl\":\"test-meter\"") && params.contains("31"),
"{params}"
);
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
assert_eq!(runtime.create(&mut ctx, ¶ms).unwrap(), 31);
assert_eq!(runtime.live_count(), 1);
assert_eq!(runtime.dispose_slot(&mut ctx, 31), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
assert_eq!(calls, vec!["meter create 1000 = 42".to_string()]);
component_api::forget(31);
assert_eq!(component_api::staged_count(), 0);
}
#[test]
fn an_unchanged_rebuild_republishes_byte_identical_params() {
register_component::<Meter>(METER_KIND);
let first = native_component(METER_KIND, Meter, MeterProps { value: 7 }).build_with_mode(
32,
ResolvedSurfaceMode::Opaque,
false,
);
let again = native_component(METER_KIND, Meter, MeterProps { value: 7 }).build_with_mode(
32,
ResolvedSurfaceMode::Opaque,
false,
);
let changed = native_component(METER_KIND, Meter, MeterProps { value: 8 }).build_with_mode(
32,
ResolvedSurfaceMode::Opaque,
false,
);
let params = |view| frames_of(view).remove(0).params_json;
let (first, again, changed) = (params(first), params(again), params(changed));
assert_eq!(
first, again,
"an unchanged rebuild changes nothing on the wire"
);
assert_ne!(again, changed);
component_api::forget(32);
}
#[test]
fn a_brightness_flip_reaches_the_wire_even_with_unchanged_props() {
register_component::<Meter>(METER_KIND);
let light = native_component(METER_KIND, Meter, MeterProps { value: 9 }).build_with_mode(
34,
ResolvedSurfaceMode::Opaque,
false,
);
let dark = native_component(METER_KIND, Meter, MeterProps { value: 9 }).build_with_mode(
34,
ResolvedSurfaceMode::Opaque,
true,
);
let params = |view| frames_of(view).remove(0).params_json;
let (light, dark) = (params(light), params(dark));
assert_ne!(
light, dark,
"an unchanged props republish with a flipped brightness must still change the wire"
);
assert!(light.contains("\"dark\":false"), "{light}");
assert!(dark.contains("\"dark\":true"), "{dark}");
component_api::forget(34);
}
struct Clicker;
const CLICKER_KIND: &str = "test-clicker";
impl NativeComponent for Clicker {
type Props = MeterProps;
type State = crate::component::ListenerHandle;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
_props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = 2_000;
let listener = ctx.attach_listener(identity, crate::component::ListenerKinds::CLICK)?;
Some((ctx.root(identity)?, listener))
}
fn update(
&self,
_ctx: &mut ComponentCtx<'_, '_, '_>,
_state: &mut Self::State,
_old: &Self::Props,
_new: &Self::Props,
) {
}
}
#[test]
fn the_on_event_hook_hears_a_click_the_component_attached_for() {
use std::sync::Mutex;
use crate::runtime::NativeEvent as WireEvent;
assert!(register_component::<Clicker>(CLICKER_KIND));
let heard: Arc<Mutex<Vec<NativeEvent>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&heard);
let mounted = native_component(CLICKER_KIND, Clicker, MeterProps { value: 1 })
.size(120.0, 44.0)
.interactive()
.on_event(move |event| recorder.lock().unwrap().push(event));
let frames = frames_of(mounted.build_with_mode(35, ResolvedSurfaceMode::Opaque, false));
let params = frames[0].params_json.clone();
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
runtime.on_event(35, WireEvent { kind: 1, detail: 0 });
runtime.on_event(35, WireEvent { kind: 2, detail: 1 });
assert_eq!(runtime.dispose_slot(&mut ctx, 35), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
component_api::forget(35);
let heard = heard.lock().unwrap();
assert_eq!(heard.len(), 1, "exactly the one attached click: {heard:?}");
assert!(heard[0].is_click());
assert_eq!(calls, vec!["attachListener 2000 click".to_string()]);
}
#[test]
fn a_refused_component_slot_publishes_no_platform_view_frame() {
register_component::<Meter>(METER_KIND);
let mounted =
native_component(METER_KIND, Meter, MeterProps { value: 42 }).size(120.0, 44.0);
let refused = mounted.build_with_mode(33, ResolvedSurfaceMode::RefusedTranslucent, false);
let mut element = build_any(refused);
let mut scene = NullScene;
let mut pctx = PaintCtx::new(Point::ZERO, Size::new(120.0, 44.0));
element.paint(&mut pctx, &mut scene);
assert!(
pctx.take_platform_views().is_empty(),
"a refused slot must render no native platform_view frame"
);
assert_eq!(
component_api::staged_count(),
0,
"a refused slot stages no props either — there is nothing to create"
);
}
}