use chrono::{Datelike, Local, NaiveDate, Timelike};
use ratatui::layout::Rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerFocus {
Calendar,
Hour,
Minute,
}
impl PickerFocus {
pub fn next(self) -> Self {
match self {
Self::Calendar => Self::Hour,
Self::Hour => Self::Minute,
Self::Minute => Self::Calendar,
}
}
pub fn prev(self) -> Self {
match self {
Self::Calendar => Self::Minute,
Self::Hour => Self::Calendar,
Self::Minute => Self::Hour,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PickerLayout {
pub frame: Rect,
pub days: Rect,
pub hour: Rect,
pub minute: Rect,
pub time_row: Rect,
}
pub struct DuePicker {
pub day: NaiveDate,
pub hour: u8,
pub minute: u8,
pub focus: PickerFocus,
entry: Vec<u8>,
pub layout: PickerLayout,
}
impl DuePicker {
pub fn new(current: &str) -> Self {
let now = Local::now();
let day = parse_day(current).unwrap_or_else(|| now.date_naive());
let (hour, minute) = match crate::due::sort_key(current) {
Some((_, _, _, h, m)) if current.contains(':') => (h as u8 % 24, m as u8 % 60),
_ => (now.hour() as u8, now.minute() as u8),
};
Self {
day,
hour,
minute,
focus: PickerFocus::Calendar,
entry: Vec::new(),
layout: PickerLayout::default(),
}
}
pub fn contains(&self, x: u16, y: u16) -> bool {
let r = self.layout.frame;
!r.is_empty() && r.contains(ratatui::layout::Position { x, y })
}
pub fn click(&mut self, x: u16, y: u16) -> bool {
let layout = self.layout;
if hit(layout.hour, x, y) {
self.set_focus(PickerFocus::Hour);
return true;
}
if hit(layout.minute, x, y) {
self.set_focus(PickerFocus::Minute);
return true;
}
if let Some(day) = date_at_click(self.day, layout.days, x, y) {
self.day = day;
self.set_focus(PickerFocus::Calendar);
return true;
}
if self.contains(x, y) {
self.set_focus(PickerFocus::Calendar);
return true;
}
false
}
pub fn scroll(&mut self, x: u16, y: u16, up: bool) -> bool {
let layout = self.layout;
let delta = if up { 1 } else { -1 };
if hit(layout.hour, x, y) || (hit(layout.time_row, x, y) && self.focus == PickerFocus::Hour)
{
self.set_focus(PickerFocus::Hour);
self.bump_hour(delta);
return true;
}
if hit(layout.minute, x, y)
|| (hit(layout.time_row, x, y) && self.focus == PickerFocus::Minute)
{
self.set_focus(PickerFocus::Minute);
self.bump_minute(delta * 5);
return true;
}
if self.contains(x, y) {
self.set_focus(PickerFocus::Calendar);
self.move_months(delta);
return true;
}
false
}
pub fn value(&self) -> String {
format!(
"{} {:02}:{:02}",
self.day.format("%Y-%m-%d"),
self.hour,
self.minute
)
}
pub fn focus_next(&mut self) {
self.set_focus(self.focus.next());
}
pub fn focus_prev(&mut self) {
self.set_focus(self.focus.prev());
}
fn set_focus(&mut self, focus: PickerFocus) {
if focus != self.focus {
self.entry.clear();
}
self.focus = focus;
}
pub fn move_days(&mut self, days: i64) {
self.entry.clear();
if let Some(day) = self.day.checked_add_signed(chrono::Duration::days(days)) {
self.day = day;
}
}
pub fn move_months(&mut self, months: i32) {
self.entry.clear();
let (mut year, mut month) = (self.day.year(), self.day.month() as i32 + months);
while month < 1 {
month += 12;
year -= 1;
}
while month > 12 {
month -= 12;
year += 1;
}
let day = self.day.day().min(days_in_month(year, month as u32));
if let Some(date) = NaiveDate::from_ymd_opt(year, month as u32, day) {
self.day = date;
}
}
pub fn today(&mut self) {
self.day = Local::now().date_naive();
}
pub fn now_time(&mut self) {
self.entry.clear();
let now = Local::now();
self.hour = now.hour() as u8;
self.minute = now.minute() as u8;
}
pub fn bump_hour(&mut self, delta: i32) {
self.entry.clear();
let h = (self.hour as i32 + delta).rem_euclid(24) as u8;
self.hour = h;
}
pub fn bump_minute(&mut self, delta: i32) {
self.entry.clear();
let total = self.hour as i32 * 60 + self.minute as i32 + delta;
let total = total.rem_euclid(24 * 60);
self.hour = (total / 60) as u8;
self.minute = (total % 60) as u8;
}
pub fn type_digit(&mut self, d: u8) {
if d > 9 {
return;
}
if self.focus == PickerFocus::Calendar {
self.set_focus(PickerFocus::Hour);
}
match self.focus {
PickerFocus::Hour => self.push_hour_digit(d),
PickerFocus::Minute => self.push_minute_digit(d),
PickerFocus::Calendar => {}
}
}
fn push_hour_digit(&mut self, d: u8) {
if self.entry.is_empty() {
self.entry.push(d);
self.hour = d;
if d >= 3 {
self.entry.clear();
self.set_focus(PickerFocus::Minute);
}
return;
}
let h = self.entry[0] * 10 + d;
self.hour = if h < 24 { h } else { d };
self.entry.clear();
self.set_focus(PickerFocus::Minute);
}
fn push_minute_digit(&mut self, d: u8) {
if self.entry.is_empty() {
self.entry.push(d);
self.minute = d;
if d >= 6 {
self.entry.clear();
}
return;
}
let m = self.entry[0] * 10 + d;
self.minute = if m < 60 { m } else { d };
self.entry.clear();
}
}
fn hit(r: Rect, x: u16, y: u16) -> bool {
!r.is_empty() && r.contains(ratatui::layout::Position { x, y })
}
fn date_at_click(anchor: NaiveDate, days: Rect, x: u16, y: u16) -> Option<NaiveDate> {
if !hit(days, x, y) {
return None;
}
let week = (y - days.y) as i64;
let col = ((x - days.x) / 3) as i64;
if !(0..=6).contains(&col) {
return None;
}
let first = anchor.with_day(1)?;
let offset = first.weekday().num_days_from_sunday() as i64;
let start = first.checked_sub_signed(chrono::Duration::days(offset))?;
start.checked_add_signed(chrono::Duration::days(week * 7 + col))
}
fn parse_day(due: &str) -> Option<NaiveDate> {
let (year, month, day, _, _) = crate::due::sort_key(due)?;
NaiveDate::from_ymd_opt(year, month, day)
}
fn days_in_month(year: i32, month: u32) -> u32 {
let (next_year, next_month) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
NaiveDate::from_ymd_opt(next_year, next_month, 1)
.and_then(|first| first.pred_opt())
.map(|last| last.day())
.unwrap_or(28)
}
pub fn to_time_date(day: NaiveDate) -> Option<time::Date> {
let month = time::Month::try_from(day.month() as u8).ok()?;
time::Date::from_calendar_date(day.year(), month, day.day() as u8).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opens_on_the_date_already_set() {
let picker = DuePicker::new("2030-01-02");
assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 1, 2).unwrap());
assert!(picker.value().starts_with("2030-01-02 "));
}
#[test]
fn keeps_the_time_that_was_there() {
let picker = DuePicker::new("2030-01-02 09:30");
assert_eq!(picker.hour, 9);
assert_eq!(picker.minute, 30);
assert_eq!(picker.value(), "2030-01-02 09:30");
}
#[test]
fn a_bare_time_means_today_at_that_time() {
let picker = DuePicker::new("09:30");
assert_eq!(picker.day, Local::now().date_naive());
assert_eq!(picker.hour, 9);
assert_eq!(picker.minute, 30);
assert!(picker.value().ends_with(" 09:30"));
}
#[test]
fn walks_days_and_months() {
let mut picker = DuePicker::new("2030-01-31");
picker.move_days(1);
assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 2, 1).unwrap());
picker.move_months(-1);
assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
}
#[test]
fn clamps_onto_a_shorter_month() {
let mut picker = DuePicker::new("2030-01-31");
picker.move_months(1);
assert_eq!(
picker.day,
NaiveDate::from_ymd_opt(2030, 2, 28).unwrap(),
"January 31st has no February counterpart"
);
}
#[test]
fn bumps_hour_and_minute_wrapping() {
let mut picker = DuePicker::new("2030-01-02 23:55");
picker.bump_hour(1);
assert_eq!((picker.hour, picker.minute), (0, 55));
picker.bump_minute(10);
assert_eq!((picker.hour, picker.minute), (1, 5));
picker.bump_minute(-10);
assert_eq!((picker.hour, picker.minute), (0, 55));
}
#[test]
fn tab_walks_focus() {
let mut picker = DuePicker::new("");
assert_eq!(picker.focus, PickerFocus::Calendar);
picker.focus_next();
assert_eq!(picker.focus, PickerFocus::Hour);
picker.focus_next();
assert_eq!(picker.focus, PickerFocus::Minute);
picker.focus_next();
assert_eq!(picker.focus, PickerFocus::Calendar);
}
#[test]
fn click_picks_a_day_from_the_grid() {
let mut picker = DuePicker::new("2026-08-05 12:00");
picker.layout.days = Rect {
x: 10,
y: 5,
width: 21,
height: 6,
};
assert!(picker.click(10 + 3 * 3 + 1, 5));
assert_eq!(picker.day, NaiveDate::from_ymd_opt(2026, 7, 29).unwrap());
picker.day = NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
assert!(picker.click(10 + 3 * 3 + 1, 6));
assert_eq!(picker.day, NaiveDate::from_ymd_opt(2026, 8, 5).unwrap());
}
#[test]
fn click_focuses_hour_and_minute() {
let mut picker = DuePicker::new("2026-08-05 12:00");
picker.layout.hour = Rect {
x: 5,
y: 10,
width: 2,
height: 1,
};
picker.layout.minute = Rect {
x: 8,
y: 10,
width: 2,
height: 1,
};
assert!(picker.click(5, 10));
assert_eq!(picker.focus, PickerFocus::Hour);
assert!(picker.click(8, 10));
assert_eq!(picker.focus, PickerFocus::Minute);
}
#[test]
fn digits_type_hour_and_minute() {
let mut picker = DuePicker::new("2030-01-02 00:00");
picker.focus = PickerFocus::Hour;
picker.type_digit(1);
picker.type_digit(5);
assert_eq!(picker.hour, 15);
assert_eq!(picker.focus, PickerFocus::Minute);
picker.type_digit(3);
picker.type_digit(0);
assert_eq!(picker.minute, 30);
assert_eq!(picker.value(), "2030-01-02 15:30");
}
#[test]
fn single_digit_hour_auto_advances() {
let mut picker = DuePicker::new("2030-01-02 00:00");
picker.focus = PickerFocus::Hour;
picker.type_digit(9);
assert_eq!(picker.hour, 9);
assert_eq!(picker.focus, PickerFocus::Minute);
}
#[test]
fn digit_on_calendar_starts_hour() {
let mut picker = DuePicker::new("2030-01-02 08:00");
assert_eq!(picker.focus, PickerFocus::Calendar);
picker.type_digit(1);
assert_eq!(picker.focus, PickerFocus::Hour);
assert_eq!(picker.hour, 1);
}
}