1use std::rc::Rc;
44
45use frust::authoring::{BuildCtx, ChangeFlags, View};
46
47use super::calendar::SelectableDay;
48use super::date::MaterialDate;
49use super::{DatePickerState, DatePickerStrings, OnDatePickerChange};
50use crate::text_field::{TextFieldView, TextFieldWidget, text_field};
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum DateInputError {
56 Format,
58 OutOfRange,
61}
62
63pub fn parse_bounded(
79 text: &str,
80 first: MaterialDate,
81 last: MaterialDate,
82) -> Result<MaterialDate, DateInputError> {
83 let date = MaterialDate::parse_compact(text).ok_or(DateInputError::Format)?;
84 if date.is_within(first, last) {
85 Ok(date)
86 } else {
87 Err(DateInputError::OutOfRange)
88 }
89}
90
91pub struct DateInputField<State: 'static> {
93 state: DatePickerState,
94 first_date: MaterialDate,
95 last_date: MaterialDate,
96 strings: DatePickerStrings,
97 selectable: Option<SelectableDay>,
98 validate: bool,
99 on_change: OnDatePickerChange<State>,
100}
101
102pub fn date_input_field<State: 'static, F>(
105 state: DatePickerState,
106 first_date: MaterialDate,
107 last_date: MaterialDate,
108 on_change: F,
109) -> DateInputField<State>
110where
111 F: Fn(&mut State, DatePickerState) + 'static,
112{
113 let (first_date, last_date) = if last_date < first_date {
114 (last_date, first_date)
115 } else {
116 (first_date, last_date)
117 };
118 DateInputField {
119 state,
120 first_date,
121 last_date,
122 strings: DatePickerStrings::ENGLISH,
123 selectable: None,
124 validate: true,
125 on_change: Rc::new(on_change),
126 }
127}
128
129impl<State: 'static> DateInputField<State> {
130 pub fn strings(mut self, strings: DatePickerStrings) -> Self {
132 self.strings = strings;
133 self
134 }
135
136 pub fn selectable<F: Fn(MaterialDate) -> bool + 'static>(mut self, predicate: F) -> Self {
139 self.selectable = Some(Rc::new(predicate));
140 self
141 }
142
143 pub fn validate(mut self, validate: bool) -> Self {
147 self.validate = validate;
148 self
149 }
150
151 pub fn parse(&self, text: &str) -> Result<MaterialDate, DateInputError> {
153 let date = parse_bounded(text, self.first_date, self.last_date)?;
154 if self.selectable.as_ref().is_none_or(|p| p(date)) {
155 Ok(date)
156 } else {
157 Err(DateInputError::OutOfRange)
158 }
159 }
160
161 pub fn error(&self) -> Option<&'static str> {
164 if !self.validate || self.state.input_text.trim().is_empty() {
165 return None;
166 }
167 match self.parse(&self.state.input_text) {
168 Ok(_) => None,
169 Err(DateInputError::Format) => Some(self.strings.error_format),
170 Err(DateInputError::OutOfRange) => Some(self.strings.error_invalid),
171 }
172 }
173
174 pub fn next_state(&self, text: &str) -> DatePickerState {
182 match self.parse(text) {
183 Ok(date) => self.state.with_selected(date).with_input_text(text),
184 Err(_) => self.state.with_input_text(text),
185 }
186 }
187
188 fn compose(&self) -> TextFieldView<State> {
191 let parsed = Parsed {
192 state: self.state.clone(),
193 first: self.first_date,
194 last: self.last_date,
195 selectable: self.selectable.clone(),
196 };
197 let on_change = self.on_change.clone();
198 let field = text_field(
199 self.state.input_text.clone(),
200 move |app: &mut State, text: String| on_change(app, parsed.next_state(&text)),
201 )
202 .label(self.strings.field_label);
203 match self.error() {
204 Some(message) => field.error_text(message),
205 None => field.supporting_text(self.strings.field_hint),
206 }
207 }
208}
209
210struct Parsed {
213 state: DatePickerState,
214 first: MaterialDate,
215 last: MaterialDate,
216 selectable: Option<SelectableDay>,
217}
218
219impl Parsed {
220 fn next_state(&self, text: &str) -> DatePickerState {
221 match parse_bounded(text, self.first, self.last) {
222 Ok(date) if self.selectable.as_ref().is_none_or(|p| p(date)) => {
223 self.state.with_selected(date).with_input_text(text)
224 }
225 _ => self.state.with_input_text(text),
226 }
227 }
228}
229
230impl<State: 'static> View<State> for DateInputField<State> {
231 type Element = TextFieldWidget;
232
233 fn build(&self, ctx: &mut BuildCtx<'_>) -> TextFieldWidget {
234 View::build(&self.compose(), ctx)
235 }
236
237 fn rebuild(
238 &self,
239 prev: &Self,
240 element: &mut TextFieldWidget,
241 ctx: &mut BuildCtx<'_>,
242 ) -> ChangeFlags {
243 View::rebuild(&self.compose(), &prev.compose(), element, ctx)
244 }
245
246 fn teardown(&self, element: &mut TextFieldWidget, ctx: &mut BuildCtx<'_>) {
247 View::teardown(&self.compose(), element, ctx);
248 }
249}
250
251pub type DateInputFieldWidget = TextFieldWidget;
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn d(year: i32, month: u32, day: u32) -> MaterialDate {
260 MaterialDate::new(year, month, day)
261 }
262
263 const FIRST: fn() -> MaterialDate = || MaterialDate::new(2020, 1, 1);
264 const LAST: fn() -> MaterialDate = || MaterialDate::new(2030, 12, 31);
265
266 #[derive(Default)]
267 struct App {
268 reported: Vec<DatePickerState>,
269 }
270
271 fn field(state: DatePickerState) -> DateInputField<App> {
272 date_input_field(state, FIRST(), LAST(), |app: &mut App, next| {
273 app.reported.push(next)
274 })
275 }
276
277 fn seeded() -> DatePickerState {
278 DatePickerState::new(None, d(2026, 8, 20))
279 }
280
281 #[test]
284 fn parsing_splits_format_errors_from_range_errors() {
285 let first = d(2026, 1, 1);
286 let last = d(2026, 12, 31);
287 assert_eq!(parse_bounded("06/15/2026", first, last), Ok(d(2026, 6, 15)));
288 assert_eq!(parse_bounded("6/5/2026", first, last), Ok(d(2026, 6, 5)));
289 assert_eq!(parse_bounded("01/01/2026", first, last), Ok(first));
291 assert_eq!(parse_bounded("12/31/2026", first, last), Ok(last));
292
293 assert_eq!(
294 parse_bounded("12/31/2025", first, last),
295 Err(DateInputError::OutOfRange)
296 );
297 assert_eq!(
298 parse_bounded("01/01/2027", first, last),
299 Err(DateInputError::OutOfRange)
300 );
301 for bad in ["", "08/2", "2026-06-15", "13/01/2026", "02/30/2026", "abc"] {
302 assert_eq!(
303 parse_bounded(bad, first, last),
304 Err(DateInputError::Format),
305 "{bad:?}"
306 );
307 }
308 }
309
310 #[test]
311 fn a_predicate_refusal_reads_as_out_of_range() {
312 let f = field(seeded()).selectable(|date| date.day() % 2 == 0);
313 assert_eq!(f.parse("06/16/2026"), Ok(d(2026, 6, 16)));
314 assert_eq!(f.parse("06/15/2026"), Err(DateInputError::OutOfRange));
315 }
316
317 #[test]
318 fn reversed_bounds_are_normalized_rather_than_asserted() {
319 let f = date_input_field(seeded(), LAST(), FIRST(), |app: &mut App, next| {
320 app.reported.push(next)
321 });
322 assert_eq!(f.parse("06/15/2026"), Ok(d(2026, 6, 15)));
323 }
324
325 #[test]
328 fn an_empty_field_never_errors() {
329 assert_eq!(field(seeded()).error(), None);
330 assert_eq!(field(seeded().with_input_text(" ")).error(), None);
331 }
332
333 #[test]
334 fn the_two_errors_map_onto_the_reference_strings() {
335 let strings = DatePickerStrings::ENGLISH;
336 assert_eq!(
337 field(seeded().with_input_text("08/2")).error(),
338 Some(strings.error_format)
339 );
340 assert_eq!(
341 field(seeded().with_input_text("08/20/2045")).error(),
342 Some(strings.error_invalid)
343 );
344 assert_eq!(field(seeded().with_input_text("08/20/2026")).error(), None);
345 }
346
347 #[test]
348 fn validation_off_shows_nothing_at_all() {
349 let f = field(seeded().with_input_text("nonsense")).validate(false);
350 assert_eq!(f.error(), None);
351 assert_eq!(f.parse("nonsense"), Err(DateInputError::Format));
353 }
354
355 #[test]
356 fn an_override_supplies_its_own_error_strings() {
357 let f = field(seeded().with_input_text("08/2")).strings(DatePickerStrings {
358 error_format: "Format invalide.",
359 ..DatePickerStrings::ENGLISH
360 });
361 assert_eq!(f.error(), Some("Format invalide."));
362 }
363
364 #[test]
367 fn a_valid_entry_reports_the_date_and_keeps_the_typed_text() {
368 let f = field(seeded());
369 let next = f.next_state("06/15/2026");
370 assert_eq!(next.selected, Some(d(2026, 6, 15)));
371 assert_eq!(next.displayed_month, d(2026, 6, 1));
372 assert_eq!(
373 next.input_text, "06/15/2026",
374 "the user's own keystrokes survive the reformat"
375 );
376 }
377
378 #[test]
379 fn an_unpadded_entry_keeps_its_own_text_rather_than_reformatting() {
380 let next = field(seeded()).next_state("6/5/2026");
381 assert_eq!(next.selected, Some(d(2026, 6, 5)));
382 assert_eq!(next.input_text, "6/5/2026");
383 }
384
385 #[test]
386 fn a_half_typed_entry_records_the_text_and_nothing_else() {
387 let start = seeded().with_selected(d(2026, 8, 20));
388 let next = field(start.clone()).next_state("08/2");
389 assert_eq!(next.input_text, "08/2");
390 assert_eq!(
391 next.selected, start.selected,
392 "an unparseable draft never clears the selection"
393 );
394 assert_eq!(next.displayed_month, start.displayed_month);
395 }
396
397 #[test]
398 fn an_out_of_range_entry_records_the_text_and_nothing_else() {
399 let next = field(seeded()).next_state("08/20/2045");
400 assert_eq!(next.input_text, "08/20/2045");
401 assert_eq!(next.selected, None);
402 }
403
404 #[test]
405 fn a_refused_entry_records_the_text_and_nothing_else() {
406 let f = field(seeded()).selectable(|date| date.day() % 2 == 0);
407 let next = f.next_state("06/15/2026");
408 assert_eq!(next.input_text, "06/15/2026");
409 assert_eq!(next.selected, None);
410 }
411
412 #[test]
415 fn the_parse_inputs_a_composed_closure_owns_report_the_same_state() {
416 let cases = ["06/15/2026", "6/5/2026", "08/2", "08/20/2045", ""];
420 for text in cases {
421 let view = field(seeded().with_selected(d(2026, 8, 20)));
422 let parsed = Parsed {
423 state: view.state.clone(),
424 first: view.first_date,
425 last: view.last_date,
426 selectable: view.selectable.clone(),
427 };
428 assert_eq!(parsed.next_state(text), view.next_state(text), "{text:?}");
429 }
430 }
431
432 #[test]
433 fn a_predicate_reaches_the_composed_closure_too() {
434 let view = field(seeded()).selectable(|date| date.day() % 2 == 0);
435 let parsed = Parsed {
436 state: view.state.clone(),
437 first: view.first_date,
438 last: view.last_date,
439 selectable: view.selectable.clone(),
440 };
441 assert_eq!(parsed.next_state("06/15/2026").selected, None);
442 assert_eq!(
443 parsed.next_state("06/16/2026").selected,
444 Some(d(2026, 6, 16))
445 );
446 }
447
448 #[test]
449 fn the_view_builds_its_wrapped_field() {
450 let mut counter = 0u64;
453 let view = field(seeded().with_input_text("08/2"));
454 let mut element = View::<App>::build(&view, &mut BuildCtx::new(&mut counter));
455 let next = field(seeded().with_input_text("08/20/2026"));
458 let flags =
459 View::<App>::rebuild(&next, &view, &mut element, &mut BuildCtx::new(&mut counter));
460 assert!(!flags.is_empty(), "a value change dirties the field");
461 }
462}