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