use super::{CONTENT_DESCRIPTION, ENABLED, Plan, Setter, TINT, color, owned_text, slot_of};
use crate::NativeWidgetError;
use crate::registry::SlotId;
use crate::runtime::Params;
pub(crate) const KIND: &str = "spinner";
pub(crate) const ANIMATING: &str = "animating";
pub(crate) const SIZE_CLASS: &str = "sizeClass";
pub(crate) struct Spinner;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum SizeClass {
Small,
#[default]
Medium,
Large,
}
impl SizeClass {
fn from_wire(raw: &str) -> Option<Self> {
match raw {
"small" => Some(Self::Small),
"medium" => Some(Self::Medium),
"large" => Some(Self::Large),
_ => None,
}
}
#[cfg(target_os = "android")]
pub(crate) fn android_style_attr(self) -> &'static jni::strings::JNIStr {
use jni::jni_str;
match self {
Self::Small => jni_str!("progressBarStyleSmall"),
Self::Medium => jni_str!("progressBarStyle"),
Self::Large => jni_str!("progressBarStyleLarge"),
}
}
#[cfg(target_os = "ios")]
pub(crate) fn activity_indicator_style(self) -> objc2_ui_kit::UIActivityIndicatorViewStyle {
use objc2_ui_kit::UIActivityIndicatorViewStyle;
match self {
Self::Small | Self::Medium => UIActivityIndicatorViewStyle::Medium,
Self::Large => UIActivityIndicatorViewStyle::Large,
}
}
#[cfg(target_os = "macos")]
pub(crate) fn control_size(self) -> objc2_app_kit::NSControlSize {
use objc2_app_kit::NSControlSize;
match self {
Self::Small => NSControlSize::Small,
Self::Medium => NSControlSize::Regular,
Self::Large => NSControlSize::Large,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SpinnerProps {
pub(crate) slot: SlotId,
pub(crate) animating: bool,
pub(crate) size_class: SizeClass,
pub(crate) tint: Option<i32>,
pub(crate) enabled: bool,
pub(crate) content_description: Option<String>,
}
impl SpinnerProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
animating: false,
size_class: SizeClass::Medium,
tint: None,
enabled: true,
content_description: None,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
Ok(Self {
slot: slot_of(params)?,
animating: params.flag(ANIMATING).unwrap_or(false),
size_class: params
.string(SIZE_CLASS)
.and_then(|raw| SizeClass::from_wire(&raw))
.unwrap_or_default(),
tint: color(params, TINT),
enabled: params.flag(ENABLED).unwrap_or(true),
content_description: owned_text(params, CONTENT_DESCRIPTION),
})
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self) -> Plan<'a> {
let mut plan = Plan::new();
if old.size_class != new.size_class {
plan.push(Setter::SizeClass(new.size_class));
}
if old.animating != new.animating {
plan.push(Setter::Animating(new.animating));
}
if old.tint != new.tint {
plan.push(Setter::SpinnerTint(new.tint));
}
if old.content_description != new.content_description {
plan.push(Setter::ContentDescription(
new.content_description.as_deref(),
));
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
plan
}
}
#[cfg(target_os = "android")]
pub(crate) mod platform {
use jni::objects::{JObject, JValue};
use jni::refs::Global;
use jni::{jni_sig, jni_str};
use super::{Spinner, SpinnerProps};
use crate::NativeWidgetError;
use crate::android::{NativeCtx, NativeView};
use crate::controls::platform::{FRAME_CAPACITY, apply_all};
use crate::runtime::{NativeWidget, Params};
const CLASS: &str = "android.widget.ProgressBar";
const R_ATTR_CLASS: &str = "android.R$attr";
const VIEW_VISIBLE: i32 = 0;
const VIEW_INVISIBLE: i32 = 4;
fn new_spinner<'local>(
ctx: &mut NativeCtx<'local, '_>,
size_class: super::SizeClass,
) -> Result<JObject<'local>, NativeWidgetError> {
let attrs = ctx.class(R_ATTR_CLASS)?;
let attr_name = size_class.android_style_attr();
let style = ctx.run_jni(
&format!("android.R.attr.<spinner style {size_class:?}>"),
|env| env.get_static_field(&attrs, attr_name, jni_sig!("I"))?.i(),
)?;
let class = ctx.class(CLASS)?;
let context = ctx.context()?;
let view = ctx.run_jni("new ProgressBar(Context, AttributeSet, int)", |env| {
env.new_object(
&class,
jni_sig!("(Landroid/content/Context;Landroid/util/AttributeSet;I)V"),
&[
JValue::Object(context),
JValue::Object(&JObject::null()),
JValue::Int(style),
],
)
})?;
ctx.call_void(
&view,
jni_str!("setIndeterminate"),
jni_sig!("(Z)V"),
&[JValue::Bool(true)],
)?;
Ok(view)
}
pub(crate) struct SpinnerState {
view: Global<JObject<'static>>,
}
impl NativeWidget for Spinner {
type Props = SpinnerProps;
type State = SpinnerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SpinnerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let view = new_spinner(ctx, props.size_class)?;
ctx.call_void(
&view,
jni_str!("setVisibility"),
jni_sig!("(I)V"),
&[JValue::Int(if props.animating {
VIEW_VISIBLE
} else {
VIEW_INVISIBLE
})],
)?;
let mut default = SpinnerProps::platform_default(props.slot);
default.size_class = props.size_class;
default.animating = props.animating;
let plan = SpinnerProps::plan(&default, props);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &view, &plan))?;
let handle = ctx.retain(&view)?;
let retained = ctx.retain(&view)?;
Ok((NativeView::new(handle), SpinnerState { view: retained }))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SpinnerProps::plan(old, new);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &state.view, &plan))
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
_state: Self::State,
) -> Result<(), NativeWidgetError> {
Ok(())
}
}
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::rc::Retained;
use objc2::{MainThreadMarker, MainThreadOnly};
use objc2_ui_kit::UIActivityIndicatorView;
use super::{KIND, Spinner, SpinnerProps};
use crate::NativeWidgetError;
use crate::apple::{NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::runtime::{NativeWidget, Params};
pub(crate) struct SpinnerState {
view: Retained<UIActivityIndicatorView>,
}
impl NativeWidget for Spinner {
type Props = SpinnerProps;
type State = SpinnerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SpinnerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let default = SpinnerProps::platform_default(props.slot);
let view = UIActivityIndicatorView::initWithActivityIndicatorStyle(
UIActivityIndicatorView::alloc(mtm),
default.size_class.activity_indicator_style(),
);
view.setHidesWhenStopped(true);
let state = SpinnerState { view };
let plan = SpinnerProps::plan(&default, props);
apply_all(mtm, &state.view, &plan);
let handle = NativeView::new(Retained::clone(&state.view).into_super(), mtm);
Ok((handle, state))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SpinnerProps::plan(old, new);
apply_all(ctx.mtm(), &state.view, &plan);
Ok(())
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
_state: Self::State,
) -> Result<(), NativeWidgetError> {
Ok(())
}
}
fn apply_all(mtm: MainThreadMarker, view: &UIActivityIndicatorView, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UIActivityIndicatorView, setter: &Setter<'_>) {
match *setter {
Setter::SizeClass(size_class) => {
view.setActivityIndicatorViewStyle(size_class.activity_indicator_style());
}
Setter::Animating(animating) => {
if animating {
view.startAnimating();
} else {
view.stopAnimating();
}
}
Setter::SpinnerTint(argb) => {
unsafe { view.setColor(platform::optional_ui_color(argb).as_deref()) };
}
Setter::Enabled(_) => {}
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::{NSProgressIndicator, NSProgressIndicatorStyle};
use super::{KIND, Spinner, SpinnerProps};
use crate::NativeWidgetError;
use crate::appkit::{NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::runtime::{NativeWidget, Params};
pub(crate) struct SpinnerState {
view: Retained<NSProgressIndicator>,
}
impl NativeWidget for Spinner {
type Props = SpinnerProps;
type State = SpinnerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SpinnerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSProgressIndicator::new(mtm);
view.setStyle(NSProgressIndicatorStyle::Spinning);
view.setIndeterminate(true);
view.setDisplayedWhenStopped(false);
let default = SpinnerProps::platform_default(props.slot);
view.setControlSize(default.size_class.control_size());
let state = SpinnerState { view };
let plan = SpinnerProps::plan(&default, props);
apply_all(&state.view, &plan);
let handle = NativeView::new(Retained::clone(&state.view).into_super(), mtm);
Ok((handle, state))
}
fn update(
_ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
apply_all(&state.view, &SpinnerProps::plan(old, new));
Ok(())
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
unsafe { state.view.stopAnimation(None) };
Ok(())
}
}
fn apply_all(view: &NSProgressIndicator, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSProgressIndicator, setter: &Setter<'_>) {
match *setter {
Setter::SizeClass(size_class) => view.setControlSize(size_class.control_size()),
Setter::Animating(animating) => {
if animating {
unsafe { view.startAnimation(None) };
} else {
unsafe { view.stopAnimation(None) };
}
}
Setter::SpinnerTint(argb) => platform::set_tint(view, "SpinnerTint", argb),
Setter::Enabled(_) => {}
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) -> SpinnerProps {
let raw = with_identity(KIND, 3, body);
SpinnerProps::decode(&Params::new(&raw)).expect("decodes")
}
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, SpinnerProps::platform_default(3));
assert!(SpinnerProps::plan(&SpinnerProps::platform_default(3), &props).is_empty());
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields_in_order() {
let props = decode(
"\"animating\":true,\"sizeClass\":\"large\",\"tint\":9,\
\"contentDescription\":\"loading\",\"enabled\":false",
);
assert_eq!(
SpinnerProps::plan(&SpinnerProps::platform_default(3), &props),
vec![
Setter::SizeClass(SizeClass::Large),
Setter::Animating(true),
Setter::SpinnerTint(Some(9)),
Setter::ContentDescription(Some("loading")),
Setter::Enabled(false),
]
);
}
#[test]
fn asking_to_animate_alone_plans_exactly_one_setter() {
let props = decode("\"animating\":true");
assert_eq!(
SpinnerProps::plan(&SpinnerProps::platform_default(3), &props),
vec![Setter::Animating(true)]
);
}
#[test]
fn stopping_a_running_spinner_plans_exactly_one_setter() {
let running = decode("\"animating\":true");
let stopped = decode("\"animating\":false");
assert_eq!(
SpinnerProps::plan(&running, &stopped),
vec![Setter::Animating(false)]
);
}
#[test]
fn a_size_class_change_alone_plans_exactly_one_setter() {
let old = decode("\"sizeClass\":\"small\"");
let new = decode("\"sizeClass\":\"large\"");
assert_eq!(
SpinnerProps::plan(&old, &new),
vec![Setter::SizeClass(SizeClass::Large)]
);
}
#[test]
fn an_unknown_size_class_spelling_degrades_to_medium() {
assert_eq!(
decode("\"sizeClass\":\"huge\"").size_class,
SizeClass::Medium
);
assert_eq!(
decode("\"sizeClass\":\"small\"").size_class,
SizeClass::Small
);
assert_eq!(
decode("\"sizeClass\":\"large\"").size_class,
SizeClass::Large
);
}
#[test]
fn a_tint_change_alone_plans_exactly_one_setter() {
let old = decode("\"tint\":1");
let new = decode("\"tint\":2");
assert_eq!(
SpinnerProps::plan(&old, &new),
vec![Setter::SpinnerTint(Some(2))]
);
}
#[test]
fn clearing_a_tint_plans_the_nullable_setter() {
let tinted = decode("\"tint\":7");
let plain = decode("");
assert_eq!(
SpinnerProps::plan(&tinted, &plain),
vec![Setter::SpinnerTint(None)],
"unlike a text colour, a tint list is clearable (setter takes null)"
);
}
#[test]
fn an_enabled_change_alone_plans_exactly_one_setter() {
let props = decode("\"enabled\":false");
assert_eq!(
SpinnerProps::plan(&SpinnerProps::platform_default(3), &props),
vec![Setter::Enabled(false)]
);
}
#[test]
fn every_planned_setter_reports_its_documented_tier() {
assert_eq!(Setter::Animating(true).tier(), super::super::Tier::Cheap);
assert_eq!(Setter::SpinnerTint(None).tier(), super::super::Tier::Cheap);
assert_eq!(
Setter::SizeClass(SizeClass::Large).tier(),
super::super::Tier::Relayout
);
}
}