Skip to main content

frust_native_widgets/controls/
date_picker.rs

1//! `DatePicker` — a real DATE-mode picker (no time), built and driven from
2//! Rust: `android.widget.DatePicker` on Android, `UIDatePicker` (mode
3//! `Date`) on iOS/iPadOS, `NSDatePicker` (elements `YearMonthDay`) on macOS.
4//! A **shared** control: it sits in [`super::SHARED_KINDS`] and all three
5//! arms register it.
6//!
7//! Date clamping is centralized in [`DatePickerProps::effective_date`], which
8//! computes the app's date clamped into the arm's resolved range; both
9//! `create` and `plan` use this single clamp implementation.
10//!
11//! Eight properties: the date, its optional `[min, max]` range, the
12//! presentation style, enabled, a tint, a text colour, and the accessibility
13//! label. [`Setter::Date`], [`Setter::Enabled`], [`Setter::DatePickerTint`],
14//! [`Setter::DatePickerTextColor`] and [`Setter::ContentDescription`] are
15//! [`Tier::Cheap`](super::Tier::Cheap); [`Setter::MinDate`],
16//! [`Setter::MaxDate`] and [`Setter::DatePickerStyle`] are
17//! [`Tier::Relayout`](super::Tier::Relayout) (a range change repopulates a
18//! calendar's pages/year list; a style swap rebuilds the whole presentation).
19//!
20//! # `CivilDate`: a calendar date, validated at every boundary
21//!
22//! [`CivilDate`] is a proleptic-Gregorian year/month/day with no time and no
23//! time zone — the value the app owns and the value every arm reports. Its
24//! fields are crate-private behind [`CivilDate::new`], so an out-of-crate
25//! caller can never hold an unvalidated one; it is additionally validated
26//! wherever it crosses a boundary inside this crate: the params wire
27//! ([`DatePickerProps::decode`] — an invalid date decodes as absent), the
28//! event wire (`crate::events::unpack_date`), and the Apple arms'
29//! `NSDate` conversion ([`foundation`]). Each date rides the params wire as
30//! ONE integer — the exact `crate::events::pack_date` value the event wire
31//! carries — so there is one codec for both directions, not two.
32//!
33//! # The controlled-component contract — `Switch`'s, date-valued
34//!
35//! Controlled exactly like `Switch`/`Slider` (`switch.rs`'s module doc is the
36//! reference account): the platform reports the *requested* date through
37//! [`crate::events::EVENT_KIND_DATE`], the app confirms it by feeding it back
38//! as `date`.
39//!
40//! 1. **Write-back.** [`DatePickerProps::plan`] takes the date the platform
41//!    last reported (`observed`) and re-plans [`Setter::Date`] when it
42//!    disagrees with the app's, even though the props' own `date` did not
43//!    change — a rejected pick snaps back on the next differing params (the
44//!    same v1 limitation `switch.rs` documents applies).
45//! 2. **Echo guard: the runtime's re-entrancy drop, and nothing else.** On
46//!    Android `DatePicker.updateDate` (the write-back) and `setMinDate`/
47//!    `setMaxDate` (when they clamp the current date) notify
48//!    `OnDateChangedListener` **synchronously**, from inside `update` — which
49//!    runs inside `crate::runtime::with_runtime`, so that echo re-enters the
50//!    held runtime borrow and is dropped there before [`decode_event`] or any
51//!    app callback sees it (`crate::controls`' module doc's *echo guard*).
52//!    Setting the platform date from inside the change handler therefore
53//!    never re-fires the handler. UIKit and AppKit send no action for a
54//!    programmatic `setDate:`/`setDateValue:` at all. No per-instance
55//!    suppression flag, on any arm.
56//!
57//! # Range: clamped on decode, never inverted or out of range on the platform
58//!
59//! A `date` outside `[min, max]` is clamped into it on decode (the platforms
60//! clamp their own display anyway; clamping first keeps the write-back from
61//! fighting them), and an inverted range (`min > max`) degrades to the
62//! single day `min` rather than reaching a platform — Android's calendar
63//! mode sizes its year list from `max - min`. This decode-time clamp is
64//! necessarily platform-agnostic — [`DatePickerProps::decode`] runs once,
65//! shared by all three arms, before any arm is known — so it only ever
66//! catches an *explicit* `min > max`; it cannot see that a `None` bound
67//! resolves to a different literal date per arm ([`CivilDate::MIN`]/
68//! [`CivilDate::MAX`] for the Apple arms; the Android arm's own documented
69//! 1900-01-01/2100-12-31 default), nor that an *explicit* bound — API-legal,
70//! valid on the Apple arms — can fall outside a narrower arm's own range.
71//!
72//! [`DatePickerProps::plan`] therefore resolves each side (`old` and `new`)
73//! against the calling arm's own [`Bounds`] and clamps the result into
74//! `[Bounds::floor, Bounds::ceiling]` before it ever reaches a setter or a
75//! widen-vs-narrow comparison: no arm ever receives an inverted range or a
76//! date outside it; on Android, bounds outside 1900-01-01..2100-12-31 are
77//! clamped to it (a LIMITATIONS entry follows). The two range setters are
78//! then ordered against these CLAMPED, arm-resolved values — the bound that
79//! *widens* the range is written first — so the ordering matches what the
80//! platform will actually hold, never a platform-agnostic assumption; a
81//! `min`/`max` whose clamped value did not change from `old` to `new` plans
82//! no setter at all, even when the app's own raw value did (e.g. an
83//! explicit bound moving from one out-of-range value to another that
84//! clamps to the same arm ceiling/floor). When clamping altered an explicit
85//! bound or the date, `plan` logs a warning (once per process) naming the slot,
86//! the app's values, and the arm's range. A range change (by its clamped value)
87//! also re-asserts [`Setter::Date`], because narrowing a range can move the
88//! platform's date — the date itself is clamped into the same resolved
89//! range so the platform is never asked for a date outside the range it
90//! will hold. Both create-time init and update-time updateDate receive the
91//! effective date (the app's date clamped into the arm's resolved range).
92//!
93//! # `style`: live on Apple, baked at construction on Android
94//!
95//! | [`DatePickerStyle`] | iOS (`preferredDatePickerStyle`) | macOS (`datePickerStyle`) | Android |
96//! |---|---|---|---|
97//! | `Compact` (default) | `.compact` | `textFieldAndStepper` + `presentsCalendarOverlay` | spinner mode |
98//! | `Wheels` | `.wheels` | `textFieldAndStepper` (AppKit has no wheels) | spinner mode |
99//! | `Inline` | `.inline` (iOS 14+, inside the 15.0 floor) | `clockAndCalendar` | calendar mode |
100//!
101//! Android's `datePickerMode` is a construction-time style attribute with no
102//! setter, so that arm's `create` reads `props.style` directly to pick the
103//! constructor style (the exact shape `spinner.rs`'s *`size_class`* section
104//! describes) and a later style change is warned-and-ignored there
105//! (`platform::warn_style_unsupported`).
106//!
107//! # Theme: `accent_ink` tint, `body_text` text colour
108//!
109//! `crate::api::theme` folds `accent_ink` into [`super::TINT`] (applied as
110//! `UIDatePicker.tintColor`; `NSDatePicker` has no tint property — the
111//! calendar's selection draws in the system accent colour — logged and
112//! no-op'd) and `body_text` into [`super::TEXT_COLOR`] (applied as
113//! `NSDatePicker.textColor`; UIKit exposes no public text colour on
114//! `UIDatePicker`, logged and no-op'd). **Android's `DatePicker` has no tint
115//! or text-colour API at all** — its colours come only from the theme it
116//! was constructed against, so both fold to a logged no-op there and the
117//! night-qualified construction `Context` (theme ladder L1) is the whole of
118//! its theming. L1 rides the raw `dark` wire like every other control; iOS
119//! re-pins `overrideUserInterfaceStyle` on every update (`crate::apple`'s
120//! theme module), macOS its `NSAppearance`.
121
122use 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
133/// The registered kind string the api layer injects as `__frustControl`.
134pub(crate) const KIND: &str = "date_picker";
135
136/// `"date"` — the app-owned date (controlled), packed with
137/// `crate::events::pack_date`.
138pub(crate) const DATE: &str = "date";
139/// `"minDate"` — the earliest selectable date, packed like [`DATE`]; absent
140/// for the platform's own floor.
141pub(crate) const MIN_DATE: &str = "minDate";
142/// `"maxDate"` — the latest selectable date, packed like [`DATE`]; absent
143/// for the platform's own ceiling.
144pub(crate) const MAX_DATE: &str = "maxDate";
145/// `"style"` — [`DatePickerStyle`]'s wire spelling.
146pub(crate) const STYLE: &str = "style";
147
148/// A calendar date — year, month, day — with no time of day and no time
149/// zone: what a native date picker shows and what its change handler
150/// reports.
151///
152/// Proleptic Gregorian, `year` 1–9999, `month` **1-based** (1 = January),
153/// `day` 1–31 and real for its month (no 31 April, 29 February only in a
154/// leap year). [`CivilDate::new`] is the only public constructor, and the
155/// only way to build one outside this crate — it refuses anything that is
156/// not a real date in range, so an out-of-crate caller can never hold an
157/// invalid value. Read the components back with [`Self::year`],
158/// [`Self::month`] and [`Self::day`].
159///
160/// Ordered chronologically (`year`, then `month`, then `day`).
161#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
162pub struct CivilDate {
163    // Crate-visible, not public: every direct field read/construction stays
164    // inside `frust_native_widgets` (`crate::events`'s wire codec and
165    // `crate::android::ctx`'s JNI calls both read these three directly), but
166    // `CivilDate::new` is the only constructor this crate's own public API
167    // (`native_date_picker`, `NativeDatePickerView::min`/`max`,
168    // `Self::on_change`'s callback) ever exposes to an app crate, so an
169    // out-of-crate caller can never assemble an invalid literal.
170    pub(crate) year: i32,
171    pub(crate) month: u8,
172    pub(crate) day: u8,
173}
174
175impl CivilDate {
176    /// The earliest date this crate represents: 1 January of year 1.
177    pub const MIN: Self = Self::from_parts(1, 1, 1);
178
179    /// The latest date this crate represents: 31 December 9999.
180    pub const MAX: Self = Self::from_parts(9999, 12, 31);
181
182    /// `year`-`month`-`day`, or `None` when that is not a real calendar date
183    /// in the supported range (see the type doc).
184    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    /// Whether this is a real calendar date in the supported range.
190    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    /// The year, 1–9999.
198    pub const fn year(&self) -> i32 {
199        self.year
200    }
201
202    /// The month, 1-based: 1 = January … 12 = December.
203    pub const fn month(&self) -> u8 {
204        self.month
205    }
206
207    /// The day of the month, 1–31 (and no more than the month has).
208    pub const fn day(&self) -> u8 {
209        self.day
210    }
211
212    /// Build a literal without going through [`Self::new`]'s validation —
213    /// never exposed publicly. Sanctioned only for this module's own
214    /// well-known constants ([`Self::MIN`]/[`Self::MAX`] here, the Android
215    /// arm's `PLATFORM_MIN`/`PLATFORM_MAX`), each a literal known valid by
216    /// inspection; every other construction path, in this crate or out of
217    /// it, goes through [`Self::new`].
218    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    /// ISO 8601 calendar-date form, `YYYY-MM-DD`.
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
227    }
228}
229
230/// Whether `year` is a proleptic-Gregorian leap year.
231fn is_leap_year(year: i32) -> bool {
232    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
233}
234
235/// How many days `month` (1-based) of `year` has; `0` for a month outside
236/// 1–12, so [`CivilDate::is_valid`] refuses every day of it.
237fn 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/// How the picker presents itself — see the module doc's *`style`* table
248/// for each arm's mapping.
249#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
250pub(crate) enum DatePickerStyle {
251    /// The smallest footprint the platform offers.
252    #[default]
253    Compact,
254    /// Spinning wheels.
255    Wheels,
256    /// A full, always-visible calendar.
257    Inline,
258}
259
260impl DatePickerStyle {
261    /// The wire spelling the api layer writes into [`STYLE`].
262    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/// What a `None` `min`/`max` resolves to on one platform's own control —
273/// supplied to [`DatePickerProps::plan`] so its widening-vs-narrowing
274/// ordering decision (module doc's *Range*) compares against the date the
275/// platform will *actually* hold, never a platform-agnostic assumption.
276/// Both Apple arms simply clear the bound for `nil`, which is this crate's
277/// own unbounded [`CivilDate::MIN`]/[`CivilDate::MAX`] ([`Self::APPLE`]);
278/// the Android arm substitutes `DatePicker`'s own documented
279/// 1900-01-01/2100-12-31 default (its `platform` module's own `BOUNDS`).
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub(crate) struct Bounds {
282    floor: CivilDate,
283    ceiling: CivilDate,
284}
285
286impl Bounds {
287    /// Both Apple arms: `setMinimumDate:`/`setMaximumDate:` and
288    /// `setMinDate:`/`setMaxDate:` simply clear the bound for `nil` — there
289    /// is no platform default narrower than this crate's own unbounded
290    /// ends to substitute.
291    pub(crate) const APPLE: Self = Self {
292        floor: CivilDate::MIN,
293        ceiling: CivilDate::MAX,
294    };
295
296    /// `min`, or this platform's own floor when it is `None`.
297    fn resolve_min(self, min: Option<CivilDate>) -> CivilDate {
298        min.unwrap_or(self.floor)
299    }
300
301    /// `max`, or this platform's own ceiling when it is `None`.
302    fn resolve_ceiling(self, max: Option<CivilDate>) -> CivilDate {
303        max.unwrap_or(self.ceiling)
304    }
305
306    /// The range this arm will actually hold for `min`/`max`: each resolved
307    /// bound ([`Self::resolve_min`]/[`Self::resolve_ceiling`]) clamped into
308    /// this arm's own `[floor, ceiling]` — module doc's *Range*. An explicit
309    /// bound outside that range (API-legal, valid on the Apple arms) is
310    /// clamped here rather than reaching the platform's own setter as an
311    /// out-of-range or inverted pair; if the clamp still crosses (only
312    /// possible when both bounds are explicit and inverted, which
313    /// [`DatePickerProps::decode`] already prevents) the floor collapses
314    /// onto the ceiling rather than risk an inverted pair reaching a
315    /// setter.
316    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
331/// The marker type registered under [`KIND`] — by all three arms.
332pub(crate) struct DatePicker;
333
334/// Everything a `DatePicker` slot can be told, as one Rust-diffed value.
335#[derive(Clone, Debug, PartialEq)]
336pub(crate) struct DatePickerProps {
337    /// The differ's slot id — **not a property**; `create` needs it for the
338    /// listener/target it attaches.
339    pub(crate) slot: SlotId,
340    /// The app-owned date (controlled), clamped into `[min, max]` on decode;
341    /// `None` leaves the platform on its own initial date (today).
342    pub(crate) date: Option<CivilDate>,
343    /// The earliest selectable date, `None` for the platform's own floor.
344    pub(crate) min: Option<CivilDate>,
345    /// The latest selectable date, `None` for the platform's own ceiling;
346    /// never before `min` (normalized on decode).
347    pub(crate) max: Option<CivilDate>,
348    /// The presentation — module doc's *`style`* table.
349    pub(crate) style: DatePickerStyle,
350    /// `View.setEnabled` / `UIControl.enabled` / `NSControl.enabled`.
351    pub(crate) enabled: bool,
352    /// Packed ARGB tint, or `None` for the platform's own.
353    pub(crate) tint: Option<i32>,
354    /// Packed ARGB text colour, or `None` for the platform's own.
355    pub(crate) text_color: Option<i32>,
356    /// The TalkBack/VoiceOver label.
357    pub(crate) content_description: Option<String>,
358}
359
360impl DatePickerProps {
361    /// The state every arm's `create` diffs its first plan against: the
362    /// platform's own date and range, [`DatePickerStyle::Compact`], enabled,
363    /// no tint/text colour/label. Each arm reaches this state at construction
364    /// (its own `create` normalizes the style on Apple and bakes it on
365    /// Android — module doc).
366    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    /// The app's `date` clamped into the effective range this arm will hold —
381    /// the date that an arm's `create` and `update` both pass to platform calls
382    /// (module doc's *Range*). `None` when the app's date is absent, leaving
383    /// the platform on its own initial date (today).
384    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    /// Decode a `DatePicker` slot's params (module doc's *Range* for the
390    /// normalization).
391    ///
392    /// # Errors
393    /// [`NativeWidgetError::Params`] when the reserved identity keys are
394    /// missing.
395    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    /// The setter-call plan for `old` → `new`, given the date the platform
419    /// last reported (`observed`, `None` until the user has picked one) and
420    /// `bounds` — the calling arm's own [`Bounds`], module doc's *Range*.
421    ///
422    /// Order is load-bearing: the style first; then the two range bounds,
423    /// the *widening* one first (compared through `bounds`'
424    /// [`Bounds::effective_range`], so a `None` resolves to — and an
425    /// out-of-range explicit bound clamps to — what this specific platform
426    /// will hold) so the platform never holds an inverted or out-of-range
427    /// pair; then the date, itself clamped into the same resolved range and
428    /// re-planned when the app's date changed, when the platform drifted
429    /// from it (the write-back), or when either bound's CLAMPED value
430    /// changed (a narrowed range can have moved the platform's date).
431    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        // A `min`/`max` setter is planned from the CLAMPED, arm-resolved
447        // value, never the app's own raw one: an explicit bound that
448        // resolves to the same clamped value as before is a no-op for this
449        // platform even when the raw value changed (module doc's *Range*).
450        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        // Growing (or keeping) the effective ceiling widens the range
455        // upward, so write it before the floor; otherwise the floor moves
456        // down first — compared through the CLAMPED effective values, so
457        // the ordering matches what the platform will actually hold.
458        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
489/// One-time warning that clamping into `bounds` (module doc's *Range*)
490/// altered an explicit `min`/`max` or the date — logged at most once per
491/// process, not once per call.
492static WARN_CLAMPED: AtomicBool = AtomicBool::new(false);
493
494/// Log at most once per process when clamping into `bounds` (module doc's
495/// *Range*) actually changed an explicit `min`/`max` or the date — never once
496/// per field, and never when nothing was out of range to begin with.
497fn 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
535/// A packed date field (`crate::events::pack_date`'s layout), `None` when
536/// absent or not a real date — degrade, never a dead slot
537/// (`crate::controls`' rule).
538fn date_field(params: &Params<'_>, key: &str) -> Option<CivilDate> {
539    params.int(key).and_then(unpack_date)
540}
541
542/// `date` clamped into the (already non-inverted) `[min, max]`.
543fn 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
548/// The wire value of `date` for a params body — the same packed integer the
549/// event wire carries (module doc).
550pub(crate) fn wire(date: CivilDate) -> i64 {
551    pack_date(date)
552}
553
554/// Decode an [`EVENT_KIND_DATE`] firing into the typed vocabulary: records
555/// the write-back drift signal (`observed`) and decodes to an
556/// [`EventPayload::Date`].
557///
558/// `None` when `event.kind` is not the date kind (defensive — the listener/
559/// target is only ever attached for this one kind), or when `detail` is not
560/// a real date (`crate::events::unpack_date`), in which case `observed` is
561/// left untouched.
562///
563/// Pure and host-testable, and the ONE decoder all three arms' `on_event`
564/// call — kind/detail parity by construction.
565pub(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/// `NSDate` ↔ [`CivilDate`] for the two Apple arms — both the controls'
578/// setters and the two target classes (`crate::apple::events`,
579/// `crate::appkit::events`) convert through here, so the arms cannot map a
580/// date differently.
581///
582/// Conversion runs in a **Gregorian** `NSCalendar` in the current time zone,
583/// deliberately not `NSCalendar.currentCalendar`: [`CivilDate`] is a
584/// Gregorian date on every arm (Android's `DatePicker` reports Gregorian
585/// fields whatever the user's locale), and a user whose system calendar is,
586/// say, Buddhist or Japanese would otherwise report a different year for the
587/// same absolute day. The picker itself keeps displaying the user's own
588/// calendar — only the Rust-side mapping is pinned. A date becomes an
589/// `NSDate` at local **noon**, so no DST transition at midnight can push it
590/// onto a neighbouring day.
591#[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    /// A Gregorian calendar in the current time zone (module doc).
601    fn gregorian() -> Option<Retained<NSCalendar>> {
602        // SAFETY: `NSCalendarIdentifierGregorian` is an immutable
603        // Foundation-exported `NSString` constant, initialized before any
604        // Rust code runs; reading the extern static is sound.
605        let identifier = unsafe { NSCalendarIdentifierGregorian };
606        NSCalendar::calendarWithIdentifier(identifier)
607    }
608
609    /// `date` at local noon, or `None` if Foundation cannot build it (it
610    /// can for every valid [`CivilDate`]; the `Option` is Foundation's).
611    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    /// The civil date `date` falls on in the current time zone, or `None`
622    /// when it is outside [`CivilDate`]'s range (before year 1 — a BC era —
623    /// or after 9999).
624    pub(crate) fn civil_date(date: &NSDate) -> Option<CivilDate> {
625        let calendar = gregorian()?;
626        // Era 1 is AD in the Gregorian calendar; era 0 years count backwards.
627        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    /// `date` as an `NSDate`, `None` staying `None` — the nullable-range
641    /// setters' argument (`nil` restores the platform's own bound).
642    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    //! The Android half: build the `DatePicker` in the mode `props.style`
650    //! asks for, apply the shared plan, and attach the ONE listener through
651    //! `DatePicker.init(year, month, day, listener)`.
652    //!
653    //! # Construction: the mode is a style resource, not a setter
654    //!
655    //! `android:datePickerMode` is read once, in the constructor, from the
656    //! style it is handed — there is no setter and no `ContextThemeWrapper`
657    //! is involved. So `create` calls the four-argument constructor
658    //! `DatePicker(context, null, 0, <style>)` with a framework style
659    //! resolved from `android.R.style` at runtime (never a hard-coded id):
660    //!
661    //! - `Inline` → `Widget_Material_DatePicker`, whose `datePickerMode` is
662    //!   `calendar`;
663    //! - `Compact`/`Wheels` → `Widget_DatePicker`, which sets no
664    //!   `datePickerMode`, so the constructor's own default — spinner mode —
665    //!   applies; its legacy `calendarViewShown` is then switched off and the
666    //!   spinners on explicitly (`setCalendarViewShown`/`setSpinnersShown`,
667    //!   both meaningful in spinner mode only).
668    //!
669    //! Both styles resolve their colours against the night-qualified
670    //! construction `Context` (theme ladder L1), exactly as the one-argument
671    //! constructor would. Should the style field ever be missing from a
672    //! device's framework, `create` falls back to that one-argument
673    //! constructor (the theme's own default mode) with one warning, rather
674    //! than a dead slot.
675    //!
676    //! # Months are 0-based on this side of the wire only
677    //!
678    //! `DatePicker.init`/`updateDate`/`getMonth` and `java.util.Calendar`
679    //! count months from 0; [`CivilDate`] and the event wire count from 1.
680    //! Every conversion is in this module (and in the Kotlin listener's
681    //! `onDateChanged`, for events) — nothing else sees a 0-based month.
682    //!
683    //! # Range bounds are local-midnight millis
684    //!
685    //! `setMinDate`/`setMaxDate` take epoch milliseconds, which `DatePicker`
686    //! turns back into a day in the device's default time zone; the bound is
687    //! therefore built through `java.util.Calendar.getInstance()` (the same
688    //! default zone) at local midnight, not computed from UTC in Rust. An
689    //! absent bound restores `DatePicker`'s own documented default (1900-01-01
690    //! / 2100-12-31).
691
692    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    /// `android.widget.DatePicker` — the framework class.
707    const CLASS: &str = "android.widget.DatePicker";
708    /// `android.R$style` — the framework's public style ids, read at runtime
709    /// (module doc's *Construction*).
710    const R_STYLE_CLASS: &str = "android.R$style";
711    /// `java.util.Calendar` — the range bounds' millis (module doc).
712    const CALENDAR_CLASS: &str = "java.util.Calendar";
713
714    /// `DatePicker`'s own default floor when no `min` is set.
715    const PLATFORM_MIN: CivilDate = CivilDate::from_parts(1900, 1, 1);
716    /// `DatePicker`'s own default ceiling when no `max` is set.
717    const PLATFORM_MAX: CivilDate = CivilDate::from_parts(2100, 12, 31);
718    /// This arm's [`Bounds`] — module doc's *Range*: a `None` `min`/`max`
719    /// resolves to [`PLATFORM_MIN`]/[`PLATFORM_MAX`] here, never this
720    /// crate's unbounded [`Bounds::APPLE`].
721    const BOUNDS: Bounds = Bounds {
722        floor: PLATFORM_MIN,
723        ceiling: PLATFORM_MAX,
724    };
725
726    /// Build the picker for `style` — module doc's *Construction*.
727    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    /// The picker's current date, read back through `getYear`/`getMonth`/
782    /// `getDayOfMonth` — `init`'s argument when the app supplied no date.
783    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    /// `date` at local midnight as epoch millis, through
812    /// `java.util.Calendar` (module doc's *Range bounds*).
813    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    /// A live picker's retained state.
846    pub(crate) struct DatePickerState {
847        /// The picker's own global reference (the second one — see
848        /// `button.rs`'s note).
849        view: Global<JObject<'static>>,
850        /// The date the platform last reported, `None` while untouched —
851        /// [`DatePickerProps::plan`]'s write-back drift signal. Written from
852        /// [`NativeWidget::on_event`] via [`decode_event`].
853        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            // The style is baked by the constructor and the date is set by
870            // `init` below, so the diffed plan must not re-report either
871            // (`spinner.rs`'s create, the same shape).
872            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            // Read after the range setters ran, so an app that supplied no
878            // date still hands `init` the platform's own. The effective date
879            // is clamped into the resolved range for this arm.
880            let date = match props.effective_date(BOUNDS) {
881                Some(date) => date,
882                None => current_date(ctx, &view)?,
883            };
884            // Attached last, the other controls' create order: nothing above
885            // can reach the runtime as an event.
886            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            // `updateDate`/`setMinDate`/`setMaxDate` echo synchronously into
908            // the listener; that echo re-enters `with_runtime` mid-borrow and
909            // is dropped there (module doc's *Echo guard*) — no suppression
910            // state is held across this call.
911            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            // Detach so a stray in-flight change can't fire after this
927            // slot's instance is gone; dropping `state` releases its global
928            // reference either way.
929            ctx.set_on_date_changed_listener(&state.view, &JObject::null())
930        }
931    }
932
933    /// Execute a whole [`Plan`], front to back — the range bounds before the
934    /// date, the order the shared plan already guarantees.
935    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    /// Execute one planned property write — this control's own setters
947    /// here, the shared ones (`Enabled`, `ContentDescription`) through
948    /// `crate::controls::platform::apply`.
949    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    /// One-time warning that a post-create style change is not applied —
1003    /// module doc's *Construction*: the mode is baked into the constructor
1004    /// style, so only a rebuilt view could change it.
1005    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    /// One-time note that `DatePicker` has no tint or text-colour API — the
1017    /// module doc's *Theme* section: a documented platform gap, not a
1018    /// defect, so debug level.
1019    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    //! The iOS half: build a `UIDatePicker` in `Date` mode and apply the
1033    //! shared plan — no echo guard of its own (UIKit sends no `ValueChanged`
1034    //! for a programmatic `setDate:`; `switch.rs`'s module doc is the
1035    //! reference account).
1036    //!
1037    //! # Construction-time normalization
1038    //!
1039    //! `create` pins `datePickerMode = Date` (a fresh picker is
1040    //! `DateAndTime`) and `preferredDatePickerStyle = Compact` — the
1041    //! [`DatePickerProps::platform_default`] style, rather than UIKit's own
1042    //! `.automatic`, so the diffed create plan starts from the truth.
1043
1044    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    /// A live picker's retained state — `SwitchState`'s shape.
1058    pub(crate) struct DatePickerState {
1059        view: Retained<UIDatePicker>,
1060        /// The target `create` attached as the `ValueChanged` action.
1061        /// `UIControl` holds targets weakly, so this is its only retain
1062        /// (`crate::apple::events`' *Target retention*).
1063        target: Retained<FrustNativeControlTarget>,
1064        /// The date the platform last reported, `None` while untouched —
1065        /// [`DatePickerProps::plan`]'s write-back drift signal.
1066        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            // Module doc's *Construction-time normalization*.
1085            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            // Attached after the initial plan, the other controls' order.
1090            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            // Nothing on this arm can fail, so the drift signal is cleared
1111            // unconditionally (`Switch`'s iOS arm).
1112            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            // Detach so a stray in-flight action can't reach a torn-down
1125            // slot; dropping `state` afterwards releases the target's retain.
1126            state.target.detach_date_picker(&state.view);
1127            Ok(())
1128        }
1129    }
1130
1131    /// The `UIDatePickerStyle` for `style` — module doc's table (top of file).
1132    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    /// Execute a whole [`Plan`], front to back.
1141    fn apply_all(mtm: MainThreadMarker, view: &UIDatePicker, plan: &Plan<'_>) {
1142        for setter in plan {
1143            apply(mtm, view, setter);
1144        }
1145    }
1146
1147    /// Execute one planned property write against `view`.
1148    fn apply(mtm: MainThreadMarker, view: &UIDatePicker, setter: &Setter<'_>) {
1149        match *setter {
1150            // No action is sent for a programmatic date (module doc), so the
1151            // write-back's snap-back is a plain write.
1152            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                // SAFETY: objc2 marks `setTintColor:` unsafe only because the
1165                // header leaves the argument's nullability unannotated;
1166                // passing `None` is `UIView`'s own documented "restore the
1167                // inherited tint" behaviour, exactly `DatePickerTint(None)`'s
1168                // meaning (`crate::controls::platform::set_image_tint`
1169                // closes the same question for `UIImageView`).
1170                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    //! The macOS half: build an `NSDatePicker` showing year/month/day only
1189    //! and apply the shared plan — no echo guard of its own (AppKit sends no
1190    //! action for a programmatic `setDateValue:`; `crate::appkit::events`'
1191    //! *No echo guard*).
1192    //!
1193    //! # Construction-time normalization
1194    //!
1195    //! `create` pins single-date mode, the `YearMonthDay` elements (no
1196    //! time), and the [`DatePickerProps::platform_default`] `Compact`
1197    //! presentation (`textFieldAndStepper` with the calendar overlay on)
1198    //! before the diffed create plan runs.
1199    //!
1200    //! # One action, one kind
1201    //!
1202    //! The picker's single target/action pair is wired with
1203    //! [`crate::events::EVENT_KIND_DATE`]; `frustAction:` reads the sender's
1204    //! `dateValue`, converts it through [`super::foundation`] and packs it
1205    //! with [`crate::events::pack_date`], and `on_event` decodes it through
1206    //! the SAME [`decode_event`] the other two arms call.
1207
1208    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    /// A live picker's retained state — the iOS arm's shape.
1224    pub(crate) struct DatePickerState {
1225        /// The picker, kept typed for its own date/range setters.
1226        view: Retained<NSDatePicker>,
1227        /// The target `create` attached. `NSControl.target` is weak, so this
1228        /// field is its only retain (`crate::appkit::events`' *Target
1229        /// retention*).
1230        target: Retained<FrustNativeControlTarget>,
1231        /// The date the platform last reported, `None` while untouched —
1232        /// [`DatePickerProps::plan`]'s write-back drift signal.
1233        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            // Module doc's *Construction-time normalization*.
1252            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            // Attached after the initial plan, the other controls' order.
1258            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            // Cleared unconditionally, as on iOS: nothing here can fail.
1281            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            // Detach so a stray in-flight action can't reach a torn-down
1294            // slot; dropping `state` afterwards releases the target's retain.
1295            state.target.detach(&state.view);
1296            Ok(())
1297        }
1298    }
1299
1300    /// Apply `style` — module doc's table (top of file): AppKit has no
1301    /// wheels, so `Wheels` is the plain text field and stepper, and
1302    /// `Compact` adds the click-to-open calendar overlay.
1303    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    /// Execute a whole [`Plan`], front to back.
1314    fn apply_all(view: &NSDatePicker, plan: &Plan<'_>) {
1315        for setter in plan {
1316            apply(view, setter);
1317        }
1318    }
1319
1320    /// Execute one planned property write against `view`.
1321    fn apply(view: &NSDatePicker, setter: &Setter<'_>) {
1322        match *setter {
1323            // `setDateValue:` sends no action (module doc's echo guard).
1324            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            // No AppKit tint on `NSDatePicker` — `set_tint`'s generic "this
1336            // view has no tint property" branch logs it (module doc's
1337            // *Theme*).
1338            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    /// The Android arm's own [`Bounds`] (`platform::BOUNDS`, compiled only
1369    /// under `target_os = "android"`) — duplicated here so the ordering
1370    /// fix is host-testable on every target, matching that module's
1371    /// documented 1900-01-01/2100-12-31 literal values.
1372    fn android_bounds() -> Bounds {
1373        Bounds {
1374            floor: date(1900, 1, 1),
1375            ceiling: date(2100, 12, 31),
1376        }
1377    }
1378
1379    // --- CivilDate ------------------------------------------------------
1380
1381    #[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    // --- decode ---------------------------------------------------------
1410
1411    #[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        // 31 April, packed by hand — never a date the platform is handed.
1446        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    // --- plan -----------------------------------------------------------
1494
1495    #[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        // The user picked 3 October; the app kept 29 September and changed
1551        // something else — the picker must snap back.
1552        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        // `old.max` (2150-01-01) is explicit and, unclamped, ABOVE Android's
1621        // own ceiling — but `plan` now clamps every explicit bound into the
1622        // arm's own range (module doc's *Range*) before comparing, so the
1623        // platform never actually held 2150-01-01: it held the clamped
1624        // 2100-12-31, the exact value `new.max = None` resolves to on this
1625        // arm. The effective ceiling is therefore unchanged and only the
1626        // floor moves. The Apple bounds never clamp anything here
1627        // (2150-01-01 is inside `CivilDate::MIN..CivilDate::MAX`), so there
1628        // `None` genuinely widens the ceiling (resolving to
1629        // `CivilDate::MAX`, above the old explicit 2150-01-01) and it is
1630        // written first — the two arms disagree on order for the exact same
1631        // props.
1632        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        // An app setting min to 2150-01-01 with no max is valid API input
1661        // on Apple (ceiling 9999-12-31) but resolves inverted for Android's
1662        // own 1900-01-01..2100-12-31 default. `plan` clamps the explicit
1663        // bound into the arm's range rather than hand an inverted pair to
1664        // the platform's own setter.
1665        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        // The warning is logged but the plan proceeds with the clamped
1669        // setter.
1670        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    // --- effective_date --------------------------------------------------
1790
1791    #[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        // Test structure: (bounds_name, bounds, min, max, date, description)
1835        // Each case tests both dates inside and outside the effective range.
1836        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            // Android bounds cases
1847            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), // above ceiling
1854            },
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), // below min
1862            },
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), // above max
1870            },
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), // above max
1878            },
1879            TestCase {
1880                name: "android_floor_collapsed",
1881                bounds: android_bounds(),
1882                min: Some(date(2150, 1, 1)), // above ceiling, collapses to ceiling
1883                max: None,
1884                date_in_range: date(2100, 12, 31), // at the collapsed floor/ceiling
1885                date_out_of_range: date(2026, 6, 1), // below the collapsed range
1886            },
1887            // Apple bounds cases
1888            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), // at the ceiling (still in range for Apple)
1895            },
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), // below min
1903            },
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), // above max
1911            },
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), // above max
1919            },
1920            TestCase {
1921                name: "apple_floor_collapsed",
1922                bounds: Bounds::APPLE,
1923                min: Some(date(2150, 1, 1)), // inverted with max below
1924                max: Some(date(2050, 12, 31)),
1925                date_in_range: date(2050, 12, 31), // at the collapsed floor/ceiling
1926                date_out_of_range: date(2026, 6, 1), // below the collapsed range
1927            },
1928        ];
1929
1930        for tc in test_cases {
1931            // Test date inside effective range
1932            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            // Test date outside effective range (gets clamped)
1966            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            // Test that unchanged date from old props does not emit a Date setter
2000            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    // --- events ---------------------------------------------------------
2025
2026    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    // There is no per-instance suppression parameter to test an echo
2066    // against: `DatePicker.updateDate`'s listener notification is
2067    // synchronous, so the ONLY guard against a `Setter::Date` echo is
2068    // `crate::runtime::with_runtime`'s re-entrancy drop one layer up
2069    // (module doc's *Echo guard*) — pinned by
2070    // `runtime::tests::the_thread_local_runtime_is_reentrancy_tolerant`,
2071    // exactly as `slider.rs`'s tests note for `Setter::Progress`.
2072}