use std::rc::Rc;
use frust::authoring::text::{FontFamily, FontWeight, TextContext, TextLayout, TextStyle};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View, Widget, any, build_child, rebuild_child,
rebuild_children, route_event, route_event_single, teardown_child, visit_children,
};
use frust::{Color, NavigatorController, PopResult, Theme, icon};
use kurbo::{Point, Size};
use peniko::Brush;
use super::calendar::{SelectableDay, calendar_date_picker};
use super::date::MaterialDate;
use super::input::date_input_field;
use super::{
ACTIONS_MIN_HEIGHT, DIALOG_PORTRAIT_CALENDAR_WIDTH, DIALOG_PORTRAIT_INPUT_WIDTH,
DatePickerState, DatePickerStrings, HEADER_PORTRAIT_HEIGHT, OnDatePickerChange,
};
use crate::button::{button, text_button};
use crate::divider::divider;
use crate::icon_button::icon_button;
use crate::overlay::{
ModalDismiss, OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
overlay_modal, show_overlay_modal,
};
const PANEL_PADDING: f64 = 24.0;
const ACTION_GAP: f64 = 8.0;
const TOGGLE_SLOT: f64 = 48.0;
const HEADLINE_GAP: f64 = 4.0;
const HELP_TEXT_SIZE: f32 = 14.0;
const HEADLINE_TEXT_SIZE: f32 = 32.0;
const SHAPING_INK: Color = Color::BLACK;
const FALLBACK_ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const FALLBACK_ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
type OnDismiss<State> = Rc<dyn Fn(&mut State)>;
type OnConfirm<State> = Rc<dyn Fn(&mut State, Option<MaterialDate>)>;
fn confirm_action<State: 'static>(
on_confirm: Option<&OnConfirm<State>>,
can_confirm: bool,
confirmable: Option<MaterialDate>,
app: &mut State,
) {
if !can_confirm {
return;
}
if let Some(on_confirm) = on_confirm {
on_confirm(app, confirmable);
}
}
fn cancel_action<State: 'static>(
dismiss: Option<&ModalDismiss>,
on_dismiss: Option<&OnDismiss<State>>,
app: &mut State,
) {
if let Some(dismiss) = dismiss {
dismiss.dismiss();
return;
}
if let Some(on_dismiss) = on_dismiss {
on_dismiss(app);
}
}
pub struct DatePickerDialog<State: 'static> {
state: DatePickerState,
first_date: MaterialDate,
last_date: MaterialDate,
today: Option<MaterialDate>,
strings: DatePickerStrings,
selectable: Option<SelectableDay>,
on_change: OnDatePickerChange<State>,
on_confirm: Option<OnConfirm<State>>,
dismissable: bool,
on_dismiss: Option<OnDismiss<State>>,
dismiss_handle: Option<ModalDismiss>,
}
pub fn date_picker_dialog<State: 'static, F>(
state: DatePickerState,
first_date: MaterialDate,
last_date: MaterialDate,
on_change: F,
) -> DatePickerDialog<State>
where
F: Fn(&mut State, DatePickerState) + 'static,
{
let (first_date, last_date) = if last_date < first_date {
(last_date, first_date)
} else {
(first_date, last_date)
};
DatePickerDialog {
state,
first_date,
last_date,
today: None,
strings: DatePickerStrings::ENGLISH,
selectable: None,
on_change: Rc::new(on_change),
on_confirm: None,
dismissable: true,
on_dismiss: None,
dismiss_handle: None,
}
}
impl<State: 'static> DatePickerDialog<State> {
pub fn today(mut self, today: MaterialDate) -> Self {
self.today = Some(today);
self
}
pub fn strings(mut self, strings: DatePickerStrings) -> Self {
self.strings = strings;
self
}
pub fn selectable<F: Fn(MaterialDate) -> bool + 'static>(mut self, predicate: F) -> Self {
self.selectable = Some(Rc::new(predicate));
self
}
pub fn on_confirm<F: Fn(&mut State, Option<MaterialDate>) + 'static>(
mut self,
on_confirm: F,
) -> Self {
self.on_confirm = Some(Rc::new(on_confirm));
self
}
pub fn dismissable(mut self, dismissable: bool) -> Self {
self.dismissable = dismissable;
self
}
pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
self.on_dismiss = Some(Rc::new(on_dismiss));
self
}
pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
self.dismiss_handle = Some(dismiss);
self
}
fn is_selectable(&self, date: MaterialDate) -> bool {
date.is_within(self.first_date, self.last_date)
&& self.selectable.as_ref().is_none_or(|p| p(date))
}
pub fn confirmable(&self) -> Option<MaterialDate> {
if self.state.entry_mode.is_input() {
MaterialDate::parse_compact(&self.state.input_text)
.filter(|date| self.is_selectable(*date))
} else {
self.state.selected.filter(|date| self.is_selectable(*date))
}
}
pub fn can_confirm(&self) -> bool {
!self.state.entry_mode.is_input() || self.confirmable().is_some()
}
fn panel_width(&self) -> f64 {
if self.state.entry_mode.is_input() {
DIALOG_PORTRAIT_INPUT_WIDTH
} else {
DIALOG_PORTRAIT_CALENDAR_WIDTH
}
}
fn headline(&self) -> String {
match self.state.selected {
Some(date) => self.strings.medium_date(date),
None => self.strings.no_date_selected.to_string(),
}
}
fn toggle_view(&self) -> Option<AnyView<State>> {
if !self.state.entry_mode.is_toggleable() {
return None;
}
let glyph = if self.state.entry_mode.is_input() {
crate::icons::CALENDAR_TODAY
} else {
crate::icons::EDIT_OUTLINED
};
let state = self.state.clone();
let on_change = self.on_change.clone();
Some(any::<State, _>(icon_button(
any::<State, _>(icon(glyph)),
move |app: &mut State| on_change(app, state.toggled_entry_mode()),
)))
}
fn body_view(&self) -> AnyView<State> {
let on_change = self.on_change.clone();
if self.state.entry_mode.is_input() {
let mut field = date_input_field(
self.state.clone(),
self.first_date,
self.last_date,
move |app: &mut State, next| on_change(app, next),
)
.strings(self.strings);
if let Some(predicate) = &self.selectable {
let predicate = predicate.clone();
field = field.selectable(move |date| predicate(date));
}
any::<State, _>(field)
} else {
let mut calendar = calendar_date_picker(
self.state.clone(),
self.first_date,
self.last_date,
move |app: &mut State, next| on_change(app, next),
)
.strings(self.strings);
if let Some(today) = self.today {
calendar = calendar.today(today);
}
if let Some(predicate) = &self.selectable {
let predicate = predicate.clone();
calendar = calendar.selectable(move |date| predicate(date));
}
any::<State, _>(calendar)
}
}
fn cancel_view(&self) -> AnyView<State> {
let on_dismiss = self.on_dismiss.clone();
let dismiss = self.dismiss_handle.clone();
any::<State, _>(text_button(
self.strings.cancel_label,
move |app: &mut State| cancel_action(dismiss.as_ref(), on_dismiss.as_ref(), app),
))
}
fn confirm_view(&self) -> AnyView<State> {
let on_confirm = self.on_confirm.clone();
let confirmable = self.confirmable();
let can_confirm = self.can_confirm();
any::<State, _>(button(
self.strings.confirm_label,
move |app: &mut State| {
confirm_action(on_confirm.as_ref(), can_confirm, confirmable, app)
},
))
}
pub fn confirm(&self, app: &mut State) {
confirm_action(
self.on_confirm.as_ref(),
self.can_confirm(),
self.confirmable(),
app,
);
}
pub fn cancel(&self, app: &mut State) {
cancel_action(self.dismiss_handle.as_ref(), self.on_dismiss.as_ref(), app);
}
fn compose(&self) -> OverlayModalView<State> {
let panel = DatePickerPanel {
help: self.strings.help_text.to_string(),
headline: self.headline(),
toggle: self.toggle_view(),
body: self.body_view(),
body_inset: if self.state.entry_mode.is_input() {
PANEL_PADDING
} else {
0.0
},
cancel: self.cancel_view(),
confirm: self.confirm_view(),
};
let mut view = overlay_modal(panel, OverlayModalConfig::centered(self.panel_width()))
.dismissable(self.dismissable)
.label(self.strings.help_text);
if let Some(on_dismiss) = &self.on_dismiss {
let on_dismiss = on_dismiss.clone();
view = view.on_dismiss(move |state: &mut State| on_dismiss(state));
}
if let Some(dismiss) = &self.dismiss_handle {
view = view.dismiss_handle(dismiss.clone());
}
view
}
}
impl<State: 'static> View<State> for DatePickerDialog<State> {
type Element = OverlayModalWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
View::build(&self.compose(), ctx)
}
fn rebuild(
&self,
prev: &Self,
element: &mut OverlayModalWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
View::rebuild(&self.compose(), &prev.compose(), element, ctx)
}
fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
View::teardown(&self.compose(), element, ctx);
}
}
impl<State: 'static> OverlayModalContent<State> for DatePickerDialog<State> {
fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
self.on_dismiss = Some(on_dismiss);
self
}
fn modal_dismissable(&self) -> bool {
self.dismissable
}
}
pub fn show_date_picker<State, B, R>(
controller: &NavigatorController<State>,
build: B,
on_result: R,
) where
State: 'static,
B: Fn() -> DatePickerDialog<State> + 'static,
R: Fn(&mut State, PopResult) + 'static,
{
let dismiss = ModalDismiss::new();
show_overlay_modal(
controller,
move || {
let confirm_dismiss = dismiss.clone();
build()
.on_confirm(
move |_state: &mut State, picked: Option<MaterialDate>| match picked {
Some(date) => confirm_dismiss.dismiss_with(PopResult::of(date)),
None => confirm_dismiss.dismiss(),
},
)
.dismiss_handle(dismiss.clone())
},
on_result,
);
}
struct DatePickerPanel<State: 'static> {
help: String,
headline: String,
toggle: Option<AnyView<State>>,
body: AnyView<State>,
body_inset: f64,
cancel: AnyView<State>,
confirm: AnyView<State>,
}
struct HeaderRun {
content: String,
size: f32,
weight: FontWeight,
family: FontFamily,
layout: Option<TextLayout>,
}
impl HeaderRun {
fn new(size: f32, weight: FontWeight) -> Self {
Self {
content: String::new(),
size,
weight,
family: FontFamily::default(),
layout: None,
}
}
fn style(&self) -> TextStyle {
TextStyle {
family: self.family.clone(),
weight: self.weight,
..TextStyle::new(self.size, SHAPING_INK)
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn shape(&mut self, ctx: &mut LayoutCtx, family: &FontFamily, max_width: f64) -> Size {
if self.family != *family {
self.family = family.clone();
self.layout = None;
}
if let Some(layout) = &self.layout {
return layout.size();
}
let style = self.style();
let laid = ctx.text_context::<TextContext>().layout(
&self.content,
&style,
Some(max_width.max(0.0) as f32),
);
let size = laid.size();
self.layout = Some(laid);
size
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
struct DatePickerPanelWidget {
help: HeaderRun,
headline: HeaderRun,
toggle: Option<ChildPod>,
divider_pod: ChildPod,
body: ChildPod,
body_inset: f64,
actions: Vec<ChildPod>,
header_height: f64,
help_origin: Point,
headline_origin: Point,
}
fn reconcile_optional<State: 'static>(
prev: Option<&AnyView<State>>,
next: Option<&AnyView<State>>,
pod: &mut Option<ChildPod>,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
match (prev, next) {
(None, Some(view)) => {
*pod = Some(build_child(view, ctx));
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(view), None) => {
if let Some(p) = pod.as_mut() {
teardown_child(view, p, ctx);
}
*pod = None;
ChangeFlags::LAYOUT | ChangeFlags::PAINT
}
(Some(a), Some(b)) => match pod.as_mut() {
Some(p) => rebuild_child(a, b, p, ctx),
None => ChangeFlags::NONE,
},
(None, None) => ChangeFlags::NONE,
}
}
impl<State: 'static> View<State> for DatePickerPanel<State> {
type Element = DatePickerPanelWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> DatePickerPanelWidget {
let mut help = HeaderRun::new(HELP_TEXT_SIZE, FontWeight::MEDIUM);
help.set_content(&self.help);
let mut headline = HeaderRun::new(HEADLINE_TEXT_SIZE, FontWeight::REGULAR);
headline.set_content(&self.headline);
DatePickerPanelWidget {
help,
headline,
toggle: self.toggle.as_ref().map(|v| build_child(v, ctx)),
divider_pod: build_child(&any::<State, _>(divider()), ctx),
body: build_child(&self.body, ctx),
body_inset: self.body_inset,
actions: vec![
build_child(&self.cancel, ctx),
build_child(&self.confirm, ctx),
],
header_height: 0.0,
help_origin: Point::ZERO,
headline_origin: Point::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut DatePickerPanelWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.help != self.help {
element.help.set_content(&self.help);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.headline != self.headline {
element.headline.set_content(&self.headline);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.body_inset != self.body_inset {
element.body_inset = self.body_inset;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags |= reconcile_optional(
prev.toggle.as_ref(),
self.toggle.as_ref(),
&mut element.toggle,
ctx,
);
flags |= rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
flags |= rebuild_child(
&any::<State, _>(divider()),
&any::<State, _>(divider()),
&mut element.divider_pod,
ctx,
);
let prev_actions = [&prev.cancel, &prev.confirm];
let next_actions = [&self.cancel, &self.confirm];
flags |= rebuild_children(
&prev_actions,
&next_actions,
&mut element.actions,
ctx,
|view: &&AnyView<State>| *view,
|_| None,
);
flags
}
fn teardown(&self, element: &mut DatePickerPanelWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(view), Some(pod)) = (&self.toggle, element.toggle.as_mut()) {
teardown_child(view, pod, ctx);
}
teardown_child(&any::<State, _>(divider()), &mut element.divider_pod, ctx);
teardown_child(&self.body, &mut element.body, ctx);
for (view, pod) in [&self.cancel, &self.confirm]
.into_iter()
.zip(element.actions.iter_mut())
{
teardown_child(view, pod, ctx);
}
}
}
impl Widget for DatePickerPanelWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let offered = bc.max().width.max(bc.min().width);
let width = if offered.is_finite() {
offered
} else {
DIALOG_PORTRAIT_CALENDAR_WIDTH
};
let inner_w = (width - 2.0 * PANEL_PADDING).max(0.0);
let toggle_size = match self.toggle.as_mut() {
Some(pod) => pod.layout_child(
ctx,
&BoxConstraints::loose(Size::new(TOGGLE_SLOT, TOGGLE_SLOT)),
),
None => Size::ZERO,
};
let help_w = (inner_w
- if self.toggle.is_some() {
TOGGLE_SLOT
} else {
0.0
})
.max(0.0);
let (help_family, headline_family) = match Theme::from_layout_ctx(ctx) {
Some(theme) => (
theme.type_scale.label_large.family.clone(),
theme.type_scale.headline_large.family.clone(),
),
None => (FontFamily::default(), FontFamily::default()),
};
let help_size = self.help.shape(ctx, &help_family, help_w);
let headline_size = self.headline.shape(ctx, &headline_family, inner_w);
let help_row_h = help_size.height.max(toggle_size.height);
let natural =
PANEL_PADDING + help_row_h + HEADLINE_GAP + headline_size.height + PANEL_PADDING;
self.header_height = natural.max(HEADER_PORTRAIT_HEIGHT);
self.help_origin = Point::new(
PANEL_PADDING,
PANEL_PADDING + (help_row_h - help_size.height) / 2.0,
);
self.headline_origin = Point::new(
PANEL_PADDING,
self.header_height - PANEL_PADDING - headline_size.height,
);
if let Some(pod) = self.toggle.as_mut() {
pod.set_origin(Point::new(
width - PANEL_PADDING - toggle_size.width,
PANEL_PADDING + (help_row_h - toggle_size.height) / 2.0,
));
}
let mut y = self.header_height;
let divider_size = self
.divider_pod
.layout_child(ctx, &BoxConstraints::tight(Size::new(width, 1.0)));
self.divider_pod.set_origin(Point::new(0.0, y));
y += divider_size.height;
let body_w = (width - 2.0 * self.body_inset).max(0.0);
let body_size = self.body.layout_child(
ctx,
&BoxConstraints::new(Size::new(body_w, 0.0), Size::new(body_w, f64::INFINITY)),
);
self.body
.set_origin(Point::new(self.body_inset, y + self.body_inset));
y += body_size.height + 2.0 * self.body_inset;
let loose = BoxConstraints::loose(Size::new(inner_w, f64::INFINITY));
let mut sizes = Vec::with_capacity(self.actions.len());
for pod in &mut self.actions {
sizes.push(pod.layout_child(ctx, &loose));
}
let row_h = sizes.iter().fold(0.0_f64, |acc, s| acc.max(s.height));
let band = (row_h + PANEL_PADDING).max(ACTIONS_MIN_HEIGHT);
let total_w = sizes.iter().map(|s| s.width).sum::<f64>()
+ ACTION_GAP * (sizes.len().saturating_sub(1)) as f64;
let mut x = width - PANEL_PADDING - total_w;
for (pod, size) in self.actions.iter_mut().zip(sizes.iter()) {
pod.set_origin(Point::new(x, y + (row_h - size.height) / 2.0));
x += size.width + ACTION_GAP;
}
y += band;
bc.constrain(Size::new(width, y))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let (on_surface, on_surface_variant) = {
let theme = Theme::from_paint_ctx(ctx);
(
theme.map_or(FALLBACK_ON_SURFACE, |t| t.scheme().on_surface),
theme.map_or(FALLBACK_ON_SURFACE_VARIANT, |t| {
t.scheme().on_surface_variant
}),
)
};
let origin = ctx.origin();
self.help.paint(
Point::new(origin.x + self.help_origin.x, origin.y + self.help_origin.y),
on_surface_variant,
scene,
);
self.headline.paint(
Point::new(
origin.x + self.headline_origin.x,
origin.y + self.headline_origin.y,
),
on_surface,
scene,
);
if let Some(pod) = self.toggle.as_mut() {
pod.paint_child(ctx, scene);
}
self.divider_pod.paint_child(ctx, scene);
self.body.paint_child(ctx, scene);
for pod in &mut self.actions {
pod.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let Some(toggle) = self.toggle.as_mut()
&& route_event_single(toggle, ctx, event) == EventResult::Handled
{
return EventResult::Handled;
}
if route_event_single(&mut self.body, ctx, event) == EventResult::Handled {
return EventResult::Handled;
}
if route_event(&mut self.actions, ctx, event) == EventResult::Handled {
return EventResult::Handled;
}
EventResult::Ignored
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if let Some(pod) = &self.toggle {
pod.semantics_child(ctx);
}
self.body.semantics_child(ctx);
for pod in &self.actions {
pod.semantics_child(ctx);
}
}
visit_children!(toggle, divider_pod, body, actions);
}
#[cfg(test)]
mod tests {
use std::any::Any;
use std::cell::Cell;
use frust::FrameTime;
use frust::authoring::{PointerButton, PointerEvent, PointerPhase};
use frust_widgets::test_support::leaf_any;
use super::super::{DatePickerEntryMode, DatePickerMode};
use super::*;
fn d(year: i32, month: u32, day: u32) -> MaterialDate {
MaterialDate::new(year, month, day)
}
const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
#[derive(Default)]
struct App {
reported: Vec<DatePickerState>,
confirmed: Vec<Option<MaterialDate>>,
dismissed: usize,
}
fn dialog(state: DatePickerState) -> DatePickerDialog<App> {
date_picker_dialog(state, FIRST(), LAST(), |app: &mut App, next| {
app.reported.push(next)
})
.on_confirm(|app: &mut App, picked| app.confirmed.push(picked))
.on_dismiss(|app: &mut App| app.dismissed += 1)
}
fn seeded() -> DatePickerState {
DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20))
}
#[test]
fn calendar_mode_confirms_whatever_is_selected() {
assert_eq!(dialog(seeded()).confirmable(), Some(d(2026, 8, 20)));
assert!(dialog(seeded()).can_confirm());
let empty = DatePickerState::new(None, d(2026, 8, 20));
assert_eq!(dialog(empty.clone()).confirmable(), None);
assert!(
dialog(empty).can_confirm(),
"an untouched calendar still lets OK close the dialog"
);
}
#[test]
fn input_mode_confirms_the_parsed_text_and_refuses_an_invalid_one() {
let valid = seeded()
.with_entry_mode(DatePickerEntryMode::Input)
.with_input_text("06/15/2026");
assert_eq!(dialog(valid.clone()).confirmable(), Some(d(2026, 6, 15)));
assert!(dialog(valid).can_confirm());
for bad in ["08/2", "", "08/20/2045"] {
let invalid = seeded()
.with_entry_mode(DatePickerEntryMode::Input)
.with_input_text(bad);
assert_eq!(dialog(invalid.clone()).confirmable(), None, "{bad:?}");
assert!(
!dialog(invalid).can_confirm(),
"OK must refuse {bad:?} rather than confirm nothing"
);
}
}
#[test]
fn a_selection_the_predicate_refuses_is_not_confirmable() {
let state = seeded().with_selected(d(2026, 8, 15));
let refused = date_picker_dialog(state.clone(), FIRST(), LAST(), |_: &mut App, _| {})
.selectable(|date| date.day() % 2 == 0);
assert_eq!(refused.confirmable(), None);
let allowed = date_picker_dialog(state, FIRST(), LAST(), |_: &mut App, _| {})
.selectable(|date| date.day() % 2 == 1);
assert_eq!(allowed.confirmable(), Some(d(2026, 8, 15)));
}
#[test]
fn a_selection_outside_the_bounds_is_not_confirmable() {
let state = DatePickerState::new(Some(d(2045, 1, 1)), d(2026, 8, 20));
assert_eq!(dialog(state).confirmable(), None);
}
#[test]
fn ok_reports_the_confirmable_date_once() {
let mut app = App::default();
dialog(seeded()).confirm(&mut app);
assert_eq!(app.confirmed, vec![Some(d(2026, 8, 20))]);
assert_eq!(app.dismissed, 0);
}
#[test]
fn ok_with_nothing_selected_still_confirms_an_empty_pick() {
let mut app = App::default();
dialog(DatePickerState::new(None, d(2026, 8, 20))).confirm(&mut app);
assert_eq!(app.confirmed, vec![None]);
}
#[test]
fn ok_in_input_mode_reports_nothing_for_an_invalid_entry() {
let mut app = App::default();
dialog(
seeded()
.with_entry_mode(DatePickerEntryMode::Input)
.with_input_text("08/2"),
)
.confirm(&mut app);
assert!(app.confirmed.is_empty(), "an invalid entry never confirms");
assert_eq!(app.dismissed, 0, "and never dismisses either");
}
#[test]
fn ok_in_input_mode_confirms_the_typed_date() {
let mut app = App::default();
dialog(
seeded()
.with_entry_mode(DatePickerEntryMode::Input)
.with_input_text("06/15/2026"),
)
.confirm(&mut app);
assert_eq!(app.confirmed, vec![Some(d(2026, 6, 15))]);
}
#[test]
fn cancel_fires_the_dismiss_callback() {
let mut app = App::default();
dialog(seeded()).cancel(&mut app);
assert_eq!(app.dismissed, 1);
assert!(app.confirmed.is_empty());
}
#[test]
fn cancel_stages_through_the_dismiss_handle_when_one_is_installed() {
let dismiss = ModalDismiss::new();
let mut app = App::default();
dialog(seeded())
.dismiss_handle(dismiss.clone())
.cancel(&mut app);
assert_eq!(app.dismissed, 0, "the unstaged callback is not fired");
assert!(app.confirmed.is_empty());
let dismiss = ModalDismiss::new();
let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
let closed = Rc::new(Cell::new(0u32));
let hook = closed.clone();
let view = picker.compose().on_close(move || hook.set(hook.get() + 1));
let mut counter = 0u64;
let mut w = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
let window = Size::new(600.0, 800.0);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(window));
for ms in [0.0, 400.0] {
let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
w.paint(&mut ctx, &mut NoopScene);
}
assert!(!w.is_exiting(), "entered");
picker.cancel(&mut app);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
w.paint(&mut ctx, &mut NoopScene);
assert!(w.is_exiting(), "Cancel stages the host's exit ramp");
assert_eq!(closed.get(), 0, "and pops nothing mid-ramp");
let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(2000.0));
w.paint(&mut ctx, &mut NoopScene);
assert_eq!(closed.get(), 1, "the pop lands when the ramp settles");
assert_eq!(app.dismissed, 0, "never through the unstaged callback");
}
#[test]
fn cancel_with_a_handle_but_no_staged_route_still_dismisses() {
let dismiss = ModalDismiss::new();
let picker = dialog(seeded()).dismiss_handle(dismiss.clone());
let mut counter = 0u64;
let mut w = View::<App>::build(&picker, &mut BuildCtx::new(&mut counter));
let window = Size::new(600.0, 800.0);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut lctx, &BoxConstraints::tight(window));
for ms in [0.0, 400.0] {
let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(ms));
w.paint(&mut ctx, &mut NoopScene);
}
let mut app = App::default();
picker.cancel(&mut app);
assert_eq!(
app.dismissed, 0,
"Cancel fires nothing itself — the handle owns the dismissal"
);
let mut ctx = PaintCtx::for_test(Point::ORIGIN, window, ft_ms(500.0));
w.paint(&mut ctx, &mut NoopScene);
assert_eq!(app.dismissed, 0);
assert!(!w.is_exiting(), "there is nothing to stage on this mount");
for _ in 0..2 {
let state_any: &mut dyn Any = &mut app;
let mut ectx = EventCtx::new(state_any, Point::ORIGIN, window);
w.event(
&mut ectx,
&InputEvent::Pointer(PointerEvent {
phase: PointerPhase::Move,
position: Point::new(5.0, 5.0),
button: PointerButton::Primary,
}),
);
}
assert_eq!(app.dismissed, 1, "Cancel is not dead on a `Stack` mount");
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
struct NoopScene;
impl PaintScene for NoopScene {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
}
#[test]
fn the_modal_content_contract_reports_the_dismissable_flag() {
assert!(OverlayModalContent::modal_dismissable(&dialog(seeded())));
assert!(!OverlayModalContent::modal_dismissable(
&dialog(seeded()).dismissable(false)
));
}
#[test]
fn the_installed_dismiss_callback_replaces_the_builders() {
let installed = OverlayModalContent::on_modal_dismiss(
dialog(seeded()),
Rc::new(|app: &mut App| app.dismissed += 100),
);
let mut app = App::default();
installed.cancel(&mut app);
assert_eq!(app.dismissed, 100);
}
#[test]
fn the_mode_affordance_exists_only_for_an_unlocked_entry_mode() {
assert!(dialog(seeded()).toggle_view().is_some());
assert!(
dialog(seeded().with_entry_mode(DatePickerEntryMode::Input))
.toggle_view()
.is_some()
);
assert!(
dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly))
.toggle_view()
.is_none()
);
assert!(
dialog(seeded().with_entry_mode(DatePickerEntryMode::InputOnly))
.toggle_view()
.is_none()
);
}
#[test]
fn the_panel_width_follows_the_entry_mode() {
assert_eq!(
dialog(seeded()).panel_width(),
DIALOG_PORTRAIT_CALENDAR_WIDTH
);
assert_eq!(
dialog(seeded().with_entry_mode(DatePickerEntryMode::Input)).panel_width(),
DIALOG_PORTRAIT_INPUT_WIDTH
);
}
#[test]
fn the_headline_shows_the_selection_or_the_placeholder() {
assert_eq!(dialog(seeded()).headline(), "Thu, Aug 20");
assert_eq!(
dialog(DatePickerState::new(None, d(2026, 8, 20))).headline(),
DatePickerStrings::ENGLISH.no_date_selected
);
}
#[test]
fn the_panel_builds_and_reconciles_across_an_entry_mode_swap() {
let mut counter = 0u64;
let calendar = dialog(seeded());
let mut element = View::<App>::build(&calendar, &mut BuildCtx::new(&mut counter));
let input = dialog(seeded().with_entry_mode(DatePickerEntryMode::Input));
let flags = View::<App>::rebuild(
&input,
&calendar,
&mut element,
&mut BuildCtx::new(&mut counter),
);
assert!(!flags.is_empty(), "an entry-mode swap dirties the panel");
let locked = dialog(seeded().with_entry_mode(DatePickerEntryMode::CalendarOnly));
View::<App>::rebuild(
&locked,
&input,
&mut element,
&mut BuildCtx::new(&mut counter),
);
View::<App>::teardown(&locked, &mut element, &mut BuildCtx::new(&mut counter));
}
fn header_panel() -> DatePickerPanel<()> {
DatePickerPanel {
help: DatePickerStrings::ENGLISH.help_text.to_string(),
headline: "Thu, Aug 20".to_string(),
toggle: None,
body: leaf_any(10.0, 10.0),
body_inset: 0.0,
cancel: leaf_any(10.0, 10.0),
confirm: leaf_any(10.0, 10.0),
}
}
fn build_header_panel() -> DatePickerPanelWidget {
let mut counter = 0u64;
View::<()>::build(&header_panel(), &mut BuildCtx::new(&mut counter))
}
fn theme_with_header(help: FontFamily, headline: FontFamily) -> Theme {
let mut theme = crate::baseline();
theme.type_scale.label_large.family = help;
theme.type_scale.headline_large.family = headline;
theme
}
fn layout_header(w: &mut DatePickerPanelWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
let mut ctx =
LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
w.layout(
&mut ctx,
&BoxConstraints::tight(Size::new(DIALOG_PORTRAIT_CALENDAR_WIDTH, 600.0)),
);
}
#[test]
fn layout_takes_the_header_families_from_their_roles() {
let mut w = build_header_panel();
let mut tcx = TextContext::new();
let (help, headline) = (
FontFamily::named("Help Role Probe"),
FontFamily::named("Headline Role Probe"),
);
layout_header(
&mut w,
&mut tcx,
Some(&theme_with_header(help.clone(), headline.clone())),
);
assert_eq!(w.help.style().family, help, "the help line is labelLarge");
assert_eq!(
w.headline.style().family,
headline,
"the headline is headlineLarge"
);
assert_eq!(w.help.style().size, HELP_TEXT_SIZE);
assert_eq!(w.headline.style().size, HEADLINE_TEXT_SIZE);
}
#[test]
fn without_a_theme_the_header_keeps_the_unthemed_styles() {
let mut w = build_header_panel();
let mut tcx = TextContext::new();
layout_header(&mut w, &mut tcx, None);
assert_eq!(
w.help.style(),
TextStyle {
weight: FontWeight::MEDIUM,
..TextStyle::new(HELP_TEXT_SIZE, SHAPING_INK)
}
);
assert_eq!(
w.headline.style(),
TextStyle {
weight: FontWeight::REGULAR,
..TextStyle::new(HEADLINE_TEXT_SIZE, SHAPING_INK)
}
);
}
#[test]
fn a_theme_swap_reshapes_the_cached_header_runs() {
let mut w = build_header_panel();
let mut tcx = TextContext::new();
let first = theme_with_header(
FontFamily::named("Header Swap Probe A"),
FontFamily::named("Header Swap Probe A"),
);
layout_header(&mut w, &mut tcx, Some(&first));
let settled = tcx.shape_cache_stats();
layout_header(&mut w, &mut tcx, Some(&first));
assert_eq!(
tcx.shape_cache_stats(),
settled,
"an unchanged theme reshapes nothing"
);
let second = theme_with_header(
FontFamily::named("Header Swap Probe B"),
FontFamily::named("Header Swap Probe B"),
);
layout_header(&mut w, &mut tcx, Some(&second));
assert_eq!(
tcx.shape_cache_stats().shapes,
settled.shapes + 2,
"a family swap must reshape both header runs, not serve the old face"
);
}
#[cfg(feature = "bundled-fonts")]
fn shaped_font(run: &HeaderRun) -> Vec<u8> {
let layout = run.layout.as_ref().expect("the run was shaped");
let runs = layout.to_scene_runs(Point::ZERO);
let first = runs
.first()
.expect("the run shaped to at least one glyph run");
first.font.font().data.as_ref().to_vec()
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn a_theme_swap_between_registered_families_changes_the_header_face() {
let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
let mut tcx = TextContext::new();
for bytes in [flex, mono] {
tcx.register_fonts(bytes.to_vec())
.expect("the bundled faces must register");
}
let flex_family = || FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY);
let mono_family = || FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY);
let mut w = build_header_panel();
layout_header(
&mut w,
&mut tcx,
Some(&theme_with_header(flex_family(), flex_family())),
);
assert!(
shaped_font(&w.help) == flex,
"labelLarge shapes the help line"
);
assert!(
shaped_font(&w.headline) == flex,
"headlineLarge shapes the headline"
);
layout_header(
&mut w,
&mut tcx,
Some(&theme_with_header(mono_family(), mono_family())),
);
assert!(
shaped_font(&w.help) == mono,
"after a theme swap the help line must shape in the new family"
);
assert!(
shaped_font(&w.headline) == mono,
"after a theme swap the headline must shape in the new family"
);
}
#[test]
fn a_calendar_sub_view_change_reconciles_in_place() {
let mut counter = 0u64;
let day = dialog(seeded());
let mut element = View::<App>::build(&day, &mut BuildCtx::new(&mut counter));
let year = dialog(seeded().with_mode(DatePickerMode::Year));
let flags =
View::<App>::rebuild(&year, &day, &mut element, &mut BuildCtx::new(&mut counter));
assert!(!flags.is_empty());
}
}