Skip to main content

frus_widgets/
datetimerange.rs

1//! [`DateTimeRange`]: a **date-and-time range** in one widget — the dual calendar of
2//! [`crate::DatePicker::range_dual`] above the [`crate::TimeRange`] time range, topped by
3//! a **"start → end" summary**. For booking a complete slot: "from 28 July 09:00 to 3
4//! August 17:30".
5//!
6//! Purely composite and **controlled**: it combines the two sub-pickers and relays their
7//! messages; the state, dates and times, lives in the application, which decides which
8//! endpoint a clicked day lands on, as the date range does.
9
10use frus_core::{Rect, Scene};
11use frus_layout::{Dimension, FlexDirection, Style};
12
13use crate::datepicker::DatePicker;
14use crate::interaction::Status;
15use crate::text::Text;
16use crate::theme::Theme;
17use crate::timepicker::{Endpoint, TimeField, TimeRange};
18use crate::widget::Widget;
19
20/// A date-and-time range picker.
21pub struct DateTimeRange<Msg = crate::callback::Callback> {
22    children: Vec<Box<dyn Widget<Msg>>>,
23}
24
25impl<Msg: Clone + 'static> DateTimeRange<Msg> {
26    /// Creates the picker. The **date** part shows `year`/`month` (1–12) and the month
27    /// after, highlighting the `[start_date, end_date]` range — `on_date((year, month,
28    /// day))` on click, `on_nav(±1)` to shift the pair — while the **time** part shows
29    /// `start_time`/`end_time` (`on_time(endpoint, field, value)`). A "start → end"
30    /// summary tops it all once **both** dates are set.
31    #[allow(clippy::too_many_arguments)]
32    pub fn new(
33        year: i32,
34        month: u32,
35        start_date: Option<(i32, u32, u32)>,
36        end_date: Option<(i32, u32, u32)>,
37        start_time: (u32, u32),
38        end_time: (u32, u32),
39        on_date: impl Fn((i32, u32, u32)) -> Msg + 'static,
40        on_nav: impl Fn(i32) -> Msg + 'static,
41        on_time: impl Fn(Endpoint, TimeField, u32) -> Msg + 'static,
42    ) -> Self {
43        let calendar = DatePicker::range_dual(year, month, start_date, end_date, on_date, on_nav);
44        let times = TimeRange::new(start_time, end_time, on_time);
45
46        let mut children: Vec<Box<dyn Widget<Msg>>> = Vec::new();
47        if let (Some(s), Some(e)) = (start_date, end_date) {
48            let l10n = crate::localizations::of();
49            let stamp = |d: (i32, u32, u32), t: (u32, u32)| {
50                format!(
51                    "{} {}, {}  {:02}:{:02}",
52                    l10n.months()[(d.1 - 1) as usize],
53                    d.2,
54                    d.0,
55                    t.0,
56                    t.1
57                )
58            };
59            let summary = format!("{}  →  {}", stamp(s, start_time), stamp(e, end_time));
60            children.push(Box::new(Text::new(summary).size(16.0)));
61        }
62        children.push(Box::new(calendar));
63        children.push(Box::new(times));
64
65        Self { children }
66    }
67}
68
69impl<Msg: Clone> Widget<Msg> for DateTimeRange<Msg> {
70    fn style(&self) -> Style {
71        Style {
72            width: Dimension::Auto,
73            flex_direction: FlexDirection::Column,
74            gap: 16.0,
75            ..Default::default()
76        }
77    }
78
79    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
80        &self.children
81    }
82
83    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
84
85    fn on_click(&self) -> Option<Msg> {
86        None
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::{build_ui, Runtime, Size};
94    use frus_core::Primitive;
95
96    #[derive(Clone, Debug, PartialEq)]
97    enum Msg {
98        Date(i32, u32, u32),
99        Nav(i32),
100        Time(Endpoint, TimeField, u32),
101    }
102
103    fn build(start: Option<(i32, u32, u32)>, end: Option<(i32, u32, u32)>) -> DateTimeRange<Msg> {
104        DateTimeRange::new(
105            2026,
106            7,
107            start,
108            end,
109            (9, 0),
110            (17, 30),
111            |(y, m, d)| Msg::Date(y, m, d),
112            Msg::Nav,
113            Msg::Time,
114        )
115    }
116
117    #[test]
118    fn summary_appears_only_with_both_dates() {
119        // Neither or one endpoint gives [calendar, times]; both give [summary, calendar, times].
120        assert_eq!(Widget::<Msg>::children(&build(None, None)).len(), 2);
121        assert_eq!(
122            Widget::<Msg>::children(&build(Some((2026, 7, 28)), None)).len(),
123            2
124        );
125        assert_eq!(
126            Widget::<Msg>::children(&build(Some((2026, 7, 28)), Some((2026, 8, 3)))).len(),
127            3,
128        );
129    }
130
131    #[test]
132    fn renders_the_combined_summary() {
133        let dtr = build(Some((2026, 7, 28)), Some((2026, 8, 3)));
134        let ui = build_ui(
135            &dtr,
136            Size::new(560.0, 700.0),
137            &Runtime::default(),
138            &Theme::default(),
139        );
140        let has = ui.scene().primitives().iter().any(|p| {
141            matches!(p, Primitive::Text { text, .. }
142                if text == "July 28, 2026  09:00  →  August 3, 2026  17:30")
143        });
144        assert!(has, "the start → end summary is shown");
145    }
146}