use std::sync::atomic::{AtomicBool, Ordering};
use super::{
CONTENT_DESCRIPTION, ENABLED, MAX, MIN, Plan, STEP, Setter, TINT, VALUE, WRAPS, color,
owned_text, slot_of,
};
use crate::NativeWidgetError;
use crate::events::{EVENT_KIND_VALUE_CHANGED, EventPayload, unpack_value_changed};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "stepper";
pub(crate) const PLATFORM_DEFAULT_MAX: i32 = 100;
pub(crate) const PLATFORM_DEFAULT_STEP: i32 = 1;
static STEP_WARNED: AtomicBool = AtomicBool::new(false);
pub(crate) struct Stepper;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct StepperProps {
pub(crate) slot: SlotId,
pub(crate) value: i32,
pub(crate) min: i32,
pub(crate) max: i32,
pub(crate) step: i32,
pub(crate) wraps: bool,
pub(crate) enabled: bool,
pub(crate) tint: Option<i32>,
pub(crate) content_description: Option<String>,
}
impl StepperProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
value: 0,
min: 0,
max: PLATFORM_DEFAULT_MAX,
step: PLATFORM_DEFAULT_STEP,
wraps: false,
enabled: true,
tint: None,
content_description: None,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
let slot = slot_of(params)?;
let step = match int_or(params, STEP, PLATFORM_DEFAULT_STEP) {
step if step >= 1 => step,
non_positive => {
if !STEP_WARNED.swap(true, Ordering::Relaxed) {
log::warn!(
"frust-native-widgets: stepper slot {slot} decoded a non-positive step \
({non_positive}) — UIStepper/NSStepper require step > 0; using 1 \
instead (this warning is logged once per process)"
);
}
1
}
};
Ok(Self {
slot,
value: int_or(params, VALUE, 0),
min: int_or(params, MIN, 0),
max: int_or(params, MAX, PLATFORM_DEFAULT_MAX),
step,
wraps: params.flag(WRAPS).unwrap_or(false),
enabled: params.flag(ENABLED).unwrap_or(true),
tint: color(params, TINT),
content_description: owned_text(params, CONTENT_DESCRIPTION),
})
}
pub(crate) fn span(&self) -> i32 {
self.max.saturating_sub(self.min).max(1)
}
pub(crate) fn effective_enabled(&self) -> bool {
self.enabled && self.max > self.min
}
pub(crate) fn platform_value(&self) -> i32 {
self.value
.clamp(self.min, self.max.max(self.min))
.saturating_sub(self.min)
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self, observed: Option<i32>) -> Plan<'a> {
let mut plan = Plan::new();
let old_enabled = old.effective_enabled();
let new_enabled = new.effective_enabled();
let reenabling = old.max <= old.min && new.max > new.min;
if old.span() != new.span() || reenabling {
plan.push(Setter::Max(new.span()));
}
let drifted = observed.is_some_and(|platform| platform != new.platform_value());
if old.platform_value() != new.platform_value() || drifted || reenabling {
plan.push(Setter::Progress(new.platform_value()));
}
if old.step != new.step || reenabling {
plan.push(Setter::Step(new.step));
}
if old.wraps != new.wraps {
plan.push(Setter::Wraps(new.wraps));
}
if old_enabled != new_enabled {
plan.push(Setter::Enabled(new_enabled));
}
if old.tint != new.tint {
plan.push(Setter::StepperTint(new.tint));
}
if old.content_description != new.content_description {
plan.push(Setter::ContentDescription(
new.content_description.as_deref(),
));
}
plan
}
}
fn int_or(params: &Params<'_>, key: &str, fallback: i32) -> i32 {
params
.int(key)
.and_then(|raw| i32::try_from(raw).ok())
.unwrap_or(fallback)
}
pub(crate) fn decode_event(
observed: &mut Option<i32>,
min: i32,
event: NativeEvent,
) -> Option<EventPayload> {
match event.kind {
EVENT_KIND_VALUE_CHANGED => {
let (platform_value, from_user) = unpack_value_changed(event.detail);
*observed = Some(platform_value);
Some(EventPayload::ValueChanged {
value: platform_value + min,
from_user,
})
}
_ => None,
}
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_ui_kit::UIStepper;
use super::{KIND, Stepper, StepperProps, decode_event};
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 StepperState {
view: Retained<UIStepper>,
target: Retained<FrustNativeControlTarget>,
min: i32,
observed: Option<i32>,
}
impl NativeWidget for Stepper {
type Props = StepperProps;
type State = StepperState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
StepperProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UIStepper::new(mtm);
let default = StepperProps::platform_default(props.slot);
view.setMinimumValue(0.0);
view.setMaximumValue(f64::from(default.span()));
view.setStepValue(f64::from(default.step));
view.setValue(f64::from(default.platform_value()));
view.setWraps(default.wraps);
let plan = StepperProps::plan(&default, props, None);
apply_all(mtm, &view, &plan);
let target = FrustNativeControlTarget::attach_stepper(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
StepperState {
view,
target,
min: props.min,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = StepperProps::plan(old, new, state.observed);
apply_all(ctx.mtm(), &state.view, &plan);
state.observed = None;
state.min = new.min;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_event(&mut state.observed, state.min, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach_stepper(&state.view);
Ok(())
}
}
fn apply_all(mtm: MainThreadMarker, view: &UIStepper, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UIStepper, setter: &Setter<'_>) {
match *setter {
Setter::Max(span) => {
debug_assert!(span >= 1, "UIStepper requires maximumValue > minimumValue");
view.setMaximumValue(f64::from(span));
}
Setter::Progress(value) => view.setValue(f64::from(value)),
Setter::Step(step) => {
debug_assert!(step >= 1, "UIStepper requires stepValue > 0");
view.setStepValue(f64::from(step));
}
Setter::Wraps(wraps) => view.setWraps(wraps),
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::StepperTint(argb) => set_tint_color(view, argb),
Setter::ContentDescription(label) => {
platform::set_accessibility_label(view, label, mtm);
}
ref other => platform::warn_unexpected_setter(KIND, other),
}
}
fn set_tint_color(view: &UIStepper, argb: Option<i32>) {
let color = platform::optional_ui_color(argb);
unsafe { view.setTintColor(color.as_deref()) };
}
}
#[cfg(target_os = "macos")]
pub(crate) mod platform {
use objc2::rc::Retained;
use objc2_app_kit::NSStepper;
use super::{KIND, Stepper, StepperProps, decode_event};
use crate::NativeWidgetError;
use crate::appkit::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::{EVENT_KIND_VALUE_CHANGED, EventPayload};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct StepperState {
view: Retained<NSStepper>,
target: Retained<FrustNativeControlTarget>,
min: i32,
observed: Option<i32>,
}
impl NativeWidget for Stepper {
type Props = StepperProps;
type State = StepperState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
StepperProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSStepper::new(mtm);
let default = StepperProps::platform_default(props.slot);
view.setMinValue(0.0);
view.setMaxValue(f64::from(default.span()));
view.setIncrement(f64::from(default.step));
view.setValueWraps(default.wraps);
view.setDoubleValue(f64::from(default.platform_value()));
let plan = StepperProps::plan(&default, props, None);
apply_all(&view, &plan);
let target =
FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_VALUE_CHANGED);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
StepperState {
view,
target,
min: props.min,
observed: None,
},
))
}
fn update(
_ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = StepperProps::plan(old, new, state.observed);
apply_all(&state.view, &plan);
state.observed = None;
state.min = new.min;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_event(&mut state.observed, state.min, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach(&state.view);
Ok(())
}
}
fn apply_all(view: &NSStepper, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSStepper, setter: &Setter<'_>) {
match *setter {
Setter::Max(span) => {
debug_assert!(span >= 1, "NSStepper's span must stay >= 1");
view.setMaxValue(f64::from(span));
}
Setter::Progress(value) => view.setDoubleValue(f64::from(value)),
Setter::Step(step) => {
debug_assert!(step >= 1, "NSStepper's increment must stay >= 1");
view.setIncrement(f64::from(step));
}
Setter::Wraps(wraps) => view.setValueWraps(wraps),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::StepperTint(argb) => platform::set_tint(view, "StepperTint", argb),
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) -> StepperProps {
let raw = with_identity(KIND, 5, body);
StepperProps::decode(&Params::new(&raw)).expect("decodes")
}
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, StepperProps::platform_default(5));
assert!(StepperProps::plan(&StepperProps::platform_default(5), &props, None).is_empty());
}
#[test]
fn a_non_zero_min_is_mapped_onto_the_platforms_zero_based_range() {
let props = decode("\"value\":15,\"min\":10,\"max\":20");
assert_eq!(props.span(), 10);
assert_eq!(props.platform_value(), 5);
assert_eq!(
StepperProps::plan(&StepperProps::platform_default(5), &props, None),
vec![Setter::Max(10), Setter::Progress(5)],
"max is always planned before value — the platform clamps"
);
}
#[test]
fn a_value_outside_the_range_is_clamped_rather_than_rejected() {
assert_eq!(
decode("\"value\":99,\"min\":0,\"max\":10").platform_value(),
10
);
assert_eq!(
decode("\"value\":-5,\"min\":0,\"max\":10").platform_value(),
0
);
let inverted = decode("\"value\":3,\"min\":10,\"max\":2");
assert_eq!(inverted.span(), 1);
assert_eq!(inverted.platform_value(), 0);
}
#[test]
fn step_defaults_to_the_platform_default_and_never_offsets_by_min() {
let props = decode("\"min\":10,\"max\":20");
assert_eq!(props.step, PLATFORM_DEFAULT_STEP);
let stepped = decode("\"min\":10,\"max\":20,\"step\":5");
assert_eq!(stepped.step, 5, "a step size rides the wire unmapped");
}
#[test]
fn a_non_positive_or_missing_step_never_decodes_below_one() {
assert_eq!(decode("\"step\":0").step, 1, "zero normalizes to 1");
assert_eq!(decode("\"step\":-4").step, 1, "negative normalizes to 1");
assert_eq!(
decode("").step,
PLATFORM_DEFAULT_STEP,
"a missing step falls back to the platform default, not the \
non-positive branch"
);
}
#[test]
fn two_non_positive_step_decodes_both_still_normalize_to_one() {
assert_eq!(decode("\"step\":0").step, 1);
assert_eq!(decode("\"step\":0").step, 1);
}
#[test]
fn a_degenerate_range_max_equal_min_spans_one_and_disables_the_control() {
let props = decode("\"value\":5,\"min\":5,\"max\":5");
assert_eq!(props.span(), 1, "UIStepper requires max > min");
assert_eq!(props.platform_value(), 0);
assert!(
!props.effective_enabled(),
"a single-point range can never be stepped"
);
}
#[test]
fn a_degenerate_range_max_less_than_min_spans_one_and_disables_the_control() {
let props = decode("\"value\":5,\"min\":10,\"max\":2");
assert_eq!(props.span(), 1);
assert_eq!(props.platform_value(), 0);
assert!(!props.effective_enabled());
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields() {
let props = decode(
"\"value\":25,\"max\":50,\"step\":5,\"wraps\":true,\"enabled\":false,\"tint\":1,\
\"contentDescription\":\"count\"",
);
assert_eq!(
StepperProps::plan(&StepperProps::platform_default(5), &props, None),
vec![
Setter::Max(50),
Setter::Progress(25),
Setter::Step(5),
Setter::Wraps(true),
Setter::Enabled(false),
Setter::StepperTint(Some(1)),
Setter::ContentDescription(Some("count")),
]
);
}
#[test]
fn a_tap_the_app_confirmed_writes_the_value_back_once() {
let before = decode("\"value\":0,\"max\":10");
let after = decode("\"value\":1,\"max\":10");
assert_eq!(
StepperProps::plan(&before, &after, Some(1)),
vec![Setter::Progress(1)]
);
}
#[test]
fn a_platform_drift_is_written_back_even_when_the_props_value_did_not_change() {
let old = decode("\"value\":3,\"max\":10");
let new = decode("\"value\":3,\"max\":10,\"enabled\":false");
assert_eq!(
StepperProps::plan(&old, &new, Some(9)),
vec![Setter::Progress(3), Setter::Enabled(false)],
"the app rejected the tap: the confirmed value is re-asserted"
);
assert_eq!(
StepperProps::plan(&old, &new, None),
vec![Setter::Enabled(false)]
);
}
#[test]
fn a_range_change_replans_both_range_setters_in_order() {
let old = decode("\"value\":5,\"min\":0,\"max\":10");
let new = decode("\"value\":5,\"min\":5,\"max\":25");
assert_eq!(
StepperProps::plan(&old, &new, None),
vec![Setter::Max(20), Setter::Progress(0)]
);
}
#[test]
fn a_range_becoming_non_degenerate_reenables_and_reasserts_max_progress_step() {
let old = decode("\"value\":5,\"min\":5,\"max\":5,\"step\":3");
let new = decode("\"value\":5,\"min\":5,\"max\":6,\"step\":3");
assert_eq!(old.span(), 1);
assert_eq!(
new.span(),
1,
"the newly valid range still spans exactly one step"
);
assert!(!old.effective_enabled());
assert!(new.effective_enabled());
assert_eq!(
StepperProps::plan(&old, &new, None),
vec![
Setter::Max(1),
Setter::Progress(0),
Setter::Step(3),
Setter::Enabled(true),
],
"re-enabling must reassert every range setter, not just the ones \
that changed"
);
}
#[test]
fn a_range_becoming_degenerate_disables_without_a_forced_reassertion() {
let old = decode("\"value\":5,\"min\":0,\"max\":10,\"step\":2");
let new = decode("\"value\":5,\"min\":5,\"max\":5,\"step\":2");
assert_eq!(
StepperProps::plan(&old, &new, None),
vec![Setter::Max(1), Setter::Progress(0), Setter::Enabled(false)],
"going disabled needs the usual diffed setters, no forced replay"
);
}
#[test]
fn enabling_a_control_whose_range_was_already_valid_plans_only_enabled() {
let old = decode("\"value\":5,\"min\":0,\"max\":10,\"enabled\":false");
let new = decode("\"value\":5,\"min\":0,\"max\":10,\"enabled\":true");
assert_eq!(
StepperProps::plan(&old, &new, None),
vec![Setter::Enabled(true)]
);
}
#[test]
fn clearing_the_tint_plans_the_nullable_setter() {
let tinted = decode("\"tint\":7");
let plain = decode("");
assert_eq!(
StepperProps::plan(&tinted, &plain, None),
vec![Setter::StepperTint(None)]
);
}
#[test]
fn every_planned_setter_reports_the_cheap_tier() {
use crate::controls::Tier;
let props =
decode("\"value\":4,\"max\":10,\"step\":2,\"wraps\":true,\"enabled\":false,\"tint\":1");
for setter in StepperProps::plan(&StepperProps::platform_default(5), &props, None) {
assert_eq!(setter.tier(), Tier::Cheap, "{setter:?}");
}
}
fn value_changed_event(platform_value: i32, from_user: bool) -> NativeEvent {
NativeEvent {
kind: EVENT_KIND_VALUE_CHANGED,
detail: crate::events::pack_value_changed(platform_value, from_user),
}
}
#[test]
fn a_user_tap_decodes_to_app_space_and_updates_the_drift_signal() {
let mut observed = None;
let payload = decode_event(&mut observed, 10, value_changed_event(5, true));
assert_eq!(
payload,
Some(EventPayload::ValueChanged {
value: 15,
from_user: true,
})
);
assert_eq!(observed, Some(5), "observed stays platform-space");
}
#[test]
fn a_misrouted_kind_decodes_to_nothing() {
let mut observed = Some(1);
let click = NativeEvent {
kind: crate::events::EVENT_KIND_CLICK,
detail: 0,
};
assert_eq!(decode_event(&mut observed, 0, click), None);
assert_eq!(observed, Some(1));
}
#[test]
fn drag_edge_kinds_decode_to_nothing_this_control_has_none_to_report() {
let mut observed = None;
let start = NativeEvent {
kind: crate::events::EVENT_KIND_DRAG_START,
detail: 0,
};
assert_eq!(decode_event(&mut observed, 0, start), None);
assert_eq!(observed, None);
}
}