Skip to main content

frus_widgets/
timepicker.rs

1//! [`TimePicker`]: a **controlled** time picker, the counterpart of [`crate::DatePicker`].
2//! Two grids of clickable cells (hours, minutes) plus a preview; 24-hour by default, or
3//! 12-hour with an AM/PM toggle ([`hour12`](TimePicker::hour12)). The minute step is
4//! adjustable ([`minute_step`](TimePicker::minute_step)). The time comes from the
5//! application state; the widget emits on click, always as a **24-hour** time.
6
7use std::rc::Rc;
8
9use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
10use frus_layout::{Dimension, FlexDirection, Style};
11
12use crate::flex::Flex;
13use crate::grid::GridView;
14use crate::interaction::Status;
15use crate::text::Text;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19const CELL: f32 = 34.0;
20
21/// A cell's style: what the picker was told, else what the theme says, else the
22/// reference's — its dial is `bodyLarge`, and its day period `titleMedium`, which is the
23/// same size, so the AM/PM cells are simply cells here.
24///
25/// **Resolved once**, so that the number the box is measured with is the number the glyphs
26/// are drawn at. Resolving is the single place the reader's font setting is applied
27/// (milestone 403); a size that never passes through it is a size the reader cannot change.
28fn dial_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
29    over.or(theme.and_then(|t| t.widgets.time_picker.dial_text_style))
30        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
31        .resolved()
32}
33
34/// The `HH:MM` line above the grids.
35///
36/// The reference has no counterpart: it puts an **editable pair of fields** at
37/// `displayMedium` where this shows one read-only line, so this takes the heading step that
38/// line is. Left as a [`TextStyle`], because the preview is a [`Text`] child and a `Text`
39/// resolves its own.
40fn preview_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
41    over.or(theme.and_then(|t| t.widgets.time_picker.preview_text_style))
42        .unwrap_or_else(|| crate::theme::type_scale(theme).headline_medium)
43}
44
45/// The "Hour" and "Minute" lines — the reference's `helpTextStyle`, `labelMedium`.
46fn help_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
47    over.or(theme.and_then(|t| t.widgets.time_picker.help_text_style))
48        .unwrap_or_else(|| crate::theme::type_scale(theme).label_medium)
49}
50
51/// The "Start" and "End" headings of a [`TimeRange`]. Each names a **whole picker**, one
52/// level above the "Hour" and "Minute" lines inside it, so it takes the heading step.
53fn range_label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> TextStyle {
54    over.or(theme.and_then(|t| t.widgets.time_picker.range_label_text_style))
55        .unwrap_or_else(|| crate::theme::type_scale(theme).title_small)
56}
57
58/// A clickable number cell (hour, minute or AM/PM), highlighted when selected.
59struct TimeCell<Msg> {
60    label: String,
61    selected: bool,
62    /// Whether this cell is **AM/PM** rather than a number, which is a different role
63    /// and not only a different label. See [`TimeCell::palette`].
64    day_period: bool,
65    text_style: Option<TextStyle>,
66    message: Option<Msg>,
67}
68
69impl<Msg> TimeCell<Msg> {
70    /// `(background, ink)`.
71    ///
72    /// **The day period is not an hour.** The reference selects a number on the dial with
73    /// `primary` / `onPrimary` (`time_picker.dart:3762`) and the AM/PM cell with
74    /// `tertiaryContainer` / `onTertiaryContainer` (`:3664`, `:3700`) — a different
75    /// family on purpose, because the two choices are not the same kind of choice. Picking
76    /// an hour is picking a value; picking AM or PM is saying which half of the day the
77    /// value is in, and giving both the accent makes the smaller decision shout as loudly
78    /// as the larger one.
79    ///
80    /// It is also the first thing in this framework to ask the scheme for a tertiary role
81    /// at all: they arrived in milestone 429 and nothing had wanted one.
82    fn palette(&self, theme: &Theme, status: &Status) -> (Color, Color) {
83        match (self.selected, self.day_period) {
84            (true, true) => (
85                theme.scheme.tertiary_container,
86                theme.scheme.on_tertiary_container,
87            ),
88            (true, false) => (theme.primary, theme.on_primary),
89            (false, _) => (
90                theme.state_layer(theme.surface, theme.on_surface, status),
91                theme.on_surface,
92            ),
93        }
94    }
95}
96
97impl<Msg> TimeCell<Msg> {
98    fn sizing(&self, theme: Option<&Theme>) -> Style {
99        let side = frus_text::line_box(CELL, &dial_style(self.text_style, theme), 0.0);
100        Style {
101            width: Dimension::Length(side),
102            height: Dimension::Length(side),
103            ..Default::default()
104        }
105    }
106}
107
108impl<Msg: Clone> Widget<Msg> for TimeCell<Msg> {
109    fn style(&self) -> Style {
110        self.sizing(None)
111    }
112
113    fn style_themed(&self, theme: &Theme) -> Style {
114        self.sizing(Some(theme))
115    }
116
117    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
118        &[]
119    }
120
121    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
122        let o = status.opacity;
123        let (bg, fg) = self.palette(theme, &status);
124        scene.draw_rect(bounds, bg.fade(o), CELL * 0.5, 0.0, Color::TRANSPARENT);
125        let style = dial_style(self.text_style, Some(theme));
126        let w = frus_text::measure_resolved(&self.label, &style).width;
127        scene.text(
128            Point::new(
129                bounds.x + (bounds.width - w) * 0.5,
130                bounds.y + (bounds.height - style.line_height()) * 0.5,
131            ),
132            self.label.clone(),
133            &style,
134            fg.fade(o),
135        );
136    }
137
138    fn on_click(&self) -> Option<Msg> {
139        self.message.clone()
140    }
141
142    fn focusable(&self) -> bool {
143        self.message.is_some()
144    }
145}
146
147/// A time picker.
148pub struct TimePicker<Msg = crate::callback::Callback> {
149    hour: u32,
150    minute: u32,
151    on_hour: Box<dyn Fn(u32) -> Msg>,
152    on_minute: Box<dyn Fn(u32) -> Msg>,
153    hour12: bool,
154    minute_step: u32,
155    dial_text_style: Option<TextStyle>,
156    preview_text_style: Option<TextStyle>,
157    help_text_style: Option<TextStyle>,
158    children: Vec<Box<dyn Widget<Msg>>>,
159}
160
161/// The 12-hour clock digit (1–12) for a 24-hour hour.
162fn digit12(hour24: u32) -> u32 {
163    if hour24.is_multiple_of(12) {
164        12
165    } else {
166        hour24 % 12
167    }
168}
169
170impl<Msg: Clone + 'static> TimePicker<Msg> {
171    /// Creates a picker for `hour` (0–23) and `minute` (0–59). `on_hour(h)` is emitted
172    /// when an hour is clicked (always **24-hour**), `on_minute(m)` when a minute is.
173    /// Defaults: 24-hour, minutes in steps of 5.
174    pub fn new(
175        hour: u32,
176        minute: u32,
177        on_hour: impl Fn(u32) -> Msg + 'static,
178        on_minute: impl Fn(u32) -> Msg + 'static,
179    ) -> Self {
180        let mut picker = Self {
181            hour: hour.min(23),
182            minute: minute.min(59),
183            on_hour: Box::new(on_hour),
184            on_minute: Box::new(on_minute),
185            hour12: false,
186            minute_step: 5,
187            dial_text_style: None,
188            preview_text_style: None,
189            help_text_style: None,
190            children: Vec::new(),
191        };
192        picker.rebuild();
193        picker
194    }
195
196    /// Switches to **12-hour**: a 1-to-12 grid + an AM/PM toggle; the preview shows
197    /// 12-hour time. The messages emitted stay in 24-hour time.
198    pub fn hour12(mut self) -> Self {
199        self.hour12 = true;
200        self.rebuild();
201        self
202    }
203
204    /// Sets the **minute step** offered (1–60, clamped). Defaults to 5.
205    pub fn minute_step(mut self, step: u32) -> Self {
206        self.minute_step = step.clamp(1, 60);
207        self.rebuild();
208        self
209    }
210
211    /// The cells' type, over the theme's and the reference's.
212    #[must_use]
213    pub fn dial_text_style(mut self, style: TextStyle) -> Self {
214        self.dial_text_style = Some(style);
215        self.rebuild();
216        self
217    }
218
219    /// The `HH:MM` line's type, over the theme's and the framework's.
220    #[must_use]
221    pub fn preview_text_style(mut self, style: TextStyle) -> Self {
222        self.preview_text_style = Some(style);
223        self.rebuild();
224        self
225    }
226
227    /// The "Hour" and "Minute" lines' type, over the theme's and the reference's.
228    #[must_use]
229    pub fn help_text_style(mut self, style: TextStyle) -> Self {
230        self.help_text_style = Some(style);
231        self.rebuild();
232        self
233    }
234
235    /// Assembles the preview and the grids from the current state.
236    fn rebuild(&mut self) {
237        let (hour, minute) = (self.hour, self.minute);
238        let pm = hour >= 12;
239
240        // HH:MM preview (+ AM/PM in 12-hour mode).
241        let preview_s = preview_style(self.preview_text_style, None);
242        let help_s = help_style(self.help_text_style, None);
243        let cell = self.dial_text_style;
244        let preview = if self.hour12 {
245            let suffix = if pm { "PM" } else { "AM" };
246            Text::styled(format!("{}:{minute:02} {suffix}", digit12(hour)), preview_s)
247        } else {
248            Text::styled(format!("{hour:02}:{minute:02}"), preview_s)
249        };
250
251        // The hours section.
252        let hours_section = if self.hour12 {
253            // AM/PM toggle: clicking a half moves the current hour into it.
254            let am_target = if pm { hour - 12 } else { hour };
255            let pm_target = if pm { hour } else { hour + 12 };
256            let ampm = Flex::row()
257                .gap(4.0)
258                .child(TimeCell {
259                    label: crate::localizations::of().ante_meridiem_label().into(),
260                    selected: !pm,
261                    day_period: true,
262                    text_style: cell,
263                    message: Some((self.on_hour)(am_target)),
264                })
265                .child(TimeCell {
266                    label: crate::localizations::of().post_meridiem_label().into(),
267                    selected: pm,
268                    day_period: true,
269                    text_style: cell,
270                    message: Some((self.on_hour)(pm_target)),
271                });
272            // A 1–12 grid; each cell targets the 24-hour hour of the current half.
273            let current12 = digit12(hour);
274            let mut grid = GridView::new(6).gap(4.0);
275            for d in 1..=12u32 {
276                let target24 = (d % 12) + if pm { 12 } else { 0 };
277                grid = grid.cell(TimeCell {
278                    label: format!("{d}"),
279                    selected: d == current12,
280                    day_period: false,
281                    text_style: cell,
282                    message: Some((self.on_hour)(target24)),
283                });
284            }
285            Flex::column()
286                .gap(6.0)
287                .child(Text::styled(
288                    crate::localizations::of().hour_label(),
289                    help_s,
290                ))
291                .child(ampm)
292                .child(grid)
293        } else {
294            let mut grid = GridView::new(6).gap(4.0);
295            for h in 0..24u32 {
296                grid = grid.cell(TimeCell {
297                    label: format!("{h:02}"),
298                    selected: h == hour,
299                    day_period: false,
300                    text_style: cell,
301                    message: Some((self.on_hour)(h)),
302                });
303            }
304            Flex::column()
305                .gap(6.0)
306                .child(Text::styled(
307                    crate::localizations::of().hour_label(),
308                    help_s,
309                ))
310                .child(grid)
311        };
312
313        // The minutes section (adjustable step). The selection only lights up if the
314        // current minute falls on a step.
315        let mut minutes = GridView::new(6).gap(4.0);
316        let mut m = 0;
317        while m < 60 {
318            minutes = minutes.cell(TimeCell {
319                label: format!("{m:02}"),
320                selected: m == minute,
321                day_period: false,
322                text_style: cell,
323                message: Some((self.on_minute)(m)),
324            });
325            m += self.minute_step;
326        }
327        let minutes_section = Flex::column()
328            .gap(6.0)
329            .child(Text::styled(
330                crate::localizations::of().minute_label(),
331                help_s,
332            ))
333            .child(minutes);
334
335        self.children = vec![
336            Box::new(preview),
337            Box::new(hours_section),
338            Box::new(minutes_section),
339        ];
340    }
341}
342
343impl<Msg> TimePicker<Msg> {
344    /// Six cells and their gaps — the cells' **own** side, not the constant: a picker whose
345    /// cells grew with the reader while its box did not would clip its last column.
346    fn sizing(&self, theme: Option<&Theme>) -> Style {
347        let cell = frus_text::line_box(CELL, &dial_style(self.dial_text_style, theme), 0.0);
348        Style {
349            width: Dimension::Length(6.0 * (cell + 4.0)),
350            flex_direction: FlexDirection::Column,
351            gap: 12.0,
352            ..Default::default()
353        }
354    }
355}
356
357impl<Msg: Clone> Widget<Msg> for TimePicker<Msg> {
358    fn style(&self) -> Style {
359        self.sizing(None)
360    }
361
362    fn style_themed(&self, theme: &Theme) -> Style {
363        self.sizing(Some(theme))
364    }
365
366    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
367        &self.children
368    }
369
370    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
371
372    fn on_click(&self) -> Option<Msg> {
373        None
374    }
375}
376
377/// Which endpoint of a time range ([`TimeRange`]) is targeted.
378#[derive(Copy, Clone, Debug, PartialEq, Eq)]
379pub enum Endpoint {
380    /// The start time.
381    Start,
382    /// The end time.
383    End,
384}
385
386/// Which field of a time ([`TimeRange`]) is changing.
387#[derive(Copy, Clone, Debug, PartialEq, Eq)]
388pub enum TimeField {
389    /// The hour.
390    Hour,
391    /// The minute.
392    Minute,
393}
394
395/// A **time range** (a start → end slot), the temporal counterpart of the dual calendar
396/// ([`crate::DatePicker::range_dual`]): two [`TimePicker`]s labelled "Start" and "End",
397/// side by side. A **single** `on_change(endpoint, field, value)` callback receives every
398/// change (values always **24-hour**); the application decides how to update its state.
399pub struct TimeRange<Msg = crate::callback::Callback> {
400    start: (u32, u32),
401    end: (u32, u32),
402    on_change: Rc<dyn Fn(Endpoint, TimeField, u32) -> Msg>,
403    label_text_style: Option<TextStyle>,
404    hour12: bool,
405    minute_step: u32,
406    children: Vec<Box<dyn Widget<Msg>>>,
407}
408
409impl<Msg: Clone + 'static> TimeRange<Msg> {
410    /// Creates a `start`/`end` range (each `(hour 0–23, minute 0–59)`). `on_change` is
411    /// called on every click with the **endpoint**, the **field** and the new value (24-hour).
412    pub fn new(
413        start: (u32, u32),
414        end: (u32, u32),
415        on_change: impl Fn(Endpoint, TimeField, u32) -> Msg + 'static,
416    ) -> Self {
417        let mut range = Self {
418            label_text_style: None,
419            start: (start.0.min(23), start.1.min(59)),
420            end: (end.0.min(23), end.1.min(59)),
421            on_change: Rc::new(on_change),
422            hour12: false,
423            minute_step: 5,
424            children: Vec::new(),
425        };
426        range.rebuild();
427        range
428    }
429
430    /// The "Start" and "End" headings' type, over the theme's and the framework's.
431    #[must_use]
432    pub fn label_text_style(mut self, style: TextStyle) -> Self {
433        self.label_text_style = Some(style);
434        self.rebuild();
435        self
436    }
437
438    /// Switches both pickers to **12-hour** (AM/PM).
439    pub fn hour12(mut self) -> Self {
440        self.hour12 = true;
441        self.rebuild();
442        self
443    }
444
445    /// Sets the **minute step** of both pickers (1–60).
446    pub fn minute_step(mut self, step: u32) -> Self {
447        self.minute_step = step.clamp(1, 60);
448        self.rebuild();
449        self
450    }
451
452    /// (Re)builds the two labelled columns from the current state.
453    fn rebuild(&mut self) {
454        let (hour12, step) = (self.hour12, self.minute_step);
455        let oc = self.on_change.clone();
456        // A TimePicker whose messages are tagged with the endpoint they target.
457        let make = |ep: Endpoint, h: u32, m: u32| -> TimePicker<Msg> {
458            let (oc_h, oc_m) = (oc.clone(), oc.clone());
459            let mut tp = TimePicker::new(
460                h,
461                m,
462                move |hh| oc_h(ep, TimeField::Hour, hh),
463                move |mm| oc_m(ep, TimeField::Minute, mm),
464            );
465            if hour12 {
466                tp = tp.hour12();
467            }
468            tp.minute_step(step)
469        };
470        let label_s = range_label_style(self.label_text_style, None);
471        let start_col = Flex::column()
472            .gap(8.0)
473            .child(Text::styled(
474                crate::localizations::of().start_label(),
475                label_s,
476            ))
477            .child(make(Endpoint::Start, self.start.0, self.start.1));
478        let end_col = Flex::column()
479            .gap(8.0)
480            .child(Text::styled(
481                crate::localizations::of().end_label(),
482                label_s,
483            ))
484            .child(make(Endpoint::End, self.end.0, self.end.1));
485        self.children = vec![Box::new(start_col), Box::new(end_col)];
486    }
487}
488
489impl<Msg: Clone> Widget<Msg> for TimeRange<Msg> {
490    fn style(&self) -> Style {
491        Style {
492            flex_direction: FlexDirection::Row,
493            gap: 24.0,
494            ..Default::default()
495        }
496    }
497
498    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
499        &self.children
500    }
501
502    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
503
504    fn on_click(&self) -> Option<Msg> {
505        None
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::{build_ui, Runtime, Size};
513    use frus_core::{Point, Primitive};
514
515    #[derive(Clone, Debug, PartialEq)]
516    enum Msg {
517        Hour(u32),
518        Minute(u32),
519    }
520
521    fn preview_text(ui: &crate::Ui<Msg>) -> Option<String> {
522        ui.scene().primitives().iter().find_map(|p| match p {
523            Primitive::Text { text, position, .. } if position.y < 40.0 && text.contains(':') => {
524                Some(text.clone())
525            }
526            _ => None,
527        })
528    }
529
530    /// `(background, ink)` of one painted cell.
531    fn cell_colors(selected: bool, day_period: bool, theme: &Theme) -> (Color, Color) {
532        let cell = TimeCell::<Msg> {
533            label: "AM".into(),
534            selected,
535            day_period,
536            text_style: None,
537            message: Some(Msg::Hour(0)),
538        };
539        let mut scene = Scene::new();
540        Widget::<Msg>::paint(
541            &cell,
542            Rect::new(0.0, 0.0, 40.0, 40.0),
543            Status {
544                opacity: 1.0,
545                ..Default::default()
546            },
547            theme,
548            &mut scene,
549        );
550        let bg = scene
551            .primitives()
552            .iter()
553            .find_map(|p| match p {
554                Primitive::Rect { color, .. } => Some(*color),
555                _ => None,
556            })
557            .expect("a cell paints a box");
558        let ink = scene
559            .primitives()
560            .iter()
561            .find_map(|p| match p {
562                Primitive::Text { color, .. } => Some(*color),
563                _ => None,
564            })
565            .expect("a cell paints its label");
566        (bg, ink)
567    }
568
569    /// **The day period is not an hour** (milestone 439).
570    ///
571    /// The reference selects a number with `primary` / `onPrimary`
572    /// (`time_picker.dart:3762`) and AM/PM with `tertiaryContainer` /
573    /// `onTertiaryContainer` (`:3664`, `:3700`) — a different family on purpose, because
574    /// the two are not the same kind of choice. Both were painted with the accent here,
575    /// which made the smaller decision shout as loudly as the larger one.
576    #[test]
577    fn the_day_period_is_not_an_hour() {
578        let theme = Theme::default();
579        assert_eq!(
580            cell_colors(true, true, &theme),
581            (
582                theme.scheme.tertiary_container,
583                theme.scheme.on_tertiary_container
584            )
585        );
586        assert_eq!(
587            cell_colors(true, false, &theme),
588            (theme.primary, theme.on_primary)
589        );
590        assert_ne!(
591            cell_colors(true, true, &theme).0,
592            cell_colors(true, false, &theme).0,
593            "the two have to differ for the distinction to be worth making"
594        );
595        // Unselected, neither is anything in particular: both are the surface under a
596        // state layer, which at rest is the surface.
597        assert_eq!(
598            cell_colors(false, true, &theme),
599            cell_colors(false, false, &theme),
600            "only the selected one names a family"
601        );
602    }
603
604    #[test]
605    fn builds_header_hours_and_minutes() {
606        let tp = TimePicker::new(9, 30, Msg::Hour, Msg::Minute);
607        assert_eq!(Widget::<Msg>::children(&tp).len(), 3);
608        // Section heures 24 h = [label, grille(24)].
609        let hours_grid = &Widget::<Msg>::children(&tp)[1].children()[1];
610        assert_eq!(hours_grid.children().len(), 24);
611        // Minutes pas de 5 → 12 cases.
612        let minutes_grid = &Widget::<Msg>::children(&tp)[2].children()[1];
613        assert_eq!(minutes_grid.children().len(), 12);
614    }
615
616    #[test]
617    fn minute_step_changes_the_minute_count() {
618        let tp = TimePicker::new(0, 0, Msg::Hour, Msg::Minute).minute_step(15);
619        let minutes_grid = &Widget::<Msg>::children(&tp)[2].children()[1];
620        assert_eq!(minutes_grid.children().len(), 4, "60/15 = 4 minutes");
621    }
622
623    #[test]
624    fn hour12_shows_am_pm_and_twelve_hours() {
625        // 15 h 05 → 12 h : 3 PM.
626        let tp = TimePicker::new(15, 5, Msg::Hour, Msg::Minute).hour12();
627        // Section heures = [label, AM/PM, grille(12)].
628        let hours_section = &Widget::<Msg>::children(&tp)[1];
629        assert_eq!(hours_section.children().len(), 3);
630        let hours_grid = &hours_section.children()[2];
631        assert_eq!(hours_grid.children().len(), 12);
632
633        let ui = build_ui(
634            &tp,
635            Size::new(240.0, 360.0),
636            &Runtime::default(),
637            &Theme::default(),
638        );
639        assert_eq!(preview_text(&ui).as_deref(), Some("3:05 PM"));
640    }
641
642    #[test]
643    fn twentyfour_hour_preview() {
644        let tp = TimePicker::new(9, 30, Msg::Hour, Msg::Minute);
645        let ui = build_ui(
646            &tp,
647            Size::new(240.0, 320.0),
648            &Runtime::default(),
649            &Theme::default(),
650        );
651        assert_eq!(preview_text(&ui).as_deref(), Some("09:30"));
652    }
653
654    #[test]
655    fn clicking_a_cell_emits_a_message() {
656        let tp = TimePicker::new(0, 0, Msg::Hour, Msg::Minute);
657        let ui = build_ui(
658            &tp,
659            Size::new(240.0, 320.0),
660            &Runtime::default(),
661            &Theme::default(),
662        );
663        let click = |x: f32, y: f32| ui.hit(Point::new(x, y)).and_then(|id| ui.msg_for(id));
664        let msg = (0..320)
665            .step_by(4)
666            .find_map(|y| click(CELL * 0.5, y as f32))
667            .expect("a clickable cell exists");
668        assert!(matches!(msg, Msg::Hour(_) | Msg::Minute(_)));
669    }
670
671    #[derive(Clone, Debug, PartialEq)]
672    enum RangeMsg {
673        Set(Endpoint, TimeField, u32),
674    }
675
676    #[test]
677    fn range_builds_start_and_end_pickers() {
678        let tr = TimeRange::new((9, 0), (17, 30), RangeMsg::Set).minute_step(15);
679        // Two labelled columns [label, TimePicker].
680        let cols = Widget::<RangeMsg>::children(&tr);
681        assert_eq!(cols.len(), 2);
682        let start_tp = &cols[0].children()[1];
683        assert_eq!(start_tp.children().len(), 3, "preview + hours + minutes");
684        // Minutes in steps of 15 → 4 cells.
685        let start_minutes = &start_tp.children()[2].children()[1];
686        assert_eq!(start_minutes.children().len(), 4);
687        // Clicking 09 h in the End endpoint emits Set(End, Hour, 9).
688        let end_tp = &cols[1].children()[1];
689        let end_hours = &end_tp.children()[1].children()[1];
690        assert_eq!(
691            end_hours.children()[9].on_click(),
692            Some(RangeMsg::Set(Endpoint::End, TimeField::Hour, 9)),
693        );
694    }
695
696    #[test]
697    fn hour12_applies_to_both_pickers() {
698        let tr = TimeRange::new((15, 0), (20, 0), RangeMsg::Set).hour12();
699        let cols = Widget::<RangeMsg>::children(&tr);
700        // A 12-hour hours section = [label, AM/PM, grid(12)] → 3 children, for both endpoints.
701        for col in cols {
702            let tp = &col.children()[1];
703            assert_eq!(tp.children()[1].children().len(), 3);
704        }
705    }
706}