Skip to main content

rich_interact/components/
date.rs

1//! Date and time pickers (#719): [`DatePicker`], [`TimePicker`] and
2//! [`DateTimePicker`].
3//!
4//! A date is picked on a month grid: ←/→ move a day, ↑/↓ a week,
5//! PgUp/PgDn (or `[`/`]`) a month, Shift+PgUp/PgDn (or `{`/`}`) a year,
6//! Home/End to the first and last of the month, and `t` to today. A time
7//! is picked field by field: ←/→ (or Tab) move between the hour, the
8//! minute, the second and AM/PM, ↑/↓ change the field, and digits type it.
9//! With both, Tab moves between the grid and the time. Enter picks; Esc
10//! cancels. Every key comes from the keymap (context `date`), so it can be
11//! rebound. Tab and Shift+Tab are the containers' `focus-next` and
12//! `focus-previous`: in a [container](crate::compose), Tab after the last
13//! part moves on to the next field, and on its own the picker goes round.
14//!
15//! A range ([`min`](DatePicker::min), [`max`](DatePicker::max)) limits
16//! what can be picked: days outside it are dimmed and the grid does not go
17//! to them; a time outside it is refused on Enter, with a message under
18//! the picker that says what the limit is, as is an answer a
19//! [validator](DatePicker::validate) refuses, and every answer when the
20//! earliest is after the latest. The days go from the year 1 to 9999, the
21//! years ISO 8601's four digits write. The value so far shows on the
22//! question line, and the selected day is in brackets as well as reversed,
23//! so both read without colour. Without seconds a picker works to the
24//! minute: the value and the answer drop their seconds, and a limit with
25//! seconds is the minute that can be picked (the earliest rounds up).
26//!
27//! Without a terminal each asks for a line and parses it: `2026-10-17`,
28//! `14:30` (or `2:30 pm`), or `2026-10-17 14:30`; an empty line answers
29//! with the default. A line outside the range or refused by the validator
30//! is an error, as is one that does not parse, and so is a default the
31//! picker would refuse.
32
33use std::cell::Cell;
34use std::sync::Arc;
35
36use rich::{Segment, Style};
37
38use crate::component::{Component, Context, Flow, View};
39use crate::components::{Theme, fit, plain, question, text};
40use crate::compose::{FOCUS_NEXT, FOCUS_PREVIOUS};
41use crate::datetime::{Date, DateTime, Time};
42use crate::event::{Button, Event, Key, KeyCode, MouseKind};
43use crate::keymap::{Keymap, keys};
44use crate::policy::{LineIo, NotInteractive};
45
46/// The keys of the date and time pickers, in context `date`. Tab and
47/// Shift+Tab are [`FOCUS_NEXT`] and [`FOCUS_PREVIOUS`], as in a
48/// container: past the picker's last part, focus moves on.
49pub fn date_keymap() -> Keymap {
50    Keymap::new("date")
51        .bind("pick", keys("enter"), "pick")
52        .bind("cancel", keys("escape"), "cancel")
53        .bind("left", keys("left h"), "a day earlier; the field before")
54        .bind("right", keys("right l"), "a day later; the next field")
55        .bind("up", keys("up k"), "a week earlier; the field up")
56        .bind("down", keys("down j"), "a week later; the field down")
57        .bind("previous-month", keys("pageup ["), "a month earlier")
58        .bind("next-month", keys("pagedown ]"), "a month later")
59        .bind("previous-year", keys("shift+pageup {"), "a year earlier")
60        .bind("next-year", keys("shift+pagedown }"), "a year later")
61        .bind("first-day", keys("home"), "the month's first day")
62        .bind("last-day", keys("end"), "the month's last day")
63        .bind("today", keys("t"), "today")
64        .bind(FOCUS_NEXT, keys("tab"), "the next part")
65        .bind(FOCUS_PREVIOUS, keys("shift+tab"), "the part before")
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69enum Mode {
70    Date,
71    Time,
72    DateTime,
73}
74
75/// What has the focus.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77enum Part {
78    Grid,
79    Hour,
80    Minute,
81    Second,
82    Half,
83}
84
85type Check = Arc<dyn Fn(DateTime) -> Result<(), String> + Send + Sync>;
86
87/// The rows of a month grid: always six weeks, so the picker keeps its
88/// height from month to month.
89const WEEKS: usize = 6;
90/// The grid's width: a space, then a separator and two digits a day, then
91/// a last separator.
92const GRID_WIDTH: usize = 23;
93/// The rows above the grid's weeks: the question, the month, the weekdays.
94const GRID_TOP: usize = 3;
95/// The first and last days a picker goes to: the years ISO 8601's four
96/// digits write, so every answer prints and parses back.
97const FIRST_DAY: Date = Date::new(1, 1, 1);
98const LAST_DAY: Date = Date::new(9999, 12, 31);
99
100/// The state the three pickers share.
101struct Chooser {
102    prompt: String,
103    mode: Mode,
104    value: DateTime,
105    /// Whether `value` was given: a default does not move the start then.
106    value_given: bool,
107    min: Option<DateTime>,
108    max: Option<DateTime>,
109    seconds: bool,
110    twelve_hour: bool,
111    today: Date,
112    part: Part,
113    /// Digits typed into the focused time field so far.
114    typed: String,
115    /// Why the last Enter was refused, until the next key.
116    message: Option<String>,
117    check: Option<Check>,
118    default: Option<DateTime>,
119    theme: Theme,
120    mouse: bool,
121    keymap: Keymap,
122    /// The day the last click selected: a click on it again picks it.
123    clicked: Option<Date>,
124    answer: Option<Option<String>>,
125    /// The width last drawn at, for the mouse.
126    width: Cell<usize>,
127}
128
129impl Chooser {
130    fn new(prompt: String, mode: Mode) -> Chooser {
131        let now = DateTime::now_utc();
132        let value = match mode {
133            Mode::Date => DateTime::new(now.date, Time::MIDNIGHT),
134            Mode::Time | Mode::DateTime => {
135                DateTime::new(now.date, Time::new(now.time.hour, now.time.minute, 0))
136            }
137        };
138        Chooser {
139            prompt,
140            mode,
141            value,
142            value_given: false,
143            min: None,
144            max: None,
145            seconds: false,
146            twelve_hour: false,
147            today: now.date,
148            part: if mode == Mode::Time {
149                Part::Hour
150            } else {
151                Part::Grid
152            },
153            typed: String::new(),
154            message: None,
155            check: None,
156            default: None,
157            theme: Theme::default(),
158            mouse: false,
159            keymap: date_keymap(),
160            clicked: None,
161            answer: None,
162            width: Cell::new(80),
163        }
164    }
165
166    fn has_date(&self) -> bool {
167        self.mode != Mode::Time
168    }
169
170    fn has_time(&self) -> bool {
171        self.mode != Mode::Date
172    }
173
174    /// The parts Tab goes through, in order.
175    fn parts(&self) -> Vec<Part> {
176        let mut parts = Vec::new();
177        if self.has_date() {
178            parts.push(Part::Grid);
179        }
180        if self.has_time() {
181            parts.extend([Part::Hour, Part::Minute]);
182            if self.seconds {
183                parts.push(Part::Second);
184            }
185            if self.twelve_hour {
186                parts.push(Part::Half);
187            }
188        }
189        parts
190    }
191
192    /// Whether the picker works to the minute: it has a time, and no
193    /// seconds.
194    fn to_the_minute(&self) -> bool {
195        self.has_time() && !self.seconds
196    }
197
198    /// `at` as the picker shows it, and so answers it: to the minute when
199    /// it has no seconds.
200    fn shown(&self, at: DateTime) -> DateTime {
201        if !self.to_the_minute() {
202            return at;
203        }
204        DateTime::new(
205            at.date,
206            Time {
207                second: 0,
208                ..at.time
209            },
210        )
211    }
212
213    /// The earliest moment that can be picked: `min`, rounded up to the
214    /// next minute when the picker works to the minute and it has seconds.
215    fn earliest(&self) -> Option<DateTime> {
216        let min = self.min?;
217        if !self.to_the_minute() || min.time.second == 0 {
218            return Some(min);
219        }
220        let minute = i64::from(min.time.seconds() - min.time.second);
221        let time = Time::from_seconds(minute + 60);
222        let date = if time == Time::MIDNIGHT {
223            min.date.add_days(1)
224        } else {
225            min.date
226        };
227        Some(DateTime::new(date, time))
228    }
229
230    /// The latest moment that can be picked: `max`, to the minute when the
231    /// picker works to the minute.
232    fn latest(&self) -> Option<DateTime> {
233        self.max.map(|max| self.shown(max))
234    }
235
236    /// The earliest, as a message says it: the minute that can be picked,
237    /// or, when no minute of a time picker's day is that late, `min` to
238    /// the second.
239    fn earliest_text(&self) -> Option<String> {
240        let (min, earliest) = (self.min?, self.earliest()?);
241        Some(if self.mode == Mode::Time && earliest.date != min.date {
242            min.time.format(true, self.twelve_hour)
243        } else {
244            self.format(earliest)
245        })
246    }
247
248    /// Whether `date` has a moment in the range.
249    fn day_allowed(&self, date: Date) -> bool {
250        (FIRST_DAY..=LAST_DAY).contains(&date)
251            && self.earliest().is_none_or(|min| date >= min.date)
252            && self.latest().is_none_or(|max| date <= max.date)
253    }
254
255    /// `date`, moved into the range (and the years 1 to 9999).
256    fn clamp_day(&self, date: Date) -> Date {
257        let mut date = date;
258        if let Some(min) = self.earliest() {
259            date = date.max(min.date);
260        }
261        if let Some(max) = self.latest() {
262            date = date.min(max.date);
263        }
264        date.clamp(FIRST_DAY, LAST_DAY)
265    }
266
267    /// What is compared with the range: a date picker's day, a time
268    /// picker's time, or both.
269    fn key_of(&self, at: DateTime) -> (Option<Date>, Option<Time>) {
270        match self.mode {
271            Mode::Date => (Some(at.date), None),
272            Mode::Time => (None, Some(at.time)),
273            Mode::DateTime => (Some(at.date), Some(at.time)),
274        }
275    }
276
277    /// How a value is written: as the picker answers it.
278    fn format(&self, at: DateTime) -> String {
279        match self.mode {
280            Mode::Date => at.date.to_string(),
281            Mode::Time => at.time.format(self.seconds, self.twelve_hour),
282            Mode::DateTime => at.format(self.seconds, self.twelve_hour),
283        }
284    }
285
286    /// Why `at` cannot be picked, if it cannot. It is compared as shown:
287    /// to the minute when the picker works to the minute.
288    fn refusal(&self, at: DateTime) -> Option<String> {
289        let at = self.shown(at);
290        if let (Some(earliest), Some(latest)) = (self.earliest(), self.latest())
291            && self.key_of(earliest) > self.key_of(latest)
292        {
293            return Some(format!(
294                "the range is empty: the earliest, {}, is after the latest, {}",
295                self.earliest_text().unwrap_or_default(),
296                self.format(latest)
297            ));
298        }
299        let at_key = self.key_of(at);
300        if let Some(min) = self.min
301            && at_key < self.key_of(min)
302        {
303            let earliest = self.earliest_text().unwrap_or_default();
304            return Some(format!("the earliest is {earliest}"));
305        }
306        if let Some(max) = self.latest()
307            && at_key > self.key_of(max)
308        {
309            return Some(format!("the latest is {}", self.format(max)));
310        }
311        self.check.as_ref().and_then(|check| check(at).err())
312    }
313
314    fn set_day(&mut self, date: Date) {
315        self.value.date = self.clamp_day(date);
316    }
317
318    /// Change the focused time field by `delta`, wrapping round.
319    fn step_field(&mut self, delta: i64) {
320        let time = self.value.time;
321        let wrap =
322            |value: u32, by: i64, modulus: i64| (value as i64 + by).rem_euclid(modulus) as u32;
323        self.value.time = match self.part {
324            Part::Hour => Time {
325                hour: wrap(time.hour, delta, 24),
326                ..time
327            },
328            Part::Minute => Time {
329                minute: wrap(time.minute, delta, 60),
330                ..time
331            },
332            Part::Second => Time {
333                second: wrap(time.second, delta, 60),
334                ..time
335            },
336            Part::Half => Time {
337                hour: (time.hour + 12) % 24,
338                ..time
339            },
340            Part::Grid => time,
341        };
342    }
343
344    /// A digit typed into the focused time field: two make the field, and
345    /// the focus moves on.
346    fn type_digit(&mut self, digit: char) {
347        self.typed.push(digit);
348        let number: u32 = self.typed.parse().unwrap_or(0);
349        let time = self.value.time;
350        let (most, full) = match self.part {
351            Part::Hour if self.twelve_hour => (12, number >= 2 || self.typed.len() == 2),
352            Part::Hour => (23, number >= 3 || self.typed.len() == 2),
353            _ => (59, number >= 6 || self.typed.len() == 2),
354        };
355        let number = number.min(most);
356        self.value.time = match self.part {
357            Part::Hour if self.twelve_hour => {
358                let pm = time.hour >= 12;
359                let hour = if self.typed.len() == 2 || full {
360                    number.max(1) % 12
361                } else {
362                    number % 12
363                };
364                Time {
365                    hour: hour + if pm { 12 } else { 0 },
366                    ..time
367                }
368            }
369            Part::Hour => Time {
370                hour: number,
371                ..time
372            },
373            Part::Minute => Time {
374                minute: number,
375                ..time
376            },
377            Part::Second => Time {
378                second: number,
379                ..time
380            },
381            _ => time,
382        };
383        if full {
384            self.typed.clear();
385            self.move_part(1);
386        }
387    }
388
389    /// Where the focused part is among the parts.
390    fn part_index(&self, parts: &[Part]) -> usize {
391        parts
392            .iter()
393            .position(|part| *part == self.part)
394            .unwrap_or(0)
395    }
396
397    /// Move the focus `by` parts, stopping at the ends (the arrows): the
398    /// arrows stay among the time fields.
399    fn move_part(&mut self, by: isize) {
400        let parts = self.parts();
401        let count = parts.len() as isize;
402        let first = if self.has_date() { 1 } else { 0 };
403        let next = (self.part_index(&parts) as isize + by).clamp(first.min(count - 1), count - 1);
404        self.part = parts[next as usize];
405        self.typed.clear();
406    }
407
408    /// Move the focus to the next part (or the one before), for Tab:
409    /// `false` past the last (or before the first), where Tab leaves the
410    /// picker for its container's next field.
411    fn step_part(&mut self, forward: bool) -> bool {
412        let parts = self.parts();
413        let at = self.part_index(&parts);
414        let next = if forward {
415            Some(at + 1)
416        } else {
417            at.checked_sub(1)
418        };
419        match next.and_then(|next| parts.get(next)) {
420            Some(part) => {
421                self.part = *part;
422                self.typed.clear();
423                true
424            }
425            None => false,
426        }
427    }
428
429    /// Take the focus from outside: at the first part going forward, at
430    /// the last going back.
431    fn enter(&mut self, forward: bool) {
432        let parts = self.parts();
433        if let Some(part) = if forward { parts.first() } else { parts.last() } {
434            self.part = *part;
435        }
436        self.typed.clear();
437    }
438
439    fn pick(&mut self) -> Flow<DateTime> {
440        let value = self.shown(self.value);
441        if let Some(why) = self.refusal(value) {
442            self.message = Some(why);
443            return Flow::Continue;
444        }
445        self.value = value;
446        self.answer = Some(Some(self.format(value)));
447        Flow::Done(value)
448    }
449
450    fn act(&mut self, action: &str) -> Flow<DateTime> {
451        let on_grid = self.part == Part::Grid;
452        let day = self.value.date;
453        match action {
454            "pick" => return self.pick(),
455            "cancel" => {
456                self.answer = Some(None);
457                return Flow::Cancel;
458            }
459            // Past the last part (or before the first) Tab is left to the
460            // container: its next field. On its own, the event loop takes
461            // the picker round to its other end.
462            FOCUS_NEXT | FOCUS_PREVIOUS => {
463                if !self.step_part(action == FOCUS_NEXT) {
464                    return Flow::Ignored;
465                }
466            }
467            "left" if on_grid => self.set_day(day.add_days(-1)),
468            "right" if on_grid => self.set_day(day.add_days(1)),
469            "up" if on_grid => self.set_day(day.add_days(-7)),
470            "down" if on_grid => self.set_day(day.add_days(7)),
471            "left" => self.move_part(-1),
472            "right" => self.move_part(1),
473            "up" => self.step_field(1),
474            "down" => self.step_field(-1),
475            "previous-month" if self.has_date() => self.set_day(day.add_months(-1)),
476            "next-month" if self.has_date() => self.set_day(day.add_months(1)),
477            "previous-year" if self.has_date() => self.set_day(day.add_months(-12)),
478            "next-year" if self.has_date() => self.set_day(day.add_months(12)),
479            "first-day" if on_grid => self.set_day(day.first_of_month()),
480            "last-day" if on_grid => self.set_day(day.last_of_month()),
481            "today" if self.has_date() => self.set_day(self.today),
482            _ => return Flow::Ignored,
483        }
484        Flow::Continue
485    }
486
487    fn key(&mut self, key: Key) -> Flow<DateTime> {
488        let plain_key = !key.modifiers.ctrl && !key.modifiers.alt;
489        // Digits and AM/PM in a time field come before the bindings (`h`
490        // and `l` move, but not in the middle of `14`).
491        if plain_key && self.part != Part::Grid {
492            match key.code {
493                KeyCode::Char(c @ '0'..='9') if self.part != Part::Half => {
494                    self.type_digit(c);
495                    return Flow::Continue;
496                }
497                KeyCode::Char(c @ ('a' | 'A' | 'p' | 'P')) if self.twelve_hour => {
498                    let pm = c.eq_ignore_ascii_case(&'p');
499                    let hour = self.value.time.hour % 12 + if pm { 12 } else { 0 };
500                    self.value.time.hour = hour;
501                    return Flow::Continue;
502                }
503                _ => {}
504            }
505        }
506        let Some(action) = self.keymap.action(key).map(str::to_string) else {
507            return Flow::Ignored;
508        };
509        // A move away from a field ends what was typed into it.
510        self.typed.clear();
511        self.act(&action)
512    }
513
514    /// Where the time row is, under the question (and the grid).
515    fn time_row(&self) -> usize {
516        if self.has_date() { GRID_TOP + WEEKS } else { 1 }
517    }
518
519    /// The day under a click at `column`, `row`, if a day is there.
520    fn day_at(&self, column: usize, row: usize) -> Option<Date> {
521        if !self.has_date() || !(GRID_TOP..GRID_TOP + WEEKS).contains(&row) || column < 2 {
522            return None;
523        }
524        let cell = (column - 1) / 3;
525        if cell >= 7 {
526            return None;
527        }
528        let first = self.value.date.first_of_month();
529        let at = (row - GRID_TOP) * 7 + cell;
530        let day = (at + 1).checked_sub(first.weekday() as usize)?;
531        (1..=Date::days_in_month(first.year, first.month) as usize)
532            .contains(&day)
533            .then_some(Date {
534                day: day as u32,
535                ..first
536            })
537    }
538
539    /// The time field under `column` of the time row.
540    fn field_at(&self, column: usize) -> Option<Part> {
541        let mut at = self.time_label().len();
542        for (part, width) in self.time_fields() {
543            if (at..at + width).contains(&column) {
544                return Some(part);
545            }
546            at += width + 1;
547        }
548        None
549    }
550
551    fn mouse_event(&mut self, kind: MouseKind, column: usize, row: usize) -> Flow<DateTime> {
552        let over_grid = self.has_date() && row < GRID_TOP + WEEKS;
553        match kind {
554            MouseKind::Down(Button::Left) => {
555                if let Some(day) = self.day_at(column, row) {
556                    if !self.day_allowed(day) {
557                        return Flow::Continue;
558                    }
559                    self.part = Part::Grid;
560                    if self.clicked == Some(day) && self.value.date == day {
561                        return self.pick();
562                    }
563                    self.value.date = day;
564                    self.clicked = Some(day);
565                } else if self.has_time()
566                    && row == self.time_row()
567                    && let Some(part) = self.field_at(column)
568                {
569                    self.part = part;
570                    self.typed.clear();
571                }
572            }
573            MouseKind::ScrollUp | MouseKind::ScrollDown => {
574                let by = if kind == MouseKind::ScrollUp { -1 } else { 1 };
575                if over_grid {
576                    self.set_day(self.value.date.add_months(by));
577                } else if self.has_time() && self.part != Part::Grid {
578                    // The wheel turns the focused field, as ↑ does.
579                    self.step_field(-(by as i64));
580                }
581            }
582            _ => return Flow::Ignored,
583        }
584        Flow::Continue
585    }
586
587    fn event(&mut self, event: &Event) -> Flow<DateTime> {
588        if let Some(mouse) = event.mouse() {
589            self.message = None;
590            return self.mouse_event(mouse.kind, mouse.column as usize, mouse.row as usize);
591        }
592        match event.key() {
593            Some(key) => {
594                self.message = None;
595                self.key(key)
596            }
597            None => Flow::Ignored,
598        }
599    }
600
601    /// The text before the time fields.
602    fn time_label(&self) -> &'static str {
603        if self.has_date() { "  Time " } else { "  " }
604    }
605
606    /// The time's fields and their widths, with brackets round the
607    /// focused one.
608    fn time_fields(&self) -> Vec<(Part, usize)> {
609        let mut fields = vec![(Part::Hour, 2), (Part::Minute, 2)];
610        if self.seconds {
611            fields.push((Part::Second, 2));
612        }
613        if self.twelve_hour {
614            fields.push((Part::Half, 2));
615        }
616        fields
617            .into_iter()
618            .map(|(part, width)| (part, width + if part == self.part { 2 } else { 0 }))
619            .collect()
620    }
621
622    fn time_line(&self, width: usize) -> Vec<Segment> {
623        let theme = &self.theme;
624        let time = self.value.time;
625        let out = self
626            .refusal(self.value)
627            .is_some_and(|_| self.min.is_some() || self.max.is_some());
628        let reverse = Style::parse("reverse").expect("a built-in style");
629        let mut line = vec![plain(self.time_label())];
630        let (hour, pm) = time.twelve_hour();
631        for (index, (part, _)) in self.time_fields().into_iter().enumerate() {
632            if index > 0 {
633                line.push(plain(if part == Part::Half { " " } else { ":" }));
634            }
635            let shown = match part {
636                Part::Hour if self.twelve_hour => format!("{hour:02}"),
637                Part::Hour => format!("{:02}", time.hour),
638                Part::Minute => format!("{:02}", time.minute),
639                Part::Second => format!("{:02}", time.second),
640                _ => (if pm { "PM" } else { "AM" }).to_string(),
641            };
642            let shown = if part == self.part && !self.typed.is_empty() {
643                format!("{:_<2}", self.typed)
644            } else {
645                shown
646            };
647            if part == self.part {
648                line.push(text(format!("[{shown}]"), &theme.focused.combine(&reverse)));
649            } else if out {
650                line.push(text(shown, &theme.error));
651            } else {
652                line.push(plain(shown));
653            }
654        }
655        fit(line, width)
656    }
657
658    fn grid_lines(&self, width: usize) -> Vec<Vec<Segment>> {
659        let theme = &self.theme;
660        let selected = self.value.date;
661        let first = selected.first_of_month();
662        let title = format!("{} {}", selected.month_name(), selected.year);
663        let pad = GRID_WIDTH.saturating_sub(title.len()) / 2;
664        let title_style = Style::parse("bold").expect("a built-in style");
665        let mut lines = vec![
666            fit(
667                vec![plain(" ".repeat(pad)), text(title, &title_style)],
668                width,
669            ),
670            fit(vec![text("  Mo Tu We Th Fr Sa Su", &theme.hint)], width),
671        ];
672        let reverse = Style::parse("reverse").expect("a built-in style");
673        let underline = Style::parse("underline").expect("a built-in style");
674        let today_style = Style::parse("bold").expect("a built-in style");
675        let chosen = if self.part == Part::Grid {
676            theme.focused.combine(&reverse)
677        } else {
678            underline
679        };
680        let offset = first.weekday() as usize;
681        let days = Date::days_in_month(first.year, first.month) as usize;
682        for week in 0..WEEKS {
683            let mut line = vec![plain(" ")];
684            for cell in 0..7 {
685                let at = week * 7 + cell;
686                let day = (at + 1)
687                    .checked_sub(offset)
688                    .filter(|day| (1..=days).contains(day));
689                let is = |cell: usize| {
690                    (week * 7 + cell + 1)
691                        .checked_sub(offset)
692                        .is_some_and(|day| day == selected.day as usize)
693                };
694                // The separator before the day: a bracket opens the
695                // selected day and closes the one before.
696                line.push(plain(if is(cell) {
697                    "["
698                } else if cell > 0 && is(cell - 1) {
699                    "]"
700                } else {
701                    " "
702                }));
703                let Some(day) = day else {
704                    line.push(plain("  "));
705                    continue;
706                };
707                let date = Date {
708                    day: day as u32,
709                    ..first
710                };
711                let label = format!("{day:>2}");
712                line.push(if date == selected {
713                    text(label, &chosen)
714                } else if !self.day_allowed(date) {
715                    text(label, &theme.hint)
716                } else if date == self.today {
717                    text(label, &today_style)
718                } else {
719                    plain(label)
720                });
721            }
722            if is_last_selected(week, offset, selected.day) {
723                line.push(plain("]"));
724            }
725            lines.push(fit(line, width));
726        }
727        lines
728    }
729
730    fn hint(&self) -> String {
731        let mut hint = String::from("  ");
732        hint.push_str(match self.part {
733            Part::Grid => "←→↑↓ day · pgup/pgdn month · t today",
734            _ => "←→ field · ↑↓ change · type digits",
735        });
736        if self.parts().len() > 1 {
737            hint.push_str(match (self.part, self.mode) {
738                (Part::Grid, Mode::DateTime) => " · tab time",
739                (_, Mode::DateTime) => " · tab date",
740                _ => " · tab next",
741            });
742        }
743        hint.push_str(" · enter pick · esc cancel");
744        hint
745    }
746
747    /// The range, as the limits that can be picked.
748    fn range_line(&self) -> Option<String> {
749        let latest = self.latest().map(|at| self.format(at));
750        match (self.earliest_text(), latest) {
751            (None, None) => None,
752            (Some(min), Some(max)) => Some(format!("  from {min} to {max}")),
753            (Some(min), None) => Some(format!("  from {min}")),
754            (None, Some(max)) => Some(format!("  until {max}")),
755        }
756    }
757
758    fn render(&self, context: &Context<'_>) -> View {
759        let width = context.width;
760        self.width.set(width);
761        let theme = &self.theme;
762        let mut header = question(theme, &self.prompt);
763        if let Some(answer) = &self.answer {
764            header.push(match answer {
765                Some(answer) => text(answer.clone(), &theme.answer),
766                None => text("cancelled", &theme.hint),
767            });
768            return View::new(vec![fit(header, width)]);
769        }
770        // The value so far, as it would be answered.
771        header.push(plain(self.format(self.value)));
772        let mut lines = vec![fit(header, width)];
773        if self.has_date() {
774            lines.extend(self.grid_lines(width));
775        }
776        if self.has_time() {
777            lines.push(self.time_line(width));
778        }
779        if let Some(range) = self.range_line() {
780            lines.push(fit(vec![text(range, &theme.hint)], width));
781        }
782        lines.push(fit(vec![text(self.hint(), &theme.hint)], width));
783        if let Some(message) = &self.message {
784            lines.push(fit(
785                vec![text(format!("  ✗ {message}"), &theme.error)],
786                width,
787            ));
788        }
789        View::new(lines)
790    }
791
792    /// What the line-based form expects, for its prompt.
793    fn shape(&self) -> &'static str {
794        match (self.mode, self.seconds) {
795            (Mode::Date, _) => "YYYY-MM-DD",
796            (Mode::Time, false) => "HH:MM",
797            (Mode::Time, true) => "HH:MM:SS",
798            (Mode::DateTime, false) => "YYYY-MM-DD HH:MM",
799            (Mode::DateTime, true) => "YYYY-MM-DD HH:MM:SS",
800        }
801    }
802
803    fn parse(&self, line: &str) -> Result<DateTime, String> {
804        match self.mode {
805            Mode::Date => line.parse::<Date>().map(DateTime::from),
806            Mode::Time => line
807                .parse::<Time>()
808                .map(|time| DateTime::new(self.value.date, time)),
809            Mode::DateTime => line.parse::<DateTime>(),
810        }
811    }
812
813    fn ask(&mut self, io: &mut dyn LineIo) -> Result<Option<DateTime>, NotInteractive> {
814        let mut prompt = format!("{} ({}", self.prompt, self.shape());
815        if let Some(range) = self.range_line() {
816            prompt.push_str(&format!(", {}", range.trim()));
817        }
818        prompt.push(')');
819        if let Some(default) = self.default {
820            prompt.push_str(&format!(" [{}]", self.format(default)));
821        }
822        io.write(&format!("{prompt}: "));
823        let line = match io.read_line() {
824            Some(line) if !line.trim().is_empty() => line,
825            // Input ended, or an empty line: the default answers, checked
826            // as a typed answer is.
827            ended => {
828                return match self.default {
829                    Some(default) => self.checked(default).map(Some),
830                    None if ended.is_none() => Err(NotInteractive::Ended),
831                    None => Err(NotInteractive::Invalid(format!(
832                        "no answer given ({})",
833                        self.shape()
834                    ))),
835                };
836            }
837        };
838        let at = self.parse(&line).map_err(NotInteractive::Invalid)?;
839        self.checked(at).map(Some)
840    }
841
842    /// `at` as the picker answers it (to the minute without seconds), or
843    /// why it is refused, as the line-based form says it.
844    fn checked(&self, at: DateTime) -> Result<DateTime, NotInteractive> {
845        let at = self.shown(at);
846        match self.refusal(at) {
847            Some(why) => Err(NotInteractive::Invalid(format!(
848                "{}: {why}",
849                self.format(at)
850            ))),
851            None => Ok(at),
852        }
853    }
854}
855
856/// Whether the selected `day` is the last cell of `week` (Sunday), so the
857/// row's closing bracket is after it.
858fn is_last_selected(week: usize, offset: usize, day: u32) -> bool {
859    (week * 7 + 7).checked_sub(offset) == Some(day as usize)
860}
861
862/// The builders every picker has.
863macro_rules! shared_builders {
864    () => {
865        pub fn theme(mut self, theme: Theme) -> Self {
866            self.0.theme = theme;
867            self
868        }
869
870        /// Report the mouse (#476): a click selects a day (a second click
871        /// on it picks it) or focuses a time field, and the wheel turns the
872        /// month or the focused field.
873        pub fn with_mouse(mut self, on: bool) -> Self {
874            self.0.mouse = on;
875            self
876        }
877
878        /// Make `keys` do `action` (see [`date_keymap`]) on this picker
879        /// only; no keys unbinds it.
880        pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
881            self.0.keymap.rebind(action, keys);
882            self
883        }
884    };
885}
886
887/// The builders of a picker with a time.
888macro_rules! time_builders {
889    () => {
890        /// Pick the seconds too. Without them (the default) the picker
891        /// works to the minute, whatever order the builders come in: the
892        /// start, the default and a typed answer drop their seconds, the
893        /// earliest rounds up to a minute and the latest down.
894        pub fn seconds(mut self, on: bool) -> Self {
895            self.0.seconds = on;
896            self
897        }
898
899        /// Show a 12-hour clock with AM and PM (`a` and `p` set them).
900        pub fn twelve_hour(mut self, on: bool) -> Self {
901            self.0.twelve_hour = on;
902            self
903        }
904    };
905}
906
907/// The builders of a picker with a date.
908macro_rules! date_builders {
909    () => {
910        /// The day marked as today and `t` goes to (default: today in
911        /// UTC).
912        pub fn today(mut self, today: Date) -> Self {
913            self.0.today = today;
914            self
915        }
916    };
917}
918
919/// Pick a day from a month grid. Returns the [`Date`].
920///
921/// ```
922/// use rich_interact::datetime::Date;
923/// use rich_interact::headless::{self, Script};
924/// use rich_interact::{DatePicker, Outcome};
925///
926/// let picker = DatePicker::new("Due").value(Date::new(2026, 10, 17));
927/// let script = Script::new().keys("right down enter");
928/// let (outcome, record) = headless::run(picker, script, 40, 14);
929/// assert_eq!(outcome.unwrap(), Outcome::Done(Date::new(2026, 10, 25)));
930/// assert_eq!(record.last_frame(), "? Due › 2026-10-25");
931/// ```
932#[must_use = "a component does nothing until it is run or mounted"]
933pub struct DatePicker(Chooser);
934
935impl DatePicker {
936    pub fn new(prompt: impl Into<String>) -> DatePicker {
937        DatePicker(Chooser::new(prompt.into(), Mode::Date))
938    }
939
940    shared_builders!();
941    date_builders!();
942
943    /// Start on this day.
944    pub fn value(mut self, date: Date) -> Self {
945        self.0.value.date = date;
946        self.0.value_given = true;
947        self
948    }
949
950    /// Answer this day without a terminal and to an empty line, and start
951    /// on it unless [`value`](Self::value) says where to start.
952    pub fn default(mut self, date: Date) -> Self {
953        if !self.0.value_given {
954            self.0.value.date = date;
955        }
956        self.0.default = Some(date.into());
957        self
958    }
959
960    /// The earliest day that can be picked.
961    pub fn min(mut self, date: Date) -> Self {
962        self.0.min = Some(date.at(Time::MIDNIGHT));
963        self
964    }
965
966    /// The latest day that can be picked.
967    pub fn max(mut self, date: Date) -> Self {
968        self.0.max = Some(date.at(Time::LAST));
969        self
970    }
971
972    /// Refuse a day `check` returns an error for (a weekend, a holiday):
973    /// Enter shows the error and waits.
974    pub fn validate(
975        mut self,
976        check: impl Fn(Date) -> Result<(), String> + Send + Sync + 'static,
977    ) -> Self {
978        self.0.check = Some(Arc::new(move |at: DateTime| check(at.date)));
979        self
980    }
981
982    /// The day selected so far.
983    pub fn selected(&self) -> Date {
984        self.0.value.date
985    }
986}
987
988/// Pick a time of day, field by field. Returns the [`Time`].
989///
990/// ```
991/// use rich_interact::datetime::Time;
992/// use rich_interact::headless::{self, Script};
993/// use rich_interact::{Outcome, TimePicker};
994///
995/// let picker = TimePicker::new("Start").value(Time::new(9, 0, 0));
996/// let script = Script::new().text("1415").keys("enter");
997/// let (outcome, _) = headless::run(picker, script, 40, 6);
998/// assert_eq!(outcome.unwrap(), Outcome::Done(Time::new(14, 15, 0)));
999/// ```
1000#[must_use = "a component does nothing until it is run or mounted"]
1001pub struct TimePicker(Chooser);
1002
1003impl TimePicker {
1004    pub fn new(prompt: impl Into<String>) -> TimePicker {
1005        TimePicker(Chooser::new(prompt.into(), Mode::Time))
1006    }
1007
1008    shared_builders!();
1009    time_builders!();
1010
1011    /// Start at this time.
1012    pub fn value(mut self, time: Time) -> Self {
1013        self.0.value.time = time;
1014        self.0.value_given = true;
1015        self
1016    }
1017
1018    /// Answer this time without a terminal and to an empty line, and start
1019    /// at it unless [`value`](Self::value) says where to start.
1020    pub fn default(mut self, time: Time) -> Self {
1021        if !self.0.value_given {
1022            self.0.value.time = time;
1023        }
1024        self.0.default = Some(self.0.value.date.at(time));
1025        self
1026    }
1027
1028    /// The earliest time that can be picked.
1029    pub fn min(mut self, time: Time) -> Self {
1030        self.0.min = Some(self.0.value.date.at(time));
1031        self
1032    }
1033
1034    /// The latest time that can be picked.
1035    pub fn max(mut self, time: Time) -> Self {
1036        self.0.max = Some(self.0.value.date.at(time));
1037        self
1038    }
1039
1040    /// Refuse a time `check` returns an error for: Enter shows the error
1041    /// and waits.
1042    pub fn validate(
1043        mut self,
1044        check: impl Fn(Time) -> Result<(), String> + Send + Sync + 'static,
1045    ) -> Self {
1046        self.0.check = Some(Arc::new(move |at: DateTime| check(at.time)));
1047        self
1048    }
1049
1050    /// The time selected so far.
1051    pub fn selected(&self) -> Time {
1052        self.0.shown(self.0.value).time
1053    }
1054}
1055
1056/// Pick a day and a time: the month grid, then the time under it (Tab
1057/// moves between them). Returns the [`DateTime`].
1058///
1059/// ```
1060/// use rich_interact::datetime::{Date, DateTime, Time};
1061/// use rich_interact::headless::{self, Script};
1062/// use rich_interact::{DateTimePicker, Outcome};
1063///
1064/// let start = DateTime::new(Date::new(2026, 10, 17), Time::new(9, 0, 0));
1065/// let picker = DateTimePicker::new("Meeting").value(start);
1066/// let script = Script::new().keys("right tab").text("1430").keys("enter");
1067/// let (outcome, record) = headless::run(picker, script, 40, 16);
1068/// let expected = DateTime::new(Date::new(2026, 10, 18), Time::new(14, 30, 0));
1069/// assert_eq!(outcome.unwrap(), Outcome::Done(expected));
1070/// assert_eq!(record.last_frame(), "? Meeting › 2026-10-18 14:30");
1071/// ```
1072#[must_use = "a component does nothing until it is run or mounted"]
1073pub struct DateTimePicker(Chooser);
1074
1075impl DateTimePicker {
1076    pub fn new(prompt: impl Into<String>) -> DateTimePicker {
1077        DateTimePicker(Chooser::new(prompt.into(), Mode::DateTime))
1078    }
1079
1080    shared_builders!();
1081    date_builders!();
1082    time_builders!();
1083
1084    /// Start at this moment.
1085    pub fn value(mut self, at: DateTime) -> Self {
1086        self.0.value = at;
1087        self.0.value_given = true;
1088        self
1089    }
1090
1091    /// Answer this moment without a terminal and to an empty line, and
1092    /// start at it unless [`value`](Self::value) says where to start.
1093    pub fn default(mut self, at: DateTime) -> Self {
1094        if !self.0.value_given {
1095            self.0.value = at;
1096        }
1097        self.0.default = Some(at);
1098        self
1099    }
1100
1101    /// The earliest moment that can be picked: days before its day are
1102    /// dimmed, and on its day an earlier time is refused.
1103    pub fn min(mut self, at: DateTime) -> Self {
1104        self.0.min = Some(at);
1105        self
1106    }
1107
1108    /// The latest moment that can be picked.
1109    pub fn max(mut self, at: DateTime) -> Self {
1110        self.0.max = Some(at);
1111        self
1112    }
1113
1114    /// Refuse a moment `check` returns an error for: Enter shows the error
1115    /// and waits.
1116    pub fn validate(
1117        mut self,
1118        check: impl Fn(DateTime) -> Result<(), String> + Send + Sync + 'static,
1119    ) -> Self {
1120        self.0.check = Some(Arc::new(check));
1121        self
1122    }
1123
1124    /// The moment selected so far.
1125    pub fn selected(&self) -> DateTime {
1126        self.0.shown(self.0.value)
1127    }
1128}
1129
1130/// The [`Component`] of a picker answering `$output`, made from the
1131/// shared state by `$from`.
1132macro_rules! picker_component {
1133    ($picker:ty, $output:ty, $from:expr) => {
1134        impl Component for $picker {
1135            type Output = $output;
1136
1137            fn start(&mut self, _context: &Context<'_>) -> Flow<$output> {
1138                // A start outside the range moves into it, and without
1139                // seconds it is to the minute.
1140                let mut value = self.0.shown(self.0.value);
1141                value.date = self.0.clamp_day(value.date);
1142                self.0.value = value;
1143                Flow::Continue
1144            }
1145
1146            fn handle(&mut self, event: &Event, _context: &Context<'_>) -> Flow<$output> {
1147                match self.0.event(event) {
1148                    Flow::Done(at) => Flow::Done($from(at)),
1149                    Flow::Cancel => Flow::Cancel,
1150                    Flow::Ignored => Flow::Ignored,
1151                    _ => Flow::Continue,
1152                }
1153            }
1154
1155            fn render(&self, context: &Context<'_>) -> View {
1156                self.0.render(context)
1157            }
1158
1159            fn mouse(&self) -> bool {
1160                self.0.mouse
1161            }
1162
1163            fn keymap(&self) -> Keymap {
1164                self.0.keymap.clone()
1165            }
1166
1167            /// Tab inside: the next part, if there is one.
1168            fn focus_step(&mut self, forward: bool) -> bool {
1169                self.0.step_part(forward)
1170            }
1171
1172            /// Tab from outside: the first part, or the last going back.
1173            fn focus_enter(&mut self, forward: bool) -> bool {
1174                self.0.enter(forward);
1175                true
1176            }
1177
1178            /// The default, unless the picker would refuse it: outside the
1179            /// range, or refused by the validator.
1180            fn default_value(&self) -> Option<$output> {
1181                self.0
1182                    .default
1183                    .and_then(|default| self.0.checked(default).ok())
1184                    .map($from)
1185            }
1186
1187            fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<$output>, NotInteractive> {
1188                Ok(self.0.ask(io)?.map($from))
1189            }
1190        }
1191    };
1192}
1193
1194picker_component!(DatePicker, Date, |at: DateTime| at.date);
1195picker_component!(TimePicker, Time, |at: DateTime| at.time);
1196picker_component!(DateTimePicker, DateTime, |at: DateTime| at);