use super::{
CHECKED, CONTENT_DESCRIPTION, ENABLED, Plan, Setter, THUMB_TINT, TRACK_TINT, TYPEFACE, color,
owned_text, slot_of,
};
use crate::NativeWidgetError;
use crate::controls::typeface::{self, Typeface};
use crate::events::{EVENT_KIND_TOGGLED, EventPayload, unpack_bool};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "switch";
pub(crate) struct Switch;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SwitchProps {
pub(crate) slot: SlotId,
pub(crate) checked: bool,
pub(crate) enabled: bool,
pub(crate) thumb_tint: Option<i32>,
pub(crate) track_tint: Option<i32>,
pub(crate) content_description: Option<String>,
pub(crate) typeface: Typeface,
}
impl SwitchProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
checked: false,
enabled: true,
thumb_tint: None,
track_tint: None,
content_description: None,
typeface: Typeface::System,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
Ok(Self {
slot: slot_of(params)?,
checked: params.flag(CHECKED).unwrap_or(false),
enabled: params.flag(ENABLED).unwrap_or(true),
thumb_tint: color(params, THUMB_TINT),
track_tint: color(params, TRACK_TINT),
content_description: owned_text(params, CONTENT_DESCRIPTION),
typeface: typeface::decode(params, TYPEFACE),
})
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self, observed: Option<bool>) -> Plan<'a> {
let mut plan = Plan::new();
let drifted = observed.is_some_and(|platform| platform != new.checked);
if old.checked != new.checked || drifted {
plan.push(Setter::Checked(new.checked));
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
if old.thumb_tint != new.thumb_tint {
plan.push(Setter::ThumbTint(new.thumb_tint));
}
if old.track_tint != new.track_tint {
plan.push(Setter::TrackTint(new.track_tint));
}
if old.typeface != new.typeface {
plan.push(Setter::Typeface(new.typeface));
}
if old.content_description != new.content_description {
plan.push(Setter::ContentDescription(
new.content_description.as_deref(),
));
}
plan
}
}
pub(crate) fn decode_toggled(
observed: &mut Option<bool>,
event: NativeEvent,
) -> Option<EventPayload> {
if event.kind != EVENT_KIND_TOGGLED {
return None;
}
let checked = unpack_bool(event.detail);
*observed = Some(checked);
Some(EventPayload::Toggled(checked))
}
#[cfg(target_os = "android")]
pub(crate) mod platform {
use jni::objects::JObject;
use jni::refs::Global;
use super::{EventPayload, Switch, SwitchProps, decode_toggled};
use crate::NativeWidgetError;
use crate::android::{NativeCtx, NativeView};
use crate::controls::platform::{FRAME_CAPACITY, apply_all};
use crate::runtime::{NativeEvent, NativeWidget, Params};
const CLASS: &str = "android.widget.Switch";
pub(crate) struct SwitchState {
view: Global<JObject<'static>>,
pub(crate) observed: Option<bool>,
}
impl NativeWidget for Switch {
type Props = SwitchProps;
type State = SwitchState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SwitchProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let view = ctx.new_view(CLASS)?;
let plan = SwitchProps::plan(&SwitchProps::platform_default(props.slot), props, None);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &view, &plan))?;
let listener = ctx.new_listener(props.slot)?;
ctx.set_on_checked_change_listener(&view, &listener)?;
let handle = ctx.retain(&view)?;
let retained = ctx.retain(&view)?;
let listener_ref = ctx.retain(&listener)?;
Ok((
NativeView::with_extra(handle, vec![listener_ref]),
SwitchState {
view: retained,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SwitchProps::plan(old, new, state.observed);
let applied = ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &state.view, &plan));
if applied.is_ok() {
state.observed = None;
}
applied
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_toggled(&mut state.observed, event)
}
fn dispose(
ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
ctx.set_on_checked_change_listener(&state.view, &JObject::null())
}
}
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_ui_kit::UISwitch;
use super::{KIND, Switch, SwitchProps, decode_toggled};
use crate::NativeWidgetError;
use crate::apple::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::EventPayload;
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct SwitchState {
view: Retained<UISwitch>,
target: Retained<FrustNativeControlTarget>,
observed: Option<bool>,
}
impl NativeWidget for Switch {
type Props = SwitchProps;
type State = SwitchState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SwitchProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UISwitch::new(mtm);
let plan = SwitchProps::plan(&SwitchProps::platform_default(props.slot), props, None);
apply_all(mtm, &view, &plan);
let target = FrustNativeControlTarget::attach_switch(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
SwitchState {
view,
target,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SwitchProps::plan(old, new, state.observed);
apply_all(ctx.mtm(), &state.view, &plan);
state.observed = None;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_toggled(&mut state.observed, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach_switch(&state.view);
Ok(())
}
}
fn apply_all(mtm: MainThreadMarker, view: &UISwitch, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UISwitch, setter: &Setter<'_>) {
match *setter {
Setter::Checked(checked) => view.setOn_animated(checked, false),
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::ThumbTint(argb) => {
view.setThumbTintColor(platform::optional_ui_color(argb).as_deref());
}
Setter::TrackTint(argb) => {
view.setOnTintColor(platform::optional_ui_color(argb).as_deref());
}
Setter::Typeface(_) => {}
Setter::ContentDescription(label) => {
platform::set_accessibility_label(view, label, mtm);
}
ref other => platform::warn_unexpected_setter(KIND, other),
}
}
}
#[cfg(target_os = "macos")]
pub(crate) mod platform {
use objc2::rc::Retained;
use objc2_app_kit::{NSControlStateValueOff, NSControlStateValueOn, NSSwitch};
use super::{KIND, Switch, SwitchProps, decode_toggled};
use crate::NativeWidgetError;
use crate::appkit::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::{EVENT_KIND_TOGGLED, EventPayload};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct SwitchState {
view: Retained<NSSwitch>,
target: Retained<FrustNativeControlTarget>,
observed: Option<bool>,
}
impl NativeWidget for Switch {
type Props = SwitchProps;
type State = SwitchState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SwitchProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSSwitch::new(mtm);
let plan = SwitchProps::plan(&SwitchProps::platform_default(props.slot), props, None);
apply_all(&view, &plan);
let target =
FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_TOGGLED);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
SwitchState {
view,
target,
observed: None,
},
))
}
fn update(
_ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SwitchProps::plan(old, new, state.observed);
apply_all(&state.view, &plan);
state.observed = None;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_toggled(&mut state.observed, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach(&state.view);
Ok(())
}
}
fn apply_all(view: &NSSwitch, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSSwitch, setter: &Setter<'_>) {
match *setter {
Setter::Checked(checked) => view.setState(if checked {
NSControlStateValueOn
} else {
NSControlStateValueOff
}),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::ThumbTint(argb) => platform::set_tint(view, "ThumbTint", argb),
Setter::TrackTint(argb) => platform::set_tint(view, "TrackTint", argb),
Setter::Typeface(_) => {}
Setter::ContentDescription(label) => platform::set_accessibility_label(view, label),
ref other => platform::warn_unexpected_setter(KIND, other),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::with_identity;
fn decode(body: &str) -> SwitchProps {
let raw = with_identity(KIND, 11, body);
SwitchProps::decode(&Params::new(&raw)).expect("decodes")
}
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, SwitchProps::platform_default(11));
assert!(SwitchProps::plan(&SwitchProps::platform_default(11), &props, None).is_empty());
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields() {
let props = decode(
"\"checked\":true,\"enabled\":false,\"thumbTint\":16,\"trackTint\":32,\
\"contentDescription\":\"wifi\"",
);
assert_eq!(
SwitchProps::plan(&SwitchProps::platform_default(11), &props, None),
vec![
Setter::Checked(true),
Setter::Enabled(false),
Setter::ThumbTint(Some(16)),
Setter::TrackTint(Some(32)),
Setter::ContentDescription(Some("wifi")),
]
);
}
#[test]
fn a_confirmed_toggle_writes_the_app_value_back_exactly_once() {
let off = decode("\"checked\":false");
let on = decode("\"checked\":true");
assert_eq!(
SwitchProps::plan(&off, &on, Some(true)),
vec![Setter::Checked(true)],
"the app confirmed what the platform reported: one idempotent set"
);
}
#[test]
fn a_platform_drift_is_written_back_even_when_the_props_did_not_change_it() {
let old = decode("\"checked\":false");
let new = decode("\"checked\":false,\"enabled\":false");
assert_eq!(
SwitchProps::plan(&old, &new, Some(true)),
vec![Setter::Checked(false), Setter::Enabled(false)]
);
assert_eq!(
SwitchProps::plan(&old, &new, None),
vec![Setter::Enabled(false)]
);
assert_eq!(
SwitchProps::plan(&old, &new, Some(false)),
vec![Setter::Enabled(false)]
);
}
#[test]
fn clearing_a_tint_plans_the_nullable_setter() {
let tinted = decode("\"thumbTint\":7,\"trackTint\":8");
let plain = decode("");
assert_eq!(
SwitchProps::plan(&tinted, &plain, None),
vec![Setter::ThumbTint(None), Setter::TrackTint(None)],
"unlike a text colour, a tint list is clearable (setter takes null)"
);
}
#[test]
fn typeface_defaults_to_system_when_absent() {
let props = decode("");
assert_eq!(props.typeface, Typeface::System);
assert_eq!(props, SwitchProps::platform_default(11));
}
#[test]
fn a_typeface_change_alone_plans_exactly_one_setter() {
let old = decode("\"typeface\":\"system\"");
let new = decode("\"typeface\":\"glyphPlex\"");
assert_eq!(
SwitchProps::plan(&old, &new, None),
vec![Setter::Typeface(Typeface::GlyphPlex)]
);
assert_eq!(
SwitchProps::plan(&new, &new, None),
vec![],
"an unchanged typeface plans nothing — the zero-FFI property"
);
}
fn toggled_event(checked: bool) -> NativeEvent {
NativeEvent {
kind: EVENT_KIND_TOGGLED,
detail: crate::events::pack_bool(checked),
}
}
#[test]
fn a_user_toggle_decodes_and_updates_the_drift_signal() {
let mut observed = None;
let payload = decode_toggled(&mut observed, toggled_event(true));
assert_eq!(payload, Some(EventPayload::Toggled(true)));
assert_eq!(observed, Some(true));
}
#[test]
fn a_misrouted_kind_decodes_to_nothing_and_leaves_observed_untouched() {
let mut observed = Some(false);
let click = NativeEvent {
kind: crate::events::EVENT_KIND_CLICK,
detail: 0,
};
assert_eq!(decode_toggled(&mut observed, click), None);
assert_eq!(observed, Some(false));
}
}