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