Skip to main content

azul_layout/widgets/
date_picker.rs

1//! Calendar date picker widget.
2//!
3//! Renders a month header (‹ prev / `Month YYYY` / next ›) above a weekday
4//! header row (`Su Mo Tu We Th Fr Sa`) and a 7-column grid of day cells laid
5//! out as week rows. The grid is computed from real calendar math — days in
6//! month (with leap-year February) and the weekday offset of the 1st (Sakamoto's
7//! algorithm) — so the leading blank cells and day count are correct for the
8//! given month.
9//!
10//! Clicking a day cell selects it: the handler reads the cell's baked day
11//! number, updates `state.day`, fires the optional `on_change(state)`, and
12//! live-restyles the grid (accent fill on the selected cell, neutral on the
13//! rest) exactly like `segmented.rs` restyles its active segment. The day number
14//! is carried per-cell (like `drop_down.rs`'s per-item data) alongside a clone of
15//! the shared-state handle, so selection never depends on re-deriving the grid
16//! offset at click time.
17//!
18//! TODO2 — MONTH NAVIGATION CANNOT REBUILD THE GRID IN-WIDGET.
19//! Clicking ‹ / › changes a *different month*, which has a different day count
20//! and weekday offset, i.e. a different *number of day-cell nodes*. A widget
21//! callback can only `set_css_property` / `change_node_text` on the EXISTING
22//! nodes — it cannot add/remove/relayout day cells (the same limitation
23//! `combobox`'s type-to-filter hit). Therefore the ‹ / › buttons DO update the
24//! month/year in the state and fire `on_change(state)` so host code can rebuild
25//! the widget (a fresh `DatePicker::create(...)` with the new month), but the
26//! in-widget grid does NOT change, and the header is deliberately NOT re-texted
27//! either — showing a new month name over the old day grid would be a misleading
28//! half-switch. Day-selection (the restyle) is fully functional for the
29//! displayed month; after a ‹ / › without a host rebuild the grid is stale (the
30//! documented limitation). Computing the initial grid from calendar math is NOT
31//! faked behaviour — only the live month rebuild is the limitation.
32//!
33//! Key types: [`DatePicker`], [`DatePickerState`], [`DatePickerOnChange`].
34
35use std::vec::Vec;
36
37use azul_core::{
38    callbacks::{CoreCallback, CoreCallbackData, Update},
39    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
40    refany::{OptionRefAny, RefAny},
41};
42use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
43use azul_css::{
44    props::{
45        basic::{color::ColorU, StyleFontSize},
46        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutAlignItems, LayoutWidth, LayoutHeight},
47        property::{CssProperty, *},
48        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextAlign, StyleCursor, StyleUserSelect, StyleTextColor},
49    },
50    impl_option_inner, AzString,
51};
52
53use crate::callbacks::{Callback, CallbackInfo};
54
55// ---- classes ----
56static DATE_PICKER_CLASS: &[IdOrClass] =
57    &[Class(AzString::from_const_str("__azul-native-date-picker"))];
58static HEADER_CLASS: &[IdOrClass] =
59    &[Class(AzString::from_const_str("__azul-native-date-picker-header"))];
60static HEADER_LABEL_CLASS: &[IdOrClass] =
61    &[Class(AzString::from_const_str("__azul-native-date-picker-label"))];
62static NAV_BTN_CLASS: &[IdOrClass] =
63    &[Class(AzString::from_const_str("__azul-native-date-picker-nav"))];
64static WEEKDAY_ROW_CLASS: &[IdOrClass] =
65    &[Class(AzString::from_const_str("__azul-native-date-picker-weekdays"))];
66static WEEKDAY_CELL_CLASS: &[IdOrClass] =
67    &[Class(AzString::from_const_str("__azul-native-date-picker-weekday"))];
68static GRID_CLASS: &[IdOrClass] =
69    &[Class(AzString::from_const_str("__azul-native-date-picker-grid"))];
70static WEEK_ROW_CLASS: &[IdOrClass] =
71    &[Class(AzString::from_const_str("__azul-native-date-picker-week"))];
72static DAY_CELL_CLASS: &[IdOrClass] =
73    &[Class(AzString::from_const_str("__azul-native-date-picker-day"))];
74
75const PREV_ARROW: AzString = AzString::from_const_str("\u{2039}"); // ‹
76const NEXT_ARROW: AzString = AzString::from_const_str("\u{203A}"); // ›
77
78const WEEKDAY_NAMES: [AzString; 7] = [
79    AzString::from_const_str("Su"),
80    AzString::from_const_str("Mo"),
81    AzString::from_const_str("Tu"),
82    AzString::from_const_str("We"),
83    AzString::from_const_str("Th"),
84    AzString::from_const_str("Fr"),
85    AzString::from_const_str("Sa"),
86];
87
88/// Callback type invoked when the selected day or displayed month/year changes.
89pub type DatePickerOnChangeCallbackType =
90    extern "C" fn(RefAny, CallbackInfo, DatePickerState) -> Update;
91impl_widget_callback!(
92    DatePickerOnChange,
93    OptionDatePickerOnChange,
94    DatePickerOnChangeCallback,
95    DatePickerOnChangeCallbackType
96);
97
98azul_core::impl_managed_callback! {
99    wrapper:        DatePickerOnChangeCallback,
100    info_ty:        CallbackInfo,
101    return_ty:      Update,
102    default_ret:    Update::DoNothing,
103    invoker_static: DATE_PICKER_ON_CHANGE_INVOKER,
104    invoker_ty:     AzDatePickerOnChangeCallbackInvoker,
105    thunk_fn:       az_date_picker_on_change_callback_thunk,
106    setter_fn:      AzApp_setDatePickerOnChangeCallbackInvoker,
107    from_handle_fn: AzDatePickerOnChangeCallback_createFromHostHandle,
108    extra_args:     [ state: DatePickerState ],
109}
110
111/// A calendar date picker.
112#[derive(Debug, Clone, PartialEq, Eq)]
113#[repr(C)]
114pub struct DatePicker {
115    pub state: DatePickerStateWrapper,
116    /// Style for the outer container.
117    pub container_style: CssPropertyWithConditionsVec,
118}
119
120/// Wraps [`DatePickerState`] together with its change callback.
121#[derive(Debug, Default, Clone, PartialEq, Eq)]
122#[repr(C)]
123pub struct DatePickerStateWrapper {
124    pub inner: DatePickerState,
125    pub on_change: OptionDatePickerOnChange,
126}
127
128/// State of a [`DatePicker`]: the displayed month/year and the selected day.
129#[derive(Debug, Copy, Clone, PartialEq, Eq)]
130#[repr(C)]
131pub struct DatePickerState {
132    /// The displayed (and selected) year.
133    pub year: u32,
134    /// The displayed (and selected) month, `1..=12`.
135    pub month: u32,
136    /// The selected day of the month, `1..=31`.
137    pub day: u32,
138}
139
140impl Default for DatePickerState {
141    fn default() -> Self {
142        Self {
143            year: 2000,
144            month: 1,
145            day: 1,
146        }
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Pure calendar math (standard, well-known formulas — not faked behaviour).
152// ---------------------------------------------------------------------------
153
154/// Gregorian leap-year test.
155const fn is_leap(year: u32) -> bool {
156    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
157}
158
159/// Number of days in the given (1-based) month of the given year.
160#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
161const fn days_in_month(year: u32, month: u32) -> u32 {
162    match month {
163        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
164        4 | 6 | 9 | 11 => 30,
165        2 => {
166            if is_leap(year) {
167                29
168            } else {
169                28
170            }
171        }
172        _ => 30, // defensive (month is clamped to 1..=12 elsewhere)
173    }
174}
175
176/// Sakamoto's algorithm: weekday of `(year, month, day)`, returned as
177/// `0 = Sunday .. 6 = Saturday`. Verified: 2000-01-01 -> 6 (Saturday).
178#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded layout/render numeric cast
179fn weekday(year: u32, month: u32, day: u32) -> u32 {
180    const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
181    let mut y = year as i32;
182    if month < 3 {
183        y -= 1;
184    }
185    let idx = if (1..=12).contains(&month) {
186        (month - 1) as usize
187    } else {
188        0
189    };
190    let w = (y + y / 4 - y / 100 + y / 400 + T[idx] + day as i32) % 7;
191    (((w % 7) + 7) % 7) as u32
192}
193
194/// English month name for a 1-based month index.
195const fn month_name(month: u32) -> &'static str {
196    const NAMES: [&str; 12] = [
197        "January",
198        "February",
199        "March",
200        "April",
201        "May",
202        "June",
203        "July",
204        "August",
205        "September",
206        "October",
207        "November",
208        "December",
209    ];
210    let idx = month.saturating_sub(1) as usize;
211    if idx < 12 {
212        NAMES[idx]
213    } else {
214        ""
215    }
216}
217
218// ---- colours ----
219const BORDER_COLOR: ColorU = ColorU { r: 206, g: 212, b: 218, a: 255 };
220const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
221const MUTED_COLOR: ColorU = ColorU { r: 108, g: 117, b: 125, a: 255 };
222const ACCENT_BG: ColorU = ColorU { r: 13, g: 110, b: 253, a: 255 };
223const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
224const TRANSPARENT: ColorU = ColorU { r: 0, g: 0, b: 0, a: 0 };
225
226const DAY_SELECTED_BG_ITEMS: &[StyleBackgroundContent] =
227    &[StyleBackgroundContent::Color(ACCENT_BG)];
228const DAY_SELECTED_BG_VEC: StyleBackgroundContentVec =
229    StyleBackgroundContentVec::from_const_slice(DAY_SELECTED_BG_ITEMS);
230const TRANSPARENT_BG_ITEMS: &[StyleBackgroundContent] =
231    &[StyleBackgroundContent::Color(TRANSPARENT)];
232const TRANSPARENT_BG_VEC: StyleBackgroundContentVec =
233    StyleBackgroundContentVec::from_const_slice(TRANSPARENT_BG_ITEMS);
234const WHITE_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(WHITE)];
235const WHITE_BG_VEC: StyleBackgroundContentVec =
236    StyleBackgroundContentVec::from_const_slice(WHITE_BG_ITEMS);
237
238const CELL_W: isize = 32;
239const CELL_H: isize = 28;
240
241/// Outer container: a column that hugs its content, bordered + white.
242static CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
243    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
244    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
245    CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
246    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
247    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(8))),
248    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
249        LayoutPaddingBottom::const_px(8),
250    )),
251    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
252        8,
253    ))),
254    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
255        LayoutPaddingRight::const_px(8),
256    )),
257    CssPropertyWithConditions::simple(CssProperty::const_background_content(WHITE_BG_VEC)),
258    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
259        LayoutBorderTopWidth::const_px(1),
260    )),
261    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
262        LayoutBorderBottomWidth::const_px(1),
263    )),
264    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
265        LayoutBorderLeftWidth::const_px(1),
266    )),
267    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
268        LayoutBorderRightWidth::const_px(1),
269    )),
270    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
271        inner: BorderStyle::Solid,
272    })),
273    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
274        StyleBorderBottomStyle {
275            inner: BorderStyle::Solid,
276        },
277    )),
278    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
279        inner: BorderStyle::Solid,
280    })),
281    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
282        StyleBorderRightStyle {
283            inner: BorderStyle::Solid,
284        },
285    )),
286    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
287        inner: BORDER_COLOR,
288    })),
289    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
290        StyleBorderBottomColor { inner: BORDER_COLOR },
291    )),
292    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
293        inner: BORDER_COLOR,
294    })),
295    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
296        StyleBorderRightColor { inner: BORDER_COLOR },
297    )),
298    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
299        StyleBorderTopLeftRadius::const_px(6),
300    )),
301    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
302        StyleBorderTopRightRadius::const_px(6),
303    )),
304    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
305        StyleBorderBottomLeftRadius::const_px(6),
306    )),
307    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
308        StyleBorderBottomRightRadius::const_px(6),
309    )),
310];
311
312/// Header row: prev button / centred label / next button.
313static HEADER_STYLE: &[CssPropertyWithConditions] = &[
314    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
315    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
316    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
317    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
318        LayoutPaddingBottom::const_px(6),
319    )),
320];
321
322/// The ‹ / › nav buttons.
323static NAV_BTN_STYLE: &[CssPropertyWithConditions] = &[
324    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(24))),
325    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(18))),
326    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
327    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
328    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
329    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
330        inner: TEXT_COLOR,
331    })),
332    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
333];
334
335/// The `Month YYYY` label (centred, fills the header).
336static HEADER_LABEL_STYLE: &[CssPropertyWithConditions] = &[
337    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
338    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
339    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
340    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
341    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
342        inner: TEXT_COLOR,
343    })),
344];
345
346/// Weekday header row + cells.
347static ROW_STYLE: &[CssPropertyWithConditions] = &[
348    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
349    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
350    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
351];
352
353static WEEKDAY_CELL_STYLE: &[CssPropertyWithConditions] = &[
354    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(CELL_W))),
355    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(11))),
356    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
357    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
358    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
359        inner: MUTED_COLOR,
360    })),
361    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
362        LayoutPaddingBottom::const_px(4),
363    )),
364];
365
366/// Grid: column of week rows.
367static GRID_STYLE: &[CssPropertyWithConditions] = &[
368    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
369    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Column)),
370    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
371];
372
373/// Blank (offset / trailing) cell — keeps the column width, no text/callback.
374static BLANK_CELL_STYLE: &[CssPropertyWithConditions] = &[
375    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(CELL_W))),
376    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(CELL_H))),
377];
378
379/// Builds the per-day-cell style. Only the background + text colour depend on
380/// the selected flag (the rest is shared), so the style is built at runtime
381/// (mirrors `segmented::build_segment_style`).
382fn build_day_cell_style(selected: bool) -> CssPropertyWithConditionsVec {
383    let (bg, text) = if selected {
384        (DAY_SELECTED_BG_VEC, WHITE)
385    } else {
386        (TRANSPARENT_BG_VEC, TEXT_COLOR)
387    };
388    CssPropertyWithConditionsVec::from_vec(alloc::vec![
389        CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(CELL_W))),
390        CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(CELL_H))),
391        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
392        CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Center)),
393        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
394            5,
395        ))),
396        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
397        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
398        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
399            StyleBorderTopLeftRadius::const_px(4),
400        )),
401        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
402            StyleBorderTopRightRadius::const_px(4),
403        )),
404        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
405            StyleBorderBottomLeftRadius::const_px(4),
406        )),
407        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
408            StyleBorderBottomRightRadius::const_px(4),
409        )),
410        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg)),
411        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
412            inner: text,
413        })),
414    ])
415}
416
417/// Per-day-cell callback payload: the cell's day number + a clone of the shared
418/// state handle (so the handler can update `state.day` + fire `on_change`).
419struct DayCellData {
420    day: u32,
421    state: RefAny,
422}
423
424impl DatePicker {
425    /// Creates a new `DatePicker` showing `year`/`month` with `day` selected.
426    /// `month` is clamped to `1..=12` and `day` to `1..=days_in_month`.
427    #[must_use] pub fn create(year: u32, month: u32, day: u32) -> Self {
428        let month = month.clamp(1, 12);
429        let dim = days_in_month(year, month);
430        let day = day.clamp(1, dim);
431        Self {
432            state: DatePickerStateWrapper {
433                inner: DatePickerState { year, month, day },
434                on_change: None.into(),
435            },
436            container_style: CssPropertyWithConditionsVec::from_const_slice(CONTAINER_STYLE),
437        }
438    }
439
440    /// Sets the callback invoked when the selection or month changes.
441    pub fn set_on_change<C: Into<DatePickerOnChangeCallback>>(&mut self, data: RefAny, callback: C) {
442        self.state.on_change = Some(DatePickerOnChange {
443            callback: callback.into(),
444            refany: data,
445        })
446        .into();
447    }
448
449    /// Builder variant of [`Self::set_on_change`].
450    #[must_use] pub fn with_on_change<C: Into<DatePickerOnChangeCallback>>(
451        mut self,
452        data: RefAny,
453        callback: C,
454    ) -> Self {
455        self.set_on_change(data, callback);
456        self
457    }
458
459    /// Replaces `self` with the default value and returns the original.
460    #[must_use] pub fn swap_with_default(&mut self) -> Self {
461        let mut s = Self::create(2000, 1, 1);
462        core::mem::swap(&mut s, self);
463        s
464    }
465
466    #[must_use] pub fn dom(self) -> Dom {
467        let inner = self.state.inner;
468        let year = inner.year;
469        let month = inner.month.clamp(1, 12);
470        let sel_day = inner.day;
471        let container_style = self.container_style.clone();
472
473        let shared = RefAny::new(self.state);
474
475        let header = build_header(year, month, shared.clone());
476        let weekday_row = build_weekday_row();
477        let grid = build_grid(year, month, sel_day, shared);
478
479        Dom::create_div()
480            .with_ids_and_classes(IdOrClassVec::from_const_slice(DATE_PICKER_CLASS))
481            .with_css_props(container_style)
482            .with_children(alloc::vec![header, weekday_row, grid].into())
483    }
484}
485
486impl Default for DatePicker {
487    fn default() -> Self {
488        Self::create(2000, 1, 1)
489    }
490}
491
492fn build_header(year: u32, month: u32, shared: RefAny) -> Dom {
493    use azul_core::dom::{EventFilter, HoverEventFilter};
494
495    let nav = |arrow: AzString, cb: usize, refany: RefAny| -> Dom {
496        Dom::create_text(arrow)
497            .with_ids_and_classes(IdOrClassVec::from_const_slice(NAV_BTN_CLASS))
498            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(NAV_BTN_STYLE))
499            .with_callbacks(
500                alloc::vec![CoreCallbackData {
501                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
502                    callback: CoreCallback {
503                        cb,
504                        ctx: OptionRefAny::None,
505                    },
506                    refany,
507                }]
508                .into(),
509            )
510            .with_tab_index(TabIndex::Auto)
511    };
512
513    let label = AzString::from(format!("{} {}", month_name(month), year));
514
515    Dom::create_div()
516        .with_ids_and_classes(IdOrClassVec::from_const_slice(HEADER_CLASS))
517        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(HEADER_STYLE))
518        .with_children(
519            alloc::vec![
520                nav(PREV_ARROW, on_prev_month as usize, shared.clone()),
521                Dom::create_text(label)
522                    .with_ids_and_classes(IdOrClassVec::from_const_slice(HEADER_LABEL_CLASS))
523                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
524                        HEADER_LABEL_STYLE,
525                    )),
526                nav(NEXT_ARROW, on_next_month as usize, shared),
527            ]
528            .into(),
529        )
530}
531
532fn build_weekday_row() -> Dom {
533    let cells: Vec<Dom> = WEEKDAY_NAMES
534        .iter()
535        .map(|n| {
536            Dom::create_text(n.clone())
537                .with_ids_and_classes(IdOrClassVec::from_const_slice(WEEKDAY_CELL_CLASS))
538                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
539                    WEEKDAY_CELL_STYLE,
540                ))
541        })
542        .collect();
543
544    Dom::create_div()
545        .with_ids_and_classes(IdOrClassVec::from_const_slice(WEEKDAY_ROW_CLASS))
546        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ROW_STYLE))
547        .with_children(cells.into())
548}
549
550#[allow(clippy::needless_pass_by_value)] // shared RefAny handle cloned per day cell
551fn build_grid(year: u32, month: u32, sel_day: u32, shared: RefAny) -> Dom {
552    let leading = weekday(year, month, 1);
553    let dim = days_in_month(year, month);
554    let total = leading + dim;
555    let rows = total.div_ceil(7);
556
557    let mut week_rows: Vec<Dom> = Vec::with_capacity(rows as usize);
558    for r in 0..rows {
559        let mut cells: Vec<Dom> = Vec::with_capacity(7);
560        for c in 0..7 {
561            let i = r * 7 + c;
562            if i < leading || i >= leading + dim {
563                cells.push(build_blank_cell());
564            } else {
565                let day = i - leading + 1;
566                cells.push(build_day_cell(day, day == sel_day, shared.clone()));
567            }
568        }
569        week_rows.push(
570            Dom::create_div()
571                .with_ids_and_classes(IdOrClassVec::from_const_slice(WEEK_ROW_CLASS))
572                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(ROW_STYLE))
573                .with_children(cells.into()),
574        );
575    }
576
577    Dom::create_div()
578        .with_ids_and_classes(IdOrClassVec::from_const_slice(GRID_CLASS))
579        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(GRID_STYLE))
580        .with_children(week_rows.into())
581}
582
583fn build_blank_cell() -> Dom {
584    Dom::create_div()
585        .with_ids_and_classes(IdOrClassVec::from_const_slice(DAY_CELL_CLASS))
586        .with_css_props(CssPropertyWithConditionsVec::from_const_slice(BLANK_CELL_STYLE))
587}
588
589fn build_day_cell(day: u32, selected: bool, shared: RefAny) -> Dom {
590    use azul_core::dom::{EventFilter, HoverEventFilter};
591
592    Dom::create_text(AzString::from(format!("{day}")))
593        .with_ids_and_classes(IdOrClassVec::from_const_slice(DAY_CELL_CLASS))
594        .with_css_props(build_day_cell_style(selected))
595        .with_callbacks(
596            alloc::vec![CoreCallbackData {
597                event: EventFilter::Hover(HoverEventFilter::MouseUp),
598                callback: CoreCallback {
599                    cb: on_day_click as usize,
600                    ctx: OptionRefAny::None,
601                },
602                refany: RefAny::new(DayCellData { day, state: shared }),
603            }]
604            .into(),
605        )
606        .with_tab_index(TabIndex::Auto)
607}
608
609/// Day-cell click handler. Reads the cell's baked day, updates the shared
610/// `state.day`, fires `on_change`, and live-restyles the whole grid.
611extern "C" fn on_day_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
612    let clicked = info.get_hit_node();
613
614    // Read the baked day + clone the shared-state handle.
615    let (day, mut shared) = {
616        let Some(cell) = data.downcast_ref::<DayCellData>() else {
617            return Update::DoNothing;
618        };
619        (cell.day, cell.state.clone())
620    };
621
622    let update = {
623        let Some(mut w) = shared.downcast_mut::<DatePickerStateWrapper>() else {
624            return Update::DoNothing;
625        };
626        w.inner.day = day;
627        let inner = w.inner;
628        let w = &mut *w;
629        match w.on_change.as_mut() {
630            Some(DatePickerOnChange { callback, refany }) => {
631                (callback.cb)(refany.clone(), info, inner)
632            }
633            None => Update::DoNothing,
634        }
635    };
636
637    restyle_days(&mut info, clicked);
638
639    update
640}
641
642/// Accents the clicked cell and neutralises every other grid cell (blanks
643/// included — for them transparent bg + a colour on empty text is a no-op).
644fn restyle_days(info: &mut CallbackInfo, clicked: azul_core::dom::DomNodeId) {
645    let Some(row) = info.get_parent(clicked) else {
646        return;
647    };
648    let Some(grid) = info.get_parent(row) else {
649        return;
650    };
651
652    let mut week = info.get_first_child(grid);
653    while let Some(w) = week {
654        let mut cellopt = info.get_first_child(w);
655        while let Some(cell) = cellopt {
656            if cell == clicked {
657                info.set_css_property(
658                    cell,
659                    CssProperty::const_background_content(DAY_SELECTED_BG_VEC),
660                );
661                info.set_css_property(
662                    cell,
663                    CssProperty::const_text_color(StyleTextColor { inner: WHITE }),
664                );
665            } else {
666                info.set_css_property(
667                    cell,
668                    CssProperty::const_background_content(TRANSPARENT_BG_VEC),
669                );
670                info.set_css_property(
671                    cell,
672                    CssProperty::const_text_color(StyleTextColor { inner: TEXT_COLOR }),
673                );
674            }
675            cellopt = info.get_next_sibling(cell);
676        }
677        week = info.get_next_sibling(w);
678    }
679}
680
681extern "C" fn on_prev_month(data: RefAny, info: CallbackInfo) -> Update {
682    month_nav(data, info, -1)
683}
684
685extern "C" fn on_next_month(data: RefAny, info: CallbackInfo) -> Update {
686    month_nav(data, info, 1)
687}
688
689/// Month navigation. Updates month/year (wrapping across year boundaries),
690/// clamps the selected day into the new month, and fires `on_change` so host
691/// code can rebuild the widget. TODO2: the in-widget grid is NOT rebuilt (see
692/// module docs) — only the reported state changes.
693#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] // bounded layout/render numeric cast
694fn month_nav(mut data: RefAny, info: CallbackInfo, delta: i32) -> Update {
695    let Some(mut w) = data.downcast_mut::<DatePickerStateWrapper>() else {
696        return Update::DoNothing;
697    };
698
699    let mut month = w.inner.month as i32 + delta;
700    let mut year = w.inner.year as i32;
701    if month < 1 {
702        month = 12;
703        year -= 1;
704    } else if month > 12 {
705        month = 1;
706        year += 1;
707    }
708    w.inner.year = year.max(1) as u32;
709    w.inner.month = month as u32;
710    let dim = days_in_month(w.inner.year, w.inner.month);
711    if w.inner.day > dim {
712        w.inner.day = dim;
713    }
714
715    let inner = w.inner;
716    let w = &mut *w;
717    match w.on_change.as_mut() {
718        Some(DatePickerOnChange { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
719        None => Update::DoNothing,
720    }
721}
722
723impl From<DatePicker> for Dom {
724    fn from(d: DatePicker) -> Self {
725        d.dom()
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn leap_years() {
735        assert!(is_leap(2000));
736        assert!(is_leap(2024));
737        assert!(!is_leap(1900));
738        assert!(!is_leap(2023));
739    }
740
741    #[test]
742    fn days_per_month() {
743        assert_eq!(days_in_month(2023, 2), 28);
744        assert_eq!(days_in_month(2024, 2), 29);
745        assert_eq!(days_in_month(2024, 4), 30);
746        assert_eq!(days_in_month(2024, 1), 31);
747    }
748
749    #[test]
750    fn weekday_known_dates() {
751        // 2000-01-01 was a Saturday (6).
752        assert_eq!(weekday(2000, 1, 1), 6);
753        // 2026-06-01 is a Monday (1).
754        assert_eq!(weekday(2026, 6, 1), 1);
755        // 1970-01-01 was a Thursday (4).
756        assert_eq!(weekday(1970, 1, 1), 4);
757    }
758}
759
760#[cfg(test)]
761mod autotest_generated {
762    use std::{
763        collections::{BTreeMap, HashMap},
764        sync::{Arc, Mutex},
765    };
766
767    use azul_core::{
768        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
769        geom::{LogicalRect, OptionLogicalPosition},
770        gl::OptionGlContextPtr,
771        hit_test::ScrollPosition,
772        resources::RendererResources,
773        styled_dom::{NodeHierarchyItemId, StyledDom},
774        window::{MonitorVec, RawWindowHandle},
775    };
776    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
777    use rust_fontconfig::FcFontCache;
778
779    use super::*;
780    #[cfg(feature = "icu")]
781    use crate::icu::IcuLocalizerHandle;
782    use crate::{
783        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
784        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
785        window::{DomLayoutResult, LayoutWindow},
786        window_state::FullWindowState,
787    };
788
789    // ==================================================================
790    // Harness
791    // ==================================================================
792
793    /// The largest `year` `weekday()` can take before its `y + y / 4` term
794    /// overflows `i32`. Sakamoto's accumulator is `i32`, so every input above
795    /// this (and below `2^31`, where the `u32 -> i32` cast wraps negative and
796    /// the sum becomes small again) is a debug-build overflow panic. Tests stay
797    /// strictly inside the safe band; see the report for the finding.
798    const MAX_SAFE_WEEKDAY_YEAR: u32 = 1_717_986_916;
799
800    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
801    fn node(idx: usize) -> DomNodeId {
802        DomNodeId {
803            dom: DomId::ROOT_ID,
804            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
805        }
806    }
807
808    /// A `DomNodeId` whose node component is `None` — the "no concrete node was
809    /// hit" case. `CallbackInfo::set_css_property` *panics* on such an id, so
810    /// `restyle_days` must bail out before it ever reaches one.
811    fn node_none() -> DomNodeId {
812        DomNodeId {
813            dom: DomId::ROOT_ID,
814            node: NodeHierarchyItemId::NONE,
815        }
816    }
817
818    /// A `DomLayoutResult` carrying only a `styled_dom`: the date-picker handlers
819    /// reach exactly four `CallbackInfo` queries (`get_hit_node`, `get_parent`,
820    /// `get_first_child`, `get_next_sibling`), all of which read the node
821    /// hierarchy only — no real layout (and no font) is needed.
822    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
823        DomLayoutResult {
824            styled_dom,
825            layout_tree: LayoutTree {
826                nodes: Vec::new(),
827                warm: Vec::new(),
828                cold: Vec::new(),
829                root: 0,
830                dom_to_layout: BTreeMap::new(),
831                children_arena: Vec::new(),
832                children_offsets: Vec::new(),
833                subtree_needs_intrinsic: Vec::new(),
834            },
835            calculated_positions: Vec::new(),
836            viewport: LogicalRect::zero(),
837            display_list: DisplayList::default(),
838            scroll_ids: HashMap::new(),
839            scroll_id_to_node_id: HashMap::new(),
840        }
841    }
842
843    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root
844    /// DOM and whose hit node is `hit`. Returns `f`'s value plus every change the
845    /// callback pushed onto the transaction log.
846    fn with_info<R>(
847        styled_dom: StyledDom,
848        hit: DomNodeId,
849        f: impl FnOnce(&mut CallbackInfo) -> R,
850    ) -> (R, Vec<CallbackChange>) {
851        let mut layout_window =
852            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
853        layout_window
854            .layout_results
855            .insert(DomId::ROOT_ID, layout_result(styled_dom));
856
857        let renderer_resources = RendererResources::default();
858        let previous_window_state: Option<FullWindowState> = None;
859        let current_window_state = FullWindowState::default();
860        let gl_context = OptionGlContextPtr::None;
861        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
862            BTreeMap::new();
863        let window_handle = RawWindowHandle::Unsupported;
864        let system_callbacks = ExternalSystemCallbacks::rust_internal();
865
866        let ref_data = CallbackInfoRefData {
867            layout_window: &layout_window,
868            renderer_resources: &renderer_resources,
869            previous_window_state: &previous_window_state,
870            current_window_state: &current_window_state,
871            gl_context: &gl_context,
872            current_scroll_manager: &scroll_states,
873            current_window_handle: &window_handle,
874            system_callbacks: &system_callbacks,
875            system_style: Arc::new(azul_css::system::SystemStyle::default()),
876            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
877            #[cfg(feature = "icu")]
878            icu_localizer: IcuLocalizerHandle::default(),
879            ctx: OptionRefAny::None,
880        };
881
882        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
883
884        let mut info = CallbackInfo::new(
885            &ref_data,
886            &changes,
887            hit,
888            OptionLogicalPosition::None,
889            OptionLogicalPosition::None,
890        );
891
892        let r = f(&mut info);
893        let pushed = info.take_changes();
894        (r, pushed)
895    }
896
897    // ------------------------------------------------------------------
898    // Tree probes (never assume a flatten index — always look the node up)
899    // ------------------------------------------------------------------
900
901    /// The three sections a date picker always renders, in order.
902    fn sections(dom: &Dom) -> (&Dom, &Dom, &Dom) {
903        let c = dom.children.as_ref();
904        assert_eq!(
905            c.len(),
906            3,
907            "a date picker renders header + weekday row + grid, got {} children",
908            c.len(),
909        );
910        (&c[0], &c[1], &c[2])
911    }
912
913    /// Every cell of the grid, in reading order (blanks included).
914    fn grid_cells(grid: &Dom) -> Vec<&Dom> {
915        grid.children
916            .as_ref()
917            .iter()
918            .flat_map(|week| week.children.as_ref().iter())
919            .collect()
920    }
921
922    /// The text a node renders, or `None` for a non-text node (a blank cell).
923    fn text_of(dom: &Dom) -> Option<String> {
924        dom.root.get_node_type().format()
925    }
926
927    /// The day numbers of a grid in reading order; `None` marks a blank cell.
928    fn day_numbers(grid: &Dom) -> Vec<Option<u32>> {
929        grid_cells(grid)
930            .into_iter()
931            .map(|c| text_of(c).map(|t| t.parse::<u32>().expect("a day cell is not a number")))
932            .collect()
933    }
934
935    fn classes(dom: &Dom) -> Vec<String> {
936        dom.root
937            .get_ids_and_classes()
938            .as_ref()
939            .iter()
940            .filter_map(|c| match c {
941                IdOrClass::Class(s) => Some(s.as_str().to_string()),
942                IdOrClass::Id(_) => None,
943            })
944            .collect()
945    }
946
947    /// The recursive `1-per-descendant` total of a `Dom`'s children — what
948    /// `estimated_total_children` caches. An under-report makes the flatten
949    /// under-allocate its arenas and panic on an out-of-bounds write.
950    fn descendants(dom: &Dom) -> usize {
951        dom.children
952            .as_ref()
953            .iter()
954            .map(|c| 1 + descendants(c))
955            .sum()
956    }
957
958    /// The `RefAny` the widget baked into its own shared state (carried by the
959    /// nav buttons and by every day cell).
960    fn shared_state(sd: &StyledDom) -> RefAny {
961        for nd in sd.node_data.as_ref() {
962            for cb in nd.callbacks.as_ref() {
963                let matches = {
964                    let mut r = cb.refany.clone();
965                    let matches = r.downcast_ref::<DatePickerStateWrapper>().is_some();
966                    matches
967                };
968                if matches {
969                    return cb.refany.clone();
970                }
971            }
972        }
973        panic!("the rendered date picker carries no DatePickerStateWrapper");
974    }
975
976    /// `(flattened node id, the cell's own payload)` of the day cell showing `day`.
977    fn day_cell(sd: &StyledDom, day: u32) -> (DomNodeId, RefAny) {
978        for (i, nd) in sd.node_data.as_ref().iter().enumerate() {
979            for cb in nd.callbacks.as_ref() {
980                let matches = {
981                    let mut r = cb.refany.clone();
982                    r.downcast_ref::<DayCellData>().is_some_and(|c| c.day == day)
983                };
984                if matches {
985                    return (node(i), cb.refany.clone());
986                }
987            }
988        }
989        panic!("the rendered grid has no cell for day {day}");
990    }
991
992    /// `(flattened node id, payload)` of the header button wired to `handler`.
993    fn nav_button(sd: &StyledDom, handler: usize) -> (DomNodeId, RefAny) {
994        for (i, nd) in sd.node_data.as_ref().iter().enumerate() {
995            for cb in nd.callbacks.as_ref() {
996                if cb.callback.cb == handler {
997                    return (node(i), cb.refany.clone());
998                }
999            }
1000        }
1001        panic!("the rendered header has no button wired to that handler");
1002    }
1003
1004    fn read_state(shared: &RefAny) -> DatePickerState {
1005        let mut s = shared.clone();
1006        let w = s
1007            .downcast_ref::<DatePickerStateWrapper>()
1008            .expect("the widget state changed type");
1009        w.inner
1010    }
1011
1012    /// Renders a picker and hands back its flattened DOM plus the very shared
1013    /// state its own handlers were wired against — nothing is re-created by
1014    /// hand, so a mismatch between `dom()` and the handlers cannot hide here.
1015    fn laid_out(picker: DatePicker) -> (StyledDom, RefAny) {
1016        let styled = StyledDom::create_from_dom(picker.dom());
1017        let shared = shared_state(&styled);
1018        (styled, shared)
1019    }
1020
1021    /// One "mouse-up on `hit`" delivered to the widget's own day handler.
1022    fn click(
1023        styled_dom: StyledDom,
1024        payload: &RefAny,
1025        hit: DomNodeId,
1026    ) -> (Update, Vec<CallbackChange>) {
1027        with_info(styled_dom, hit, |info| on_day_click(payload.clone(), *info))
1028    }
1029
1030    /// Drives `times` ‹ / › presses against a hand-built state (the arithmetic in
1031    /// `month_nav` never touches the DOM, so an empty `StyledDom` suffices) and
1032    /// returns the state left behind plus the last verdict and all pushed changes.
1033    fn press_nav(
1034        inner: DatePickerState,
1035        next: bool,
1036        times: usize,
1037    ) -> (DatePickerState, Update, Vec<CallbackChange>) {
1038        let shared = RefAny::new(DatePickerStateWrapper {
1039            inner,
1040            on_change: None.into(),
1041        });
1042        let (update, changes) = with_info(StyledDom::default(), node(0), |info| {
1043            let mut last = Update::DoNothing;
1044            for _ in 0..times {
1045                last = if next {
1046                    on_next_month(shared.clone(), *info)
1047                } else {
1048                    on_prev_month(shared.clone(), *info)
1049                };
1050            }
1051            last
1052        });
1053        (read_state(&shared), update, changes)
1054    }
1055
1056    // ------------------------------------------------------------------
1057    // Style probes
1058    // ------------------------------------------------------------------
1059
1060    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
1061        v.as_ref().iter().map(|p| p.property.clone()).collect()
1062    }
1063
1064    fn find<T>(
1065        v: &CssPropertyWithConditionsVec,
1066        f: impl Fn(&CssProperty) -> Option<T>,
1067    ) -> Option<T> {
1068        v.as_ref().iter().find_map(|p| f(&p.property))
1069    }
1070
1071    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An
1072    /// `em`/`%` slipping into the cell geometry would resolve against the parent
1073    /// font/box, so a 32x28 cell could render at any size at all — and the seven
1074    /// columns of a week would stop lining up with the seven weekday headers.
1075    fn px(pv: &PixelValue) -> f32 {
1076        assert_eq!(
1077            pv.metric,
1078            SizeMetric::Px,
1079            "date-picker geometry must be absolute px, got {:?}",
1080            pv.metric,
1081        );
1082        pv.number.get()
1083    }
1084
1085    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
1086        find(v, |p| match p {
1087            CssProperty::Width(w) => match w.get_property() {
1088                Some(LayoutWidth::Px(pv)) => Some(px(pv)),
1089                _ => None,
1090            },
1091            _ => None,
1092        })
1093    }
1094
1095    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
1096        find(v, |p| match p {
1097            CssProperty::Height(h) => match h.get_property() {
1098                Some(LayoutHeight::Px(pv)) => Some(px(pv)),
1099                _ => None,
1100            },
1101            _ => None,
1102        })
1103    }
1104
1105    /// The first declared background colour of a style vec.
1106    fn background(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
1107        find(v, |p| match p {
1108            CssProperty::BackgroundContent(b) => {
1109                b.get_property().and_then(|v| match v.as_ref().first() {
1110                    Some(StyleBackgroundContent::Color(c)) => Some(*c),
1111                    _ => None,
1112                })
1113            }
1114            _ => None,
1115        })
1116    }
1117
1118    fn text_colour(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
1119        find(v, |p| match p {
1120            CssProperty::TextColor(t) => t.get_property().map(|t| t.inner),
1121            _ => None,
1122        })
1123    }
1124
1125    /// The declared background colour of a *rendered* node's inline style.
1126    fn rendered_background(dom: &Dom) -> Option<ColorU> {
1127        dom.root
1128            .style
1129            .iter_inline_properties()
1130            .find_map(|(p, _)| match p {
1131                CssProperty::BackgroundContent(b) => {
1132                    b.get_property().and_then(|v| match v.as_ref().first() {
1133                        Some(StyleBackgroundContent::Color(c)) => Some(*c),
1134                        _ => None,
1135                    })
1136                }
1137                _ => None,
1138            })
1139    }
1140
1141    /// The background colours pushed onto nodes by `restyle_days`, in push order.
1142    fn pushed_backgrounds(changes: &[CallbackChange]) -> Vec<(NodeId, ColorU)> {
1143        changes
1144            .iter()
1145            .filter_map(|c| match c {
1146                CallbackChange::ChangeNodeCssProperties {
1147                    node_id, properties, ..
1148                } => {
1149                    let col = properties.as_ref().iter().find_map(|p| match p {
1150                        CssProperty::BackgroundContent(b) => {
1151                            b.get_property().and_then(|v| match v.as_ref().first() {
1152                                Some(StyleBackgroundContent::Color(c)) => Some(*c),
1153                                _ => None,
1154                            })
1155                        }
1156                        _ => None,
1157                    })?;
1158                    Some((*node_id, col))
1159                }
1160                _ => None,
1161            })
1162            .collect()
1163    }
1164
1165    /// The text colours pushed onto nodes by `restyle_days`, in push order.
1166    fn pushed_text_colours(changes: &[CallbackChange]) -> Vec<(NodeId, ColorU)> {
1167        changes
1168            .iter()
1169            .filter_map(|c| match c {
1170                CallbackChange::ChangeNodeCssProperties {
1171                    node_id, properties, ..
1172                } => {
1173                    let col = properties.as_ref().iter().find_map(|p| match p {
1174                        CssProperty::TextColor(t) => t.get_property().map(|t| t.inner),
1175                        _ => None,
1176                    })?;
1177                    Some((*node_id, col))
1178                }
1179                _ => None,
1180            })
1181            .collect()
1182    }
1183
1184    // ------------------------------------------------------------------
1185    // User callbacks
1186    // ------------------------------------------------------------------
1187
1188    /// A payload the change callback writes into. It arrives as the `data: RefAny`
1189    /// argument — a *shared* clone of what the test still holds — so the test can
1190    /// read back exactly what the widget reported, without any global state.
1191    #[derive(Debug, Clone, PartialEq, Eq)]
1192    struct ChangeLog {
1193        seen: Vec<DatePickerState>,
1194        payload: u32,
1195    }
1196
1197    extern "C" fn record_change(
1198        mut data: RefAny,
1199        _info: CallbackInfo,
1200        state: DatePickerState,
1201    ) -> Update {
1202        if let Some(mut log) = data.downcast_mut::<ChangeLog>() {
1203            log.seen.push(state);
1204        }
1205        Update::RefreshDom
1206    }
1207
1208    extern "C" fn change_do_nothing(
1209        _data: RefAny,
1210        _info: CallbackInfo,
1211        _state: DatePickerState,
1212    ) -> Update {
1213        Update::DoNothing
1214    }
1215
1216    extern "C" fn change_refresh_all(
1217        _data: RefAny,
1218        _info: CallbackInfo,
1219        _state: DatePickerState,
1220    ) -> Update {
1221        Update::RefreshDomAllWindows
1222    }
1223
1224    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in,
1225    /// which the `From<Callback>` arm *transmutes* into the 3-arg date-picker
1226    /// slot. Never called.
1227    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
1228        Update::DoNothing
1229    }
1230
1231    fn log_refany() -> RefAny {
1232        RefAny::new(ChangeLog {
1233            seen: Vec::new(),
1234            payload: 0xDEAD_BEEF,
1235        })
1236    }
1237
1238    fn read_log(probe: &RefAny) -> ChangeLog {
1239        let mut probe = probe.clone();
1240        let log = probe
1241            .downcast_ref::<ChangeLog>()
1242            .expect("the user payload changed type");
1243        log.clone()
1244    }
1245
1246    // ==================================================================
1247    // is_leap
1248    // ==================================================================
1249
1250    #[test]
1251    fn is_leap_applies_all_three_gregorian_rules_including_the_century_exceptions() {
1252        // The century rule is where naive `% 4` implementations break: 1700/1800/1900
1253        // are *not* leap years, 1600/2000/2400 are. Getting this wrong silently
1254        // shifts every date after February by a day.
1255        for (year, expected) in [
1256            (1, false),
1257            (4, true),
1258            (100, false),
1259            (400, true),
1260            (1600, true),
1261            (1700, false),
1262            (1800, false),
1263            (1900, false),
1264            (2000, true),
1265            (2023, false),
1266            (2024, true),
1267            (2100, false),
1268            (2400, true),
1269        ] {
1270            assert_eq!(is_leap(year), expected, "is_leap({year}) is wrong");
1271        }
1272    }
1273
1274    #[test]
1275    fn is_leap_is_total_at_the_boundaries_of_u32() {
1276        // Year 0 satisfies `% 400 == 0`, so the rule says leap; u32::MAX is
1277        // 4294967295, which is neither `% 4` nor `% 400`. Both must answer without
1278        // panicking rather than being treated as unreachable.
1279        assert!(is_leap(0), "year 0 is divisible by 400 and must be leap");
1280        assert!(
1281            !is_leap(u32::MAX),
1282            "u32::MAX (4294967295) is not divisible by 4",
1283        );
1284        for year in (u32::MAX - 500)..=u32::MAX {
1285            // Nothing to compare against here — the point is that no input in the
1286            // top of the range panics or diverges.
1287            let _ = is_leap(year);
1288        }
1289    }
1290
1291    #[test]
1292    fn is_leap_repeats_with_the_400_year_gregorian_cycle() {
1293        // The whole calendar is periodic mod 400. If any of the three rules is
1294        // mis-ordered, some year in 0..400 breaks the periodicity.
1295        for year in 0..1200u32 {
1296            assert_eq!(
1297                is_leap(year),
1298                is_leap(year + 400),
1299                "the leap rule is not 400-periodic at year {year}",
1300            );
1301        }
1302        // ... and exactly 97 of every 400 years are leap.
1303        let leaps = (0..400u32).filter(|y| is_leap(*y)).count();
1304        assert_eq!(leaps, 97, "a 400-year cycle must contain exactly 97 leap years");
1305    }
1306
1307    #[test]
1308    fn is_leap_agrees_with_the_length_of_february() {
1309        // The two functions encode the same rule twice; a divergence means the grid
1310        // would show a 28-day February in a year the header calls a leap year.
1311        for year in 0..800u32 {
1312            assert_eq!(
1313                is_leap(year),
1314                days_in_month(year, 2) == 29,
1315                "is_leap and days_in_month disagree about February {year}",
1316            );
1317        }
1318    }
1319
1320    // ==================================================================
1321    // days_in_month
1322    // ==================================================================
1323
1324    #[test]
1325    fn days_in_month_returns_the_calendar_length_of_every_real_month() {
1326        let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1327        for (i, want) in expected.iter().enumerate() {
1328            let month = u32::try_from(i).unwrap() + 1;
1329            assert_eq!(
1330                days_in_month(2023, month),
1331                *want,
1332                "month {month} of the non-leap year 2023 has the wrong length",
1333            );
1334        }
1335    }
1336
1337    #[test]
1338    fn days_in_month_february_tracks_the_leap_rule_at_the_extremes() {
1339        for (year, want) in [
1340            (0u32, 29),      // divisible by 400
1341            (1900, 28),      // century, not divisible by 400
1342            (2000, 29),      // divisible by 400
1343            (2023, 28),
1344            (2024, 29),
1345            (u32::MAX, 28),  // 4294967295 % 4 == 3
1346        ] {
1347            assert_eq!(
1348                days_in_month(year, 2),
1349                want,
1350                "February {year} has the wrong length",
1351            );
1352        }
1353    }
1354
1355    #[test]
1356    fn days_in_month_falls_back_to_thirty_outside_one_to_twelve_without_panicking() {
1357        // Documented defensive arm: callers clamp the month, but a raw state field
1358        // can still carry 0 or 13. A panic here would take down the whole frame.
1359        for month in [0u32, 13, 14, 99, 1000, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1360            assert_eq!(
1361                days_in_month(2024, month),
1362                30,
1363                "out-of-range month {month} did not take the 30-day fallback",
1364            );
1365        }
1366    }
1367
1368    #[test]
1369    fn days_in_month_never_leaves_the_28_to_31_band_for_any_input() {
1370        // A grid is sized from this number; anything outside 28..=31 would either
1371        // truncate the month or allocate week rows that render as dead space.
1372        for year in [0u32, 1, 1900, 2000, 2023, 2024, u32::MAX - 1, u32::MAX] {
1373            for month in [0u32, 1, 2, 6, 12, 13, u32::MAX] {
1374                let dim = days_in_month(year, month);
1375                assert!(
1376                    (28..=31).contains(&dim),
1377                    "days_in_month({year}, {month}) = {dim} is outside 28..=31",
1378                );
1379            }
1380        }
1381    }
1382
1383    #[test]
1384    fn days_in_month_sums_to_a_real_year() {
1385        for year in [1900u32, 1999, 2000, 2023, 2024, 2100, 2400] {
1386            let total: u32 = (1..=12).map(|m| days_in_month(year, m)).sum();
1387            let want = if is_leap(year) { 366 } else { 365 };
1388            assert_eq!(total, want, "the twelve months of {year} do not add up to a year");
1389        }
1390    }
1391
1392    // ==================================================================
1393    // weekday
1394    // ==================================================================
1395
1396    #[test]
1397    fn weekday_matches_independently_known_dates() {
1398        for (y, m, d, want) in [
1399            (1900u32, 1u32, 1u32, 1u32),  // Monday
1400            (1970, 1, 1, 4),              // Thursday (the unix epoch)
1401            (2000, 1, 1, 6),              // Saturday
1402            (2000, 3, 1, 3),              // Wednesday (across a leap February)
1403            (2024, 2, 29, 4),             // Thursday (a leap day)
1404            (2024, 12, 31, 2),            // Tuesday
1405        ] {
1406            assert_eq!(
1407                weekday(y, m, d),
1408                want,
1409                "weekday({y}, {m}, {d}) disagrees with the real calendar",
1410            );
1411        }
1412    }
1413
1414    #[test]
1415    fn weekday_is_always_a_valid_index_into_the_seven_weekday_names() {
1416        // `build_grid` uses this as a count of leading blank cells; a value >= 7
1417        // would push the 1st of the month into a second row and desynchronise the
1418        // whole grid from the `Su..Sa` header.
1419        //
1420        // The years here are all small: `day` and `year` share one i32 accumulator,
1421        // so a huge year *and* a huge day together would overflow it. The saturated
1422        // year is covered on its own below.
1423        for year in [0u32, 1, 1899, 1900, 2000, 2024] {
1424            for month in [0u32, 1, 2, 3, 12, 13, 100, u32::MAX] {
1425                for day in [0u32, 1, 28, 31, 32, 999, 1_000_000_000, u32::MAX] {
1426                    let w = weekday(year, month, day);
1427                    assert!(
1428                        w < 7,
1429                        "weekday({year}, {month}, {day}) = {w} is not a weekday index",
1430                    );
1431                }
1432            }
1433        }
1434    }
1435
1436    #[test]
1437    fn weekday_survives_a_saturated_year_by_wrapping_into_the_negative_range() {
1438        // `year as i32` turns u32::MAX into -1, so the accumulator stays tiny and no
1439        // overflow occurs. This is the input `DatePicker::create(u32::MAX, ..)` feeds
1440        // through `dom()`, so it must not panic.
1441        for month in 0..=13u32 {
1442            let w = weekday(u32::MAX, month, 1);
1443            assert!(w < 7, "weekday(u32::MAX, {month}, 1) = {w} is not a weekday index");
1444        }
1445        assert!(weekday(u32::MAX, 12, u32::MAX) < 7);
1446        assert!(weekday(MAX_SAFE_WEEKDAY_YEAR, 12, 1) < 7);
1447    }
1448
1449    #[test]
1450    fn weekday_advances_by_exactly_one_per_day_within_a_month() {
1451        for (year, month) in [(2024u32, 1u32), (2024, 2), (2023, 2), (2000, 12), (1900, 6)] {
1452            let dim = days_in_month(year, month);
1453            for day in 1..dim {
1454                assert_eq!(
1455                    weekday(year, month, day + 1),
1456                    (weekday(year, month, day) + 1) % 7,
1457                    "{year}-{month}: the weekday jumps between day {day} and {}",
1458                    day + 1,
1459                );
1460            }
1461        }
1462    }
1463
1464    #[test]
1465    fn weekday_of_the_first_chains_across_every_month_of_three_decades() {
1466        // The strongest available cross-check: the 1st of the next month must be
1467        // exactly `days_in_month` weekdays after the 1st of this one — across leap
1468        // Februaries, century non-leap years and year boundaries alike. If the
1469        // `month < 3` year shift or the `T` table were off by one anywhere, this
1470        // chain breaks.
1471        for year in 1995..2025u32 {
1472            for month in 1..=12u32 {
1473                let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
1474                assert_eq!(
1475                    weekday(ny, nm, 1),
1476                    (weekday(year, month, 1) + days_in_month(year, month)) % 7,
1477                    "the calendar breaks between {year}-{month} and {ny}-{nm}",
1478                );
1479            }
1480        }
1481    }
1482
1483    #[test]
1484    fn weekday_treats_day_zero_as_the_last_day_of_the_previous_month() {
1485        // `day` is not range-checked, it is simply summed. Day 0 is therefore the
1486        // day before the 1st — a consistent extrapolation rather than a panic.
1487        assert_eq!(
1488            weekday(2000, 1, 0),
1489            weekday(1999, 12, 31),
1490            "day 0 of January is not the same weekday as the preceding 31 December",
1491        );
1492        assert_eq!(weekday(2024, 3, 0), weekday(2024, 2, 29));
1493    }
1494
1495    #[test]
1496    fn weekday_collapses_every_month_above_twelve_onto_one_behaviour() {
1497        // The `idx` fallback picks `T[0]` for any out-of-range month, and the
1498        // `month < 3` shift never fires above 12 — so 13, 99 and u32::MAX are
1499        // indistinguishable. Deterministic, which is what the grid needs.
1500        for day in [0u32, 1, 15, 31] {
1501            let thirteen = weekday(2024, 13, day);
1502            for month in [14u32, 99, 1000, u32::MAX] {
1503                assert_eq!(
1504                    weekday(2024, month, day),
1505                    thirteen,
1506                    "month {month} is not handled like every other out-of-range month",
1507                );
1508            }
1509        }
1510        // Month 0 *does* take the `< 3` shift, so it behaves exactly like January.
1511        for day in [0u32, 1, 15, 31] {
1512            assert_eq!(
1513                weekday(2024, 0, day),
1514                weekday(2024, 1, day),
1515                "month 0 is not handled like January",
1516            );
1517        }
1518    }
1519
1520    // ==================================================================
1521    // month_name
1522    // ==================================================================
1523
1524    #[test]
1525    fn month_name_maps_each_real_month_to_a_distinct_english_name() {
1526        let expected = [
1527            "January", "February", "March", "April", "May", "June",
1528            "July", "August", "September", "October", "November", "December",
1529        ];
1530        for (i, want) in expected.iter().enumerate() {
1531            let month = u32::try_from(i).unwrap() + 1;
1532            assert_eq!(month_name(month), *want, "month {month} has the wrong name");
1533        }
1534        let mut names: Vec<&str> = (1..=12).map(month_name).collect();
1535        names.sort_unstable();
1536        names.dedup();
1537        assert_eq!(names.len(), 12, "two months share a name");
1538    }
1539
1540    #[test]
1541    fn month_name_returns_empty_above_december_but_january_at_zero() {
1542        // `saturating_sub(1)` maps 0 -> index 0, so month 0 silently renders as
1543        // "January" instead of the empty string the >12 arm produces. `dom()` clamps
1544        // the month first, so this only shows through a direct `build_header` call —
1545        // but it *is* the behaviour, and it is not symmetric with the upper bound.
1546        assert_eq!(month_name(0), "January", "the zero month stopped saturating to January");
1547        for month in [13u32, 14, 99, 1000, u32::MAX / 2, u32::MAX - 1, u32::MAX] {
1548            assert_eq!(
1549                month_name(month),
1550                "",
1551                "out-of-range month {month} produced a name",
1552            );
1553        }
1554    }
1555
1556    #[test]
1557    fn month_name_is_total_and_only_names_the_first_thirteen_indices() {
1558        for month in 0..2000u32 {
1559            let name = month_name(month);
1560            assert_eq!(
1561                !name.is_empty(),
1562                month <= 12,
1563                "month {month} named {name:?} outside the 0..=12 window",
1564            );
1565        }
1566    }
1567
1568    // ==================================================================
1569    // build_day_cell_style
1570    // ==================================================================
1571
1572    #[test]
1573    fn build_day_cell_style_differs_between_the_two_states_only_in_colour() {
1574        // A selected cell that also changed size would reflow the entire week row
1575        // on every click — the accent must be purely a repaint.
1576        let sel = build_day_cell_style(true);
1577        let plain = build_day_cell_style(false);
1578        let a = properties(&sel);
1579        let b = properties(&plain);
1580
1581        assert_eq!(
1582            a.len(),
1583            b.len(),
1584            "the selected and unselected cell styles declare a different number of properties",
1585        );
1586        let differing: Vec<_> = a
1587            .iter()
1588            .zip(b.iter())
1589            .filter(|(x, y)| x != y)
1590            .map(|(x, _)| core::mem::discriminant(x))
1591            .collect();
1592        assert_eq!(
1593            differing,
1594            vec![
1595                core::mem::discriminant(&CssProperty::const_background_content(WHITE_BG_VEC)),
1596                core::mem::discriminant(&CssProperty::const_text_color(StyleTextColor {
1597                    inner: WHITE
1598                })),
1599            ],
1600            "the two day-cell styles differ in something other than background + text colour",
1601        );
1602    }
1603
1604    #[test]
1605    fn build_day_cell_style_paints_the_selection_accent_on_white_and_the_rest_transparent() {
1606        // Swapping these two branches yields a picker where every day *but* the
1607        // selected one is highlighted — which still type-checks and still renders.
1608        assert_eq!(background(&build_day_cell_style(true)), Some(ACCENT_BG));
1609        assert_eq!(text_colour(&build_day_cell_style(true)), Some(WHITE));
1610        assert_eq!(background(&build_day_cell_style(false)), Some(TRANSPARENT));
1611        assert_eq!(text_colour(&build_day_cell_style(false)), Some(TEXT_COLOR));
1612    }
1613
1614    #[test]
1615    fn build_day_cell_style_keeps_the_cell_geometry_identical_and_absolute() {
1616        // The seven columns of a week must line up with the seven weekday headers,
1617        // which are also CELL_W wide — so both states have to declare the same
1618        // absolute px box as `WEEKDAY_CELL_STYLE` and `BLANK_CELL_STYLE`.
1619        let blanks = CssPropertyWithConditionsVec::from_const_slice(BLANK_CELL_STYLE);
1620        let headers = CssPropertyWithConditionsVec::from_const_slice(WEEKDAY_CELL_STYLE);
1621        let want_w = Some(CELL_W as f32);
1622        let want_h = Some(CELL_H as f32);
1623
1624        for selected in [false, true] {
1625            let v = build_day_cell_style(selected);
1626            assert_eq!(width_px(&v), want_w, "selected={selected}: wrong cell width");
1627            assert_eq!(height_px(&v), want_h, "selected={selected}: wrong cell height");
1628        }
1629        assert_eq!(width_px(&blanks), want_w, "a blank cell is not a full column wide");
1630        assert_eq!(height_px(&blanks), want_h, "a blank cell is not a full row tall");
1631        assert_eq!(
1632            width_px(&headers),
1633            want_w,
1634            "the weekday header column is not the same width as a day cell",
1635        );
1636    }
1637
1638    #[test]
1639    fn build_day_cell_style_is_pure() {
1640        for selected in [false, true] {
1641            assert_eq!(
1642                properties(&build_day_cell_style(selected)),
1643                properties(&build_day_cell_style(selected)),
1644                "selected={selected}: two identical calls produced different styles",
1645            );
1646        }
1647    }
1648
1649    // ==================================================================
1650    // DatePicker::create
1651    // ==================================================================
1652
1653    #[test]
1654    fn create_stores_the_date_it_was_given_and_installs_no_callback() {
1655        let p = DatePicker::create(2024, 7, 4);
1656        assert_eq!(
1657            p.state.inner,
1658            DatePickerState { year: 2024, month: 7, day: 4 },
1659        );
1660        assert!(
1661            p.state.on_change.as_ref().is_none(),
1662            "create invented a change callback out of nowhere",
1663        );
1664    }
1665
1666    #[test]
1667    fn create_clamps_the_month_into_one_to_twelve() {
1668        assert_eq!(DatePicker::create(2024, 0, 1).state.inner.month, 1);
1669        assert_eq!(DatePicker::create(2024, 13, 1).state.inner.month, 12);
1670        assert_eq!(DatePicker::create(2024, u32::MAX, 1).state.inner.month, 12);
1671        // ... and leaves the real ones alone.
1672        for month in 1..=12u32 {
1673            assert_eq!(DatePicker::create(2024, month, 1).state.inner.month, month);
1674        }
1675    }
1676
1677    #[test]
1678    fn create_clamps_the_day_into_the_real_length_of_the_month() {
1679        // The documented contract, and the one that matters: a picker that stored
1680        // "31 February" would render a selection highlight on a cell that does not
1681        // exist, i.e. on nothing at all.
1682        for (y, m, d, want) in [
1683            (2024u32, 2u32, 31u32, 29u32), // leap February
1684            (2023, 2, 31, 28),             // ordinary February
1685            (2024, 2, 30, 29),
1686            (2024, 4, 31, 30),             // 30-day month
1687            (2024, 1, 31, 31),             // exact fit, untouched
1688            (2024, 1, 0, 1),               // lower clamp
1689            (2024, 6, u32::MAX, 30),
1690            (2024, 0, 99, 31),             // month clamps to January first
1691            (2024, 13, 99, 31),            // ... and to December
1692        ] {
1693            assert_eq!(
1694                DatePicker::create(y, m, d).state.inner.day,
1695                want,
1696                "create({y}, {m}, {d}) did not clamp the day correctly",
1697            );
1698        }
1699    }
1700
1701    #[test]
1702    fn create_never_leaves_an_impossible_date_for_any_input() {
1703        for year in [0u32, 1, 1899, 1900, 2000, 2023, 2024, u32::MAX] {
1704            for month in [0u32, 1, 2, 11, 12, 13, u32::MAX] {
1705                for day in [0u32, 1, 28, 29, 30, 31, 32, u32::MAX] {
1706                    let s = DatePicker::create(year, month, day).state.inner;
1707                    assert!(
1708                        (1..=12).contains(&s.month),
1709                        "create({year}, {month}, {day}) left month {}",
1710                        s.month,
1711                    );
1712                    let dim = days_in_month(s.year, s.month);
1713                    assert!(
1714                        (1..=dim).contains(&s.day),
1715                        "create({year}, {month}, {day}) left day {} in a {dim}-day month",
1716                        s.day,
1717                    );
1718                }
1719            }
1720        }
1721    }
1722
1723    #[test]
1724    fn create_passes_the_year_through_untouched() {
1725        // Only the month and the day are documented as clamped. A silent year clamp
1726        // would make an out-of-range year render a *different* (valid-looking) month
1727        // grid instead of an obviously wrong one.
1728        for year in [0u32, 1, u32::MAX] {
1729            assert_eq!(DatePicker::create(year, 1, 1).state.inner.year, year);
1730        }
1731    }
1732
1733    #[test]
1734    fn create_is_pure_and_distinct_dates_stay_distinguishable() {
1735        assert_eq!(DatePicker::create(2024, 7, 4), DatePicker::create(2024, 7, 4));
1736        assert_ne!(DatePicker::create(2024, 7, 4), DatePicker::create(2024, 7, 5));
1737        assert_ne!(DatePicker::create(2024, 7, 4), DatePicker::create(2024, 8, 4));
1738        assert_ne!(DatePicker::create(2024, 7, 4), DatePicker::create(2025, 7, 4));
1739        // ... but two inputs that clamp to the same date *are* the same picker.
1740        assert_eq!(DatePicker::create(2023, 2, 31), DatePicker::create(2023, 2, 28));
1741    }
1742
1743    #[test]
1744    fn default_is_the_first_of_january_2000() {
1745        assert_eq!(DatePicker::default(), DatePicker::create(2000, 1, 1));
1746        assert_eq!(
1747            DatePickerState::default(),
1748            DatePickerState { year: 2000, month: 1, day: 1 },
1749        );
1750        assert_eq!(
1751            DatePickerStateWrapper::default().inner,
1752            DatePickerState::default(),
1753        );
1754    }
1755
1756    // ==================================================================
1757    // DatePicker::set_on_change / with_on_change
1758    // ==================================================================
1759
1760    #[test]
1761    fn set_on_change_stores_the_function_pointer_and_the_payload_verbatim() {
1762        let mut p = DatePicker::create(2024, 1, 1);
1763        p.set_on_change(
1764            RefAny::new(0xDEAD_BEEF_u32),
1765            change_do_nothing as DatePickerOnChangeCallbackType,
1766        );
1767
1768        let c = p
1769            .state
1770            .on_change
1771            .as_ref()
1772            .expect("set_on_change did not store anything");
1773        assert_eq!(
1774            c.callback.cb as *const () as usize,
1775            change_do_nothing as *const () as usize,
1776            "the stored function pointer is not the one that was handed in",
1777        );
1778        let mut payload = c.refany.clone();
1779        assert_eq!(
1780            *payload.downcast_ref::<u32>().expect("the payload changed type"),
1781            0xDEAD_BEEF,
1782        );
1783    }
1784
1785    #[test]
1786    fn set_on_change_replaces_rather_than_accumulates() {
1787        let mut p = DatePicker::create(2024, 1, 1);
1788        p.set_on_change(RefAny::new(1u8), change_do_nothing as DatePickerOnChangeCallbackType);
1789        p.set_on_change(RefAny::new(2u8), change_refresh_all as DatePickerOnChangeCallbackType);
1790
1791        let c = p.state.on_change.as_ref().expect("the callback vanished");
1792        assert_eq!(
1793            c.callback.cb as *const () as usize,
1794            change_refresh_all as *const () as usize,
1795            "the second set_on_change did not win",
1796        );
1797        let mut payload = c.refany.clone();
1798        assert_eq!(*payload.downcast_ref::<u8>().expect("wrong payload type"), 2);
1799    }
1800
1801    #[test]
1802    fn set_on_change_does_not_disturb_the_date_or_the_container_style() {
1803        let before = DatePicker::create(2023, 2, 31);
1804        let mut after = DatePicker::create(2023, 2, 31);
1805        after.set_on_change(RefAny::new(0u8), change_do_nothing as DatePickerOnChangeCallbackType);
1806
1807        assert_eq!(after.state.inner, before.state.inner, "installing a callback moved the date");
1808        assert_eq!(
1809            properties(&after.container_style),
1810            properties(&before.container_style),
1811            "installing a callback restyled the container",
1812        );
1813    }
1814
1815    #[test]
1816    fn with_on_change_is_exactly_set_on_change_in_builder_form() {
1817        let built = DatePicker::create(2024, 5, 9)
1818            .with_on_change(RefAny::new(7u32), change_do_nothing as DatePickerOnChangeCallbackType);
1819        let mut set = DatePicker::create(2024, 5, 9);
1820        set.set_on_change(RefAny::new(7u32), change_do_nothing as DatePickerOnChangeCallbackType);
1821
1822        assert_eq!(built.state.inner, set.state.inner);
1823        let a = built.state.on_change.as_ref().expect("builder dropped the callback");
1824        let b = set.state.on_change.as_ref().expect("setter dropped the callback");
1825        assert_eq!(a.callback.cb as *const () as usize, b.callback.cb as *const () as usize);
1826
1827        let (mut pa, mut pb) = (a.refany.clone(), b.refany.clone());
1828        assert_eq!(
1829            *pa.downcast_ref::<u32>().expect("builder payload changed type"),
1830            *pb.downcast_ref::<u32>().expect("setter payload changed type"),
1831        );
1832    }
1833
1834    #[test]
1835    fn with_on_change_accepts_a_generic_callback_without_mangling_the_pointer() {
1836        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg
1837        // date-picker slot — this is the FFI (Python/C) path. The pointer must come
1838        // out bit-identical; a mangled one would be a wild jump on the first click.
1839        let generic = Callback {
1840            cb: generic_shaped,
1841            ctx: OptionRefAny::None,
1842        };
1843        let expected = generic_shaped as *const () as usize;
1844
1845        let p = DatePicker::create(2024, 1, 1).with_on_change(RefAny::new(0u8), generic);
1846        let c = p.state.on_change.as_ref().expect("the generic callback was dropped");
1847        assert_eq!(
1848            c.callback.cb as *const () as usize,
1849            expected,
1850            "the Callback -> DatePickerOnChangeCallback transmute mangled the pointer",
1851        );
1852    }
1853
1854    // ==================================================================
1855    // DatePicker::swap_with_default
1856    // ==================================================================
1857
1858    #[test]
1859    fn swap_with_default_hands_out_the_original_and_leaves_a_default_behind() {
1860        let mut p = DatePicker::create(2024, 7, 4);
1861        let taken = p.swap_with_default();
1862
1863        assert_eq!(taken.state.inner, DatePickerState { year: 2024, month: 7, day: 4 });
1864        assert_eq!(p, DatePicker::default(), "the picker left behind is not a default one");
1865    }
1866
1867    #[test]
1868    fn swap_with_default_carries_the_callback_out_with_the_original() {
1869        // If the callback stayed behind on the *default* picker, the host would keep
1870        // getting change notifications from a widget it thought it had taken away.
1871        let mut p = DatePicker::create(2024, 7, 4)
1872            .with_on_change(RefAny::new(3u8), change_do_nothing as DatePickerOnChangeCallbackType);
1873        let taken = p.swap_with_default();
1874
1875        assert!(taken.state.on_change.as_ref().is_some(), "the callback did not leave with the original");
1876        assert!(p.state.on_change.as_ref().is_none(), "the callback stayed behind on the default");
1877    }
1878
1879    #[test]
1880    fn swapping_a_default_twice_is_idempotent() {
1881        let mut p = DatePicker::default();
1882        let first = p.swap_with_default();
1883        let second = p.swap_with_default();
1884        assert_eq!(first, DatePicker::default());
1885        assert_eq!(second, DatePicker::default());
1886        assert_eq!(p, DatePicker::default());
1887    }
1888
1889    // ==================================================================
1890    // DatePicker::dom
1891    // ==================================================================
1892
1893    #[test]
1894    fn dom_builds_a_header_a_weekday_row_and_a_grid() {
1895        let dom = DatePicker::create(2024, 1, 15).dom();
1896
1897        assert!(matches!(dom.root.get_node_type(), NodeType::Div));
1898        assert_eq!(classes(&dom), vec!["__azul-native-date-picker".to_string()]);
1899
1900        let (header, weekdays, grid) = sections(&dom);
1901        assert_eq!(classes(header), vec!["__azul-native-date-picker-header".to_string()]);
1902        assert_eq!(classes(weekdays), vec!["__azul-native-date-picker-weekdays".to_string()]);
1903        assert_eq!(classes(grid), vec!["__azul-native-date-picker-grid".to_string()]);
1904        assert_eq!(
1905            header.children.as_ref().len(),
1906            3,
1907            "the header must be prev / label / next",
1908        );
1909    }
1910
1911    #[test]
1912    fn dom_labels_the_header_with_the_month_name_and_the_year() {
1913        for (y, m, want) in [
1914            (2024u32, 1u32, "January 2024"),
1915            (2024, 12, "December 2024"),
1916            (0, 6, "June 0"),
1917            (u32::MAX, 2, "February 4294967295"),
1918        ] {
1919            let dom = DatePicker::create(y, m, 1).dom();
1920            let (header, _, _) = sections(&dom);
1921            let kids = header.children.as_ref();
1922            assert_eq!(text_of(&kids[0]).as_deref(), Some("\u{2039}"), "wrong prev arrow");
1923            assert_eq!(text_of(&kids[1]).as_deref(), Some(want), "wrong header label");
1924            assert_eq!(text_of(&kids[2]).as_deref(), Some("\u{203A}"), "wrong next arrow");
1925        }
1926    }
1927
1928    #[test]
1929    fn dom_names_the_seven_weekdays_starting_at_sunday() {
1930        // The grid's leading blanks are counted from `weekday()`, which returns
1931        // 0 = Sunday. A header row starting on Monday would silently shift every
1932        // date in the picker by one column.
1933        let dom = DatePicker::create(2024, 1, 1).dom();
1934        let (_, weekdays, _) = sections(&dom);
1935        let names: Vec<Option<String>> = weekdays.children.as_ref().iter().map(text_of).collect();
1936        assert_eq!(
1937            names,
1938            ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
1939                .iter()
1940                .map(|s| Some((*s).to_string()))
1941                .collect::<Vec<_>>(),
1942        );
1943    }
1944
1945    #[test]
1946    fn dom_opens_the_month_after_exactly_weekday_of_the_first_blank_cells() {
1947        // January 2024 starts on a Monday, February 2024 on a Thursday, and
1948        // February 2015 on a Sunday (no leading blanks at all).
1949        for (y, m, want_leading) in [
1950            (2024u32, 1u32, 1usize),
1951            (2024, 2, 4),
1952            (2015, 2, 0),
1953            (2021, 5, 6), // the widest possible offset
1954        ] {
1955            let dom = DatePicker::create(y, m, 1).dom();
1956            let (_, _, grid) = sections(&dom);
1957            let days = day_numbers(grid);
1958            let leading = days.iter().take_while(|d| d.is_none()).count();
1959            assert_eq!(
1960                leading, want_leading,
1961                "{y}-{m}: wrong number of leading blank cells",
1962            );
1963            assert_eq!(
1964                u32::try_from(leading).unwrap(),
1965                weekday(y, m, 1),
1966                "{y}-{m}: the leading blanks disagree with the weekday of the 1st",
1967            );
1968            assert_eq!(days[leading], Some(1), "{y}-{m}: the 1st is not the first day cell");
1969        }
1970    }
1971
1972    #[test]
1973    fn dom_renders_every_day_of_the_month_exactly_once_and_in_order() {
1974        for year in [1900u32, 2000, 2023, 2024] {
1975            for month in 1..=12u32 {
1976                let dom = DatePicker::create(year, month, 1).dom();
1977                let (_, _, grid) = sections(&dom);
1978                let days: Vec<u32> = day_numbers(grid).into_iter().flatten().collect();
1979                let dim = days_in_month(year, month);
1980                assert_eq!(
1981                    days,
1982                    (1..=dim).collect::<Vec<_>>(),
1983                    "{year}-{month}: the grid does not render 1..={dim} in order",
1984                );
1985            }
1986        }
1987    }
1988
1989    #[test]
1990    fn dom_grid_rows_are_always_full_weeks_and_never_wholly_blank() {
1991        // `div_ceil` must produce a row count that covers offset + days without
1992        // allocating an entirely empty trailing week.
1993        for year in [1900u32, 2015, 2021, 2024] {
1994            for month in 1..=12u32 {
1995                let dom = DatePicker::create(year, month, 1).dom();
1996                let (_, _, grid) = sections(&dom);
1997                let rows = grid.children.as_ref();
1998                assert!(
1999                    (4..=6).contains(&rows.len()),
2000                    "{year}-{month}: {} week rows is not a possible month",
2001                    rows.len(),
2002                );
2003                for (i, row) in rows.iter().enumerate() {
2004                    assert_eq!(
2005                        row.children.as_ref().len(),
2006                        7,
2007                        "{year}-{month}: week row {i} is not seven columns wide",
2008                    );
2009                    assert_eq!(classes(row), vec!["__azul-native-date-picker-week".to_string()]);
2010                }
2011                let last = &rows[rows.len() - 1];
2012                assert!(
2013                    last.children.as_ref().iter().any(|c| text_of(c).is_some()),
2014                    "{year}-{month}: the last week row is entirely blank",
2015                );
2016            }
2017        }
2018    }
2019
2020    #[test]
2021    fn dom_accents_exactly_the_selected_day_and_nothing_else() {
2022        for day in [1u32, 15, 29] {
2023            let dom = DatePicker::create(2024, 2, day).dom();
2024            let (_, _, grid) = sections(&dom);
2025            let accented: Vec<Option<u32>> = grid_cells(grid)
2026                .into_iter()
2027                .filter(|c| rendered_background(c) == Some(ACCENT_BG))
2028                .map(|c| text_of(c).map(|t| t.parse().unwrap()))
2029                .collect();
2030            assert_eq!(
2031                accented,
2032                vec![Some(day)],
2033                "selecting day {day} did not highlight exactly that one cell",
2034            );
2035        }
2036    }
2037
2038    #[test]
2039    fn dom_clamps_an_out_of_range_month_before_computing_the_grid() {
2040        // `dom()` re-clamps rather than trusting the state field, because the state
2041        // is `pub` and a host can write 0 or 13 into it directly. Without the clamp,
2042        // `month_name` and `days_in_month` would disagree about the same month.
2043        for (raw, want_name, want_dim) in [(0u32, "January", 31u32), (13, "December", 31), (u32::MAX, "December", 31)] {
2044            let mut p = DatePicker::create(2024, 1, 1);
2045            p.state.inner.month = raw;
2046            let dom = p.dom();
2047
2048            let (header, _, grid) = sections(&dom);
2049            assert_eq!(
2050                text_of(&header.children.as_ref()[1]).as_deref(),
2051                Some(format!("{want_name} 2024").as_str()),
2052                "raw month {raw} produced a nonsense header",
2053            );
2054            let days: Vec<u32> = day_numbers(grid).into_iter().flatten().collect();
2055            assert_eq!(
2056                u32::try_from(days.len()).unwrap(),
2057                want_dim,
2058                "raw month {raw} produced a grid of the wrong length",
2059            );
2060        }
2061    }
2062
2063    #[test]
2064    fn dom_survives_a_saturated_or_zero_year_without_panicking() {
2065        // `create` does not clamp the year, so `dom()` is reachable with u32::MAX.
2066        // The `u32 -> i32` cast in `weekday` wraps it to -1, which is fine.
2067        for (y, m, d) in [(u32::MAX, u32::MAX, u32::MAX), (u32::MAX, 1, 1), (0, 0, 0), (1, 12, 31)] {
2068            let dom = DatePicker::create(y, m, d).dom();
2069            let (_, _, grid) = sections(&dom);
2070            let days: Vec<u32> = day_numbers(grid).into_iter().flatten().collect();
2071            let clamped_month = m.clamp(1, 12);
2072            assert_eq!(
2073                u32::try_from(days.len()).unwrap(),
2074                days_in_month(y, clamped_month),
2075                "create({y}, {m}, {d}).dom() rendered the wrong number of days",
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn dom_gives_every_day_cell_a_mouse_up_handler_and_the_blanks_none() {
2082        let dom = DatePicker::create(2024, 2, 10).dom();
2083        let (_, _, grid) = sections(&dom);
2084
2085        let mut with_handler = 0;
2086        for cell in grid_cells(grid) {
2087            assert_eq!(
2088                classes(cell),
2089                vec!["__azul-native-date-picker-day".to_string()],
2090                "a grid cell lost the day class",
2091            );
2092            let cbs = cell.root.callbacks.as_ref();
2093            if text_of(cell).is_some() {
2094                assert_eq!(cbs.len(), 1, "a day cell must register exactly one handler");
2095                assert_eq!(cbs[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
2096                assert_eq!(cbs[0].callback.cb, on_day_click as usize);
2097                assert_eq!(
2098                    cell.root.flags.get_tab_index(),
2099                    Some(TabIndex::Auto),
2100                    "a day cell is not keyboard-focusable",
2101                );
2102                with_handler += 1;
2103            } else {
2104                assert!(cbs.is_empty(), "a blank cell registered a click handler");
2105            }
2106        }
2107        assert_eq!(with_handler, days_in_month(2024, 2), "not every day is clickable");
2108    }
2109
2110    #[test]
2111    fn dom_bakes_a_distinct_day_number_into_every_cell_payload() {
2112        // Selection must not depend on re-deriving the grid offset at click time —
2113        // each cell carries its own day. A shared or off-by-one payload would make
2114        // every click select the same (or the wrong) date.
2115        let styled = StyledDom::create_from_dom(DatePicker::create(2024, 2, 1).dom());
2116        let mut seen: Vec<u32> = Vec::new();
2117        for nd in styled.node_data.as_ref() {
2118            for cb in nd.callbacks.as_ref() {
2119                let mut r = cb.refany.clone();
2120                if let Some(cell) = r.downcast_ref::<DayCellData>() {
2121                    seen.push(cell.day);
2122                };
2123            }
2124        }
2125        seen.sort_unstable();
2126        assert_eq!(
2127            seen,
2128            (1..=29).collect::<Vec<u32>>(),
2129            "the baked day numbers are not exactly 1..=29 of February 2024",
2130        );
2131    }
2132
2133    #[test]
2134    fn dom_keeps_its_cached_child_count_in_sync_with_the_tree() {
2135        // `estimated_total_children` is a cache; if it under-reports, the flatten
2136        // under-allocates its arenas and panics on an out-of-bounds write.
2137        for (y, m) in [(2024u32, 1u32), (2015, 2), (2021, 5), (u32::MAX, 12)] {
2138            let dom = DatePicker::create(y, m, 1).dom();
2139            assert_eq!(
2140                dom.estimated_total_children,
2141                descendants(&dom),
2142                "{y}-{m}: the cached descendant count is wrong",
2143            );
2144
2145            let rows = dom.children.as_ref()[2].children.as_ref().len();
2146            let styled = StyledDom::create_from_dom(dom);
2147            assert_eq!(
2148                styled.node_data.as_ref().len(),
2149                14 + 8 * rows,
2150                "{y}-{m}: container + header(3) + weekdays(7) + grid of {rows} weeks did not flatten as expected",
2151            );
2152        }
2153    }
2154
2155    #[test]
2156    fn from_datepicker_for_dom_is_the_dom_method() {
2157        let via_from: Dom = DatePicker::create(2024, 3, 7).into();
2158        let via_dom = DatePicker::create(2024, 3, 7).dom();
2159        assert_eq!(classes(&via_from), classes(&via_dom));
2160        assert_eq!(via_from.children.as_ref().len(), via_dom.children.as_ref().len());
2161        assert_eq!(via_from.estimated_total_children, via_dom.estimated_total_children);
2162    }
2163
2164    // ==================================================================
2165    // build_grid / build_header / build_weekday_row / build_*_cell
2166    // ==================================================================
2167
2168    #[test]
2169    fn build_grid_uses_the_thirty_day_fallback_for_an_out_of_range_month() {
2170        // Reachable only directly (`dom()` clamps first), but it must still produce
2171        // a coherent grid rather than an empty or unbounded one.
2172        for month in [0u32, 13, u32::MAX] {
2173            let grid = build_grid(2024, month, 1, RefAny::new(DatePickerStateWrapper::default()));
2174            let days: Vec<u32> = day_numbers(&grid).into_iter().flatten().collect();
2175            assert_eq!(
2176                days,
2177                (1..=30).collect::<Vec<_>>(),
2178                "month {month} did not fall back to a 30-day grid",
2179            );
2180        }
2181    }
2182
2183    #[test]
2184    fn build_grid_never_highlights_more_than_one_cell() {
2185        // `sel_day` is not validated: 0, 32 and u32::MAX must simply match nothing
2186        // rather than highlighting a blank or panicking.
2187        for sel in [0u32, 1, 29, 30, 31, 32, u32::MAX] {
2188            let grid = build_grid(2024, 2, sel, RefAny::new(DatePickerStateWrapper::default()));
2189            let accented = grid_cells(&grid)
2190                .into_iter()
2191                .filter(|c| rendered_background(c) == Some(ACCENT_BG))
2192                .count();
2193            let want = usize::from((1..=29).contains(&sel));
2194            assert_eq!(
2195                accented, want,
2196                "sel_day={sel} highlighted {accented} cells in a 29-day February",
2197            );
2198        }
2199    }
2200
2201    #[test]
2202    fn build_grid_is_total_across_extreme_years() {
2203        for year in [0u32, 1, 1900, 2024, MAX_SAFE_WEEKDAY_YEAR, u32::MAX] {
2204            for month in [0u32, 1, 2, 12, 13] {
2205                let grid = build_grid(year, month, 1, RefAny::new(DatePickerStateWrapper::default()));
2206                let rows = grid.children.as_ref();
2207                assert!(
2208                    (4..=6).contains(&rows.len()),
2209                    "build_grid({year}, {month}) produced {} week rows",
2210                    rows.len(),
2211                );
2212                for row in rows {
2213                    assert_eq!(row.children.as_ref().len(), 7);
2214                }
2215            }
2216        }
2217    }
2218
2219    #[test]
2220    fn build_header_is_total_for_out_of_range_months() {
2221        // `month_name` yields "" above December, so the label degrades to a bare
2222        // year rather than panicking or indexing out of bounds.
2223        for (month, want) in [(0u32, "January 2024"), (12, "December 2024"), (13, " 2024"), (u32::MAX, " 2024")] {
2224            let header = build_header(2024, month, RefAny::new(DatePickerStateWrapper::default()));
2225            let kids = header.children.as_ref();
2226            assert_eq!(kids.len(), 3);
2227            assert_eq!(
2228                text_of(&kids[1]).as_deref(),
2229                Some(want),
2230                "build_header(2024, {month}) produced the wrong label",
2231            );
2232        }
2233    }
2234
2235    #[test]
2236    fn build_header_wires_prev_and_next_to_different_handlers() {
2237        // A copy-paste that pointed both arrows at `on_next_month` would make the
2238        // month only ever move forwards — and would still render identically.
2239        let header = build_header(2024, 6, RefAny::new(DatePickerStateWrapper::default()));
2240        let kids = header.children.as_ref();
2241        let prev = kids[0].root.callbacks.as_ref();
2242        let next = kids[2].root.callbacks.as_ref();
2243
2244        assert_eq!(prev.len(), 1);
2245        assert_eq!(next.len(), 1);
2246        assert_eq!(prev[0].callback.cb, on_prev_month as usize);
2247        assert_eq!(next[0].callback.cb, on_next_month as usize);
2248        assert_ne!(prev[0].callback.cb, next[0].callback.cb);
2249        // The label between them is inert.
2250        assert!(kids[1].root.callbacks.as_ref().is_empty(), "the header label is clickable");
2251    }
2252
2253    #[test]
2254    fn build_weekday_row_has_exactly_seven_inert_cells() {
2255        let row = build_weekday_row();
2256        let cells = row.children.as_ref();
2257        assert_eq!(cells.len(), 7);
2258        for cell in cells {
2259            assert!(cell.root.callbacks.as_ref().is_empty(), "a weekday header is clickable");
2260            assert_eq!(classes(cell), vec!["__azul-native-date-picker-weekday".to_string()]);
2261        }
2262    }
2263
2264    #[test]
2265    fn build_blank_cell_is_an_untexted_uncallbacked_column_placeholder() {
2266        let blank = build_blank_cell();
2267        assert!(matches!(blank.root.get_node_type(), NodeType::Div));
2268        assert_eq!(text_of(&blank), None, "a blank cell renders text");
2269        assert!(blank.root.callbacks.as_ref().is_empty(), "a blank cell is clickable");
2270        assert!(blank.children.as_ref().is_empty());
2271        assert_eq!(classes(&blank), vec!["__azul-native-date-picker-day".to_string()]);
2272    }
2273
2274    #[test]
2275    fn build_day_cell_renders_and_bakes_whatever_number_it_is_given() {
2276        // The day is never range-checked here — `build_grid` guarantees 1..=31 —
2277        // so the extremes must at least round-trip rather than panic.
2278        for day in [0u32, 1, 31, 99, u32::MAX] {
2279            for selected in [false, true] {
2280                let cell = build_day_cell(day, selected, RefAny::new(DatePickerStateWrapper::default()));
2281                assert_eq!(
2282                    text_of(&cell).as_deref(),
2283                    Some(day.to_string().as_str()),
2284                    "day {day} did not render as its own number",
2285                );
2286                assert_eq!(
2287                    rendered_background(&cell),
2288                    Some(if selected { ACCENT_BG } else { TRANSPARENT }),
2289                    "day {day} selected={selected}: wrong background",
2290                );
2291
2292                let cbs = cell.root.callbacks.as_ref();
2293                assert_eq!(cbs.len(), 1);
2294                let mut r = cbs[0].refany.clone();
2295                let baked = r
2296                    .downcast_ref::<DayCellData>()
2297                    .expect("a day cell no longer carries a DayCellData")
2298                    .day;
2299                assert_eq!(baked, day, "the rendered number and the baked one disagree");
2300            }
2301        }
2302    }
2303
2304    // ==================================================================
2305    // on_day_click / restyle_days
2306    // ==================================================================
2307
2308    #[test]
2309    fn clicking_a_day_selects_it_and_restyles_the_whole_grid() {
2310        let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2311        let (cell, payload) = day_cell(&styled, 17);
2312        let rows = 5; // February 2024: 4 leading blanks + 29 days = 33 -> 5 rows
2313
2314        let (update, changes) = click(styled, &payload, cell);
2315
2316        assert_eq!(
2317            read_state(&shared),
2318            DatePickerState { year: 2024, month: 2, day: 17 },
2319            "the click did not move the selection to the clicked day",
2320        );
2321        assert_eq!(
2322            update,
2323            Update::DoNothing,
2324            "with no user callback installed the handler must report DoNothing",
2325        );
2326
2327        let bgs = pushed_backgrounds(&changes);
2328        assert_eq!(
2329            bgs.len(),
2330            rows * 7,
2331            "restyle_days must repaint every cell of every week row",
2332        );
2333        let accented: Vec<NodeId> = bgs
2334            .iter()
2335            .filter(|(_, c)| *c == ACCENT_BG)
2336            .map(|(n, _)| *n)
2337            .collect();
2338        assert_eq!(
2339            accented,
2340            vec![cell.node.into_crate_internal().unwrap()],
2341            "exactly the clicked cell must end up accented",
2342        );
2343        assert!(
2344            bgs.iter().filter(|(n, _)| *n != accented[0]).all(|(_, c)| *c == TRANSPARENT),
2345            "a cell other than the clicked one kept a background",
2346        );
2347
2348        let texts = pushed_text_colours(&changes);
2349        assert_eq!(texts.len(), rows * 7, "every cell needs its text colour resynced too");
2350        assert_eq!(
2351            texts.iter().filter(|(_, c)| *c == WHITE).map(|(n, _)| *n).collect::<Vec<_>>(),
2352            accented,
2353            "the white-on-accent text did not land on the accented cell",
2354        );
2355    }
2356
2357    #[test]
2358    fn clicking_a_foreign_payload_changes_nothing_at_all() {
2359        // A stale/mismatched RefAny must bail out *before* touching the state and
2360        // before pushing any DOM change.
2361        let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2362        let (cell, _) = day_cell(&styled, 17);
2363        let before = read_state(&shared);
2364
2365        let (update, changes) = click(styled, &RefAny::new(0xBAD_u32), cell);
2366
2367        assert_eq!(update, Update::DoNothing);
2368        assert!(changes.is_empty(), "a foreign payload still restyled the grid");
2369        assert_eq!(read_state(&shared), before, "a foreign payload moved the selection");
2370    }
2371
2372    #[test]
2373    fn clicking_a_detached_node_updates_the_state_but_pushes_no_style() {
2374        // `set_css_property` *panics* on a None node id, so `restyle_days` has to
2375        // bail out at `get_parent`. The selection itself is already committed by
2376        // then — the state moves, the pixels do not, and nothing panics.
2377        for hit in [node_none(), node(9999)] {
2378            let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2379            let (_, payload) = day_cell(&styled, 17);
2380
2381            let (update, changes) = click(styled, &payload, hit);
2382
2383            assert_eq!(update, Update::DoNothing, "{hit:?}: unexpected verdict");
2384            assert!(changes.is_empty(), "{hit:?}: a detached hit still pushed a style change");
2385            assert_eq!(read_state(&shared).day, 17, "{hit:?}: the selection was not committed");
2386        }
2387    }
2388
2389    #[test]
2390    fn clicking_a_node_without_a_grandparent_restyles_nothing() {
2391        // `restyle_days` walks cell -> week row -> grid. Handing it the container
2392        // (which has no grandparent) must be a no-op, not a panic or a repaint of
2393        // the header.
2394        let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2395        let (_, payload) = day_cell(&styled, 3);
2396
2397        let (_, changes) = click(styled, &payload, node(0));
2398
2399        assert!(changes.is_empty(), "restyling from the root touched nodes anyway");
2400        assert_eq!(read_state(&shared).day, 3);
2401    }
2402
2403    #[test]
2404    fn the_change_callback_sees_the_clicked_day_and_its_verdict_is_forwarded() {
2405        // Order matters: the state is written *before* the user callback runs, so
2406        // the callback observes the date the user just asked for, not the stale one.
2407        let probe = log_refany();
2408        let (styled, shared) = laid_out(
2409            DatePicker::create(2024, 2, 1)
2410                .with_on_change(probe.clone(), record_change as DatePickerOnChangeCallbackType),
2411        );
2412        let (cell, payload) = day_cell(&styled, 29);
2413
2414        let (update, changes) = click(styled, &payload, cell);
2415
2416        let log = read_log(&probe);
2417        assert_eq!(
2418            log.seen,
2419            vec![DatePickerState { year: 2024, month: 2, day: 29 }],
2420            "the change callback was not called exactly once with the NEW state",
2421        );
2422        assert_eq!(
2423            log.payload, 0xDEAD_BEEF,
2424            "the callback was handed something other than the user's own RefAny",
2425        );
2426        assert_eq!(update, Update::RefreshDom, "the user callback's Update was swallowed");
2427        assert_eq!(read_state(&shared).day, 29);
2428        // ... and the restyle still happens after the user callback returns.
2429        assert!(!changes.is_empty(), "a RefreshDom callback suppressed the restyle");
2430    }
2431
2432    #[test]
2433    fn a_change_callback_that_declines_the_update_still_gets_the_grid_restyled() {
2434        let (styled, shared) = laid_out(
2435            DatePicker::create(2024, 2, 1)
2436                .with_on_change(RefAny::new(0u8), change_do_nothing as DatePickerOnChangeCallbackType),
2437        );
2438        let (cell, payload) = day_cell(&styled, 12);
2439
2440        let (update, changes) = click(styled, &payload, cell);
2441
2442        assert_eq!(update, Update::DoNothing);
2443        assert_eq!(read_state(&shared).day, 12);
2444        assert!(
2445            !changes.is_empty(),
2446            "a DoNothing user callback suppressed the widget's own repaint",
2447        );
2448    }
2449
2450    #[test]
2451    fn clicking_every_day_of_the_month_keeps_the_state_and_the_accent_in_agreement() {
2452        // The class of bug this catches is drift: the state saying one day while the
2453        // accent sits on another. Re-rendered every iteration, so each click is
2454        // delivered against a DOM that matches the state it starts from.
2455        for day in 1..=29u32 {
2456            let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2457            let (cell, payload) = day_cell(&styled, day);
2458
2459            let (_, changes) = click(styled, &payload, cell);
2460
2461            assert_eq!(read_state(&shared).day, day, "click #{day}: the state drifted");
2462            let accented: Vec<NodeId> = pushed_backgrounds(&changes)
2463                .into_iter()
2464                .filter(|(_, c)| *c == ACCENT_BG)
2465                .map(|(n, _)| n)
2466                .collect();
2467            assert_eq!(
2468                accented,
2469                vec![cell.node.into_crate_internal().unwrap()],
2470                "click #{day}: the accent disagrees with the clicked cell",
2471            );
2472        }
2473    }
2474
2475    #[test]
2476    fn repeated_clicks_on_the_same_cell_are_idempotent() {
2477        let (styled, shared) = laid_out(DatePicker::create(2024, 2, 1));
2478        let (cell, payload) = day_cell(&styled, 20);
2479        let styled2 = StyledDom::create_from_dom(DatePicker::create(2024, 2, 1).dom());
2480
2481        let (_, first) = click(styled, &payload, cell);
2482        let (_, second) = click(styled2, &payload, cell);
2483
2484        assert_eq!(
2485            pushed_backgrounds(&first),
2486            pushed_backgrounds(&second),
2487            "clicking the same cell twice produced different repaints",
2488        );
2489        assert_eq!(read_state(&shared).day, 20);
2490    }
2491
2492    // ==================================================================
2493    // on_prev_month / on_next_month / month_nav
2494    // ==================================================================
2495
2496    #[test]
2497    fn next_month_wraps_december_into_january_of_the_following_year() {
2498        let (s, _, _) = press_nav(DatePickerState { year: 2024, month: 12, day: 15 }, true, 1);
2499        assert_eq!(s, DatePickerState { year: 2025, month: 1, day: 15 });
2500    }
2501
2502    #[test]
2503    fn prev_month_wraps_january_into_december_of_the_preceding_year() {
2504        let (s, _, _) = press_nav(DatePickerState { year: 2024, month: 1, day: 15 }, false, 1);
2505        assert_eq!(s, DatePickerState { year: 2023, month: 12, day: 15 });
2506    }
2507
2508    #[test]
2509    fn twelve_presses_in_either_direction_land_on_the_same_month_one_year_away() {
2510        let start = DatePickerState { year: 2024, month: 5, day: 15 };
2511        let (fwd, _, _) = press_nav(start, true, 12);
2512        let (back, _, _) = press_nav(start, false, 12);
2513        assert_eq!(fwd, DatePickerState { year: 2025, month: 5, day: 15 });
2514        assert_eq!(back, DatePickerState { year: 2023, month: 5, day: 15 });
2515    }
2516
2517    #[test]
2518    fn month_nav_walks_every_month_in_order_across_a_year_boundary() {
2519        let mut state = DatePickerState { year: 2023, month: 11, day: 10 };
2520        let expected = [
2521            (2023u32, 12u32), (2024, 1), (2024, 2), (2024, 3), (2024, 4), (2024, 5),
2522            (2024, 6), (2024, 7), (2024, 8), (2024, 9), (2024, 10), (2024, 11),
2523            (2024, 12), (2025, 1),
2524        ];
2525        for (i, (y, m)) in expected.iter().enumerate() {
2526            let (next, _, _) = press_nav(state, true, 1);
2527            assert_eq!(
2528                (next.year, next.month),
2529                (*y, *m),
2530                "step {i}: the month walk went off the rails",
2531            );
2532            state = next;
2533        }
2534    }
2535
2536    #[test]
2537    fn month_nav_clamps_the_selected_day_into_the_shorter_month_and_never_grows_it_back() {
2538        // 31 January -> February clamps to 29; stepping on to March leaves it at 29
2539        // rather than restoring the 31 the user originally picked. Lossy, but the
2540        // alternative (remembering the original) is not what the code does — and a
2541        // silently un-clamped 31 February would highlight a cell that isn't there.
2542        let jan31 = DatePickerState { year: 2024, month: 1, day: 31 };
2543        let (feb, _, _) = press_nav(jan31, true, 1);
2544        assert_eq!(feb, DatePickerState { year: 2024, month: 2, day: 29 });
2545
2546        let (mar, _, _) = press_nav(feb, true, 1);
2547        assert_eq!(mar, DatePickerState { year: 2024, month: 3, day: 29 }, "the clamp grew back");
2548
2549        // Non-leap February clamps one day further.
2550        let (feb23, _, _) = press_nav(DatePickerState { year: 2023, month: 1, day: 31 }, true, 1);
2551        assert_eq!(feb23.day, 28);
2552        // A 31st into a 30-day month.
2553        let (apr, _, _) = press_nav(DatePickerState { year: 2024, month: 3, day: 31 }, true, 1);
2554        assert_eq!(apr, DatePickerState { year: 2024, month: 4, day: 30 });
2555    }
2556
2557    #[test]
2558    fn month_nav_leaves_a_renderable_day_for_every_month_of_a_two_year_walk() {
2559        // The invariant the widget actually depends on: after any number of presses
2560        // the day still exists in the displayed month, so the grid always has a cell
2561        // to accent.
2562        for start_day in [1u32, 28, 29, 30, 31] {
2563            let mut state = DatePickerState { year: 2023, month: 1, day: start_day };
2564            for step in 0..24 {
2565                let (next, _, _) = press_nav(state, true, 1);
2566                assert!(
2567                    (1..=12).contains(&next.month),
2568                    "step {step} from day {start_day}: month {} is out of range",
2569                    next.month,
2570                );
2571                assert!(
2572                    next.day <= days_in_month(next.year, next.month),
2573                    "step {step} from day {start_day}: day {} does not exist in {}-{}",
2574                    next.day,
2575                    next.year,
2576                    next.month,
2577                );
2578                state = next;
2579            }
2580        }
2581    }
2582
2583    #[test]
2584    fn month_nav_floors_the_year_at_one_instead_of_wrapping_below_zero() {
2585        // Stepping back out of January of year 1 would give year 0; the `max(1)`
2586        // pins it. Year 0 itself is likewise lifted to 1.
2587        let (from_one, _, _) = press_nav(DatePickerState { year: 1, month: 1, day: 1 }, false, 1);
2588        assert_eq!(from_one, DatePickerState { year: 1, month: 12, day: 1 });
2589
2590        let (from_zero, _, _) = press_nav(DatePickerState { year: 0, month: 1, day: 1 }, false, 1);
2591        assert_eq!(from_zero, DatePickerState { year: 1, month: 12, day: 1 });
2592
2593        // Twenty more presses cannot push it below the floor.
2594        let (deep, _, _) = press_nav(DatePickerState { year: 1, month: 1, day: 1 }, false, 20);
2595        assert!(deep.year >= 1, "the year floor leaked, got {}", deep.year);
2596        assert!((1..=12).contains(&deep.month));
2597    }
2598
2599    #[test]
2600    fn month_nav_collapses_a_saturated_year_to_the_floor_without_panicking() {
2601        // `year as i32` turns u32::MAX into -1, so the ±1 lands at 0 or -2 and the
2602        // `max(1)` floor catches both. Deterministic, and above all not a panic.
2603        for next in [false, true] {
2604            let (s, _, _) = press_nav(DatePickerState { year: u32::MAX, month: 6, day: 1 }, next, 1);
2605            assert_eq!(s.year, 1, "next={next}: a saturated year did not collapse to the floor");
2606            assert!((1..=12).contains(&s.month));
2607        }
2608        // Month 12 + next is the year-incrementing branch, month 1 + prev the
2609        // decrementing one — both must survive the wrapped year.
2610        let (a, _, _) = press_nav(DatePickerState { year: u32::MAX, month: 12, day: 1 }, true, 1);
2611        assert_eq!(a, DatePickerState { year: 1, month: 1, day: 1 });
2612        let (b, _, _) = press_nav(DatePickerState { year: u32::MAX, month: 1, day: 1 }, false, 1);
2613        assert_eq!(b, DatePickerState { year: 1, month: 12, day: 1 });
2614    }
2615
2616    #[test]
2617    fn month_nav_repairs_an_out_of_range_month_rather_than_propagating_it() {
2618        // The state is `pub`, so a host can leave 0 or 13 in it. Whatever the nav
2619        // does, it must hand back something `days_in_month` and `month_name` agree
2620        // on — i.e. a month in 1..=12.
2621        for raw in [0u32, 13, 99, u32::MAX] {
2622            for next in [false, true] {
2623                let (s, _, _) = press_nav(DatePickerState { year: 2024, month: raw, day: 1 }, next, 1);
2624                assert!(
2625                    (1..=12).contains(&s.month),
2626                    "raw month {raw}, next={next}: nav left month {}",
2627                    s.month,
2628                );
2629                assert!(s.year >= 1);
2630            }
2631        }
2632    }
2633
2634    #[test]
2635    fn month_nav_does_not_touch_the_grid() {
2636        // TODO2 in the module docs: ‹ / › cannot add or remove day cells, so they
2637        // deliberately push *no* DOM change at all. A restyle here would leave the
2638        // old month's grid wearing the new month's highlighting.
2639        for next in [false, true] {
2640            let (_, _, changes) = press_nav(DatePickerState { year: 2024, month: 3, day: 15 }, next, 1);
2641            assert!(
2642                changes.is_empty(),
2643                "next={next}: month navigation pushed {} DOM change(s)",
2644                changes.len(),
2645            );
2646        }
2647    }
2648
2649    #[test]
2650    fn month_nav_reports_the_new_month_so_the_host_can_rebuild() {
2651        // The whole point of the TODO2 design: the widget cannot rebuild its own
2652        // grid, so it *must* tell the host what changed.
2653        let probe = log_refany();
2654        let shared = RefAny::new(DatePickerStateWrapper {
2655            inner: DatePickerState { year: 2024, month: 1, day: 31 },
2656            on_change: Some(DatePickerOnChange {
2657                callback: DatePickerOnChangeCallback::from(
2658                    record_change as DatePickerOnChangeCallbackType,
2659                ),
2660                refany: probe.clone(),
2661            })
2662            .into(),
2663        });
2664
2665        let (update, _) = with_info(StyledDom::default(), node(0), |info| {
2666            on_next_month(shared.clone(), *info)
2667        });
2668
2669        assert_eq!(
2670            read_log(&probe).seen,
2671            vec![DatePickerState { year: 2024, month: 2, day: 29 }],
2672            "the host was not told the new (clamped) month",
2673        );
2674        assert_eq!(update, Update::RefreshDom, "the host's verdict was swallowed");
2675    }
2676
2677    #[test]
2678    fn month_nav_on_the_real_header_buttons_moves_the_real_widget_state() {
2679        // End-to-end through `dom()`: the arrows the widget actually renders are
2680        // wired to the state the widget actually shares.
2681        for (handler, want) in [
2682            (on_prev_month as usize, DatePickerState { year: 2024, month: 5, day: 10 }),
2683            (on_next_month as usize, DatePickerState { year: 2024, month: 7, day: 10 }),
2684        ] {
2685            let (styled, shared) = laid_out(DatePicker::create(2024, 6, 10));
2686            let (hit, payload) = nav_button(&styled, handler);
2687
2688            let (_, changes) = with_info(styled, hit, |info| {
2689                if handler == on_prev_month as usize {
2690                    on_prev_month(payload.clone(), *info)
2691                } else {
2692                    on_next_month(payload.clone(), *info)
2693                }
2694            });
2695
2696            assert_eq!(read_state(&shared), want, "the header arrow did not move the state");
2697            assert!(changes.is_empty(), "the header arrow tried to restyle the stale grid");
2698        }
2699    }
2700
2701    #[test]
2702    fn month_nav_with_a_foreign_payload_is_a_no_op() {
2703        for next in [false, true] {
2704            let foreign = RefAny::new(0xBAD_u32);
2705            let (update, changes) = with_info(StyledDom::default(), node(0), |info| {
2706                if next {
2707                    on_next_month(foreign.clone(), *info)
2708                } else {
2709                    on_prev_month(foreign.clone(), *info)
2710                }
2711            });
2712            assert_eq!(update, Update::DoNothing, "next={next}: a foreign payload was acted on");
2713            assert!(changes.is_empty(), "next={next}: a foreign payload pushed a DOM change");
2714        }
2715    }
2716
2717    #[test]
2718    fn navigating_then_clicking_still_selects_within_the_stale_grid() {
2719        // The documented limitation: after ‹ / › the grid is stale. A click on it
2720        // must still be coherent — it writes the *displayed* cell's day into the
2721        // state, alongside the already-navigated month.
2722        let (styled, shared) = laid_out(DatePicker::create(2024, 6, 10));
2723        let (nav_hit, nav_payload) = nav_button(&styled, on_next_month as usize);
2724        let (cell, cell_payload) = day_cell(&styled, 23);
2725
2726        let (_, _) = with_info(styled, nav_hit, |info| on_next_month(nav_payload.clone(), *info));
2727        assert_eq!(read_state(&shared), DatePickerState { year: 2024, month: 7, day: 10 });
2728
2729        let fresh = StyledDom::create_from_dom(DatePicker::create(2024, 6, 10).dom());
2730        let (_, changes) = click(fresh, &cell_payload, cell);
2731
2732        assert_eq!(
2733            read_state(&shared),
2734            DatePickerState { year: 2024, month: 7, day: 23 },
2735            "a click on the stale grid did not write the displayed day into the state",
2736        );
2737        assert!(!changes.is_empty(), "the stale grid was not restyled");
2738    }
2739}