use chrono::{Datelike, Duration, NaiveDate, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
#[derive(Debug, Clone)]
pub struct DatePicker {
selected: NaiveDate,
view_month: NaiveDate,
}
impl DatePicker {
pub fn new() -> Self {
let today = Utc::now().date_naive();
Self {
selected: today,
view_month: today,
}
}
pub fn with_date(date: NaiveDate) -> Self {
Self {
selected: date,
view_month: date,
}
}
pub fn selected(&self) -> NaiveDate {
self.selected
}
pub fn set_date(&mut self, date: NaiveDate) {
self.selected = date;
self.view_month = date;
}
pub fn handle_key(&mut self, key: KeyEvent) -> DatePickerAction {
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
self.selected -= Duration::days(1);
self.ensure_view_contains_selected();
DatePickerAction::None
}
KeyCode::Right | KeyCode::Char('l') => {
self.selected += Duration::days(1);
self.ensure_view_contains_selected();
DatePickerAction::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected -= Duration::days(7);
self.ensure_view_contains_selected();
DatePickerAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected += Duration::days(7);
self.ensure_view_contains_selected();
DatePickerAction::None
}
KeyCode::PageUp | KeyCode::Char('H') => {
self.prev_month();
DatePickerAction::None
}
KeyCode::PageDown | KeyCode::Char('L') => {
self.next_month();
DatePickerAction::None
}
KeyCode::Char('t') => {
self.selected = Utc::now().date_naive();
self.ensure_view_contains_selected();
DatePickerAction::None
}
KeyCode::Enter => DatePickerAction::Select,
KeyCode::Esc | KeyCode::Char('q') => DatePickerAction::Cancel,
_ => DatePickerAction::None,
}
}
fn prev_month(&mut self) {
let year = self.view_month.year();
let month = self.view_month.month();
self.view_month = if month == 1 {
NaiveDate::from_ymd_opt(year - 1, 12, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(year, month - 1, 1).unwrap()
};
self.selected = NaiveDate::from_ymd_opt(
self.view_month.year(),
self.view_month.month(),
self.selected.day().min(days_in_month(self.view_month)),
)
.unwrap();
}
fn next_month(&mut self) {
let year = self.view_month.year();
let month = self.view_month.month();
self.view_month = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
};
self.selected = NaiveDate::from_ymd_opt(
self.view_month.year(),
self.view_month.month(),
self.selected.day().min(days_in_month(self.view_month)),
)
.unwrap();
}
fn ensure_view_contains_selected(&mut self) {
if self.selected.year() != self.view_month.year()
|| self.selected.month() != self.view_month.month()
{
self.view_month = NaiveDate::from_ymd_opt(
self.selected.year(),
self.selected.month(),
1,
)
.unwrap();
}
}
pub fn render(&self, frame: &mut Frame) {
let area = frame.area();
let popup_width = 32;
let popup_height = 15;
let popup_area = centered_rect(popup_width, popup_height, area);
frame.render_widget(Clear, area);
let dim = Block::default().style(Style::default().bg(Color::Black));
frame.render_widget(dim, area);
let block = Block::default()
.title(" Select Date ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let chunks = Layout::vertical([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Min(6), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let month_name = month_name(self.view_month.month());
let header = format!(
"◀ {} {} ▶",
month_name,
self.view_month.year()
);
let header_para = Paragraph::new(header)
.style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center);
frame.render_widget(header_para, chunks[0]);
let day_names = " Mo Tu We Th Fr Sa Su";
let day_names_para = Paragraph::new(day_names)
.style(Style::default().fg(Color::DarkGray).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center);
frame.render_widget(day_names_para, chunks[1]);
let grid = self.build_calendar_grid();
let grid_para = Paragraph::new(grid).alignment(Alignment::Center);
frame.render_widget(grid_para, chunks[3]);
let help = "←↓↑→:nav PgUp/Dn:month t:today Enter:ok";
let help_para = Paragraph::new(help)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help_para, chunks[5]);
}
fn build_calendar_grid(&self) -> Vec<Line<'static>> {
let today = Utc::now().date_naive();
let first_of_month = NaiveDate::from_ymd_opt(
self.view_month.year(),
self.view_month.month(),
1,
)
.unwrap();
let days_in_month = days_in_month(first_of_month);
let start_weekday = first_of_month.weekday().num_days_from_monday() as usize;
let mut lines = Vec::new();
let mut current_line: Vec<Span> = Vec::new();
for _ in 0..start_weekday {
current_line.push(Span::raw(" "));
}
for day in 1..=days_in_month {
let date = NaiveDate::from_ymd_opt(
self.view_month.year(),
self.view_month.month(),
day,
)
.unwrap();
let is_selected = date == self.selected;
let is_today = date == today;
let is_weekend = date.weekday().num_days_from_monday() >= 5;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_today {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_weekend {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White)
};
current_line.push(Span::styled(format!(" {:2} ", day), style));
if current_line.len() == 7 {
lines.push(Line::from(current_line));
current_line = Vec::new();
}
}
if !current_line.is_empty() {
while current_line.len() < 7 {
current_line.push(Span::raw(" "));
}
lines.push(Line::from(current_line));
}
while lines.len() < 6 {
lines.push(Line::from(""));
}
lines
}
}
impl Default for DatePicker {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatePickerAction {
None,
Select,
Cancel,
}
fn days_in_month(date: NaiveDate) -> u32 {
let year = date.year();
let month = date.month();
if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
}
.signed_duration_since(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
.num_days() as u32
}
fn month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Unknown",
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyModifiers;
#[test]
fn test_new_picker() {
let picker = DatePicker::new();
assert_eq!(picker.selected(), Utc::now().date_naive());
}
#[test]
fn test_navigation_right() {
let mut picker = DatePicker::with_date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap());
picker.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(picker.selected(), NaiveDate::from_ymd_opt(2025, 1, 16).unwrap());
}
#[test]
fn test_navigation_down() {
let mut picker = DatePicker::with_date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap());
picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(picker.selected(), NaiveDate::from_ymd_opt(2025, 1, 22).unwrap());
}
#[test]
fn test_month_navigation() {
let mut picker = DatePicker::with_date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap());
picker.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
assert_eq!(picker.view_month.month(), 2);
}
#[test]
fn test_enter_selects() {
let mut picker = DatePicker::new();
let action = picker.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(action, DatePickerAction::Select);
}
#[test]
fn test_escape_cancels() {
let mut picker = DatePicker::new();
let action = picker.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(action, DatePickerAction::Cancel);
}
#[test]
fn test_days_in_month() {
assert_eq!(days_in_month(NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()), 31);
assert_eq!(days_in_month(NaiveDate::from_ymd_opt(2025, 2, 1).unwrap()), 28);
assert_eq!(days_in_month(NaiveDate::from_ymd_opt(2024, 2, 1).unwrap()), 29); assert_eq!(days_in_month(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()), 30);
}
}