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) -> impl View<State> + use<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        field
136    }
137
138    fn minute_field(&self) -> impl View<State> + use<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        field
152    }
153
154    /// One AM/PM button — filled while selected, outlined otherwise
155    /// (`_buildMeridiemRow`).
156    // erasure: keep element of the Vec<AnyView> the period ChildPods are built from
157    fn period_button(&self, pm: bool) -> AnyView<State> {
158        let on_change = self.on_change.clone();
159        let entry = self.entry.clone();
160        let label = if pm {
161            self.strings.pm.clone()
162        } else {
163            self.strings.am.clone()
164        };
165        let selected = self.entry.pm == pm;
166        any(button(label, move |state: &mut State| {
167            on_change(state, entry.clone().with_pm(pm));
168        })
169        .variant(if selected {
170            ButtonVariant::Filled
171        } else {
172            ButtonVariant::Outlined
173        }))
174    }
175
176    /// The period row's two buttons, or an empty list in 24-hour mode.
177    fn period_buttons(&self) -> Vec<AnyView<State>> {
178        if self.use_24_hour {
179            Vec::new()
180        } else {
181            vec![self.period_button(false), self.period_button(true)]
182        }
183    }
184}
185
186/// The retained widget for a [`TimeInputView`].
187pub struct TimeInputWidget {
188    hour: ChildPod,
189    minute: ChildPod,
190    /// AM then PM, empty in 24-hour mode.
191    period: Vec<ChildPod>,
192}
193
194impl<State: 'static> View<State> for TimeInputView<State> {
195    type Element = TimeInputWidget;
196
197    fn build(&self, ctx: &mut BuildCtx<'_>) -> TimeInputWidget {
198        TimeInputWidget {
199            hour: build_child(&self.hour_field(), ctx),
200            minute: build_child(&self.minute_field(), ctx),
201            period: self
202                .period_buttons()
203                .iter()
204                .map(|v| build_child(v, ctx))
205                .collect(),
206        }
207    }
208
209    fn rebuild(
210        &self,
211        prev: &Self,
212        element: &mut TimeInputWidget,
213        ctx: &mut BuildCtx<'_>,
214    ) -> ChangeFlags {
215        let mut flags = rebuild_child(
216            &prev.hour_field(),
217            &self.hour_field(),
218            &mut element.hour,
219            ctx,
220        );
221        flags |= rebuild_child(
222            &prev.minute_field(),
223            &self.minute_field(),
224            &mut element.minute,
225            ctx,
226        );
227
228        let prev_period = prev.period_buttons();
229        let next_period = self.period_buttons();
230        if prev_period.len() == next_period.len() {
231            for ((p, n), pod) in prev_period
232                .iter()
233                .zip(next_period.iter())
234                .zip(element.period.iter_mut())
235            {
236                flags |= rebuild_child(p, n, pod, ctx);
237            }
238        } else {
239            // The format flipped: tear the old row down and build the new one.
240            for (view, pod) in prev_period.iter().zip(element.period.iter_mut()) {
241                teardown_child(view, pod, ctx);
242            }
243            element.period = next_period.iter().map(|v| build_child(v, ctx)).collect();
244            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
245        }
246        flags
247    }
248
249    fn teardown(&self, element: &mut TimeInputWidget, ctx: &mut BuildCtx<'_>) {
250        teardown_child(&self.hour_field(), &mut element.hour, ctx);
251        teardown_child(&self.minute_field(), &mut element.minute, ctx);
252        for (view, pod) in self.period_buttons().iter().zip(element.period.iter_mut()) {
253            teardown_child(view, pod, ctx);
254        }
255    }
256}
257
258impl Widget for TimeInputWidget {
259    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
260        let width = bc.max().width.max(bc.min().width);
261        // Two equal-width fields with one gap between them (the reference's
262        // paired `Expanded`s).
263        let field_w = ((width - FIELD_GAP) / 2.0).max(0.0);
264        let field_bc =
265            BoxConstraints::new(Size::new(field_w, 0.0), Size::new(field_w, f64::INFINITY));
266        let hour = self.hour.layout_child(ctx, &field_bc);
267        let minute = self.minute.layout_child(ctx, &field_bc);
268        self.hour.set_origin(Point::ZERO);
269        self.minute.set_origin(Point::new(field_w + FIELD_GAP, 0.0));
270
271        let mut y = hour.height.max(minute.height);
272
273        if !self.period.is_empty() {
274            y += FIELD_GAP;
275            let loose = BoxConstraints::loose(Size::new(width, f64::INFINITY));
276            let mut x = 0.0;
277            let mut row_h: f64 = 0.0;
278            for (i, pod) in self.period.iter_mut().enumerate() {
279                let s = pod.layout_child(ctx, &loose);
280                if i > 0 {
281                    x += PERIOD_GAP;
282                }
283                pod.set_origin(Point::new(x, y));
284                x += s.width;
285                row_h = row_h.max(s.height);
286            }
287            y += row_h;
288        }
289
290        bc.constrain(Size::new(width, y))
291    }
292
293    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
294        self.hour.paint_child(ctx, scene);
295        self.minute.paint_child(ctx, scene);
296        for pod in &mut self.period {
297            pod.paint_child(ctx, scene);
298        }
299    }
300
301    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
302        if route_event_single(&mut self.hour, ctx, event) == EventResult::Handled {
303            return EventResult::Handled;
304        }
305        if route_event_single(&mut self.minute, ctx, event) == EventResult::Handled {
306            return EventResult::Handled;
307        }
308        route_event(&mut self.period, ctx, event)
309    }
310
311    fn semantics(&self, ctx: &mut SemanticsCtx) {
312        self.hour.semantics_child(ctx);
313        self.minute.semantics_child(ctx);
314        for pod in &self.period {
315            pod.semantics_child(ctx);
316        }
317    }
318
319    visit_children!(hour, minute, period);
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::time_picker::TimeOfDay;
326    use frust::authoring::text::TextContext;
327    use std::any::Any;
328
329    #[test]
330    fn hour_validity_follows_the_format() {
331        for (text, ok_24, ok_12) in [
332            ("0", true, false),
333            ("00", true, false),
334            ("1", true, true),
335            ("12", true, true),
336            ("13", true, false),
337            ("23", true, false),
338            ("24", false, false),
339            ("", false, false),
340            ("ab", false, false),
341            ("1 2", false, false),
342            ("-1", false, false),
343            ("999999999999999999999", false, false),
344        ] {
345            let entry = TimeEntry::default().with_hour_text(text);
346            assert_eq!(entry.hour_is_valid(true), ok_24, "24-hour `{text}`");
347            assert_eq!(entry.hour_is_valid(false), ok_12, "12-hour `{text}`");
348        }
349    }
350
351    #[test]
352    fn minute_validity_is_zero_through_fifty_nine() {
353        for (text, ok) in [
354            ("0", true),
355            ("00", true),
356            ("59", true),
357            ("60", false),
358            ("", false),
359            ("x", false),
360        ] {
361            assert_eq!(
362                TimeEntry::default()
363                    .with_minute_text(text)
364                    .minute_is_valid(),
365                ok,
366                "`{text}`"
367            );
368        }
369    }
370
371    #[test]
372    fn parse_folds_the_twelve_hour_half_through_the_period_flag() {
373        let am = TimeEntry {
374            hour_text: "12".into(),
375            minute_text: "05".into(),
376            pm: false,
377        };
378        assert_eq!(
379            am.parse(false),
380            Some(TimeOfDay::new(0, 5)),
381            "12 AM is 00:05"
382        );
383        let pm = am.clone().with_pm(true);
384        assert_eq!(
385            pm.parse(false),
386            Some(TimeOfDay::new(12, 5)),
387            "12 PM is 12:05"
388        );
389        let nine_pm = TimeEntry {
390            hour_text: "9".into(),
391            minute_text: "30".into(),
392            pm: true,
393        };
394        assert_eq!(nine_pm.parse(false), Some(TimeOfDay::new(21, 30)));
395        // In 24-hour mode the period flag is ignored outright.
396        assert_eq!(nine_pm.parse(true), Some(TimeOfDay::new(9, 30)));
397    }
398
399    #[test]
400    fn parse_refuses_an_incomplete_or_out_of_range_entry() {
401        for entry in [
402            TimeEntry::default(),
403            TimeEntry::default().with_hour_text("10"),
404            TimeEntry::default().with_minute_text("10"),
405            TimeEntry {
406                hour_text: "25".into(),
407                minute_text: "00".into(),
408                pm: false,
409            },
410            TimeEntry {
411                hour_text: "10".into(),
412                minute_text: "61".into(),
413                pm: false,
414            },
415        ] {
416            assert_eq!(entry.parse(true), None, "{entry:?}");
417        }
418    }
419
420    #[test]
421    fn surrounding_whitespace_is_trimmed_before_validating() {
422        let entry = TimeEntry {
423            hour_text: " 09 ".into(),
424            minute_text: "\t15\n".into(),
425            pm: false,
426        };
427        assert!(entry.hour_is_valid(true));
428        assert!(entry.minute_is_valid());
429        assert_eq!(entry.parse(true), Some(TimeOfDay::new(9, 15)));
430    }
431
432    #[test]
433    fn from_time_seeds_zero_padded_text_and_the_period_flag() {
434        let entry = TimeEntry::from_time(TimeOfDay::new(21, 5), false);
435        assert_eq!(entry.hour_text, "09");
436        assert_eq!(entry.minute_text, "05");
437        assert!(entry.pm);
438        let entry24 = TimeEntry::from_time(TimeOfDay::new(21, 5), true);
439        assert_eq!(entry24.hour_text, "21");
440        assert_eq!(entry24.minute_text, "05");
441    }
442
443    // ------------------------------------------------------------------
444    // Widget-level behaviour
445    // ------------------------------------------------------------------
446
447    #[derive(Default)]
448    struct App {
449        entry: TimeEntry,
450    }
451
452    fn view(entry: TimeEntry, use_24_hour: bool) -> TimeInputView<App> {
453        time_input(entry, |s: &mut App, e: TimeEntry| s.entry = e).use_24_hour(use_24_hour)
454    }
455
456    /// Build the form widget directly and lay it out against a text context —
457    /// the harness `crate::switch`'s own tests use.
458    fn mount(view: &TimeInputView<App>) -> (TimeInputWidget, TextContext) {
459        let mut counter = 0u64;
460        let mut widget = View::<App>::build(view, &mut BuildCtx::new(&mut counter));
461        let mut tcx = TextContext::new();
462        {
463            let mut layout_ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
464            widget.layout(
465                &mut layout_ctx,
466                &BoxConstraints::tight(Size::new(320.0, 400.0)),
467            );
468        }
469        (widget, tcx)
470    }
471
472    #[test]
473    fn twelve_hour_mode_shows_the_period_row_and_twenty_four_hour_mode_does_not() {
474        let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
475        let twelve = view(entry.clone(), false);
476        let (mut widget, _tcx) = mount(&twelve);
477        assert_eq!(widget.period.len(), 2);
478
479        let mut counter = 0u64;
480        let twenty_four = view(entry.clone(), true);
481        View::<App>::rebuild(
482            &twenty_four,
483            &twelve,
484            &mut widget,
485            &mut BuildCtx::new(&mut counter),
486        );
487        assert_eq!(widget.period.len(), 0, "the row is torn down");
488
489        let back = view(entry, false);
490        View::<App>::rebuild(
491            &back,
492            &twenty_four,
493            &mut widget,
494            &mut BuildCtx::new(&mut counter),
495        );
496        assert_eq!(widget.period.len(), 2, "and rebuilt on the way back");
497    }
498
499    #[test]
500    fn autovalidate_gates_the_error_line() {
501        let entry = TimeEntry {
502            hour_text: "99".into(),
503            minute_text: "".into(),
504            pm: false,
505        };
506        let quiet: TimeInputView<App> = time_input(entry.clone(), |_: &mut App, _| {});
507        assert_eq!(quiet.error_for(entry.hour_is_valid(false)), None);
508        let loud: TimeInputView<App> =
509            time_input(entry.clone(), |_: &mut App, _| {}).autovalidate(true);
510        assert_eq!(
511            loud.error_for(entry.hour_is_valid(false)),
512            Some(TimePickerStrings::default().invalid_time.as_str())
513        );
514        assert_eq!(
515            loud.error_for(true),
516            None,
517            "a well-formed field stays quiet even under autovalidation"
518        );
519    }
520
521    #[test]
522    fn the_form_lays_both_fields_out_side_by_side() {
523        let entry = TimeEntry::from_time(TimeOfDay::new(9, 15), false);
524        let (widget, _tcx) = mount(&view(entry, false));
525        assert_eq!(widget.hour.origin(), Point::ZERO);
526        assert_eq!(widget.hour.origin().y, widget.minute.origin().y, "same row");
527        assert!(
528            (widget.minute.origin().x - (widget.hour.size().width + FIELD_GAP)).abs() < 1e-9,
529            "one 16dp gap between the fields"
530        );
531        assert!(
532            widget.period[0].origin().y >= widget.hour.size().height + FIELD_GAP,
533            "the period row sits one gap below the fields"
534        );
535    }
536}