Skip to main content

ferroui_themes_material/
date_picker_state.rs

1// Copyright 2022-2026 The Android Open Source Project (reference algorithms).
2// Copyright 2026 FerroUI contributors (Rust adaptation).
3// SPDX-License-Identifier: Apache-2.0
4//! Canonical date selection and locale-ordered numeric input for Material date pickers.
5//! Reference: AndroidX DatePicker.kt, DateRangePicker.kt and DateInput.kt at
6//! e4d933c98df7baa7ea2b83eea6e086915961e0ff. Dates are calendar days, without a time zone.
7
8use ferroui_base::utilities::{DateTime, DateTimeFormatInfo, DayOfWeek};
9
10#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
11pub enum MaterialDatePickerMode {
12    #[default]
13    Picker,
14    Input,
15}
16ferroui_base::ferro_markup_enum!(MaterialDatePickerMode { Picker, Input },
17    { namespace: "FerroUI.Themes.Material" });
18
19#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
20pub enum MaterialDateSelectionMode {
21    #[default]
22    Single,
23    Range,
24}
25ferroui_base::ferro_markup_enum!(MaterialDateSelectionMode { Single, Range },
26    { namespace: "FerroUI.Themes.Material" });
27
28/// A restorable native selection snapshot. End dates are inclusive. An incomplete range
29/// has a start and no end; an invalid or reversed programmatic range clears both ends.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct MaterialDatePickerState {
32    pub selected_start: Option<DateTime>,
33    pub selected_end: Option<DateTime>,
34    pub displayed_month: DateTime,
35    pub mode: MaterialDatePickerMode,
36    pub selection_mode: MaterialDateSelectionMode,
37    pub first_year: i32,
38    pub last_year: i32,
39}
40impl Default for MaterialDatePickerState {
41    fn default() -> Self {
42        Self {
43            selected_start: None,
44            selected_end: None,
45            displayed_month: month_start(DateTime::today()),
46            mode: MaterialDatePickerMode::Picker,
47            selection_mode: MaterialDateSelectionMode::Single,
48            first_year: 1900,
49            last_year: 2100,
50        }
51    }
52}
53impl MaterialDatePickerState {
54    pub fn canonical_date(&self, date: Option<DateTime>) -> Option<DateTime> {
55        date.filter(|d| self.contains_year(d.year())).map(day_start)
56    }
57    pub fn contains_year(&self, year: i32) -> bool {
58        (self.first_year..=self.last_year).contains(&year)
59    }
60    pub fn set_selection(&mut self, start: Option<DateTime>, end: Option<DateTime>) {
61        let start = self.canonical_date(start);
62        let end = if self.selection_mode == MaterialDateSelectionMode::Range {
63            self.canonical_date(end)
64        } else {
65            None
66        };
67        if start.is_some() && (end.is_none() || start <= end) {
68            self.selected_start = start;
69            self.selected_end = end;
70        } else {
71            self.selected_start = None;
72            self.selected_end = None;
73        }
74    }
75    /// Call only after checking the date and year selection policy. Range selection does
76    /// not require every intervening day to be selectable, matching the native picker.
77    pub fn select_date(&mut self, date: DateTime) {
78        let Some(date) = self.canonical_date(Some(date)) else {
79            return;
80        };
81        if self.selection_mode == MaterialDateSelectionMode::Single {
82            self.set_selection(Some(date), None);
83        } else if self.selected_end.is_some()
84            || self.selected_start.is_none()
85            || self.selected_start.is_some_and(|start| date < start)
86        {
87            self.set_selection(Some(date), None);
88        } else {
89            self.set_selection(self.selected_start, Some(date));
90        }
91    }
92    pub fn set_displayed_month(&mut self, date: DateTime) {
93        if self.contains_year(date.year()) {
94            self.displayed_month = month_start(date);
95        }
96    }
97    pub fn set_mode(&mut self, mode: MaterialDatePickerMode) {
98        if let Some(date) = self.selected_start {
99            self.set_displayed_month(date);
100        }
101        self.mode = mode;
102    }
103    pub fn set_year_range(&mut self, first: i32, last: i32) {
104        self.first_year = first.clamp(1, 9999);
105        self.last_year = last.clamp(self.first_year, 9999);
106        self.set_selection(self.selected_start, self.selected_end);
107        if !self.contains_year(self.displayed_month.year()) {
108            self.displayed_month = DateTime::new(
109                self.displayed_month
110                    .year()
111                    .clamp(self.first_year, self.last_year),
112                self.displayed_month.month(),
113                1,
114            );
115        }
116    }
117    pub fn is_in_range(&self, date: DateTime) -> bool {
118        self.selected_start
119            .zip(self.selected_end)
120            .is_some_and(|(start, end)| start <= date && date <= end)
121    }
122    pub fn is_complete(&self) -> bool {
123        self.selected_start.is_some()
124            && (self.selection_mode == MaterialDateSelectionMode::Single
125                || self.selected_end.is_some())
126    }
127    pub fn month_index(&self, month: DateTime) -> i32 {
128        (month.year() - self.first_year) * 12 + month.month() - 1
129    }
130    pub fn month_count(&self) -> i32 {
131        (self.last_year - self.first_year + 1) * 12
132    }
133    pub fn month_at(&self, index: i32) -> DateTime {
134        let index = index.clamp(0, self.month_count() - 1);
135        DateTime::new(self.first_year + index / 12, index % 12 + 1, 1)
136    }
137}
138
139pub(crate) fn day_start(date: DateTime) -> DateTime {
140    DateTime::new(date.year(), date.month(), date.day())
141}
142pub(crate) fn month_start(date: DateTime) -> DateTime {
143    DateTime::new(date.year(), date.month(), 1)
144}
145pub(crate) fn month_leading_days(month: DateTime, first_day: DayOfWeek) -> i32 {
146    (month_start(month).day_of_week().to_i32() - first_day.to_i32() + 7) % 7
147}
148
149/// Native date input always uses two-digit month/day and four-digit year in the
150/// culture's order. Eight underlying digits are displayed with two delimiters.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct MaterialDateInputFormat {
153    order: [char; 3],
154    delimiter: char,
155}
156impl MaterialDateInputFormat {
157    pub fn for_culture(info: &DateTimeFormatInfo) -> Self {
158        let mut order = Vec::new();
159        let mut delimiter = None;
160        let mut quoted = false;
161        for c in info.short_date_pattern().chars() {
162            if c == '\'' || c == '"' {
163                quoted = !quoted;
164                continue;
165            }
166            if quoted {
167                continue;
168            }
169            let part = match c {
170                'M' => Some('M'),
171                'd' => Some('d'),
172                'y' => Some('y'),
173                _ => None,
174            };
175            if let Some(part) = part {
176                if !order.contains(&part) {
177                    order.push(part);
178                }
179            } else if matches!(c, '/' | '.' | '-') && delimiter.is_none() {
180                delimiter = Some(c);
181            }
182        }
183        Self {
184            order: order.try_into().unwrap_or(['M', 'd', 'y']),
185            delimiter: delimiter.unwrap_or('/'),
186        }
187    }
188    pub fn pattern(&self) -> String {
189        self.order
190            .iter()
191            .map(|c| match c {
192                'y' => "YYYY",
193                'M' => "MM",
194                _ => "DD",
195            })
196            .collect::<Vec<_>>()
197            .join(&self.delimiter.to_string())
198    }
199    pub fn digits(&self, date: DateTime) -> String {
200        self.order
201            .iter()
202            .map(|c| match c {
203                'y' => format!("{:04}", date.year()),
204                'M' => format!("{:02}", date.month()),
205                _ => format!("{:02}", date.day()),
206            })
207            .collect()
208    }
209    pub fn format_digits(&self, digits: &str) -> String {
210        let mut text = String::new();
211        let first = if self.order[0] == 'y' { 4 } else { 2 };
212        let second = first + if self.order[1] == 'y' { 4 } else { 2 };
213        for (index, digit) in digits.chars().take(8).enumerate() {
214            if index == first || index == second {
215                text.push(self.delimiter);
216            }
217            text.push(digit);
218        }
219        // OutputTransformation also inserts a delimiter immediately after a completed part.
220        if digits.len() == first || digits.len() == second {
221            text.push(self.delimiter);
222        }
223        text
224    }
225    pub fn extract_digits(&self, text: &str) -> Option<String> {
226        if text
227            .chars()
228            .any(|c| !c.is_ascii_digit() && c != self.delimiter)
229        {
230            return None;
231        }
232        let digits: String = text.chars().filter(char::is_ascii_digit).collect();
233        (digits.len() <= 8).then_some(digits)
234    }
235    pub fn parse_digits(&self, digits: &str) -> Option<DateTime> {
236        if digits.len() != 8 || !digits.bytes().all(|v| v.is_ascii_digit()) {
237            return None;
238        }
239        let mut index = 0;
240        let (mut year, mut month, mut day) = (0, 0, 0);
241        for part in self.order {
242            let length = if part == 'y' { 4 } else { 2 };
243            let value: i32 = digits[index..index + length].parse().ok()?;
244            match part {
245                'y' => year = value,
246                'M' => month = value,
247                _ => day = value,
248            }
249            index += length;
250        }
251        DateTime::try_new(year, month, day)
252    }
253}