Skip to main content

frus_widgets/
datepicker.rs

1//! [`DatePicker`]: a **controlled** month calendar, built on [`crate::GridView`].
2//! Date arithmetic is **home-grown**, with no time dependency.
3
4use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
5use frus_layout::{Align, Dimension, FlexDirection, Style};
6
7use crate::flex::Flex;
8use crate::grid::GridView;
9use crate::interaction::Status;
10use crate::text::Text;
11use crate::theme::Theme;
12use crate::widget::Widget;
13
14const CELL: f32 = 34.0;
15
16/// A day's style: what the calendar was told, else what the theme says, else the
17/// reference's — a Material 3 date picker sets its days in `bodyLarge`.
18///
19/// **Resolved once** so that the number the cell is measured with is the number the figures
20/// are drawn at. Resolving is the single place the reader's font setting is applied
21/// (milestone 403).
22fn day_style(theme: Option<&Theme>) -> ResolvedTextStyle {
23    theme
24        .and_then(|t| t.widgets.date_picker.day_text_style)
25        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
26        .resolved()
27}
28
29/// The weekday initial's style — the reference says `bodyLarge` for these too. See
30/// [`day_style`].
31fn weekday_style(theme: Option<&Theme>) -> ResolvedTextStyle {
32    theme
33        .and_then(|t| t.widgets.date_picker.weekday_text_style)
34        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
35        .resolved()
36}
37
38/// A cell's side: the constant, unless the reader asked for figures that do not fit in it.
39fn cell(theme: Option<&Theme>) -> f32 {
40    frus_text::line_box(CELL, &day_style(theme), 0.0)
41}
42/// The gap between the two months of a dual calendar.
43const DUAL_GAP: f32 = 24.0;
44
45/// True if `year` is a leap year.
46fn is_leap(year: i32) -> bool {
47    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
48}
49
50/// Number of days in month `month` (1..=12) of `year`.
51fn days_in_month(year: i32, month: u32) -> u32 {
52    match month {
53        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
54        4 | 6 | 9 | 11 => 30,
55        2 => {
56            if is_leap(year) {
57                29
58            } else {
59                28
60            }
61        }
62        _ => 30,
63    }
64}
65
66/// The weekday (`0` = Sunday … `6` = Saturday) of the 1st of the month (Sakamoto).
67fn first_weekday(year: i32, month: u32) -> usize {
68    const T: [i32; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
69    let y = if month < 3 { year - 1 } else { year };
70    let m = month as usize;
71    (((y + y / 4 - y / 100 + y / 400 + T[m - 1] + 1) % 7 + 7) % 7) as usize
72}
73
74/// A day cell's state with respect to the selection.
75#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76enum DayMark {
77    /// Neither selected nor within a range.
78    Off,
79    /// A selected day (simple mode).
80    Selected,
81    /// A range's **start** bound.
82    Start,
83    /// A range's **end** bound.
84    End,
85    /// A day **between** the two bounds (the connecting band).
86    Between,
87}
88
89/// The range marker of a `date` `(year, month, day)` with respect to `[start, end]`:
90/// a start or end bound, an in-between day, or outside the range. Dates compare as
91/// tuples (year, then month, then day), so chronological order is the order of `<`.
92fn range_mark(
93    date: (i32, u32, u32),
94    start: Option<(i32, u32, u32)>,
95    end: Option<(i32, u32, u32)>,
96) -> DayMark {
97    match (start, end) {
98        (Some(s), _) if date == s => DayMark::Start,
99        (_, Some(e)) if date == e => DayMark::End,
100        (Some(s), Some(e)) if s < date && date < e => DayMark::Between,
101        _ => DayMark::Off,
102    }
103}
104
105/// A clickable day cell (empty when `day == 0`).
106struct Day<Msg> {
107    day: u32,
108    mark: DayMark,
109    /// A **disabled** day (outside the bounds): dimmed, not clickable — milestone 231.
110    disabled: bool,
111    message: Option<Msg>,
112}
113
114impl<Msg> Day<Msg> {
115    fn sizing(&self, theme: Option<&Theme>) -> Style {
116        let side = cell(theme);
117        Style {
118            width: Dimension::Length(side),
119            height: Dimension::Length(side),
120            ..Default::default()
121        }
122    }
123}
124
125impl<Msg: Clone> Widget<Msg> for Day<Msg> {
126    fn style(&self) -> Style {
127        self.sizing(None)
128    }
129
130    fn style_themed(&self, theme: &Theme) -> Style {
131        self.sizing(Some(theme))
132    }
133
134    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
135        &[]
136    }
137
138    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
139        if self.day == 0 {
140            return; // a filler cell
141        }
142        let o = status.opacity;
143        // A disabled day (outside the bounds): a dimmed figure, no background, no band.
144        if self.disabled {
145            let label = self.day.to_string();
146            let style = day_style(Some(theme));
147            let w = frus_text::measure_resolved(&label, &style).width;
148            scene.text(
149                Point::new(
150                    bounds.x + (bounds.width - w) * 0.5,
151                    bounds.y + (bounds.height - style.line_height()) * 0.5,
152                ),
153                label,
154                &style,
155                theme.muted.fade(o * 0.4),
156            );
157            return;
158        }
159        // The range band (a soft background, square corners so neighbouring cells touch).
160        let band = theme.primary.fade(0.18 * o);
161        let half = Rect::new(bounds.x, bounds.y, bounds.width * 0.5, bounds.height);
162        let right = Rect::new(
163            bounds.x + bounds.width * 0.5,
164            bounds.y,
165            bounds.width * 0.5,
166            bounds.height,
167        );
168        match self.mark {
169            // The bound paints its half-band on the inner side to meet the in-between days.
170            DayMark::Start => scene.draw_rect(right, band, 0.0, 0.0, Color::TRANSPARENT),
171            DayMark::End => scene.draw_rect(half, band, 0.0, 0.0, Color::TRANSPARENT),
172            DayMark::Between => scene.draw_rect(bounds, band, 0.0, 0.0, Color::TRANSPARENT),
173            _ => {}
174        }
175        // The cell's background (a solid pill for a bound or selection, hover otherwise).
176        let (bg, fg) = match self.mark {
177            DayMark::Selected | DayMark::Start | DayMark::End => (theme.primary, theme.on_primary),
178            _ => {
179                let hovered = theme.state_layer(theme.surface, theme.on_surface, &status);
180                (hovered, theme.on_surface)
181            }
182        };
183        if self.mark != DayMark::Between {
184            scene.draw_rect(bounds, bg.fade(o), CELL * 0.5, 0.0, Color::TRANSPARENT);
185        }
186        let label = self.day.to_string();
187        let style = day_style(Some(theme));
188        let w = frus_text::measure_resolved(&label, &style).width;
189        scene.text(
190            Point::new(
191                bounds.x + (bounds.width - w) * 0.5,
192                bounds.y + (bounds.height - style.line_height()) * 0.5,
193            ),
194            label,
195            &style,
196            fg.fade(o),
197        );
198    }
199
200    fn on_click(&self) -> Option<Msg> {
201        self.message.clone()
202    }
203
204    fn focusable(&self) -> bool {
205        self.message.is_some()
206    }
207}
208
209/// A month calendar (or a pair of months in [`range_dual`](DatePicker::range_dual) mode).
210pub struct DatePicker<Msg = crate::callback::Callback> {
211    children: Vec<Box<dyn Widget<Msg>>>,
212    /// Two months side by side, which doubles its width.
213    dual: bool,
214    day_text_style: Option<TextStyle>,
215    weekday_text_style: Option<TextStyle>,
216}
217
218impl<Msg> DatePicker<Msg> {
219    /// The days' type, over the theme's and the reference's.
220    ///
221    /// It reaches the cells through [`Widget::theme_override`] rather than through the
222    /// cells' fields: a calendar is assembled by five different constructors, and a value
223    /// carried down the theme arrives at every one of them without any of them being
224    /// taught to pass it on.
225    #[must_use]
226    pub fn day_text_style(mut self, style: TextStyle) -> Self {
227        self.day_text_style = Some(style);
228        self
229    }
230
231    /// The weekday initials' type, over the theme's and the reference's. See
232    /// [`day_text_style`](Self::day_text_style).
233    #[must_use]
234    pub fn weekday_text_style(mut self, style: TextStyle) -> Self {
235        self.weekday_text_style = Some(style);
236        self
237    }
238}
239
240/// **How far into the week the 1st of the month falls**, counting from whichever day
241/// the week starts on where the reader is (`date.dart:356`).
242///
243/// [`first_weekday`] answers Sunday-based, and so does
244/// [`Localizations::first_day_of_week_index`](crate::Localizations::first_day_of_week_index)
245/// — so the difference between them is the number of blank cells, and it is taken
246/// **Euclidean** because the difference goes negative for every locale that does not
247/// start on Sunday.
248///
249/// This used to be `first_weekday` alone, which is the same number only in a country
250/// whose week starts on Sunday. Everywhere else the days were in the wrong columns.
251fn lead_cells(year: i32, month: u32, first_day_of_week: usize) -> usize {
252    (first_weekday(year, month) as isize - first_day_of_week as isize).rem_euclid(7) as usize
253}
254
255impl<Msg: Clone + 'static> DatePicker<Msg> {
256    /// Creates a calendar for `year`/`month` (1..=12), with an optional `selected`
257    /// day. `on_select(day)` on click; `on_nav(±1)` to change month.
258    pub fn new(
259        year: i32,
260        month: u32,
261        selected: Option<u32>,
262        on_select: impl Fn(u32) -> Msg + 'static,
263        on_nav: impl Fn(i32) -> Msg + 'static,
264    ) -> Self {
265        Self::assemble(
266            year,
267            month,
268            on_select,
269            on_nav,
270            move |day| {
271                if selected == Some(day) {
272                    DayMark::Selected
273                } else {
274                    DayMark::Off
275                }
276            },
277            |_| true,
278        )
279    }
280
281    /// A simple calendar **filtered by predicate**: a day `(year, month, day)` is clickable
282    /// if and only if `is_enabled(date)` is true — the others are **disabled** (dimmed, not
283    /// clickable). This is the general escape hatch: isolated **blackout** days, forbidden
284    /// weekends, bounds, or any combination. `selected`/`on_select`/`on_nav` as in
285    /// [`new`](Self::new) — milestone 235.
286    pub fn filtered(
287        year: i32,
288        month: u32,
289        selected: Option<u32>,
290        is_enabled: impl Fn((i32, u32, u32)) -> bool + 'static,
291        on_select: impl Fn(u32) -> Msg + 'static,
292        on_nav: impl Fn(i32) -> Msg + 'static,
293    ) -> Self {
294        let month = month.clamp(1, 12);
295        Self::assemble(
296            year,
297            month,
298            on_select,
299            on_nav,
300            move |day| {
301                if selected == Some(day) {
302                    DayMark::Selected
303                } else {
304                    DayMark::Off
305                }
306            },
307            move |day| is_enabled((year, month, day)),
308        )
309    }
310
311    /// A simple **bounded** calendar: days outside `[min, max]` (dates `(year, month, day)`,
312    /// with optional and **inclusive** bounds) are **disabled** — dimmed and not clickable.
313    /// Identical to [`new`](Self::new) otherwise (the `selected` day, `on_select`, `on_nav`).
314    /// Useful to forbid past dates, to set a booking window, and so on — milestone 231.
315    pub fn bounded(
316        year: i32,
317        month: u32,
318        selected: Option<u32>,
319        min: Option<(i32, u32, u32)>,
320        max: Option<(i32, u32, u32)>,
321        on_select: impl Fn(u32) -> Msg + 'static,
322        on_nav: impl Fn(i32) -> Msg + 'static,
323    ) -> Self {
324        let month = month.clamp(1, 12);
325        Self::assemble(
326            year,
327            month,
328            on_select,
329            on_nav,
330            move |day| {
331                if selected == Some(day) {
332                    DayMark::Selected
333                } else {
334                    DayMark::Off
335                }
336            },
337            move |day| {
338                let date = (year, month, day);
339                min.is_none_or(|m| date >= m) && max.is_none_or(|m| date <= m)
340            },
341        )
342    }
343
344    /// A calendar in **range mode**: it brings out the `[start, end]` interval (dates
345    /// `(year, month, day)`) — the bounds as solid pills, the days between as a soft band.
346    /// A single bound (`end == None`) shows just the start (a selection in progress).
347    /// `on_select(day)` reports the clicked day of the month shown — the application
348    /// decides whether it becomes the start or the end.
349    pub fn range(
350        year: i32,
351        month: u32,
352        start: Option<(i32, u32, u32)>,
353        end: Option<(i32, u32, u32)>,
354        on_select: impl Fn(u32) -> Msg + 'static,
355        on_nav: impl Fn(i32) -> Msg + 'static,
356    ) -> Self {
357        let month = month.clamp(1, 12);
358        Self::assemble(
359            year,
360            month,
361            on_select,
362            on_nav,
363            move |day| range_mark((year, month, day), start, end),
364            |_| true,
365        )
366    }
367
368    /// A calendar in **bounded range mode**: like [`range`](Self::range), but days outside
369    /// `[min, max]` (optional and **inclusive** bounds) are **disabled** — dimmed and not
370    /// clickable. Combines bringing out a selected interval with an allowed input window
371    /// (booking a range within an open period, say) — milestone 234.
372    #[allow(clippy::too_many_arguments)]
373    pub fn range_bounded(
374        year: i32,
375        month: u32,
376        start: Option<(i32, u32, u32)>,
377        end: Option<(i32, u32, u32)>,
378        min: Option<(i32, u32, u32)>,
379        max: Option<(i32, u32, u32)>,
380        on_select: impl Fn(u32) -> Msg + 'static,
381        on_nav: impl Fn(i32) -> Msg + 'static,
382    ) -> Self {
383        let month = month.clamp(1, 12);
384        Self::assemble(
385            year,
386            month,
387            on_select,
388            on_nav,
389            move |day| range_mark((year, month, day), start, end),
390            move |day| {
391                let date = (year, month, day);
392                min.is_none_or(|m| date >= m) && max.is_none_or(|m| date <= m)
393            },
394        )
395    }
396
397    /// A **dual** calendar: the `year`/`month` month and the **next**, side by side, sharing
398    /// the same `[start, end]` range — to enter long ranges without changing month.
399    /// `on_select((year, month, day))` reports the **complete** date of the day clicked (the
400    /// month is disambiguated), and `on_nav(±1)` shifts the **pair**. The range band carries
401    /// on from one month to the other (dates compare as whole values — see `range_mark`).
402    pub fn range_dual(
403        year: i32,
404        month: u32,
405        start: Option<(i32, u32, u32)>,
406        end: Option<(i32, u32, u32)>,
407        on_select: impl Fn((i32, u32, u32)) -> Msg + 'static,
408        on_nav: impl Fn(i32) -> Msg + 'static,
409    ) -> Self {
410        let month = month.clamp(1, 12);
411        let (ny, nm) = if month == 12 {
412            (year + 1, 1)
413        } else {
414            (year, month + 1)
415        };
416        // Shared between the two months.
417        let on_select = std::rc::Rc::new(on_select);
418        let on_nav = std::rc::Rc::new(on_nav);
419        let (os1, os2) = (on_select.clone(), on_select);
420        let (nv1, nv2) = (on_nav.clone(), on_nav);
421        let left = DatePicker::range(
422            year,
423            month,
424            start,
425            end,
426            move |d| os1((year, month, d)),
427            move |n| nv1(n),
428        );
429        let right = DatePicker::range(
430            ny,
431            nm,
432            start,
433            end,
434            move |d| os2((ny, nm, d)),
435            move |n| nv2(n),
436        );
437        let row = Flex::row().gap(DUAL_GAP).child(left).child(right);
438        Self {
439            children: vec![Box::new(row)],
440            dual: true,
441            day_text_style: None,
442            weekday_text_style: None,
443        }
444    }
445
446    /// Assembles the header, the weekday row and the grid; `mark_of(day)` decides each
447    /// cell's state (a simple selection, or a range).
448    fn assemble(
449        year: i32,
450        month: u32,
451        on_select: impl Fn(u32) -> Msg + 'static,
452        on_nav: impl Fn(i32) -> Msg + 'static,
453        mark_of: impl Fn(u32) -> DayMark,
454        enabled: impl Fn(u32) -> bool,
455    ) -> Self {
456        let month = month.clamp(1, 12);
457
458        // The header: ‹ Month Year ›.
459        let header = Flex::row()
460            .align(Align::Center)
461            .gap(8.0)
462            .child(
463                crate::IconButton::new(crate::icons::Icons::CHEVRON_LEFT)
464                    .label(crate::localizations::of().previous_month_label())
465                    .icon_size(18.0)
466                    .on_press(on_nav(-1)),
467            )
468            .child(Flex::row().flex(1.0))
469            .child(
470                Text::new(format!(
471                    "{} {}",
472                    crate::localizations::of().months()[(month - 1) as usize],
473                    year
474                ))
475                .size(16.0),
476            )
477            .child(Flex::row().flex(1.0))
478            .child(
479                crate::IconButton::new(crate::icons::Icons::CHEVRON_RIGHT)
480                    .label(crate::localizations::of().next_month_label())
481                    .icon_size(18.0)
482                    .on_press(on_nav(1)),
483            );
484
485        // The weekday row, **rotated to start where the reader's week does**
486        // (`calendar_date_picker.dart:1100`). The initials themselves are always listed
487        // Sunday-first, which is what lets the index be an index into them.
488        let l10n = crate::localizations::of();
489        let first = l10n.first_day_of_week_index() % 7;
490        let initials = l10n.narrow_weekdays();
491        let mut weekdays = GridView::new(7).gap(2.0);
492        for column in 0..7 {
493            weekdays = weekdays.cell(WeekdayCell {
494                label: initials[(first + column) % 7].to_string(),
495            });
496        }
497
498        // The day grid (empty cells before the 1st).
499        let lead = lead_cells(year, month, first);
500        let total = days_in_month(year, month);
501        let mut grid = GridView::new(7).gap(2.0);
502        for _ in 0..lead {
503            grid = grid.cell(Day::<Msg> {
504                day: 0,
505                mark: DayMark::Off,
506                disabled: false,
507                message: None,
508            });
509        }
510        for day in 1..=total {
511            let on = enabled(day);
512            grid = grid.cell(Day {
513                day,
514                mark: mark_of(day),
515                disabled: !on,
516                message: if on { Some(on_select(day)) } else { None },
517            });
518        }
519
520        Self {
521            children: vec![Box::new(header), Box::new(weekdays), Box::new(grid)],
522            dual: false,
523            day_text_style: None,
524            weekday_text_style: None,
525        }
526    }
527}
528
529/// The floor of the weekday header's height.
530const WEEKDAY_H: f32 = 22.0;
531
532/// A weekday header cell (not clickable).
533struct WeekdayCell {
534    label: String,
535}
536
537impl WeekdayCell {
538    fn sizing(&self, theme: Option<&Theme>) -> Style {
539        // The floor, or the line the initials actually need.
540        Style {
541            height: Dimension::Length(frus_text::line_box(WEEKDAY_H, &weekday_style(theme), 0.0)),
542            ..Default::default()
543        }
544    }
545}
546
547impl<Msg> Widget<Msg> for WeekdayCell {
548    fn style(&self) -> Style {
549        self.sizing(None)
550    }
551
552    fn style_themed(&self, theme: &Theme) -> Style {
553        self.sizing(Some(theme))
554    }
555
556    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
557        &[]
558    }
559
560    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
561        let style = weekday_style(Some(theme));
562        let w = frus_text::measure_resolved(&self.label, &style).width;
563        scene.text(
564            Point::new(bounds.x + (bounds.width - w) * 0.5, bounds.y),
565            self.label.clone(),
566            &style,
567            theme.muted.fade(status.opacity),
568        );
569    }
570
571    fn on_click(&self) -> Option<Msg> {
572        None
573    }
574}
575
576impl<Msg> DatePicker<Msg> {
577    fn sizing(&self, theme: Option<&Theme>) -> Style {
578        // Seven cells and their gaps — the cells' **own** side, not the constant: a
579        // calendar whose cells grew with the reader while its box did not would clip its
580        // last column.
581        let month_w = 7.0 * (cell(theme) + 2.0);
582        let width = if self.dual {
583            2.0 * month_w + DUAL_GAP
584        } else {
585            month_w
586        };
587        Style {
588            width: Dimension::Length(width),
589            flex_direction: FlexDirection::Column,
590            gap: 8.0,
591            ..Default::default()
592        }
593    }
594}
595
596impl<Msg: Clone> Widget<Msg> for DatePicker<Msg> {
597    fn style(&self) -> Style {
598        self.sizing(None)
599    }
600
601    fn style_themed(&self, theme: &Theme) -> Style {
602        self.sizing(Some(theme))
603    }
604
605    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
606        &self.children
607    }
608
609    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
610
611    fn on_click(&self) -> Option<Msg> {
612        None
613    }
614
615    /// Carries what this calendar was told down to its cells, over what the subtree
616    /// inherited. `None` when it was told nothing, so a calendar that says nothing costs
617    /// nothing and the theme's own answer stands.
618    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
619        if self.day_text_style.is_none() && self.weekday_text_style.is_none() {
620            return None;
621        }
622        let mut theme = inherited.clone();
623        if let Some(style) = self.day_text_style {
624            theme.widgets.date_picker.day_text_style = Some(style);
625        }
626        if let Some(style) = self.weekday_text_style {
627            theme.widgets.date_picker.weekday_text_style = Some(style);
628        }
629        Some(Box::new(theme))
630    }
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[derive(Clone, Debug, PartialEq)]
638    enum Msg {
639        Pick(u32),
640        Nav(i32),
641    }
642
643    /// A table whose weeks start on Monday, and whose columns say so.
644    struct Fr;
645
646    impl crate::Localizations for Fr {
647        fn first_day_of_week_index(&self) -> usize {
648            1
649        }
650
651        fn narrow_weekdays(&self) -> [&str; 7] {
652            ["D", "L", "M", "M", "J", "V", "S"]
653        }
654
655        fn months(&self) -> [&str; 12] {
656            [
657                "janvier",
658                "f\u{e9}vrier",
659                "mars",
660                "avril",
661                "mai",
662                "juin",
663                "juillet",
664                "ao\u{fb}t",
665                "septembre",
666                "octobre",
667                "novembre",
668                "d\u{e9}cembre",
669            ]
670        }
671    }
672
673    /// **A calendar starts its week where the reader's week starts**
674    /// (`calendar_date_picker.dart:1100`, `date.dart:356`).
675    ///
676    /// This is not a translation: it is which column a day sits in. The grid always began
677    /// on Sunday, so in every country whose week starts on Monday — most of Europe — every
678    /// date in the month was one column out.
679    #[test]
680    fn a_week_starts_where_the_reader_s_week_starts() {
681        // 1 July 2026 is a Wednesday. Sunday-first: three blanks (Sun, Mon, Tue).
682        assert_eq!(lead_cells(2026, 7, 0), 3);
683        // Monday-first: two (Mon, Tue).
684        assert_eq!(lead_cells(2026, 7, 1), 2);
685        // And 1 March 2026 is a Sunday: no blanks at all Sunday-first, six Monday-first.
686        assert_eq!(first_weekday(2026, 3), 0);
687        assert_eq!(lead_cells(2026, 3, 0), 0);
688        assert_eq!(
689            lead_cells(2026, 3, 1),
690            6,
691            "the difference goes negative here, which is why it is taken Euclidean"
692        );
693
694        // And the grid really has that many cells.
695        let cells = |l10n: Option<std::rc::Rc<dyn crate::Localizations>>| {
696            let build = || {
697                let dp = DatePicker::new(2026, 7, Some(11), Msg::Pick, Msg::Nav);
698                Widget::<Msg>::children(&dp)[2].children().len()
699            };
700            match l10n {
701                Some(table) => crate::localizations::scope(table, build),
702                None => build(),
703            }
704        };
705        assert_eq!(cells(None), 3 + 31, "Sunday-first");
706        assert_eq!(cells(Some(std::rc::Rc::new(Fr))), 2 + 31, "Monday-first");
707    }
708
709    /// And the **column headings rotate with it**, out of a list that is always written
710    /// Sunday-first (`date.dart:353`) — which is what lets one index serve both.
711    #[test]
712    fn the_column_headings_rotate_and_the_month_is_named() {
713        let heading = |i: usize, table: Option<std::rc::Rc<dyn crate::Localizations>>| {
714            let build = || {
715                let dp = DatePicker::new(2026, 7, None, Msg::Pick, Msg::Nav);
716                let row = &Widget::<Msg>::children(&dp)[1];
717                let mut scene = Scene::new();
718                row.children()[i].paint(
719                    Rect::new(0.0, 0.0, 32.0, 32.0),
720                    Status::default(),
721                    &Theme::dark(),
722                    &mut scene,
723                );
724                scene.primitives().iter().find_map(|p| match p {
725                    frus_core::Primitive::Text { text, .. } => Some(text.clone()),
726                    _ => None,
727                })
728            };
729            match table {
730                Some(table) => crate::localizations::scope(table, build),
731                None => build(),
732            }
733        };
734        assert_eq!(heading(0, None).as_deref(), Some("S"), "Sunday first");
735        assert_eq!(
736            heading(0, Some(std::rc::Rc::new(Fr))).as_deref(),
737            Some("L"),
738            "Monday first, and in the reader's letters"
739        );
740        assert_eq!(
741            heading(6, Some(std::rc::Rc::new(Fr))).as_deref(),
742            Some("D"),
743            "and Sunday has moved to the end"
744        );
745    }
746
747    #[test]
748    fn date_math_is_correct() {
749        assert_eq!(days_in_month(2024, 2), 29); // bissextile
750        assert_eq!(days_in_month(2023, 2), 28);
751        assert_eq!(days_in_month(2024, 4), 30);
752        // 1er janvier 2024 = lundi (1).
753        assert_eq!(first_weekday(2024, 1), 1);
754        // 1er juillet 2026 = mercredi (3).
755        assert_eq!(first_weekday(2026, 7), 3);
756    }
757
758    #[test]
759    fn builds_header_weekdays_and_grid() {
760        let dp = DatePicker::new(2026, 7, Some(11), Msg::Pick, Msg::Nav);
761        // [header, weekdays, grid].
762        assert_eq!(Widget::<Msg>::children(&dp).len(), 3);
763        // The grid = filler cells + 31 days; July 2026 starts on a Wednesday
764        // (3 cases vides) → 3 + 31 = 34 cellules.
765        let grid = &Widget::<Msg>::children(&dp)[2];
766        assert_eq!(grid.children().len(), 34);
767    }
768
769    #[test]
770    fn bounded_disables_days_outside_the_range() {
771        // The window [10, 20] July 2026: outside = not clickable, inside = clickable.
772        let dp = DatePicker::bounded(
773            2026,
774            7,
775            None,
776            Some((2026, 7, 10)),
777            Some((2026, 7, 20)),
778            Msg::Pick,
779            Msg::Nav,
780        );
781        let grid = &Widget::<Msg>::children(&dp)[2];
782        // July 2026 starts on a Wednesday (3 empty cells); day `d` sits at index 3 + (d - 1).
783        let at = |d: u32| grid.children()[3 + (d - 1) as usize].on_click();
784        assert_eq!(at(9), None, "9 July disabled (before min)");
785        assert_eq!(
786            at(10),
787            Some(Msg::Pick(10)),
788            "10 juillet cliquable (borne min incluse)"
789        );
790        assert_eq!(at(15), Some(Msg::Pick(15)), "15 July clickable (inside)");
791        assert_eq!(
792            at(20),
793            Some(Msg::Pick(20)),
794            "20 July clickable (max bound included)"
795        );
796        assert_eq!(at(21), None, "21 July disabled (after max)");
797        // Without a max bound: everything >= min is clickable.
798        let open = DatePicker::bounded(
799            2026,
800            7,
801            None,
802            Some((2026, 7, 10)),
803            None,
804            Msg::Pick,
805            Msg::Nav,
806        );
807        let g2 = &Widget::<Msg>::children(&open)[2];
808        assert_eq!(
809            g2.children()[3 + 30].on_click(),
810            Some(Msg::Pick(31)),
811            "31 juillet cliquable (pas de max)"
812        );
813    }
814
815    #[test]
816    fn filtered_disables_days_by_predicate() {
817        // Blackout: 12 and 18 July unavailable; everything else clickable.
818        let blackout = [(2026, 7, 12), (2026, 7, 18)];
819        let dp = DatePicker::filtered(
820            2026,
821            7,
822            None,
823            move |date| !blackout.contains(&date),
824            Msg::Pick,
825            Msg::Nav,
826        );
827        let grid = &Widget::<Msg>::children(&dp)[2];
828        let at = |d: u32| grid.children()[3 + (d - 1) as usize].on_click();
829        assert_eq!(at(12), None, "12 juillet en blackout");
830        assert_eq!(at(18), None, "18 juillet en blackout");
831        assert_eq!(at(13), Some(Msg::Pick(13)), "13 juillet cliquable");
832        assert_eq!(at(1), Some(Msg::Pick(1)), "1er juillet cliquable");
833    }
834
835    #[test]
836    fn range_bounded_disables_days_outside_the_window() {
837        // The range 10..15 is selected, but the allowed input window is [8, 20] July 2026.
838        let dp = DatePicker::range_bounded(
839            2026,
840            7,
841            Some((2026, 7, 10)),
842            Some((2026, 7, 15)),
843            Some((2026, 7, 8)),
844            Some((2026, 7, 20)),
845            Msg::Pick,
846            Msg::Nav,
847        );
848        let grid = &Widget::<Msg>::children(&dp)[2];
849        let at = |d: u32| grid.children()[3 + (d - 1) as usize].on_click();
850        assert_eq!(at(7), None, "7 July outside the window (before min)");
851        assert_eq!(at(8), Some(Msg::Pick(8)), "8 July clickable (min bound)");
852        assert_eq!(
853            at(12),
854            Some(Msg::Pick(12)),
855            "12 July clickable (inside the range)"
856        );
857        assert_eq!(at(20), Some(Msg::Pick(20)), "20 July clickable (max bound)");
858        assert_eq!(at(21), None, "21 July outside the window (after max)");
859    }
860
861    #[test]
862    fn range_marks_endpoints_and_interior() {
863        let start = Some((2026, 7, 10));
864        let end = Some((2026, 7, 15));
865        assert_eq!(range_mark((2026, 7, 10), start, end), DayMark::Start);
866        assert_eq!(range_mark((2026, 7, 15), start, end), DayMark::End);
867        assert_eq!(range_mark((2026, 7, 12), start, end), DayMark::Between);
868        assert_eq!(
869            range_mark((2026, 7, 9), start, end),
870            DayMark::Off,
871            "before the start"
872        );
873        assert_eq!(
874            range_mark((2026, 7, 16), start, end),
875            DayMark::Off,
876            "after the end"
877        );
878        // It crosses month boundaries: June is "between" June 15 and August 1.
879        let cross = (Some((2026, 6, 15)), Some((2026, 8, 1)));
880        assert_eq!(
881            range_mark((2026, 7, 20), cross.0, cross.1),
882            DayMark::Between
883        );
884        assert_eq!(range_mark((2026, 5, 31), cross.0, cross.1), DayMark::Off);
885        // A selection in progress (a single bound): only the start is marked.
886        assert_eq!(range_mark((2026, 7, 10), start, None), DayMark::Start);
887        assert_eq!(
888            range_mark((2026, 7, 12), start, None),
889            DayMark::Off,
890            "no open-ended band"
891        );
892    }
893
894    #[test]
895    fn range_builds_grid_with_clickable_days() {
896        let dp = DatePicker::range(
897            2026,
898            7,
899            Some((2026, 7, 10)),
900            Some((2026, 7, 15)),
901            Msg::Pick,
902            Msg::Nav,
903        );
904        assert_eq!(Widget::<Msg>::children(&dp).len(), 3);
905        let grid = &Widget::<Msg>::children(&dp)[2];
906        // Leading blanks (Wednesday = 3) plus 31 days.
907        assert_eq!(grid.children().len(), 34);
908        // 10 July (3 blanks + days 1..10 → index 12) is still clickable.
909        assert_eq!(grid.children()[12].on_click(), Some(Msg::Pick(10)));
910    }
911
912    #[test]
913    fn range_dual_shows_two_consecutive_months() {
914        #[derive(Clone, Debug, PartialEq)]
915        enum M {
916            Pick(i32, u32, u32),
917            Nav(i32),
918        }
919        // December → January of the following year (a year rollover).
920        let dp = DatePicker::range_dual(
921            2026,
922            12,
923            Some((2026, 12, 28)),
924            Some((2027, 1, 3)),
925            |(y, m, d)| M::Pick(y, m, d),
926            M::Nav,
927        );
928        // A single child: the row of two months.
929        let row = &Widget::<M>::children(&dp);
930        assert_eq!(row.len(), 1);
931        let months = row[0].children();
932        assert_eq!(months.len(), 2, "two calendars side by side");
933        // The right-hand month is January 2027: clicking its 3rd reports the complete date.
934        let jan_grid = &months[1].children()[2];
935        // January 2027 starts on a Friday (5 empty cells) → the 3rd is at index 5 + 2 = 7.
936        assert_eq!(first_weekday(2027, 1), 5);
937        assert_eq!(jan_grid.children()[7].on_click(), Some(M::Pick(2027, 1, 3)));
938    }
939}