use super::{
BACKGROUND_COLOR, CONTENT_DESCRIPTION, CORNER_RADIUS_DP, ENABLED, Plan, Setter, TEXT,
TEXT_COLOR, TEXT_SIZE_SP, TYPEFACE, color, owned_text, plan_color, slot_of, text_or_empty,
};
use crate::NativeWidgetError;
use crate::controls::typeface::{self, Typeface};
use crate::registry::SlotId;
use crate::runtime::Params;
pub(crate) const KIND: &str = "button";
pub(crate) struct Button;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ButtonProps {
pub(crate) slot: SlotId,
pub(crate) text: String,
pub(crate) enabled: bool,
pub(crate) text_color: Option<i32>,
pub(crate) background_color: Option<i32>,
pub(crate) text_size_sp: Option<f32>,
pub(crate) content_description: Option<String>,
pub(crate) corner_radius_dp: Option<f32>,
pub(crate) typeface: Typeface,
}
impl ButtonProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
text: String::new(),
enabled: true,
text_color: None,
background_color: None,
text_size_sp: None,
content_description: None,
corner_radius_dp: None,
typeface: Typeface::System,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
Ok(Self {
slot: slot_of(params)?,
text: text_or_empty(params, TEXT),
enabled: params.flag(ENABLED).unwrap_or(true),
text_color: color(params, TEXT_COLOR),
background_color: color(params, BACKGROUND_COLOR),
text_size_sp: params.float(TEXT_SIZE_SP).map(|size| size as f32),
content_description: owned_text(params, CONTENT_DESCRIPTION),
corner_radius_dp: params.float(CORNER_RADIUS_DP).map(|radius| radius as f32),
typeface: typeface::decode(params, TYPEFACE),
})
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self) -> Plan<'a> {
let mut plan = Plan::new();
if old.text != new.text {
plan.push(Setter::Text(&new.text));
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
plan_color(&mut plan, old.text_color, new.text_color, Setter::TextColor);
plan_themed_background(&mut plan, old, new);
if old.text_size_sp != new.text_size_sp
&& let Some(size) = new.text_size_sp
{
plan.push(Setter::TextSizeSp(size));
}
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
}
}
fn plan_themed_background<'a>(plan: &mut Plan<'a>, old: &ButtonProps, new: &'a ButtonProps) {
if old.background_color == new.background_color && old.corner_radius_dp == new.corner_radius_dp
{
return;
}
match (new.background_color, new.corner_radius_dp) {
(Some(fill), Some(radius_dp)) => plan.push(Setter::ThemedBackground { fill, radius_dp }),
(Some(fill), None) => plan.push(Setter::BackgroundColor(fill)),
(None, _) => {}
}
}
#[cfg(target_os = "android")]
pub(crate) mod platform {
use jni::objects::JObject;
use jni::refs::Global;
use super::{Button, ButtonProps};
use crate::NativeWidgetError;
use crate::android::{NativeCtx, NativeView};
use crate::controls::platform::{FRAME_CAPACITY, apply_all};
use crate::events::{EventPayload, decode_click};
use crate::runtime::{NativeEvent, NativeWidget, Params};
const CLASS: &str = "android.widget.Button";
pub(crate) struct ButtonState {
view: Global<JObject<'static>>,
}
impl NativeWidget for Button {
type Props = ButtonProps;
type State = ButtonState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
ButtonProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let view = ctx.new_view(CLASS)?;
let plan = ButtonProps::plan(&ButtonProps::platform_default(props.slot), props);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &view, &plan))?;
let listener = ctx.new_listener(props.slot)?;
ctx.set_on_click_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]),
ButtonState { view: retained },
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = ButtonProps::plan(old, new);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &state.view, &plan))
}
fn on_event(_state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_click(event)
}
fn dispose(
ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
ctx.set_on_click_listener(&state.view, &JObject::null())
}
}
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_foundation::NSString;
use objc2_ui_kit::{UIButton, UIButtonType, UIControlState};
use super::{Button, ButtonProps, KIND};
use crate::NativeWidgetError;
use crate::apple::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::{EventPayload, decode_click};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct ButtonState {
view: Retained<UIButton>,
target: Retained<FrustNativeControlTarget>,
font: platform::FontState,
}
impl NativeWidget for Button {
type Props = ButtonProps;
type State = ButtonState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
ButtonProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UIButton::buttonWithType(UIButtonType::System, mtm);
let mut font = platform::FontState::platform_default();
let plan = ButtonProps::plan(&ButtonProps::platform_default(props.slot), props);
apply_all(mtm, &view, &mut font, &plan);
let target = FrustNativeControlTarget::attach_button(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((handle, ButtonState { view, target, font }))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
apply_all(
ctx.mtm(),
&state.view,
&mut state.font,
&ButtonProps::plan(old, new),
);
Ok(())
}
fn on_event(_state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_click(event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach_button(&state.view);
Ok(())
}
}
fn apply_all(
mtm: MainThreadMarker,
view: &UIButton,
font: &mut platform::FontState,
plan: &Plan<'_>,
) {
for setter in plan {
apply(mtm, view, font, setter);
}
}
fn apply(
mtm: MainThreadMarker,
view: &UIButton,
font: &mut platform::FontState,
setter: &Setter<'_>,
) {
match *setter {
Setter::Text(text) => {
view.setTitle_forState(Some(&NSString::from_str(text)), UIControlState::Normal);
}
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::TextColor(argb) => {
view.setTitleColor_forState(
Some(&platform::ui_color(argb)),
UIControlState::Normal,
);
}
Setter::BackgroundColor(argb) => platform::set_background_color(view, argb),
Setter::TextSizeSp(sp) => {
let resolved = font.apply_size(sp);
if let Some(label) = view.titleLabel() {
platform::set_label_font(&label, resolved.as_ui_font());
}
}
Setter::ThemedBackground { fill, radius_dp } => {
platform::set_background_color(view, fill);
platform::set_corner_radius(view, radius_dp);
}
Setter::Typeface(face) => {
let resolved = font.apply_typeface(face);
if let Some(label) = view.titleLabel() {
platform::set_label_font(&label, resolved.as_ui_font());
}
}
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::{NSBezelStyle, NSButton, NSButtonType};
use objc2_foundation::NSString;
use super::{Button, ButtonProps, KIND};
use crate::NativeWidgetError;
use crate::appkit::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::{EVENT_KIND_CLICK, EventPayload, decode_click};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct ButtonState {
view: Retained<NSButton>,
target: Retained<FrustNativeControlTarget>,
}
impl NativeWidget for Button {
type Props = ButtonProps;
type State = ButtonState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
ButtonProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSButton::new(mtm);
view.setButtonType(NSButtonType::MomentaryPushIn);
view.setBezelStyle(NSBezelStyle::Push);
view.setTitle(&NSString::from_str(""));
let plan = ButtonProps::plan(&ButtonProps::platform_default(props.slot), props);
apply_all(&view, &plan);
let target = FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_CLICK);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((handle, ButtonState { view, target }))
}
fn update(
_ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
apply_all(&state.view, &ButtonProps::plan(old, new));
Ok(())
}
fn on_event(_state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_click(event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach(&state.view);
Ok(())
}
}
fn apply_all(view: &NSButton, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSButton, setter: &Setter<'_>) {
match *setter {
Setter::Text(text) => view.setTitle(&NSString::from_str(text)),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::TextColor(argb) => platform::set_text_color(view, argb),
Setter::BackgroundColor(argb) => platform::set_background_color(view, argb),
Setter::TextSizeSp(sp) => platform::set_text_size(view, sp),
Setter::ThemedBackground { fill, radius_dp } => {
platform::set_background_color(view, fill);
platform::set_corner_radius(view, radius_dp);
}
Setter::Typeface(face) => platform::set_typeface(view, face),
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 params(body: &str) -> String {
with_identity(KIND, 7, body)
}
fn decode(body: &str) -> ButtonProps {
ButtonProps::decode(&Params::new(¶ms(body))).expect("decodes")
}
#[test]
fn absent_fields_decode_to_the_platform_defaults() {
let props = decode("");
assert_eq!(props, ButtonProps::platform_default(7));
assert!(ButtonProps::plan(&ButtonProps::platform_default(7), &props).is_empty());
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields() {
let props = decode(
"\"text\":\"Save\",\"enabled\":false,\"textColor\":-1,\"backgroundColor\":255,\
\"textSizeSp\":18.5,\"contentDescription\":\"Save the note\"",
);
assert_eq!(
ButtonProps::plan(&ButtonProps::platform_default(7), &props),
vec![
Setter::Text("Save"),
Setter::Enabled(false),
Setter::TextColor(-1),
Setter::BackgroundColor(255),
Setter::TextSizeSp(18.5),
Setter::ContentDescription(Some("Save the note")),
]
);
}
#[test]
fn an_unchanged_field_never_reaches_the_platform() {
let old = decode("\"text\":\"Save\",\"textColor\":16711680");
let new = decode("\"text\":\"Saved\",\"textColor\":16711680");
assert_eq!(ButtonProps::plan(&old, &new), vec![Setter::Text("Saved")]);
assert_eq!(ButtonProps::plan(&new, &new), vec![]);
}
#[test]
fn clearing_a_color_plans_nothing_but_clearing_a_label_does() {
let with_color = decode("\"textColor\":123,\"contentDescription\":\"a\"");
let without = decode("");
assert_eq!(
ButtonProps::plan(&with_color, &without),
vec![Setter::ContentDescription(None)]
);
assert_eq!(
ButtonProps::plan(&without, &with_color),
vec![
Setter::TextColor(123),
Setter::ContentDescription(Some("a")),
]
);
}
#[test]
fn params_without_identity_are_a_params_error() {
assert!(matches!(
ButtonProps::decode(&Params::new("{\"text\":\"x\"}")),
Err(NativeWidgetError::Params(_))
));
}
#[test]
fn a_fill_with_a_radius_plans_the_combined_themed_background() {
let props = decode("\"backgroundColor\":255,\"cornerRadiusDp\":6.0");
assert_eq!(props.corner_radius_dp, Some(6.0));
assert_eq!(
ButtonProps::plan(&ButtonProps::platform_default(7), &props),
vec![Setter::ThemedBackground {
fill: 255,
radius_dp: 6.0,
}]
);
}
#[test]
fn a_fill_with_no_radius_keeps_the_pre_p1_07_flat_setter() {
let props = decode("\"backgroundColor\":255");
assert_eq!(props.corner_radius_dp, None);
assert_eq!(
ButtonProps::plan(&ButtonProps::platform_default(7), &props),
vec![Setter::BackgroundColor(255)]
);
}
#[test]
fn a_radius_change_alone_replans_the_themed_background() {
let old = decode("\"backgroundColor\":255,\"cornerRadiusDp\":6.0");
let new = decode("\"backgroundColor\":255,\"cornerRadiusDp\":10.0");
assert_eq!(
ButtonProps::plan(&old, &new),
vec![Setter::ThemedBackground {
fill: 255,
radius_dp: 10.0,
}]
);
assert_eq!(ButtonProps::plan(&new, &new), vec![]);
}
#[test]
fn clearing_a_radius_back_to_a_flat_fill_falls_back_to_background_color() {
let themed = decode("\"backgroundColor\":255,\"cornerRadiusDp\":6.0");
let flat = decode("\"backgroundColor\":255");
assert_eq!(
ButtonProps::plan(&themed, &flat),
vec![Setter::BackgroundColor(255)]
);
}
#[test]
fn typeface_defaults_to_system_when_absent() {
let props = decode("");
assert_eq!(props.typeface, Typeface::System);
assert_eq!(props, ButtonProps::platform_default(7));
}
#[test]
fn a_typeface_change_alone_plans_exactly_one_setter() {
let old = decode("\"typeface\":\"glyphPlex\"");
let new = decode("\"typeface\":\"glyphMono\"");
assert_eq!(
ButtonProps::plan(&old, &new),
vec![Setter::Typeface(Typeface::GlyphMono)]
);
}
#[test]
fn an_unchanged_typeface_plans_nothing_the_zero_ffi_property() {
let props = decode("\"typeface\":\"glyphMono\"");
assert_eq!(
ButtonProps::plan(&props, &props),
vec![],
"identical typeface Props must plan no setter — the whole-struct \
PartialEq gate this crate's `Props: PartialEq` contract relies on"
);
}
}