Skip to main content

frust_material/date_picker/
dialog.rs

1// Ported from `material_3_expressive` v1.0.8's date picker dialog (MIT, © 2026
2// Paa Developments;
3// `tmp/material_3_expressive/lib/components/date_pickers/m3e_date_picker_dialog.dart`,
4// `m3e_date_pickers.dart` (`M3EDatePicker.show`), and
5// `components/{m3e_date_picker_header, m3e_date_picker_actions,
6// m3e_date_picker_dialog_content}.dart`, retrieved 2026-08-20).
7// Upstream: <https://github.com/paadevelopments/material_3_expressive>
8//
9// Porting decisions (documented in the module docs below): the portrait layout
10// only; the panel is content on the merged modal host rather than its own
11// `Material` + `showGeneralDialog`; the OK/Cancel result plumbing runs through
12// the navigator's own pop-with-result seam; and OK/Cancel dismiss through the
13// host's public staged-dismiss handle rather than popping the navigator
14// themselves.
15
16//! The modal **date picker dialog**: [`date_picker_dialog`] builds the panel,
17//! [`show_date_picker`] pushes it and delivers the picked date back through the
18//! navigator's pop result.
19//!
20//! # The panel is content, the host is chrome
21//!
22//! Like every other modal in this catalog, the panel is a
23//! [`crate::overlay::modal::overlay_modal`] content view: the host contributes
24//! the scrim, the fill/radius/elevation, the entrance and exit ramps, and the
25//! scrim-tap/`Escape`/Android-back dismissal; this module contributes the
26//! header (help line, headline date, entry-mode affordance), the divider, the
27//! body (calendar or input field) and the Cancel/OK row.
28//!
29//! The panel is capped at [`DIALOG_PORTRAIT_CALENDAR_WIDTH`] in calendar mode
30//! and [`DIALOG_PORTRAIT_INPUT_WIDTH`] in input mode — the reference's
31//! `calendarPortraitDialogSize`/`inputPortraitDialogSize` widths.
32//!
33//! # Result plumbing
34//!
35//! [`show_date_picker`] wires three exits, matching `_handleOk`/`_handleCancel`
36//! plus the barrier:
37//!
38//! | Exit | Pops with |
39//! |---|---|
40//! | **OK** | `PopResult::of(`[`MaterialDate`]`)` when a date is picked, an empty result otherwise |
41//! | **Cancel** | an empty result |
42//! | scrim tap / `Escape` / Android back | an empty result (the host's own staged path) |
43//!
44//! so an app reads `result.take::<MaterialDate>()` and gets `Some` only for a
45//! confirmed pick — the reference's `Future<DateTime?>` in the shape this
46//! framework already has.
47//!
48//! **OK in input mode refuses an invalid entry**: it pops nothing at all when
49//! [`DatePickerState::input_text`] does not parse into a selectable date,
50//! exactly as `_handleOk`'s `if (!form.validate()) { return; }` does. The field
51//! is already showing why (see [`mod@super::input`]'s live validation).
52//!
53//! # OK and Cancel animate out, like every other exit
54//!
55//! Both actions sit *inside* the panel, so neither can reach the host's exit
56//! ramp on its own: popping the navigator from a button takes the page out from
57//! under the host and the panel vanishes mid-fade. [`show_date_picker`]
58//! therefore mints a [`ModalDismiss`] and hands it to the dialog
59//! ([`DatePickerDialog::dismiss_handle`]) — OK dismisses with its
60//! [`PopResult`], Cancel dismisses empty, and both take the same staged exit
61//! the scrim tap, `Escape`, and an Android back press take.
62//!
63//! With no handle installed (a `Stack`-mounted dialog, or one pushed by hand),
64//! Cancel falls back to firing [`DatePickerDialog::on_dismiss`] directly, which
65//! is unstaged — the only route open to it.
66//!
67//! With a handle installed but nothing to stage behind it — a `Stack`-mounted
68//! dialog the app handed a handle anyway — Cancel reaches that same
69//! `on_dismiss`, but through the *host*: the request is raised here and the
70//! host, which alone knows whether a staged close hook exists, delivers the
71//! unstaged fallback in the event pass (`crate::overlay::modal`'s *What a
72//! request consumes*). Both mounts dismiss, exactly once, whichever way the
73//! dialog was mounted.
74//!
75//! # Deliberate cuts
76//!
77//! The landscape layout (`calendarLandscapeDialogSize`, a side header) is not
78//! ported — see [`mod@super`]'s scope section. Neither is the reference's
79//! `RestorationMixin` state restoration, which has no framework counterpart.
80
81use std::rc::Rc;
82
83use frust::authoring::text::{FontFamily, FontWeight, TextContext, TextLayout, TextStyle};
84use frust::authoring::{
85    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
86    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any, build_child, rebuild_child,
87    rebuild_children, route_event, route_event_single, teardown_child, visit_children,
88};
89use frust::{Color, NavigatorController, PopResult, Theme, icon};
90use kurbo::{Point, Size};
91use peniko::Brush;
92
93use super::calendar::{SelectableDay, calendar_date_picker};
94use super::date::MaterialDate;
95use super::input::date_input_field;
96use super::{
97    ACTIONS_MIN_HEIGHT, DIALOG_PORTRAIT_CALENDAR_WIDTH, DIALOG_PORTRAIT_INPUT_WIDTH,
98    DatePickerState, DatePickerStrings, HEADER_PORTRAIT_HEIGHT, OnDatePickerChange,
99};
100use crate::button::{button, text_button};
101use crate::divider::divider;
102use crate::icon_button::icon_button;
103use crate::overlay::{
104    ModalDismiss, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
105    overlay_modal, show_overlay_modal,
106};
107
108/// The panel's inset on all four edges, in logical px (`M3EDialogTheme.padding`,
109/// which the reference's header/actions/input body all read).
110const PANEL_PADDING: f64 = 24.0;
111/// The gap between the Cancel and OK buttons (`M3EDialogTheme.actionGap`).
112const ACTION_GAP: f64 = 8.0;
113/// The entry-mode affordance's slot, in logical px — one M3 48dp touch target.
114const TOGGLE_SLOT: f64 = 48.0;
115/// The gap between the header's help line and its headline date.
116const HEADLINE_GAP: f64 = 4.0;
117
118/// The help line's type role (M3 `labelLarge`, `headerHelpStyle`).
119const HELP_TEXT_SIZE: f32 = 14.0;
120/// The headline date's type role (M3 `headlineLarge`, `headerHeadlineStyle`).
121const HEADLINE_TEXT_SIZE: f32 = 32.0;
122/// The ink every retained run is shaped with; each is re-brushed at paint time.
123const SHAPING_INK: Color = Color::BLACK;
124
125/// Unthemed-fallback `onSurface` (M3 baseline light).
126const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
127/// Unthemed-fallback `onSurfaceVariant`.
128const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
129
130/// A view-held, typed dismiss callback.
131type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
132/// A view-held, typed confirm callback carrying whatever OK resolved to.
133type OnConfirm<State> = Rc<dyn Fn(&mut State, Option<MaterialDate>)>;
134
135/// The **OK** action's whole body, shared by the composed button's callback and
136/// [`DatePickerDialog::confirm`] so the two cannot drift.
137///
138/// A `can_confirm` of `false` (an input mode whose text does not resolve) fires
139/// nothing at all — the reference's `_handleOk` early `return`.
140fn confirm_action<State: 'static>(
141    on_confirm: Option<&OnConfirm<State>>,
142    can_confirm: bool,
143    confirmable: Option<MaterialDate>,
144    app: &mut State,
145) {
146    if !can_confirm {
147        return;
148    }
149    if let Some(on_confirm) = on_confirm {
150        on_confirm(app, confirmable);
151    }
152}
153
154/// The **Cancel** action's whole body — see [`confirm_action`].
155///
156/// A handle takes precedence over the callback rather than firing alongside it:
157/// on the [`show_date_picker`] path that callback *is* the host's raw
158/// `controller.pop()`, so running both would pop the page out from under the
159/// exit ramp the handle just staged.
160///
161/// **The unstaged fallback is the host's, not this function's.** A handle
162/// raises a request; whether that request can be *staged* depends on how the
163/// dialog is mounted, which only the host knows — with no staged close hook
164/// behind it (a `Stack` mount), the host falls back to firing
165/// [`DatePickerDialog::on_dismiss`] itself, in the event pass, so Cancel still
166/// dismisses (`crate::overlay::modal`'s *What a request consumes*). Firing the
167/// callback from here as well would dismiss twice on the mount that *can*
168/// stage, and this function cannot tell the two apart — the host observes the
169/// request after this returns.
170fn cancel_action<State: 'static>(
171    dismiss: Option<&ModalDismiss>,
172    on_dismiss: Option<&OnDismiss<State>>,
173    app: &mut State,
174) {
175    if let Some(dismiss) = dismiss {
176        dismiss.dismiss();
177        return;
178    }
179    if let Some(on_dismiss) = on_dismiss {
180        on_dismiss(app);
181    }
182}
183
184/// A declarative M3E date picker dialog. See the [module docs](self).
185pub struct DatePickerDialog<State: 'static> {
186    state: DatePickerState,
187    first_date: MaterialDate,
188    last_date: MaterialDate,
189    today: Option<MaterialDate>,
190    strings: DatePickerStrings,
191    selectable: Option<SelectableDay>,
192    on_change: OnDatePickerChange<State>,
193    on_confirm: Option<OnConfirm<State>>,
194    dismissable: bool,
195    on_dismiss: Option<OnDismiss<State>>,
196    dismiss_handle: Option<ModalDismiss>,
197}
198
199/// Create a controlled date picker dialog over `state`, bounded by
200/// `first_date..=last_date`, reporting every requested state change through
201/// `on_change`.
202///
203/// Hand it to [`show_date_picker`], which wires OK/Cancel/back for you:
204///
205/// ```ignore
206/// show_date_picker(
207///     &app.nav,
208///     || date_picker_dialog(
209///         app.picker.clone(),
210///         MaterialDate::new(2020, 1, 1),
211///         MaterialDate::new(2030, 12, 31),
212///         |app: &mut App, next| app.picker = next,
213///     ),
214///     |app: &mut App, result: PopResult| {
215///         if let Some(date) = result.take::<MaterialDate>() {
216///             app.due = Some(date);
217///         }
218///     },
219/// );
220/// ```
221pub fn date_picker_dialog<State: 'static, F>(
222    state: DatePickerState,
223    first_date: MaterialDate,
224    last_date: MaterialDate,
225    on_change: F,
226) -> DatePickerDialog<State>
227where
228    F: Fn(&mut State, DatePickerState) + 'static,
229{
230    let (first_date, last_date) = if last_date < first_date {
231        (last_date, first_date)
232    } else {
233        (first_date, last_date)
234    };
235    DatePickerDialog {
236        state,
237        first_date,
238        last_date,
239        today: None,
240        strings: DatePickerStrings::ENGLISH,
241        selectable: None,
242        on_change: Rc::new(on_change),
243        on_confirm: None,
244        dismissable: true,
245        on_dismiss: None,
246        dismiss_handle: None,
247    }
248}
249
250impl<State: 'static> DatePickerDialog<State> {
251    /// Mark `today` with the today ring — see
252    /// [`CalendarDatePicker::today`](super::CalendarDatePicker::today) for why
253    /// this is required rather than defaulted.
254    pub fn today(mut self, today: MaterialDate) -> Self {
255        self.today = Some(today);
256        self
257    }
258
259    /// Replace the localized strings (default [`DatePickerStrings::ENGLISH`]).
260    pub fn strings(mut self, strings: DatePickerStrings) -> Self {
261        self.strings = strings;
262        self
263    }
264
265    /// Refuse individual dates inside the bounds — the reference's
266    /// `selectableDayPredicate`, applied by both the calendar and the field.
267    pub fn selectable<F: Fn(MaterialDate) -> bool + 'static>(mut self, predicate: F) -> Self {
268        self.selectable = Some(Rc::new(predicate));
269        self
270    }
271
272    /// Observe OK. [`show_date_picker`] wires this to a staged dismissal
273    /// carrying the picked date ([`ModalDismiss::dismiss_with`]); a caller
274    /// mounting the dialog directly (in a `Stack`, with no navigator) wires its
275    /// own.
276    pub fn on_confirm<F: Fn(&mut State, Option<MaterialDate>) + 'static>(
277        mut self,
278        on_confirm: F,
279    ) -> Self {
280        self.on_confirm = Some(Rc::new(on_confirm));
281        self
282    }
283
284    /// Whether the user can dismiss the dialog at all — the scrim tap,
285    /// `Escape`, and an Android back press (default `true`). `false` disables
286    /// all three; Cancel and OK still work.
287    pub fn dismissable(mut self, dismissable: bool) -> Self {
288        self.dismissable = dismissable;
289        self
290    }
291
292    /// Observe a dismissal — the callback the **Cancel** button fires when no
293    /// [`dismiss_handle`](Self::dismiss_handle) is installed (see the [module
294    /// docs](self)' OK-and-Cancel section). [`show_date_picker`] installs a
295    /// handle, so on that path Cancel stages the host's exit instead and this
296    /// callback is the *host's* own unstaged fallback, nothing more.
297    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
298        self.on_dismiss = Some(Rc::new(on_dismiss));
299        self
300    }
301
302    /// Install the app-triggered staged-dismiss handle, so **Cancel** closes
303    /// the dialog through the host's exit ramp instead of firing
304    /// [`on_dismiss`](Self::on_dismiss) into an immediate pop (see
305    /// [`ModalDismiss`]). [`show_date_picker`] installs one for you — and wires
306    /// OK to it too, since OK's own pop is its to make.
307    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
308        self.dismiss_handle = Some(dismiss);
309        self
310    }
311
312    /// Whether `date` is inside the bounds and accepted by the predicate.
313    fn is_selectable(&self, date: MaterialDate) -> bool {
314        date.is_within(self.first_date, self.last_date)
315            && self.selectable.as_ref().is_none_or(|p| p(date))
316    }
317
318    /// What pressing OK would confirm right now.
319    ///
320    /// In calendar mode that is [`DatePickerState::selected`] (or `None` — the
321    /// reference pops a null date from an untouched calendar too). In input
322    /// mode it is the parsed field text, and `None` there means **OK does
323    /// nothing at all** rather than confirming an empty pick — see the
324    /// [module docs](self).
325    pub fn confirmable(&self) -> Option<MaterialDate> {
326        if self.state.entry_mode.is_input() {
327            MaterialDate::parse_compact(&self.state.input_text)
328                .filter(|date| self.is_selectable(*date))
329        } else {
330            self.state.selected.filter(|date| self.is_selectable(*date))
331        }
332    }
333
334    /// Whether pressing OK does anything at all — false only for an input mode
335    /// whose text does not resolve.
336    pub fn can_confirm(&self) -> bool {
337        !self.state.entry_mode.is_input() || self.confirmable().is_some()
338    }
339
340    /// The panel's own max width for the current entry mode.
341    fn panel_width(&self) -> f64 {
342        if self.state.entry_mode.is_input() {
343            DIALOG_PORTRAIT_INPUT_WIDTH
344        } else {
345            DIALOG_PORTRAIT_CALENDAR_WIDTH
346        }
347    }
348
349    /// The headline the header shows: the selected date in medium form, or the
350    /// unspecified-date placeholder.
351    ///
352    /// The reference hides the headline outright while nothing is selected
353    /// (`showTitle: _selectedDate.value != null`); this port shows
354    /// [`DatePickerStrings::no_date_selected`] (Flutter's own `unspecifiedDate`)
355    /// so the header does not change height on the first pick.
356    fn headline(&self) -> String {
357        match self.state.selected {
358            Some(date) => self.strings.medium_date(date),
359            None => self.strings.no_date_selected.to_string(),
360        }
361    }
362
363    /// The entry-mode affordance, or `None` for a locked (`*Only`) mode.
364    fn toggle_view(&self) -> Option<AnyView<State>> {
365        if !self.state.entry_mode.is_toggleable() {
366            return None;
367        }
368        let glyph = if self.state.entry_mode.is_input() {
369            crate::icons::CALENDAR_TODAY
370        } else {
371            crate::icons::EDIT_OUTLINED
372        };
373        let state = self.state.clone();
374        let on_change = self.on_change.clone();
375        Some(any::<State, _>(icon_button(
376            any::<State, _>(icon(glyph)),
377            move |app: &mut State| on_change(app, state.toggled_entry_mode()),
378        )))
379    }
380
381    /// The body: the calendar, or the input field.
382    fn body_view(&self) -> AnyView<State> {
383        let on_change = self.on_change.clone();
384        if self.state.entry_mode.is_input() {
385            let mut field = date_input_field(
386                self.state.clone(),
387                self.first_date,
388                self.last_date,
389                move |app: &mut State, next| on_change(app, next),
390            )
391            .strings(self.strings);
392            if let Some(predicate) = &self.selectable {
393                let predicate = predicate.clone();
394                field = field.selectable(move |date| predicate(date));
395            }
396            any::<State, _>(field)
397        } else {
398            let mut calendar = calendar_date_picker(
399                self.state.clone(),
400                self.first_date,
401                self.last_date,
402                move |app: &mut State, next| on_change(app, next),
403            )
404            .strings(self.strings);
405            if let Some(today) = self.today {
406                calendar = calendar.today(today);
407            }
408            if let Some(predicate) = &self.selectable {
409                let predicate = predicate.clone();
410                calendar = calendar.selectable(move |date| predicate(date));
411            }
412            any::<State, _>(calendar)
413        }
414    }
415
416    /// Cancel — stages the host's exit through the dismiss handle, or fires
417    /// the dismiss callback when there is none (see the [module docs](self)).
418    fn cancel_view(&self) -> AnyView<State> {
419        let on_dismiss = self.on_dismiss.clone();
420        let dismiss = self.dismiss_handle.clone();
421        any::<State, _>(text_button(
422            self.strings.cancel_label,
423            move |app: &mut State| cancel_action(dismiss.as_ref(), on_dismiss.as_ref(), app),
424        ))
425    }
426
427    /// OK — fires the confirm callback with whatever [`Self::confirmable`]
428    /// resolved to, or nothing at all when the input text does not parse.
429    fn confirm_view(&self) -> AnyView<State> {
430        let on_confirm = self.on_confirm.clone();
431        let confirmable = self.confirmable();
432        let can_confirm = self.can_confirm();
433        any::<State, _>(button(
434            self.strings.confirm_label,
435            move |app: &mut State| {
436                confirm_action(on_confirm.as_ref(), can_confirm, confirmable, app)
437            },
438        ))
439    }
440
441    /// Run the **OK** action's body directly — the same code path
442    /// [`Self::confirm_view`]'s button callback runs, reachable without an
443    /// event pass (both routes share [`confirm_action`], so a test of one is a
444    /// test of the other).
445    pub fn confirm(&self, app: &mut State) {
446        confirm_action(
447            self.on_confirm.as_ref(),
448            self.can_confirm(),
449            self.confirmable(),
450            app,
451        );
452    }
453
454    /// Run the **Cancel** action's body directly — see [`Self::confirm`].
455    pub fn cancel(&self, app: &mut State) {
456        cancel_action(self.dismiss_handle.as_ref(), self.on_dismiss.as_ref(), app);
457    }
458
459    /// Compose the host-facing modal view fresh from the current fields — the
460    /// same recompose-per-pass shape [`crate::dialog`] takes.
461    fn compose(&self) -> OverlayModalView<State> {
462        let panel = DatePickerPanel {
463            help: self.strings.help_text.to_string(),
464            headline: self.headline(),
465            toggle: self.toggle_view(),
466            body: self.body_view(),
467            body_inset: if self.state.entry_mode.is_input() {
468                PANEL_PADDING
469            } else {
470                0.0
471            },
472            cancel: self.cancel_view(),
473            confirm: self.confirm_view(),
474        };
475        let mut view = overlay_modal(panel, OverlayModalConfig::centered(self.panel_width()))
476            .dismissable(self.dismissable)
477            .label(self.strings.help_text);
478        if let Some(on_dismiss) = &self.on_dismiss {
479            let on_dismiss = on_dismiss.clone();
480            view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
481        }
482        if let Some(dismiss) = &self.dismiss_handle {
483            view = view.dismiss_handle(dismiss.clone());
484        }
485        view
486    }
487}
488
489impl<State: 'static> View<State> for DatePickerDialog<State> {
490    type Element = OverlayModalWidget;
491
492    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
493        View::build(&self.compose(), ctx)
494    }
495
496    fn rebuild(
497        &self,
498        prev: &Self,
499        element: &mut OverlayModalWidget,
500        ctx: &mut BuildCtx<'_>,
501    ) -> ChangeFlags {
502        View::rebuild(&self.compose(), &prev.compose(), element, ctx)
503    }
504
505    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
506        View::teardown(&self.compose(), element, ctx);
507    }
508}
509
510impl<State: 'static> OverlayModalContent<State> for DatePickerDialog<State> {
511    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
512        self.on_dismiss = Some(on_dismiss);
513        self
514    }
515
516    fn modal_dismissable(&self) -> bool {
517        self.dismissable
518    }
519}
520
521/// Push `build`'s dialog as a transparent navigator page over the host's scrim,
522/// and register `on_result` for the date it pops with.
523///
524/// See the [module docs](self)' result-plumbing table: OK pops
525/// `PopResult::of(`[`MaterialDate`]`)` when it resolves to a date, and every
526/// other exit pops empty — so `result.take::<MaterialDate>()` is `Some` exactly
527/// for a confirmed pick.
528///
529/// Both in-panel actions dismiss through one [`ModalDismiss`] minted here (the
530/// module docs' OK-and-Cancel section), so they animate out exactly like the
531/// scrim tap does. It is minted **outside** the page builder on purpose: the
532/// builder re-runs on every navigator rebuild, and the handle must stay the one
533/// the mounted host latched.
534pub fn show_date_picker<State, B, R>(
535    controller: &NavigatorController<State>,
536    build: B,
537    on_result: R,
538) where
539    State: 'static,
540    B: Fn() -> DatePickerDialog<State> + 'static,
541    R: Fn(&mut State, PopResult) + 'static,
542{
543    let dismiss = ModalDismiss::new();
544    show_overlay_modal(
545        controller,
546        move || {
547            let confirm_dismiss = dismiss.clone();
548            build()
549                .on_confirm(
550                    move |_state: &mut State, picked: Option<MaterialDate>| match picked {
551                        Some(date) => confirm_dismiss.dismiss_with(PopResult::of(date)),
552                        None => confirm_dismiss.dismiss(),
553                    },
554                )
555                .dismiss_handle(dismiss.clone())
556        },
557        on_result,
558    );
559}
560
561// ============================================================================
562// DatePickerPanel — the modal host's content: header, divider, body, actions.
563// ============================================================================
564
565/// The dialog's panel content. See the [module docs](self).
566struct DatePickerPanel<State: 'static> {
567    help: String,
568    headline: String,
569    toggle: Option<AnyView<State>>,
570    body: AnyView<State>,
571    body_inset: f64,
572    cancel: AnyView<State>,
573    confirm: AnyView<State>,
574}
575
576/// A lazily shaped, paint-time re-brushed header run — the idiom
577/// [`crate::badge`]'s `LabelRun` establishes. Size and weight are the role's
578/// token literals; the family is the live theme's, resolved at layout.
579struct HeaderRun {
580    content: String,
581    size: f32,
582    weight: FontWeight,
583    /// The family the cached layout was shaped in.
584    family: FontFamily,
585    layout: Option<TextLayout>,
586}
587
588impl HeaderRun {
589    fn new(size: f32, weight: FontWeight) -> Self {
590        Self {
591            content: String::new(),
592            size,
593            weight,
594            family: FontFamily::default(),
595            layout: None,
596        }
597    }
598
599    /// The style the run shapes with.
600    fn style(&self) -> TextStyle {
601        TextStyle {
602            family: self.family.clone(),
603            weight: self.weight,
604            ..TextStyle::new(self.size, SHAPING_INK)
605        }
606    }
607
608    fn set_content(&mut self, content: &str) {
609        if self.content != content {
610            self.content = content.to_string();
611            self.layout = None;
612        }
613    }
614
615    /// Shape (or reuse) the run in `family`, returning its measured size.
616    ///
617    /// The cache is keyed on the **content and the family**, not on
618    /// `max_width`. The family is part of the key so a theme swap (or a font
619    /// picker) reshapes the run instead of serving the old face. The width is
620    /// not: both header lines are short single lines that never wrap at either
621    /// panel width ([`DIALOG_PORTRAIT_INPUT_WIDTH`]/
622    /// [`DIALOG_PORTRAIT_CALENDAR_WIDTH`]), so a width change alone cannot
623    /// change the shaping. A caller that narrowed the panel far enough to force
624    /// a wrap would need to invalidate through [`Self::set_content`].
625    fn shape(&mut self, ctx: &mut LayoutCtx, family: &FontFamily, max_width: f64) -> Size {
626        if self.family != *family {
627            self.family = family.clone();
628            self.layout = None;
629        }
630        if let Some(layout) = &self.layout {
631            return layout.size();
632        }
633        let style = self.style();
634        let laid = ctx.text_context::<TextContext>().layout(
635            &self.content,
636            &style,
637            Some(max_width.max(0.0) as f32),
638        );
639        let size = laid.size();
640        self.layout = Some(laid);
641        size
642    }
643
644    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
645        let Some(layout) = &self.layout else {
646            return;
647        };
648        for mut run in layout.to_scene_runs(origin) {
649            run.brush = Brush::Solid(color);
650            scene.draw_glyph_run(run);
651        }
652    }
653}
654
655/// The retained widget for a [`DatePickerPanel`].
656struct DatePickerPanelWidget {
657    help: HeaderRun,
658    headline: HeaderRun,
659    toggle: Option<ChildPod>,
660    divider_pod: ChildPod,
661    body: ChildPod,
662    body_inset: f64,
663    /// Cancel then OK, in reading order — the shape `route_event` walks.
664    actions: Vec<ChildPod>,
665    /// The header's own resolved height, recorded at layout for paint.
666    header_height: f64,
667    help_origin: Point,
668    headline_origin: Point,
669}
670
671/// Build/teardown/reconcile one optional erased child in place — the same
672/// three-way match [`crate::dialog`]'s `reconcile_any` runs.
673fn reconcile_optional<State: 'static>(
674    prev: Option<&AnyView<State>>,
675    next: Option<&AnyView<State>>,
676    pod: &mut Option<ChildPod>,
677    ctx: &mut BuildCtx<'_>,
678) -> ChangeFlags {
679    match (prev, next) {
680        (None, Some(view)) => {
681            *pod = Some(build_child(view, ctx));
682            ChangeFlags::LAYOUT | ChangeFlags::PAINT
683        }
684        (Some(view), None) => {
685            if let Some(p) = pod.as_mut() {
686                teardown_child(view, p, ctx);
687            }
688            *pod = None;
689            ChangeFlags::LAYOUT | ChangeFlags::PAINT
690        }
691        (Some(a), Some(b)) => match pod.as_mut() {
692            Some(p) => rebuild_child(a, b, p, ctx),
693            None => ChangeFlags::NONE,
694        },
695        (None, None) => ChangeFlags::NONE,
696    }
697}
698
699impl<State: 'static> View<State> for DatePickerPanel<State> {
700    type Element = DatePickerPanelWidget;
701
702    fn build(&self, ctx: &mut BuildCtx<'_>) -> DatePickerPanelWidget {
703        let mut help = HeaderRun::new(HELP_TEXT_SIZE, FontWeight::MEDIUM);
704        help.set_content(&self.help);
705        let mut headline = HeaderRun::new(HEADLINE_TEXT_SIZE, FontWeight::REGULAR);
706        headline.set_content(&self.headline);
707        DatePickerPanelWidget {
708            help,
709            headline,
710            toggle: self.toggle.as_ref().map(|v| build_child(v, ctx)),
711            divider_pod: build_child(&any::<State, _>(divider()), ctx),
712            body: build_child(&self.body, ctx),
713            body_inset: self.body_inset,
714            actions: vec![
715                build_child(&self.cancel, ctx),
716                build_child(&self.confirm, ctx),
717            ],
718            header_height: 0.0,
719            help_origin: Point::ZERO,
720            headline_origin: Point::ZERO,
721        }
722    }
723
724    fn rebuild(
725        &self,
726        prev: &Self,
727        element: &mut DatePickerPanelWidget,
728        ctx: &mut BuildCtx<'_>,
729    ) -> ChangeFlags {
730        let mut flags = ChangeFlags::NONE;
731        if prev.help != self.help {
732            element.help.set_content(&self.help);
733            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
734        }
735        if prev.headline != self.headline {
736            element.headline.set_content(&self.headline);
737            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
738        }
739        if element.body_inset != self.body_inset {
740            element.body_inset = self.body_inset;
741            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
742        }
743        flags |= reconcile_optional(
744            prev.toggle.as_ref(),
745            self.toggle.as_ref(),
746            &mut element.toggle,
747            ctx,
748        );
749        flags |= rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
750        flags |= rebuild_child(
751            &any::<State, _>(divider()),
752            &any::<State, _>(divider()),
753            &mut element.divider_pod,
754            ctx,
755        );
756        let prev_actions = [&prev.cancel, &prev.confirm];
757        let next_actions = [&self.cancel, &self.confirm];
758        flags |= rebuild_children(
759            &prev_actions,
760            &next_actions,
761            &mut element.actions,
762            ctx,
763            |view: &&AnyView<State>| *view,
764            |_| None,
765        );
766        flags
767    }
768
769    fn teardown(&self, element: &mut DatePickerPanelWidget, ctx: &mut BuildCtx<'_>) {
770        if let (Some(view), Some(pod)) = (&self.toggle, element.toggle.as_mut()) {
771            teardown_child(view, pod, ctx);
772        }
773        teardown_child(&any::<State, _>(divider()), &mut element.divider_pod, ctx);
774        teardown_child(&self.body, &mut element.body, ctx);
775        for (view, pod) in [&self.cancel, &self.confirm]
776            .into_iter()
777            .zip(element.actions.iter_mut())
778        {
779            teardown_child(view, pod, ctx);
780        }
781    }
782}
783
784impl Widget for DatePickerPanelWidget {
785    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
786        // The host gives a centred panel a tight width; the fallback covers the
787        // degenerate unbounded case a `Stack`-mounted panel could hand in.
788        let offered = bc.max().width.max(bc.min().width);
789        let width = if offered.is_finite() {
790            offered
791        } else {
792            DIALOG_PORTRAIT_CALENDAR_WIDTH
793        };
794        let inner_w = (width - 2.0 * PANEL_PADDING).max(0.0);
795
796        // --- header: help row (help text + optional mode affordance), headline.
797        let toggle_size = match self.toggle.as_mut() {
798            Some(pod) => pod.layout_child(
799                ctx,
800                &BoxConstraints::loose(Size::new(TOGGLE_SLOT, TOGGLE_SLOT)),
801            ),
802            None => Size::ZERO,
803        };
804        let help_w = (inner_w
805            - if self.toggle.is_some() {
806                TOGGLE_SLOT
807            } else {
808                0.0
809            })
810        .max(0.0);
811        // The header runs' families: the live theme's `labelLarge`/
812        // `headlineLarge`, or the system UI family unthemed.
813        let (help_family, headline_family) = match Theme::from_layout_ctx(ctx) {
814            Some(theme) => (
815                theme.type_scale.label_large.family.clone(),
816                theme.type_scale.headline_large.family.clone(),
817            ),
818            None => (FontFamily::default(), FontFamily::default()),
819        };
820        let help_size = self.help.shape(ctx, &help_family, help_w);
821        let headline_size = self.headline.shape(ctx, &headline_family, inner_w);
822
823        let help_row_h = help_size.height.max(toggle_size.height);
824        let natural =
825            PANEL_PADDING + help_row_h + HEADLINE_GAP + headline_size.height + PANEL_PADDING;
826        self.header_height = natural.max(HEADER_PORTRAIT_HEIGHT);
827        self.help_origin = Point::new(
828            PANEL_PADDING,
829            PANEL_PADDING + (help_row_h - help_size.height) / 2.0,
830        );
831        self.headline_origin = Point::new(
832            PANEL_PADDING,
833            self.header_height - PANEL_PADDING - headline_size.height,
834        );
835        if let Some(pod) = self.toggle.as_mut() {
836            pod.set_origin(Point::new(
837                width - PANEL_PADDING - toggle_size.width,
838                PANEL_PADDING + (help_row_h - toggle_size.height) / 2.0,
839            ));
840        }
841
842        let mut y = self.header_height;
843
844        // --- divider.
845        let divider_size = self
846            .divider_pod
847            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
848        self.divider_pod.set_origin(Point::new(0.0, y));
849        y += divider_size.height;
850
851        // --- body.
852        let body_w = (width - 2.0 * self.body_inset).max(0.0);
853        let body_size = self.body.layout_child(
854            ctx,
855            &BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
856        );
857        self.body
858            .set_origin(Point::new(self.body_inset, y + self.body_inset));
859        y += body_size.height + 2.0 * self.body_inset;
860
861        // --- actions: right-aligned Cancel then OK.
862        let loose = BoxConstraints::loose(Size::new(inner_w, f64::INFINITY));
863        let mut sizes = Vec::with_capacity(self.actions.len());
864        for pod in &mut self.actions {
865            sizes.push(pod.layout_child(ctx, &loose));
866        }
867        let row_h = sizes.iter().fold(0.0_f64, |acc, s| acc.max(s.height));
868        let band = (row_h + PANEL_PADDING).max(ACTIONS_MIN_HEIGHT);
869        let total_w = sizes.iter().map(|s| s.width).sum::<f64>()
870            + ACTION_GAP * (sizes.len().saturating_sub(1)) as f64;
871        let mut x = width - PANEL_PADDING - total_w;
872        for (pod, size) in self.actions.iter_mut().zip(sizes.iter()) {
873            pod.set_origin(Point::new(x, y + (row_h - size.height) / 2.0));
874            x += size.width + ACTION_GAP;
875        }
876        y += band;
877
878        bc.constrain(Size::new(width, y))
879    }
880
881    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
882        let (on_surface, on_surface_variant) = {
883            let theme = Theme::from_paint_ctx(ctx);
884            (
885                theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface),
886                theme.map_or(FALLBACK_ON_SURFACE_VARIANT, |t| {
887                    t.scheme().on_surface_variant
888                }),
889            )
890        };
891        let origin = ctx.origin();
892        self.help.paint(
893            Point::new(origin.x + self.help_origin.x, origin.y + self.help_origin.y),
894            on_surface_variant,
895            scene,
896        );
897        self.headline.paint(
898            Point::new(
899                origin.x + self.headline_origin.x,
900                origin.y + self.headline_origin.y,
901            ),
902            on_surface,
903            scene,
904        );
905        if let Some(pod) = self.toggle.as_mut() {
906            pod.paint_child(ctx, scene);
907        }
908        self.divider_pod.paint_child(ctx, scene);
909        self.body.paint_child(ctx, scene);
910        for pod in &mut self.actions {
911            pod.paint_child(ctx, scene);
912        }
913    }
914
915    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
916        if let Some(toggle) = self.toggle.as_mut()
917            && route_event_single(toggle, ctx, event) == EventResult::Handled
918        {
919            return EventResult::Handled;
920        }
921        if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
922            return EventResult::Handled;
923        }
924        if route_event(&mut self.actions, ctx, event) == EventResult::Handled {
925            return EventResult::Handled;
926        }
927        EventResult::Ignored
928    }
929
930    fn semantics(&self, ctx: &mut SemanticsCtx) {
931        if let Some(pod) = &self.toggle {
932            pod.semantics_child(ctx);
933        }
934        self.body.semantics_child(ctx);
935        for pod in &self.actions {
936            pod.semantics_child(ctx);
937        }
938    }
939
940    visit_children!(toggle, divider_pod, body, actions);
941}
942
943#[cfg(test)]
944mod tests {
945    use std::any::Any;
946    use std::cell::Cell;
947
948    use frust::FrameTime;
949    use frust::authoring::{PointerButton, PointerEvent, PointerPhase};
950    use frust_widgets::test_support::leaf_any;
951
952    use super::super::{DatePickerEntryMode, DatePickerMode};
953    use super::*;
954
955    fn d(year: i32, month: u32, day: u32) -> MaterialDate {
956        MaterialDate::new(year, month, day)
957    }
958
959    const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
960    const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
961
962    #[derive(Default)]
963    struct App {
964        reported: Vec<DatePickerState>,
965        confirmed: Vec<Option<MaterialDate>>,
966        dismissed: usize,
967    }
968
969    fn dialog(state: DatePickerState) -> DatePickerDialog<App> {
970        date_picker_dialog(state, FIRST(), LAST(), |app: &mut App, next| {
971            app.reported.push(next)
972        })
973        .on_confirm(|app: &mut App, picked| app.confirmed.push(picked))
974        .on_dismiss(|app: &mut App| app.dismissed += 1)
975    }
976
977    fn seeded() -> DatePickerState {
978        DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20))
979    }
980
981    // ---- confirm resolution ----------------------------------------------
982
983    #[test]
984    fn calendar_mode_confirms_whatever_is_selected() {
985        assert_eq!(dialog(seeded()).confirmable(), Some(d(2026, 8, 20)));
986        assert!(dialog(seeded()).can_confirm());
987
988        // Nothing selected still confirms — the reference pops a null date.
989        let empty = DatePickerState::new(None, d(2026, 8, 20));
990        assert_eq!(dialog(empty.clone()).confirmable(), None);
991        assert!(
992            dialog(empty).can_confirm(),
993            "an untouched calendar still lets OK close the dialog"
994        );
995    }
996
997    #[test]
998    fn input_mode_confirms_the_parsed_text_and_refuses_an_invalid_one() {
999        let valid = seeded()
1000            .with_entry_mode(DatePickerEntryMode::Input)
1001            .with_input_text("06/15/2026");
1002        assert_eq!(dialog(valid.clone()).confirmable(), Some(d(2026, 6, 15)));
1003        assert!(dialog(valid).can_confirm());
1004
1005        for bad in ["08/2", "", "08/20/2045"] {
1006            let invalid = seeded()
1007                .with_entry_mode(DatePickerEntryMode::Input)
1008                .with_input_text(bad);
1009            assert_eq!(dialog(invalid.clone()).confirmable(), None, "{bad:?}");
1010            assert!(
1011                !dialog(invalid).can_confirm(),
1012                "OK must refuse {bad:?} rather than confirm nothing"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn a_selection_the_predicate_refuses_is_not_confirmable() {
1019        let state = seeded().with_selected(d(2026, 8, 15));
1020        let refused = date_picker_dialog(state.clone(), FIRST(), LAST(), |_: &mut App, _| {})
1021            .selectable(|date| date.day() % 2 == 0);
1022        assert_eq!(refused.confirmable(), None);
1023
1024        let allowed = date_picker_dialog(state, FIRST(), LAST(), |_: &mut App, _| {})
1025            .selectable(|date| date.day() % 2 == 1);
1026        assert_eq!(allowed.confirmable(), Some(d(2026, 8, 15)));
1027    }
1028
1029    #[test]
1030    fn a_selection_outside_the_bounds_is_not_confirmable() {
1031        let state = DatePickerState::new(Some(d(2045, 1, 1)), d(2026, 8, 20));
1032        assert_eq!(dialog(state).confirmable(), None);
1033    }
1034
1035    // ---- OK / Cancel wiring ----------------------------------------------
1036
1037    #[test]
1038    fn ok_reports_the_confirmable_date_once() {
1039        let mut app = App::default();
1040        dialog(seeded()).confirm(&mut app);
1041        assert_eq!(app.confirmed, vec![Some(d(2026, 8, 20))]);
1042        assert_eq!(app.dismissed, 0);
1043    }
1044
1045    #[test]
1046    fn ok_with_nothing_selected_still_confirms_an_empty_pick() {
1047        let mut app = App::default();
1048        dialog(DatePickerState::new(None, d(2026, 8, 20))).confirm(&mut app);
1049        assert_eq!(app.confirmed, vec![None]);
1050    }
1051
1052    #[test]
1053    fn ok_in_input_mode_reports_nothing_for_an_invalid_entry() {
1054        let mut app = App::default();
1055        dialog(
1056            seeded()
1057                .with_entry_mode(DatePickerEntryMode::Input)
1058                .with_input_text("08/2"),
1059        )
1060        .confirm(&mut app);
1061        assert!(app.confirmed.is_empty(), "an invalid entry never confirms");
1062        assert_eq!(app.dismissed, 0, "and never dismisses either");
1063    }
1064
1065    #[test]
1066    fn ok_in_input_mode_confirms_the_typed_date() {
1067        let mut app = App::default();
1068        dialog(
1069            seeded()
1070                .with_entry_mode(DatePickerEntryMode::Input)
1071                .with_input_text("06/15/2026"),
1072        )
1073        .confirm(&mut app);
1074        assert_eq!(app.confirmed, vec![Some(d(2026, 6, 15))]);
1075    }
1076
1077    #[test]
1078    fn cancel_fires_the_dismiss_callback() {
1079        let mut app = App::default();
1080        dialog(seeded()).cancel(&mut app);
1081        assert_eq!(app.dismissed, 1);
1082        assert!(app.confirmed.is_empty());
1083    }
1084
1085    #[test]
1086    fn cancel_stages_through_the_dismiss_handle_when_one_is_installed() {
1087        // The `show_date_picker` path: the callback Cancel used to fire is the
1088        // host's own raw `controller.pop()`, so with a handle installed Cancel
1089        // asks the host to animate out instead — and fires nothing itself.
1090        let dismiss = ModalDismiss::new();
1091        let mut app = App::default();
1092        dialog(seeded())
1093            .dismiss_handle(dismiss.clone())
1094            .cancel(&mut app);
1095        assert_eq!(app.dismissed, 0, "the unstaged callback is not fired");
1096        assert!(app.confirmed.is_empty());
1097
1098        // The request is real: mounted the way `show_date_picker` mounts it (a
1099        // staged close hook on the host), Cancel's body makes the host animate
1100        // out and pop only on settle, instead of the panel vanishing at once.
1101        let dismiss = ModalDismiss::new();
1102        let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
1103        let closed = Rc::new(Cell::new(0u32));
1104        let hook = closed.clone();
1105        let view = picker.compose().on_close(move || hook.set(hook.get() + 1));
1106        let mut counter = 0u64;
1107        let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
1108        let window = Size::new(600.0, 800.0);
1109        let mut tcx = TextContext::new();
1110        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1111        w.layout(&mut lctx, &BoxConstraints::tight(window));
1112        for ms in [0.0, 400.0] {
1113            let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
1114            w.paint(&mut ctx, &mut NoopScene);
1115        }
1116        assert!(!w.is_exiting(), "entered");
1117
1118        picker.cancel(&mut app);
1119        let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
1120        w.paint(&mut ctx, &mut NoopScene);
1121        assert!(w.is_exiting(), "Cancel stages the host's exit ramp");
1122        assert_eq!(closed.get(), 0, "and pops nothing mid-ramp");
1123
1124        let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(2000.0));
1125        w.paint(&mut ctx, &mut NoopScene);
1126        assert_eq!(closed.get(), 1, "the pop lands when the ramp settles");
1127        assert_eq!(app.dismissed, 0, "never through the unstaged callback");
1128    }
1129
1130    #[test]
1131    fn cancel_with_a_handle_but_no_staged_route_still_dismisses() {
1132        // A `Stack`-mounted picker the app handed a handle anyway: there is no
1133        // close hook behind it to stage, so the host — the only party that
1134        // knows that — delivers the unstaged `on_dismiss` itself, in the event
1135        // pass. Without that fallback the handle is inert and Cancel does
1136        // nothing at all on this mount.
1137        let dismiss = ModalDismiss::new();
1138        let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
1139        let mut counter = 0u64;
1140        let mut w = View::<App>::build(&picker, &mut BuildCtx::new(&mut counter));
1141        let window = Size::new(600.0, 800.0);
1142        let mut tcx = TextContext::new();
1143        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1144        w.layout(&mut lctx, &BoxConstraints::tight(window));
1145        for ms in [0.0, 400.0] {
1146            let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
1147            w.paint(&mut ctx, &mut NoopScene);
1148        }
1149
1150        let mut app = App::default();
1151        picker.cancel(&mut app);
1152        assert_eq!(
1153            app.dismissed, 0,
1154            "Cancel fires nothing itself — the handle owns the dismissal"
1155        );
1156
1157        // The paint backstop reaches no app state, so it leaves the request
1158        // pending rather than swallowing it…
1159        let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
1160        w.paint(&mut ctx, &mut NoopScene);
1161        assert_eq!(app.dismissed, 0);
1162        assert!(!w.is_exiting(), "there is nothing to stage on this mount");
1163
1164        // …and the next event pass delivers it, exactly once.
1165        for _ in 0..2 {
1166            let state_any: &mut dyn Any = &mut app;
1167            let mut ectx = EventCtx::new(state_any, Point::ORIGIN, window);
1168            w.event(
1169                &mut ectx,
1170                &InputEvent::Pointer(PointerEvent {
1171                    phase: PointerPhase::Move,
1172                    position: Point::new(5.0, 5.0),
1173                    button: PointerButton::Primary,
1174                }),
1175            );
1176        }
1177        assert_eq!(app.dismissed, 1, "Cancel is not dead on a `Stack` mount");
1178    }
1179
1180    /// A frame time `ms` milliseconds in.
1181    fn ft_ms(ms: f64) -> FrameTime {
1182        FrameTime::from_nanos((ms * 1_000_000.0) as u64)
1183    }
1184
1185    /// A paint sink for a test that only needs the pass to run.
1186    struct NoopScene;
1187
1188    impl PaintScene for NoopScene {
1189        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
1190        fn draw_text(&mut self, _origin: Point, _text: &str) {}
1191    }
1192
1193    #[test]
1194    fn the_modal_content_contract_reports_the_dismissable_flag() {
1195        assert!(OverlayModalContent::modal_dismissable(&dialog(seeded())));
1196        assert!(!OverlayModalContent::modal_dismissable(
1197            &dialog(seeded()).dismissable(false)
1198        ));
1199    }
1200
1201    #[test]
1202    fn the_installed_dismiss_callback_replaces_the_builders() {
1203        // `show_overlay_modal` installs `controller.pop()` through the trait;
1204        // Cancel then routes to *that*, not to whatever the builder set.
1205        let installed = OverlayModalContent::on_modal_dismiss(
1206            dialog(seeded()),
1207            Rc::new(|app: &mut App| app.dismissed += 100),
1208        );
1209        let mut app = App::default();
1210        installed.cancel(&mut app);
1211        assert_eq!(app.dismissed, 100);
1212    }
1213
1214    // ---- entry mode -------------------------------------------------------
1215
1216    #[test]
1217    fn the_mode_affordance_exists_only_for_an_unlocked_entry_mode() {
1218        assert!(dialog(seeded()).toggle_view().is_some());
1219        assert!(
1220            dialog(seeded().with_entry_mode(DatePickerEntryMode::Input))
1221                .toggle_view()
1222                .is_some()
1223        );
1224        assert!(
1225            dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly))
1226                .toggle_view()
1227                .is_none()
1228        );
1229        assert!(
1230            dialog(seeded().with_entry_mode(DatePickerEntryMode::InputOnly))
1231                .toggle_view()
1232                .is_none()
1233        );
1234    }
1235
1236    #[test]
1237    fn the_panel_width_follows_the_entry_mode() {
1238        assert_eq!(
1239            dialog(seeded()).panel_width(),
1240            DIALOG_PORTRAIT_CALENDAR_WIDTH
1241        );
1242        assert_eq!(
1243            dialog(seeded().with_entry_mode(DatePickerEntryMode::Input)).panel_width(),
1244            DIALOG_PORTRAIT_INPUT_WIDTH
1245        );
1246    }
1247
1248    #[test]
1249    fn the_headline_shows_the_selection_or_the_placeholder() {
1250        assert_eq!(dialog(seeded()).headline(), "Thu, Aug 20");
1251        assert_eq!(
1252            dialog(DatePickerState::new(None, d(2026, 8, 20))).headline(),
1253            DatePickerStrings::ENGLISH.no_date_selected
1254        );
1255    }
1256
1257    // ---- panel composition -------------------------------------------------
1258
1259    #[test]
1260    fn the_panel_builds_and_reconciles_across_an_entry_mode_swap() {
1261        // The body swaps concrete view types (calendar <-> input field); the
1262        // erased child handles that, and the affordance pod survives.
1263        let mut counter = 0u64;
1264        let calendar = dialog(seeded());
1265        let mut element = View::<App>::build(&calendar, &mut BuildCtx::new(&mut counter));
1266        let input = dialog(seeded().with_entry_mode(DatePickerEntryMode::Input));
1267        let flags = View::<App>::rebuild(
1268            &input,
1269            &calendar,
1270            &mut element,
1271            &mut BuildCtx::new(&mut counter),
1272        );
1273        assert!(!flags.is_empty(), "an entry-mode swap dirties the panel");
1274
1275        // ...and back, plus a swap into a locked mode that drops the affordance.
1276        let locked = dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly));
1277        View::<App>::rebuild(
1278            &locked,
1279            &input,
1280            &mut element,
1281            &mut BuildCtx::new(&mut counter),
1282        );
1283        View::<App>::teardown(&locked, &mut element, &mut BuildCtx::new(&mut counter));
1284    }
1285
1286    // ---- header typeface: the runs' families follow the live theme ---------
1287
1288    /// A bare panel — leaf children, no mode affordance — so a test reaches the
1289    /// two header runs without a modal host around them.
1290    fn header_panel() -> DatePickerPanel<()> {
1291        DatePickerPanel {
1292            help: DatePickerStrings::ENGLISH.help_text.to_string(),
1293            headline: "Thu, Aug 20".to_string(),
1294            toggle: None,
1295            body: leaf_any(10.0, 10.0),
1296            body_inset: 0.0,
1297            cancel: leaf_any(10.0, 10.0),
1298            confirm: leaf_any(10.0, 10.0),
1299        }
1300    }
1301
1302    fn build_header_panel() -> DatePickerPanelWidget {
1303        let mut counter = 0u64;
1304        View::<()>::build(&header_panel(), &mut BuildCtx::new(&mut counter))
1305    }
1306
1307    /// A baseline theme whose `labelLarge`/`headlineLarge` roles name `help`/
1308    /// `headline`.
1309    fn theme_with_header(help: FontFamily, headline: FontFamily) -> Theme {
1310        let mut theme = crate::baseline();
1311        theme.type_scale.label_large.family = help;
1312        theme.type_scale.headline_large.family = headline;
1313        theme
1314    }
1315
1316    /// Lay the panel out against `tcx`, threading `theme` the way the render
1317    /// root does.
1318    fn layout_header(w: &mut DatePickerPanelWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
1319        let mut ctx =
1320            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1321        w.layout(
1322            &mut ctx,
1323            &BoxConstraints::tight(Size::new(DIALOG_PORTRAIT_CALENDAR_WIDTH, 600.0)),
1324        );
1325    }
1326
1327    #[test]
1328    fn layout_takes_the_header_families_from_their_roles() {
1329        let mut w = build_header_panel();
1330        let mut tcx = TextContext::new();
1331        let (help, headline) = (
1332            FontFamily::named("Help Role Probe"),
1333            FontFamily::named("Headline Role Probe"),
1334        );
1335        layout_header(
1336            &mut w,
1337            &mut tcx,
1338            Some(&theme_with_header(help.clone(), headline.clone())),
1339        );
1340        assert_eq!(w.help.style().family, help, "the help line is labelLarge");
1341        assert_eq!(
1342            w.headline.style().family,
1343            headline,
1344            "the headline is headlineLarge"
1345        );
1346        // Only the family is themed: the roles' own size/weight literals stay.
1347        assert_eq!(w.help.style().size, HELP_TEXT_SIZE);
1348        assert_eq!(w.headline.style().size, HEADLINE_TEXT_SIZE);
1349    }
1350
1351    #[test]
1352    fn without_a_theme_the_header_keeps_the_unthemed_styles() {
1353        let mut w = build_header_panel();
1354        let mut tcx = TextContext::new();
1355        layout_header(&mut w, &mut tcx, None);
1356        assert_eq!(
1357            w.help.style(),
1358            TextStyle {
1359                weight: FontWeight::MEDIUM,
1360                ..TextStyle::new(HELP_TEXT_SIZE, SHAPING_INK)
1361            }
1362        );
1363        assert_eq!(
1364            w.headline.style(),
1365            TextStyle {
1366                weight: FontWeight::REGULAR,
1367                ..TextStyle::new(HEADLINE_TEXT_SIZE, SHAPING_INK)
1368            }
1369        );
1370    }
1371
1372    #[test]
1373    fn a_theme_swap_reshapes_the_cached_header_runs() {
1374        let mut w = build_header_panel();
1375        let mut tcx = TextContext::new();
1376        let first = theme_with_header(
1377            FontFamily::named("Header Swap Probe A"),
1378            FontFamily::named("Header Swap Probe A"),
1379        );
1380        layout_header(&mut w, &mut tcx, Some(&first));
1381
1382        // Control: the same theme again reuses both cached runs outright.
1383        let settled = tcx.shape_cache_stats();
1384        layout_header(&mut w, &mut tcx, Some(&first));
1385        assert_eq!(
1386            tcx.shape_cache_stats(),
1387            settled,
1388            "an unchanged theme reshapes nothing"
1389        );
1390
1391        let second = theme_with_header(
1392            FontFamily::named("Header Swap Probe B"),
1393            FontFamily::named("Header Swap Probe B"),
1394        );
1395        layout_header(&mut w, &mut tcx, Some(&second));
1396        assert_eq!(
1397            tcx.shape_cache_stats().shapes,
1398            settled.shapes + 2,
1399            "a family swap must reshape both header runs, not serve the old face"
1400        );
1401    }
1402
1403    /// The first glyph run's font bytes — the face `run` actually shaped in.
1404    #[cfg(feature = "bundled-fonts")]
1405    fn shaped_font(run: &HeaderRun) -> Vec<u8> {
1406        let layout = run.layout.as_ref().expect("the run was shaped");
1407        let runs = layout.to_scene_runs(Point::ZERO);
1408        let first = runs
1409            .first()
1410            .expect("the run shaped to at least one glyph run");
1411        first.font.font().data.as_ref().to_vec()
1412    }
1413
1414    #[cfg(feature = "bundled-fonts")]
1415    #[test]
1416    fn a_theme_swap_between_registered_families_changes_the_header_face() {
1417        // `font_data()`'s documented order: Roboto Flex, then Roboto Mono.
1418        let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
1419        let mut tcx = TextContext::new();
1420        for bytes in [flex, mono] {
1421            tcx.register_fonts(bytes.to_vec())
1422                .expect("the bundled faces must register");
1423        }
1424        let flex_family = || FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY);
1425        let mono_family = || FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY);
1426        let mut w = build_header_panel();
1427
1428        layout_header(
1429            &mut w,
1430            &mut tcx,
1431            Some(&theme_with_header(flex_family(), flex_family())),
1432        );
1433        assert!(
1434            shaped_font(&w.help) == flex,
1435            "labelLarge shapes the help line"
1436        );
1437        assert!(
1438            shaped_font(&w.headline) == flex,
1439            "headlineLarge shapes the headline"
1440        );
1441
1442        layout_header(
1443            &mut w,
1444            &mut tcx,
1445            Some(&theme_with_header(mono_family(), mono_family())),
1446        );
1447        assert!(
1448            shaped_font(&w.help) == mono,
1449            "after a theme swap the help line must shape in the new family"
1450        );
1451        assert!(
1452            shaped_font(&w.headline) == mono,
1453            "after a theme swap the headline must shape in the new family"
1454        );
1455    }
1456
1457    #[test]
1458    fn a_calendar_sub_view_change_reconciles_in_place() {
1459        let mut counter = 0u64;
1460        let day = dialog(seeded());
1461        let mut element = View::<App>::build(&day, &mut BuildCtx::new(&mut counter));
1462        let year = dialog(seeded().with_mode(DatePickerMode::Year));
1463        let flags =
1464            View::<App>::rebuild(&year, &day, &mut element, &mut BuildCtx::new(&mut counter));
1465        assert!(!flags.is_empty());
1466    }
1467}