1use std::fmt;
123use std::sync::atomic::{AtomicBool, Ordering};
124
125use super::{
126 CONTENT_DESCRIPTION, ENABLED, Plan, Setter, TEXT_COLOR, TINT, color, owned_text, slot_of,
127};
128use crate::NativeWidgetError;
129use crate::events::{EVENT_KIND_DATE, EventPayload, pack_date, unpack_date};
130use crate::registry::SlotId;
131use crate::runtime::{NativeEvent, Params};
132
133pub(crate) const KIND: &str = "date_picker";
135
136pub(crate) const DATE: &str = "date";
139pub(crate) const MIN_DATE: &str = "minDate";
142pub(crate) const MAX_DATE: &str = "maxDate";
145pub(crate) const STYLE: &str = "style";
147
148#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
162pub struct CivilDate {
163 pub(crate) year: i32,
171 pub(crate) month: u8,
172 pub(crate) day: u8,
173}
174
175impl CivilDate {
176 pub const MIN: Self = Self::from_parts(1, 1, 1);
178
179 pub const MAX: Self = Self::from_parts(9999, 12, 31);
181
182 pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
185 let date = Self::from_parts(year, month, day);
186 date.is_valid().then_some(date)
187 }
188
189 pub fn is_valid(self) -> bool {
191 (Self::MIN.year..=Self::MAX.year).contains(&self.year)
192 && (1..=12).contains(&self.month)
193 && self.day >= 1
194 && self.day <= days_in_month(self.year, self.month)
195 }
196
197 pub const fn year(&self) -> i32 {
199 self.year
200 }
201
202 pub const fn month(&self) -> u8 {
204 self.month
205 }
206
207 pub const fn day(&self) -> u8 {
209 self.day
210 }
211
212 const fn from_parts(year: i32, month: u8, day: u8) -> Self {
219 Self { year, month, day }
220 }
221}
222
223impl fmt::Display for CivilDate {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
227 }
228}
229
230fn is_leap_year(year: i32) -> bool {
232 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
233}
234
235fn days_in_month(year: i32, month: u8) -> u8 {
238 match month {
239 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
240 4 | 6 | 9 | 11 => 30,
241 2 if is_leap_year(year) => 29,
242 2 => 28,
243 _ => 0,
244 }
245}
246
247#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
250pub(crate) enum DatePickerStyle {
251 #[default]
253 Compact,
254 Wheels,
256 Inline,
258}
259
260impl DatePickerStyle {
261 fn from_wire(raw: &str) -> Option<Self> {
263 match raw {
264 "compact" => Some(Self::Compact),
265 "wheels" => Some(Self::Wheels),
266 "inline" => Some(Self::Inline),
267 _ => None,
268 }
269 }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub(crate) struct Bounds {
282 floor: CivilDate,
283 ceiling: CivilDate,
284}
285
286impl Bounds {
287 pub(crate) const APPLE: Self = Self {
292 floor: CivilDate::MIN,
293 ceiling: CivilDate::MAX,
294 };
295
296 fn resolve_min(self, min: Option<CivilDate>) -> CivilDate {
298 min.unwrap_or(self.floor)
299 }
300
301 fn resolve_ceiling(self, max: Option<CivilDate>) -> CivilDate {
303 max.unwrap_or(self.ceiling)
304 }
305
306 fn effective_range(
317 self,
318 min: Option<CivilDate>,
319 max: Option<CivilDate>,
320 ) -> (CivilDate, CivilDate) {
321 let floor = self.resolve_min(min).clamp(self.floor, self.ceiling);
322 let ceiling = self.resolve_ceiling(max).clamp(self.floor, self.ceiling);
323 if floor > ceiling {
324 (ceiling, ceiling)
325 } else {
326 (floor, ceiling)
327 }
328 }
329}
330
331pub(crate) struct DatePicker;
333
334#[derive(Clone, Debug, PartialEq)]
336pub(crate) struct DatePickerProps {
337 pub(crate) slot: SlotId,
340 pub(crate) date: Option<CivilDate>,
343 pub(crate) min: Option<CivilDate>,
345 pub(crate) max: Option<CivilDate>,
348 pub(crate) style: DatePickerStyle,
350 pub(crate) enabled: bool,
352 pub(crate) tint: Option<i32>,
354 pub(crate) text_color: Option<i32>,
356 pub(crate) content_description: Option<String>,
358}
359
360impl DatePickerProps {
361 pub(crate) fn platform_default(slot: SlotId) -> Self {
367 Self {
368 slot,
369 date: None,
370 min: None,
371 max: None,
372 style: DatePickerStyle::Compact,
373 enabled: true,
374 tint: None,
375 text_color: None,
376 content_description: None,
377 }
378 }
379
380 pub(crate) fn effective_date(&self, bounds: Bounds) -> Option<CivilDate> {
385 let (min, max) = bounds.effective_range(self.min, self.max);
386 self.date.map(|date| date.clamp(min, max))
387 }
388
389 pub(crate) fn decode(params: &Params<'_>) -> Result<Self, NativeWidgetError> {
396 let min = date_field(params, MIN_DATE);
397 let max = match (min, date_field(params, MAX_DATE)) {
398 (Some(min), Some(max)) if max < min => Some(min),
399 (_, max) => max,
400 };
401 let date = date_field(params, DATE).map(|date| clamp(date, min, max));
402 Ok(Self {
403 slot: slot_of(params)?,
404 date,
405 min,
406 max,
407 style: params
408 .string(STYLE)
409 .and_then(|raw| DatePickerStyle::from_wire(&raw))
410 .unwrap_or_default(),
411 enabled: params.flag(ENABLED).unwrap_or(true),
412 tint: color(params, TINT),
413 text_color: color(params, TEXT_COLOR),
414 content_description: owned_text(params, CONTENT_DESCRIPTION),
415 })
416 }
417
418 pub(crate) fn plan<'a>(
432 old: &Self,
433 new: &'a Self,
434 observed: Option<CivilDate>,
435 bounds: Bounds,
436 ) -> Plan<'a> {
437 let (new_min, new_max) = bounds.effective_range(new.min, new.max);
438 let (old_min, old_max) = bounds.effective_range(old.min, old.max);
439 let clamped_date = new.effective_date(bounds);
440 warn_if_clamped(new, new_min, new_max, clamped_date, bounds);
441
442 let mut plan = Plan::new();
443 if old.style != new.style {
444 plan.push(Setter::DatePickerStyle(new.style));
445 }
446 let min_changed = old_min != new_min;
451 let max_changed = old_max != new_max;
452 let min_setter = min_changed.then(|| Setter::MinDate(new.min.map(|_| new_min)));
453 let max_setter = max_changed.then(|| Setter::MaxDate(new.max.map(|_| new_max)));
454 if new_max >= old_max {
459 plan.extend(max_setter);
460 plan.extend(min_setter);
461 } else {
462 plan.extend(min_setter);
463 plan.extend(max_setter);
464 }
465 if let Some(date) = clamped_date {
466 let drifted = observed.is_some_and(|platform| platform != date);
467 if old.date != new.date || drifted || min_changed || max_changed {
468 plan.push(Setter::Date(date));
469 }
470 }
471 if old.enabled != new.enabled {
472 plan.push(Setter::Enabled(new.enabled));
473 }
474 if old.tint != new.tint {
475 plan.push(Setter::DatePickerTint(new.tint));
476 }
477 if old.text_color != new.text_color {
478 plan.push(Setter::DatePickerTextColor(new.text_color));
479 }
480 if old.content_description != new.content_description {
481 plan.push(Setter::ContentDescription(
482 new.content_description.as_deref(),
483 ));
484 }
485 plan
486 }
487}
488
489static WARN_CLAMPED: AtomicBool = AtomicBool::new(false);
493
494fn warn_if_clamped(
498 new: &DatePickerProps,
499 new_min: CivilDate,
500 new_max: CivilDate,
501 clamped_date: Option<CivilDate>,
502 bounds: Bounds,
503) {
504 let mut clamped: Vec<String> = Vec::new();
505 if let Some(min) = new.min
506 && min != new_min
507 {
508 clamped.push(format!("min {min} -> {new_min}"));
509 }
510 if let Some(max) = new.max
511 && max != new_max
512 {
513 clamped.push(format!("max {max} -> {new_max}"));
514 }
515 if let (Some(date), Some(clamped_date)) = (new.date, clamped_date)
516 && date != clamped_date
517 {
518 clamped.push(format!("date {date} -> {clamped_date}"));
519 }
520 if clamped.is_empty() {
521 return;
522 }
523 if !WARN_CLAMPED.swap(true, Ordering::Relaxed) {
524 log::warn!(
525 "frust-native-widgets: date_picker slot {} — {} outside this platform's own range \
526 {}..{}, clamped into it (this warning is logged once per process)",
527 new.slot,
528 clamped.join(", "),
529 bounds.floor,
530 bounds.ceiling
531 );
532 }
533}
534
535fn date_field(params: &Params<'_>, key: &str) -> Option<CivilDate> {
539 params.int(key).and_then(unpack_date)
540}
541
542fn clamp(date: CivilDate, min: Option<CivilDate>, max: Option<CivilDate>) -> CivilDate {
544 let floored = min.map_or(date, |min| date.max(min));
545 max.map_or(floored, |max| floored.min(max))
546}
547
548pub(crate) fn wire(date: CivilDate) -> i64 {
551 pack_date(date)
552}
553
554pub(crate) fn decode_event(
566 observed: &mut Option<CivilDate>,
567 event: NativeEvent,
568) -> Option<EventPayload> {
569 if event.kind != EVENT_KIND_DATE {
570 return None;
571 }
572 let date = unpack_date(event.detail)?;
573 *observed = Some(date);
574 Some(EventPayload::Date(date))
575}
576
577#[cfg(any(target_os = "ios", target_os = "macos"))]
592pub(crate) mod foundation {
593 use objc2::rc::Retained;
594 use objc2_foundation::{
595 NSCalendar, NSCalendarIdentifierGregorian, NSCalendarUnit, NSDate, NSDateComponents,
596 };
597
598 use super::CivilDate;
599
600 fn gregorian() -> Option<Retained<NSCalendar>> {
602 let identifier = unsafe { NSCalendarIdentifierGregorian };
606 NSCalendar::calendarWithIdentifier(identifier)
607 }
608
609 pub(crate) fn ns_date(date: CivilDate) -> Option<Retained<NSDate>> {
612 let calendar = gregorian()?;
613 let components = NSDateComponents::new();
614 components.setYear(date.year as isize);
615 components.setMonth(isize::from(date.month));
616 components.setDay(isize::from(date.day));
617 components.setHour(12);
618 calendar.dateFromComponents(&components)
619 }
620
621 pub(crate) fn civil_date(date: &NSDate) -> Option<CivilDate> {
625 let calendar = gregorian()?;
626 if calendar.component_fromDate(NSCalendarUnit::Era, date) != 1 {
628 return None;
629 }
630 let year = calendar.component_fromDate(NSCalendarUnit::Year, date);
631 let month = calendar.component_fromDate(NSCalendarUnit::Month, date);
632 let day = calendar.component_fromDate(NSCalendarUnit::Day, date);
633 CivilDate::new(
634 i32::try_from(year).ok()?,
635 u8::try_from(month).ok()?,
636 u8::try_from(day).ok()?,
637 )
638 }
639
640 pub(crate) fn optional_ns_date(date: Option<CivilDate>) -> Option<Retained<NSDate>> {
643 date.and_then(ns_date)
644 }
645}
646
647#[cfg(target_os = "android")]
648pub(crate) mod platform {
649 use std::sync::Once;
693
694 use jni::objects::{JObject, JValue};
695 use jni::refs::Global;
696 use jni::{jni_sig, jni_str};
697
698 use super::{Bounds, CivilDate, DatePicker, DatePickerProps, DatePickerStyle, decode_event};
699 use crate::NativeWidgetError;
700 use crate::android::{NativeCtx, NativeView};
701 use crate::controls::platform::{FRAME_CAPACITY, apply as apply_shared};
702 use crate::controls::{Plan, Setter};
703 use crate::events::EventPayload;
704 use crate::runtime::{NativeEvent, NativeWidget, Params};
705
706 const CLASS: &str = "android.widget.DatePicker";
708 const R_STYLE_CLASS: &str = "android.R$style";
711 const CALENDAR_CLASS: &str = "java.util.Calendar";
713
714 const PLATFORM_MIN: CivilDate = CivilDate::from_parts(1900, 1, 1);
716 const PLATFORM_MAX: CivilDate = CivilDate::from_parts(2100, 12, 31);
718 const BOUNDS: Bounds = Bounds {
722 floor: PLATFORM_MIN,
723 ceiling: PLATFORM_MAX,
724 };
725
726 fn new_date_picker<'local>(
728 ctx: &mut NativeCtx<'local, '_>,
729 style: DatePickerStyle,
730 ) -> Result<JObject<'local>, NativeWidgetError> {
731 let (field, spinner) = match style {
732 DatePickerStyle::Inline => (jni_str!("Widget_Material_DatePicker"), false),
733 DatePickerStyle::Compact | DatePickerStyle::Wheels => {
734 (jni_str!("Widget_DatePicker"), true)
735 }
736 };
737 let styles = ctx.class(R_STYLE_CLASS)?;
738 let style_res = match ctx.run_jni(&format!("android.R.style.{field}"), |env| {
739 env.get_static_field(&styles, field, jni_sig!("I"))?.i()
740 }) {
741 Ok(style_res) => style_res,
742 Err(error) => {
743 log::warn!(
744 "frust-native-widgets: {error} — building the DatePicker with the theme's own \
745 default mode instead"
746 );
747 return ctx.new_view(CLASS);
748 }
749 };
750 let class = ctx.class(CLASS)?;
751 let context = ctx.context()?;
752 let view = ctx.run_jni("new DatePicker(Context, AttributeSet, int, int)", |env| {
753 env.new_object(
754 &class,
755 jni_sig!("(Landroid/content/Context;Landroid/util/AttributeSet;II)V"),
756 &[
757 JValue::Object(context),
758 JValue::Object(&JObject::null()),
759 JValue::Int(0),
760 JValue::Int(style_res),
761 ],
762 )
763 })?;
764 if spinner {
765 ctx.call_void(
766 &view,
767 jni_str!("setCalendarViewShown"),
768 jni_sig!("(Z)V"),
769 &[JValue::Bool(false)],
770 )?;
771 ctx.call_void(
772 &view,
773 jni_str!("setSpinnersShown"),
774 jni_sig!("(Z)V"),
775 &[JValue::Bool(true)],
776 )?;
777 }
778 Ok(view)
779 }
780
781 fn current_date(
784 ctx: &mut NativeCtx<'_, '_>,
785 view: &JObject<'_>,
786 ) -> Result<CivilDate, NativeWidgetError> {
787 let (year, month0, day) = ctx.run_jni("DatePicker.get{Year,Month,DayOfMonth}", |env| {
788 let year = env
789 .call_method(view, jni_str!("getYear"), jni_sig!("()I"), &[])?
790 .i()?;
791 let month0 = env
792 .call_method(view, jni_str!("getMonth"), jni_sig!("()I"), &[])?
793 .i()?;
794 let day = env
795 .call_method(view, jni_str!("getDayOfMonth"), jni_sig!("()I"), &[])?
796 .i()?;
797 Ok((year, month0, day))
798 })?;
799 u8::try_from(month0 + 1)
800 .ok()
801 .zip(u8::try_from(day).ok())
802 .and_then(|(month, day)| CivilDate::new(year, month, day))
803 .ok_or_else(|| {
804 NativeWidgetError::Platform(format!(
805 "android native-widgets: DatePicker reported {year}-{month0}(0-based)-{day}, \
806 not a supported date"
807 ))
808 })
809 }
810
811 fn epoch_millis(
814 ctx: &mut NativeCtx<'_, '_>,
815 date: CivilDate,
816 ) -> Result<i64, NativeWidgetError> {
817 let class = ctx.class(CALENDAR_CLASS)?;
818 ctx.run_jni("Calendar.getTimeInMillis", |env| {
819 let calendar = env
820 .call_static_method(
821 &class,
822 jni_str!("getInstance"),
823 jni_sig!("()Ljava/util/Calendar;"),
824 &[],
825 )?
826 .l()?;
827 env.call_method(&calendar, jni_str!("clear"), jni_sig!("()V"), &[])?
828 .v()?;
829 env.call_method(
830 &calendar,
831 jni_str!("set"),
832 jni_sig!("(III)V"),
833 &[
834 JValue::Int(date.year),
835 JValue::Int(i32::from(date.month) - 1),
836 JValue::Int(i32::from(date.day)),
837 ],
838 )?
839 .v()?;
840 env.call_method(&calendar, jni_str!("getTimeInMillis"), jni_sig!("()J"), &[])?
841 .j()
842 })
843 }
844
845 pub(crate) struct DatePickerState {
847 view: Global<JObject<'static>>,
850 observed: Option<CivilDate>,
854 }
855
856 impl NativeWidget for DatePicker {
857 type Props = DatePickerProps;
858 type State = DatePickerState;
859
860 fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
861 DatePickerProps::decode(params)
862 }
863
864 fn create(
865 ctx: &mut NativeCtx<'_, '_>,
866 props: &Self::Props,
867 ) -> Result<(NativeView, Self::State), NativeWidgetError> {
868 let view = new_date_picker(ctx, props.style)?;
869 let mut default = DatePickerProps::platform_default(props.slot);
873 default.style = props.style;
874 default.date = props.date;
875 let plan = DatePickerProps::plan(&default, props, None, BOUNDS);
876 ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &view, &plan))?;
877 let date = match props.effective_date(BOUNDS) {
881 Some(date) => date,
882 None => current_date(ctx, &view)?,
883 };
884 let listener = ctx.new_listener(props.slot)?;
887 ctx.init_date_picker(&view, date, &listener)?;
888 let handle = ctx.retain(&view)?;
889 let retained = ctx.retain(&view)?;
890 let listener_ref = ctx.retain(&listener)?;
891 Ok((
892 NativeView::with_extra(handle, vec![listener_ref]),
893 DatePickerState {
894 view: retained,
895 observed: None,
896 },
897 ))
898 }
899
900 fn update(
901 ctx: &mut NativeCtx<'_, '_>,
902 state: &mut Self::State,
903 old: &Self::Props,
904 new: &Self::Props,
905 ) -> Result<(), NativeWidgetError> {
906 let plan = DatePickerProps::plan(old, new, state.observed, BOUNDS);
907 let applied = ctx.with_frame(FRAME_CAPACITY, |ctx| apply_all(ctx, &state.view, &plan));
912 if applied.is_ok() {
913 state.observed = None;
914 }
915 applied
916 }
917
918 fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
919 decode_event(&mut state.observed, event)
920 }
921
922 fn dispose(
923 ctx: &mut NativeCtx<'_, '_>,
924 state: Self::State,
925 ) -> Result<(), NativeWidgetError> {
926 ctx.set_on_date_changed_listener(&state.view, &JObject::null())
930 }
931 }
932
933 fn apply_all(
936 ctx: &mut NativeCtx<'_, '_>,
937 view: &JObject<'_>,
938 plan: &Plan<'_>,
939 ) -> Result<(), NativeWidgetError> {
940 for setter in plan {
941 apply(ctx, view, setter)?;
942 }
943 Ok(())
944 }
945
946 fn apply(
950 ctx: &mut NativeCtx<'_, '_>,
951 view: &JObject<'_>,
952 setter: &Setter<'_>,
953 ) -> Result<(), NativeWidgetError> {
954 match *setter {
955 Setter::Date(date) => ctx.call_void(
956 view,
957 jni_str!("updateDate"),
958 jni_sig!("(III)V"),
959 &[
960 JValue::Int(date.year),
961 JValue::Int(i32::from(date.month) - 1),
962 JValue::Int(i32::from(date.day)),
963 ],
964 ),
965 Setter::MinDate(min) => {
966 let millis = epoch_millis(ctx, min.unwrap_or(PLATFORM_MIN))?;
967 ctx.call_void(
968 view,
969 jni_str!("setMinDate"),
970 jni_sig!("(J)V"),
971 &[JValue::Long(millis)],
972 )
973 }
974 Setter::MaxDate(max) => {
975 let millis = epoch_millis(ctx, max.unwrap_or(PLATFORM_MAX))?;
976 ctx.call_void(
977 view,
978 jni_str!("setMaxDate"),
979 jni_sig!("(J)V"),
980 &[JValue::Long(millis)],
981 )
982 }
983 Setter::DatePickerStyle(style) => {
984 warn_style_unsupported(style);
985 Ok(())
986 }
987 Setter::DatePickerTint(_) | Setter::DatePickerTextColor(_) => {
988 log_no_colour_api();
989 Ok(())
990 }
991 Setter::Enabled(_) | Setter::ContentDescription(_) => apply_shared(ctx, view, setter),
992 ref other => {
993 log::warn!(
994 "frust-native-widgets: control 'date_picker' planned a setter its Android \
995 arm does not implement ({other:?}) — ignored"
996 );
997 Ok(())
998 }
999 }
1000 }
1001
1002 pub(crate) fn warn_style_unsupported(style: DatePickerStyle) {
1006 static ONCE: Once = Once::new();
1007 ONCE.call_once(|| {
1008 log::warn!(
1009 "frust-native-widgets: Android's DatePicker bakes its spinner/calendar mode into \
1010 the style it was constructed with, so a later style change to {style:?} is not \
1011 applied — the picker keeps the mode it was created in"
1012 );
1013 });
1014 }
1015
1016 fn log_no_colour_api() {
1020 static ONCE: Once = Once::new();
1021 ONCE.call_once(|| {
1022 log::debug!(
1023 "frust-native-widgets: Android's DatePicker exposes no tint or text-colour setter \
1024 — its colours come from the (night-qualified) construction theme only"
1025 );
1026 });
1027 }
1028}
1029
1030#[cfg(target_os = "ios")]
1031pub(crate) mod platform {
1032 use objc2::MainThreadMarker;
1045 use objc2::rc::Retained;
1046 use objc2_ui_kit::{UIDatePicker, UIDatePickerMode, UIDatePickerStyle};
1047
1048 use super::foundation::{ns_date, optional_ns_date};
1049 use super::{Bounds, DatePicker, DatePickerProps, DatePickerStyle, KIND, decode_event};
1050 use crate::NativeWidgetError;
1051 use crate::apple::{FrustNativeControlTarget, NativeCtx, NativeView};
1052 use crate::controls::platform;
1053 use crate::controls::{Plan, Setter};
1054 use crate::events::EventPayload;
1055 use crate::runtime::{NativeEvent, NativeWidget, Params};
1056
1057 pub(crate) struct DatePickerState {
1059 view: Retained<UIDatePicker>,
1060 target: Retained<FrustNativeControlTarget>,
1064 observed: Option<super::CivilDate>,
1067 }
1068
1069 impl NativeWidget for DatePicker {
1070 type Props = DatePickerProps;
1071 type State = DatePickerState;
1072
1073 fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
1074 DatePickerProps::decode(params)
1075 }
1076
1077 fn create(
1078 ctx: &mut NativeCtx<'_, '_>,
1079 props: &Self::Props,
1080 ) -> Result<(NativeView, Self::State), NativeWidgetError> {
1081 let mtm = ctx.mtm();
1082 let view = UIDatePicker::new(mtm);
1083 let default = DatePickerProps::platform_default(props.slot);
1084 view.setDatePickerMode(UIDatePickerMode::Date);
1086 view.setPreferredDatePickerStyle(ui_style(default.style));
1087 let plan = DatePickerProps::plan(&default, props, None, Bounds::APPLE);
1088 apply_all(mtm, &view, &plan);
1089 let target = FrustNativeControlTarget::attach_date_picker(mtm, props.slot, &view);
1091 let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
1092 Ok((
1093 handle,
1094 DatePickerState {
1095 view,
1096 target,
1097 observed: None,
1098 },
1099 ))
1100 }
1101
1102 fn update(
1103 ctx: &mut NativeCtx<'_, '_>,
1104 state: &mut Self::State,
1105 old: &Self::Props,
1106 new: &Self::Props,
1107 ) -> Result<(), NativeWidgetError> {
1108 let plan = DatePickerProps::plan(old, new, state.observed, Bounds::APPLE);
1109 apply_all(ctx.mtm(), &state.view, &plan);
1110 state.observed = None;
1113 Ok(())
1114 }
1115
1116 fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
1117 decode_event(&mut state.observed, event)
1118 }
1119
1120 fn dispose(
1121 _ctx: &mut NativeCtx<'_, '_>,
1122 state: Self::State,
1123 ) -> Result<(), NativeWidgetError> {
1124 state.target.detach_date_picker(&state.view);
1127 Ok(())
1128 }
1129 }
1130
1131 fn ui_style(style: DatePickerStyle) -> UIDatePickerStyle {
1133 match style {
1134 DatePickerStyle::Compact => UIDatePickerStyle::Compact,
1135 DatePickerStyle::Wheels => UIDatePickerStyle::Wheels,
1136 DatePickerStyle::Inline => UIDatePickerStyle::Inline,
1137 }
1138 }
1139
1140 fn apply_all(mtm: MainThreadMarker, view: &UIDatePicker, plan: &Plan<'_>) {
1142 for setter in plan {
1143 apply(mtm, view, setter);
1144 }
1145 }
1146
1147 fn apply(mtm: MainThreadMarker, view: &UIDatePicker, setter: &Setter<'_>) {
1149 match *setter {
1150 Setter::Date(date) => match ns_date(date) {
1153 Some(ns) => view.setDate(&ns),
1154 None => log::warn!(
1155 "frust-native-widgets: iOS could not build an NSDate for {date} — not applied"
1156 ),
1157 },
1158 Setter::MinDate(min) => view.setMinimumDate(optional_ns_date(min).as_deref()),
1159 Setter::MaxDate(max) => view.setMaximumDate(optional_ns_date(max).as_deref()),
1160 Setter::DatePickerStyle(style) => view.setPreferredDatePickerStyle(ui_style(style)),
1161 Setter::Enabled(enabled) => view.setEnabled(enabled),
1162 Setter::DatePickerTint(argb) => {
1163 let color = platform::optional_ui_color(argb);
1164 unsafe { view.setTintColor(color.as_deref()) };
1171 }
1172 Setter::DatePickerTextColor(argb) => {
1173 log::debug!(
1174 "frust-native-widgets: iOS DatePickerTextColor({argb:?}) not applied — \
1175 UIDatePicker exposes no public text colour"
1176 );
1177 }
1178 Setter::ContentDescription(label) => {
1179 platform::set_accessibility_label(view, label, mtm);
1180 }
1181 ref other => platform::warn_unexpected_setter(KIND, other),
1182 }
1183 }
1184}
1185
1186#[cfg(target_os = "macos")]
1187pub(crate) mod platform {
1188 use objc2::rc::Retained;
1209 use objc2_app_kit::{
1210 NSColor, NSDatePicker, NSDatePickerElementFlags, NSDatePickerMode,
1211 NSDatePickerStyle as AppKitStyle,
1212 };
1213
1214 use super::foundation::{ns_date, optional_ns_date};
1215 use super::{Bounds, DatePicker, DatePickerProps, DatePickerStyle, KIND, decode_event};
1216 use crate::NativeWidgetError;
1217 use crate::appkit::{FrustNativeControlTarget, NativeCtx, NativeView};
1218 use crate::controls::platform;
1219 use crate::controls::{Plan, Setter};
1220 use crate::events::{EVENT_KIND_DATE, EventPayload};
1221 use crate::runtime::{NativeEvent, NativeWidget, Params};
1222
1223 pub(crate) struct DatePickerState {
1225 view: Retained<NSDatePicker>,
1227 target: Retained<FrustNativeControlTarget>,
1231 observed: Option<super::CivilDate>,
1234 }
1235
1236 impl NativeWidget for DatePicker {
1237 type Props = DatePickerProps;
1238 type State = DatePickerState;
1239
1240 fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
1241 DatePickerProps::decode(params)
1242 }
1243
1244 fn create(
1245 ctx: &mut NativeCtx<'_, '_>,
1246 props: &Self::Props,
1247 ) -> Result<(NativeView, Self::State), NativeWidgetError> {
1248 let mtm = ctx.mtm();
1249 let view = NSDatePicker::new(mtm);
1250 let default = DatePickerProps::platform_default(props.slot);
1251 view.setDatePickerMode(NSDatePickerMode::Single);
1253 view.setDatePickerElements(NSDatePickerElementFlags::YearMonthDay);
1254 set_style(&view, default.style);
1255 let plan = DatePickerProps::plan(&default, props, None, Bounds::APPLE);
1256 apply_all(&view, &plan);
1257 let target = FrustNativeControlTarget::attach(mtm, &view, props.slot, EVENT_KIND_DATE);
1259 let handle = NativeView::new(Retained::clone(&view).into_super().into_super(), mtm);
1260 Ok((
1261 handle,
1262 DatePickerState {
1263 view,
1264 target,
1265 observed: None,
1266 },
1267 ))
1268 }
1269
1270 fn update(
1271 _ctx: &mut NativeCtx<'_, '_>,
1272 state: &mut Self::State,
1273 old: &Self::Props,
1274 new: &Self::Props,
1275 ) -> Result<(), NativeWidgetError> {
1276 apply_all(
1277 &state.view,
1278 &DatePickerProps::plan(old, new, state.observed, Bounds::APPLE),
1279 );
1280 state.observed = None;
1282 Ok(())
1283 }
1284
1285 fn on_event(state: &mut Self::State, event: NativeEvent) -> Option<EventPayload> {
1286 decode_event(&mut state.observed, event)
1287 }
1288
1289 fn dispose(
1290 _ctx: &mut NativeCtx<'_, '_>,
1291 state: Self::State,
1292 ) -> Result<(), NativeWidgetError> {
1293 state.target.detach(&state.view);
1296 Ok(())
1297 }
1298 }
1299
1300 fn set_style(view: &NSDatePicker, style: DatePickerStyle) {
1304 let (appkit, overlay) = match style {
1305 DatePickerStyle::Compact => (AppKitStyle::TextFieldAndStepper, true),
1306 DatePickerStyle::Wheels => (AppKitStyle::TextFieldAndStepper, false),
1307 DatePickerStyle::Inline => (AppKitStyle::ClockAndCalendar, false),
1308 };
1309 view.setDatePickerStyle(appkit);
1310 view.setPresentsCalendarOverlay(overlay);
1311 }
1312
1313 fn apply_all(view: &NSDatePicker, plan: &Plan<'_>) {
1315 for setter in plan {
1316 apply(view, setter);
1317 }
1318 }
1319
1320 fn apply(view: &NSDatePicker, setter: &Setter<'_>) {
1322 match *setter {
1323 Setter::Date(date) => match ns_date(date) {
1325 Some(ns) => view.setDateValue(&ns),
1326 None => log::warn!(
1327 "frust-native-widgets: macOS could not build an NSDate for {date} — not \
1328 applied"
1329 ),
1330 },
1331 Setter::MinDate(min) => view.setMinDate(optional_ns_date(min).as_deref()),
1332 Setter::MaxDate(max) => view.setMaxDate(optional_ns_date(max).as_deref()),
1333 Setter::DatePickerStyle(style) => set_style(view, style),
1334 Setter::Enabled(enabled) => platform::set_enabled(view, enabled),
1335 Setter::DatePickerTint(argb) => platform::set_tint(view, "DatePickerTint", argb),
1339 Setter::DatePickerTextColor(argb) => {
1340 let color = argb.map_or_else(NSColor::controlTextColor, platform::ns_color);
1341 view.setTextColor(&color);
1342 }
1343 Setter::ContentDescription(label) => platform::set_accessibility_label(view, label),
1344 ref other => platform::warn_unexpected_setter(KIND, other),
1345 }
1346 }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use crate::controls::Tier;
1353 use crate::runtime::with_identity;
1354
1355 fn date(year: i32, month: u8, day: u8) -> CivilDate {
1356 CivilDate::new(year, month, day).expect("a real date")
1357 }
1358
1359 fn decode(body: &str) -> DatePickerProps {
1360 let raw = with_identity(KIND, 5, body);
1361 DatePickerProps::decode(&Params::new(&raw)).expect("decodes")
1362 }
1363
1364 fn field(key: &str, value: CivilDate) -> String {
1365 format!("\"{key}\":{}", wire(value))
1366 }
1367
1368 fn android_bounds() -> Bounds {
1373 Bounds {
1374 floor: date(1900, 1, 1),
1375 ceiling: date(2100, 12, 31),
1376 }
1377 }
1378
1379 #[test]
1382 fn civil_date_validates_every_field_and_the_month_length() {
1383 assert!(CivilDate::new(1, 1, 1).is_some());
1384 assert!(CivilDate::new(9999, 12, 31).is_some());
1385 assert!(CivilDate::new(0, 1, 1).is_none(), "year 0");
1386 assert!(CivilDate::new(10_000, 1, 1).is_none(), "year 10000");
1387 assert!(CivilDate::new(-1, 1, 1).is_none(), "negative year");
1388 assert!(CivilDate::new(2026, 0, 1).is_none(), "month 0");
1389 assert!(CivilDate::new(2026, 13, 1).is_none(), "month 13");
1390 assert!(CivilDate::new(2026, 1, 0).is_none(), "day 0");
1391 assert!(CivilDate::new(2026, 1, 32).is_none(), "day 32");
1392 assert!(CivilDate::new(2026, 4, 31).is_none(), "31 April");
1393 assert!(CivilDate::new(2024, 2, 29).is_some(), "leap year");
1394 assert!(CivilDate::new(2000, 2, 29).is_some(), "400-year leap");
1395 assert!(CivilDate::new(1900, 2, 29).is_none(), "century non-leap");
1396 assert!(CivilDate::new(2026, 2, 29).is_none(), "common year");
1397 assert_eq!(CivilDate::MIN, date(1, 1, 1));
1398 assert_eq!(CivilDate::MAX, date(9999, 12, 31));
1399 }
1400
1401 #[test]
1402 fn civil_dates_order_chronologically_and_display_as_iso_8601() {
1403 assert!(date(2026, 1, 31) < date(2026, 2, 1));
1404 assert!(date(2025, 12, 31) < date(2026, 1, 1));
1405 assert_eq!(date(7, 3, 9).to_string(), "0007-03-09");
1406 assert_eq!(date(2026, 9, 29).to_string(), "2026-09-29");
1407 }
1408
1409 #[test]
1412 fn absent_fields_decode_to_the_platform_defaults_and_plan_nothing() {
1413 let props = decode("");
1414 assert_eq!(props, DatePickerProps::platform_default(5));
1415 assert!(
1416 DatePickerProps::plan(
1417 &DatePickerProps::platform_default(5),
1418 &props,
1419 None,
1420 Bounds::APPLE
1421 )
1422 .is_empty()
1423 );
1424 }
1425
1426 #[test]
1427 fn dates_decode_from_their_packed_wire_integers() {
1428 let props = decode(&format!(
1429 "{},{},{},\"style\":\"inline\",\"enabled\":false,\"tint\":7,\"textColor\":8",
1430 field(DATE, date(2026, 9, 29)),
1431 field(MIN_DATE, date(2026, 1, 1)),
1432 field(MAX_DATE, date(2026, 12, 31)),
1433 ));
1434 assert_eq!(props.date, Some(date(2026, 9, 29)));
1435 assert_eq!(props.min, Some(date(2026, 1, 1)));
1436 assert_eq!(props.max, Some(date(2026, 12, 31)));
1437 assert_eq!(props.style, DatePickerStyle::Inline);
1438 assert!(!props.enabled);
1439 assert_eq!(props.tint, Some(7));
1440 assert_eq!(props.text_color, Some(8));
1441 }
1442
1443 #[test]
1444 fn an_invalid_wire_date_decodes_as_absent() {
1445 let props = decode(&format!("\"date\":{}", (2026 << 16) | (4 << 8) | 31));
1447 assert_eq!(props.date, None);
1448 let negative = decode("\"minDate\":-5,\"maxDate\":0");
1449 assert_eq!((negative.min, negative.max), (None, None));
1450 }
1451
1452 #[test]
1453 fn an_unknown_style_decodes_to_compact() {
1454 assert_eq!(
1455 decode("\"style\":\"sideways\"").style,
1456 DatePickerStyle::Compact
1457 );
1458 assert_eq!(
1459 decode("\"style\":\"wheels\"").style,
1460 DatePickerStyle::Wheels
1461 );
1462 }
1463
1464 #[test]
1465 fn the_date_is_clamped_into_the_range_on_decode() {
1466 let before = decode(&format!(
1467 "{},{}",
1468 field(DATE, date(2020, 5, 5)),
1469 field(MIN_DATE, date(2026, 1, 1))
1470 ));
1471 assert_eq!(before.date, Some(date(2026, 1, 1)));
1472 let after = decode(&format!(
1473 "{},{}",
1474 field(DATE, date(2030, 5, 5)),
1475 field(MAX_DATE, date(2026, 12, 31))
1476 ));
1477 assert_eq!(after.date, Some(date(2026, 12, 31)));
1478 }
1479
1480 #[test]
1481 fn an_inverted_range_degrades_to_the_single_day_min() {
1482 let props = decode(&format!(
1483 "{},{},{}",
1484 field(DATE, date(2026, 6, 1)),
1485 field(MIN_DATE, date(2026, 12, 1)),
1486 field(MAX_DATE, date(2026, 1, 1)),
1487 ));
1488 assert_eq!(props.min, Some(date(2026, 12, 1)));
1489 assert_eq!(props.max, Some(date(2026, 12, 1)));
1490 assert_eq!(props.date, Some(date(2026, 12, 1)));
1491 }
1492
1493 #[test]
1496 fn the_create_plan_sets_exactly_the_non_default_fields_in_order() {
1497 let props = decode(&format!(
1498 "{},{},{},\"style\":\"wheels\",\"enabled\":false,\"tint\":1,\"textColor\":2,\
1499 \"contentDescription\":\"birthday\"",
1500 field(DATE, date(2026, 9, 29)),
1501 field(MIN_DATE, date(2000, 1, 1)),
1502 field(MAX_DATE, date(2030, 12, 31)),
1503 ));
1504 assert_eq!(
1505 DatePickerProps::plan(
1506 &DatePickerProps::platform_default(5),
1507 &props,
1508 None,
1509 Bounds::APPLE
1510 ),
1511 vec![
1512 Setter::DatePickerStyle(DatePickerStyle::Wheels),
1513 Setter::MinDate(Some(date(2000, 1, 1))),
1514 Setter::MaxDate(Some(date(2030, 12, 31))),
1515 Setter::Date(date(2026, 9, 29)),
1516 Setter::Enabled(false),
1517 Setter::DatePickerTint(Some(1)),
1518 Setter::DatePickerTextColor(Some(2)),
1519 Setter::ContentDescription(Some("birthday")),
1520 ]
1521 );
1522 }
1523
1524 #[test]
1525 fn a_date_change_alone_plans_exactly_one_setter() {
1526 let old = decode(&field(DATE, date(2026, 9, 29)));
1527 let new = decode(&field(DATE, date(2026, 9, 30)));
1528 assert_eq!(
1529 DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
1530 vec![Setter::Date(date(2026, 9, 30))]
1531 );
1532 assert!(
1533 DatePickerProps::plan(&new, &new, None, Bounds::APPLE).is_empty(),
1534 "unchanged props plan nothing — the zero-FFI property"
1535 );
1536 }
1537
1538 #[test]
1539 fn a_pick_the_app_confirmed_writes_the_date_back_once() {
1540 let before = decode(&field(DATE, date(2026, 9, 29)));
1541 let after = decode(&field(DATE, date(2026, 10, 3)));
1542 assert_eq!(
1543 DatePickerProps::plan(&before, &after, Some(date(2026, 10, 3)), Bounds::APPLE),
1544 vec![Setter::Date(date(2026, 10, 3))]
1545 );
1546 }
1547
1548 #[test]
1549 fn a_platform_drift_is_written_back_even_when_the_props_date_did_not_change() {
1550 let old = decode(&field(DATE, date(2026, 9, 29)));
1553 let new = decode(&format!(
1554 "{},\"enabled\":false",
1555 field(DATE, date(2026, 9, 29))
1556 ));
1557 assert_eq!(
1558 DatePickerProps::plan(&old, &new, Some(date(2026, 10, 3)), Bounds::APPLE),
1559 vec![Setter::Date(date(2026, 9, 29)), Setter::Enabled(false)],
1560 "the app rejected the pick: the confirmed date is re-asserted"
1561 );
1562 assert_eq!(
1563 DatePickerProps::plan(&old, &new, Some(date(2026, 9, 29)), Bounds::APPLE),
1564 vec![Setter::Enabled(false)],
1565 "an observed date matching the app's is no drift"
1566 );
1567 }
1568
1569 #[test]
1570 fn a_range_that_moves_later_writes_the_ceiling_first() {
1571 let old = decode(&format!(
1572 "{},{},{}",
1573 field(DATE, date(2026, 1, 15)),
1574 field(MIN_DATE, date(2026, 1, 1)),
1575 field(MAX_DATE, date(2026, 1, 31)),
1576 ));
1577 let new = decode(&format!(
1578 "{},{},{}",
1579 field(DATE, date(2026, 3, 15)),
1580 field(MIN_DATE, date(2026, 3, 1)),
1581 field(MAX_DATE, date(2026, 3, 31)),
1582 ));
1583 assert_eq!(
1584 DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
1585 vec![
1586 Setter::MaxDate(Some(date(2026, 3, 31))),
1587 Setter::MinDate(Some(date(2026, 3, 1))),
1588 Setter::Date(date(2026, 3, 15)),
1589 ],
1590 "floor-first would pass through [March 1, January 31] — the \
1591 non-crossing case: the Apple bounds' unbounded ceiling never \
1592 enters the comparison"
1593 );
1594 }
1595
1596 #[test]
1597 fn a_range_that_moves_earlier_writes_the_floor_first() {
1598 let old = decode(&format!(
1599 "{},{}",
1600 field(MIN_DATE, date(2026, 3, 1)),
1601 field(MAX_DATE, date(2026, 3, 31)),
1602 ));
1603 let new = decode(&format!(
1604 "{},{}",
1605 field(MIN_DATE, date(2026, 1, 1)),
1606 field(MAX_DATE, date(2026, 1, 31)),
1607 ));
1608 assert_eq!(
1609 DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
1610 vec![
1611 Setter::MinDate(Some(date(2026, 1, 1))),
1612 Setter::MaxDate(Some(date(2026, 1, 31))),
1613 ],
1614 "ceiling-first would pass through [March 1, January 31]"
1615 );
1616 }
1617
1618 #[test]
1619 fn a_none_ceiling_crosses_below_an_explicit_old_ceiling_on_android_only() {
1620 let old = decode(&format!(
1633 "{},{}",
1634 field(MIN_DATE, date(2095, 1, 1)),
1635 field(MAX_DATE, date(2150, 1, 1)),
1636 ));
1637 let new = decode(&field(MIN_DATE, date(2050, 1, 1)));
1638 assert_eq!(new.max, None);
1639
1640 assert_eq!(
1641 DatePickerProps::plan(&old, &new, None, android_bounds()),
1642 vec![Setter::MinDate(Some(date(2050, 1, 1)))],
1643 "old.max's clamped ceiling (2100-12-31) equals new's resolved \
1644 None default — no MaxDate setter at all, only the floor moves"
1645 );
1646 assert_eq!(
1647 DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
1648 vec![
1649 Setter::MaxDate(None),
1650 Setter::MinDate(Some(date(2050, 1, 1))),
1651 ],
1652 "Apple's None ceiling (CivilDate::MAX) is still >= the old one \
1653 — a widen, so the ceiling moves first; the two arms disagree \
1654 on order for the exact same props"
1655 );
1656 }
1657
1658 #[test]
1659 fn planning_with_an_inverted_resolved_range_does_not_panic() {
1660 let old = DatePickerProps::platform_default(7);
1666 let new = decode(&field(MIN_DATE, date(2150, 1, 1)));
1667 let plan = DatePickerProps::plan(&old, &new, None, android_bounds());
1668 assert_eq!(plan, vec![Setter::MinDate(Some(date(2100, 12, 31)))]);
1671 }
1672
1673 #[test]
1674 fn an_explicit_min_above_androids_ceiling_clamps_the_bound_and_the_date() {
1675 let old = DatePickerProps::platform_default(9);
1676 let new = decode(&format!(
1677 "{},{}",
1678 field(MIN_DATE, date(2150, 1, 1)),
1679 field(DATE, date(2150, 6, 1)),
1680 ));
1681 assert_eq!(
1682 DatePickerProps::plan(&old, &new, None, android_bounds()),
1683 vec![
1684 Setter::MinDate(Some(date(2100, 12, 31))),
1685 Setter::Date(date(2100, 12, 31)),
1686 ],
1687 "an explicit min above Android's own ceiling clamps into it, \
1688 and a date later than the clamped ceiling clamps with it — the \
1689 platform is never asked for a date outside the range it will \
1690 hold"
1691 );
1692 }
1693
1694 #[test]
1695 fn an_explicit_max_below_androids_floor_clamps_the_bound() {
1696 let old = DatePickerProps::platform_default(9);
1697 let new = decode(&field(MAX_DATE, date(1850, 1, 1)));
1698 assert_eq!(
1699 DatePickerProps::plan(&old, &new, None, android_bounds()),
1700 vec![Setter::MaxDate(Some(date(1900, 1, 1)))],
1701 "an explicit max below Android's own floor clamps up into it"
1702 );
1703 }
1704
1705 #[test]
1706 fn the_same_out_of_androids_range_bounds_are_not_clamped_on_apple() {
1707 let old = DatePickerProps::platform_default(9);
1708 let min_only = decode(&field(MIN_DATE, date(2150, 1, 1)));
1709 assert_eq!(
1710 DatePickerProps::plan(&old, &min_only, None, Bounds::APPLE),
1711 vec![Setter::MinDate(Some(date(2150, 1, 1)))],
1712 "2150-01-01 is well inside CivilDate::MIN..CivilDate::MAX — \
1713 Apple's own unbounded range — so nothing clamps it"
1714 );
1715 let max_only = decode(&field(MAX_DATE, date(1850, 1, 1)));
1716 assert_eq!(
1717 DatePickerProps::plan(&old, &max_only, None, Bounds::APPLE),
1718 vec![Setter::MaxDate(Some(date(1850, 1, 1)))],
1719 "1850-01-01 is well inside CivilDate::MIN..CivilDate::MAX, so \
1720 nothing clamps it on Apple either"
1721 );
1722 }
1723
1724 #[test]
1725 fn a_range_change_reasserts_an_unchanged_date() {
1726 let old = decode(&format!(
1727 "{},{}",
1728 field(DATE, date(2026, 6, 15)),
1729 field(MIN_DATE, date(2026, 1, 1))
1730 ));
1731 let new = decode(&format!(
1732 "{},{}",
1733 field(DATE, date(2026, 6, 15)),
1734 field(MIN_DATE, date(2026, 6, 1))
1735 ));
1736 assert_eq!(
1737 DatePickerProps::plan(&old, &new, None, Bounds::APPLE),
1738 vec![
1739 Setter::MinDate(Some(date(2026, 6, 1))),
1740 Setter::Date(date(2026, 6, 15)),
1741 ]
1742 );
1743 }
1744
1745 #[test]
1746 fn clearing_a_bound_and_the_colours_plans_the_nullable_setters() {
1747 let set = decode(&format!(
1748 "{},\"tint\":1,\"textColor\":2",
1749 field(MAX_DATE, date(2026, 12, 31))
1750 ));
1751 let cleared = decode("");
1752 assert_eq!(
1753 DatePickerProps::plan(&set, &cleared, None, Bounds::APPLE),
1754 vec![
1755 Setter::MaxDate(None),
1756 Setter::DatePickerTint(None),
1757 Setter::DatePickerTextColor(None),
1758 ]
1759 );
1760 }
1761
1762 #[test]
1763 fn every_planned_setter_reports_its_documented_tier() {
1764 let props = decode(&format!(
1765 "{},{},{},\"style\":\"inline\",\"enabled\":false,\"tint\":1,\"textColor\":2,\
1766 \"contentDescription\":\"d\"",
1767 field(DATE, date(2026, 9, 29)),
1768 field(MIN_DATE, date(2000, 1, 1)),
1769 field(MAX_DATE, date(2030, 12, 31)),
1770 ));
1771 for setter in DatePickerProps::plan(
1772 &DatePickerProps::platform_default(5),
1773 &props,
1774 None,
1775 Bounds::APPLE,
1776 ) {
1777 let expected = if matches!(
1778 setter,
1779 Setter::MinDate(_) | Setter::MaxDate(_) | Setter::DatePickerStyle(_)
1780 ) {
1781 Tier::Relayout
1782 } else {
1783 Tier::Cheap
1784 };
1785 assert_eq!(setter.tier(), expected, "{setter:?}");
1786 }
1787 }
1788
1789 #[test]
1792 fn effective_date_with_android_bounds_clamps_out_of_range_dates() {
1793 let props = decode(&field(DATE, date(2150, 6, 1)));
1794 let clamped = props.effective_date(android_bounds());
1795 assert_eq!(
1796 clamped,
1797 Some(date(2100, 12, 31)),
1798 "2150-06-01 is outside Android's 1900-01-01..2100-12-31 range, \
1799 clamped to the ceiling"
1800 );
1801 }
1802
1803 #[test]
1804 fn effective_date_with_android_bounds_and_explicit_min_still_clamps() {
1805 let props = decode(&format!(
1806 "{},{}",
1807 field(DATE, date(2150, 6, 1)),
1808 field(MIN_DATE, date(2050, 1, 1))
1809 ));
1810 let clamped = props.effective_date(android_bounds());
1811 assert_eq!(
1812 clamped,
1813 Some(date(2100, 12, 31)),
1814 "with an explicit min, the date 2150-06-01 still clamps to \
1815 Android's ceiling 2100-12-31"
1816 );
1817 }
1818
1819 #[test]
1820 fn effective_date_is_identity_with_apple_bounds() {
1821 let date_to_test = date(2150, 6, 1);
1822 let props = decode(&field(DATE, date_to_test));
1823 let result = props.effective_date(Bounds::APPLE);
1824 assert_eq!(
1825 result,
1826 Some(date_to_test),
1827 "2150-06-01 is inside Apple's CivilDate::MIN..CivilDate::MAX, \
1828 so effective_date returns it unchanged"
1829 );
1830 }
1831
1832 #[test]
1833 fn effective_date_equals_the_date_setter_in_plan_table_driven() {
1834 struct TestCase {
1837 name: &'static str,
1838 bounds: Bounds,
1839 min: Option<CivilDate>,
1840 max: Option<CivilDate>,
1841 date_in_range: CivilDate,
1842 date_out_of_range: CivilDate,
1843 }
1844
1845 let test_cases = vec![
1846 TestCase {
1848 name: "android_no_bounds",
1849 bounds: android_bounds(),
1850 min: None,
1851 max: None,
1852 date_in_range: date(2026, 6, 1),
1853 date_out_of_range: date(2150, 6, 1), },
1855 TestCase {
1856 name: "android_min_only",
1857 bounds: android_bounds(),
1858 min: Some(date(2050, 1, 1)),
1859 max: None,
1860 date_in_range: date(2050, 6, 1),
1861 date_out_of_range: date(1950, 6, 1), },
1863 TestCase {
1864 name: "android_max_only",
1865 bounds: android_bounds(),
1866 min: None,
1867 max: Some(date(2050, 12, 31)),
1868 date_in_range: date(2050, 6, 1),
1869 date_out_of_range: date(2150, 6, 1), },
1871 TestCase {
1872 name: "android_both_explicit",
1873 bounds: android_bounds(),
1874 min: Some(date(2050, 1, 1)),
1875 max: Some(date(2050, 12, 31)),
1876 date_in_range: date(2050, 6, 1),
1877 date_out_of_range: date(2150, 6, 1), },
1879 TestCase {
1880 name: "android_floor_collapsed",
1881 bounds: android_bounds(),
1882 min: Some(date(2150, 1, 1)), max: None,
1884 date_in_range: date(2100, 12, 31), date_out_of_range: date(2026, 6, 1), },
1887 TestCase {
1889 name: "apple_no_bounds",
1890 bounds: Bounds::APPLE,
1891 min: None,
1892 max: None,
1893 date_in_range: date(2026, 6, 1),
1894 date_out_of_range: date(9999, 12, 31), },
1896 TestCase {
1897 name: "apple_min_only",
1898 bounds: Bounds::APPLE,
1899 min: Some(date(2050, 1, 1)),
1900 max: None,
1901 date_in_range: date(2050, 6, 1),
1902 date_out_of_range: date(1950, 6, 1), },
1904 TestCase {
1905 name: "apple_max_only",
1906 bounds: Bounds::APPLE,
1907 min: None,
1908 max: Some(date(2050, 12, 31)),
1909 date_in_range: date(2050, 6, 1),
1910 date_out_of_range: date(2150, 6, 1), },
1912 TestCase {
1913 name: "apple_both_explicit",
1914 bounds: Bounds::APPLE,
1915 min: Some(date(2050, 1, 1)),
1916 max: Some(date(2050, 12, 31)),
1917 date_in_range: date(2050, 6, 1),
1918 date_out_of_range: date(2150, 6, 1), },
1920 TestCase {
1921 name: "apple_floor_collapsed",
1922 bounds: Bounds::APPLE,
1923 min: Some(date(2150, 1, 1)), max: Some(date(2050, 12, 31)),
1925 date_in_range: date(2050, 12, 31), date_out_of_range: date(2026, 6, 1), },
1928 ];
1929
1930 for tc in test_cases {
1931 let props_in = decode(&format!(
1933 "{}{}{}",
1934 tc.min
1935 .map(|m| format!("\"minDate\":{},", wire(m)))
1936 .unwrap_or_default(),
1937 tc.max
1938 .map(|m| format!("\"maxDate\":{},", wire(m)))
1939 .unwrap_or_default(),
1940 field(DATE, tc.date_in_range)
1941 ));
1942
1943 let effective_in = props_in.effective_date(tc.bounds);
1944 let plan_in = DatePickerProps::plan(
1945 &DatePickerProps::platform_default(5),
1946 &props_in,
1947 None,
1948 tc.bounds,
1949 );
1950
1951 let date_setter_in = plan_in.iter().find_map(|s| {
1952 if let Setter::Date(d) = s {
1953 Some(*d)
1954 } else {
1955 None
1956 }
1957 });
1958
1959 assert_eq!(
1960 date_setter_in, effective_in,
1961 "{}: plan's Date setter matches effective_date for date inside range",
1962 tc.name
1963 );
1964
1965 let props_out = decode(&format!(
1967 "{}{}{}",
1968 tc.min
1969 .map(|m| format!("\"minDate\":{},", wire(m)))
1970 .unwrap_or_default(),
1971 tc.max
1972 .map(|m| format!("\"maxDate\":{},", wire(m)))
1973 .unwrap_or_default(),
1974 field(DATE, tc.date_out_of_range)
1975 ));
1976
1977 let effective_out = props_out.effective_date(tc.bounds);
1978 let plan_out = DatePickerProps::plan(
1979 &DatePickerProps::platform_default(5),
1980 &props_out,
1981 None,
1982 tc.bounds,
1983 );
1984
1985 let date_setter_out = plan_out.iter().find_map(|s| {
1986 if let Setter::Date(d) = s {
1987 Some(*d)
1988 } else {
1989 None
1990 }
1991 });
1992
1993 assert_eq!(
1994 date_setter_out, effective_out,
1995 "{}: plan's Date setter matches effective_date for date outside range (clamped)",
1996 tc.name
1997 );
1998
1999 let old_with_date = decode(&format!(
2001 "{}{}{}",
2002 tc.min
2003 .map(|m| format!("\"minDate\":{},", wire(m)))
2004 .unwrap_or_default(),
2005 tc.max
2006 .map(|m| format!("\"maxDate\":{},", wire(m)))
2007 .unwrap_or_default(),
2008 field(DATE, tc.date_in_range)
2009 ));
2010 let new_with_same_date = old_with_date.clone();
2011
2012 let plan_unchanged =
2013 DatePickerProps::plan(&old_with_date, &new_with_same_date, None, tc.bounds);
2014
2015 let has_date_setter = plan_unchanged.iter().any(|s| matches!(s, Setter::Date(_)));
2016 assert!(
2017 !has_date_setter,
2018 "{}: unchanged date should not emit Date setter",
2019 tc.name
2020 );
2021 }
2022 }
2023
2024 fn picked(date: CivilDate) -> NativeEvent {
2027 NativeEvent {
2028 kind: EVENT_KIND_DATE,
2029 detail: pack_date(date),
2030 }
2031 }
2032
2033 #[test]
2034 fn a_user_pick_decodes_and_updates_the_drift_signal() {
2035 let mut observed = None;
2036 assert_eq!(
2037 decode_event(&mut observed, picked(date(2026, 10, 3))),
2038 Some(EventPayload::Date(date(2026, 10, 3)))
2039 );
2040 assert_eq!(observed, Some(date(2026, 10, 3)));
2041 }
2042
2043 #[test]
2044 fn an_invalid_report_decodes_to_nothing_and_leaves_observed_untouched() {
2045 let mut observed = Some(date(2026, 1, 1));
2046 let corrupt = NativeEvent {
2047 kind: EVENT_KIND_DATE,
2048 detail: (2026 << 16) | (2 << 8) | 30,
2049 };
2050 assert_eq!(decode_event(&mut observed, corrupt), None);
2051 assert_eq!(observed, Some(date(2026, 1, 1)));
2052 }
2053
2054 #[test]
2055 fn a_misrouted_kind_decodes_to_nothing() {
2056 let mut observed = None;
2057 let selection = NativeEvent {
2058 kind: crate::events::EVENT_KIND_SELECTION,
2059 detail: pack_date(date(2026, 1, 1)),
2060 };
2061 assert_eq!(decode_event(&mut observed, selection), None);
2062 assert_eq!(observed, None);
2063 }
2064
2065 }