use std::time::Duration;
use frust::authoring::TypedArgCallback;
pub mod calendar;
pub mod date;
pub mod dialog;
pub mod grid;
pub mod input;
pub use calendar::{CalendarDatePicker, CalendarDatePickerWidget, calendar_date_picker};
pub use date::{DateRange, MAX_YEAR, MIN_YEAR, MaterialDate, days_in_month, is_leap_year};
pub use dialog::{DatePickerDialog, date_picker_dialog, show_date_picker};
pub use grid::MonthGrid;
pub use input::{
DateInputError, DateInputField, DateInputFieldWidget, date_input_field, parse_bounded,
};
pub type OnDatePickerChange<State> = TypedArgCallback<State, DatePickerState>;
pub const DAYS_PER_WEEK: usize = 7;
pub const SUB_HEADER_HEIGHT: f64 = 52.0;
pub const WEEKDAY_ROW_HEIGHT: f64 = 24.0;
pub const DAY_ROW_HEIGHT: f64 = 48.0;
pub const DAY_GRID_TOP_PADDING: f64 = 4.0;
pub const MAX_DAY_PICKER_ROW_COUNT: usize = 6;
pub const MAX_DAY_PICKER_HEIGHT: f64 = DAY_ROW_HEIGHT * (MAX_DAY_PICKER_ROW_COUNT as f64 + 1.0);
pub const MONTH_NAV_BUTTONS_WIDTH: f64 = 108.0;
pub const SUB_HEADER_START_INSET: f64 = 16.0;
pub const YEAR_COLUMN_COUNT: usize = 3;
pub const YEAR_GRID_PADDING: f64 = 16.0;
pub const YEAR_ROW_HEIGHT: f64 = 52.0;
pub const YEAR_ROW_SPACING: f64 = 8.0;
pub const CALENDAR_WIDTH: f64 = 328.0;
pub const CALENDAR_PADDING: f64 = 12.0;
pub const DAY_SIZE: f64 = 40.0;
pub const ARROW_ICON_SIZE: f64 = 24.0;
pub const ARROW_PADDING: f64 = 8.0;
pub const HEADER_PORTRAIT_HEIGHT: f64 = 120.0;
pub const ACTIONS_MIN_HEIGHT: f64 = 52.0;
pub const MONTH_SCROLL_DURATION: Duration = Duration::from_millis(200);
pub const DIALOG_PORTRAIT_CALENDAR_WIDTH: f64 = 360.0;
pub const DIALOG_PORTRAIT_INPUT_WIDTH: f64 = 328.0;
pub const DISABLED_DAY_OPACITY: f32 = 0.38;
pub const RANGE_HIGHLIGHT_ALPHA: f32 = 0.12;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DatePickerMode {
#[default]
Day,
Year,
}
impl DatePickerMode {
pub fn toggled(self) -> Self {
match self {
DatePickerMode::Day => DatePickerMode::Year,
DatePickerMode::Year => DatePickerMode::Day,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DatePickerEntryMode {
#[default]
Calendar,
Input,
CalendarOnly,
InputOnly,
}
impl DatePickerEntryMode {
pub fn is_input(self) -> bool {
matches!(
self,
DatePickerEntryMode::Input | DatePickerEntryMode::InputOnly
)
}
pub fn is_toggleable(self) -> bool {
matches!(
self,
DatePickerEntryMode::Calendar | DatePickerEntryMode::Input
)
}
pub fn toggled(self) -> Self {
match self {
DatePickerEntryMode::Calendar => DatePickerEntryMode::Input,
DatePickerEntryMode::Input => DatePickerEntryMode::Calendar,
other => other,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DatePickerStrings {
pub months: [&'static str; 12],
pub month_abbreviations: [&'static str; 12],
pub weekday_initials: [&'static str; 7],
pub weekday_abbreviations: [&'static str; 7],
pub first_day_of_week: u32,
pub help_text: &'static str,
pub no_date_selected: &'static str,
pub cancel_label: &'static str,
pub confirm_label: &'static str,
pub field_label: &'static str,
pub field_hint: &'static str,
pub error_format: &'static str,
pub error_invalid: &'static str,
pub previous_month: &'static str,
pub next_month: &'static str,
pub select_year: &'static str,
pub input_mode: &'static str,
pub calendar_mode: &'static str,
}
impl DatePickerStrings {
pub const ENGLISH: Self = Self {
months: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
month_abbreviations: [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
],
weekday_initials: ["S", "M", "T", "W", "T", "F", "S"],
weekday_abbreviations: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
first_day_of_week: 0,
help_text: "Select date",
no_date_selected: "Date",
cancel_label: "Cancel",
confirm_label: "OK",
field_label: "Enter Date",
field_hint: "mm/dd/yyyy",
error_format: "Invalid format.",
error_invalid: "Out of range.",
previous_month: "Previous month",
next_month: "Next month",
select_year: "Select year",
input_mode: "Switch to text input mode",
calendar_mode: "Switch to calendar mode",
};
pub fn weekday_initial(&self, column: usize) -> &'static str {
self.weekday_initials[(self.first_day_of_week as usize + column) % DAYS_PER_WEEK]
}
pub fn month_year(&self, date: MaterialDate) -> String {
format!(
"{} {}",
self.months[(date.month() - 1) as usize],
date.year()
)
}
pub fn year(&self, year: i32) -> String {
year.to_string()
}
pub fn medium_date(&self, date: MaterialDate) -> String {
format!(
"{}, {} {}",
self.weekday_abbreviations[date.weekday_index() as usize],
self.month_abbreviations[(date.month() - 1) as usize],
date.day()
)
}
}
impl Default for DatePickerStrings {
fn default() -> Self {
Self::ENGLISH
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DatePickerState {
pub selected: Option<MaterialDate>,
pub displayed_month: MaterialDate,
pub mode: DatePickerMode,
pub entry_mode: DatePickerEntryMode,
pub input_text: String,
}
impl DatePickerState {
pub fn new(initial: Option<MaterialDate>, today: MaterialDate) -> Self {
let base = initial.unwrap_or(today);
Self {
selected: initial,
displayed_month: base.first_of_month(),
mode: DatePickerMode::Day,
entry_mode: DatePickerEntryMode::Calendar,
input_text: initial
.map(MaterialDate::format_compact)
.unwrap_or_default(),
}
}
pub fn with_selected(&self, date: MaterialDate) -> Self {
Self {
selected: Some(date),
displayed_month: date.first_of_month(),
input_text: date.format_compact(),
..self.clone()
}
}
pub fn with_displayed_month(&self, month: MaterialDate) -> Self {
Self {
displayed_month: month.first_of_month(),
..self.clone()
}
}
pub fn stepped_month(&self, delta: i32, first: MaterialDate, last: MaterialDate) -> Self {
let low = first.first_of_month();
let high = last.first_of_month();
let next = self.displayed_month.add_months(delta).clamp_to(low, high);
self.with_displayed_month(next)
}
pub fn can_step_month(&self, delta: i32, first: MaterialDate, last: MaterialDate) -> bool {
self.stepped_month(delta, first, last).displayed_month != self.displayed_month
}
pub fn with_mode(&self, mode: DatePickerMode) -> Self {
Self {
mode,
..self.clone()
}
}
pub fn toggled_mode(&self) -> Self {
self.with_mode(self.mode.toggled())
}
pub fn with_entry_mode(&self, entry_mode: DatePickerEntryMode) -> Self {
Self {
entry_mode,
..self.clone()
}
}
pub fn toggled_entry_mode(&self) -> Self {
self.with_entry_mode(self.entry_mode.toggled())
}
pub fn with_input_text(&self, text: impl Into<String>) -> Self {
Self {
input_text: text.into(),
..self.clone()
}
}
pub fn year_candidate(
&self,
year: i32,
first: MaterialDate,
last: MaterialDate,
) -> MaterialDate {
let base = self.selected.unwrap_or(self.displayed_month);
MaterialDate::new(year, base.month(), base.day()).clamp_to(first, last)
}
pub fn with_year(&self, year: i32, first: MaterialDate, last: MaterialDate) -> Self {
let candidate = self.year_candidate(year, first, last);
self.with_selected(candidate).with_mode(DatePickerMode::Day)
}
}
#[cfg(test)]
mod tests {
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);
#[test]
fn the_ported_metrics_match_the_reference_constants() {
assert_eq!(DAYS_PER_WEEK, 7);
assert_eq!(SUB_HEADER_HEIGHT, 52.0);
assert_eq!(WEEKDAY_ROW_HEIGHT, 24.0);
assert_eq!(DAY_ROW_HEIGHT, 48.0);
assert_eq!(DAY_GRID_TOP_PADDING, 4.0);
assert_eq!(MAX_DAY_PICKER_ROW_COUNT, 6);
assert_eq!(MAX_DAY_PICKER_HEIGHT, 336.0);
assert_eq!(MONTH_NAV_BUTTONS_WIDTH, 108.0);
assert_eq!(YEAR_COLUMN_COUNT, 3);
assert_eq!(YEAR_GRID_PADDING, 16.0);
assert_eq!(YEAR_ROW_HEIGHT, 52.0);
assert_eq!(YEAR_ROW_SPACING, 8.0);
assert_eq!(CALENDAR_WIDTH, 328.0);
assert_eq!(CALENDAR_PADDING, 12.0);
assert_eq!(DAY_SIZE, 40.0);
assert_eq!(ARROW_ICON_SIZE, 24.0);
assert_eq!(ARROW_PADDING, 8.0);
assert_eq!(HEADER_PORTRAIT_HEIGHT, 120.0);
assert_eq!(ACTIONS_MIN_HEIGHT, 52.0);
assert_eq!(MONTH_SCROLL_DURATION, Duration::from_millis(200));
assert_eq!(DIALOG_PORTRAIT_CALENDAR_WIDTH, 360.0);
assert_eq!(DIALOG_PORTRAIT_INPUT_WIDTH, 328.0);
assert_eq!(DISABLED_DAY_OPACITY, 0.38);
assert_eq!(RANGE_HIGHLIGHT_ALPHA, 0.12);
}
#[test]
fn the_calendar_sub_view_toggles_both_ways() {
assert_eq!(DatePickerMode::default(), DatePickerMode::Day);
assert_eq!(DatePickerMode::Day.toggled(), DatePickerMode::Year);
assert_eq!(DatePickerMode::Year.toggled(), DatePickerMode::Day);
}
#[test]
fn only_the_unlocked_entry_modes_toggle() {
assert_eq!(
DatePickerEntryMode::default(),
DatePickerEntryMode::Calendar
);
assert_eq!(
DatePickerEntryMode::Calendar.toggled(),
DatePickerEntryMode::Input
);
assert_eq!(
DatePickerEntryMode::Input.toggled(),
DatePickerEntryMode::Calendar
);
assert_eq!(
DatePickerEntryMode::CalendarOnly.toggled(),
DatePickerEntryMode::CalendarOnly
);
assert_eq!(
DatePickerEntryMode::InputOnly.toggled(),
DatePickerEntryMode::InputOnly
);
assert!(!DatePickerEntryMode::Calendar.is_input());
assert!(DatePickerEntryMode::Input.is_input());
assert!(!DatePickerEntryMode::CalendarOnly.is_input());
assert!(DatePickerEntryMode::InputOnly.is_input());
assert!(DatePickerEntryMode::Calendar.is_toggleable());
assert!(DatePickerEntryMode::Input.is_toggleable());
assert!(!DatePickerEntryMode::CalendarOnly.is_toggleable());
assert!(!DatePickerEntryMode::InputOnly.is_toggleable());
}
#[test]
fn the_weekday_header_rotates_with_the_first_day_of_week() {
let sunday = DatePickerStrings::ENGLISH;
let columns: Vec<&str> = (0..7).map(|c| sunday.weekday_initial(c)).collect();
assert_eq!(columns, ["S", "M", "T", "W", "T", "F", "S"]);
let monday = DatePickerStrings {
first_day_of_week: 1,
..DatePickerStrings::ENGLISH
};
let columns: Vec<&str> = (0..7).map(|c| monday.weekday_initial(c)).collect();
assert_eq!(columns, ["M", "T", "W", "T", "F", "S", "S"]);
}
#[test]
fn the_label_formats_match_the_en_us_patterns() {
let s = DatePickerStrings::ENGLISH;
assert_eq!(s.month_year(d(2026, 3, 1)), "March 2026");
assert_eq!(s.month_year(d(2026, 12, 31)), "December 2026");
assert_eq!(s.year(2026), "2026");
assert_eq!(s.medium_date(d(2026, 8, 20)), "Thu, Aug 20");
assert_eq!(s.medium_date(d(2026, 1, 1)), "Thu, Jan 1");
}
#[test]
fn an_override_keeps_every_untranslated_field() {
let french = DatePickerStrings {
months: [
"janvier",
"février",
"mars",
"avril",
"mai",
"juin",
"juillet",
"août",
"septembre",
"octobre",
"novembre",
"décembre",
],
first_day_of_week: 1,
cancel_label: "Annuler",
..DatePickerStrings::ENGLISH
};
assert_eq!(french.month_year(d(2026, 3, 1)), "mars 2026");
assert_eq!(french.cancel_label, "Annuler");
assert_eq!(
french.confirm_label,
DatePickerStrings::ENGLISH.confirm_label
);
assert_eq!(french.weekday_initial(0), "M");
}
#[test]
fn a_fresh_state_shows_the_selected_month_or_today() {
let today = d(2026, 8, 20);
let empty = DatePickerState::new(None, today);
assert_eq!(empty.selected, None);
assert_eq!(empty.displayed_month, d(2026, 8, 1));
assert_eq!(empty.mode, DatePickerMode::Day);
assert_eq!(empty.entry_mode, DatePickerEntryMode::Calendar);
assert_eq!(empty.input_text, "");
let seeded = DatePickerState::new(Some(d(2020, 2, 29)), today);
assert_eq!(seeded.selected, Some(d(2020, 2, 29)));
assert_eq!(seeded.displayed_month, d(2020, 2, 1));
assert_eq!(seeded.input_text, "02/29/2020");
}
#[test]
fn selecting_records_the_date_moves_the_month_and_refreshes_the_text() {
let state = DatePickerState::new(None, d(2026, 8, 20));
let next = state.with_selected(d(2027, 3, 5));
assert_eq!(next.selected, Some(d(2027, 3, 5)));
assert_eq!(next.displayed_month, d(2027, 3, 1));
assert_eq!(next.input_text, "03/05/2027");
assert_eq!(state.selected, None);
}
#[test]
fn month_paging_clamps_at_both_bounds() {
let state = DatePickerState::new(Some(d(2020, 1, 15)), d(2026, 8, 20));
assert_eq!(state.displayed_month, d(2020, 1, 1));
assert!(!state.can_step_month(-1, FIRST(), LAST()));
assert_eq!(
state.stepped_month(-1, FIRST(), LAST()).displayed_month,
d(2020, 1, 1)
);
assert!(state.can_step_month(1, FIRST(), LAST()));
let next = state.stepped_month(1, FIRST(), LAST());
assert_eq!(next.displayed_month, d(2020, 2, 1));
assert_eq!(next.selected, state.selected);
let end = state.stepped_month(10_000, FIRST(), LAST());
assert_eq!(end.displayed_month, d(2030, 12, 1));
assert!(!end.can_step_month(1, FIRST(), LAST()));
}
#[test]
fn the_sub_view_toggle_round_trips() {
let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
let year = state.toggled_mode();
assert_eq!(year.mode, DatePickerMode::Year);
assert_eq!(year.toggled_mode().mode, DatePickerMode::Day);
assert_eq!(year.selected, state.selected, "the toggle keeps the value");
}
#[test]
fn the_entry_mode_toggle_round_trips_the_value_and_its_text() {
let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
assert_eq!(state.input_text, "08/20/2026");
let input = state.toggled_entry_mode();
assert_eq!(input.entry_mode, DatePickerEntryMode::Input);
assert_eq!(input.selected, state.selected);
assert_eq!(input.input_text, state.input_text);
let back = input.toggled_entry_mode();
assert_eq!(back.entry_mode, DatePickerEntryMode::Calendar);
assert_eq!(back.selected, state.selected);
assert_eq!(back.input_text, state.input_text);
assert_eq!(back, state, "a full round trip is the identity");
}
#[test]
fn a_locked_entry_mode_cannot_be_toggled_out_of() {
let locked = DatePickerState::new(None, d(2026, 8, 20))
.with_entry_mode(DatePickerEntryMode::InputOnly);
assert_eq!(
locked.toggled_entry_mode().entry_mode,
DatePickerEntryMode::InputOnly
);
}
#[test]
fn typing_replaces_only_the_draft_text() {
let state = DatePickerState::new(Some(d(2026, 8, 20)), d(2026, 8, 20));
let typing = state.with_input_text("08/2");
assert_eq!(typing.input_text, "08/2");
assert_eq!(typing.selected, state.selected, "typing never picks a date");
assert_eq!(typing.displayed_month, state.displayed_month);
}
#[test]
fn a_year_cell_carries_the_selection_forward_and_returns_to_the_day_grid() {
let state = DatePickerState::new(Some(d(2026, 3, 31)), d(2026, 8, 20))
.with_mode(DatePickerMode::Year);
let next = state.with_year(2028, FIRST(), LAST());
assert_eq!(next.mode, DatePickerMode::Day);
assert_eq!(next.selected, Some(d(2028, 3, 31)));
assert_eq!(next.displayed_month, d(2028, 3, 1));
assert_eq!(next.input_text, "03/31/2028");
}
#[test]
fn a_year_candidate_shortens_a_too_long_day_and_clamps_to_the_bounds() {
let leap = DatePickerState::new(Some(d(2024, 2, 29)), d(2026, 8, 20));
assert_eq!(leap.year_candidate(2025, FIRST(), LAST()), d(2025, 2, 28));
assert_eq!(leap.year_candidate(2028, FIRST(), LAST()), d(2028, 2, 29));
let narrow_first = d(2026, 6, 1);
let narrow_last = d(2026, 6, 30);
let state = DatePickerState::new(Some(d(2026, 6, 15)), d(2026, 6, 15));
assert_eq!(
state.year_candidate(2020, narrow_first, narrow_last),
narrow_first
);
assert_eq!(
state.year_candidate(2030, narrow_first, narrow_last),
narrow_last
);
}
#[test]
fn a_year_candidate_falls_back_to_the_displayed_month_with_no_selection() {
let state = DatePickerState::new(None, d(2026, 8, 20));
assert_eq!(state.displayed_month, d(2026, 8, 1));
assert_eq!(state.year_candidate(2029, FIRST(), LAST()), d(2029, 8, 1));
}
}