use crossterm::event::KeyCode;
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use time::{Date, Duration, Month};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DatePickerEvent {
None,
Cancelled,
Selected(String),
Cleared,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DatePickerHit {
Day(String),
PrevMonth,
NextMonth,
Submit,
}
#[derive(Debug, Clone)]
pub struct DatePicker {
view: Date,
selected: Date,
}
impl DatePicker {
pub fn new(value: &str) -> Self {
let selected = parse_date(value).unwrap_or_else(today_date);
Self {
view: first_of_month(selected),
selected,
}
}
pub fn selected(&self) -> String {
format_date(self.selected)
}
pub fn handle_key(&mut self, code: KeyCode) -> DatePickerEvent {
match code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
return DatePickerEvent::Cancelled;
}
KeyCode::Enter => return DatePickerEvent::Selected(self.selected()),
KeyCode::Delete | KeyCode::Char('c') | KeyCode::Char('C') => {
return DatePickerEvent::Cleared;
}
KeyCode::Left | KeyCode::Char('h') => self.shift_days(-1),
KeyCode::Right | KeyCode::Char('l') => self.shift_days(1),
KeyCode::Up | KeyCode::Char('k') => self.shift_days(-7),
KeyCode::Down | KeyCode::Char('j') => self.shift_days(7),
KeyCode::PageUp => self.shift_months(-1),
KeyCode::PageDown => self.shift_months(1),
KeyCode::Char('[') => self.shift_years(-1),
KeyCode::Char(']') => self.shift_years(1),
_ => {}
}
DatePickerEvent::None
}
fn shift_days(&mut self, days: i64) {
let delta = Duration::days(days);
let next = if days >= 0 {
self.selected.checked_add(delta)
} else {
self.selected.checked_sub(-delta)
};
if let Some(date) = next {
self.set_selected(date);
}
}
fn shift_months(&mut self, months: i32) {
let month0 = i32::from(u8::from(self.selected.month())) - 1 + months;
let year = self.selected.year() + month0.div_euclid(12);
let month = Month::try_from((month0.rem_euclid(12) + 1) as u8).expect("1..=12");
let day = self.selected.day().min(month.length(year));
if let Ok(date) = Date::from_calendar_date(year, month, day) {
self.set_selected(date);
}
}
fn shift_years(&mut self, years: i32) {
let year = self.selected.year() + years;
let month = self.selected.month();
let day = self.selected.day().min(month.length(year));
if let Ok(date) = Date::from_calendar_date(year, month, day) {
self.set_selected(date);
}
}
fn set_selected(&mut self, date: Date) {
self.selected = date;
self.view = first_of_month(date);
}
pub fn select_date(&mut self, date: Date) {
self.set_selected(date);
}
pub fn select_date_str(&mut self, value: &str) {
if let Some(date) = parse_date(value) {
self.set_selected(date);
}
}
pub fn shift_month(&mut self, months: i32) {
self.shift_months(months);
}
pub fn popup_rect(&self, area: Rect) -> Rect {
let width = 64.min(area.width);
let height = (u16::from(weeks_for(self.view)) + 6).min(area.height);
centered(area, width, height)
}
pub fn date_at(&self, area: Rect, x: u16, y: u16) -> Option<Date> {
let rect = self.popup_rect(area);
let inner_x = rect.x + 1;
let days_y = rect.y + 1 + 1 + 1; if y < days_y || x < inner_x {
return None;
}
let col = (x - inner_x) / 3;
if col > 6 {
return None;
}
let row = y - days_y;
if row >= u16::from(weeks_for(self.view)) {
return None;
}
let first = self.view.replace_day(1).ok()?;
let offset = Duration::days(i64::from(first.weekday().number_days_from_sunday()));
first
.checked_sub(offset)
.and_then(|s| s.checked_add(Duration::days(i64::from(row * 7 + col))))
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
.expect("built-in bindings are valid");
self.render_with_bindings(
f,
area,
&bindings,
crate::keybindings::InputContext::DatePicker,
);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &crate::keybindings::EffectiveBindings,
context: crate::keybindings::InputContext,
) {
let mut events = CalendarEventStore::default();
events.add(
self.selected,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
events.add(
today_date(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let monthly = Monthly::new(self.view, events)
.show_surrounding(Style::default().fg(Color::DarkGray))
.show_weekdays_header(Style::default().add_modifier(Modifier::ITALIC))
.show_month_header(Style::default().add_modifier(Modifier::BOLD))
.block(
Block::default()
.borders(Borders::ALL)
.title(" Pick a date ")
.style(Style::default().fg(Color::Cyan)),
);
let width = monthly.width().max(64).min(area.width);
let height = (monthly.height() + 2).min(area.height);
let rect = centered(area, width, height);
let calendar_area = Rect {
height: height.saturating_sub(2),
..rect
};
let hint_area = Rect {
y: rect.y + height.saturating_sub(1),
height: 1,
..rect
};
f.render_widget(Clear, rect);
f.render_widget(monthly, calendar_area);
let move_days = format!(
"{}/{} {}/{} move",
bindings.label(context, crate::keybindings::TuiAction::MoveLeft),
bindings.label(context, crate::keybindings::TuiAction::MoveRight),
bindings.label(context, crate::keybindings::TuiAction::MoveUp),
bindings.label(context, crate::keybindings::TuiAction::MoveDown),
);
let months = format!(
"{}/{} month",
bindings.label(context, crate::keybindings::TuiAction::PreviousMonth),
bindings.label(context, crate::keybindings::TuiAction::NextMonth),
);
let years = format!(
"{}/{} year",
bindings.label(context, crate::keybindings::TuiAction::PreviousYear),
bindings.label(context, crate::keybindings::TuiAction::NextYear),
);
f.render_widget(
Paragraph::new(Line::from(format!(
"{move_days} \u{b7} {months} \u{b7} {years} \u{b7} {} ok \u{b7} {} clear \u{b7} {} cancel",
bindings.label(context, crate::keybindings::TuiAction::Submit),
bindings.label(context, crate::keybindings::TuiAction::Reset),
bindings.label(context, crate::keybindings::TuiAction::Cancel),
)))
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray)),
hint_area,
);
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
fn first_of_month(date: Date) -> Date {
Date::from_calendar_date(date.year(), date.month(), 1).expect("day 1 is always valid")
}
fn weeks_for(date: Date) -> u8 {
let first = first_of_month(date);
let last = first
.replace_day(first.month().length(first.year()))
.expect("last day of month is always valid");
last.sunday_based_week()
.saturating_sub(first.sunday_based_week())
+ 1
}
fn parse_date(value: &str) -> Option<Date> {
if !crate::model::validate_date(value) {
return None;
}
let year: i32 = value.get(0..4)?.parse().ok()?;
let month: u8 = value.get(5..7)?.parse().ok()?;
let day: u8 = value.get(8..10)?.parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
pub fn is_complete_date(value: &str) -> bool {
crate::model::validate_date(value)
}
pub fn type_date(current: &str, c: char) -> Option<String> {
if c.is_ascii_digit() {
let base = if is_complete_date(current) {
""
} else {
current
};
let mut digits = digits_of(base);
if digits.len() >= 8 {
return Some(format_digits(&digits));
}
digits.push(c);
Some(format_digits(&digits))
} else if c == '-' {
let base = if is_complete_date(current) {
""
} else {
current
};
Some(format_digits(&digits_of(base)))
} else {
None
}
}
pub fn backspace_date(current: &str) -> String {
let mut digits = digits_of(current);
digits.pop();
format_digits(&digits)
}
fn digits_of(value: &str) -> String {
value.chars().filter(char::is_ascii_digit).collect()
}
fn format_digits(digits: &str) -> String {
let mut out = String::new();
for (i, ch) in digits.chars().take(8).enumerate() {
if i == 4 || i == 6 {
out.push('-');
}
out.push(ch);
}
out
}
fn today_date() -> Date {
parse_date(&crate::model::today()).unwrap_or_else(|| {
Date::from_calendar_date(1970, Month::January, 1).expect("epoch is valid")
})
}
pub fn format_date(date: Date) -> String {
format!(
"{:04}-{:02}-{:02}",
date.year(),
u8::from(date.month()),
date.day()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opens_on_valid_value() {
let picker = DatePicker::new("2026-03-15");
assert_eq!(picker.selected(), "2026-03-15");
assert_eq!(
picker.view,
first_of_month(parse_date("2026-03-15").unwrap())
);
}
#[test]
fn opens_on_today_for_invalid_value() {
let picker = DatePicker::new("not-a-date");
assert_eq!(picker.selected(), crate::model::today());
}
#[test]
fn enter_selects_highlighted_date() {
let mut picker = DatePicker::new("2026-03-15");
assert_eq!(
picker.handle_key(KeyCode::Enter),
DatePickerEvent::Selected("2026-03-15".into())
);
}
#[test]
fn esc_cancels_and_c_clears() {
let mut picker = DatePicker::new("2026-03-15");
assert_eq!(picker.handle_key(KeyCode::Esc), DatePickerEvent::Cancelled);
assert_eq!(
picker.handle_key(KeyCode::Char('c')),
DatePickerEvent::Cleared
);
}
#[test]
fn day_week_and_month_navigation() {
let mut picker = DatePicker::new("2026-03-15");
picker.handle_key(KeyCode::Right);
assert_eq!(picker.selected(), "2026-03-16");
picker.handle_key(KeyCode::Down);
assert_eq!(picker.selected(), "2026-03-23");
picker.handle_key(KeyCode::PageDown);
assert_eq!(picker.selected(), "2026-04-23");
picker.handle_key(KeyCode::PageUp);
assert_eq!(picker.selected(), "2026-03-23");
}
#[test]
fn month_shift_clamps_day() {
let mut picker = DatePicker::new("2026-01-31");
picker.handle_key(KeyCode::PageDown);
assert_eq!(picker.selected(), "2026-02-28");
}
#[test]
fn year_shift_clamps_leap_day() {
let mut picker = DatePicker::new("2024-02-29");
picker.handle_key(KeyCode::Char(']'));
assert_eq!(picker.selected(), "2025-02-28");
}
#[test]
fn mask_typing_inserts_separators() {
let mut value = String::new();
for c in "20260910".chars() {
value = type_date(&value, c).unwrap();
}
assert_eq!(value, "2026-09-10");
assert!(is_complete_date(&value));
}
#[test]
fn mask_typing_a_dash_is_a_noop() {
let value = type_date("2026", '-').unwrap();
assert_eq!(value, "2026");
}
#[test]
fn mask_rejects_letters_and_extra_digits() {
assert_eq!(type_date("2026", 'x'), None);
let full = type_date("2026-09-10", '5').unwrap();
assert_eq!(full, "5", "typing over a complete date replaces it");
}
#[test]
fn mask_backspace_removes_last_digit() {
assert_eq!(backspace_date("2026-09-1"), "2026-09");
assert_eq!(backspace_date("2026-09"), "2026-0");
assert_eq!(backspace_date("2026"), "202");
}
#[test]
fn render_smoke() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let picker = DatePicker::new("2026-03-15");
terminal.draw(|f| picker.render(f, f.area())).unwrap();
let buffer = terminal.backend().buffer().clone();
let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(text.contains("Pick a date"));
assert!(text.contains("2026"));
}
}