1use chrono::{Datelike, Local, NaiveDate, Timelike};
5use ratatui::layout::Rect;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PickerFocus {
9 Calendar,
10 Hour,
11 Minute,
12}
13
14impl PickerFocus {
15 pub fn next(self) -> Self {
16 match self {
17 Self::Calendar => Self::Hour,
18 Self::Hour => Self::Minute,
19 Self::Minute => Self::Calendar,
20 }
21 }
22
23 pub fn prev(self) -> Self {
24 match self {
25 Self::Calendar => Self::Minute,
26 Self::Hour => Self::Calendar,
27 Self::Minute => Self::Hour,
28 }
29 }
30}
31
32#[derive(Debug, Default, Clone, Copy)]
36pub struct PickerLayout {
37 pub frame: Rect,
38 pub days: Rect,
40 pub hour: Rect,
41 pub minute: Rect,
42 pub time_row: Rect,
43}
44
45pub struct DuePicker {
46 pub day: NaiveDate,
48 pub hour: u8,
49 pub minute: u8,
50 pub focus: PickerFocus,
51 entry: Option<u8>,
53 original: String,
54 date_changed: bool,
55 time_changed: bool,
56 pub layout: PickerLayout,
58}
59
60impl DuePicker {
61 pub fn new(current: &str) -> Self {
63 let current = current.trim();
64 let now = Local::now();
65 let day = parse_day(current).unwrap_or_else(|| now.date_naive());
66 let (hour, minute) = match crate::due::sort_key_at(current, now.date_naive()) {
67 Some((_, _, _, h, m)) if current.contains(':') => (h as u8, m as u8),
69 _ => (now.hour() as u8, now.minute() as u8),
70 };
71 Self {
72 day,
73 hour,
74 minute,
75 focus: PickerFocus::Calendar,
76 entry: None,
77 original: current.to_string(),
78 date_changed: false,
79 time_changed: false,
80 layout: PickerLayout::default(),
81 }
82 }
83
84 pub fn contains(&self, x: u16, y: u16) -> bool {
86 hit(self.layout.frame, x, y)
87 }
88
89 pub(crate) fn day_at(&self, x: u16, y: u16) -> Option<NaiveDate> {
92 date_at_click(self.day, self.layout.days, x, y)
93 }
94
95 pub fn click(&mut self, x: u16, y: u16) -> bool {
97 let layout = self.layout;
98 if hit(layout.hour, x, y) {
99 self.set_focus(PickerFocus::Hour);
100 return true;
101 }
102 if hit(layout.minute, x, y) {
103 self.set_focus(PickerFocus::Minute);
104 return true;
105 }
106 if let Some(day) = self.day_at(x, y) {
107 self.set_day(day);
108 self.set_focus(PickerFocus::Calendar);
109 return true;
110 }
111 if self.contains(x, y) {
113 self.set_focus(PickerFocus::Calendar);
114 return true;
115 }
116 false
117 }
118
119 pub fn scroll(&mut self, x: u16, y: u16, up: bool) -> bool {
121 let layout = self.layout;
122 let delta = if up { 1 } else { -1 };
123 if hit(layout.hour, x, y) || (hit(layout.time_row, x, y) && self.focus == PickerFocus::Hour)
124 {
125 self.set_focus(PickerFocus::Hour);
126 self.bump_hour(delta);
127 return true;
128 }
129 if hit(layout.minute, x, y)
130 || (hit(layout.time_row, x, y) && self.focus == PickerFocus::Minute)
131 {
132 self.set_focus(PickerFocus::Minute);
133 self.bump_minute(delta * 5);
134 return true;
135 }
136 if self.contains(x, y) {
137 self.set_focus(PickerFocus::Calendar);
138 self.move_months(delta);
139 return true;
140 }
141 false
142 }
143
144 pub fn value(&self) -> String {
147 if !self.date_changed && !self.time_changed {
148 if !self.original.is_empty() {
149 return self.original.clone();
150 }
151 return format!(
152 "{} {:02}:{:02}",
153 self.day.format("%Y-%m-%d"),
154 self.hour,
155 self.minute
156 );
157 }
158 let (had_date, had_year, had_time) = original_shape(&self.original);
159 let include_date = had_date || self.date_changed;
160 let include_time = had_time || self.time_changed;
161 if include_date {
162 if include_time {
163 format!(
164 "{} {:02}:{:02}",
165 self.day.format("%Y-%m-%d"),
166 self.hour,
167 self.minute
168 )
169 } else if had_date && !had_year {
170 self.day.format("%m-%d").to_string()
171 } else {
172 self.day.format("%Y-%m-%d").to_string()
173 }
174 } else {
175 debug_assert!(include_time);
176 format!("{:02}:{:02}", self.hour, self.minute)
177 }
178 }
179
180 pub fn focus_next(&mut self) {
181 self.set_focus(self.focus.next());
182 }
183
184 pub fn focus_prev(&mut self) {
185 self.set_focus(self.focus.prev());
186 }
187
188 fn set_focus(&mut self, focus: PickerFocus) {
189 if focus != self.focus {
190 self.entry = None;
191 }
192 self.focus = focus;
193 }
194
195 pub fn move_days(&mut self, days: i64) {
196 self.entry = None;
197 if let Some(day) = self.day.checked_add_signed(chrono::Duration::days(days)) {
198 self.set_day(day);
199 }
200 }
201
202 pub fn move_months(&mut self, months: i32) {
203 self.entry = None;
204 let (mut year, mut month) = (self.day.year(), self.day.month() as i32 + months);
205 while month < 1 {
206 month += 12;
207 year -= 1;
208 }
209 while month > 12 {
210 month -= 12;
211 year += 1;
212 }
213 let day = self.day.day().min(days_in_month(year, month as u32));
215 if let Some(date) = NaiveDate::from_ymd_opt(year, month as u32, day) {
216 self.set_day(date);
217 }
218 }
219
220 pub fn today(&mut self) {
222 self.set_day(Local::now().date_naive());
223 }
224
225 pub fn now_time(&mut self) {
227 self.entry = None;
228 let now = Local::now();
229 self.set_time(now.hour() as u8, now.minute() as u8);
230 }
231
232 pub fn bump_hour(&mut self, delta: i32) {
233 self.entry = None;
234 let h = (self.hour as i32 + delta).rem_euclid(24) as u8;
235 self.set_time(h, self.minute);
236 }
237
238 pub fn bump_minute(&mut self, delta: i32) {
239 self.entry = None;
240 let total = self.hour as i32 * 60 + self.minute as i32 + delta;
241 let total = total.rem_euclid(24 * 60);
242 self.set_time((total / 60) as u8, (total % 60) as u8);
243 }
244
245 pub fn type_digit(&mut self, d: u8) {
249 if d > 9 {
250 return;
251 }
252 if self.focus == PickerFocus::Calendar {
253 self.set_focus(PickerFocus::Hour);
254 }
255 match self.focus {
256 PickerFocus::Hour => self.push_hour_digit(d),
257 PickerFocus::Minute => self.push_minute_digit(d),
258 PickerFocus::Calendar => {}
259 }
260 }
261
262 fn push_hour_digit(&mut self, d: u8) {
263 let Some(first) = self.entry.take() else {
264 self.entry = Some(d);
265 self.set_time(d, self.minute);
266 if d >= 3 {
268 self.entry = None;
269 self.set_focus(PickerFocus::Minute);
270 }
271 return;
272 };
273 let h = first * 10 + d;
274 self.set_time(if h < 24 { h } else { d }, self.minute);
275 self.set_focus(PickerFocus::Minute);
276 }
277
278 fn push_minute_digit(&mut self, d: u8) {
279 let Some(first) = self.entry.take() else {
280 self.entry = Some(d);
281 self.set_time(self.hour, d);
282 if d >= 6 {
284 self.entry = None;
285 }
286 return;
287 };
288 let m = first * 10 + d;
289 self.set_time(self.hour, if m < 60 { m } else { d });
290 }
291
292 fn set_day(&mut self, day: NaiveDate) {
293 if day != self.day {
294 self.day = day;
295 self.date_changed = true;
296 }
297 }
298
299 fn set_time(&mut self, hour: u8, minute: u8) {
300 if (hour, minute) != (self.hour, self.minute) {
301 self.hour = hour;
302 self.minute = minute;
303 self.time_changed = true;
304 }
305 }
306}
307
308fn original_shape(original: &str) -> (bool, bool, bool) {
309 let had_date = original.contains('-');
310 let had_year = original
311 .split_whitespace()
312 .next()
313 .is_some_and(|date| date.split('-').next().is_some_and(|year| year.len() == 4));
314 (had_date, had_year, original.contains(':'))
315}
316
317fn hit(r: Rect, x: u16, y: u16) -> bool {
318 !r.is_empty() && r.contains(ratatui::layout::Position { x, y })
319}
320
321fn date_at_click(anchor: NaiveDate, days: Rect, x: u16, y: u16) -> Option<NaiveDate> {
327 if !hit(days, x, y) {
328 return None;
329 }
330 let week = (y - days.y) as i64;
331 let col = ((x - days.x) / 3) as i64;
332 if !(0..=6).contains(&col) {
333 return None;
334 }
335 let first = anchor.with_day(1)?;
336 let offset = first.weekday().num_days_from_sunday() as i64;
337 let start = first.checked_sub_signed(chrono::Duration::days(offset))?;
338 start.checked_add_signed(chrono::Duration::days(week * 7 + col))
339}
340
341fn parse_day(due: &str) -> Option<NaiveDate> {
344 let (year, month, day, _, _) = crate::due::sort_key(due)?;
345 NaiveDate::from_ymd_opt(year, month, day)
346}
347
348fn days_in_month(year: i32, month: u32) -> u32 {
349 let (next_year, next_month) = if month == 12 {
350 (year + 1, 1)
351 } else {
352 (year, month + 1)
353 };
354 NaiveDate::from_ymd_opt(next_year, next_month, 1)
355 .and_then(|first| first.pred_opt())
356 .map(|last| last.day())
357 .unwrap_or(28)
358}
359
360pub fn to_time_date(day: NaiveDate) -> Option<time::Date> {
363 let month = time::Month::try_from(day.month() as u8).ok()?;
364 time::Date::from_calendar_date(day.year(), month, day.day() as u8).ok()
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[test]
372 fn opens_on_the_date_already_set() {
373 let picker = DuePicker::new("2030-01-02");
374 assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 1, 2).unwrap());
375 assert_eq!(picker.value(), "2030-01-02");
376 }
377
378 #[test]
379 fn keeps_the_time_that_was_there() {
380 let picker = DuePicker::new("2030-01-02 09:30");
381 assert_eq!(picker.hour, 9);
382 assert_eq!(picker.minute, 30);
383 assert_eq!(picker.value(), "2030-01-02 09:30");
384 }
385
386 #[test]
387 fn a_bare_time_means_today_at_that_time() {
388 let picker = DuePicker::new("09:30");
389 assert_eq!(picker.day, Local::now().date_naive());
390 assert_eq!(picker.hour, 9);
391 assert_eq!(picker.minute, 30);
392 assert_eq!(picker.value(), "09:30");
393 }
394
395 #[test]
396 fn changing_only_an_existing_component_preserves_due_precision() {
397 let mut date = DuePicker::new("2030-01-02");
398 date.move_days(1);
399 assert_eq!(date.value(), "2030-01-03");
400
401 let mut time = DuePicker::new("09:30");
402 time.bump_minute(5);
403 assert_eq!(time.value(), "09:35");
404
405 let mut yearless = DuePicker::new("08-09");
406 yearless.move_days(1);
407 assert_eq!(yearless.value(), "08-10");
408 }
409
410 #[test]
411 fn changing_a_missing_component_intentionally_adds_it() {
412 let mut date = DuePicker::new("2030-01-02");
413 date.bump_hour(1);
414 assert!(date.value().starts_with("2030-01-02 "));
415
416 let mut time = DuePicker::new("09:30");
417 time.move_days(1);
418 assert!(time.value().ends_with(" 09:30"));
419 }
420
421 #[test]
422 fn walks_days_and_months() {
423 let mut picker = DuePicker::new("2030-01-31");
424 picker.move_days(1);
425 assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 2, 1).unwrap());
426 picker.move_months(-1);
427 assert_eq!(picker.day, NaiveDate::from_ymd_opt(2030, 1, 1).unwrap());
428 }
429
430 #[test]
431 fn clamps_onto_a_shorter_month() {
432 let mut picker = DuePicker::new("2030-01-31");
433 picker.move_months(1);
434 assert_eq!(
435 picker.day,
436 NaiveDate::from_ymd_opt(2030, 2, 28).unwrap(),
437 "January 31st has no February counterpart"
438 );
439 }
440
441 #[test]
442 fn bumps_hour_and_minute_wrapping() {
443 let mut picker = DuePicker::new("2030-01-02 23:55");
444 picker.bump_hour(1);
445 assert_eq!((picker.hour, picker.minute), (0, 55));
446 picker.bump_minute(10);
447 assert_eq!((picker.hour, picker.minute), (1, 5));
448 picker.bump_minute(-10);
449 assert_eq!((picker.hour, picker.minute), (0, 55));
450 }
451
452 #[test]
453 fn tab_walks_focus() {
454 let mut picker = DuePicker::new("");
455 assert_eq!(picker.focus, PickerFocus::Calendar);
456 picker.focus_next();
457 assert_eq!(picker.focus, PickerFocus::Hour);
458 picker.focus_next();
459 assert_eq!(picker.focus, PickerFocus::Minute);
460 picker.focus_next();
461 assert_eq!(picker.focus, PickerFocus::Calendar);
462 }
463
464 #[test]
465 fn click_picks_a_day_from_the_grid() {
466 let mut picker = DuePicker::new("2026-08-05 12:00");
468 picker.layout.days = Rect {
469 x: 10,
470 y: 5,
471 width: 21,
472 height: 6,
473 };
474 assert!(picker.click(10 + 3 * 3 + 1, 5));
476 assert_eq!(picker.day, NaiveDate::from_ymd_opt(2026, 7, 29).unwrap());
477
478 picker.day = NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
480 assert!(picker.click(10 + 3 * 3 + 1, 6));
482 assert_eq!(picker.day, NaiveDate::from_ymd_opt(2026, 8, 5).unwrap());
483 }
484
485 #[test]
486 fn click_focuses_hour_and_minute() {
487 let mut picker = DuePicker::new("2026-08-05 12:00");
488 picker.layout.hour = Rect {
489 x: 5,
490 y: 10,
491 width: 2,
492 height: 1,
493 };
494 picker.layout.minute = Rect {
495 x: 8,
496 y: 10,
497 width: 2,
498 height: 1,
499 };
500 assert!(picker.click(5, 10));
501 assert_eq!(picker.focus, PickerFocus::Hour);
502 assert!(picker.click(8, 10));
503 assert_eq!(picker.focus, PickerFocus::Minute);
504 }
505
506 #[test]
507 fn digits_type_hour_and_minute() {
508 let mut picker = DuePicker::new("2030-01-02 00:00");
509 picker.focus = PickerFocus::Hour;
510 picker.type_digit(1);
511 picker.type_digit(5);
512 assert_eq!(picker.hour, 15);
513 assert_eq!(picker.focus, PickerFocus::Minute);
514 picker.type_digit(3);
515 picker.type_digit(0);
516 assert_eq!(picker.minute, 30);
517 assert_eq!(picker.value(), "2030-01-02 15:30");
518 }
519
520 #[test]
521 fn single_digit_hour_auto_advances() {
522 let mut picker = DuePicker::new("2030-01-02 00:00");
523 picker.focus = PickerFocus::Hour;
524 picker.type_digit(9);
525 assert_eq!(picker.hour, 9);
526 assert_eq!(picker.focus, PickerFocus::Minute);
527 }
528
529 #[test]
530 fn digit_on_calendar_starts_hour() {
531 let mut picker = DuePicker::new("2030-01-02 08:00");
532 assert_eq!(picker.focus, PickerFocus::Calendar);
533 picker.type_digit(1);
534 assert_eq!(picker.focus, PickerFocus::Hour);
535 assert_eq!(picker.hour, 1);
536 }
537}