Skip to main content

frust_material/time_picker/
input.rs

1// Ported from `material_3_expressive` v1.0.8 (MIT, © 2026 Paa Developments;
2// `tmp/material_3_expressive/lib/components/time_pickers/components/m3e_input_time_picker_form_field.dart`
3// plus `utils/m3e_time_picker_utils.dart`'s validation/parse helpers,
4// retrieved 2026-08-20).
5// Upstream: <https://github.com/paadevelopments/material_3_expressive>
6//
7// Porting decisions (documented below): the reference's two internal
8// `TextEditingController`s become one caller-owned `TimeEntry` prop, since
9// this crate's whole editable family is controlled; and `Form`/`FormField`
10// autovalidation becomes a plain `autovalidate` flag, since there is no form
11// registry to save/validate through.
12
13//! The M3E time picker's **text entry** mode: an hour field, a minute field,
14//! and (in 12-hour mode) an AM/PM button pair.
15//!
16//! [`time_input`] is a controlled component over [`TimeEntry`] — the *raw*
17//! text of both fields plus the AM/PM flag — rather than over a
18//! [`super::TimeOfDay`], because a half-typed field ("1", "", "9x") has no
19//! [`super::TimeOfDay`] to be. The caller threads its own `TimeEntry` back in on
20//! every edit, exactly the way `crate::dialog`'s `selection_dialog` threads
21//! `.selected(..)`; [`TimeEntry::parse`] turns a complete entry back into a
22//! time.
23//!
24//! # Validation (cited)
25//!
26//! [`TimeEntry::hour_is_valid`]/[`TimeEntry::minute_is_valid`] are
27//! `M3ETimePickerUtils.isValidHourText`/`isValidMinuteText`: an hour parses to
28//! `0..=23` in 24-hour mode and `1..=12` in 12-hour mode, a minute to
29//! `0..=59`, and anything that does not parse at all is invalid. Text is
30//! trimmed first (`_parseFields`). [`TimeEntry::parse`] is
31//! `parseInputTime`: both halves must validate, then the 12-hour hour is
32//! folded through `to24Hour(hour12, pm)`.
33//!
34//! # Errors appear on demand, not while typing
35//!
36//! The reference starts at `AutovalidateMode.disabled` and only flips to
37//! `always` once an OK press fails validation
38//! (`_M3ETimePickerDialogState._handleOk`). [`TimeInputView::autovalidate`]
39//! is that flip: `false` (the default) paints no error however malformed the
40//! text is; `true` paints `strings.invalid_time` under whichever field is
41//! invalid.
42
43use std::rc::Rc;
44
45use frust::authoring::{
46    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
47    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, TypedArgCallback, View, Widget, any,
48    build_child, rebuild_child, route_event, route_event_single, teardown_child, visit_children,
49};
50use kurbo::{Point, Size};
51
52use super::{TimeEntry, TimePickerStrings};
53use crate::button::{ButtonVariant, button};
54use crate::text_field::text_field;
55
56/// Gap between the hour and minute fields, and between the field row and the
57/// AM/PM row (`_buildHourMinuteRow`'s `SizedBox(width: 16)` and
58/// `build`'s `SizedBox(height: 16)`).
59const FIELD_GAP: f64 = 16.0;
60/// Gap between the AM and PM buttons (`_buildMeridiemRow`'s
61/// `SizedBox(width: 8)`).
62const PERIOD_GAP: f64 = 8.0;
63
64/// A declarative time entry form. See the [module docs](self).
65pub struct TimeInputView<State: 'static> {
66    entry: TimeEntry,
67    use_24_hour: bool,
68    autovalidate: bool,
69    strings: Rc<TimePickerStrings>,
70    on_change: TypedArgCallback<State, TimeEntry>,
71}
72
73/// Create a controlled time entry form over `entry`, reporting each requested
74/// entry through `on_change(state, requested)`.
75pub fn time_input<State: 'static, F: Fn(&mut State, TimeEntry) + 'static>(
76    entry: TimeEntry,
77    on_change: F,
78) -> TimeInputView<State> {
79    TimeInputView {
80        entry,
81        use_24_hour: false,
82        autovalidate: false,
83        strings: Rc::new(TimePickerStrings::default()),
84        on_change: Rc::new(on_change),
85    }
86}
87
88impl<State: 'static> TimeInputView<State> {
89    /// Validate the hour against `0..=23` and drop the AM/PM row
90    /// (`alwaysUse24HourFormat`).
91    pub fn use_24_hour(mut self, use_24_hour: bool) -> Self {
92        self.use_24_hour = use_24_hour;
93        self
94    }
95
96    /// Show the error line under any invalid field — see the [module
97    /// docs](self)' *Errors appear on demand*.
98    pub fn autovalidate(mut self, autovalidate: bool) -> Self {
99        self.autovalidate = autovalidate;
100        self
101    }
102
103    /// Override the field labels, the AM/PM labels and the error text.
104    pub fn strings(mut self, strings: TimePickerStrings) -> Self {
105        self.strings = Rc::new(strings);
106        self
107    }
108
109    /// Share an already-`Rc`'d string bundle (used by
110    /// [`crate::time_picker::time_picker`]).
111    pub(super) fn shared_strings(mut self, strings: Rc<TimePickerStrings>) -> Self {
112        self.strings = strings;
113        self
114    }
115
116    /// The error text for a field, or `None` while autovalidation is off or
117    /// the field is well-formed.
118    fn error_for(&self, valid: bool) -> Option<&str> {
119        (self.autovalidate && !valid).then_some(self.strings.invalid_time.as_str())
120    }
121
122    fn hour_field(&self) -> AnyView<State> {
123        let on_change = self.on_change.clone();
124        let entry = self.entry.clone();
125        let mut field = text_field(
126            self.entry.hour_text.clone(),
127            move |state: &mut State, text: String| {
128                on_change(state, entry.clone().with_hour_text(text));
129            },
130        )
131        .label(self.strings.hour_label.clone());
132        if let Some(error) = self.error_for(self.entry.hour_is_valid(self.use_24_hour)) {
133            field = field.error_text(error);
134        }
135        any(field)
136    }
137
138    fn minute_field(&self) -> AnyView<State> {
139        let on_change = self.on_change.clone();
140        let entry = self.entry.clone();
141        let mut field = text_field(
142            self.entry.minute_text.clone(),
143            move |state: &mut State, text: String| {
144                on_change(state, entry.clone().with_minute_text(text));
145            },
146        )
147        .label(self.strings.minute_label.clone());
148        if let Some(error) = self.error_for(self.entry.minute_is_valid()) {
149            field = field.error_text(error);
150        }
151        any(field)
152    }
153
154    /// One AM/PM button — filled while selected, outlined otherwise
155    /// (`_buildMeridiemRow`).
156    fn period_button(&self, pm: bool) -> AnyView<State> {
157        let on_change = self.on_change.clone();
158        let entry = self.entry.clone();
159        let label = if pm {
160            self.strings.pm.clone()
161        } else {
162            self.strings.am.clone()
163        };
164        let selected = self.entry.pm == pm;
165        any(button(label, move |state: &mut State| {
166            on_change(state, entry.clone().with_pm(pm));
167        })
168        .variant(if selected {
169            ButtonVariant::Filled
170        } else {
171            ButtonVariant::Outlined
172        }))
173    }
174
175    /// The period row's two buttons, or an empty list in 24-hour mode.
176    fn period_buttons(&self) -> Vec<AnyView<State>> {
177        if self.use_24_hour {
178            Vec::new()
179        } else {
180            vec![self.period_button(false), self.period_button(true)]
181        }
182    }
183}
184
185/// The retained widget for a [`TimeInputView`].
186pub struct TimeInputWidget {
187    hour: ChildPod,
188    minute: ChildPod,
189    /// AM then PM, empty in 24-hour mode.
190    period: Vec<ChildPod>,
191}
192
193impl<State: 'static> View<State> for TimeInputView<State> {
194    type Element = TimeInputWidget;
195
196    fn build(&self, ctx: &mut BuildCtx<'_>) -> TimeInputWidget {
197        TimeInputWidget {
198            hour: build_child(&self.hour_field(), ctx),
199            minute: build_child(&self.minute_field(), ctx),
200            period: self
201                .period_buttons()
202                .iter()
203                .map(|v| build_child(v, ctx))
204                .collect(),
205        }
206    }
207
208    fn rebuild(
209        &self,
210        prev: &Self,
211        element: &mut TimeInputWidget,
212        ctx: &mut BuildCtx<'_>,
213    ) -> ChangeFlags {
214        let mut flags = rebuild_child(
215            &prev.hour_field(),
216            &self.hour_field(),
217            &mut element.hour,
218            ctx,
219        );
220        flags |= rebuild_child(
221            &prev.minute_field(),
222            &self.minute_field(),
223            &mut element.minute,
224            ctx,
225        );
226
227        let prev_period = prev.period_buttons();
228        let next_period = self.period_buttons();
229        if prev_period.len() == next_period.len() {
230            for ((p, n), pod) in prev_period
231                .iter()
232                .zip(next_period.iter())
233                .zip(element.period.iter_mut())
234            {
235                flags |= rebuild_child(p, n, pod, ctx);
236            }
237        } else {
238            // The format flipped: tear the old row down and build the new one.
239            for (view, pod) in prev_period.iter().zip(element.period.iter_mut()) {
240                teardown_child(view, pod, ctx);
241            }
242            element.period = next_period.iter().map(|v| build_child(v, ctx)).collect();
243            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
244        }
245        flags
246    }
247
248    fn teardown(&self, element: &mut TimeInputWidget, ctx: &mut BuildCtx<'_>) {
249        teardown_child(&self.hour_field(), &mut element.hour, ctx);
250        teardown_child(&self.minute_field(), &mut element.minute, ctx);
251        for (view, pod) in self.period_buttons().iter().zip(element.period.iter_mut()) {
252            teardown_child(view, pod, ctx);
253        }
254    }
255}
256
257impl Widget for TimeInputWidget {
258    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
259        let width = bc.max().width.max(bc.min().width);
260        // Two equal-width fields with one gap between them (the reference's
261        // paired `Expanded`s).
262        let field_w = ((width - FIELD_GAP) / 2.0).max(0.0);
263        let field_bc =
264            BoxConstraints::new(Size::new(field_w, 0.0), Size::new(field_w, f64::INFINITY));
265        let hour = self.hour.layout_child(ctx, &field_bc);
266        let minute = self.minute.layout_child(ctx, &field_bc);
267        self.hour.set_origin(Point::ZERO);
268        self.minute.set_origin(Point::new(field_w + FIELD_GAP, 0.0));
269
270        let mut y = hour.height.max(minute.height);
271
272        if !self.period.is_empty() {
273            y += FIELD_GAP;
274            let loose = BoxConstraints::loose(Size::new(width, f64::INFINITY));
275            let mut x = 0.0;
276            let mut row_h: f64 = 0.0;
277            for (i, pod) in self.period.iter_mut().enumerate() {
278                let s = pod.layout_child(ctx, &loose);
279                if i > 0 {
280                    x += PERIOD_GAP;
281                }
282                pod.set_origin(Point::new(x, y));
283                x += s.width;
284                row_h = row_h.max(s.height);
285            }
286            y += row_h;
287        }
288
289        bc.constrain(Size::new(width, y))
290    }
291
292    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
293        self.hour.paint_child(ctx, scene);
294        self.minute.paint_child(ctx, scene);
295        for pod in &mut self.period {
296            pod.paint_child(ctx, scene);
297        }
298    }
299
300    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
301        if route_event_single(&mut self.hour, ctx, event) == EventResult::Handled {
302            return EventResult::Handled;
303        }
304        if route_event_single(&mut self.minute, ctx, event) == EventResult::Handled {
305            return EventResult::Handled;
306        }
307        route_event(&mut self.period, ctx, event)
308    }
309
310    fn semantics(&self, ctx: &mut SemanticsCtx) {
311        self.hour.semantics_child(ctx);
312        self.minute.semantics_child(ctx);
313        for pod in &self.period {
314            pod.semantics_child(ctx);
315        }
316    }
317
318    visit_children!(hour, minute, period);
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::time_picker::TimeOfDay;
325    use frust::authoring::text::TextContext;
326    use std::any::Any;
327
328    #[test]
329    fn hour_validity_follows_the_format() {
330        for (text, ok_24, ok_12) in [
331            ("0", true, false),
332            ("00", true, false),
333            ("1", true, true),
334            ("12", true, true),
335            ("13", true, false),
336            ("23", true, false),
337            ("24", false, false),
338            ("", false, false),
339            ("ab", false, false),
340            ("1 2", false, false),
341            ("-1", false, false),
342            ("999999999999999999999", false, false),
343        ] {
344            let entry = TimeEntry::default().with_hour_text(text);
345            assert_eq!(entry.hour_is_valid(true), ok_24, "24-hour `{text}`");
346            assert_eq!(entry.hour_is_valid(false), ok_12, "12-hour `{text}`");
347        }
348    }
349
350    #[test]
351    fn minute_validity_is_zero_through_fifty_nine() {
352        for (text, ok) in [
353            ("0", true),
354            ("00", true),
355            ("59", true),
356            ("60", false),
357            ("", false),
358            ("x", false),
359        ] {
360            assert_eq!(
361                TimeEntry::default()
362                    .with_minute_text(text)
363                    .minute_is_valid(),
364                ok,
365                "`{text}`"
366            );
367        }
368    }
369
370    #[test]
371    fn parse_folds_the_twelve_hour_half_through_the_period_flag() {
372        let am = TimeEntry {
373            hour_text: "12".into(),
374            minute_text: "05".into(),
375            pm: false,
376        };
377        assert_eq!(
378            am.parse(false),
379            Some(TimeOfDay::new(0, 5)),
380            "12 AM is 00:05"
381        );
382        let pm = am.clone().with_pm(true);
383        assert_eq!(
384            pm.parse(false),
385            Some(TimeOfDay::new(12, 5)),
386            "12 PM is 12:05"
387        );
388        let nine_pm = TimeEntry {
389            hour_text: "9".into(),
390            minute_text: "30".into(),
391            pm: true,
392        };
393        assert_eq!(nine_pm.parse(false), Some(TimeOfDay::new(21, 30)));
394        // In 24-hour mode the period flag is ignored outright.
395        assert_eq!(nine_pm.parse(true), Some(TimeOfDay::new(9, 30)));
396    }
397
398    #[test]
399    fn parse_refuses_an_incomplete_or_out_of_range_entry() {
400        for entry in [
401            TimeEntry::default(),
402            TimeEntry::default().with_hour_text("10"),
403            TimeEntry::default().with_minute_text("10"),
404            TimeEntry {
405                hour_text: "25".into(),
406                minute_text: "00".into(),
407                pm: false,
408            },
409            TimeEntry {
410                hour_text: "10".into(),
411                minute_text: "61".into(),
412                pm: false,
413            },
414        ] {
415            assert_eq!(entry.parse(true), None, "{entry:?}");
416        }
417    }
418
419    #[test]
420    fn surrounding_whitespace_is_trimmed_before_validating() {
421        let entry = TimeEntry {
422            hour_text: " 09 ".into(),
423            minute_text: "\t15\n".into(),
424            pm: false,
425        };
426        assert!(entry.hour_is_valid(true));
427        assert!(entry.minute_is_valid());
428        assert_eq!(entry.parse(true), Some(TimeOfDay::new(9, 15)));
429    }
430
431    #[test]
432    fn from_time_seeds_zero_padded_text_and_the_period_flag() {
433        let entry = TimeEntry::from_time(TimeOfDay::new(21, 5), false);
434        assert_eq!(entry.hour_text, "09");
435        assert_eq!(entry.minute_text, "05");
436        assert!(entry.pm);
437        let entry24 = TimeEntry::from_time(TimeOfDay::new(21, 5), true);
438        assert_eq!(entry24.hour_text, "21");
439        assert_eq!(entry24.minute_text, "05");
440    }
441
442    // ------------------------------------------------------------------
443    // Widget-level behaviour
444    // ------------------------------------------------------------------
445
446    #[derive(Default)]
447    struct App {
448        entry: TimeEntry,
449    }
450
451    fn view(entry: TimeEntry, use_24_hour: bool) -> TimeInputView<App> {
452        time_input(entry, |s: &mut App, e: TimeEntry| s.entry = e).use_24_hour(use_24_hour)
453    }
454
455    /// Build the form widget directly and lay it out against a text context —
456    /// the harness `crate::switch`'s own tests use.
457    fn mount(view: &TimeInputView<App>) -> (TimeInputWidget, TextContext) {
458        let mut counter = 0u64;
459        let mut widget = View::<App>::build(view, &mut BuildCtx::new(&mut counter));
460        let mut tcx = TextContext::new();
461        {
462            let mut layout_ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
463            widget.layout(
464                &mut layout_ctx,
465                &BoxConstraints::tight(Size::new(320.0, 400.0)),
466            );
467        }
468        (widget, tcx)
469    }
470
471    #[test]
472    fn twelve_hour_mode_shows_the_period_row_and_twenty_four_hour_mode_does_not() {
473        let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
474        let twelve = view(entry.clone(), false);
475        let (mut widget, _tcx) = mount(&twelve);
476        assert_eq!(widget.period.len(), 2);
477
478        let mut counter = 0u64;
479        let twenty_four = view(entry.clone(), true);
480        View::<App>::rebuild(
481            &twenty_four,
482            &twelve,
483            &mut widget,
484            &mut BuildCtx::new(&mut counter),
485        );
486        assert_eq!(widget.period.len(), 0, "the row is torn down");
487
488        let back = view(entry, false);
489        View::<App>::rebuild(
490            &back,
491            &twenty_four,
492            &mut widget,
493            &mut BuildCtx::new(&mut counter),
494        );
495        assert_eq!(widget.period.len(), 2, "and rebuilt on the way back");
496    }
497
498    #[test]
499    fn autovalidate_gates_the_error_line() {
500        let entry = TimeEntry {
501            hour_text: "99".into(),
502            minute_text: "".into(),
503            pm: false,
504        };
505        let quiet: TimeInputView<App> = time_input(entry.clone(), |_: &mut App, _| {});
506        assert_eq!(quiet.error_for(entry.hour_is_valid(false)), None);
507        let loud: TimeInputView<App> =
508            time_input(entry.clone(), |_: &mut App, _| {}).autovalidate(true);
509        assert_eq!(
510            loud.error_for(entry.hour_is_valid(false)),
511            Some(TimePickerStrings::default().invalid_time.as_str())
512        );
513        assert_eq!(
514            loud.error_for(true),
515            None,
516            "a well-formed field stays quiet even under autovalidation"
517        );
518    }
519
520    #[test]
521    fn the_form_lays_both_fields_out_side_by_side() {
522        let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
523        let (widget, _tcx) = mount(&view(entry, false));
524        assert_eq!(widget.hour.origin(), Point::ZERO);
525        assert_eq!(widget.hour.origin().y, widget.minute.origin().y, "same row");
526        assert!(
527            (widget.minute.origin().x - (widget.hour.size().width + FIELD_GAP)).abs() < 1e-9,
528            "one 16dp gap between the fields"
529        );
530        assert!(
531            widget.period[0].origin().y >= widget.hour.size().height + FIELD_GAP,
532            "the period row sits one gap below the fields"
533        );
534    }
535}