use super::{CONTENT_DESCRIPTION, ENABLED, Plan, Setter, TINT, color, owned_text, slot_of};
use crate::NativeWidgetError;
use crate::events::{EVENT_KIND_SELECTION, EventPayload, unpack_index};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "segmented";
pub(crate) const SEGMENT_COUNT: &str = "segmentCount";
pub(crate) const SEGMENT_PREFIX: &str = "segment";
pub(crate) const SELECTED: &str = "selected";
pub(crate) const MOMENTARY: &str = "momentary";
pub(crate) const MAX_SEGMENTS: usize = 64;
pub(crate) const NO_SEGMENT: isize = -1;
pub(crate) fn segment_key(index: usize) -> String {
format!("{SEGMENT_PREFIX}{index}")
}
pub(crate) fn platform_index(selected: Option<usize>, segment_count: usize) -> isize {
selected
.filter(|&index| index < segment_count)
.and_then(|index| isize::try_from(index).ok())
.unwrap_or(NO_SEGMENT)
}
pub(crate) struct Segmented;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SegmentedProps {
pub(crate) slot: SlotId,
pub(crate) labels: Vec<String>,
pub(crate) selected: Option<usize>,
pub(crate) enabled: bool,
pub(crate) momentary: bool,
pub(crate) tint: Option<i32>,
pub(crate) content_description: Option<String>,
}
impl SegmentedProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
labels: Vec::new(),
selected: None,
enabled: true,
momentary: false,
tint: None,
content_description: None,
}
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
let count = params
.int(SEGMENT_COUNT)
.and_then(|count| usize::try_from(count).ok())
.unwrap_or(0)
.min(MAX_SEGMENTS);
let labels: Vec<String> = (0..count)
.map(|index| owned_text(params, &segment_key(index)).unwrap_or_default())
.collect();
let selected = params
.int(SELECTED)
.and_then(|index| usize::try_from(index).ok())
.filter(|&index| index < labels.len());
Ok(Self {
slot: slot_of(params)?,
labels,
selected,
enabled: params.flag(ENABLED).unwrap_or(true),
momentary: params.flag(MOMENTARY).unwrap_or(false),
tint: color(params, TINT),
content_description: owned_text(params, CONTENT_DESCRIPTION),
})
}
pub(crate) fn plan<'a>(old: &Self, new: &'a Self, observed: Option<usize>) -> Plan<'a> {
let mut plan = Plan::new();
let momentary_changed = old.momentary != new.momentary;
if momentary_changed {
plan.push(Setter::Momentary(new.momentary));
}
let segments_changed = old.labels != new.labels;
if segments_changed {
plan.push(Setter::Segments(&new.labels));
}
let drifted =
!new.momentary && observed.is_some_and(|platform| Some(platform) != new.selected);
if segments_changed || momentary_changed || old.selected != new.selected || drifted {
plan.push(Setter::SelectedSegment(new.selected));
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
if old.tint != new.tint {
plan.push(Setter::SegmentTint(new.tint));
}
if old.content_description != new.content_description {
plan.push(Setter::ContentDescription(
new.content_description.as_deref(),
));
}
plan
}
}
pub(crate) fn decode_event(
observed: &mut Option<usize>,
event: NativeEvent,
) -> Option<EventPayload> {
if event.kind != EVENT_KIND_SELECTION {
return None;
}
let index = unpack_index(event.detail)?;
*observed = Some(index);
Some(EventPayload::Selected(index))
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_foundation::NSString;
use objc2_ui_kit::UISegmentedControl;
use super::{KIND, Segmented, SegmentedProps, decode_event, platform_index};
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 SegmentedState {
view: Retained<UISegmentedControl>,
target: Retained<FrustNativeControlTarget>,
observed: Option<usize>,
}
impl NativeWidget for Segmented {
type Props = SegmentedProps;
type State = SegmentedState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SegmentedProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UISegmentedControl::new(mtm);
let plan =
SegmentedProps::plan(&SegmentedProps::platform_default(props.slot), props, None);
apply_all(mtm, &view, &plan);
let target = FrustNativeControlTarget::attach_segmented(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
SegmentedState {
view,
target,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = SegmentedProps::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_event(&mut state.observed, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach_segmented(&state.view);
Ok(())
}
}
fn apply_all(mtm: MainThreadMarker, view: &UISegmentedControl, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UISegmentedControl, setter: &Setter<'_>) {
match *setter {
Setter::Segments(labels) => {
view.removeAllSegments();
for (index, label) in labels.iter().enumerate() {
view.insertSegmentWithTitle_atIndex_animated(
Some(&NSString::from_str(label)),
index,
false,
);
}
}
Setter::SelectedSegment(selected) => {
view.setSelectedSegmentIndex(platform_index(selected, view.numberOfSegments()));
}
Setter::Momentary(momentary) => view.setMomentary(momentary),
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::SegmentTint(argb) => {
view.setSelectedSegmentTintColor(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::{NSSegmentDistribution, NSSegmentSwitchTracking, NSSegmentedControl};
use objc2_foundation::NSString;
use super::{KIND, Segmented, SegmentedProps, decode_event, platform_index};
use crate::NativeWidgetError;
use crate::appkit::{FrustNativeControlTarget, NativeCtx, NativeView};
use crate::controls::platform;
use crate::controls::{Plan, Setter};
use crate::events::{EVENT_KIND_SELECTION, EventPayload};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct SegmentedState {
view: Retained<NSSegmentedControl>,
target: Retained<FrustNativeControlTarget>,
observed: Option<usize>,
}
impl NativeWidget for Segmented {
type Props = SegmentedProps;
type State = SegmentedState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
SegmentedProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSSegmentedControl::new(mtm);
view.setTrackingMode(NSSegmentSwitchTracking::SelectOne);
view.setSegmentDistribution(NSSegmentDistribution::FillEqually);
let plan =
SegmentedProps::plan(&SegmentedProps::platform_default(props.slot), props, None);
apply_all(&view, &plan);
let target =
FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_SELECTION);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
SegmentedState {
view,
target,
observed: None,
},
))
}
fn update(
_ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
apply_all(&state.view, &SegmentedProps::plan(old, new, state.observed));
state.observed = None;
Ok(())
}
fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
decode_event(&mut state.observed, event)
}
fn dispose(
_ctx: &mut NativeCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
state.target.detach(&state.view);
Ok(())
}
}
fn apply_all(view: &NSSegmentedControl, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSSegmentedControl, setter: &Setter<'_>) {
match *setter {
Setter::Segments(labels) => {
view.setSegmentCount(labels.len() as isize);
for (index, label) in labels.iter().enumerate() {
view.setLabel_forSegment(&NSString::from_str(label), index as isize);
}
}
Setter::SelectedSegment(selected) => {
let count = usize::try_from(view.segmentCount()).unwrap_or(0);
view.setSelectedSegment(platform_index(selected, count));
}
Setter::Momentary(momentary) => view.setTrackingMode(if momentary {
NSSegmentSwitchTracking::Momentary
} else {
NSSegmentSwitchTracking::SelectOne
}),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::SegmentTint(argb) => {
view.setSelectedSegmentBezelColor(argb.map(platform::ns_color).as_deref());
}
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::controls::Tier;
use crate::events::pack_index;
use crate::runtime::with_identity;
fn decode(body: &str) -> SegmentedProps {
let raw = with_identity(KIND, 5, body);
SegmentedProps::decode(&Params::new(&raw)).expect("decodes")
}
fn labels(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
const DAY_WEEK_MONTH: &str =
"\"segmentCount\":3,\"segment0\":\"Day\",\"segment1\":\"Week\",\"segment2\":\"Month\"";
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, SegmentedProps::platform_default(5));
assert!(
SegmentedProps::plan(&SegmentedProps::platform_default(5), &props, None).is_empty()
);
}
#[test]
fn labels_decode_from_the_flat_count_and_indexed_keys() {
let props = decode(&format!("{DAY_WEEK_MONTH},\"selected\":1"));
assert_eq!(props.labels, labels(&["Day", "Week", "Month"]));
assert_eq!(props.selected, Some(1));
}
#[test]
fn a_missing_label_decodes_empty_and_the_count_is_capped() {
let props = decode("\"segmentCount\":2,\"segment1\":\"B\"");
assert_eq!(props.labels, labels(&["", "B"]));
let huge = decode("\"segmentCount\":100000");
assert_eq!(huge.labels.len(), MAX_SEGMENTS);
let negative = decode("\"segmentCount\":-3");
assert!(negative.labels.is_empty());
}
#[test]
fn an_out_of_range_selection_decodes_to_no_selection() {
let past_end = decode(&format!("{DAY_WEEK_MONTH},\"selected\":3"));
assert_eq!(past_end.selected, None);
let negative = decode(&format!("{DAY_WEEK_MONTH},\"selected\":-1"));
assert_eq!(negative.selected, None);
let no_segments = decode("\"selected\":0");
assert_eq!(no_segments.selected, None);
}
#[test]
fn the_create_plan_is_momentary_then_segments_then_selection_in_order() {
let props = decode(&format!(
"{DAY_WEEK_MONTH},\"selected\":2,\"momentary\":true,\"enabled\":false,\"tint\":9,\
\"contentDescription\":\"range\""
));
assert_eq!(
SegmentedProps::plan(&SegmentedProps::platform_default(5), &props, None),
vec![
Setter::Momentary(true),
Setter::Segments(&props.labels),
Setter::SelectedSegment(Some(2)),
Setter::Enabled(false),
Setter::SegmentTint(Some(9)),
Setter::ContentDescription(Some("range")),
]
);
}
#[test]
fn a_selection_change_alone_plans_exactly_one_setter() {
let old = decode(&format!("{DAY_WEEK_MONTH},\"selected\":0"));
let new = decode(&format!("{DAY_WEEK_MONTH},\"selected\":1"));
assert_eq!(
SegmentedProps::plan(&old, &new, None),
vec![Setter::SelectedSegment(Some(1))]
);
assert!(
SegmentedProps::plan(&new, &new, None).is_empty(),
"unchanged props plan nothing — the zero-FFI property"
);
}
#[test]
fn a_label_change_replaces_wholesale_and_reasserts_the_selection() {
let old = decode(&format!("{DAY_WEEK_MONTH},\"selected\":1"));
let new = decode(
"\"segmentCount\":3,\"segment0\":\"Day\",\"segment1\":\"Wk\",\"segment2\":\"Month\",\
\"selected\":1",
);
assert_eq!(
SegmentedProps::plan(&old, &new, None),
vec![
Setter::Segments(&new.labels),
Setter::SelectedSegment(Some(1)),
],
"replacing the segments resets the platform's selection, so it is re-planned"
);
}
#[test]
fn a_platform_drift_is_written_back_even_when_the_props_did_not_change_it() {
let old = decode(&format!("{DAY_WEEK_MONTH},\"selected\":0"));
let new = decode(&format!(
"{DAY_WEEK_MONTH},\"selected\":0,\"enabled\":false"
));
assert_eq!(
SegmentedProps::plan(&old, &new, Some(2)),
vec![Setter::SelectedSegment(Some(0)), Setter::Enabled(false)]
);
assert_eq!(
SegmentedProps::plan(&old, &new, Some(0)),
vec![Setter::Enabled(false)],
"an observed value matching the app's is no drift"
);
}
#[test]
fn a_momentary_control_ignores_the_drift_signal() {
let old = decode(&format!("{DAY_WEEK_MONTH},\"momentary\":true"));
let new = decode(&format!(
"{DAY_WEEK_MONTH},\"momentary\":true,\"enabled\":false"
));
assert_eq!(
SegmentedProps::plan(&old, &new, Some(2)),
vec![Setter::Enabled(false)]
);
}
#[test]
fn clearing_the_tint_plans_the_nullable_setter() {
let tinted = decode("\"tint\":7");
let plain = decode("");
assert_eq!(
SegmentedProps::plan(&tinted, &plain, None),
vec![Setter::SegmentTint(None)]
);
}
#[test]
fn every_planned_setter_reports_its_documented_tier() {
let props = decode(&format!(
"{DAY_WEEK_MONTH},\"selected\":1,\"momentary\":true,\"enabled\":false,\"tint\":1"
));
for setter in SegmentedProps::plan(&SegmentedProps::platform_default(5), &props, None) {
let expected = if matches!(setter, Setter::Segments(_)) {
Tier::Relayout
} else {
Tier::Cheap
};
assert_eq!(setter.tier(), expected, "{setter:?}");
}
}
#[test]
fn the_platform_index_is_never_out_of_range() {
assert_eq!(platform_index(Some(0), 3), 0);
assert_eq!(platform_index(Some(2), 3), 2);
assert_eq!(platform_index(Some(3), 3), NO_SEGMENT);
assert_eq!(platform_index(None, 3), NO_SEGMENT);
assert_eq!(platform_index(Some(0), 0), NO_SEGMENT);
}
fn selection(index: isize) -> NativeEvent {
NativeEvent {
kind: EVENT_KIND_SELECTION,
detail: pack_index(index),
}
}
#[test]
fn a_user_selection_decodes_and_updates_the_drift_signal() {
let mut observed = None;
assert_eq!(
decode_event(&mut observed, selection(2)),
Some(EventPayload::Selected(2))
);
assert_eq!(observed, Some(2));
}
#[test]
fn a_no_segment_report_decodes_to_nothing_and_leaves_observed_untouched() {
let mut observed = Some(1);
assert_eq!(decode_event(&mut observed, selection(NO_SEGMENT)), None);
assert_eq!(observed, Some(1));
}
#[test]
fn a_misrouted_kind_decodes_to_nothing() {
let mut observed = None;
let toggle = NativeEvent {
kind: crate::events::EVENT_KIND_TOGGLED,
detail: 1,
};
assert_eq!(decode_event(&mut observed, toggle), None);
assert_eq!(observed, None);
}
}