Skip to main content

frust_material/time_picker/
mod.rs

1// Ported from `material_3_expressive` v1.0.8 (MIT, © 2026 Paa Developments;
2// `tmp/material_3_expressive/lib/components/time_pickers/` —
3// `m3e_time_pickers.dart` (`M3ETimePicker.show`), `m3e_time_picker_dialog.dart`,
4// `models/m3e_time.dart`, `enums/m3e_time_picker_enums.dart`,
5// `utils/m3e_time_picker_utils.dart`, `res/m3e_time_picker_constants.dart`,
6// `styles/m3e_time_picker_theme.dart`, and `components/` —
7// `m3e_time_picker_header.dart`, `m3e_time_picker_actions.dart`,
8// `m3e_time_picker_dialog_content.dart`; retrieved 2026-08-20).
9// Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//
11// Porting decisions (each documented in the module docs below): every piece of
12// picker state is a controlled prop rather than `setState`/`RestorableValue`;
13// the reference's `MaterialLocalizations` reads become one English-defaulted
14// `TimePickerStrings` bundle; the orientation is an explicit prop rather than
15// a `MediaQuery` read; and the panel is content-sized rather than animating
16// between the reference's four fixed dialog boxes.
17
18//! The M3E **time picker**: a clock dial ([`mod@dial`]), a text entry form
19//! ([`mod@input`]), and the modal dialog that presents either of them
20//! ([`time_picker`]/[`show_time_picker`]).
21//!
22//! ```ignore
23//! // `time`/`entry`/`entry_mode`/`autovalidate` all live in the app's own
24//! // state; the picker only ever *reports* the change it wants.
25//! let time = state.time;
26//! // Minted outside the page builder: OK/Cancel dismiss through the host's
27//! // exit ramp rather than popping the navigator from inside the panel.
28//! let dismiss = ModalDismiss::new();
29//! show_time_picker(
30//!     &state.nav,
31//!     move || {
32//!         let confirm = dismiss.clone();
33//!         let cancel = dismiss.clone();
34//!         time_picker(time)
35//!             .on_change(|s: &mut State, t| s.time = t)
36//!             .on_confirm(move |s: &mut State, confirmed| match confirmed {
37//!                 Some(picked) => confirm.dismiss_with(PopResult::of(picked)),
38//!                 // The typed entry didn't parse — light the error lines up.
39//!                 None => s.time_autovalidate = true,
40//!             })
41//!             .on_cancel(move |_s: &mut State| cancel.dismiss())
42//!             .dismiss_handle(dismiss.clone())
43//!     },
44//!     |state: &mut State, result: PopResult| {
45//!         if let Some(picked) = result.take::<TimeOfDay>() {
46//!             state.time = picked;
47//!         }
48//!     },
49//! );
50//! ```
51//!
52//! # Controlled all the way down
53//!
54//! The reference's dialog is a `StatefulWidget` holding the selected time, the
55//! entry mode and the autovalidate flag in `RestorableValue`s, and its input
56//! form holds two `TextEditingController`s besides. This port holds **none**
57//! of that: [`TimePickerView`] takes [`value`](TimePickerView::value),
58//! [`entry`](TimePickerView::entry), [`entry_mode`](TimePickerView::entry_mode)
59//! and [`autovalidate`](TimePickerView::autovalidate) as props and reports
60//! each requested change back out — the same contract
61//! [`crate::dialog::selection_dialog`] documents, and for the same reason: a
62//! navigator-pushed page has no local-component-state hook to lean on, and
63//! this crate's controls are controlled by charter
64//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics). The one exception is
65//! which unit the ring is editing, which stays inside [`TimeDialWidget`] the
66//! way `pressed` does.
67//!
68//! [`TimePickerView::entry`] is optional: leave it unset and the input form
69//! seeds itself from `value` on every pass ([`TimeEntry::from_time`]), which is
70//! right for a dial-only picker and wrong the moment the user types, so an
71//! app offering input mode threads its own [`TimeEntry`] in.
72//!
73//! # Strings: English defaults, overridable as a bundle
74//!
75//! Every user-visible string the reference reads from `MaterialLocalizations`
76//! lives in [`TimePickerStrings`], defaulted to Flutter's own English values.
77//! An app localizes by handing the picker a filled-in bundle
78//! ([`TimePickerView::strings`]); this crate reaches no i18n dependency of its
79//! own (the design-system charter forbids depending on a sibling plugin — see
80//! `docs/PLUGINS_ARCHITECTURE.md`'s Layer Dependencies).
81//!
82//! # Orientation is a prop, not a `MediaQuery` read
83//!
84//! The reference resolves `widget.orientation ?? MediaQuery.orientationOf(context)`.
85//! A widget here has no window handle, so [`TimePickerView::orientation`] is
86//! explicit and [`TimePickerOrientation::for_size`] is the rule an app applies
87//! to its own window size — the same shape [`crate::search::SearchViewMode::for_width`]
88//! already uses for its presentation split. Both layouts are ported:
89//! portrait stacks header / divider / body / actions, landscape puts a
90//! [`HEADER_LANDSCAPE_WIDTH`]-wide header column beside a vertical divider and
91//! the body/actions column.
92//!
93//! # Panel geometry (cited)
94//!
95//! [`DIALOG_PORTRAIT_WIDTH`] (328) and [`DIALOG_LANDSCAPE_WIDTH`] (544) are
96//! `M3ETimePickerConstants`' `dialPortraitDialogSize.width` /
97//! `dialLandscapeDialogSize.width` (its input sizes share both widths), fed to
98//! the host's `OverlayModalConfig::centered`. The reference's fixed dialog
99//! *heights* and its 200ms `AnimatedContainer`/`AnimatedSize` crossfade
100//! between them are deliberately **not** ported: this panel is content-sized,
101//! so a taller type scale grows the dialog rather than clipping it, and the
102//! host owns the entrance/exit motion for every modal in this crate. The dial
103//! body's natural height (80 header + 24 gap + 256 ring = 360) equals the
104//! reference's own `dialDialogBodyHeight`, so the two agree anyway at the
105//! default type scale.
106
107pub mod dial;
108pub mod input;
109
110pub use dial::{DIAL_SIZE, DIAL_SLOTS, TimeDialView, TimeDialWidget, time_dial};
111pub use input::{TimeInputView, TimeInputWidget, time_input};
112
113use std::rc::Rc;
114
115use frust::authoring::text::{FontWeight, LineHeight};
116use frust::authoring::{
117    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
118    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextColor, ThemeTextType, View, Widget,
119    any, build_child, rebuild_child, route_event, route_event_single, teardown_child,
120    visit_children,
121};
122use frust::{NavigatorController, PopResult, text};
123use kurbo::{Point, Size};
124
125use crate::button::{button, text_button};
126use crate::divider::divider;
127use crate::icon_button::icon_button;
128use crate::overlay::{
129    ModalDismiss, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
130    overlay_modal, show_overlay_modal,
131};
132
133/// Panel padding on all four edges (`M3EDialogTheme.padding`, the padding the
134/// reference's header/actions/content all inset by).
135const PADDING: f64 = 24.0;
136/// Horizontal spacing between the cancel and confirm actions
137/// (`M3EDialogTheme.actionGap`).
138const ACTION_GAP: f64 = 8.0;
139/// Width of the landscape header column (`M3ETimePickerTheme.headerLandscapeWidth`).
140pub const HEADER_LANDSCAPE_WIDTH: f64 = 176.0;
141/// Panel width in portrait (`M3ETimePickerConstants.dialPortraitDialogSize.width`,
142/// shared with `inputPortraitDialogSize`).
143pub const DIALOG_PORTRAIT_WIDTH: f64 = 328.0;
144/// Panel width in landscape (`dialLandscapeDialogSize.width`, shared with
145/// `inputLandscapeDialogSize`).
146pub const DIALOG_LANDSCAPE_WIDTH: f64 = 544.0;
147
148/// Help-line type-scale token (M3 `labelLarge`, 14/20 w500).
149const HELP_SIZE: f32 = 14.0;
150const HELP_LINE_HEIGHT: f32 = 20.0;
151const HELP_WEIGHT: FontWeight = FontWeight::MEDIUM;
152/// Headline token for the formatted time (M3 `headlineLarge`, 32/40 w400 —
153/// `M3ETimePickerTheme.headerHeadlineStyle`).
154const TITLE_SIZE: f32 = 32.0;
155const TITLE_LINE_HEIGHT: f32 = 40.0;
156/// The short headline the landscape input layout uses instead
157/// (`headerHeadlineShortStyle`, M3 `headlineSmall`).
158const TITLE_SHORT_SIZE: f32 = 24.0;
159const TITLE_SHORT_LINE_HEIGHT: f32 = 32.0;
160/// Divider thickness (`crate::divider`'s own 1dp line).
161const DIVIDER_THICKNESS: f64 = 1.0;
162
163// ============================================================================
164// Model
165// ============================================================================
166
167/// An immutable wall-clock time in 24-hour form (`M3ETime`).
168///
169/// Every constructor clamps (`M3ETimePickerUtils.clampRaw`), so an instance is
170/// always `hour in 0..=23`, `minute in 0..=59` — the reference's `assert`s made
171/// total, since a release-mode Dart assert is a silent no-op and this crate
172/// must not hand a malformed hour to the dial's arithmetic.
173#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
174pub struct TimeOfDay {
175    hour: u8,
176    minute: u8,
177}
178
179impl TimeOfDay {
180    /// 00:00.
181    pub const MIDNIGHT: TimeOfDay = TimeOfDay { hour: 0, minute: 0 };
182
183    /// A time, with both halves clamped into range.
184    pub fn new(hour: u8, minute: u8) -> Self {
185        TimeOfDay {
186            hour: hour.min(23),
187            minute: minute.min(59),
188        }
189    }
190
191    /// A time from a 12-hour hour (`1..=12`) plus a period flag —
192    /// `M3ETimePickerUtils.to24Hour`: `(hour12 % 12) + if pm { 12 }`.
193    pub fn from_hour_12(hour_12: u8, pm: bool, minute: u8) -> Self {
194        let base = hour_12 % 12;
195        TimeOfDay::new(if pm { base + 12 } else { base }, minute)
196    }
197
198    /// The hour, `0..=23`.
199    pub fn hour(self) -> u8 {
200        self.hour
201    }
202
203    /// The minute, `0..=59`.
204    pub fn minute(self) -> u8 {
205        self.minute
206    }
207
208    /// Whether this time is noon or later (`M3ETime.isPm`).
209    pub fn is_pm(self) -> bool {
210        self.hour >= 12
211    }
212
213    /// The hour in 12-hour form, `1..=12` (`M3ETime.hourOf12`).
214    pub fn hour_of_12(self) -> u8 {
215        let value = self.hour % 12;
216        if value == 0 { 12 } else { value }
217    }
218
219    /// This time with a different hour (clamped).
220    pub fn with_hour(self, hour: u8) -> Self {
221        TimeOfDay::new(hour, self.minute)
222    }
223
224    /// This time with a different minute (clamped).
225    pub fn with_minute(self, minute: u8) -> Self {
226        TimeOfDay::new(self.hour, minute)
227    }
228
229    /// This time moved to AM or PM, keeping the clock face
230    /// (`_M3EDialTimePickerState._setPeriod`).
231    pub fn with_period(self, pm: bool) -> Self {
232        TimeOfDay::from_hour_12(self.hour_of_12(), pm, self.minute)
233    }
234
235    /// The zero-padded two-digit hour the header field shows — the 24-hour
236    /// hour or the 12-hour one, per format (`_buildHeader`'s `hourText`).
237    pub fn hour_label(self, use_24_hour: bool) -> String {
238        let value = if use_24_hour {
239            self.hour
240        } else {
241            self.hour_of_12()
242        };
243        format!("{value:02}")
244    }
245
246    /// The zero-padded two-digit minute (`M3ETime.minuteLabel`).
247    pub fn minute_label(self) -> String {
248        format!("{:02}", self.minute)
249    }
250
251    /// The dialog headline: `"HH:MM"` in 24-hour form, `"H:MM AM"` otherwise —
252    /// this port's stand-in for `MaterialLocalizations.formatTimeOfDay`, whose
253    /// per-locale patterns have no home in this crate (see the [module
254    /// docs](self)' *Strings*).
255    pub fn format(self, use_24_hour: bool, strings: &TimePickerStrings) -> String {
256        if use_24_hour {
257            format!("{:02}:{:02}", self.hour, self.minute)
258        } else {
259            let period = if self.is_pm() {
260                &strings.pm
261            } else {
262                &strings.am
263            };
264            format!("{}:{:02} {}", self.hour_of_12(), self.minute, period)
265        }
266    }
267}
268
269/// The raw contents of the entry form's two fields plus its AM/PM flag — see
270/// [`mod@input`].
271#[derive(Clone, Debug, Default, PartialEq, Eq)]
272pub struct TimeEntry {
273    /// The hour field's text, exactly as typed.
274    pub hour_text: String,
275    /// The minute field's text, exactly as typed.
276    pub minute_text: String,
277    /// Whether the PM half is selected (ignored in 24-hour mode).
278    pub pm: bool,
279}
280
281impl TimeEntry {
282    /// Seed both fields from `time`, zero-padded (`_applyInitialTime`).
283    pub fn from_time(time: TimeOfDay, use_24_hour: bool) -> Self {
284        TimeEntry {
285            hour_text: time.hour_label(use_24_hour),
286            minute_text: time.minute_label(),
287            pm: time.is_pm(),
288        }
289    }
290
291    /// Both fields blank, AM selected (`emptyInitialInput`).
292    pub fn empty() -> Self {
293        TimeEntry::default()
294    }
295
296    /// This entry with a different hour text.
297    pub fn with_hour_text(mut self, text: impl Into<String>) -> Self {
298        self.hour_text = text.into();
299        self
300    }
301
302    /// This entry with a different minute text.
303    pub fn with_minute_text(mut self, text: impl Into<String>) -> Self {
304        self.minute_text = text.into();
305        self
306    }
307
308    /// This entry with a different period flag.
309    pub fn with_pm(mut self, pm: bool) -> Self {
310        self.pm = pm;
311        self
312    }
313
314    /// Whether the hour text parses into range — `isValidHourText`: `0..=23`
315    /// in 24-hour mode, `1..=12` otherwise.
316    pub fn hour_is_valid(&self, use_24_hour: bool) -> bool {
317        match self.hour_text.trim().parse::<i64>() {
318            Ok(value) if use_24_hour => (0..=23).contains(&value),
319            Ok(value) => (1..=12).contains(&value),
320            Err(_) => false,
321        }
322    }
323
324    /// Whether the minute text parses into `0..=59` (`isValidMinuteText`).
325    pub fn minute_is_valid(&self) -> bool {
326        matches!(self.minute_text.trim().parse::<i64>(), Ok(v) if (0..=59).contains(&v))
327    }
328
329    /// The time this entry spells out, or `None` if either half is invalid
330    /// (`parseInputTime`).
331    pub fn parse(&self, use_24_hour: bool) -> Option<TimeOfDay> {
332        if !self.hour_is_valid(use_24_hour) || !self.minute_is_valid() {
333            return None;
334        }
335        let hour: i64 = self.hour_text.trim().parse().ok()?;
336        let minute: i64 = self.minute_text.trim().parse().ok()?;
337        let (hour, minute) = (hour as u8, minute as u8);
338        Some(if use_24_hour {
339            TimeOfDay::new(hour, minute)
340        } else {
341            TimeOfDay::from_hour_12(hour, self.pm, minute)
342        })
343    }
344}
345
346/// Which face the picker opens on, and whether the mode toggle is offered
347/// (`M3ETimePickerEntryMode`).
348#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
349pub enum TimePickerEntryMode {
350    /// The dial, with a toggle to text entry.
351    #[default]
352    Dial,
353    /// Text entry, with a toggle back to the dial.
354    Input,
355    /// The dial, with no toggle.
356    DialOnly,
357    /// Text entry, with no toggle.
358    InputOnly,
359}
360
361impl TimePickerEntryMode {
362    /// Whether this mode shows the entry form rather than the dial.
363    pub fn is_input(self) -> bool {
364        matches!(
365            self,
366            TimePickerEntryMode::Input | TimePickerEntryMode::InputOnly
367        )
368    }
369
370    /// Whether the header offers the mode-toggle affordance.
371    pub fn shows_toggle(self) -> bool {
372        matches!(self, TimePickerEntryMode::Dial | TimePickerEntryMode::Input)
373    }
374
375    /// The mode a toggle press asks for — `_handleEntryModeToggle`: the two
376    /// `*Only` modes are fixed and report themselves unchanged.
377    pub fn toggled(self) -> Self {
378        match self {
379            TimePickerEntryMode::Dial => TimePickerEntryMode::Input,
380            TimePickerEntryMode::Input => TimePickerEntryMode::Dial,
381            fixed => fixed,
382        }
383    }
384}
385
386/// Which unit the dial's ring is editing (`M3ETimePickerMode`).
387#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
388pub enum TimePickerMode {
389    /// The hour hand/field.
390    #[default]
391    Hour,
392    /// The minute hand/field.
393    Minute,
394}
395
396/// The panel arrangement — see the [module docs](self)' *Orientation*.
397#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
398pub enum TimePickerOrientation {
399    /// Header above the body, actions below (`Orientation.portrait`).
400    #[default]
401    Portrait,
402    /// Header beside the body (`Orientation.landscape`).
403    Landscape,
404}
405
406impl TimePickerOrientation {
407    /// The orientation a window of `size` calls for — landscape iff it is
408    /// wider than it is tall, matching `MediaQuery.orientationOf`.
409    pub fn for_size(size: Size) -> Self {
410        if size.width > size.height {
411            TimePickerOrientation::Landscape
412        } else {
413            TimePickerOrientation::Portrait
414        }
415    }
416
417    /// Whether this is the landscape arrangement.
418    pub fn is_landscape(self) -> bool {
419        matches!(self, TimePickerOrientation::Landscape)
420    }
421
422    /// The panel width this arrangement asks the host for.
423    pub fn panel_width(self) -> f64 {
424        if self.is_landscape() {
425            DIALOG_LANDSCAPE_WIDTH
426        } else {
427            DIALOG_PORTRAIT_WIDTH
428        }
429    }
430}
431
432/// Every user-visible string the picker needs, defaulted to Flutter's own
433/// English `MaterialLocalizations` values — see the [module docs](self)'
434/// *Strings*.
435#[derive(Clone, Debug, PartialEq, Eq)]
436pub struct TimePickerStrings {
437    /// Help line above the dial (`timePickerDialHelpText`).
438    pub dial_help: String,
439    /// Help line above the entry form (`timePickerInputHelpText`).
440    pub input_help: String,
441    /// Hour field label (`timePickerHourLabel`).
442    pub hour_label: String,
443    /// Minute field label (`timePickerMinuteLabel`).
444    pub minute_label: String,
445    /// Error shown under a malformed field (`invalidTimeLabel`).
446    pub invalid_time: String,
447    /// AM abbreviation (`anteMeridiemAbbreviation`).
448    pub am: String,
449    /// PM abbreviation (`postMeridiemAbbreviation`).
450    pub pm: String,
451    /// Cancel action (`cancelButtonLabel`).
452    pub cancel: String,
453    /// Confirm action (`okButtonLabel`).
454    pub confirm: String,
455    /// Tooltip on the toggle that switches to the dial (`dialModeButtonLabel`).
456    pub dial_mode_tooltip: String,
457    /// Tooltip on the toggle that switches to text entry
458    /// (`inputTimeModeButtonLabel`).
459    pub input_mode_tooltip: String,
460    /// Announced while the ring edits hours (`timePickerHourModeAnnouncement`).
461    pub hour_mode_announcement: String,
462    /// Announced while the ring edits minutes
463    /// (`timePickerMinuteModeAnnouncement`).
464    pub minute_mode_announcement: String,
465}
466
467impl Default for TimePickerStrings {
468    fn default() -> Self {
469        TimePickerStrings {
470            dial_help: "Select time".into(),
471            input_help: "Enter time".into(),
472            hour_label: "Hour".into(),
473            minute_label: "Minute".into(),
474            invalid_time: "Enter a valid time".into(),
475            am: "AM".into(),
476            pm: "PM".into(),
477            cancel: "Cancel".into(),
478            confirm: "OK".into(),
479            dial_mode_tooltip: "Switch to dial picker mode".into(),
480            input_mode_tooltip: "Switch to text input mode".into(),
481            hour_mode_announcement: "Select hours".into(),
482            minute_mode_announcement: "Select minutes".into(),
483        }
484    }
485}
486
487// ============================================================================
488// Panel
489// ============================================================================
490
491/// A view-held, typed no-argument callback (erased on build).
492type OnAction<State> = Rc<dyn Fn(&mut State)>;
493
494/// The dialog panel's content: help line + headline + optional mode toggle,
495/// a divider, the picker body, and the cancel/confirm row.
496struct TimePickerPanelView<State: 'static> {
497    help: String,
498    title: String,
499    short_title: bool,
500    orientation: TimePickerOrientation,
501    input_mode: bool,
502    mode_button: Option<PanelModeButton<State>>,
503    body: Rc<AnyView<State>>,
504    cancel_label: String,
505    confirm_label: String,
506    on_cancel: Option<OnAction<State>>,
507    on_confirm: Option<OnAction<State>>,
508}
509
510/// The mode-toggle affordance's icon/tooltip/callback triple.
511struct PanelModeButton<State: 'static> {
512    to_input: bool,
513    tooltip: String,
514    on_press: OnAction<State>,
515}
516
517/// The retained widget for a [`TimePickerPanelView`].
518struct TimePickerPanelWidget {
519    help: ChildPod,
520    title: ChildPod,
521    mode_button: Option<ChildPod>,
522    divider: ChildPod,
523    body: ChildPod,
524    actions: Vec<ChildPod>,
525    orientation: TimePickerOrientation,
526    input_mode: bool,
527}
528
529/// The help line: `labelLarge` metrics and family, `on_surface_variant`.
530fn help_view<State: 'static>(s: &str) -> AnyView<State> {
531    any::<State, _>(
532        text(s.to_string())
533            .size(HELP_SIZE)
534            .weight(HELP_WEIGHT)
535            .line_height(LineHeight::Absolute(HELP_LINE_HEIGHT))
536            .themed_role(ThemeTextColor::OnSurfaceVariant)
537            .themed_family(ThemeTextType::LabelLarge),
538    )
539}
540
541/// The formatted-time headline: `headlineLarge` metrics and family, or
542/// `headlineSmall`'s for the `short` landscape-input variant.
543fn title_view<State: 'static>(s: &str, short: bool) -> AnyView<State> {
544    let (size, line_height, family) = if short {
545        (
546            TITLE_SHORT_SIZE,
547            TITLE_SHORT_LINE_HEIGHT,
548            ThemeTextType::HeadlineSmall,
549        )
550    } else {
551        (TITLE_SIZE, TITLE_LINE_HEIGHT, ThemeTextType::HeadlineLarge)
552    };
553    any::<State, _>(
554        text(s.to_string())
555            .size(size)
556            .line_height(LineHeight::Absolute(line_height))
557            .themed_family(family),
558    )
559}
560
561fn divider_view<State: 'static>(vertical: bool) -> AnyView<State> {
562    let d = divider();
563    any::<State, _>(if vertical { d.vertical() } else { d })
564}
565
566impl<State: 'static> TimePickerPanelView<State> {
567    fn mode_button_view(&self) -> Option<AnyView<State>> {
568        let spec = self.mode_button.as_ref()?;
569        let on_press = spec.on_press.clone();
570        let glyph = if spec.to_input {
571            crate::icons::EDIT_OUTLINED
572        } else {
573            crate::icons::SCHEDULE
574        };
575        Some(any(icon_button(
576            any(frust::icon(glyph)),
577            move |state: &mut State| {
578                on_press(state);
579            },
580        )
581        .semantic_label(spec.tooltip.clone())))
582    }
583
584    fn action_views(&self) -> Vec<AnyView<State>> {
585        let cancel = self.on_cancel.clone();
586        let confirm = self.on_confirm.clone();
587        vec![
588            any(text_button(
589                self.cancel_label.clone(),
590                move |state: &mut State| {
591                    if let Some(cancel) = &cancel {
592                        cancel(state);
593                    }
594                },
595            )),
596            any(button(
597                self.confirm_label.clone(),
598                move |state: &mut State| {
599                    if let Some(confirm) = &confirm {
600                        confirm(state);
601                    }
602                },
603            )),
604        ]
605    }
606}
607
608impl<State: 'static> View<State> for TimePickerPanelView<State> {
609    type Element = TimePickerPanelWidget;
610
611    fn build(&self, ctx: &mut BuildCtx<'_>) -> TimePickerPanelWidget {
612        TimePickerPanelWidget {
613            help: build_child(&help_view::<State>(&self.help), ctx),
614            title: build_child(&title_view::<State>(&self.title, self.short_title), ctx),
615            mode_button: self.mode_button_view().map(|v| build_child(&v, ctx)),
616            divider: build_child(&divider_view::<State>(self.orientation.is_landscape()), ctx),
617            body: build_child(self.body.as_ref(), ctx),
618            actions: self
619                .action_views()
620                .iter()
621                .map(|v| build_child(v, ctx))
622                .collect(),
623            orientation: self.orientation,
624            input_mode: self.input_mode,
625        }
626    }
627
628    fn rebuild(
629        &self,
630        prev: &Self,
631        element: &mut TimePickerPanelWidget,
632        ctx: &mut BuildCtx<'_>,
633    ) -> ChangeFlags {
634        let mut flags = rebuild_child(
635            &help_view::<State>(&prev.help),
636            &help_view::<State>(&self.help),
637            &mut element.help,
638            ctx,
639        );
640        flags |= rebuild_child(
641            &title_view::<State>(&prev.title, prev.short_title),
642            &title_view::<State>(&self.title, self.short_title),
643            &mut element.title,
644            ctx,
645        );
646
647        match (prev.mode_button_view(), self.mode_button_view()) {
648            (Some(p), Some(n)) => {
649                if let Some(pod) = element.mode_button.as_mut() {
650                    flags |= rebuild_child(&p, &n, pod, ctx);
651                }
652            }
653            (Some(p), None) => {
654                if let Some(pod) = element.mode_button.as_mut() {
655                    teardown_child(&p, pod, ctx);
656                }
657                element.mode_button = None;
658                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
659            }
660            (None, Some(n)) => {
661                element.mode_button = Some(build_child(&n, ctx));
662                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
663            }
664            (None, None) => {}
665        }
666
667        flags |= rebuild_child(
668            &divider_view::<State>(prev.orientation.is_landscape()),
669            &divider_view::<State>(self.orientation.is_landscape()),
670            &mut element.divider,
671            ctx,
672        );
673        flags |= rebuild_child(
674            prev.body.as_ref(),
675            self.body.as_ref(),
676            &mut element.body,
677            ctx,
678        );
679        for ((p, n), pod) in prev
680            .action_views()
681            .iter()
682            .zip(self.action_views().iter())
683            .zip(element.actions.iter_mut())
684        {
685            flags |= rebuild_child(p, n, pod, ctx);
686        }
687
688        if element.orientation != self.orientation || element.input_mode != self.input_mode {
689            element.orientation = self.orientation;
690            element.input_mode = self.input_mode;
691            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
692        }
693        flags
694    }
695
696    fn teardown(&self, element: &mut TimePickerPanelWidget, ctx: &mut BuildCtx<'_>) {
697        teardown_child(&help_view::<State>(&self.help), &mut element.help, ctx);
698        teardown_child(
699            &title_view::<State>(&self.title, self.short_title),
700            &mut element.title,
701            ctx,
702        );
703        if let (Some(view), Some(pod)) = (self.mode_button_view(), element.mode_button.as_mut()) {
704            teardown_child(&view, pod, ctx);
705        }
706        teardown_child(
707            &divider_view::<State>(self.orientation.is_landscape()),
708            &mut element.divider,
709            ctx,
710        );
711        teardown_child(self.body.as_ref(), &mut element.body, ctx);
712        for (view, pod) in self.action_views().iter().zip(element.actions.iter_mut()) {
713            teardown_child(view, pod, ctx);
714        }
715    }
716}
717
718impl TimePickerPanelWidget {
719    /// Lay the help line and (optional) mode toggle out as one row inside
720    /// `[x, x + width]`, returning the row's height (`M3ETimePickerHeader`'s
721    /// `helpRow`).
722    fn layout_help_row(&mut self, ctx: &mut LayoutCtx, x: f64, y: f64, width: f64) -> f64 {
723        let button_w = match self.mode_button.as_mut() {
724            Some(pod) => {
725                pod.layout_child(ctx, &BoxConstraints::loose(Size::new(width, f64::INFINITY)))
726                    .width
727            }
728            None => 0.0,
729        };
730        let help_w = (width - button_w).max(0.0);
731        let help_h = self
732            .help
733            .layout_child(
734                ctx,
735                &BoxConstraints::new(Size::new(help_w, 0.0), Size::new(help_w, f64::INFINITY)),
736            )
737            .height;
738        let button_h = self.mode_button.as_ref().map_or(0.0, |p| p.size().height);
739        let row_h = help_h.max(button_h);
740        self.help
741            .set_origin(Point::new(x, y + (row_h - help_h) / 2.0));
742        if let Some(pod) = self.mode_button.as_mut() {
743            pod.set_origin(Point::new(
744                x + width - button_w,
745                y + (row_h - button_h) / 2.0,
746            ));
747        }
748        row_h
749    }
750
751    /// Lay the cancel/confirm row out right-aligned inside `[x, x + width]`,
752    /// returning its height (`M3ETimePickerActions`).
753    fn layout_actions(&mut self, ctx: &mut LayoutCtx, x: f64, y: f64, width: f64) -> f64 {
754        let loose = BoxConstraints::loose(Size::new(width, f64::INFINITY));
755        let mut row_h: f64 = 0.0;
756        for pod in &mut self.actions {
757            let s = pod.layout_child(ctx, &loose);
758            row_h = row_h.max(s.height);
759        }
760        let mut right = x + width;
761        for pod in self.actions.iter_mut().rev() {
762            let w = pod.size().width;
763            right -= w;
764            pod.set_origin(Point::new(right, y));
765            right -= ACTION_GAP;
766        }
767        row_h
768    }
769
770    /// The body's own inset — the reference's `M3ETimePickerDialogContent`:
771    /// full padding in input mode, vertical-only for the dial (whose ring
772    /// centres itself across the full panel width).
773    fn body_inset(&self) -> f64 {
774        if self.input_mode { PADDING } else { 0.0 }
775    }
776
777    fn layout_portrait(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, width: f64) -> Size {
778        let inner = (width - 2.0 * PADDING).max(0.0);
779        let mut y = PADDING;
780        y += self.layout_help_row(ctx, PADDING, y, inner);
781        let title_h = self
782            .title
783            .layout_child(
784                ctx,
785                &BoxConstraints::new(Size::new(inner, 0.0), Size::new(inner, f64::INFINITY)),
786            )
787            .height;
788        self.title.set_origin(Point::new(PADDING, y));
789        y += title_h + PADDING;
790
791        self.divider.layout_child(
792            ctx,
793            &BoxConstraints::tight(Size::new(width, DIVIDER_THICKNESS)),
794        );
795        self.divider.set_origin(Point::new(0.0, y));
796        y += DIVIDER_THICKNESS;
797
798        let inset = self.body_inset();
799        let body_w = (width - 2.0 * inset).max(0.0);
800        y += PADDING;
801        let body_h = self
802            .body
803            .layout_child(
804                ctx,
805                &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
806            )
807            .height;
808        self.body.set_origin(Point::new(inset, y));
809        y += body_h + PADDING;
810
811        y += self.layout_actions(ctx, PADDING, y, inner);
812        y += PADDING;
813
814        bc.constrain(Size::new(width, y))
815    }
816
817    fn layout_landscape(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, width: f64) -> Size {
818        let header_w = HEADER_LANDSCAPE_WIDTH.min(width);
819        let header_inner = (header_w - 2.0 * PADDING).max(0.0);
820        let mut header_y = PADDING;
821        header_y += self.layout_help_row(ctx, PADDING, header_y, header_inner);
822        let title_h = self
823            .title
824            .layout_child(
825                ctx,
826                &BoxConstraints::new(
827                    Size::new(header_inner, 0.0),
828                    Size::new(header_inner, f64::INFINITY),
829                ),
830            )
831            .height;
832        self.title.set_origin(Point::new(PADDING, header_y));
833        header_y += title_h + PADDING;
834
835        let body_x = header_w + DIVIDER_THICKNESS;
836        let column_w = (width - body_x).max(0.0);
837        let inset = self.body_inset();
838        let body_w = (column_w - 2.0 * inset).max(0.0);
839        let mut column_y = PADDING;
840        let body_h = self
841            .body
842            .layout_child(
843                ctx,
844                &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
845            )
846            .height;
847        self.body.set_origin(Point::new(body_x + inset, column_y));
848        column_y += body_h + PADDING;
849        let actions_w = (column_w - 2.0 * PADDING).max(0.0);
850        column_y += self.layout_actions(ctx, body_x + PADDING, column_y, actions_w);
851        column_y += PADDING;
852
853        let height = header_y.max(column_y);
854        self.divider.layout_child(
855            ctx,
856            &BoxConstraints::tight(Size::new(DIVIDER_THICKNESS, height)),
857        );
858        self.divider.set_origin(Point::new(header_w, 0.0));
859
860        bc.constrain(Size::new(width, height))
861    }
862}
863
864impl Widget for TimePickerPanelWidget {
865    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
866        // The host gives content a tight width (`OverlayGeometry::Centered`).
867        let width = bc.max().width.max(bc.min().width);
868        if self.orientation.is_landscape() {
869            self.layout_landscape(ctx, bc, width)
870        } else {
871            self.layout_portrait(ctx, bc, width)
872        }
873    }
874
875    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
876        self.help.paint_child(ctx, scene);
877        self.title.paint_child(ctx, scene);
878        if let Some(pod) = self.mode_button.as_mut() {
879            pod.paint_child(ctx, scene);
880        }
881        self.divider.paint_child(ctx, scene);
882        self.body.paint_child(ctx, scene);
883        for pod in &mut self.actions {
884            pod.paint_child(ctx, scene);
885        }
886    }
887
888    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
889        if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
890            return EventResult::Handled;
891        }
892        if let Some(pod) = self.mode_button.as_mut()
893            && route_event_single(pod, ctx, event) == EventResult::Handled
894        {
895            return EventResult::Handled;
896        }
897        route_event(&mut self.actions, ctx, event)
898    }
899
900    fn semantics(&self, ctx: &mut SemanticsCtx) {
901        self.help.semantics_child(ctx);
902        self.title.semantics_child(ctx);
903        if let Some(pod) = &self.mode_button {
904            pod.semantics_child(ctx);
905        }
906        self.body.semantics_child(ctx);
907        for pod in &self.actions {
908            pod.semantics_child(ctx);
909        }
910    }
911
912    visit_children!(help, title, mode_button, divider, body, actions);
913}
914
915// ============================================================================
916// time_picker() / TimePickerView / show_time_picker()
917// ============================================================================
918
919/// A view-held, typed one-argument callback (erased on build).
920type OnValue<State, T> = Rc<dyn Fn(&mut State, T)>;
921
922/// A declarative M3E time picker dialog. See the [module docs](self).
923pub struct TimePickerView<State: 'static> {
924    value: TimeOfDay,
925    entry: Option<TimeEntry>,
926    entry_mode: TimePickerEntryMode,
927    orientation: TimePickerOrientation,
928    use_24_hour: bool,
929    autovalidate: bool,
930    strings: Rc<TimePickerStrings>,
931    on_change: Option<OnValue<State, TimeOfDay>>,
932    on_entry_change: Option<OnValue<State, TimeEntry>>,
933    on_entry_mode_change: Option<OnValue<State, TimePickerEntryMode>>,
934    on_confirm: Option<OnValue<State, Option<TimeOfDay>>>,
935    on_cancel: Option<OnAction<State>>,
936    dismissable: bool,
937    on_dismiss: Option<OnAction<State>>,
938    dismiss_handle: Option<ModalDismiss>,
939}
940
941/// Create a time picker showing `value`. Chain
942/// [`on_change`](TimePickerView::on_change) to observe the dial,
943/// [`on_confirm`](TimePickerView::on_confirm) to handle OK, and the rest as
944/// the picker's modes require — see the [module docs](self).
945pub fn time_picker<State: 'static>(value: TimeOfDay) -> TimePickerView<State> {
946    TimePickerView {
947        value,
948        entry: None,
949        entry_mode: TimePickerEntryMode::default(),
950        orientation: TimePickerOrientation::default(),
951        use_24_hour: false,
952        autovalidate: false,
953        strings: Rc::new(TimePickerStrings::default()),
954        on_change: None,
955        on_entry_change: None,
956        on_entry_mode_change: None,
957        on_confirm: None,
958        on_cancel: None,
959        dismissable: true,
960        on_dismiss: None,
961        dismiss_handle: None,
962    }
963}
964
965/// PascalCase alias for [`time_picker`].
966#[allow(non_snake_case)]
967pub fn TimePicker<State: 'static>(value: TimeOfDay) -> TimePickerView<State> {
968    time_picker(value)
969}
970
971impl<State: 'static> TimePickerView<State> {
972    /// The time the dial shows and the confirm action reports (controlled).
973    pub fn value(mut self, value: TimeOfDay) -> Self {
974        self.value = value;
975        self
976    }
977
978    /// The entry form's raw text (controlled). Unset, the form re-seeds from
979    /// [`value`](Self::value) every pass — see the [module docs](self).
980    pub fn entry(mut self, entry: TimeEntry) -> Self {
981        self.entry = Some(entry);
982        self
983    }
984
985    /// Which face to show, and whether to offer the mode toggle
986    /// (`initialEntryMode`, controlled here rather than seeded).
987    pub fn entry_mode(mut self, entry_mode: TimePickerEntryMode) -> Self {
988        self.entry_mode = entry_mode;
989        self
990    }
991
992    /// The panel arrangement (`orientation`) — see the [module docs](self).
993    pub fn orientation(mut self, orientation: TimePickerOrientation) -> Self {
994        self.orientation = orientation;
995        self
996    }
997
998    /// Use the 24-hour ring/validation and drop the AM/PM selector
999    /// (`alwaysUse24HourFormat`).
1000    pub fn use_24_hour(mut self, use_24_hour: bool) -> Self {
1001        self.use_24_hour = use_24_hour;
1002        self
1003    }
1004
1005    /// Show the entry form's error lines — set this from
1006    /// [`on_confirm`](Self::on_confirm)'s `None` arm, the reference's own
1007    /// `AutovalidateMode.always` flip.
1008    pub fn autovalidate(mut self, autovalidate: bool) -> Self {
1009        self.autovalidate = autovalidate;
1010        self
1011    }
1012
1013    /// Replace every user-visible string (see the [module docs](self)).
1014    pub fn strings(mut self, strings: TimePickerStrings) -> Self {
1015        self.strings = Rc::new(strings);
1016        self
1017    }
1018
1019    /// Observe a dial edit — the *requested* time, clamped and complete.
1020    pub fn on_change<F: Fn(&mut State, TimeOfDay) + 'static>(mut self, on_change: F) -> Self {
1021        self.on_change = Some(Rc::new(on_change));
1022        self
1023    }
1024
1025    /// Observe a keystroke or AM/PM press in the entry form — the *requested*
1026    /// [`TimeEntry`], which the caller threads back through [`entry`](Self::entry).
1027    pub fn on_entry_change<F: Fn(&mut State, TimeEntry) + 'static>(mut self, on_change: F) -> Self {
1028        self.on_entry_change = Some(Rc::new(on_change));
1029        self
1030    }
1031
1032    /// Observe a mode-toggle press — the requested [`TimePickerEntryMode`].
1033    pub fn on_entry_mode_change<F: Fn(&mut State, TimePickerEntryMode) + 'static>(
1034        mut self,
1035        on_change: F,
1036    ) -> Self {
1037        self.on_entry_mode_change = Some(Rc::new(on_change));
1038        self
1039    }
1040
1041    /// Observe the confirm action. `Some(time)` is the confirmed value —
1042    /// `value` on the dial, the parsed entry in input mode. `None` is the
1043    /// reference's failed-validation branch (`_handleOk`'s early return): the
1044    /// typed entry does not spell a time, so the caller flips
1045    /// [`autovalidate`](Self::autovalidate) on instead of popping.
1046    pub fn on_confirm<F: Fn(&mut State, Option<TimeOfDay>) + 'static>(
1047        mut self,
1048        on_confirm: F,
1049    ) -> Self {
1050        self.on_confirm = Some(Rc::new(on_confirm));
1051        self
1052    }
1053
1054    /// Observe the cancel action (`_handleCancel`).
1055    pub fn on_cancel<F: Fn(&mut State) + 'static>(mut self, on_cancel: F) -> Self {
1056        self.on_cancel = Some(Rc::new(on_cancel));
1057        self
1058    }
1059
1060    /// Whether a scrim tap, `Escape` or Android back may dismiss the dialog —
1061    /// see [`crate::overlay::modal::OverlayModalView::dismissable`].
1062    pub fn dismissable(mut self, dismissable: bool) -> Self {
1063        self.dismissable = dismissable;
1064        self
1065    }
1066
1067    /// Observe a scrim-tap/`Escape`/back dismissal ([`show_time_picker`]
1068    /// already wires the pop).
1069    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
1070        self.on_dismiss = Some(Rc::new(on_dismiss));
1071        self
1072    }
1073
1074    /// Install the app-triggered staged-dismiss handle, so the OK/Cancel
1075    /// callbacks can close the dialog through the host's exit ramp
1076    /// ([`ModalDismiss::dismiss_with`] / [`ModalDismiss::dismiss`]) instead of
1077    /// popping the navigator themselves, which skips the ramp entirely.
1078    ///
1079    /// Unlike [`crate::show_date_picker`], this family leaves both actions to
1080    /// the caller (the module docs' worked example), so the handle is the
1081    /// caller's to mint and wire — outside [`show_time_picker`]'s page builder.
1082    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
1083        self.dismiss_handle = Some(dismiss);
1084        self
1085    }
1086
1087    /// The entry form's current contents — the caller's own, or one seeded
1088    /// from `value`.
1089    fn resolved_entry(&self) -> TimeEntry {
1090        self.entry
1091            .clone()
1092            .unwrap_or_else(|| TimeEntry::from_time(self.value, self.use_24_hour))
1093    }
1094
1095    /// What the confirm action reports — see [`Self::on_confirm`].
1096    fn confirmed(&self) -> Option<TimeOfDay> {
1097        if self.entry_mode.is_input() {
1098            self.resolved_entry().parse(self.use_24_hour)
1099        } else {
1100            Some(self.value)
1101        }
1102    }
1103
1104    fn body_view(&self) -> AnyView<State> {
1105        if self.entry_mode.is_input() {
1106            let on_change = self.on_entry_change.clone();
1107            any(
1108                time_input(self.resolved_entry(), move |state: &mut State, e| {
1109                    if let Some(on_change) = &on_change {
1110                        on_change(state, e);
1111                    }
1112                })
1113                .use_24_hour(self.use_24_hour)
1114                .autovalidate(self.autovalidate)
1115                .shared_strings(self.strings.clone()),
1116            )
1117        } else {
1118            let on_change = self.on_change.clone();
1119            any(time_dial(self.value, move |state: &mut State, t| {
1120                if let Some(on_change) = &on_change {
1121                    on_change(state, t);
1122                }
1123            })
1124            .use_24_hour(self.use_24_hour)
1125            .shared_strings(self.strings.clone()))
1126        }
1127    }
1128
1129    /// Compose the host-facing modal fresh from the current fields (the same
1130    /// per-pass recompose [`crate::dialog`] documents).
1131    fn compose(&self) -> OverlayModalView<State> {
1132        let input_mode = self.entry_mode.is_input();
1133        let help = if input_mode {
1134            self.strings.input_help.clone()
1135        } else {
1136            self.strings.dial_help.clone()
1137        };
1138        let confirmed = self.confirmed();
1139        let on_confirm = self.on_confirm.clone();
1140        let mode_button = self.entry_mode.shows_toggle().then(|| {
1141            let requested = self.entry_mode.toggled();
1142            let on_mode_change = self.on_entry_mode_change.clone();
1143            PanelModeButton {
1144                to_input: !input_mode,
1145                tooltip: if input_mode {
1146                    self.strings.dial_mode_tooltip.clone()
1147                } else {
1148                    self.strings.input_mode_tooltip.clone()
1149                },
1150                on_press: Rc::new(move |state: &mut State| {
1151                    if let Some(on_mode_change) = &on_mode_change {
1152                        on_mode_change(state, requested);
1153                    }
1154                }),
1155            }
1156        });
1157        let panel = TimePickerPanelView {
1158            help,
1159            title: self.value.format(self.use_24_hour, &self.strings),
1160            // `isShort`: the landscape input layout only.
1161            short_title: self.orientation.is_landscape() && input_mode,
1162            orientation: self.orientation,
1163            input_mode,
1164            mode_button,
1165            body: Rc::new(self.body_view()),
1166            cancel_label: self.strings.cancel.clone(),
1167            confirm_label: self.strings.confirm.clone(),
1168            on_cancel: self.on_cancel.clone(),
1169            on_confirm: Some(Rc::new(move |state: &mut State| {
1170                if let Some(on_confirm) = &on_confirm {
1171                    on_confirm(state, confirmed);
1172                }
1173            })),
1174        };
1175        let mut view = overlay_modal(
1176            panel,
1177            OverlayModalConfig::centered(self.orientation.panel_width()),
1178        )
1179        .dismissable(self.dismissable)
1180        .label(if input_mode {
1181            self.strings.input_help.clone()
1182        } else {
1183            self.strings.dial_help.clone()
1184        });
1185        if let Some(on_dismiss) = &self.on_dismiss {
1186            let on_dismiss = on_dismiss.clone();
1187            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
1188        }
1189        if let Some(dismiss) = &self.dismiss_handle {
1190            view = view.dismiss_handle(dismiss.clone());
1191        }
1192        view
1193    }
1194}
1195
1196impl<State: 'static> View<State> for TimePickerView<State> {
1197    type Element = OverlayModalWidget;
1198
1199    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
1200        View::build(&self.compose(), ctx)
1201    }
1202
1203    fn rebuild(
1204        &self,
1205        prev: &Self,
1206        element: &mut OverlayModalWidget,
1207        ctx: &mut BuildCtx<'_>,
1208    ) -> ChangeFlags {
1209        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
1210    }
1211
1212    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
1213        View::teardown(&self.compose(), element, ctx);
1214    }
1215}
1216
1217impl<State: 'static> OverlayModalContent<State> for TimePickerView<State> {
1218    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
1219        self.on_dismiss = Some(on_dismiss);
1220        self
1221    }
1222
1223    fn modal_dismissable(&self) -> bool {
1224        self.dismissable
1225    }
1226}
1227
1228/// Push `build`'s time picker as a transparent navigator page over the host's
1229/// scrim, and register `on_result` for the value it pops with — the same push
1230/// helper every modal component in this crate uses
1231/// ([`crate::overlay::modal::show_overlay_modal`]).
1232///
1233/// Dismissal (scrim tap, `Escape`, Android back) is wired to
1234/// `controller.pop()` for you, staged behind the host's exit ramp. Confirm and
1235/// cancel are the caller's own actions: dismiss with a value from
1236/// [`TimePickerView::on_confirm`]'s `Some` arm and bare from
1237/// [`TimePickerView::on_cancel`], both through a [`ModalDismiss`] the caller
1238/// mints and installs with [`TimePickerView::dismiss_handle`] (see the [module
1239/// docs](self)' worked example) — a direct `controller.pop()` from either
1240/// action would skip that ramp.
1241pub fn show_time_picker<State, B, R>(
1242    controller: &NavigatorController<State>,
1243    build: B,
1244    on_result: R,
1245) where
1246    State: 'static,
1247    B: Fn() -> TimePickerView<State> + 'static,
1248    R: Fn(&mut State, PopResult) + 'static,
1249{
1250    show_overlay_modal(controller, build, on_result);
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use frust::authoring::scene::GlyphRun;
1257    use frust::authoring::text::TextContext;
1258    use frust::authoring::{
1259        CornerRadii, Key, KeyEvent, Modifiers, NamedKey, PointerButton, PointerEvent, PointerPhase,
1260    };
1261    use frust::{Color, FrameTime, NavigatorView};
1262    use frust_core::RenderRoot;
1263    use frust_widgets::navigator;
1264    use kurbo::Rect;
1265    use std::any::Any;
1266
1267    #[test]
1268    fn a_time_clamps_both_halves_on_construction() {
1269        assert_eq!(TimeOfDay::new(99, 99), TimeOfDay::new(23, 59));
1270        assert_eq!(TimeOfDay::new(23, 59).hour(), 23);
1271        assert_eq!(TimeOfDay::default(), TimeOfDay::MIDNIGHT);
1272        assert_eq!(TimeOfDay::MIDNIGHT.hour_of_12(), 12, "00:00 reads as 12 AM");
1273    }
1274
1275    #[test]
1276    fn the_twelve_hour_view_of_every_hour_round_trips() {
1277        for hour in 0..24u8 {
1278            let t = TimeOfDay::new(hour, 0);
1279            let back = TimeOfDay::from_hour_12(t.hour_of_12(), t.is_pm(), 0);
1280            assert_eq!(back, t, "hour {hour} must survive the 12-hour round trip");
1281        }
1282    }
1283
1284    #[test]
1285    fn with_period_keeps_the_clock_face() {
1286        let ten_am = TimeOfDay::new(10, 30);
1287        assert_eq!(ten_am.with_period(true), TimeOfDay::new(22, 30));
1288        assert_eq!(ten_am.with_period(false), ten_am, "already AM");
1289        // Noon and midnight are the interesting pair: both read as "12".
1290        assert_eq!(
1291            TimeOfDay::new(12, 0).with_period(false),
1292            TimeOfDay::new(0, 0)
1293        );
1294        assert_eq!(
1295            TimeOfDay::new(0, 0).with_period(true),
1296            TimeOfDay::new(12, 0)
1297        );
1298    }
1299
1300    #[test]
1301    fn labels_are_zero_padded_per_format() {
1302        let t = TimeOfDay::new(9, 5);
1303        assert_eq!(t.hour_label(true), "09");
1304        assert_eq!(t.hour_label(false), "09");
1305        assert_eq!(TimeOfDay::new(21, 5).hour_label(true), "21");
1306        assert_eq!(TimeOfDay::new(21, 5).hour_label(false), "09");
1307        assert_eq!(t.minute_label(), "05");
1308    }
1309
1310    #[test]
1311    fn the_headline_follows_the_format() {
1312        let strings = TimePickerStrings::default();
1313        assert_eq!(TimeOfDay::new(21, 5).format(true, &strings), "21:05");
1314        assert_eq!(TimeOfDay::new(21, 5).format(false, &strings), "9:05 PM");
1315        assert_eq!(TimeOfDay::new(0, 0).format(false, &strings), "12:00 AM");
1316        assert_eq!(TimeOfDay::new(12, 0).format(false, &strings), "12:00 PM");
1317    }
1318
1319    #[test]
1320    fn the_toggle_is_a_no_op_for_the_fixed_modes() {
1321        assert_eq!(
1322            TimePickerEntryMode::Dial.toggled(),
1323            TimePickerEntryMode::Input
1324        );
1325        assert_eq!(
1326            TimePickerEntryMode::Input.toggled(),
1327            TimePickerEntryMode::Dial
1328        );
1329        for fixed in [
1330            TimePickerEntryMode::DialOnly,
1331            TimePickerEntryMode::InputOnly,
1332        ] {
1333            assert_eq!(fixed.toggled(), fixed);
1334            assert!(!fixed.shows_toggle());
1335        }
1336        assert!(TimePickerEntryMode::Dial.shows_toggle());
1337        assert!(TimePickerEntryMode::Input.shows_toggle());
1338        assert!(TimePickerEntryMode::InputOnly.is_input());
1339        assert!(!TimePickerEntryMode::DialOnly.is_input());
1340    }
1341
1342    #[test]
1343    fn the_orientation_rule_matches_media_query() {
1344        assert_eq!(
1345            TimePickerOrientation::for_size(Size::new(392.0, 800.0)),
1346            TimePickerOrientation::Portrait
1347        );
1348        assert_eq!(
1349            TimePickerOrientation::for_size(Size::new(800.0, 392.0)),
1350            TimePickerOrientation::Landscape
1351        );
1352        // A square window is portrait (`width > height` is strict).
1353        assert_eq!(
1354            TimePickerOrientation::for_size(Size::new(600.0, 600.0)),
1355            TimePickerOrientation::Portrait
1356        );
1357        assert_eq!(
1358            TimePickerOrientation::Portrait.panel_width(),
1359            DIALOG_PORTRAIT_WIDTH
1360        );
1361        assert_eq!(
1362            TimePickerOrientation::Landscape.panel_width(),
1363            DIALOG_LANDSCAPE_WIDTH
1364        );
1365    }
1366
1367    #[test]
1368    fn confirm_reports_the_dial_value_in_dial_mode() {
1369        let view: TimePickerView<()> = time_picker(TimeOfDay::new(14, 45));
1370        assert_eq!(view.confirmed(), Some(TimeOfDay::new(14, 45)));
1371    }
1372
1373    #[test]
1374    fn confirm_parses_the_entry_in_input_mode_and_reports_none_when_it_cannot() {
1375        let good: TimePickerView<()> = time_picker(TimeOfDay::MIDNIGHT)
1376            .entry_mode(TimePickerEntryMode::Input)
1377            .entry(TimeEntry {
1378                hour_text: "7".into(),
1379                minute_text: "05".into(),
1380                pm: true,
1381            });
1382        assert_eq!(good.confirmed(), Some(TimeOfDay::new(19, 5)));
1383
1384        let bad: TimePickerView<()> = time_picker(TimeOfDay::MIDNIGHT)
1385            .entry_mode(TimePickerEntryMode::InputOnly)
1386            .entry(TimeEntry {
1387                hour_text: "99".into(),
1388                minute_text: "".into(),
1389                pm: false,
1390            });
1391        assert_eq!(
1392            bad.confirmed(),
1393            None,
1394            "the failed-validation branch reports None instead of a guessed time"
1395        );
1396    }
1397
1398    #[test]
1399    fn an_unset_entry_seeds_itself_from_the_value() {
1400        let view: TimePickerView<()> = time_picker(TimeOfDay::new(21, 5))
1401            .entry_mode(TimePickerEntryMode::Input)
1402            .use_24_hour(true);
1403        assert_eq!(view.resolved_entry().hour_text, "21");
1404        assert_eq!(view.confirmed(), Some(TimeOfDay::new(21, 5)));
1405    }
1406
1407    // ------------------------------------------------------------------
1408    // Panel layout
1409    // ------------------------------------------------------------------
1410
1411    /// A fixed-size leaf standing in for the picker body, so the panel's own
1412    /// geometry can be asserted without depending on text-shaping metrics.
1413    struct Sized {
1414        size: Size,
1415    }
1416    struct SizedW {
1417        size: Size,
1418    }
1419    impl View<NavState> for Sized {
1420        type Element = SizedW;
1421        fn build(&self, _c: &mut BuildCtx<'_>) -> SizedW {
1422            SizedW { size: self.size }
1423        }
1424        fn rebuild(&self, _p: &Self, _e: &mut SizedW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1425            ChangeFlags::NONE
1426        }
1427    }
1428    impl Widget for SizedW {
1429        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1430            bc.constrain(self.size)
1431        }
1432        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1433            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1434        }
1435    }
1436
1437    fn panel_view(
1438        orientation: TimePickerOrientation,
1439        input_mode: bool,
1440    ) -> TimePickerPanelView<NavState> {
1441        TimePickerPanelView {
1442            help: "Select time".into(),
1443            title: "10:30 AM".into(),
1444            short_title: false,
1445            orientation,
1446            input_mode,
1447            mode_button: None,
1448            body: Rc::new(any(Sized {
1449                size: Size::new(200.0, 100.0),
1450            })),
1451            cancel_label: "Cancel".into(),
1452            confirm_label: "OK".into(),
1453            on_cancel: None,
1454            on_confirm: None,
1455        }
1456    }
1457
1458    fn mount_panel(
1459        view: &TimePickerPanelView<NavState>,
1460        width: f64,
1461    ) -> (TimePickerPanelWidget, Size, TextContext) {
1462        let mut counter = 0u64;
1463        let mut widget = View::<NavState>::build(view, &mut BuildCtx::new(&mut counter));
1464        let mut tcx = TextContext::new();
1465        let size = {
1466            let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1467            widget.layout(
1468                &mut ctx,
1469                &BoxConstraints::new(Size::new(width, 0.0), Size::new(width, f64::INFINITY)),
1470            )
1471        };
1472        (widget, size, tcx)
1473    }
1474
1475    #[test]
1476    fn the_portrait_panel_stacks_header_divider_body_and_actions() {
1477        let view = panel_view(TimePickerOrientation::Portrait, false);
1478        let (widget, size, _tcx) = mount_panel(&view, DIALOG_PORTRAIT_WIDTH);
1479        assert_eq!(size.width, DIALOG_PORTRAIT_WIDTH);
1480        assert_eq!(widget.help.origin().x, PADDING);
1481        assert!(
1482            widget.title.origin().y > widget.help.origin().y,
1483            "title below help"
1484        );
1485        assert!(
1486            widget.divider.origin().y > widget.title.origin().y,
1487            "divider below the header block"
1488        );
1489        assert_eq!(
1490            widget.divider.size().width,
1491            DIALOG_PORTRAIT_WIDTH,
1492            "full bleed"
1493        );
1494        assert!(widget.body.origin().y > widget.divider.origin().y);
1495        assert_eq!(
1496            widget.body.origin().x,
1497            0.0,
1498            "the dial body is not inset — it centres itself"
1499        );
1500        // The action row is right-aligned, confirm outermost.
1501        let cancel = widget.actions[0].origin().x;
1502        let confirm = widget.actions[1].origin().x;
1503        assert!(
1504            confirm > cancel,
1505            "confirm sits closest to the trailing edge"
1506        );
1507        assert!(
1508            (confirm + widget.actions[1].size().width - (DIALOG_PORTRAIT_WIDTH - PADDING)).abs()
1509                < 1e-9
1510        );
1511        assert!(widget.actions[0].origin().y > widget.body.origin().y);
1512    }
1513
1514    #[test]
1515    fn the_input_body_is_inset_but_the_dial_body_is_not() {
1516        let dial = panel_view(TimePickerOrientation::Portrait, false);
1517        let (dial_widget, _, _t1) = mount_panel(&dial, DIALOG_PORTRAIT_WIDTH);
1518        assert_eq!(dial_widget.body.origin().x, 0.0);
1519        assert_eq!(dial_widget.body.size().width, DIALOG_PORTRAIT_WIDTH);
1520
1521        let input = panel_view(TimePickerOrientation::Portrait, true);
1522        let (input_widget, _, _t2) = mount_panel(&input, DIALOG_PORTRAIT_WIDTH);
1523        assert_eq!(input_widget.body.origin().x, PADDING);
1524        assert_eq!(
1525            input_widget.body.size().width,
1526            DIALOG_PORTRAIT_WIDTH - 2.0 * PADDING
1527        );
1528    }
1529
1530    #[test]
1531    fn the_landscape_panel_puts_the_header_column_beside_the_body() {
1532        let view = panel_view(TimePickerOrientation::Landscape, false);
1533        let (widget, size, _tcx) = mount_panel(&view, DIALOG_LANDSCAPE_WIDTH);
1534        assert_eq!(size.width, DIALOG_LANDSCAPE_WIDTH);
1535        assert_eq!(widget.help.origin().x, PADDING, "header column is inset");
1536        assert!(widget.help.origin().x < HEADER_LANDSCAPE_WIDTH);
1537        assert_eq!(
1538            widget.divider.origin(),
1539            Point::new(HEADER_LANDSCAPE_WIDTH, 0.0)
1540        );
1541        assert_eq!(
1542            widget.divider.size().width,
1543            DIVIDER_THICKNESS,
1544            "vertical rule"
1545        );
1546        assert_eq!(widget.divider.size().height, size.height, "full height");
1547        assert!(
1548            widget.body.origin().x >= HEADER_LANDSCAPE_WIDTH + DIVIDER_THICKNESS,
1549            "the body sits right of the divider"
1550        );
1551        assert!(
1552            widget.actions[0].origin().y > widget.body.origin().y,
1553            "actions sit under the body, inside the right-hand column"
1554        );
1555        assert!(widget.actions[0].origin().x > HEADER_LANDSCAPE_WIDTH);
1556    }
1557
1558    // ------------------------------------------------------------------
1559    // Dialog presentation and result plumbing
1560    // ------------------------------------------------------------------
1561
1562    /// The window every dialog test lays out in — tall enough for the dial
1563    /// panel's full content height, so the actions row is reachable.
1564    const WINDOW: Size = Size::new(420.0, 900.0);
1565
1566    #[derive(Default)]
1567    struct NavState {
1568        results: Vec<Option<TimeOfDay>>,
1569        confirmed: Vec<Option<TimeOfDay>>,
1570        cancels: u32,
1571        value: TimeOfDay,
1572    }
1573
1574    #[derive(Default)]
1575    struct Recorder {
1576        glyphs: usize,
1577    }
1578    impl PaintScene for Recorder {
1579        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1580        fn draw_text(&mut self, _o: Point, _t: &str) {}
1581        fn draw_glyph_run(&mut self, _run: GlyphRun) {
1582            self.glyphs += 1;
1583        }
1584        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _r: f64, _c: Color) {}
1585        fn fill_rounded_rect_radii(&mut self, _o: Point, _s: Size, _radii: CornerRadii, _c: Color) {
1586        }
1587    }
1588
1589    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1590        InputEvent::Pointer(PointerEvent {
1591            phase,
1592            position: Point::new(x, y),
1593            button: PointerButton::Primary,
1594        })
1595    }
1596
1597    fn escape_event() -> InputEvent {
1598        InputEvent::Key(KeyEvent {
1599            key: Key::Named(NamedKey::Escape),
1600            modifiers: Modifiers::default(),
1601            repeat: false,
1602        })
1603    }
1604
1605    type NavLogic = Box<dyn FnMut(&mut NavState) -> NavigatorView<NavState>>;
1606
1607    struct Harness {
1608        root: RenderRoot<NavState, NavigatorView<NavState>>,
1609        state: NavState,
1610        tcx: TextContext,
1611        controller: NavigatorController<NavState>,
1612        logic: NavLogic,
1613    }
1614
1615    impl Harness {
1616        fn new() -> Self {
1617            let controller: NavigatorController<NavState> = NavigatorController::new();
1618            let ctrl = controller.clone();
1619            let mut h = Harness {
1620                root: RenderRoot::new(),
1621                state: NavState::default(),
1622                tcx: TextContext::new(),
1623                controller,
1624                logic: Box::new(move |_: &mut NavState| {
1625                    navigator(&ctrl, || {
1626                        any::<NavState, _>(Sized {
1627                            size: Size::new(WINDOW.width, WINDOW.height),
1628                        })
1629                    })
1630                }),
1631            };
1632            h.pass();
1633            h
1634        }
1635
1636        fn pass(&mut self) {
1637            self.root.rebuild(&mut self.logic, &mut self.state);
1638            self.root
1639                .layout_with_text(WINDOW, &mut self.tcx as &mut dyn Any);
1640        }
1641
1642        /// Settle the modal's entrance ramp.
1643        fn settle(&mut self) {
1644            self.root.paint(&mut Recorder::default(), FrameTime::ZERO);
1645            self.root.paint(
1646                &mut Recorder::default(),
1647                FrameTime::from_nanos(1_000_000_000),
1648            );
1649            self.pass();
1650        }
1651
1652        /// Settle a running staged-exit ramp so its enqueued pop drains.
1653        fn settle_exit(&mut self) {
1654            self.root.paint(
1655                &mut Recorder::default(),
1656                FrameTime::from_nanos(2_000_000_000),
1657            );
1658            self.root.paint(
1659                &mut Recorder::default(),
1660                FrameTime::from_nanos(3_000_000_000),
1661            );
1662            self.pass();
1663        }
1664
1665        fn event(&mut self, event: &InputEvent) {
1666            self.root.event(&mut self.state, event);
1667        }
1668
1669        fn flush(&mut self) {
1670            self.pass();
1671            self.event(&ev(PointerPhase::Move, 2.0, 2.0));
1672        }
1673
1674        fn paint(&mut self) -> Recorder {
1675            let mut rec = Recorder::default();
1676            self.root
1677                .paint(&mut rec, FrameTime::from_nanos(4_000_000_000));
1678            rec
1679        }
1680
1681        /// The absolute bounds of the picker's action buttons, leading first.
1682        /// Both actions are `crate::button`'s widget (the confirm one filled,
1683        /// the cancel one text), so they are found by type and ordered by x.
1684        fn action_bounds(&self) -> Vec<Rect> {
1685            let mut found: Vec<Rect> = self
1686                .root
1687                .inspect()
1688                .into_iter()
1689                .filter(|n| n.type_name.contains("::ButtonWidget"))
1690                .map(|n| n.bounds)
1691                .collect();
1692            found.sort_by(|a, b| a.x0.partial_cmp(&b.x0).expect("finite bounds"));
1693            found
1694        }
1695
1696        fn tap(&mut self, rect: Rect) {
1697            let c = rect.center();
1698            self.event(&ev(PointerPhase::Down, c.x, c.y));
1699            self.event(&ev(PointerPhase::Up, c.x, c.y));
1700            self.flush();
1701        }
1702    }
1703
1704    fn show_dial_picker(h: &mut Harness) {
1705        let ctrl = h.controller.clone();
1706        show_time_picker(
1707            &h.controller,
1708            move || {
1709                let ctrl = ctrl.clone();
1710                let cancel_ctrl = ctrl.clone();
1711                time_picker(TimeOfDay::new(14, 45))
1712                    .on_confirm(move |state: &mut NavState, confirmed| {
1713                        state.confirmed.push(confirmed);
1714                        if let Some(time) = confirmed {
1715                            ctrl.pop_with_result(PopResult::of(time));
1716                        }
1717                    })
1718                    .on_cancel(move |state: &mut NavState| {
1719                        state.cancels += 1;
1720                        cancel_ctrl.pop();
1721                    })
1722            },
1723            |state: &mut NavState, result: PopResult| {
1724                state.results.push(result.take::<TimeOfDay>());
1725            },
1726        );
1727        h.pass();
1728        h.settle();
1729    }
1730
1731    #[test]
1732    fn the_picker_renders_its_header_body_and_actions() {
1733        let mut h = Harness::new();
1734        show_dial_picker(&mut h);
1735        let rec = h.paint();
1736        // Help line, headline, two header fields, the colon, AM/PM, twelve
1737        // ring labels, and both action labels all shape.
1738        assert!(
1739            rec.glyphs >= 15,
1740            "the whole picker shapes, got {} runs",
1741            rec.glyphs
1742        );
1743    }
1744
1745    #[test]
1746    fn confirming_pops_the_chosen_time_through_to_the_result_callback() {
1747        let mut h = Harness::new();
1748        show_dial_picker(&mut h);
1749        let actions = h.action_bounds();
1750        assert_eq!(actions.len(), 2, "cancel and confirm");
1751        // The confirm action is the trailing one.
1752        h.tap(actions[1]);
1753        h.settle_exit();
1754        assert_eq!(
1755            h.state.confirmed,
1756            vec![Some(TimeOfDay::new(14, 45))],
1757            "the dial's value is what confirm reports"
1758        );
1759        assert_eq!(
1760            h.state.results,
1761            vec![Some(TimeOfDay::new(14, 45))],
1762            "and it reaches the result callback through the pop"
1763        );
1764    }
1765
1766    #[test]
1767    fn cancelling_pops_with_no_result() {
1768        let mut h = Harness::new();
1769        show_dial_picker(&mut h);
1770        let actions = h.action_bounds();
1771        h.tap(actions[0]);
1772        h.settle_exit();
1773        assert_eq!(h.state.cancels, 1);
1774        assert_eq!(h.state.confirmed, Vec::new(), "confirm never fired");
1775        assert_eq!(h.state.results, vec![None], "an empty pop result");
1776    }
1777
1778    #[test]
1779    fn escape_dismisses_the_picker_with_an_empty_result() {
1780        let mut h = Harness::new();
1781        show_dial_picker(&mut h);
1782        // A press inside the panel's header band claims focus (host-owned)
1783        // without touching the dial or an action.
1784        h.event(&ev(PointerPhase::Down, 200.0, 200.0));
1785        h.event(&ev(PointerPhase::Up, 200.0, 200.0));
1786        h.flush();
1787        assert!(h.state.results.is_empty());
1788        h.event(&escape_event());
1789        h.flush();
1790        assert!(h.state.results.is_empty(), "still animating out");
1791        h.settle_exit();
1792        h.flush();
1793        assert_eq!(h.state.results, vec![None]);
1794        assert_eq!(h.state.confirmed, Vec::new());
1795    }
1796
1797    #[test]
1798    fn an_invalid_entry_refuses_to_confirm_and_leaves_the_dialog_up() {
1799        let mut h = Harness::new();
1800        let ctrl = h.controller.clone();
1801        show_time_picker(
1802            &h.controller,
1803            move || {
1804                let ctrl = ctrl.clone();
1805                time_picker(TimeOfDay::MIDNIGHT)
1806                    .entry_mode(TimePickerEntryMode::InputOnly)
1807                    .entry(TimeEntry {
1808                        hour_text: "99".into(),
1809                        minute_text: String::new(),
1810                        pm: false,
1811                    })
1812                    .on_confirm(move |state: &mut NavState, confirmed| {
1813                        state.confirmed.push(confirmed);
1814                        if let Some(time) = confirmed {
1815                            ctrl.pop_with_result(PopResult::of(time));
1816                        }
1817                    })
1818            },
1819            |state: &mut NavState, result: PopResult| {
1820                state.results.push(result.take::<TimeOfDay>());
1821            },
1822        );
1823        h.pass();
1824        h.settle();
1825        let actions = h.action_bounds();
1826        // The entry form's own AM/PM pair are buttons too, so the picker's
1827        // actions are the last two in reading order.
1828        let confirm = *actions.last().expect("a confirm action");
1829        h.tap(confirm);
1830        h.settle_exit();
1831        assert_eq!(
1832            h.state.confirmed,
1833            vec![None],
1834            "the failed-validation branch fires with None"
1835        );
1836        assert!(h.state.results.is_empty(), "and nothing pops");
1837    }
1838
1839    #[test]
1840    fn the_mode_toggle_reports_the_other_entry_mode() {
1841        let mut h = Harness::new();
1842        let requested: Rc<std::cell::RefCell<Vec<TimePickerEntryMode>>> = Rc::default();
1843        let sink = requested.clone();
1844        show_time_picker(
1845            &h.controller,
1846            move || {
1847                let sink = sink.clone();
1848                time_picker(TimeOfDay::new(9, 0)).on_entry_mode_change(
1849                    move |_state: &mut NavState, mode| sink.borrow_mut().push(mode),
1850                )
1851            },
1852            |_state: &mut NavState, _result: PopResult| {},
1853        );
1854        h.pass();
1855        h.settle();
1856        let toggle = h
1857            .root
1858            .inspect()
1859            .into_iter()
1860            .find(|n| n.type_name.contains("::IconButtonWidget"))
1861            .expect("the dial mode offers a toggle")
1862            .bounds;
1863        h.tap(toggle);
1864        assert_eq!(
1865            requested.borrow().as_slice(),
1866            &[TimePickerEntryMode::Input],
1867            "the dial's toggle asks for text entry"
1868        );
1869    }
1870
1871    #[test]
1872    fn a_dial_edit_reports_the_requested_time_out_of_the_dialog() {
1873        let mut h = Harness::new();
1874        let ctrl = h.controller.clone();
1875        show_time_picker(
1876            &h.controller,
1877            move || {
1878                let _ = &ctrl;
1879                time_picker(TimeOfDay::new(14, 45))
1880                    .on_change(|state: &mut NavState, time| state.value = time)
1881            },
1882            |_state: &mut NavState, _result: PopResult| {},
1883        );
1884        h.pass();
1885        h.settle();
1886        // The ring's centre sits inside the panel; a press at the ring's
1887        // 12 o'clock slot is found from the dial widget's own bounds.
1888        let ring = h
1889            .root
1890            .inspect()
1891            .into_iter()
1892            .find(|n| n.type_name.contains("::TimeDialWidget"))
1893            .expect("the dial is mounted")
1894            .bounds;
1895        // The ring occupies the dial's bottom `DIAL_SIZE` square, centred.
1896        let ring_center = Point::new(ring.center().x, ring.y1 - DIAL_SIZE / 2.0);
1897        let top = Point::new(ring_center.x, ring_center.y - DIAL_SIZE / 2.0 + 30.0);
1898        h.event(&ev(PointerPhase::Down, top.x, top.y));
1899        h.event(&ev(PointerPhase::Up, top.x, top.y));
1900        h.flush();
1901        assert_eq!(
1902            h.state.value,
1903            TimeOfDay::new(12, 45),
1904            "12 o'clock in the PM half is 12:45"
1905        );
1906    }
1907
1908    #[test]
1909    fn an_installed_dismiss_handle_reaches_the_host_view() {
1910        // OK/Cancel are the caller's own here, so the handle they dismiss
1911        // through has to survive the recompose; the staging itself is the
1912        // host's, and tested there.
1913        let dismiss = ModalDismiss::new();
1914        assert!(
1915            time_picker::<NavState>(TimeOfDay::new(9, 30))
1916                .dismiss_handle(dismiss)
1917                .compose()
1918                .dismiss_handle
1919                .is_some()
1920        );
1921        assert!(
1922            time_picker::<NavState>(TimeOfDay::new(9, 30))
1923                .compose()
1924                .dismiss_handle
1925                .is_none(),
1926            "and none is invented for a picker that was handed none"
1927        );
1928    }
1929}