Skip to main content

cli_ui/prompt/
date.rs

1//! Date picker — built on the [`Prompt`] trait.
2
3use crate::styles::{paint, DIM, WHITE};
4
5use super::core::{Frame, Key, Prompt, RenderCtx, Step};
6use super::error::{PromptError, Result};
7use super::theme;
8
9#[derive(Clone, Copy, PartialEq, Eq)]
10enum Seg {
11    Y,
12    M,
13    D,
14}
15
16/// Proleptic Gregorian calendar date — used by [`date()`] and [`DatePrompt`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Date {
19    /// Year, e.g. `2026`.
20    pub year: i32,
21    /// Month, `1..=12`.
22    pub month: u32,
23    /// Day of month, `1..=31` (clamped to the month's actual length).
24    pub day: u32,
25}
26
27impl Date {
28    /// Today, computed from `SystemTime::now()` in UTC.
29    pub fn today() -> Self {
30        let secs = std::time::SystemTime::now()
31            .duration_since(std::time::UNIX_EPOCH)
32            .map(|d| d.as_secs())
33            .unwrap_or(0);
34        from_unix_days((secs / 86_400) as i64)
35    }
36    /// `yyyy-mm-dd` string, e.g. `2026-06-30`.
37    pub fn iso(&self) -> String {
38        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
39    }
40    fn days_in_month(&self) -> u32 {
41        match self.month {
42            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
43            4 | 6 | 9 | 11 => 30,
44            2 => {
45                if is_leap(self.year) {
46                    29
47                } else {
48                    28
49                }
50            }
51            _ => 30,
52        }
53    }
54    fn clamp(&mut self) {
55        if self.month < 1 {
56            self.month = 12;
57            self.year -= 1;
58        }
59        if self.month > 12 {
60            self.month = 1;
61            self.year += 1;
62        }
63        let max = self.days_in_month();
64        if self.day < 1 {
65            self.day = max;
66        }
67        if self.day > max {
68            self.day = max;
69        }
70    }
71}
72
73fn is_leap(y: i32) -> bool {
74    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
75}
76
77fn from_unix_days(mut days: i64) -> Date {
78    let mut y = 1970i32;
79    loop {
80        let in_year = if is_leap(y) { 366 } else { 365 };
81        if days < in_year as i64 {
82            break;
83        }
84        days -= in_year as i64;
85        y += 1;
86    }
87    let mut m = 1u32;
88    let months_len = |year: i32, mo: u32| -> i64 {
89        match mo {
90            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
91            4 | 6 | 9 | 11 => 30,
92            2 => {
93                if is_leap(year) {
94                    29
95                } else {
96                    28
97                }
98            }
99            _ => 0,
100        }
101    };
102    while days >= months_len(y, m) {
103        days -= months_len(y, m);
104        m += 1;
105    }
106    Date {
107        year: y,
108        month: m,
109        day: days as u32 + 1,
110    }
111}
112
113/// Builder returned by [`date()`].
114pub struct DatePrompt {
115    label: String,
116    value: Date,
117    min: Option<Date>,
118    max: Option<Date>,
119    seg: Seg,
120}
121
122/// Pick a date with three editable segments (year / month / day). Navigate
123/// segments with ←/→, adjust with ↑/↓.
124///
125/// ```no_run
126/// use cli_ui::prompt::date::date;
127///
128/// let birthday = date("When were you born?").run()?;
129/// # Ok::<(), cli_ui::prompt::PromptError>(())
130/// ```
131pub fn date(label: impl Into<String>) -> DatePrompt {
132    DatePrompt {
133        label: label.into(),
134        value: Date::today(),
135        min: None,
136        max: None,
137        seg: Seg::Y,
138    }
139}
140
141impl DatePrompt {
142    /// Initial date shown (default: today).
143    pub fn initial(mut self, d: Date) -> Self {
144        self.value = d;
145        self
146    }
147    /// Reject dates earlier than `d`.
148    pub fn min(mut self, d: Date) -> Self {
149        self.min = Some(d);
150        self
151    }
152    /// Reject dates later than `d`.
153    pub fn max(mut self, d: Date) -> Self {
154        self.max = Some(d);
155        self
156    }
157
158    /// Run the prompt to completion.
159    pub fn run(self) -> Result<Date> {
160        super::core::run(self)
161    }
162
163    fn within_range(&self) -> bool {
164        let iso = self.value.iso();
165        if let Some(ref m) = self.min {
166            if iso < m.iso() {
167                return false;
168            }
169        }
170        if let Some(ref m) = self.max {
171            if iso > m.iso() {
172                return false;
173            }
174        }
175        true
176    }
177}
178
179impl Prompt for DatePrompt {
180    type Output = Date;
181
182    fn handle(&mut self, key: Key) -> Step<Date> {
183        match key {
184            Key::Left => {
185                self.seg = match self.seg {
186                    Seg::Y => Seg::D,
187                    Seg::M => Seg::Y,
188                    Seg::D => Seg::M,
189                };
190                Step::Continue
191            }
192            Key::Right => {
193                self.seg = match self.seg {
194                    Seg::Y => Seg::M,
195                    Seg::M => Seg::D,
196                    Seg::D => Seg::Y,
197                };
198                Step::Continue
199            }
200            Key::Up => {
201                adjust(&mut self.value, self.seg, 1);
202                self.value.clamp();
203                Step::Continue
204            }
205            Key::Down => {
206                adjust(&mut self.value, self.seg, -1);
207                self.value.clamp();
208                Step::Continue
209            }
210            Key::Enter => {
211                if !self.within_range() {
212                    Step::Reject("Date out of range".into())
213                } else {
214                    Step::Submit(self.value)
215                }
216            }
217            Key::Escape | Key::Interrupt => Step::Cancel,
218            _ => Step::Continue,
219        }
220    }
221
222    fn render(&self, _ctx: RenderCtx) -> Frame {
223        let mut f: Frame = Vec::with_capacity(4);
224        f.push(theme::label(&self.label));
225        let seg_y = format_seg(format!("{:04}", self.value.year), self.seg == Seg::Y);
226        let seg_m = format_seg(format!("{:02}", self.value.month), self.seg == Seg::M);
227        let seg_d = format_seg(format!("{:02}", self.value.day), self.seg == Seg::D);
228        let dash = paint(DIM, "-");
229        f.push(format!(
230            "{}  {}{dash}{}{dash}{}",
231            paint(DIM, "│"),
232            seg_y,
233            seg_m,
234            seg_d
235        ));
236        f.push(theme::hint("← → segment · ↑ ↓ adjust · Enter to confirm"));
237        f.push(theme::frame_bot(None));
238        let _ = WHITE;
239        f
240    }
241
242    fn render_answered(&self, value: &Date) -> Frame {
243        theme::answered(&self.label, &value.iso())
244            .split("\r\n")
245            .map(String::from)
246            .collect()
247    }
248
249    fn run_fallback(self) -> Result<Date> {
250        use std::io::Write;
251        let mut out = std::io::stderr();
252        write!(
253            out,
254            "  {} [yyyy-mm-dd, default {}]: ",
255            self.label,
256            self.value.iso()
257        )
258        .map_err(PromptError::Io)?;
259        out.flush().map_err(PromptError::Io)?;
260        let line = super::engine::fallback::read_line_raw()?;
261        if line.is_empty() {
262            return Ok(self.value);
263        }
264        let parts: Vec<&str> = line.split('-').collect();
265        if parts.len() != 3 {
266            return Err(PromptError::Interrupted);
267        }
268        Ok(Date {
269            year: parts[0].parse().unwrap_or(self.value.year),
270            month: parts[1].parse().unwrap_or(self.value.month),
271            day: parts[2].parse().unwrap_or(self.value.day),
272        })
273    }
274}
275
276fn adjust(d: &mut Date, seg: Seg, by: i32) {
277    match seg {
278        Seg::Y => d.year = (d.year + by).max(1),
279        Seg::M => d.month = (d.month as i32 + by).clamp(1, 12) as u32,
280        Seg::D => d.day = (d.day as i32 + by).max(1) as u32,
281    }
282}
283
284fn format_seg(value: String, active: bool) -> String {
285    let c = super::settings::colors();
286    if active {
287        paint(c.accent, &format!("\x1b[7m{value}\x1b[27m"))
288    } else {
289        paint(c.input, &value)
290    }
291}