use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use super::{
CONTENT_DESCRIPTION, ENABLED, Plan, Setter, TEXT_COLOR, TINT, color, owned_text, slot_of,
};
use crate::NativeWidgetError;
use crate::events::{EVENT_KIND_DATE, EventPayload, pack_date, unpack_date};
use crate::registry::SlotId;
use crate::runtime::{NativeEvent, Params};
pub(crate) const KIND: &str = "date_picker";
pub(crate) const DATE: &str = "date";
pub(crate) const MIN_DATE: &str = "minDate";
pub(crate) const MAX_DATE: &str = "maxDate";
pub(crate) const STYLE: &str = "style";
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CivilDate {
pub(crate) year: i32,
pub(crate) month: u8,
pub(crate) day: u8,
}
impl CivilDate {
pub const MIN: Self = Self::from_parts(1, 1, 1);
pub const MAX: Self = Self::from_parts(9999, 12, 31);
pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
let date = Self::from_parts(year, month, day);
date.is_valid().then_some(date)
}
pub fn is_valid(self) -> bool {
(Self::MIN.year..=Self::MAX.year).contains(&self.year)
&& (1..=12).contains(&self.month)
&& self.day >= 1
&& self.day <= days_in_month(self.year, self.month)
}
pub const fn year(&self) -> i32 {
self.year
}
pub const fn month(&self) -> u8 {
self.month
}
pub const fn day(&self) -> u8 {
self.day
}
const fn from_parts(year: i32, month: u8, day: u8) -> Self {
Self { year, month, day }
}
}
impl fmt::Display for CivilDate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum DatePickerStyle {
#[default]
Compact,
Wheels,
Inline,
}
impl DatePickerStyle {
fn from_wire(raw: &str) -> Option<Self> {
match raw {
"compact" => Some(Self::Compact),
"wheels" => Some(Self::Wheels),
"inline" => Some(Self::Inline),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Bounds {
floor: CivilDate,
ceiling: CivilDate,
}
impl Bounds {
pub(crate) const APPLE: Self = Self {
floor: CivilDate::MIN,
ceiling: CivilDate::MAX,
};
fn resolve_min(self, min: Option<CivilDate>) -> CivilDate {
min.unwrap_or(self.floor)
}
fn resolve_ceiling(self, max: Option<CivilDate>) -> CivilDate {
max.unwrap_or(self.ceiling)
}
fn effective_range(
self,
min: Option<CivilDate>,
max: Option<CivilDate>,
) -> (CivilDate, CivilDate) {
let floor = self.resolve_min(min).clamp(self.floor, self.ceiling);
let ceiling = self.resolve_ceiling(max).clamp(self.floor, self.ceiling);
if floor > ceiling {
(ceiling, ceiling)
} else {
(floor, ceiling)
}
}
}
pub(crate) struct DatePicker;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DatePickerProps {
pub(crate) slot: SlotId,
pub(crate) date: Option<CivilDate>,
pub(crate) min: Option<CivilDate>,
pub(crate) max: Option<CivilDate>,
pub(crate) style: DatePickerStyle,
pub(crate) enabled: bool,
pub(crate) tint: Option<i32>,
pub(crate) text_color: Option<i32>,
pub(crate) content_description: Option<String>,
}
impl DatePickerProps {
pub(crate) fn platform_default(slot: SlotId) -> Self {
Self {
slot,
date: None,
min: None,
max: None,
style: DatePickerStyle::Compact,
enabled: true,
tint: None,
text_color: None,
content_description: None,
}
}
pub(crate) fn effective_date(&self, bounds: Bounds) -> Option<CivilDate> {
let (min, max) = bounds.effective_range(self.min, self.max);
self.date.map(|date| date.clamp(min, max))
}
pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
let min = date_field(params, MIN_DATE);
let max = match (min, date_field(params, MAX_DATE)) {
(Some(min), Some(max)) if max < min => Some(min),
(_, max) => max,
};
let date = date_field(params, DATE).map(|date| clamp(date, min, max));
Ok(Self {
slot: slot_of(params)?,
date,
min,
max,
style: params
.string(STYLE)
.and_then(|raw| DatePickerStyle::from_wire(&raw))
.unwrap_or_default(),
enabled: params.flag(ENABLED).unwrap_or(true),
tint: color(params, TINT),
text_color: color(params, TEXT_COLOR),
content_description: owned_text(params, CONTENT_DESCRIPTION),
})
}
pub(crate) fn plan<'a>(
old: &Self,
new: &'a Self,
observed: Option<CivilDate>,
bounds: Bounds,
) -> Plan<'a> {
let (new_min, new_max) = bounds.effective_range(new.min, new.max);
let (old_min, old_max) = bounds.effective_range(old.min, old.max);
let clamped_date = new.effective_date(bounds);
warn_if_clamped(new, new_min, new_max, clamped_date, bounds);
let mut plan = Plan::new();
if old.style != new.style {
plan.push(Setter::DatePickerStyle(new.style));
}
let min_changed = old_min != new_min;
let max_changed = old_max != new_max;
let min_setter = min_changed.then(|| Setter::MinDate(new.min.map(|_| new_min)));
let max_setter = max_changed.then(|| Setter::MaxDate(new.max.map(|_| new_max)));
if new_max >= old_max {
plan.extend(max_setter);
plan.extend(min_setter);
} else {
plan.extend(min_setter);
plan.extend(max_setter);
}
if let Some(date) = clamped_date {
let drifted = observed.is_some_and(|platform| platform != date);
if old.date != new.date || drifted || min_changed || max_changed {
plan.push(Setter::Date(date));
}
}
if old.enabled != new.enabled {
plan.push(Setter::Enabled(new.enabled));
}
if old.tint != new.tint {
plan.push(Setter::DatePickerTint(new.tint));
}
if old.text_color != new.text_color {
plan.push(Setter::DatePickerTextColor(new.text_color));
}
if old.content_description != new.content_description {
plan.push(Setter::ContentDescription(
new.content_description.as_deref(),
));
}
plan
}
}
static WARN_CLAMPED: AtomicBool = AtomicBool::new(false);
fn warn_if_clamped(
new: &DatePickerProps,
new_min: CivilDate,
new_max: CivilDate,
clamped_date: Option<CivilDate>,
bounds: Bounds,
) {
let mut clamped: Vec<String> = Vec::new();
if let Some(min) = new.min
&& min != new_min
{
clamped.push(format!("min {min} -> {new_min}"));
}
if let Some(max) = new.max
&& max != new_max
{
clamped.push(format!("max {max} -> {new_max}"));
}
if let (Some(date), Some(clamped_date)) = (new.date, clamped_date)
&& date != clamped_date
{
clamped.push(format!("date {date} -> {clamped_date}"));
}
if clamped.is_empty() {
return;
}
if !WARN_CLAMPED.swap(true, Ordering::Relaxed) {
log::warn!(
"frust-native-widgets: date_picker slot {} — {} outside this platform's own range \
{}..{}, clamped into it (this warning is logged once per process)",
new.slot,
clamped.join(", "),
bounds.floor,
bounds.ceiling
);
}
}
fn date_field(params: &Params<'_>, key: &str) -> Option<CivilDate> {
params.int(key).and_then(unpack_date)
}
fn clamp(date: CivilDate, min: Option<CivilDate>, max: Option<CivilDate>) -> CivilDate {
let floored = min.map_or(date, |min| date.max(min));
max.map_or(floored, |max| floored.min(max))
}
pub(crate) fn wire(date: CivilDate) -> i64 {
pack_date(date)
}
pub(crate) fn decode_event(
observed: &mut Option<CivilDate>,
event: NativeEvent,
) -> Option<EventPayload> {
if event.kind != EVENT_KIND_DATE {
return None;
}
let date = unpack_date(event.detail)?;
*observed = Some(date);
Some(EventPayload::Date(date))
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
pub(crate) mod foundation {
use objc2::rc::Retained;
use objc2_foundation::{
NSCalendar, NSCalendarIdentifierGregorian, NSCalendarUnit, NSDate, NSDateComponents,
};
use super::CivilDate;
fn gregorian() -> Option<Retained<NSCalendar>> {
let identifier = unsafe { NSCalendarIdentifierGregorian };
NSCalendar::calendarWithIdentifier(identifier)
}
pub(crate) fn ns_date(date: CivilDate) -> Option<Retained<NSDate>> {
let calendar = gregorian()?;
let components = NSDateComponents::new();
components.setYear(date.year as isize);
components.setMonth(isize::from(date.month));
components.setDay(isize::from(date.day));
components.setHour(12);
calendar.dateFromComponents(&components)
}
pub(crate) fn civil_date(date: &NSDate) -> Option<CivilDate> {
let calendar = gregorian()?;
if calendar.component_fromDate(NSCalendarUnit::Era, date) != 1 {
return None;
}
let year = calendar.component_fromDate(NSCalendarUnit::Year, date);
let month = calendar.component_fromDate(NSCalendarUnit::Month, date);
let day = calendar.component_fromDate(NSCalendarUnit::Day, date);
CivilDate::new(
i32::try_from(year).ok()?,
u8::try_from(month).ok()?,
u8::try_from(day).ok()?,
)
}
pub(crate) fn optional_ns_date(date: Option<CivilDate>) -> Option<Retained<NSDate>> {
date.and_then(ns_date)
}
}
#[cfg(target_os = "android")]
pub(crate) mod platform {
use std::sync::Once;
use jni::objects::{JObject, JValue};
use jni::refs::Global;
use jni::{jni_sig, jni_str};
use super::{Bounds, CivilDate, DatePicker, DatePickerProps, DatePickerStyle, decode_event};
use crate::NativeWidgetError;
use crate::android::{NativeCtx, NativeView};
use crate::controls::platform::{FRAME_CAPACITY, apply as apply_shared};
use crate::controls::{Plan, Setter};
use crate::events::EventPayload;
use crate::runtime::{NativeEvent, NativeWidget, Params};
const CLASS: &str = "android.widget.DatePicker";
const R_STYLE_CLASS: &str = "android.R$style";
const CALENDAR_CLASS: &str = "java.util.Calendar";
const PLATFORM_MIN: CivilDate = CivilDate::from_parts(1900, 1, 1);
const PLATFORM_MAX: CivilDate = CivilDate::from_parts(2100, 12, 31);
const BOUNDS: Bounds = Bounds {
floor: PLATFORM_MIN,
ceiling: PLATFORM_MAX,
};
fn new_date_picker<'local>(
ctx: &mut NativeCtx<'local, '_>,
style: DatePickerStyle,
) -> Result<JObject<'local>, NativeWidgetError> {
let (field, spinner) = match style {
DatePickerStyle::Inline => (jni_str!("Widget_Material_DatePicker"), false),
DatePickerStyle::Compact | DatePickerStyle::Wheels => {
(jni_str!("Widget_DatePicker"), true)
}
};
let styles = ctx.class(R_STYLE_CLASS)?;
let style_res = match ctx.run_jni(&format!("android.R.style.{field}"), |env| {
env.get_static_field(&styles, field, jni_sig!("I"))?.i()
}) {
Ok(style_res) => style_res,
Err(error) => {
log::warn!(
"frust-native-widgets: {error} — building the DatePicker with the theme's own \
default mode instead"
);
return ctx.new_view(CLASS);
}
};
let class = ctx.class(CLASS)?;
let context = ctx.context()?;
let view = ctx.run_jni("new DatePicker(Context, AttributeSet, int, int)", |env| {
env.new_object(
&class,
jni_sig!("(Landroid/content/Context;Landroid/util/AttributeSet;II)V"),
&[
JValue::Object(context),
JValue::Object(&JObject::null()),
JValue::Int(0),
JValue::Int(style_res),
],
)
})?;
if spinner {
ctx.call_void(
&view,
jni_str!("setCalendarViewShown"),
jni_sig!("(Z)V"),
&[JValue::Bool(false)],
)?;
ctx.call_void(
&view,
jni_str!("setSpinnersShown"),
jni_sig!("(Z)V"),
&[JValue::Bool(true)],
)?;
}
Ok(view)
}
fn current_date(
ctx: &mut NativeCtx<'_, '_>,
view: &JObject<'_>,
) -> Result<CivilDate, NativeWidgetError> {
let (year, month0, day) = ctx.run_jni("DatePicker.get{Year,Month,DayOfMonth}", |env| {
let year = env
.call_method(view, jni_str!("getYear"), jni_sig!("()I"), &[])?
.i()?;
let month0 = env
.call_method(view, jni_str!("getMonth"), jni_sig!("()I"), &[])?
.i()?;
let day = env
.call_method(view, jni_str!("getDayOfMonth"), jni_sig!("()I"), &[])?
.i()?;
Ok((year, month0, day))
})?;
u8::try_from(month0 + 1)
.ok()
.zip(u8::try_from(day).ok())
.and_then(|(month, day)| CivilDate::new(year, month, day))
.ok_or_else(|| {
NativeWidgetError::Platform(format!(
"android native-widgets: DatePicker reported {year}-{month0}(0-based)-{day}, \
not a supported date"
))
})
}
fn epoch_millis(
ctx: &mut NativeCtx<'_, '_>,
date: CivilDate,
) -> Result<i64, NativeWidgetError> {
let class = ctx.class(CALENDAR_CLASS)?;
ctx.run_jni("Calendar.getTimeInMillis", |env| {
let calendar = env
.call_static_method(
&class,
jni_str!("getInstance"),
jni_sig!("()Ljava/util/Calendar;"),
&[],
)?
.l()?;
env.call_method(&calendar, jni_str!("clear"), jni_sig!("()V"), &[])?
.v()?;
env.call_method(
&calendar,
jni_str!("set"),
jni_sig!("(III)V"),
&[
JValue::Int(date.year),
JValue::Int(i32::from(date.month) - 1),
JValue::Int(i32::from(date.day)),
],
)?
.v()?;
env.call_method(&calendar, jni_str!("getTimeInMillis"), jni_sig!("()J"), &[])?
.j()
})
}
pub(crate) struct DatePickerState {
view: Global<JObject<'static>>,
observed: Option<CivilDate>,
}
impl NativeWidget for DatePicker {
type Props = DatePickerProps;
type State = DatePickerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
DatePickerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let view = new_date_picker(ctx, props.style)?;
let mut default = DatePickerProps::platform_default(props.slot);
default.style = props.style;
default.date = props.date;
let plan = DatePickerProps::plan(&default, props, None, BOUNDS);
ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &view, &plan))?;
let date = match props.effective_date(BOUNDS) {
Some(date) => date,
None => current_date(ctx, &view)?,
};
let listener = ctx.new_listener(props.slot)?;
ctx.init_date_picker(&view, date, &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]),
DatePickerState {
view: retained,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = DatePickerProps::plan(old, new, state.observed, BOUNDS);
let applied = ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &state.view, &plan));
if applied.is_ok() {
state.observed = None;
}
applied
}
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> {
ctx.set_on_date_changed_listener(&state.view, &JObject::null())
}
}
fn apply_all(
ctx: &mut NativeCtx<'_, '_>,
view: &JObject<'_>,
plan: &Plan<'_>,
) -> Result<(), NativeWidgetError> {
for setter in plan {
apply(ctx, view, setter)?;
}
Ok(())
}
fn apply(
ctx: &mut NativeCtx<'_, '_>,
view: &JObject<'_>,
setter: &Setter<'_>,
) -> Result<(), NativeWidgetError> {
match *setter {
Setter::Date(date) => ctx.call_void(
view,
jni_str!("updateDate"),
jni_sig!("(III)V"),
&[
JValue::Int(date.year),
JValue::Int(i32::from(date.month) - 1),
JValue::Int(i32::from(date.day)),
],
),
Setter::MinDate(min) => {
let millis = epoch_millis(ctx, min.unwrap_or(PLATFORM_MIN))?;
ctx.call_void(
view,
jni_str!("setMinDate"),
jni_sig!("(J)V"),
&[JValue::Long(millis)],
)
}
Setter::MaxDate(max) => {
let millis = epoch_millis(ctx, max.unwrap_or(PLATFORM_MAX))?;
ctx.call_void(
view,
jni_str!("setMaxDate"),
jni_sig!("(J)V"),
&[JValue::Long(millis)],
)
}
Setter::DatePickerStyle(style) => {
warn_style_unsupported(style);
Ok(())
}
Setter::DatePickerTint(_) | Setter::DatePickerTextColor(_) => {
log_no_colour_api();
Ok(())
}
Setter::Enabled(_) | Setter::ContentDescription(_) => apply_shared(ctx, view, setter),
ref other => {
log::warn!(
"frust-native-widgets: control 'date_picker' planned a setter its Android \
arm does not implement ({other:?}) — ignored"
);
Ok(())
}
}
}
pub(crate) fn warn_style_unsupported(style: DatePickerStyle) {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
log::warn!(
"frust-native-widgets: Android's DatePicker bakes its spinner/calendar mode into \
the style it was constructed with, so a later style change to {style:?} is not \
applied — the picker keeps the mode it was created in"
);
});
}
fn log_no_colour_api() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
log::debug!(
"frust-native-widgets: Android's DatePicker exposes no tint or text-colour setter \
— its colours come from the (night-qualified) construction theme only"
);
});
}
}
#[cfg(target_os = "ios")]
pub(crate) mod platform {
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2_ui_kit::{UIDatePicker, UIDatePickerMode, UIDatePickerStyle};
use super::foundation::{ns_date, optional_ns_date};
use super::{Bounds, DatePicker, DatePickerProps, DatePickerStyle, KIND, 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 DatePickerState {
view: Retained<UIDatePicker>,
target: Retained<FrustNativeControlTarget>,
observed: Option<super::CivilDate>,
}
impl NativeWidget for DatePicker {
type Props = DatePickerProps;
type State = DatePickerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
DatePickerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = UIDatePicker::new(mtm);
let default = DatePickerProps::platform_default(props.slot);
view.setDatePickerMode(UIDatePickerMode::Date);
view.setPreferredDatePickerStyle(ui_style(default.style));
let plan = DatePickerProps::plan(&default, props, None, Bounds::APPLE);
apply_all(mtm, &view, &plan);
let target = FrustNativeControlTarget::attach_date_picker(mtm, props.slot, &view);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
DatePickerState {
view,
target,
observed: None,
},
))
}
fn update(
ctx: &mut NativeCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
let plan = DatePickerProps::plan(old, new, state.observed, Bounds::APPLE);
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_date_picker(&state.view);
Ok(())
}
}
fn ui_style(style: DatePickerStyle) -> UIDatePickerStyle {
match style {
DatePickerStyle::Compact => UIDatePickerStyle::Compact,
DatePickerStyle::Wheels => UIDatePickerStyle::Wheels,
DatePickerStyle::Inline => UIDatePickerStyle::Inline,
}
}
fn apply_all(mtm: MainThreadMarker, view: &UIDatePicker, plan: &Plan<'_>) {
for setter in plan {
apply(mtm, view, setter);
}
}
fn apply(mtm: MainThreadMarker, view: &UIDatePicker, setter: &Setter<'_>) {
match *setter {
Setter::Date(date) => match ns_date(date) {
Some(ns) => view.setDate(&ns),
None => log::warn!(
"frust-native-widgets: iOS could not build an NSDate for {date} — not applied"
),
},
Setter::MinDate(min) => view.setMinimumDate(optional_ns_date(min).as_deref()),
Setter::MaxDate(max) => view.setMaximumDate(optional_ns_date(max).as_deref()),
Setter::DatePickerStyle(style) => view.setPreferredDatePickerStyle(ui_style(style)),
Setter::Enabled(enabled) => view.setEnabled(enabled),
Setter::DatePickerTint(argb) => {
let color = platform::optional_ui_color(argb);
unsafe { view.setTintColor(color.as_deref()) };
}
Setter::DatePickerTextColor(argb) => {
log::debug!(
"frust-native-widgets: iOS DatePickerTextColor({argb:?}) not applied — \
UIDatePicker exposes no public text colour"
);
}
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::{
NSColor, NSDatePicker, NSDatePickerElementFlags, NSDatePickerMode,
NSDatePickerStyle as AppKitStyle,
};
use super::foundation::{ns_date, optional_ns_date};
use super::{Bounds, DatePicker, DatePickerProps, DatePickerStyle, KIND, 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_DATE, EventPayload};
use crate::runtime::{NativeEvent, NativeWidget, Params};
pub(crate) struct DatePickerState {
view: Retained<NSDatePicker>,
target: Retained<FrustNativeControlTarget>,
observed: Option<super::CivilDate>,
}
impl NativeWidget for DatePicker {
type Props = DatePickerProps;
type State = DatePickerState;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
DatePickerProps::decode(params)
}
fn create(
ctx: &mut NativeCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let mtm = ctx.mtm();
let view = NSDatePicker::new(mtm);
let default = DatePickerProps::platform_default(props.slot);
view.setDatePickerMode(NSDatePickerMode::Single);
view.setDatePickerElements(NSDatePickerElementFlags::YearMonthDay);
set_style(&view, default.style);
let plan = DatePickerProps::plan(&default, props, None, Bounds::APPLE);
apply_all(&view, &plan);
let target = FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_DATE);
let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
Ok((
handle,
DatePickerState {
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,
&DatePickerProps::plan(old, new, state.observed, Bounds::APPLE),
);
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 set_style(view: &NSDatePicker, style: DatePickerStyle) {
let (appkit, overlay) = match style {
DatePickerStyle::Compact => (AppKitStyle::TextFieldAndStepper, true),
DatePickerStyle::Wheels => (AppKitStyle::TextFieldAndStepper, false),
DatePickerStyle::Inline => (AppKitStyle::ClockAndCalendar, false),
};
view.setDatePickerStyle(appkit);
view.setPresentsCalendarOverlay(overlay);
}
fn apply_all(view: &NSDatePicker, plan: &Plan<'_>) {
for setter in plan {
apply(view, setter);
}
}
fn apply(view: &NSDatePicker, setter: &Setter<'_>) {
match *setter {
Setter::Date(date) => match ns_date(date) {
Some(ns) => view.setDateValue(&ns),
None => log::warn!(
"frust-native-widgets: macOS could not build an NSDate for {date} — not \
applied"
),
},
Setter::MinDate(min) => view.setMinDate(optional_ns_date(min).as_deref()),
Setter::MaxDate(max) => view.setMaxDate(optional_ns_date(max).as_deref()),
Setter::DatePickerStyle(style) => set_style(view, style),
Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
Setter::DatePickerTint(argb) => platform::set_tint(view, "DatePickerTint", argb),
Setter::DatePickerTextColor(argb) => {
let color = argb.map_or_else(NSColor::controlTextColor, platform::ns_color);
view.setTextColor(&color);
}
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::runtime::with_identity;
fn date(year: i32, month: u8, day: u8) -> CivilDate {
CivilDate::new(year, month, day).expect("a real date")
}
fn decode(body: &str) -> DatePickerProps {
let raw = with_identity(KIND, 5, body);
DatePickerProps::decode(&Params::new(&raw)).expect("decodes")
}
fn field(key: &str, value: CivilDate) -> String {
format!("\"{key}\":{}", wire(value))
}
fn android_bounds() -> Bounds {
Bounds {
floor: date(1900, 1, 1),
ceiling: date(2100, 12, 31),
}
}
#[test]
fn civil_date_validates_every_field_and_the_month_length() {
assert!(CivilDate::new(1, 1, 1).is_some());
assert!(CivilDate::new(9999, 12, 31).is_some());
assert!(CivilDate::new(0, 1, 1).is_none(), "year 0");
assert!(CivilDate::new(10_000, 1, 1).is_none(), "year 10000");
assert!(CivilDate::new(-1, 1, 1).is_none(), "negative year");
assert!(CivilDate::new(2026, 0, 1).is_none(), "month 0");
assert!(CivilDate::new(2026, 13, 1).is_none(), "month 13");
assert!(CivilDate::new(2026, 1, 0).is_none(), "day 0");
assert!(CivilDate::new(2026, 1, 32).is_none(), "day 32");
assert!(CivilDate::new(2026, 4, 31).is_none(), "31 April");
assert!(CivilDate::new(2024, 2, 29).is_some(), "leap year");
assert!(CivilDate::new(2000, 2, 29).is_some(), "400-year leap");
assert!(CivilDate::new(1900, 2, 29).is_none(), "century non-leap");
assert!(CivilDate::new(2026, 2, 29).is_none(), "common year");
assert_eq!(CivilDate::MIN, date(1, 1, 1));
assert_eq!(CivilDate::MAX, date(9999, 12, 31));
}
#[test]
fn civil_dates_order_chronologically_and_display_as_iso_8601() {
assert!(date(2026, 1, 31) < date(2026, 2, 1));
assert!(date(2025, 12, 31) < date(2026, 1, 1));
assert_eq!(date(7, 3, 9).to_string(), "0007-03-09");
assert_eq!(date(2026, 9, 29).to_string(), "2026-09-29");
}
#[test]
fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
let props = decode("");
assert_eq!(props, DatePickerProps::platform_default(5));
assert!(
DatePickerProps::plan(
&DatePickerProps::platform_default(5),
&props,
None,
Bounds::APPLE
)
.is_empty()
);
}
#[test]
fn dates_decode_from_their_packed_wire_integers() {
let props = decode(&format!(
"{},{},{},\"style\":\"inline\",\"enabled\":false,\"tint\":7,\"textColor\":8",
field(DATE, date(2026, 9, 29)),
field(MIN_DATE, date(2026, 1, 1)),
field(MAX_DATE, date(2026, 12, 31)),
));
assert_eq!(props.date, Some(date(2026, 9, 29)));
assert_eq!(props.min, Some(date(2026, 1, 1)));
assert_eq!(props.max, Some(date(2026, 12, 31)));
assert_eq!(props.style, DatePickerStyle::Inline);
assert!(!props.enabled);
assert_eq!(props.tint, Some(7));
assert_eq!(props.text_color, Some(8));
}
#[test]
fn an_invalid_wire_date_decodes_as_absent() {
let props = decode(&format!("\"date\":{}", (2026 << 16) | (4 << 8) | 31));
assert_eq!(props.date, None);
let negative = decode("\"minDate\":-5,\"maxDate\":0");
assert_eq!((negative.min, negative.max), (None, None));
}
#[test]
fn an_unknown_style_decodes_to_compact() {
assert_eq!(
decode("\"style\":\"sideways\"").style,
DatePickerStyle::Compact
);
assert_eq!(
decode("\"style\":\"wheels\"").style,
DatePickerStyle::Wheels
);
}
#[test]
fn the_date_is_clamped_into_the_range_on_decode() {
let before = decode(&format!(
"{},{}",
field(DATE, date(2020, 5, 5)),
field(MIN_DATE, date(2026, 1, 1))
));
assert_eq!(before.date, Some(date(2026, 1, 1)));
let after = decode(&format!(
"{},{}",
field(DATE, date(2030, 5, 5)),
field(MAX_DATE, date(2026, 12, 31))
));
assert_eq!(after.date, Some(date(2026, 12, 31)));
}
#[test]
fn an_inverted_range_degrades_to_the_single_day_min() {
let props = decode(&format!(
"{},{},{}",
field(DATE, date(2026, 6, 1)),
field(MIN_DATE, date(2026, 12, 1)),
field(MAX_DATE, date(2026, 1, 1)),
));
assert_eq!(props.min, Some(date(2026, 12, 1)));
assert_eq!(props.max, Some(date(2026, 12, 1)));
assert_eq!(props.date, Some(date(2026, 12, 1)));
}
#[test]
fn the_create_plan_sets_exactly_the_non_default_fields_in_order() {
let props = decode(&format!(
"{},{},{},\"style\":\"wheels\",\"enabled\":false,\"tint\":1,\"textColor\":2,\
\"contentDescription\":\"birthday\"",
field(DATE, date(2026, 9, 29)),
field(MIN_DATE, date(2000, 1, 1)),
field(MAX_DATE, date(2030, 12, 31)),
));
assert_eq!(
DatePickerProps::plan(
&DatePickerProps::platform_default(5),
&props,
None,
Bounds::APPLE
),
vec![
Setter::DatePickerStyle(DatePickerStyle::Wheels),
Setter::MinDate(Some(date(2000, 1, 1))),
Setter::MaxDate(Some(date(2030, 12, 31))),
Setter::Date(date(2026, 9, 29)),
Setter::Enabled(false),
Setter::DatePickerTint(Some(1)),
Setter::DatePickerTextColor(Some(2)),
Setter::ContentDescription(Some("birthday")),
]
);
}
#[test]
fn a_date_change_alone_plans_exactly_one_setter() {
let old = decode(&field(DATE, date(2026, 9, 29)));
let new = decode(&field(DATE, date(2026, 9, 30)));
assert_eq!(
DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
vec![Setter::Date(date(2026, 9, 30))]
);
assert!(
DatePickerProps::plan(&new, &new, None, Bounds::APPLE).is_empty(),
"unchanged props plan nothing — the zero-FFI property"
);
}
#[test]
fn a_pick_the_app_confirmed_writes_the_date_back_once() {
let before = decode(&field(DATE, date(2026, 9, 29)));
let after = decode(&field(DATE, date(2026, 10, 3)));
assert_eq!(
DatePickerProps::plan(&before, &after, Some(date(2026, 10, 3)), Bounds::APPLE),
vec![Setter::Date(date(2026, 10, 3))]
);
}
#[test]
fn a_platform_drift_is_written_back_even_when_the_props_date_did_not_change() {
let old = decode(&field(DATE, date(2026, 9, 29)));
let new = decode(&format!(
"{},\"enabled\":false",
field(DATE, date(2026, 9, 29))
));
assert_eq!(
DatePickerProps::plan(&old, &new, Some(date(2026, 10, 3)), Bounds::APPLE),
vec![Setter::Date(date(2026, 9, 29)), Setter::Enabled(false)],
"the app rejected the pick: the confirmed date is re-asserted"
);
assert_eq!(
DatePickerProps::plan(&old, &new, Some(date(2026, 9, 29)), Bounds::APPLE),
vec![Setter::Enabled(false)],
"an observed date matching the app's is no drift"
);
}
#[test]
fn a_range_that_moves_later_writes_the_ceiling_first() {
let old = decode(&format!(
"{},{},{}",
field(DATE, date(2026, 1, 15)),
field(MIN_DATE, date(2026, 1, 1)),
field(MAX_DATE, date(2026, 1, 31)),
));
let new = decode(&format!(
"{},{},{}",
field(DATE, date(2026, 3, 15)),
field(MIN_DATE, date(2026, 3, 1)),
field(MAX_DATE, date(2026, 3, 31)),
));
assert_eq!(
DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
vec![
Setter::MaxDate(Some(date(2026, 3, 31))),
Setter::MinDate(Some(date(2026, 3, 1))),
Setter::Date(date(2026, 3, 15)),
],
"floor-first would pass through [March 1, January 31] — the \
non-crossing case: the Apple bounds' unbounded ceiling never \
enters the comparison"
);
}
#[test]
fn a_range_that_moves_earlier_writes_the_floor_first() {
let old = decode(&format!(
"{},{}",
field(MIN_DATE, date(2026, 3, 1)),
field(MAX_DATE, date(2026, 3, 31)),
));
let new = decode(&format!(
"{},{}",
field(MIN_DATE, date(2026, 1, 1)),
field(MAX_DATE, date(2026, 1, 31)),
));
assert_eq!(
DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
vec![
Setter::MinDate(Some(date(2026, 1, 1))),
Setter::MaxDate(Some(date(2026, 1, 31))),
],
"ceiling-first would pass through [March 1, January 31]"
);
}
#[test]
fn a_none_ceiling_crosses_below_an_explicit_old_ceiling_on_android_only() {
let old = decode(&format!(
"{},{}",
field(MIN_DATE, date(2095, 1, 1)),
field(MAX_DATE, date(2150, 1, 1)),
));
let new = decode(&field(MIN_DATE, date(2050, 1, 1)));
assert_eq!(new.max, None);
assert_eq!(
DatePickerProps::plan(&old, &new, None, android_bounds()),
vec![Setter::MinDate(Some(date(2050, 1, 1)))],
"old.max's clamped ceiling (2100-12-31) equals new's resolved \
None default — no MaxDate setter at all, only the floor moves"
);
assert_eq!(
DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
vec![
Setter::MaxDate(None),
Setter::MinDate(Some(date(2050, 1, 1))),
],
"Apple's None ceiling (CivilDate::MAX) is still >= the old one \
— a widen, so the ceiling moves first; the two arms disagree \
on order for the exact same props"
);
}
#[test]
fn planning_with_an_inverted_resolved_range_does_not_panic() {
let old = DatePickerProps::platform_default(7);
let new = decode(&field(MIN_DATE, date(2150, 1, 1)));
let plan = DatePickerProps::plan(&old, &new, None, android_bounds());
assert_eq!(plan, vec![Setter::MinDate(Some(date(2100, 12, 31)))]);
}
#[test]
fn an_explicit_min_above_androids_ceiling_clamps_the_bound_and_the_date() {
let old = DatePickerProps::platform_default(9);
let new = decode(&format!(
"{},{}",
field(MIN_DATE, date(2150, 1, 1)),
field(DATE, date(2150, 6, 1)),
));
assert_eq!(
DatePickerProps::plan(&old, &new, None, android_bounds()),
vec![
Setter::MinDate(Some(date(2100, 12, 31))),
Setter::Date(date(2100, 12, 31)),
],
"an explicit min above Android's own ceiling clamps into it, \
and a date later than the clamped ceiling clamps with it — the \
platform is never asked for a date outside the range it will \
hold"
);
}
#[test]
fn an_explicit_max_below_androids_floor_clamps_the_bound() {
let old = DatePickerProps::platform_default(9);
let new = decode(&field(MAX_DATE, date(1850, 1, 1)));
assert_eq!(
DatePickerProps::plan(&old, &new, None, android_bounds()),
vec![Setter::MaxDate(Some(date(1900, 1, 1)))],
"an explicit max below Android's own floor clamps up into it"
);
}
#[test]
fn the_same_out_of_androids_range_bounds_are_not_clamped_on_apple() {
let old = DatePickerProps::platform_default(9);
let min_only = decode(&field(MIN_DATE, date(2150, 1, 1)));
assert_eq!(
DatePickerProps::plan(&old, &min_only, None, Bounds::APPLE),
vec![Setter::MinDate(Some(date(2150, 1, 1)))],
"2150-01-01 is well inside CivilDate::MIN..CivilDate::MAX — \
Apple's own unbounded range — so nothing clamps it"
);
let max_only = decode(&field(MAX_DATE, date(1850, 1, 1)));
assert_eq!(
DatePickerProps::plan(&old, &max_only, None, Bounds::APPLE),
vec![Setter::MaxDate(Some(date(1850, 1, 1)))],
"1850-01-01 is well inside CivilDate::MIN..CivilDate::MAX, so \
nothing clamps it on Apple either"
);
}
#[test]
fn a_range_change_reasserts_an_unchanged_date() {
let old = decode(&format!(
"{},{}",
field(DATE, date(2026, 6, 15)),
field(MIN_DATE, date(2026, 1, 1))
));
let new = decode(&format!(
"{},{}",
field(DATE, date(2026, 6, 15)),
field(MIN_DATE, date(2026, 6, 1))
));
assert_eq!(
DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
vec![
Setter::MinDate(Some(date(2026, 6, 1))),
Setter::Date(date(2026, 6, 15)),
]
);
}
#[test]
fn clearing_a_bound_and_the_colours_plans_the_nullable_setters() {
let set = decode(&format!(
"{},\"tint\":1,\"textColor\":2",
field(MAX_DATE, date(2026, 12, 31))
));
let cleared = decode("");
assert_eq!(
DatePickerProps::plan(&set, &cleared, None, Bounds::APPLE),
vec![
Setter::MaxDate(None),
Setter::DatePickerTint(None),
Setter::DatePickerTextColor(None),
]
);
}
#[test]
fn every_planned_setter_reports_its_documented_tier() {
let props = decode(&format!(
"{},{},{},\"style\":\"inline\",\"enabled\":false,\"tint\":1,\"textColor\":2,\
\"contentDescription\":\"d\"",
field(DATE, date(2026, 9, 29)),
field(MIN_DATE, date(2000, 1, 1)),
field(MAX_DATE, date(2030, 12, 31)),
));
for setter in DatePickerProps::plan(
&DatePickerProps::platform_default(5),
&props,
None,
Bounds::APPLE,
) {
let expected = if matches!(
setter,
Setter::MinDate(_) | Setter::MaxDate(_) | Setter::DatePickerStyle(_)
) {
Tier::Relayout
} else {
Tier::Cheap
};
assert_eq!(setter.tier(), expected, "{setter:?}");
}
}
#[test]
fn effective_date_with_android_bounds_clamps_out_of_range_dates() {
let props = decode(&field(DATE, date(2150, 6, 1)));
let clamped = props.effective_date(android_bounds());
assert_eq!(
clamped,
Some(date(2100, 12, 31)),
"2150-06-01 is outside Android's 1900-01-01..2100-12-31 range, \
clamped to the ceiling"
);
}
#[test]
fn effective_date_with_android_bounds_and_explicit_min_still_clamps() {
let props = decode(&format!(
"{},{}",
field(DATE, date(2150, 6, 1)),
field(MIN_DATE, date(2050, 1, 1))
));
let clamped = props.effective_date(android_bounds());
assert_eq!(
clamped,
Some(date(2100, 12, 31)),
"with an explicit min, the date 2150-06-01 still clamps to \
Android's ceiling 2100-12-31"
);
}
#[test]
fn effective_date_is_identity_with_apple_bounds() {
let date_to_test = date(2150, 6, 1);
let props = decode(&field(DATE, date_to_test));
let result = props.effective_date(Bounds::APPLE);
assert_eq!(
result,
Some(date_to_test),
"2150-06-01 is inside Apple's CivilDate::MIN..CivilDate::MAX, \
so effective_date returns it unchanged"
);
}
#[test]
fn effective_date_equals_the_date_setter_in_plan_table_driven() {
struct TestCase {
name: &'static str,
bounds: Bounds,
min: Option<CivilDate>,
max: Option<CivilDate>,
date_in_range: CivilDate,
date_out_of_range: CivilDate,
}
let test_cases = vec![
TestCase {
name: "android_no_bounds",
bounds: android_bounds(),
min: None,
max: None,
date_in_range: date(2026, 6, 1),
date_out_of_range: date(2150, 6, 1), },
TestCase {
name: "android_min_only",
bounds: android_bounds(),
min: Some(date(2050, 1, 1)),
max: None,
date_in_range: date(2050, 6, 1),
date_out_of_range: date(1950, 6, 1), },
TestCase {
name: "android_max_only",
bounds: android_bounds(),
min: None,
max: Some(date(2050, 12, 31)),
date_in_range: date(2050, 6, 1),
date_out_of_range: date(2150, 6, 1), },
TestCase {
name: "android_both_explicit",
bounds: android_bounds(),
min: Some(date(2050, 1, 1)),
max: Some(date(2050, 12, 31)),
date_in_range: date(2050, 6, 1),
date_out_of_range: date(2150, 6, 1), },
TestCase {
name: "android_floor_collapsed",
bounds: android_bounds(),
min: Some(date(2150, 1, 1)), max: None,
date_in_range: date(2100, 12, 31), date_out_of_range: date(2026, 6, 1), },
TestCase {
name: "apple_no_bounds",
bounds: Bounds::APPLE,
min: None,
max: None,
date_in_range: date(2026, 6, 1),
date_out_of_range: date(9999, 12, 31), },
TestCase {
name: "apple_min_only",
bounds: Bounds::APPLE,
min: Some(date(2050, 1, 1)),
max: None,
date_in_range: date(2050, 6, 1),
date_out_of_range: date(1950, 6, 1), },
TestCase {
name: "apple_max_only",
bounds: Bounds::APPLE,
min: None,
max: Some(date(2050, 12, 31)),
date_in_range: date(2050, 6, 1),
date_out_of_range: date(2150, 6, 1), },
TestCase {
name: "apple_both_explicit",
bounds: Bounds::APPLE,
min: Some(date(2050, 1, 1)),
max: Some(date(2050, 12, 31)),
date_in_range: date(2050, 6, 1),
date_out_of_range: date(2150, 6, 1), },
TestCase {
name: "apple_floor_collapsed",
bounds: Bounds::APPLE,
min: Some(date(2150, 1, 1)), max: Some(date(2050, 12, 31)),
date_in_range: date(2050, 12, 31), date_out_of_range: date(2026, 6, 1), },
];
for tc in test_cases {
let props_in = decode(&format!(
"{}{}{}",
tc.min
.map(|m| format!("\"minDate\":{},", wire(m)))
.unwrap_or_default(),
tc.max
.map(|m| format!("\"maxDate\":{},", wire(m)))
.unwrap_or_default(),
field(DATE, tc.date_in_range)
));
let effective_in = props_in.effective_date(tc.bounds);
let plan_in = DatePickerProps::plan(
&DatePickerProps::platform_default(5),
&props_in,
None,
tc.bounds,
);
let date_setter_in = plan_in.iter().find_map(|s| {
if let Setter::Date(d) = s {
Some(*d)
} else {
None
}
});
assert_eq!(
date_setter_in, effective_in,
"{}: plan's Date setter matches effective_date for date inside range",
tc.name
);
let props_out = decode(&format!(
"{}{}{}",
tc.min
.map(|m| format!("\"minDate\":{},", wire(m)))
.unwrap_or_default(),
tc.max
.map(|m| format!("\"maxDate\":{},", wire(m)))
.unwrap_or_default(),
field(DATE, tc.date_out_of_range)
));
let effective_out = props_out.effective_date(tc.bounds);
let plan_out = DatePickerProps::plan(
&DatePickerProps::platform_default(5),
&props_out,
None,
tc.bounds,
);
let date_setter_out = plan_out.iter().find_map(|s| {
if let Setter::Date(d) = s {
Some(*d)
} else {
None
}
});
assert_eq!(
date_setter_out, effective_out,
"{}: plan's Date setter matches effective_date for date outside range (clamped)",
tc.name
);
let old_with_date = decode(&format!(
"{}{}{}",
tc.min
.map(|m| format!("\"minDate\":{},", wire(m)))
.unwrap_or_default(),
tc.max
.map(|m| format!("\"maxDate\":{},", wire(m)))
.unwrap_or_default(),
field(DATE, tc.date_in_range)
));
let new_with_same_date = old_with_date.clone();
let plan_unchanged =
DatePickerProps::plan(&old_with_date, &new_with_same_date, None, tc.bounds);
let has_date_setter = plan_unchanged.iter().any(|s| matches!(s, Setter::Date(_)));
assert!(
!has_date_setter,
"{}: unchanged date should not emit Date setter",
tc.name
);
}
}
fn picked(date: CivilDate) -> NativeEvent {
NativeEvent {
kind: EVENT_KIND_DATE,
detail: pack_date(date),
}
}
#[test]
fn a_user_pick_decodes_and_updates_the_drift_signal() {
let mut observed = None;
assert_eq!(
decode_event(&mut observed, picked(date(2026, 10, 3))),
Some(EventPayload::Date(date(2026, 10, 3)))
);
assert_eq!(observed, Some(date(2026, 10, 3)));
}
#[test]
fn an_invalid_report_decodes_to_nothing_and_leaves_observed_untouched() {
let mut observed = Some(date(2026, 1, 1));
let corrupt = NativeEvent {
kind: EVENT_KIND_DATE,
detail: (2026 << 16) | (2 << 8) | 30,
};
assert_eq!(decode_event(&mut observed, corrupt), None);
assert_eq!(observed, Some(date(2026, 1, 1)));
}
#[test]
fn a_misrouted_kind_decodes_to_nothing() {
let mut observed = None;
let selection = NativeEvent {
kind: crate::events::EVENT_KIND_SELECTION,
detail: pack_date(date(2026, 1, 1)),
};
assert_eq!(decode_event(&mut observed, selection), None);
assert_eq!(observed, None);
}
}