1use crossterm::event::KeyCode;
13use ratatui::Frame;
14use ratatui::layout::{Alignment, Rect};
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::Line;
17use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
18use ratatui::widgets::{Block, Borders, Clear, Paragraph};
19use time::{Date, Duration, Month};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum DatePickerEvent {
24 None,
26 Cancelled,
28 Selected(String),
30 Cleared,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum DatePickerHit {
39 Day(String),
41 PrevMonth,
43 NextMonth,
45 Submit,
47}
48
49#[derive(Debug, Clone)]
51pub struct DatePicker {
52 view: Date,
54 selected: Date,
56}
57
58impl DatePicker {
59 pub fn new(value: &str) -> Self {
62 let selected = parse_date(value).unwrap_or_else(today_date);
63 Self {
64 view: first_of_month(selected),
65 selected,
66 }
67 }
68
69 pub fn selected(&self) -> String {
71 format_date(self.selected)
72 }
73
74 pub fn handle_key(&mut self, code: KeyCode) -> DatePickerEvent {
76 match code {
77 KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
78 return DatePickerEvent::Cancelled;
79 }
80 KeyCode::Enter => return DatePickerEvent::Selected(self.selected()),
81 KeyCode::Delete | KeyCode::Char('c') | KeyCode::Char('C') => {
82 return DatePickerEvent::Cleared;
83 }
84 KeyCode::Left | KeyCode::Char('h') => self.shift_days(-1),
85 KeyCode::Right | KeyCode::Char('l') => self.shift_days(1),
86 KeyCode::Up | KeyCode::Char('k') => self.shift_days(-7),
87 KeyCode::Down | KeyCode::Char('j') => self.shift_days(7),
88 KeyCode::PageUp => self.shift_months(-1),
89 KeyCode::PageDown => self.shift_months(1),
90 KeyCode::Char('[') => self.shift_years(-1),
91 KeyCode::Char(']') => self.shift_years(1),
92 _ => {}
93 }
94 DatePickerEvent::None
95 }
96
97 fn shift_days(&mut self, days: i64) {
98 let delta = Duration::days(days);
99 let next = if days >= 0 {
100 self.selected.checked_add(delta)
101 } else {
102 self.selected.checked_sub(-delta)
103 };
104 if let Some(date) = next {
105 self.set_selected(date);
106 }
107 }
108
109 fn shift_months(&mut self, months: i32) {
110 let month0 = i32::from(u8::from(self.selected.month())) - 1 + months;
111 let year = self.selected.year() + month0.div_euclid(12);
112 let month = Month::try_from((month0.rem_euclid(12) + 1) as u8).expect("1..=12");
113 let day = self.selected.day().min(month.length(year));
114 if let Ok(date) = Date::from_calendar_date(year, month, day) {
115 self.set_selected(date);
116 }
117 }
118
119 fn shift_years(&mut self, years: i32) {
120 let year = self.selected.year() + years;
121 let month = self.selected.month();
122 let day = self.selected.day().min(month.length(year));
123 if let Ok(date) = Date::from_calendar_date(year, month, day) {
124 self.set_selected(date);
125 }
126 }
127
128 fn set_selected(&mut self, date: Date) {
129 self.selected = date;
130 self.view = first_of_month(date);
131 }
132
133 pub fn select_date(&mut self, date: Date) {
136 self.set_selected(date);
137 }
138
139 pub fn select_date_str(&mut self, value: &str) {
141 if let Some(date) = parse_date(value) {
142 self.set_selected(date);
143 }
144 }
145
146 pub fn shift_month(&mut self, months: i32) {
149 self.shift_months(months);
150 }
151
152 pub fn popup_rect(&self, area: Rect) -> Rect {
155 let width = 64.min(area.width);
156 let height = (u16::from(weeks_for(self.view)) + 6).min(area.height);
157 centered(area, width, height)
158 }
159
160 pub fn date_at(&self, area: Rect, x: u16, y: u16) -> Option<Date> {
165 let rect = self.popup_rect(area);
166 let inner_x = rect.x + 1;
167 let days_y = rect.y + 1 + 1 + 1; if y < days_y || x < inner_x {
169 return None;
170 }
171 let col = (x - inner_x) / 3;
172 if col > 6 {
173 return None;
174 }
175 let row = y - days_y;
176 if row >= u16::from(weeks_for(self.view)) {
177 return None;
178 }
179 let first = self.view.replace_day(1).ok()?;
180 let offset = Duration::days(i64::from(first.weekday().number_days_from_sunday()));
181 first
182 .checked_sub(offset)
183 .and_then(|s| s.checked_add(Duration::days(i64::from(row * 7 + col))))
184 }
185
186 pub fn render(&self, f: &mut Frame, area: Rect) {
188 let bindings =
189 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
190 .expect("built-in bindings are valid");
191 self.render_with_bindings(
192 f,
193 area,
194 &bindings,
195 crate::keybindings::InputContext::DatePicker,
196 );
197 }
198
199 pub fn render_with_bindings(
200 &self,
201 f: &mut Frame,
202 area: Rect,
203 bindings: &crate::keybindings::EffectiveBindings,
204 context: crate::keybindings::InputContext,
205 ) {
206 let mut events = CalendarEventStore::default();
207 events.add(
208 self.selected,
209 Style::default()
210 .fg(Color::Black)
211 .bg(Color::Cyan)
212 .add_modifier(Modifier::BOLD),
213 );
214 events.add(
215 today_date(),
216 Style::default()
217 .fg(Color::Yellow)
218 .add_modifier(Modifier::BOLD),
219 );
220
221 let monthly = Monthly::new(self.view, events)
222 .show_surrounding(Style::default().fg(Color::DarkGray))
223 .show_weekdays_header(Style::default().add_modifier(Modifier::ITALIC))
224 .show_month_header(Style::default().add_modifier(Modifier::BOLD))
225 .block(
226 Block::default()
227 .borders(Borders::ALL)
228 .title(" Pick a date ")
229 .style(Style::default().fg(Color::Cyan)),
230 );
231
232 let width = monthly.width().max(64).min(area.width);
233 let height = (monthly.height() + 2).min(area.height);
234 let rect = centered(area, width, height);
235 let calendar_area = Rect {
236 height: height.saturating_sub(2),
237 ..rect
238 };
239 let hint_area = Rect {
240 y: rect.y + height.saturating_sub(1),
241 height: 1,
242 ..rect
243 };
244
245 f.render_widget(Clear, rect);
246 f.render_widget(monthly, calendar_area);
247 let move_days = format!(
248 "{}/{} {}/{} move",
249 bindings.label(context, crate::keybindings::TuiAction::MoveLeft),
250 bindings.label(context, crate::keybindings::TuiAction::MoveRight),
251 bindings.label(context, crate::keybindings::TuiAction::MoveUp),
252 bindings.label(context, crate::keybindings::TuiAction::MoveDown),
253 );
254 let months = format!(
255 "{}/{} month",
256 bindings.label(context, crate::keybindings::TuiAction::PreviousMonth),
257 bindings.label(context, crate::keybindings::TuiAction::NextMonth),
258 );
259 let years = format!(
260 "{}/{} year",
261 bindings.label(context, crate::keybindings::TuiAction::PreviousYear),
262 bindings.label(context, crate::keybindings::TuiAction::NextYear),
263 );
264 f.render_widget(
265 Paragraph::new(Line::from(format!(
266 "{move_days} \u{b7} {months} \u{b7} {years} \u{b7} {} ok \u{b7} {} clear \u{b7} {} cancel",
267 bindings.label(context, crate::keybindings::TuiAction::Submit),
268 bindings.label(context, crate::keybindings::TuiAction::Reset),
269 bindings.label(context, crate::keybindings::TuiAction::Cancel),
270 )))
271 .alignment(Alignment::Center)
272 .style(Style::default().fg(Color::DarkGray)),
273 hint_area,
274 );
275 }
276}
277
278fn centered(area: Rect, width: u16, height: u16) -> Rect {
280 let width = width.min(area.width);
281 let height = height.min(area.height);
282 Rect::new(
283 area.x + (area.width - width) / 2,
284 area.y + (area.height - height) / 2,
285 width,
286 height,
287 )
288}
289
290fn first_of_month(date: Date) -> Date {
291 Date::from_calendar_date(date.year(), date.month(), 1).expect("day 1 is always valid")
292}
293
294fn weeks_for(date: Date) -> u8 {
297 let first = first_of_month(date);
298 let last = first
299 .replace_day(first.month().length(first.year()))
300 .expect("last day of month is always valid");
301 last.sunday_based_week()
302 .saturating_sub(first.sunday_based_week())
303 + 1
304}
305
306fn parse_date(value: &str) -> Option<Date> {
308 if !crate::model::validate_date(value) {
309 return None;
310 }
311 let year: i32 = value.get(0..4)?.parse().ok()?;
312 let month: u8 = value.get(5..7)?.parse().ok()?;
313 let day: u8 = value.get(8..10)?.parse().ok()?;
314 Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
315}
316
317pub fn is_complete_date(value: &str) -> bool {
319 crate::model::validate_date(value)
320}
321
322pub fn type_date(current: &str, c: char) -> Option<String> {
329 if c.is_ascii_digit() {
330 let base = if is_complete_date(current) {
331 ""
332 } else {
333 current
334 };
335 let mut digits = digits_of(base);
336 if digits.len() >= 8 {
337 return Some(format_digits(&digits));
338 }
339 digits.push(c);
340 Some(format_digits(&digits))
341 } else if c == '-' {
342 let base = if is_complete_date(current) {
343 ""
344 } else {
345 current
346 };
347 Some(format_digits(&digits_of(base)))
348 } else {
349 None
350 }
351}
352
353pub fn backspace_date(current: &str) -> String {
355 let mut digits = digits_of(current);
356 digits.pop();
357 format_digits(&digits)
358}
359
360fn digits_of(value: &str) -> String {
361 value.chars().filter(char::is_ascii_digit).collect()
362}
363
364fn format_digits(digits: &str) -> String {
365 let mut out = String::new();
366 for (i, ch) in digits.chars().take(8).enumerate() {
367 if i == 4 || i == 6 {
368 out.push('-');
369 }
370 out.push(ch);
371 }
372 out
373}
374
375fn today_date() -> Date {
376 parse_date(&crate::model::today()).unwrap_or_else(|| {
377 Date::from_calendar_date(1970, Month::January, 1).expect("epoch is valid")
378 })
379}
380
381pub fn format_date(date: Date) -> String {
382 format!(
383 "{:04}-{:02}-{:02}",
384 date.year(),
385 u8::from(date.month()),
386 date.day()
387 )
388}
389
390#[cfg(test)]
391mod tests {
392 #[allow(clippy::wildcard_imports)]
393 use super::*;
394
395 #[test]
396 fn opens_on_valid_value() {
397 let picker = DatePicker::new("2026-03-15");
398 assert_eq!(picker.selected(), "2026-03-15");
399 assert_eq!(
400 picker.view,
401 first_of_month(parse_date("2026-03-15").unwrap())
402 );
403 }
404
405 #[test]
406 fn opens_on_today_for_invalid_value() {
407 let picker = DatePicker::new("not-a-date");
408 assert_eq!(picker.selected(), crate::model::today());
409 }
410
411 #[test]
412 fn enter_selects_highlighted_date() {
413 let mut picker = DatePicker::new("2026-03-15");
414 assert_eq!(
415 picker.handle_key(KeyCode::Enter),
416 DatePickerEvent::Selected("2026-03-15".into())
417 );
418 }
419
420 #[test]
421 fn esc_cancels_and_c_clears() {
422 let mut picker = DatePicker::new("2026-03-15");
423 assert_eq!(picker.handle_key(KeyCode::Esc), DatePickerEvent::Cancelled);
424 assert_eq!(
425 picker.handle_key(KeyCode::Char('c')),
426 DatePickerEvent::Cleared
427 );
428 }
429
430 #[test]
431 fn day_week_and_month_navigation() {
432 let mut picker = DatePicker::new("2026-03-15");
433 picker.handle_key(KeyCode::Right);
434 assert_eq!(picker.selected(), "2026-03-16");
435 picker.handle_key(KeyCode::Down);
436 assert_eq!(picker.selected(), "2026-03-23");
437 picker.handle_key(KeyCode::PageDown);
438 assert_eq!(picker.selected(), "2026-04-23");
439 picker.handle_key(KeyCode::PageUp);
440 assert_eq!(picker.selected(), "2026-03-23");
441 }
442
443 #[test]
444 fn month_shift_clamps_day() {
445 let mut picker = DatePicker::new("2026-01-31");
446 picker.handle_key(KeyCode::PageDown);
447 assert_eq!(picker.selected(), "2026-02-28");
448 }
449
450 #[test]
451 fn year_shift_clamps_leap_day() {
452 let mut picker = DatePicker::new("2024-02-29");
453 picker.handle_key(KeyCode::Char(']'));
454 assert_eq!(picker.selected(), "2025-02-28");
455 }
456
457 #[test]
458 fn mask_typing_inserts_separators() {
459 let mut value = String::new();
460 for c in "20260910".chars() {
461 value = type_date(&value, c).unwrap();
462 }
463 assert_eq!(value, "2026-09-10");
464 assert!(is_complete_date(&value));
465 }
466
467 #[test]
468 fn mask_typing_a_dash_is_a_noop() {
469 let value = type_date("2026", '-').unwrap();
470 assert_eq!(value, "2026");
471 }
472
473 #[test]
474 fn mask_rejects_letters_and_extra_digits() {
475 assert_eq!(type_date("2026", 'x'), None);
476 let full = type_date("2026-09-10", '5').unwrap();
477 assert_eq!(full, "5", "typing over a complete date replaces it");
478 }
479
480 #[test]
481 fn mask_backspace_removes_last_digit() {
482 assert_eq!(backspace_date("2026-09-1"), "2026-09");
483 assert_eq!(backspace_date("2026-09"), "2026-0");
484 assert_eq!(backspace_date("2026"), "202");
485 }
486
487 #[test]
488 fn render_smoke() {
489 use ratatui::Terminal;
490 use ratatui::backend::TestBackend;
491
492 let backend = TestBackend::new(80, 24);
493 let mut terminal = Terminal::new(backend).unwrap();
494 let picker = DatePicker::new("2026-03-15");
495 terminal.draw(|f| picker.render(f, f.area())).unwrap();
496 let buffer = terminal.backend().buffer().clone();
497 let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
498 assert!(text.contains("Pick a date"));
499 assert!(text.contains("2026"));
500 }
501}