Skip to main content

tix/ui/
date_picker.rs

1//! A reusable calendar date picker popup.
2//!
3//! Date fields across the TUI (issue create/edit form, planning form, filter
4//! modal) are picker-only: values can only be set as a valid `YYYY-MM-DD`
5//! date, never typed free-form. Pressing `F2` on a focused date field opens
6//! this modal; `Enter` writes the highlighted date back, `Esc` cancels, and
7//! `c`/`Delete` clears the field.
8//!
9//! Rendering is delegated to [`ratatui::widgets::calendar::Monthly`]; this
10//! module owns the selection state, key handling, and popup layout.
11
12use crossterm::event::KeyCode;
13use ratatui::Frame;
14use ratatui::layout::{Alignment, Rect};
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::text::Line;
17use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
18use ratatui::widgets::{Block, Borders, Clear, Paragraph};
19use time::{Date, Duration, Month};
20
21/// The outcome of routing a key event to a [`DatePicker`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum DatePickerEvent {
24    /// The key did not resolve the picker (movement / no-op).
25    None,
26    /// The user dismissed the picker (`Esc`).
27    Cancelled,
28    /// The user confirmed the highlighted date (`Enter`), as `YYYY-MM-DD`.
29    Selected(String),
30    /// The user cleared the field (`c` / `Delete`).
31    Cleared,
32}
33
34/// Pointer input resolved against the picker popup (D5/D6): a specific day
35/// cell or a chrome control. Mapped to the same outcome the equivalent key
36/// would produce — selecting a date, paging months, or submitting.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum DatePickerHit {
39    /// A day cell, as `YYYY-MM-DD` (equivalent to navigating there + Enter).
40    Day(String),
41    /// The previous-month control (`PageUp`).
42    PrevMonth,
43    /// The next-month control (`PageDown`).
44    NextMonth,
45    /// The submit control (`Enter` on the highlighted date).
46    Submit,
47}
48
49/// A calendar popup for picking a single date.
50#[derive(Debug, Clone)]
51pub struct DatePicker {
52    /// First day of the month currently displayed.
53    view: Date,
54    /// The highlighted date.
55    selected: Date,
56}
57
58impl DatePicker {
59    /// Open the picker on `value` when it parses as a valid `YYYY-MM-DD` date,
60    /// otherwise on today's date.
61    pub fn new(value: &str) -> Self {
62        let selected = parse_date(value).unwrap_or_else(today_date);
63        Self {
64            view: first_of_month(selected),
65            selected,
66        }
67    }
68
69    /// The currently highlighted date as `YYYY-MM-DD`.
70    pub fn selected(&self) -> String {
71        format_date(self.selected)
72    }
73
74    /// Route a key press, returning the resulting [`DatePickerEvent`].
75    pub fn handle_key(&mut self, code: KeyCode) -> DatePickerEvent {
76        match code {
77            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
78                return DatePickerEvent::Cancelled;
79            }
80            KeyCode::Enter => return DatePickerEvent::Selected(self.selected()),
81            KeyCode::Delete | KeyCode::Char('c') | KeyCode::Char('C') => {
82                return DatePickerEvent::Cleared;
83            }
84            KeyCode::Left | KeyCode::Char('h') => self.shift_days(-1),
85            KeyCode::Right | KeyCode::Char('l') => self.shift_days(1),
86            KeyCode::Up | KeyCode::Char('k') => self.shift_days(-7),
87            KeyCode::Down | KeyCode::Char('j') => self.shift_days(7),
88            KeyCode::PageUp => self.shift_months(-1),
89            KeyCode::PageDown => self.shift_months(1),
90            KeyCode::Char('[') => self.shift_years(-1),
91            KeyCode::Char(']') => self.shift_years(1),
92            _ => {}
93        }
94        DatePickerEvent::None
95    }
96
97    fn shift_days(&mut self, days: i64) {
98        let delta = Duration::days(days);
99        let next = if days >= 0 {
100            self.selected.checked_add(delta)
101        } else {
102            self.selected.checked_sub(-delta)
103        };
104        if let Some(date) = next {
105            self.set_selected(date);
106        }
107    }
108
109    fn shift_months(&mut self, months: i32) {
110        let month0 = i32::from(u8::from(self.selected.month())) - 1 + months;
111        let year = self.selected.year() + month0.div_euclid(12);
112        let month = Month::try_from((month0.rem_euclid(12) + 1) as u8).expect("1..=12");
113        let day = self.selected.day().min(month.length(year));
114        if let Ok(date) = Date::from_calendar_date(year, month, day) {
115            self.set_selected(date);
116        }
117    }
118
119    fn shift_years(&mut self, years: i32) {
120        let year = self.selected.year() + years;
121        let month = self.selected.month();
122        let day = self.selected.day().min(month.length(year));
123        if let Ok(date) = Date::from_calendar_date(year, month, day) {
124            self.set_selected(date);
125        }
126    }
127
128    fn set_selected(&mut self, date: Date) {
129        self.selected = date;
130        self.view = first_of_month(date);
131    }
132
133    /// Jump the highlight straight to `date` (a day-cell click, D5/D6) —
134    /// the same state navigating there and pressing Enter would leave.
135    pub fn select_date(&mut self, date: Date) {
136        self.set_selected(date);
137    }
138
139    /// `select_date` for a `YYYY-MM-DD` string (day-cell click payload).
140    pub fn select_date_str(&mut self, value: &str) {
141        if let Some(date) = parse_date(value) {
142            self.set_selected(date);
143        }
144    }
145
146    /// Shift the view by `months` months (Prev/Next controls, `PageUp`/
147    /// `PageDown` equivalents).
148    pub fn shift_month(&mut self, months: i32) {
149        self.shift_months(months);
150    }
151
152    /// The popup rectangle for `area`, exactly matching what [`DatePicker::render`]
153    /// draws against. Exposed so the pointer can be mapped onto the calendar.
154    pub fn popup_rect(&self, area: Rect) -> Rect {
155        let width = 64.min(area.width);
156        let height = (u16::from(weeks_for(self.view)) + 6).min(area.height);
157        centered(area, width, height)
158    }
159
160    /// The date under `(x, y)` when it falls on a calendar day cell within the
161    /// popup for `area`, following the same Sunday-grid layout ratatui's
162    /// `Monthly` widget uses (gutter + 2-char day per column, week rows under
163    /// the month/weekday headers). Returns `None` for popup chrome or outside.
164    pub fn date_at(&self, area: Rect, x: u16, y: u16) -> Option<Date> {
165        let rect = self.popup_rect(area);
166        let inner_x = rect.x + 1;
167        let days_y = rect.y + 1 + 1 + 1; // border + month header + weekday header
168        if y < days_y || x < inner_x {
169            return None;
170        }
171        let col = (x - inner_x) / 3;
172        if col > 6 {
173            return None;
174        }
175        let row = y - days_y;
176        if row >= u16::from(weeks_for(self.view)) {
177            return None;
178        }
179        let first = self.view.replace_day(1).ok()?;
180        let offset = Duration::days(i64::from(first.weekday().number_days_from_sunday()));
181        first
182            .checked_sub(offset)
183            .and_then(|s| s.checked_add(Duration::days(i64::from(row * 7 + col))))
184    }
185
186    /// Render the popup centered over `area`.
187    pub fn render(&self, f: &mut Frame, area: Rect) {
188        let bindings =
189            crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
190                .expect("built-in bindings are valid");
191        self.render_with_bindings(
192            f,
193            area,
194            &bindings,
195            crate::keybindings::InputContext::DatePicker,
196        );
197    }
198
199    pub fn render_with_bindings(
200        &self,
201        f: &mut Frame,
202        area: Rect,
203        bindings: &crate::keybindings::EffectiveBindings,
204        context: crate::keybindings::InputContext,
205    ) {
206        let mut events = CalendarEventStore::default();
207        events.add(
208            self.selected,
209            Style::default()
210                .fg(Color::Black)
211                .bg(Color::Cyan)
212                .add_modifier(Modifier::BOLD),
213        );
214        events.add(
215            today_date(),
216            Style::default()
217                .fg(Color::Yellow)
218                .add_modifier(Modifier::BOLD),
219        );
220
221        let monthly = Monthly::new(self.view, events)
222            .show_surrounding(Style::default().fg(Color::DarkGray))
223            .show_weekdays_header(Style::default().add_modifier(Modifier::ITALIC))
224            .show_month_header(Style::default().add_modifier(Modifier::BOLD))
225            .block(
226                Block::default()
227                    .borders(Borders::ALL)
228                    .title(" Pick a date ")
229                    .style(Style::default().fg(Color::Cyan)),
230            );
231
232        let width = monthly.width().max(64).min(area.width);
233        let height = (monthly.height() + 2).min(area.height);
234        let rect = centered(area, width, height);
235        let calendar_area = Rect {
236            height: height.saturating_sub(2),
237            ..rect
238        };
239        let hint_area = Rect {
240            y: rect.y + height.saturating_sub(1),
241            height: 1,
242            ..rect
243        };
244
245        f.render_widget(Clear, rect);
246        f.render_widget(monthly, calendar_area);
247        let move_days = format!(
248            "{}/{} {}/{} move",
249            bindings.label(context, crate::keybindings::TuiAction::MoveLeft),
250            bindings.label(context, crate::keybindings::TuiAction::MoveRight),
251            bindings.label(context, crate::keybindings::TuiAction::MoveUp),
252            bindings.label(context, crate::keybindings::TuiAction::MoveDown),
253        );
254        let months = format!(
255            "{}/{} month",
256            bindings.label(context, crate::keybindings::TuiAction::PreviousMonth),
257            bindings.label(context, crate::keybindings::TuiAction::NextMonth),
258        );
259        let years = format!(
260            "{}/{} year",
261            bindings.label(context, crate::keybindings::TuiAction::PreviousYear),
262            bindings.label(context, crate::keybindings::TuiAction::NextYear),
263        );
264        f.render_widget(
265            Paragraph::new(Line::from(format!(
266                "{move_days} \u{b7} {months} \u{b7} {years} \u{b7} {} ok \u{b7} {} clear \u{b7} {} cancel",
267                bindings.label(context, crate::keybindings::TuiAction::Submit),
268                bindings.label(context, crate::keybindings::TuiAction::Reset),
269                bindings.label(context, crate::keybindings::TuiAction::Cancel),
270            )))
271            .alignment(Alignment::Center)
272            .style(Style::default().fg(Color::DarkGray)),
273            hint_area,
274        );
275    }
276}
277
278/// Center a `width`x`height` rect inside `area`, clamped to fit.
279fn centered(area: Rect, width: u16, height: u16) -> Rect {
280    let width = width.min(area.width);
281    let height = height.min(area.height);
282    Rect::new(
283        area.x + (area.width - width) / 2,
284        area.y + (area.height - height) / 2,
285        width,
286        height,
287    )
288}
289
290fn first_of_month(date: Date) -> Date {
291    Date::from_calendar_date(date.year(), date.month(), 1).expect("day 1 is always valid")
292}
293
294/// Number of Sunday-based week rows needed to render `date`'s month, mirroring
295/// ratatui's `Monthly` calendar widget layout (used for hit geometry).
296fn weeks_for(date: Date) -> u8 {
297    let first = first_of_month(date);
298    let last = first
299        .replace_day(first.month().length(first.year()))
300        .expect("last day of month is always valid");
301    last.sunday_based_week()
302        .saturating_sub(first.sunday_based_week())
303        + 1
304}
305
306/// Parse a `YYYY-MM-DD` string that already passed [`crate::model::validate_date`].
307fn parse_date(value: &str) -> Option<Date> {
308    if !crate::model::validate_date(value) {
309        return None;
310    }
311    let year: i32 = value.get(0..4)?.parse().ok()?;
312    let month: u8 = value.get(5..7)?.parse().ok()?;
313    let day: u8 = value.get(8..10)?.parse().ok()?;
314    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
315}
316
317/// Whether `value` is a complete, valid `YYYY-MM-DD` date.
318pub fn is_complete_date(value: &str) -> bool {
319    crate::model::validate_date(value)
320}
321
322/// Apply one masked-edit keystroke to a `YYYY-MM-DD` field.
323///
324/// Only digits are meaningful; the `-` separators are inserted automatically.
325/// Returns `None` when `c` is not accepted. `current` may be empty, partial,
326/// or a full date; when it is already complete the new digit starts a fresh
327/// value (so typing over an existing date replaces it).
328pub fn type_date(current: &str, c: char) -> Option<String> {
329    if c.is_ascii_digit() {
330        let base = if is_complete_date(current) {
331            ""
332        } else {
333            current
334        };
335        let mut digits = digits_of(base);
336        if digits.len() >= 8 {
337            return Some(format_digits(&digits));
338        }
339        digits.push(c);
340        Some(format_digits(&digits))
341    } else if c == '-' {
342        let base = if is_complete_date(current) {
343            ""
344        } else {
345            current
346        };
347        Some(format_digits(&digits_of(base)))
348    } else {
349        None
350    }
351}
352
353/// Remove the last masked-edit digit from a `YYYY-MM-DD` field.
354pub fn backspace_date(current: &str) -> String {
355    let mut digits = digits_of(current);
356    digits.pop();
357    format_digits(&digits)
358}
359
360fn digits_of(value: &str) -> String {
361    value.chars().filter(char::is_ascii_digit).collect()
362}
363
364fn format_digits(digits: &str) -> String {
365    let mut out = String::new();
366    for (i, ch) in digits.chars().take(8).enumerate() {
367        if i == 4 || i == 6 {
368            out.push('-');
369        }
370        out.push(ch);
371    }
372    out
373}
374
375fn today_date() -> Date {
376    parse_date(&crate::model::today()).unwrap_or_else(|| {
377        Date::from_calendar_date(1970, Month::January, 1).expect("epoch is valid")
378    })
379}
380
381pub fn format_date(date: Date) -> String {
382    format!(
383        "{:04}-{:02}-{:02}",
384        date.year(),
385        u8::from(date.month()),
386        date.day()
387    )
388}
389
390#[cfg(test)]
391mod tests {
392    #[allow(clippy::wildcard_imports)]
393    use super::*;
394
395    #[test]
396    fn opens_on_valid_value() {
397        let picker = DatePicker::new("2026-03-15");
398        assert_eq!(picker.selected(), "2026-03-15");
399        assert_eq!(
400            picker.view,
401            first_of_month(parse_date("2026-03-15").unwrap())
402        );
403    }
404
405    #[test]
406    fn opens_on_today_for_invalid_value() {
407        let picker = DatePicker::new("not-a-date");
408        assert_eq!(picker.selected(), crate::model::today());
409    }
410
411    #[test]
412    fn enter_selects_highlighted_date() {
413        let mut picker = DatePicker::new("2026-03-15");
414        assert_eq!(
415            picker.handle_key(KeyCode::Enter),
416            DatePickerEvent::Selected("2026-03-15".into())
417        );
418    }
419
420    #[test]
421    fn esc_cancels_and_c_clears() {
422        let mut picker = DatePicker::new("2026-03-15");
423        assert_eq!(picker.handle_key(KeyCode::Esc), DatePickerEvent::Cancelled);
424        assert_eq!(
425            picker.handle_key(KeyCode::Char('c')),
426            DatePickerEvent::Cleared
427        );
428    }
429
430    #[test]
431    fn day_week_and_month_navigation() {
432        let mut picker = DatePicker::new("2026-03-15");
433        picker.handle_key(KeyCode::Right);
434        assert_eq!(picker.selected(), "2026-03-16");
435        picker.handle_key(KeyCode::Down);
436        assert_eq!(picker.selected(), "2026-03-23");
437        picker.handle_key(KeyCode::PageDown);
438        assert_eq!(picker.selected(), "2026-04-23");
439        picker.handle_key(KeyCode::PageUp);
440        assert_eq!(picker.selected(), "2026-03-23");
441    }
442
443    #[test]
444    fn month_shift_clamps_day() {
445        let mut picker = DatePicker::new("2026-01-31");
446        picker.handle_key(KeyCode::PageDown);
447        assert_eq!(picker.selected(), "2026-02-28");
448    }
449
450    #[test]
451    fn year_shift_clamps_leap_day() {
452        let mut picker = DatePicker::new("2024-02-29");
453        picker.handle_key(KeyCode::Char(']'));
454        assert_eq!(picker.selected(), "2025-02-28");
455    }
456
457    #[test]
458    fn mask_typing_inserts_separators() {
459        let mut value = String::new();
460        for c in "20260910".chars() {
461            value = type_date(&value, c).unwrap();
462        }
463        assert_eq!(value, "2026-09-10");
464        assert!(is_complete_date(&value));
465    }
466
467    #[test]
468    fn mask_typing_a_dash_is_a_noop() {
469        let value = type_date("2026", '-').unwrap();
470        assert_eq!(value, "2026");
471    }
472
473    #[test]
474    fn mask_rejects_letters_and_extra_digits() {
475        assert_eq!(type_date("2026", 'x'), None);
476        let full = type_date("2026-09-10", '5').unwrap();
477        assert_eq!(full, "5", "typing over a complete date replaces it");
478    }
479
480    #[test]
481    fn mask_backspace_removes_last_digit() {
482        assert_eq!(backspace_date("2026-09-1"), "2026-09");
483        assert_eq!(backspace_date("2026-09"), "2026-0");
484        assert_eq!(backspace_date("2026"), "202");
485    }
486
487    #[test]
488    fn render_smoke() {
489        use ratatui::Terminal;
490        use ratatui::backend::TestBackend;
491
492        let backend = TestBackend::new(80, 24);
493        let mut terminal = Terminal::new(backend).unwrap();
494        let picker = DatePicker::new("2026-03-15");
495        terminal.draw(|f| picker.render(f, f.area())).unwrap();
496        let buffer = terminal.backend().buffer().clone();
497        let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
498        assert!(text.contains("Pick a date"));
499        assert!(text.contains("2026"));
500    }
501}