Skip to main content

mach/
duepicker.rs

1//! Date-and-time picker for the Due field. Both the day and the clock
2//! are set from this UI — the field itself is not free-typed.
3
4use 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/// Hit targets filled in while drawing, so mouse events can find a day
33/// or the hour/minute cells. Matches ratatui's Monthly layout: each day
34/// is three columns (` ` + two-digit day), weeks start on Sunday.
35#[derive(Debug, Default, Clone, Copy)]
36pub struct PickerLayout {
37    pub frame: Rect,
38    /// The day grid only (under the month + weekday headers).
39    pub days: Rect,
40    pub hour: Rect,
41    pub minute: Rect,
42    pub time_row: Rect,
43}
44
45pub struct DuePicker {
46    /// The day under the cursor.
47    pub day: NaiveDate,
48    pub hour: u8,
49    pub minute: u8,
50    pub focus: PickerFocus,
51    /// First digit typed into the focused hour/minute field.
52    entry: Option<u8>,
53    original: String,
54    date_changed: bool,
55    time_changed: bool,
56    /// Last drawn positions for hit-testing.
57    pub layout: PickerLayout,
58}
59
60impl DuePicker {
61    /// Opens on the date/time already in the field, or on now.
62    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            // Only trust the clock when the stored value actually has one.
68            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    /// True when `(x, y)` is inside the picker frame.
85    pub fn contains(&self, x: u16, y: u16) -> bool {
86        hit(self.layout.frame, x, y)
87    }
88
89    /// Calendar date under a painted day cell, including surrounding-month
90    /// days. Shared by click handling and hover registration.
91    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    /// Apply a left-click. Returns true when the click was handled.
96    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        // Click elsewhere inside the frame just focuses the calendar.
112        if self.contains(x, y) {
113            self.set_focus(PickerFocus::Calendar);
114            return true;
115        }
116        false
117    }
118
119    /// Scroll over the picker: calendar months, or hour/minute steps.
120    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    /// Preserve the representation the user opened. A missing date or clock
145    /// is only added when the user actually changes that component.
146    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        // Clamp onto the last day of a shorter month.
214        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    /// Jump the day to today; leave the clock as it is.
221    pub fn today(&mut self) {
222        self.set_day(Local::now().date_naive());
223    }
224
225    /// Set the clock to the current time of day.
226    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    /// Type a digit into the clock. On the calendar, jumps to hour first.
246    /// Hour: 0–23 (auto-advances to minute after a complete value).
247    /// Minute: 0–59.
248    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            // 3–9 can only be single-digit hours → commit and move on.
267            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            // 6–9 can only be single-digit minutes.
283            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
321/// Map a click on the Monthly day grid to a calendar date.
322///
323/// Layout (per ratatui-widgets calendar): each of 7 days is three cells
324/// wide (` ` + `dd`), rows are Sunday-based weeks starting from the
325/// Sunday on or before the 1st of the displayed month.
326fn 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
341/// Reads back the shapes mach stores, so reopening the picker lands
342/// on the date that is already set.
343fn 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
360/// The picker's day as the `time` crate spells it, which is what
361/// ratatui's calendar widget takes.
362pub 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        // August 2026 starts on Saturday → Sunday before is July 26.
467        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        // Week 0, column 3 (Wed) → July 29 (surrounding month).
475        assert!(picker.click(10 + 3 * 3 + 1, 5));
476        assert_eq!(picker.day, NaiveDate::from_ymd_opt(2026, 7, 29).unwrap());
477
478        // Reset to August so the grid is that month again.
479        picker.day = NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
480        // Week 1, column 3 → August 5.
481        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}