use crate::component::{ListenerHandle, NativeChild, register_component};
#[allow(unused_imports)]
use crate::component::{ComponentCtx, ListenerKinds, NativeComponent, NativeRoot};
pub const DEMO_CARD_KIND: &str = "frust.demo.card";
pub const DEMO_CARD_WIDTH: f64 = 240.0;
pub const DEMO_CARD_HEIGHT: f64 = 160.0;
pub const DEMO_CARD_CHILDREN: usize = 3;
#[allow(dead_code)]
const CARD_PADDING: f64 = 8.0;
#[allow(dead_code)]
const TITLE_HEIGHT: f64 = 24.0;
#[allow(dead_code)]
const BUTTON_HEIGHT: f64 = 44.0;
#[allow(dead_code)]
const ROW_GAP: f64 = 8.0;
const FRAME_CAPACITY: usize = 16;
pub struct DemoCard;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DemoCardProps {
pub title: String,
pub primary: String,
pub secondary: String,
pub background_argb: i32,
pub title_argb: i32,
pub presses: u32,
}
impl DemoCardProps {
pub fn primary_caption(&self) -> String {
format!("{} ({})", self.primary, self.presses)
}
}
pub struct DemoCardState {
root: NativeChild,
label: LabelHandle,
buttons: [ButtonHandle; 2],
listener: ListenerHandle,
}
#[cfg(target_os = "ios")]
type LabelHandle = objc2::rc::Retained<objc2_ui_kit::UILabel>;
#[cfg(target_os = "macos")]
type LabelHandle = objc2::rc::Retained<objc2_app_kit::NSTextField>;
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
type LabelHandle = NativeChild;
#[cfg(target_os = "ios")]
type ButtonHandle = objc2::rc::Retained<objc2_ui_kit::UIButton>;
#[cfg(target_os = "macos")]
type ButtonHandle = objc2::rc::Retained<objc2_app_kit::NSButton>;
#[cfg(not(any(target_os = "ios", target_os = "macos")))]
type ButtonHandle = NativeChild;
pub fn register_demo_components() -> bool {
register_component::<DemoCard>(DEMO_CARD_KIND)
}
#[cfg(target_os = "android")]
mod platform {
use jni::objects::{JObject, JValue};
use jni::strings::JNIStr;
use jni::{jni_sig, jni_str};
use super::{
BUTTON_HEIGHT, CARD_PADDING, DemoCard, DemoCardProps, DemoCardState, FRAME_CAPACITY,
ROW_GAP, TITLE_HEIGHT,
};
use crate::component::{ComponentCtx, ListenerKinds, NativeComponent, NativeEvent, NativeRoot};
const LINEAR_LAYOUT: &str = "android.widget.LinearLayout";
const TEXT_VIEW: &str = "android.widget.TextView";
const BUTTON: &str = "android.widget.Button";
const LINEAR_LAYOUT_VERTICAL: i32 = 1;
impl NativeComponent for DemoCard {
type Props = DemoCardProps;
type State = DemoCardState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
ctx.with_local_frame(FRAME_CAPACITY, |ctx| {
let parent = ctx.new_view(LINEAR_LAYOUT)?;
call_int(
ctx,
&parent,
jni_str!("setOrientation"),
LINEAR_LAYOUT_VERTICAL,
)?;
call_int(
ctx,
&parent,
jni_str!("setBackgroundColor"),
props.background_argb,
)?;
set_padding(ctx, &parent)?;
let label = ctx.new_view(TEXT_VIEW)?;
set_text(ctx, &label, &props.title)?;
call_int(ctx, &label, jni_str!("setTextColor"), props.title_argb)?;
call_int(ctx, &label, jni_str!("setMinHeight"), px(TITLE_HEIGHT))?;
ctx.add_child(&parent, &label)?;
let mut buttons = Vec::with_capacity(2);
let mut listener = None;
for (row, caption) in [props.primary_caption(), props.secondary.clone()]
.into_iter()
.enumerate()
{
let button = ctx.new_view(BUTTON)?;
set_text(ctx, &button, &caption)?;
call_int(ctx, &button, jni_str!("setMinHeight"), px(BUTTON_HEIGHT))?;
ctx.add_child(&parent, &button)?;
if row == 0 {
listener = Some(ctx.attach_listener(&button, ListenerKinds::CLICK)?);
}
buttons.push(ctx.retain_child(&button)?);
}
let [primary, secondary] = <[_; 2]>::try_from(buttons).ok()?;
let listener = listener?;
let root = ctx.retain_child(&parent)?;
let label = ctx.retain_child(&label)?;
Some((
ctx.root(&parent)?,
DemoCardState {
root,
label,
buttons: [primary, secondary],
listener,
},
))
})
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) {
if old.background_argb != new.background_argb {
call_int(
ctx,
state.root.as_object(),
jni_str!("setBackgroundColor"),
new.background_argb,
);
}
if old.title != new.title {
set_text(ctx, state.label.as_object(), &new.title);
}
if old.title_argb != new.title_argb {
call_int(
ctx,
state.label.as_object(),
jni_str!("setTextColor"),
new.title_argb,
);
}
let captions = [
(old.primary_caption(), new.primary_caption()),
(old.secondary.clone(), new.secondary.clone()),
];
for (button, (was, now)) in state.buttons.iter().zip(captions) {
if was != now {
set_text(ctx, button.as_object(), &now);
}
}
}
fn on_event(
&self,
_state: &mut Self::State,
_props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
event.is_click().then_some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
let DemoCardState {
listener, buttons, ..
} = state;
ctx.detach_listener(listener);
log::debug!(
"frust-native-widgets demo: card disposed, releasing {} retained handles",
buttons.len() + 2
);
}
}
fn set_padding(ctx: &mut ComponentCtx<'_, '_, '_>, view: &JObject<'_>) -> Option<()> {
let side = px(CARD_PADDING);
let vertical = px(ROW_GAP);
guard_jni(ctx, "setPadding", |env| {
env.call_method(
view,
jni_str!("setPadding"),
jni_sig!("(IIII)V"),
&[
JValue::Int(side),
JValue::Int(vertical),
JValue::Int(side),
JValue::Int(vertical),
],
)?
.v()
})
}
fn set_text(ctx: &mut ComponentCtx<'_, '_, '_>, view: &JObject<'_>, text: &str) -> Option<()> {
guard_jni(ctx, "setText", |env| {
let value = env.new_string(text)?;
env.call_method(
view,
jni_str!("setText"),
jni_sig!("(Ljava/lang/CharSequence;)V"),
&[JValue::Object(&value)],
)?
.v()
})
}
fn call_int(
ctx: &mut ComponentCtx<'_, '_, '_>,
view: &JObject<'_>,
name: &JNIStr,
value: i32,
) -> Option<()> {
guard_jni(ctx, "int setter", |env| {
env.call_method(view, name, jni_sig!("(I)V"), &[JValue::Int(value)])?
.v()
})
}
fn guard_jni<T>(
ctx: &mut ComponentCtx<'_, '_, '_>,
op: &str,
f: impl FnOnce(&mut jni::Env<'_>) -> Result<T, jni::errors::Error>,
) -> Option<T> {
match crate::android::run_jni(ctx.env(), op, f) {
Ok(value) => Some(value),
Err(error) => {
ctx.report_error(format!("frust-native-widgets demo: {error}"));
None
}
}
}
fn px(value: f64) -> i32 {
value as i32
}
}
#[cfg(target_os = "ios")]
mod platform {
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
use objc2_foundation::NSString;
use objc2_ui_kit::{UIButton, UIButtonType, UIControlState, UILabel, UIView};
use super::{
BUTTON_HEIGHT, CARD_PADDING, DEMO_CARD_HEIGHT, DEMO_CARD_WIDTH, DemoCard, DemoCardProps,
DemoCardState, FRAME_CAPACITY, ROW_GAP, TITLE_HEIGHT,
};
use crate::component::{ComponentCtx, ListenerKinds, NativeComponent, NativeEvent, NativeRoot};
use crate::controls::platform::{set_background_color, set_label_text_color, ui_color};
impl NativeComponent for DemoCard {
type Props = DemoCardProps;
type State = DemoCardState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
ctx.with_local_frame(FRAME_CAPACITY, |ctx| {
let mtm = ctx.mtm();
let parent = UIView::new(mtm);
parent.setFrame(CGRect::new(
CGPoint::ZERO,
CGSize::new(DEMO_CARD_WIDTH, DEMO_CARD_HEIGHT),
));
set_background_color(&parent, props.background_argb);
let label = UILabel::new(mtm);
label.setText(Some(&NSString::from_str(&props.title)));
set_label_text_color(&label, &ui_color(props.title_argb));
label.setFrame(row_frame(0));
ctx.add_child(&parent, &label)?;
let mut buttons = Vec::with_capacity(2);
for (row, caption) in [props.primary_caption(), props.secondary.clone()]
.into_iter()
.enumerate()
{
let button = UIButton::buttonWithType(UIButtonType::System, mtm);
button.setTitle_forState(
Some(&NSString::from_str(&caption)),
UIControlState::Normal,
);
button.setFrame(row_frame(row + 1));
ctx.add_child(&parent, &button)?;
buttons.push(button);
}
let [primary, secondary] = <[_; 2]>::try_from(buttons).ok()?;
let listener = ctx.attach_listener(&primary, ListenerKinds::CLICK)?;
let root = ctx.retain_child(&parent)?;
Some((
ctx.root(parent)?,
DemoCardState {
root,
label,
buttons: [primary, secondary],
listener,
},
))
})
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) {
let mtm = ctx.mtm();
if old.background_argb != new.background_argb {
set_background_color(state.root.view(mtm), new.background_argb);
}
if old.title != new.title {
state.label.setText(Some(&NSString::from_str(&new.title)));
}
if old.title_argb != new.title_argb {
set_label_text_color(&state.label, &ui_color(new.title_argb));
}
let captions = [
(old.primary_caption(), new.primary_caption()),
(old.secondary.clone(), new.secondary.clone()),
];
for (button, (was, now)) in state.buttons.iter().zip(captions) {
if was != now {
button
.setTitle_forState(Some(&NSString::from_str(&now)), UIControlState::Normal);
}
}
}
fn on_event(
&self,
_state: &mut Self::State,
_props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
event.is_click().then_some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
let DemoCardState {
listener, buttons, ..
} = state;
ctx.detach_listener(listener);
log::debug!(
"frust-native-widgets demo: card disposed, releasing {} retained handles",
buttons.len() + 2
);
}
}
fn row_frame(row: usize) -> CGRect {
let width = DEMO_CARD_WIDTH - 2.0 * CARD_PADDING;
let (y, height) = match row {
0 => (CARD_PADDING, TITLE_HEIGHT),
n => (
CARD_PADDING
+ TITLE_HEIGHT
+ (n as f64) * ROW_GAP
+ ((n - 1) as f64) * BUTTON_HEIGHT,
BUTTON_HEIGHT,
),
};
CGRect::new(CGPoint::new(CARD_PADDING, y), CGSize::new(width, height))
}
}
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::{
NSAutoresizingMaskOptions, NSBezelStyle, NSButton, NSButtonType, NSTextField, NSView,
};
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
use super::{
BUTTON_HEIGHT, CARD_PADDING, DEMO_CARD_HEIGHT, DEMO_CARD_WIDTH, DemoCard, DemoCardProps,
DemoCardState, FRAME_CAPACITY, ROW_GAP, TITLE_HEIGHT,
};
use crate::component::{ComponentCtx, ListenerKinds, NativeComponent, NativeEvent, NativeRoot};
use crate::controls::platform::{set_background_color, set_text_color};
impl NativeComponent for DemoCard {
type Props = DemoCardProps;
type State = DemoCardState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
ctx.with_local_frame(FRAME_CAPACITY, |ctx| {
let mtm = ctx.mtm();
let parent = NSView::new(mtm);
parent.setFrame(NSRect::new(
NSPoint::new(0.0, 0.0),
NSSize::new(DEMO_CARD_WIDTH, DEMO_CARD_HEIGHT),
));
parent.setWantsLayer(true);
set_background_color(&parent, props.background_argb);
let label = NSTextField::labelWithString(&NSString::from_str(&props.title), mtm);
set_text_color(&label, props.title_argb);
place(&label, 0);
ctx.add_child(&parent, &label)?;
let mut buttons = Vec::with_capacity(2);
for (row, caption) in [props.primary_caption(), props.secondary.clone()]
.into_iter()
.enumerate()
{
let button = NSButton::new(mtm);
button.setButtonType(NSButtonType::MomentaryPushIn);
button.setBezelStyle(NSBezelStyle::Push);
button.setTitle(&NSString::from_str(&caption));
place(&button, row + 1);
ctx.add_child(&parent, &button)?;
buttons.push(button);
}
let [primary, secondary] = <[_; 2]>::try_from(buttons).ok()?;
let listener = ctx.attach_listener(&primary, ListenerKinds::CLICK)?;
let root = ctx.retain_child(&parent)?;
Some((
ctx.root(parent)?,
DemoCardState {
root,
label,
buttons: [primary, secondary],
listener,
},
))
})
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) {
let mtm = ctx.mtm();
if old.background_argb != new.background_argb {
set_background_color(state.root.view(mtm), new.background_argb);
}
if old.title != new.title {
state.label.setStringValue(&NSString::from_str(&new.title));
}
if old.title_argb != new.title_argb {
set_text_color(&state.label, new.title_argb);
}
let captions = [
(old.primary_caption(), new.primary_caption()),
(old.secondary.clone(), new.secondary.clone()),
];
for (button, (was, now)) in state.buttons.iter().zip(captions) {
if was != now {
button.setTitle(&NSString::from_str(&now));
}
}
}
fn on_event(
&self,
_state: &mut Self::State,
_props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
event.is_click().then_some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
let DemoCardState {
listener, buttons, ..
} = state;
ctx.detach_listener(listener);
log::debug!(
"frust-native-widgets demo: card disposed, releasing {} retained handles",
buttons.len() + 2
);
}
}
fn place(view: &NSView, row: usize) {
view.setFrame(row_frame(row));
view.setAutoresizingMask(NSAutoresizingMaskOptions::ViewMinYMargin);
}
fn row_frame(row: usize) -> NSRect {
let width = DEMO_CARD_WIDTH - 2.0 * CARD_PADDING;
let (top, height) = match row {
0 => (CARD_PADDING, TITLE_HEIGHT),
n => (
CARD_PADDING
+ TITLE_HEIGHT
+ (n as f64) * ROW_GAP
+ ((n - 1) as f64) * BUTTON_HEIGHT,
BUTTON_HEIGHT,
),
};
let y = DEMO_CARD_HEIGHT - top - height;
NSRect::new(NSPoint::new(CARD_PADDING, y), NSSize::new(width, height))
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
mod platform {
use super::{DemoCard, DemoCardProps, DemoCardState, FRAME_CAPACITY};
use crate::component::{ComponentCtx, ListenerKinds, NativeComponent, NativeEvent, NativeRoot};
impl NativeComponent for DemoCard {
type Props = DemoCardProps;
type State = DemoCardState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
ctx.with_local_frame(FRAME_CAPACITY, |ctx| {
let parent = next_identity();
ctx.record(format!("card parent {parent} bg {}", props.background_argb));
let label = next_identity();
ctx.record(format!(
"card title {label} '{}' ink {}",
props.title, props.title_argb
));
ctx.add_child(parent, label)?;
let mut buttons = Vec::with_capacity(2);
for caption in [props.primary_caption(), props.secondary.clone()] {
let button = next_identity();
ctx.record(format!("card button {button} '{caption}'"));
ctx.add_child(parent, button)?;
buttons.push(ctx.retain_child(button)?);
}
let [primary, secondary] = <[_; 2]>::try_from(buttons).ok()?;
let listener = ctx.attach_listener(primary.identity(), ListenerKinds::CLICK)?;
let root = ctx.retain_child(parent)?;
let label = ctx.retain_child(label)?;
Some((
ctx.root(parent)?,
DemoCardState {
root,
label,
buttons: [primary, secondary],
listener,
},
))
})
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) {
if old.background_argb != new.background_argb {
ctx.record(format!(
"card bg {} -> {}",
state.root.identity(),
new.background_argb
));
}
if old.title != new.title {
ctx.record(format!(
"card title {} -> '{}'",
state.label.identity(),
new.title
));
}
if old.title_argb != new.title_argb {
ctx.record(format!(
"card ink {} -> {}",
state.label.identity(),
new.title_argb
));
}
let captions = [
(old.primary_caption(), new.primary_caption()),
(old.secondary.clone(), new.secondary.clone()),
];
for (button, (was, now)) in state.buttons.iter().zip(captions) {
if was != now {
ctx.record(format!("card caption {} -> '{now}'", button.identity()));
}
}
}
fn on_event(
&self,
_state: &mut Self::State,
_props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
event.is_click().then_some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
let DemoCardState {
root,
buttons,
listener,
..
} = state;
ctx.detach_listener(listener);
ctx.record(format!(
"card dispose {} with {} retained handles",
root.identity(),
buttons.len() + 2
));
}
}
fn next_identity() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
}
#[cfg(all(
test,
not(any(target_os = "android", target_os = "ios", target_os = "macos"))
))]
mod tests {
use std::rc::Rc;
use super::*;
use crate::component::{
component_params, forget, live_child_count, live_listener_count, publish, staged_count,
};
use crate::registry::SlotId;
use crate::runtime::{DisposeOutcome, NativeCtx as PlatformCtx, UpdateOutcome, with_runtime};
const HANDLES: usize = DEMO_CARD_CHILDREN + 1;
fn props(title: &str, primary: &str) -> DemoCardProps {
DemoCardProps {
title: title.to_string(),
primary: primary.to_string(),
secondary: "Dismiss".to_string(),
background_argb: 0x1122_3344,
title_argb: -1,
presses: 0,
}
}
fn mount(slot: SlotId, props: DemoCardProps) -> String {
let generation = publish(slot, Rc::new(DemoCard), props);
component_params(DEMO_CARD_KIND, slot, generation, false)
}
#[test]
fn the_demo_composite_ships_one_slot_and_releases_every_child() {
assert_eq!(live_child_count(), 0, "this test thread starts clean");
assert!(register_demo_components());
let params = mount(70, props("Now playing", "Play"));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
assert_eq!(runtime.create(&mut ctx, ¶ms).unwrap(), 70);
assert_eq!(
runtime.live_count(),
1,
"a parent and {DEMO_CARD_CHILDREN} children ship as ONE frust slot — \
that is the whole point of the composite"
);
assert_eq!(
live_child_count(),
HANDLES,
"every handle is retained while the card is live"
);
let changed = mount(70, props("Up next", "Play"));
assert_eq!(
runtime.update_params(&mut ctx, &changed).unwrap(),
UpdateOutcome::Applied
);
let same = mount(70, props("Up next", "Play"));
assert_eq!(
runtime.update_params(&mut ctx, &same).unwrap(),
UpdateOutcome::Unchanged
);
assert_eq!(runtime.dispose_slot(&mut ctx, 70), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
forget(70);
assert_eq!(
live_child_count(),
0,
"every retained handle was released with the card"
);
assert_eq!(
live_listener_count(),
0,
"the primary's click listener was released with the card"
);
assert_eq!(staged_count(), 0);
assert_eq!(calls[0], format!("pushLocalFrame {FRAME_CAPACITY}"));
let parent = calls[1]
.split(' ')
.nth(2)
.expect("the parent identity")
.to_string();
let attached: Vec<&String> = calls
.iter()
.filter(|call| call.starts_with("addChild "))
.collect();
assert_eq!(
attached.len(),
DEMO_CARD_CHILDREN,
"one addChild per child: {calls:?}"
);
assert!(
attached
.iter()
.all(|call| call.starts_with(&format!("addChild {parent} <- "))),
"every child went under the card's own parent: {attached:?}"
);
assert_eq!(
calls
.iter()
.filter(|call| call.starts_with("retainChild "))
.count(),
HANDLES
);
let last_add = calls
.iter()
.rposition(|call| call.starts_with("addChild "))
.expect("an addChild");
let popped = calls
.iter()
.position(|call| call == "popLocalFrame")
.expect("the frame pops");
assert!(
last_add < popped,
"the whole subtree build ran inside ONE local frame: {calls:?}"
);
assert!(
calls.iter().any(|call| call.contains("-> 'Up next'")),
"the changed title reached the retained label: {calls:?}"
);
assert!(
calls.last().unwrap().starts_with("card dispose"),
"{calls:?}"
);
}
#[test]
fn back_to_back_card_cycles_strand_nothing() {
assert_eq!(live_child_count(), 0);
register_demo_components();
for slot in [71, 72] {
let params = mount(slot, props("Cycle", "Go"));
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
assert_eq!(runtime.live_count(), 1, "one slot per card, every cycle");
assert_eq!(live_child_count(), HANDLES);
assert_eq!(
runtime.dispose_slot(&mut ctx, slot),
DisposeOutcome::Disposed
);
})
.expect("the thread's runtime");
forget(slot);
assert_eq!(live_child_count(), 0, "cycle {slot} released everything");
assert_eq!(
live_listener_count(),
0,
"cycle {slot} released its listener"
);
}
}
#[test]
fn a_primary_click_reaches_the_hook_and_the_count_moves_the_caption() {
use std::sync::{Arc, Mutex};
use crate::events::EventPayload;
use crate::runtime::NativeEvent as WireEvent;
register_demo_components();
let heard: Arc<Mutex<Vec<EventPayload>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&heard);
let params = mount(73, props("Tap", "Play"));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.set_callback(
73,
Arc::new(move |payload| recorder.lock().unwrap().push(payload)),
);
runtime.create(&mut ctx, ¶ms).unwrap();
assert_eq!(live_listener_count(), 1, "the primary listens");
runtime.on_event(73, WireEvent { kind: 1, detail: 0 });
let counted = mount(
73,
DemoCardProps {
presses: 1,
..props("Tap", "Play")
},
);
assert_eq!(
runtime.update_params(&mut ctx, &counted).unwrap(),
UpdateOutcome::Applied
);
assert_eq!(runtime.dispose_slot(&mut ctx, 73), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(73);
assert_eq!(*heard.lock().unwrap(), vec![EventPayload::Click]);
let primary = calls
.iter()
.find(|call| call.starts_with("card button ") && call.ends_with("'Play (0)'"))
.and_then(|call| call.split(' ').nth(2))
.expect("the primary button, captioned with its count")
.to_string();
assert!(
calls.contains(&format!("attachListener {primary} click")),
"the click listener went on the PRIMARY button: {calls:?}"
);
assert!(
calls.contains(&format!("card caption {primary} -> 'Play (1)'")),
"the new count reached the primary's caption: {calls:?}"
);
assert!(
calls.contains(&format!("detachListener {primary} click")),
"dispose detached it: {calls:?}"
);
assert_eq!(live_listener_count(), 0);
}
}
#[cfg(test)]
mod layout_tests {
use super::*;
#[test]
fn the_card_declares_a_slot_size_its_own_rows_fit_inside() {
let stacked = CARD_PADDING
+ TITLE_HEIGHT
+ ROW_GAP
+ BUTTON_HEIGHT
+ ROW_GAP
+ BUTTON_HEIGHT
+ CARD_PADDING;
assert!(
stacked <= DEMO_CARD_HEIGHT,
"the card's own rows ({stacked}) must fit inside DEMO_CARD_HEIGHT"
);
let content_width = DEMO_CARD_WIDTH - 2.0 * CARD_PADDING;
assert!(
content_width > 0.0,
"the padded content width ({content_width}) must be positive"
);
}
}