1use 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
16fn 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
29fn 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
38fn cell(theme: Option<&Theme>) -> f32 {
40 frus_text::line_box(CELL, &day_style(theme), 0.0)
41}
42const DUAL_GAP: f32 = 24.0;
44
45fn is_leap(year: i32) -> bool {
47 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
48}
49
50fn 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
66fn 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
76enum DayMark {
77 Off,
79 Selected,
81 Start,
83 End,
85 Between,
87}
88
89fn 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
105struct Day<Msg> {
107 day: u32,
108 mark: DayMark,
109 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; }
142 let o = status.opacity;
143 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 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 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 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
209pub struct DatePicker<Msg = crate::callback::Callback> {
211 children: Vec<Box<dyn Widget<Msg>>>,
212 dual: bool,
214 day_text_style: Option<TextStyle>,
215 weekday_text_style: Option<TextStyle>,
216}
217
218impl<Msg> DatePicker<Msg> {
219 #[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 #[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
240fn 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 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 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 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 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 #[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 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 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 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 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 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 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
529const WEEKDAY_H: f32 = 22.0;
531
532struct WeekdayCell {
534 label: String,
535}
536
537impl WeekdayCell {
538 fn sizing(&self, theme: Option<&Theme>) -> Style {
539 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 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 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 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 #[test]
680 fn a_week_starts_where_the_reader_s_week_starts() {
681 assert_eq!(lead_cells(2026, 7, 0), 3);
683 assert_eq!(lead_cells(2026, 7, 1), 2);
685 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 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 #[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); assert_eq!(days_in_month(2023, 2), 28);
751 assert_eq!(days_in_month(2024, 4), 30);
752 assert_eq!(first_weekday(2024, 1), 1);
754 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 assert_eq!(Widget::<Msg>::children(&dp).len(), 3);
763 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 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 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 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 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 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 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 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 assert_eq!(grid.children().len(), 34);
908 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 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 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 let jan_grid = &months[1].children()[2];
935 assert_eq!(first_weekday(2027, 1), 5);
937 assert_eq!(jan_grid.children()[7].on_click(), Some(M::Pick(2027, 1, 3)));
938 }
939}