use super::{
CONTENT_DESCRIPTION, ENABLED, MAX, MIN, PROGRESS_TINT, Plan, Setter, THUMB_TINT, VALUE, color,
owned_text, slot_of,
};
use crate::NativeWidgetError;
use crate::events::{
EVENT_KIND_DRAG_END, EVENT_KIND_DRAG_START, EVENT_KIND_VALUE_CHANGED, EventPayload,
unpack_value_changed,
};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "slider";
pub(crate) const PLATFORM_DEFAULT_MAX: i32 = 100;
pub(crate) struct Slider;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SliderProps {
pub(crate) slot: SlotId,
pub(crate) value: i32,
pub(crate) min: i32,
pub(crate) max: i32,
pub(crate) enabled: bool,
pub(crate) progress_tint: Option<i32>,
pub(crate) thumb_tint: Option<i32>,
pub(crate) content_description: Option<String>,
}
impl SliderProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
value: 0,
min: 0,
max: PLATFORM_DEFAULT_MAX,
enabled: true,
progress_tint: None,
thumb_tint: None,
content_description: None,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
Ok(Self {
slot: slot_of(params)?,
value: int_or(params, VALUE, 0),
min: int_or(params, MIN, 0),
max: int_or(params, MAX, PLATFORM_DEFAULT_MAX),
enabled: params.flag(ENABLED).unwrap_or(true),
progress_tint: color(params, PROGRESS_TINT),
thumb_tint: color(params, THUMB_TINT),
content_description: owned_text(params, CONTENT_DESCRIPTION),
})
}
pub(crate) fn span(&self) -> i32 {
self.max.saturating_sub(self.min).max(0)
}
pub(crate) fn progress(&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();
if old.span() != new.span() {
plan.push(Setter::Max(new.span()));
}
let drifted = observed.is_some_and(|platform| platform != new.progress());
if old.progress() != new.progress() || drifted {
plan.push(Setter::Progress(new.progress()));
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
if old.progress_tint != new.progress_tint {
plan.push(Setter::ProgressTint(new.progress_tint));
}
if old.thumb_tint != new.thumb_tint {
plan.push(Setter::ThumbTint(new.thumb_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,
})
}
EVENT_KIND_DRAG_START => Some(EventPayload::DragStart),
EVENT_KIND_DRAG_END => Some(EventPayload::DragEnd),
_ => None,
}
}
#[cfg(target_os = "android")]
pub(crate) mod platform {
use jni::objects::JObject;
use jni::refs::Global;
use super::{EventPayload, Slider, SliderProps, decode_event};
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.SeekBar";
pub(crate) struct SliderState {
view: Global<JObject<'static>>,
min: i32,
pub(crate) observed: Option<i32>,
}
impl NativeWidget for Slider {
type Props = SliderProps;
type State = SliderState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SliderProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let view = ctx.new_view(CLASS)?;
let plan = SliderProps::plan(&SliderProps::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_seek_bar_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]),
SliderState {
view: retained,
min: props.min,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SliderProps::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;
state.min = new.min;
}
applied
}
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> {
ctx.set_on_seek_bar_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::UISlider;
use super::{KIND, Slider, SliderProps, 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 SliderState {
view: Retained<UISlider>,
target: Retained<FrustNativeControlTarget>,
min: i32,
observed: Option<i32>,
}
impl NativeWidget for Slider {
type Props = SliderProps;
type State = SliderState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SliderProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UISlider::new(mtm);
let default = SliderProps::platform_default(props.slot);
view.setMinimumValue(0.0);
view.setMaximumValue(default.span() as f32);
let plan = SliderProps::plan(&default, props, None);
apply_all(mtm, &view, &plan);
let target = FrustNativeControlTarget::attach_slider(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
SliderState {
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 = SliderProps::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_slider(&state.view);
Ok(())
}
}
fn apply_all(mtm: MainThreadMarker, view: &UISlider, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UISlider, setter: &Setter<'_>) {
match *setter {
Setter::Max(span) => view.setMaximumValue(span as f32),
Setter::Progress(progress) => view.setValue(progress as f32),
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::ProgressTint(argb) => {
view.setMinimumTrackTintColor(platform::optional_ui_color(argb).as_deref());
}
Setter::ThumbTint(argb) => {
view.setThumbTintColor(platform::optional_ui_color(argb).as_deref());
}
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::NSSlider;
use super::{KIND, Slider, SliderProps, 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 SliderState {
view: Retained<NSSlider>,
target: Retained<FrustNativeControlTarget>,
min: i32,
observed: Option<i32>,
}
impl NativeWidget for Slider {
type Props = SliderProps;
type State = SliderState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SliderProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSSlider::new(mtm);
let default = SliderProps::platform_default(props.slot);
view.setVertical(false);
view.setContinuous(true);
view.setMinValue(0.0);
view.setMaxValue(f64::from(default.span()));
view.setDoubleValue(f64::from(default.progress()));
let plan = SliderProps::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,
SliderState {
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 = SliderProps::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: &NSSlider, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSSlider, setter: &Setter<'_>) {
match *setter {
Setter::Max(span) => view.setMaxValue(f64::from(span)),
Setter::Progress(progress) => view.setDoubleValue(f64::from(progress)),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::ProgressTint(argb) => platform::set_tint(view, "ProgressTint", argb),
Setter::ThumbTint(argb) => platform::set_tint(view, "ThumbTint", 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) -> SliderProps {
let raw = with_identity(KIND, 5, body);
SliderProps::decode(&Params::new(&raw)).expect("decodes")
}
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, SliderProps::platform_default(5));
assert!(SliderProps::plan(&SliderProps::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.progress(), 5);
assert_eq!(
SliderProps::plan(&SliderProps::platform_default(5), &props, None),
vec![Setter::Max(10), Setter::Progress(5)],
"max is always planned before progress — the platform clamps"
);
}
#[test]
fn a_value_outside_the_range_is_clamped_rather_than_rejected() {
assert_eq!(decode("\"value\":99,\"min\":0,\"max\":10").progress(), 10);
assert_eq!(decode("\"value\":-5,\"min\":0,\"max\":10").progress(), 0);
let inverted = decode("\"value\":3,\"min\":10,\"max\":2");
assert_eq!(inverted.span(), 0);
assert_eq!(inverted.progress(), 0);
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields() {
let props = decode(
"\"value\":25,\"max\":50,\"enabled\":false,\"progressTint\":1,\"thumbTint\":2,\
\"contentDescription\":\"volume\"",
);
assert_eq!(
SliderProps::plan(&SliderProps::platform_default(5), &props, None),
vec![
Setter::Max(50),
Setter::Progress(25),
Setter::Enabled(false),
Setter::ProgressTint(Some(1)),
Setter::ThumbTint(Some(2)),
Setter::ContentDescription(Some("volume")),
]
);
}
#[test]
fn a_drag_the_app_confirmed_writes_the_value_back_once() {
let before = decode("\"value\":0,\"max\":10");
let after = decode("\"value\":7,\"max\":10");
assert_eq!(
SliderProps::plan(&before, &after, Some(7)),
vec![Setter::Progress(7)]
);
}
#[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!(
SliderProps::plan(&old, &new, Some(9)),
vec![Setter::Progress(3), Setter::Enabled(false)],
"the app rejected the drag: the confirmed value is re-asserted"
);
assert_eq!(
SliderProps::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!(
SliderProps::plan(&old, &new, None),
vec![Setter::Max(20), Setter::Progress(0)]
);
}
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_drag_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 drag_start_and_end_decode_unconditionally_and_do_not_touch_observed() {
let mut observed = Some(3);
let start = NativeEvent {
kind: EVENT_KIND_DRAG_START,
detail: 0,
};
let end = NativeEvent {
kind: EVENT_KIND_DRAG_END,
detail: 0,
};
assert_eq!(
decode_event(&mut observed, 0, start),
Some(EventPayload::DragStart),
"a drag gesture is always user-caused"
);
assert_eq!(
decode_event(&mut observed, 0, end),
Some(EventPayload::DragEnd)
);
assert_eq!(observed, Some(3), "neither kind touches the drift signal");
}
#[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));
}
}