use std::any::Any;
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use crate::NativeWidgetError;
use crate::events::EventPayload;
use crate::registry::{Registry, SlotId};
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
pub(crate) use self::host::{NativeCtx, NativeView};
#[cfg(target_os = "android")]
pub(crate) use crate::android::{NativeCtx, NativeView};
#[cfg(target_os = "macos")]
pub(crate) use crate::appkit::{NativeCtx, NativeView};
#[cfg(target_os = "ios")]
pub(crate) use crate::apple::{NativeCtx, NativeView};
pub(crate) const CONTROL_KEY: &str = "__frustControl";
pub(crate) const SLOT_KEY: &str = "__frustSlot";
pub(crate) struct Params<'a> {
raw: &'a str,
}
impl<'a> Params<'a> {
pub(crate) fn new(raw: &'a str) -> Self {
Self { raw }
}
pub(crate) fn identity(&self) -> Result<(Cow<'a, str>, SlotId), NativeWidgetError> {
let kind = self.string(CONTROL_KEY).ok_or_else(|| {
NativeWidgetError::Params(format!("params carry no `{CONTROL_KEY}` control kind"))
})?;
let slot = self.int(SLOT_KEY).ok_or_else(|| {
NativeWidgetError::Params(format!("params carry no `{SLOT_KEY}` slot id"))
})?;
let slot = SlotId::try_from(slot)
.map_err(|_| NativeWidgetError::Params(format!("`{SLOT_KEY}` is negative ({slot})")))?;
Ok((kind, slot))
}
pub(crate) fn string(&self, key: &str) -> Option<Cow<'a, str>> {
match value_of(self.raw, key)? {
Value::Str(raw) => Some(unescape(raw)),
_ => None,
}
}
pub(crate) fn int(&self, key: &str) -> Option<i64> {
match value_of(self.raw, key)? {
Value::Number(raw) => raw.parse().ok(),
_ => None,
}
}
pub(crate) fn float(&self, key: &str) -> Option<f64> {
match value_of(self.raw, key)? {
Value::Number(raw) => raw.parse().ok(),
_ => None,
}
}
pub(crate) fn flag(&self, key: &str) -> Option<bool> {
match value_of(self.raw, key)? {
Value::Bool(v) => Some(v),
_ => None,
}
}
}
pub(crate) fn with_identity(kind: &str, slot_id: SlotId, body: &str) -> String {
ensure_platform_factory();
let mut out = String::with_capacity(body.len() + kind.len() + 48);
out.push('{');
out.push('"');
out.push_str(CONTROL_KEY);
out.push_str("\":\"");
out.push_str(&escape(kind));
out.push_str("\",\"");
out.push_str(SLOT_KEY);
out.push_str("\":");
out.push_str(&slot_id.to_string());
if !body.is_empty() {
out.push(',');
out.push_str(body);
}
out.push('}');
out
}
#[inline]
pub(crate) fn ensure_platform_factory() {
#[cfg(target_os = "ios")]
crate::apple::ensure_registered();
#[cfg(target_os = "macos")]
crate::appkit::ensure_registered();
}
pub(crate) fn escape(value: &str) -> Cow<'_, str> {
if !value
.chars()
.any(|c| matches!(c, '"' | '\\') || c.is_control())
{
return Cow::Borrowed(value);
}
let mut out = String::with_capacity(value.len() + 8);
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
Cow::Owned(out)
}
enum Value<'a> {
Str(&'a str),
Number(&'a str),
Bool(bool),
Other,
}
fn value_of<'a>(raw: &'a str, key: &str) -> Option<Value<'a>> {
let bytes = raw.as_bytes();
let mut i = skip_ws(bytes, 0);
if bytes.get(i) != Some(&b'{') {
return None;
}
i += 1;
loop {
i = skip_ws(bytes, i);
match bytes.get(i) {
Some(b'}') | None => return None,
Some(b',') => {
i += 1;
continue;
}
Some(b'"') => {}
Some(_) => return None,
}
let (name, next) = scan_string(bytes, i)?;
i = skip_ws(bytes, next);
if bytes.get(i) != Some(&b':') {
return None;
}
i = skip_ws(bytes, i + 1);
let (value, next) = scan_value(raw, i)?;
if unescape(&raw[name.clone()]) == key {
return Some(value);
}
i = next;
}
}
fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
while matches!(bytes.get(i), Some(b' ' | b'\t' | b'\n' | b'\r')) {
i += 1;
}
i
}
fn scan_string(bytes: &[u8], i: usize) -> Option<(std::ops::Range<usize>, usize)> {
debug_assert_eq!(bytes.get(i), Some(&b'"'));
let start = i + 1;
let mut j = start;
while let Some(&b) = bytes.get(j) {
match b {
b'\\' => j += 2,
b'"' => return Some((start..j, j + 1)),
_ => j += 1,
}
}
None
}
fn scan_value(raw: &str, i: usize) -> Option<(Value<'_>, usize)> {
let bytes = raw.as_bytes();
match bytes.get(i)? {
b'"' => {
let (range, next) = scan_string(bytes, i)?;
Some((Value::Str(&raw[range]), next))
}
b'{' | b'[' => Some((Value::Other, skip_nested(bytes, i)?)),
b't' if raw[i..].starts_with("true") => Some((Value::Bool(true), i + 4)),
b'f' if raw[i..].starts_with("false") => Some((Value::Bool(false), i + 5)),
b'n' if raw[i..].starts_with("null") => Some((Value::Other, i + 4)),
_ => {
let mut j = i;
while matches!(
bytes.get(j),
Some(b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E')
) {
j += 1;
}
(j > i).then(|| (Value::Number(&raw[i..j]), j))
}
}
}
fn skip_nested(bytes: &[u8], i: usize) -> Option<usize> {
let mut depth = 0usize;
let mut j = i;
while let Some(&b) = bytes.get(j) {
match b {
b'"' => {
let (_, next) = scan_string(bytes, j)?;
j = next;
continue;
}
b'{' | b'[' => depth += 1,
b'}' | b']' => {
depth -= 1;
if depth == 0 {
return Some(j + 1);
}
}
_ => {}
}
j += 1;
}
None
}
fn unescape(raw: &str) -> Cow<'_, str> {
if !raw.contains('\\') {
return Cow::Borrowed(raw);
}
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('b') => out.push('\u{8}'),
Some('f') => out.push('\u{c}'),
Some('u') => {
let hex: String = chars.by_ref().take(4).collect();
match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
Some(c) => out.push(c),
None => out.push('\u{fffd}'),
}
}
Some(other) => out.push(other),
None => break,
}
}
Cow::Owned(out)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct NativeEvent {
pub(crate) kind: i32,
pub(crate) detail: i64,
}
pub(crate) trait NativeWidget: 'static {
type Props: Clone + PartialEq + Send + 'static;
type State: 'static;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError>;
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError>;
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError>;
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
let _ = (state, event);
None
}
fn dispose(ctx: &mut NativeCtx<'_, '_>, state: Self::State) -> Result<(), NativeWidgetError>;
}
#[derive(Clone, Copy)]
struct KindVTable {
decode: DecodeFn,
create: CreateFn,
update: UpdateFn,
props_eq: PropsEqFn,
on_event: EventFn,
dispose: DisposeFn,
}
type DecodeFn = fn(&Params<'_>) -> Result<Box<dyn Any>, NativeWidgetError>;
type CreateFn =
fn(&mut NativeCtx<'_, '_>, &dyn Any) -> Result<(NativeView, Box<dyn Any>), NativeWidgetError>;
type UpdateFn =
fn(&mut NativeCtx<'_, '_>, &mut dyn Any, &dyn Any, &dyn Any) -> Result<(), NativeWidgetError>;
type PropsEqFn = fn(&dyn Any, &dyn Any) -> bool;
type EventFn = fn(&mut dyn Any, NativeEvent) -> Option<EventPayload>;
type DisposeFn = fn(&mut NativeCtx<'_, '_>, Box<dyn Any>) -> Result<(), NativeWidgetError>;
impl KindVTable {
fn of<W: NativeWidget>() -> Self {
Self {
decode: |params| W::decode_props(params).map(|p| Box::new(p) as Box<dyn Any>),
create: |ctx, props| {
let (view, state) = W::create(ctx, downcast::<W::Props>(props, "props"))?;
Ok((view, Box::new(state) as Box<dyn Any>))
},
update: |ctx, state, old, new| {
W::update(
ctx,
downcast_mut::<W::State>(state, "state"),
downcast::<W::Props>(old, "old props"),
downcast::<W::Props>(new, "new props"),
)
},
props_eq: |a, b| downcast::<W::Props>(a, "props") == downcast::<W::Props>(b, "props"),
on_event: |state, event| W::on_event(downcast_mut::<W::State>(state, "state"), event),
dispose: |ctx, state| {
W::dispose(
ctx,
*state
.downcast::<W::State>()
.unwrap_or_else(|_| panic!("{ERASURE_BUG}: state")),
)
},
}
}
}
const ERASURE_BUG: &str = "frust-native-widgets: instance paired with another kind's vtable";
fn downcast<'a, T: 'static>(value: &'a dyn Any, what: &str) -> &'a T {
value
.downcast_ref::<T>()
.unwrap_or_else(|| panic!("{ERASURE_BUG}: {what}"))
}
fn downcast_mut<'a, T: 'static>(value: &'a mut dyn Any, what: &str) -> &'a mut T {
value
.downcast_mut::<T>()
.unwrap_or_else(|| panic!("{ERASURE_BUG}: {what}"))
}
pub(crate) struct Instance {
kind: &'static str,
view: NativeView,
props: Box<dyn Any>,
state: Box<dyn Any>,
vtable: KindVTable,
callback: Option<Arc<dyn Fn(EventPayload) + Send + Sync>>,
}
impl Instance {
pub(crate) fn view(&self) -> &NativeView {
&self.view
}
pub(crate) fn kind(&self) -> &'static str {
self.kind
}
pub(crate) fn dispose(self, ctx: &mut NativeCtx<'_, '_>) -> Result<(), NativeWidgetError> {
let Self {
kind: _,
view,
props: _,
state,
vtable,
callback: _,
} = self;
let outcome = (vtable.dispose)(ctx, state);
#[allow(clippy::drop_non_drop)]
drop(view);
outcome
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum UpdateOutcome {
Applied,
Unchanged,
UnknownSlot,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DisposeOutcome {
Disposed,
NotFound,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum EventOutcome {
Delivered,
UnknownSlot,
}
pub(crate) struct NativeRuntime {
kinds: HashMap<&'static str, KindVTable>,
instances: Registry<Instance>,
pending_callbacks: HashMap<SlotId, Arc<dyn Fn(EventPayload) + Send + Sync>>,
}
impl NativeRuntime {
pub(crate) fn new() -> Self {
Self {
kinds: HashMap::new(),
instances: Registry::new(),
pending_callbacks: HashMap::new(),
}
}
pub(crate) fn register<W: NativeWidget>(&mut self, kind: &'static str) -> bool {
let fresh = self.kinds.insert(kind, KindVTable::of::<W>()).is_none();
if !fresh {
log::warn!("frust-native-widgets: control kind '{kind}' registered twice");
}
fresh
}
pub(crate) fn register_if_free<W: NativeWidget>(&mut self, kind: &'static str) -> bool {
if self.kinds.contains_key(kind) {
log::warn!(
"frust-native-widgets: control kind '{kind}' is already registered — keeping the \
existing one"
);
return false;
}
self.register::<W>(kind)
}
pub(crate) fn is_registered(&self, kind: &str) -> bool {
self.kinds.contains_key(kind)
}
pub(crate) fn create(
&mut self,
ctx: &mut NativeCtx<'_, '_>,
params_json: &str,
) -> Result<SlotId, NativeWidgetError> {
let params = Params::new(params_json);
let (kind, slot_id) = params.identity()?;
let (kind, vtable) = self
.kinds
.get_key_value(kind.as_ref())
.map(|(name, vtable)| (*name, *vtable))
.ok_or_else(|| NativeWidgetError::UnknownControl(kind.into_owned()))?;
let props = (vtable.decode)(¶ms)?;
let (view, state) = (vtable.create)(ctx, &*props)?;
let callback = self
.pending_callbacks
.remove(&slot_id)
.or_else(|| self.instances.get(slot_id).and_then(|p| p.callback.clone()));
let instance = Instance {
kind,
view,
props,
state,
vtable,
callback,
};
if let Some(previous) = self.instances.insert(slot_id, instance) {
log::warn!(
"frust-native-widgets: create for live slot {slot_id} ('{kind}') — replacing"
);
if let Err(e) = previous.dispose(ctx) {
log::warn!("frust-native-widgets: disposing replaced slot {slot_id}: {e}");
}
}
Ok(slot_id)
}
pub(crate) fn instance(&self, slot_id: SlotId) -> Option<&Instance> {
self.instances.get(slot_id)
}
pub(crate) fn update_params(
&mut self,
ctx: &mut NativeCtx<'_, '_>,
params_json: &str,
) -> Result<UpdateOutcome, NativeWidgetError> {
let params = Params::new(params_json);
let (kind, slot_id) = params.identity()?;
let Some(instance) = self.instances.get_mut(slot_id) else {
return Ok(UpdateOutcome::UnknownSlot);
};
if instance.kind != kind {
return Err(NativeWidgetError::Params(format!(
"slot {slot_id} is a '{}' but its params now say '{kind}'",
instance.kind
)));
}
let vtable = instance.vtable;
let new_props = (vtable.decode)(¶ms)?;
if (vtable.props_eq)(&*instance.props, &*new_props) {
return Ok(UpdateOutcome::Unchanged);
}
(vtable.update)(ctx, &mut *instance.state, &*instance.props, &*new_props)?;
instance.props = new_props;
Ok(UpdateOutcome::Applied)
}
pub(crate) fn on_event(&mut self, slot_id: SlotId, event: NativeEvent) -> EventOutcome {
let Some(instance) = self.instances.get_mut(slot_id) else {
return EventOutcome::UnknownSlot;
};
let payload = (instance.vtable.on_event)(&mut *instance.state, event);
let callback = payload
.is_some()
.then(|| instance.callback.clone())
.flatten();
if let (Some(payload), Some(callback)) = (payload, callback) {
callback(payload);
}
EventOutcome::Delivered
}
pub(crate) fn set_callback(
&mut self,
slot_id: SlotId,
callback: Arc<dyn Fn(EventPayload) + Send + Sync>,
) -> bool {
match self.instances.get_mut(slot_id) {
Some(instance) => {
instance.callback = Some(callback);
self.pending_callbacks.remove(&slot_id);
true
}
None => {
self.pending_callbacks.insert(slot_id, callback);
true
}
}
}
pub(crate) fn take_matching(
&mut self,
is_match: impl FnMut(&NativeView) -> bool,
) -> Option<(SlotId, Instance)> {
let mut is_match = is_match;
self.instances
.remove_matching(|instance| is_match(&instance.view))
}
pub(crate) fn dispose_slot(
&mut self,
ctx: &mut NativeCtx<'_, '_>,
slot_id: SlotId,
) -> DisposeOutcome {
self.pending_callbacks.remove(&slot_id);
let Some(instance) = self.instances.remove(slot_id) else {
return DisposeOutcome::NotFound;
};
if let Err(e) = instance.dispose(ctx) {
log::warn!("frust-native-widgets: disposing slot {slot_id}: {e}");
}
DisposeOutcome::Disposed
}
pub(crate) fn forget_pending_callback(&mut self, slot: SlotId) {
self.pending_callbacks.remove(&slot);
}
pub(crate) fn live_count(&self) -> usize {
self.instances.live_count()
}
}
impl Default for NativeRuntime {
fn default() -> Self {
Self::new()
}
}
thread_local! {
static RUNTIME: RefCell<NativeRuntime> = RefCell::new(seeded_runtime());
}
fn seeded_runtime() -> NativeRuntime {
#[allow(unused_mut)]
let mut runtime = NativeRuntime::new();
#[cfg(target_os = "android")]
crate::android::register_controls(&mut runtime);
#[cfg(target_os = "ios")]
crate::apple::register_controls(&mut runtime);
#[cfg(target_os = "macos")]
crate::appkit::register_controls(&mut runtime);
runtime
}
pub(crate) fn with_runtime<T>(f: impl FnOnce(&mut NativeRuntime) -> T) -> Option<T> {
RUNTIME
.try_with(|cell| match cell.try_borrow_mut() {
Ok(mut runtime) => Some(f(&mut runtime)),
Err(_) => {
log::warn!(
"frust-native-widgets: re-entrant runtime call ignored (a platform setter \
fired its own listener?)"
);
None
}
})
.unwrap_or_default()
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
pub(crate) mod host {
use std::marker::PhantomData;
pub(crate) struct NativeCtx<'local, 'env> {
calls: &'env mut Vec<String>,
_frame: PhantomData<&'local ()>,
}
impl<'local, 'env> NativeCtx<'local, 'env> {
pub(crate) fn new(calls: &'env mut Vec<String>) -> Self {
Self {
calls,
_frame: PhantomData,
}
}
}
impl NativeCtx<'_, '_> {
pub(crate) fn record(&mut self, call: impl Into<String>) {
self.calls.push(call.into());
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct NativeView {
pub(crate) identity: u64,
}
}
#[cfg(all(
test,
not(any(target_os = "android", target_os = "ios", target_os = "macos"))
))]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::events::EVENT_KIND_CLICK;
struct FakeControl;
#[derive(Clone, Debug, PartialEq)]
struct FakeProps {
label: String,
enabled: bool,
}
struct FakeState {
identity: u64,
events: Vec<NativeEvent>,
}
const FAKE_KIND: &str = "fake";
const FAIL_CREATE_LABEL: &str = "FAIL_CREATE";
impl NativeWidget for FakeControl {
type Props = FakeProps;
type State = FakeState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
Ok(FakeProps {
label: params
.string("label")
.ok_or_else(|| NativeWidgetError::Params("no `label`".into()))?
.into_owned(),
enabled: params.flag("enabled").unwrap_or(true),
})
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
if props.label == FAIL_CREATE_LABEL {
ctx.record("create failed".to_string());
return Err(NativeWidgetError::Platform("fake create failure".into()));
}
let identity = params_identity_counter();
ctx.record(format!("create {} '{}'", identity, props.label));
Ok((
NativeView { identity },
FakeState {
identity,
events: Vec::new(),
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
ctx.record(format!(
"update {} '{}' -> '{}'",
state.identity, old.label, new.label
));
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
state.events.push(event);
Some(EventPayload::Click)
}
fn dispose(
ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
ctx.record(format!("dispose {}", state.identity));
Ok(())
}
}
fn params_identity_counter() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
fn runtime() -> NativeRuntime {
let mut runtime = NativeRuntime::new();
assert!(runtime.register::<FakeControl>(FAKE_KIND));
runtime
}
fn params(slot: SlotId, label: &str, enabled: bool) -> String {
with_identity(
FAKE_KIND,
slot,
&format!("\"label\":\"{}\",\"enabled\":{enabled}", escape(label)),
)
}
type FiredLog = Arc<Mutex<Vec<EventPayload>>>;
type TestCallback = Arc<dyn Fn(EventPayload) + Send + Sync>;
fn recorder() -> (FiredLog, TestCallback) {
let fired: FiredLog = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&fired);
(
fired,
Arc::new(move |payload| sink.lock().unwrap().push(payload)),
)
}
const CLICK: NativeEvent = NativeEvent {
kind: EVENT_KIND_CLICK,
detail: 0,
};
#[test]
fn identity_injection_round_trips() {
let raw = params(7, "Save", true);
let parsed = Params::new(&raw);
let (kind, slot) = parsed.identity().expect("identity present");
assert_eq!(kind, FAKE_KIND);
assert_eq!(slot, 7);
assert_eq!(parsed.string("label").unwrap(), "Save");
assert_eq!(parsed.flag("enabled"), Some(true));
}
#[test]
fn identity_survives_an_escaped_body() {
let raw = params(3, "say \"hi\"\n", false);
let parsed = Params::new(&raw);
assert_eq!(parsed.identity().unwrap(), (Cow::Borrowed(FAKE_KIND), 3));
assert_eq!(parsed.string("label").unwrap(), "say \"hi\"\n");
assert_eq!(parsed.flag("enabled"), Some(false));
}
#[test]
fn a_key_inside_a_string_value_is_not_matched() {
let raw = format!(
"{{\"{CONTROL_KEY}\":\"fake\",\"{SLOT_KEY}\":9,\"label\":\"\\\"{SLOT_KEY}\\\":42\"}}"
);
let parsed = Params::new(&raw);
assert_eq!(parsed.int(SLOT_KEY), Some(9));
assert_eq!(parsed.string("label").unwrap(), "\"__frustSlot\":42");
}
#[test]
fn nested_values_are_skipped_as_a_unit() {
let raw = format!(
"{{\"{CONTROL_KEY}\":\"fake\",\"nested\":{{\"{SLOT_KEY}\":1,\"a\":[1,2]}},\
\"{SLOT_KEY}\":5,\"size\":12.5}}"
);
let parsed = Params::new(&raw);
assert_eq!(parsed.identity().unwrap().1, 5);
assert_eq!(parsed.float("size"), Some(12.5));
assert!(parsed.string("nested").is_none());
}
#[test]
fn params_without_identity_are_an_error() {
assert!(matches!(
Params::new("{\"label\":\"x\"}").identity(),
Err(NativeWidgetError::Params(_))
));
assert!(matches!(
Params::new(&format!("{{\"{CONTROL_KEY}\":\"fake\"}}")).identity(),
Err(NativeWidgetError::Params(_))
));
assert!(matches!(
Params::new("not json at all").identity(),
Err(NativeWidgetError::Params(_))
));
}
#[test]
fn create_dispatches_by_kind_and_retains_the_instance() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let slot = runtime
.create(&mut ctx, ¶ms(1, "Save", true))
.expect("create");
assert_eq!(slot, 1);
assert_eq!(runtime.live_count(), 1);
assert_eq!(runtime.instance(1).map(Instance::kind), Some(FAKE_KIND));
assert_eq!(calls.len(), 1, "exactly one platform create: {calls:?}");
assert!(calls[0].ends_with("'Save'"));
}
#[test]
fn create_for_an_unregistered_kind_is_an_error() {
let mut runtime = NativeRuntime::new();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let err = runtime
.create(&mut ctx, ¶ms(1, "Save", true))
.expect_err("no kinds registered");
assert!(matches!(err, NativeWidgetError::UnknownControl(kind) if kind == FAKE_KIND));
assert_eq!(runtime.live_count(), 0);
assert!(calls.is_empty(), "nothing crossed the boundary");
}
#[test]
fn create_for_a_live_slot_replaces_and_disposes_the_previous() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(4, "first", true)).unwrap();
runtime
.create(&mut ctx, ¶ms(4, "second", true))
.unwrap();
assert_eq!(runtime.live_count(), 1, "the slot holds one instance");
assert_eq!(calls.len(), 3, "{calls:?}");
assert!(calls[1].ends_with("'second'"));
assert!(calls[2].starts_with("dispose"));
}
#[test]
fn unchanged_props_never_reach_the_platform() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(2, "Save", true)).unwrap();
calls.clear();
let mut ctx = NativeCtx::new(&mut calls);
let outcome = runtime
.update_params(&mut ctx, ¶ms(2, "Save", true))
.unwrap();
assert_eq!(outcome, UpdateOutcome::Unchanged);
let reordered = format!(
"{{ \"enabled\":true, \"label\":\"Save\", \"{SLOT_KEY}\":2, \"{CONTROL_KEY}\":\"fake\" }}"
);
assert_eq!(
runtime.update_params(&mut ctx, &reordered).unwrap(),
UpdateOutcome::Unchanged
);
assert!(calls.is_empty(), "the diff gate crossed nothing: {calls:?}");
}
#[test]
fn changed_props_apply_once_and_rebase_the_gate() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(2, "Save", true)).unwrap();
calls.clear();
let mut ctx = NativeCtx::new(&mut calls);
assert_eq!(
runtime
.update_params(&mut ctx, ¶ms(2, "Saved", true))
.unwrap(),
UpdateOutcome::Applied
);
assert_eq!(
runtime
.update_params(&mut ctx, ¶ms(2, "Saved", true))
.unwrap(),
UpdateOutcome::Unchanged
);
assert_eq!(calls.len(), 1, "{calls:?}");
assert!(calls[0].contains("'Save' -> 'Saved'"));
}
#[test]
fn update_for_an_unknown_slot_is_tolerated() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
assert_eq!(
runtime
.update_params(&mut ctx, ¶ms(99, "ghost", true))
.unwrap(),
UpdateOutcome::UnknownSlot
);
assert!(calls.is_empty());
}
#[test]
fn update_naming_a_different_kind_is_an_error() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(2, "Save", true)).unwrap();
let other = with_identity("other", 2, "\"label\":\"x\"");
assert!(matches!(
runtime.update_params(&mut ctx, &other),
Err(NativeWidgetError::Params(_))
));
}
#[test]
fn create_dispose_cycles_return_to_zero_live_instances() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
for slot in 0..50u64 {
runtime.create(&mut ctx, ¶ms(slot, "x", true)).unwrap();
assert_eq!(
runtime.dispose_slot(&mut ctx, slot),
DisposeOutcome::Disposed
);
}
assert_eq!(runtime.live_count(), 0);
}
#[test]
fn duplicate_and_unknown_disposes_are_silent_no_ops() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(1, "x", true)).unwrap();
assert_eq!(runtime.dispose_slot(&mut ctx, 1), DisposeOutcome::Disposed);
assert_eq!(runtime.dispose_slot(&mut ctx, 1), DisposeOutcome::NotFound);
assert_eq!(
runtime.dispose_slot(&mut ctx, 404),
DisposeOutcome::NotFound
);
assert_eq!(runtime.live_count(), 0);
}
#[test]
fn a_late_dispose_for_a_replaced_view_never_touches_the_live_one() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(8, "first", true)).unwrap();
let stale = runtime.instance(8).unwrap().view().identity;
runtime
.create(&mut ctx, ¶ms(8, "second", true))
.unwrap();
let live = runtime.instance(8).unwrap().view().identity;
assert_ne!(stale, live);
assert!(
runtime
.take_matching(|view| view.identity == stale)
.is_none(),
"a dispose naming the replaced view finds nothing"
);
assert_eq!(runtime.live_count(), 1);
assert_eq!(runtime.instance(8).unwrap().view().identity, live);
let (slot, instance) = runtime
.take_matching(|view| view.identity == live)
.expect("live view matched");
assert_eq!(slot, 8);
instance.dispose(&mut ctx).unwrap();
assert_eq!(runtime.live_count(), 0);
}
#[test]
fn events_route_to_their_slot_and_unknown_slots_are_tolerated() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(1, "a", true)).unwrap();
runtime.create(&mut ctx, ¶ms(2, "b", true)).unwrap();
let click = NativeEvent {
kind: EVENT_KIND_CLICK,
detail: 0,
};
assert_eq!(runtime.on_event(2, click), EventOutcome::Delivered);
assert_eq!(runtime.on_event(77, click), EventOutcome::UnknownSlot);
let state_of = |runtime: &NativeRuntime, slot: SlotId| {
runtime
.instance(slot)
.unwrap()
.state
.downcast_ref::<FakeState>()
.unwrap()
.events
.clone()
};
assert!(state_of(&runtime, 1).is_empty(), "no cross-talk");
assert_eq!(state_of(&runtime, 2), vec![click]);
}
#[test]
fn a_registered_callback_fires_only_for_its_own_slot() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
runtime.create(&mut ctx, ¶ms(1, "a", true)).unwrap();
runtime.create(&mut ctx, ¶ms(2, "b", true)).unwrap();
let fired: Arc<Mutex<Vec<EventPayload>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&fired);
assert!(runtime.set_callback(
2,
Arc::new(move |payload| recorder.lock().unwrap().push(payload))
));
let click = NativeEvent {
kind: EVENT_KIND_CLICK,
detail: 0,
};
assert_eq!(runtime.on_event(1, click), EventOutcome::Delivered);
assert_eq!(runtime.on_event(2, click), EventOutcome::Delivered);
assert_eq!(*fired.lock().unwrap(), vec![EventPayload::Click]);
}
#[test]
fn a_callback_registered_before_create_is_born_with_the_instance() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (fired, callback) = recorder();
assert!(runtime.set_callback(5, callback));
assert_eq!(runtime.pending_callbacks.len(), 1, "deferred, not dropped");
assert_eq!(runtime.live_count(), 0);
runtime.create(&mut ctx, ¶ms(5, "Save", true)).unwrap();
assert!(
runtime.pending_callbacks.is_empty(),
"the create took the pending registration"
);
assert_eq!(runtime.on_event(5, CLICK), EventOutcome::Delivered);
assert_eq!(*fired.lock().unwrap(), vec![EventPayload::Click]);
}
#[test]
fn a_callback_registered_after_create_still_attaches() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (fired, callback) = recorder();
runtime.create(&mut ctx, ¶ms(6, "Save", true)).unwrap();
assert!(runtime.set_callback(6, callback));
assert!(runtime.pending_callbacks.is_empty());
assert_eq!(runtime.on_event(6, CLICK), EventOutcome::Delivered);
assert_eq!(*fired.lock().unwrap(), vec![EventPayload::Click]);
}
#[test]
fn a_replay_create_inherits_the_replaced_instance_callback() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (fired, callback) = recorder();
runtime.create(&mut ctx, ¶ms(7, "first", true)).unwrap();
assert!(runtime.set_callback(7, callback));
runtime
.create(&mut ctx, ¶ms(7, "second", true))
.unwrap();
assert_eq!(runtime.live_count(), 1, "the replay replaced in place");
assert_eq!(runtime.on_event(7, CLICK), EventOutcome::Delivered);
assert_eq!(*fired.lock().unwrap(), vec![EventPayload::Click]);
}
#[test]
fn a_pending_registration_outranks_the_replaced_instance_callback() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (stale, stale_callback) = recorder();
let (fresh, fresh_callback) = recorder();
runtime.create(&mut ctx, ¶ms(8, "first", true)).unwrap();
assert!(runtime.set_callback(8, stale_callback));
runtime.pending_callbacks.insert(8, fresh_callback);
runtime
.create(&mut ctx, ¶ms(8, "second", true))
.unwrap();
assert_eq!(runtime.on_event(8, CLICK), EventOutcome::Delivered);
assert_eq!(*fresh.lock().unwrap(), vec![EventPayload::Click]);
assert!(stale.lock().unwrap().is_empty(), "the older callback lost");
}
#[test]
fn disposing_a_slot_that_never_created_drops_its_pending_callback() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (fired, callback) = recorder();
assert!(runtime.set_callback(9, callback));
assert_eq!(runtime.pending_callbacks.len(), 1);
assert_eq!(
Arc::strong_count(&fired),
2,
"the runtime holds the closure"
);
assert_eq!(runtime.dispose_slot(&mut ctx, 9), DisposeOutcome::NotFound);
assert!(
runtime.pending_callbacks.is_empty(),
"no closure stranded for the process lifetime"
);
assert_eq!(Arc::strong_count(&fired), 1, "the closure was dropped");
runtime.create(&mut ctx, ¶ms(9, "x", true)).unwrap();
assert_eq!(runtime.on_event(9, CLICK), EventOutcome::Delivered);
assert!(fired.lock().unwrap().is_empty());
}
#[test]
fn the_culled_dispose_then_republish_leak_is_reaped_on_component_teardown() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (first_fired, first_callback) = recorder();
assert!(runtime.set_callback(20, first_callback));
runtime.create(&mut ctx, ¶ms(20, "x", true)).unwrap();
assert!(
runtime.pending_callbacks.is_empty(),
"the create took the pending registration"
);
drop(first_fired);
let identity = runtime.instance(20).unwrap().view().identity;
let (slot, instance) = runtime
.take_matching(|view| view.identity == identity)
.expect("the culled dispose finds the live view by identity");
assert_eq!(slot, 20);
instance.dispose(&mut ctx).unwrap();
assert_eq!(runtime.live_count(), 0);
let (fresh, fresh_callback) = recorder();
assert!(runtime.set_callback(20, fresh_callback));
assert_eq!(runtime.pending_callbacks.len(), 1);
assert_eq!(
Arc::strong_count(&fresh),
2,
"the runtime holds the closure"
);
runtime.forget_pending_callback(20);
assert!(
runtime.pending_callbacks.is_empty(),
"without forget_pending_callback the fresh entry would strand \
its closure for the process lifetime — a defect that once shipped"
);
assert_eq!(
Arc::strong_count(&fresh),
1,
"the closure was actually dropped, not just unreachable"
);
runtime.create(&mut ctx, ¶ms(20, "y", true)).unwrap();
assert_eq!(runtime.on_event(20, CLICK), EventOutcome::Delivered);
assert!(fresh.lock().unwrap().is_empty(), "reaped, never re-fires");
}
#[test]
fn a_failed_create_leaves_the_entry_pending_and_teardown_still_reaps_it() {
let mut runtime = runtime();
let mut calls = Vec::new();
let mut ctx = NativeCtx::new(&mut calls);
let (fired, callback) = recorder();
assert!(runtime.set_callback(30, callback));
assert_eq!(runtime.pending_callbacks.len(), 1);
let err = runtime
.create(&mut ctx, ¶ms(30, FAIL_CREATE_LABEL, true))
.expect_err("the fake control's create fails for this sentinel label");
assert!(matches!(err, NativeWidgetError::Platform(_)));
assert_eq!(runtime.live_count(), 0, "no instance was ever retained");
assert_eq!(
runtime.pending_callbacks.len(),
1,
"a failed create must not touch the pending entry — nothing \
schedules a retry, so dropping it here would strand it \
instead of just leaving it pending"
);
assert_eq!(Arc::strong_count(&fired), 2, "the runtime still holds it");
runtime.forget_pending_callback(30);
assert!(
runtime.pending_callbacks.is_empty(),
"Component teardown still reaps a pending entry whose create \
never once succeeded"
);
assert_eq!(Arc::strong_count(&fired), 1, "the closure was dropped");
runtime.forget_pending_callback(30);
assert!(runtime.pending_callbacks.is_empty());
}
#[test]
fn registering_the_same_kind_twice_reports_it() {
let mut runtime = runtime();
assert!(!runtime.register::<FakeControl>(FAKE_KIND));
assert!(runtime.is_registered(FAKE_KIND));
assert!(!runtime.is_registered("nope"));
}
#[test]
fn the_thread_local_runtime_is_reentrancy_tolerant() {
let outer = with_runtime(|_| {
let inner = with_runtime(|_| "should not run");
assert!(
inner.is_none(),
"a re-entrant call is dropped, not panicked"
);
"outer ran"
});
assert_eq!(outer, Some("outer ran"));
}
}