Skip to main content

a2ui_tui/components/
date_time_input.rs

1//! DateTimeInput component — renders a date/time input display.
2
3use chrono::{Datelike, Duration, NaiveDateTime, NaiveTime, Timelike};
4use ratatui::{
5    Frame,
6    layout::Rect,
7    style::{Color, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Paragraph},
10};
11
12use a2ui_base::event::{EventResult, InputEvent, InputKey};
13use a2ui_base::model::component_context::ComponentContext;
14use a2ui_base::protocol::common_types::DynamicString;
15use crate::component_impl::TuiComponent;
16
17/// DateTimeInput component implementation.
18///
19/// Renders a bordered block with a date/time icon and the ISO date string.
20/// Display: `[📅 2026-06-13 14:30]` style.
21/// Applies a default 1-cell margin.
22pub struct DateTimeInputComponent;
23
24impl TuiComponent for DateTimeInputComponent {
25    fn name(&self) -> &'static str {
26        "DateTimeInput"
27    }
28
29    fn render(
30        &self,
31        ctx: &ComponentContext,
32        area: Rect,
33        frame: &mut Frame,
34        _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
35        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
36    ) {
37        let comp_model = match ctx.components.get(&ctx.component_id) {
38            Some(m) => m,
39            None => return,
40        };
41
42        // Apply default 1-cell margin on all sides (never collapses to zero).
43        let inner = crate::layout_engine::padded_content(area);
44
45        if inner.width == 0 || inner.height == 0 {
46            return;
47        }
48
49        // Resolve label.
50        let label = match comp_model.get_property::<DynamicString>("label") {
51            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
52            None => String::new(),
53        };
54
55        // Resolve value (ISO date string).
56        let value = match comp_model.get_property::<DynamicString>("value") {
57            Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
58            None => String::new(),
59        };
60
61        // Resolve enableDate and enableTime flags.
62        let enable_date: bool = comp_model.get_property("enableDate").unwrap_or(true);
63        let enable_time: bool = comp_model.get_property("enableTime").unwrap_or(true);
64        let _min = comp_model.get_property::<DynamicString>("min")
65            .map(|ds| ctx.data_context.resolve_dynamic_string(&ds));
66        let _max = comp_model.get_property::<DynamicString>("max")
67            .map(|ds| ctx.data_context.resolve_dynamic_string(&ds));
68
69        // Choose icon based on enabled modes.
70        let icon = match (enable_date, enable_time) {
71            (true, true) => "\u{1F4C5}",   // calendar
72            (true, false) => "\u{1F4C5}",   // calendar only
73            (false, true) => "\u{23F0}",    // clock only
74            (false, false) => "\u{1F4C5}",  // default
75        };
76
77        // Build display text with appropriate icon.
78        let display_text = format!("{} {}", icon, value);
79
80        // Determine if this date-time input has keyboard focus.
81        let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
82
83        // Build bordered block with label as title.
84        let block_style = if is_focused {
85            Style::default().fg(Color::Yellow)
86        } else {
87            Style::default()
88        };
89        let mut block = Block::default().borders(Borders::ALL).style(block_style);
90        if !label.is_empty() {
91            block = block.title(Span::styled(
92                format!(" {} ", label),
93                Style::default().fg(Color::White),
94            ));
95        }
96
97        let content_area = block.inner(inner);
98        frame.render_widget(block, inner);
99
100        if content_area.width == 0 || content_area.height == 0 {
101            return;
102        }
103
104        let paragraph = Paragraph::new(Line::from(Span::styled(
105            display_text,
106            Style::default().fg(Color::White),
107        )));
108        frame.render_widget(paragraph, content_area);
109    }
110
111    fn natural_height(
112        &self,
113        _ctx: &ComponentContext,
114        _available_width: u16,
115        _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
116    ) -> Option<u16> {
117        // 1 display line + 2-cell margin + 2-cell border = 5 (render shrink(1) margin
118        // then a bordered block, so content needs area.height - 4 >= 1).
119        Some(5)
120    }
121
122    fn handle_event(
123        &self,
124        ctx: &ComponentContext,
125        event: &a2ui_base::event::InputEvent,
126    ) -> Option<a2ui_base::event::EventResult> {
127        let comp_model = ctx.components.get(&ctx.component_id)?;
128
129        // The value must be a data binding — otherwise there is no path in the
130        // data model to write the new datetime back to (mirrors slider.rs).
131        let value_ds = comp_model.get_property::<DynamicString>("value")?;
132        let binding = match value_ds {
133            DynamicString::Binding(b) => b,
134            _ => return None,
135        };
136
137        // Resolve flags (default both enabled, matching render()).
138        let enable_date: bool = comp_model.get_property("enableDate").unwrap_or(true);
139        let enable_time: bool = comp_model.get_property("enableTime").unwrap_or(true);
140
141        // Read + parse the current value. On empty/unparseable input, seed with now.
142        let current_str =
143            ctx.data_context.resolve_dynamic_string(&DynamicString::Binding(binding.clone()));
144        let dt = parse_value(&current_str).unwrap_or_else(|| chrono::Local::now().naive_local());
145
146        // Only the four arrow keys are handled; everything else bubbles up.
147        let direction = match event {
148            InputEvent::KeyPress { key: InputKey::Up } => Direction::Forward,
149            InputEvent::KeyPress { key: InputKey::Right } => Direction::Forward,
150            InputEvent::KeyPress { key: InputKey::Down } => Direction::Backward,
151            InputEvent::KeyPress { key: InputKey::Left } => Direction::Backward,
152            _ => return None,
153        };
154        let axis = match event {
155            InputEvent::KeyPress { key: InputKey::Up } | InputEvent::KeyPress { key: InputKey::Down } => Axis::Primary,
156            InputEvent::KeyPress { key: InputKey::Left } | InputEvent::KeyPress { key: InputKey::Right } => Axis::Secondary,
157            _ => return None,
158        };
159
160        let new_dt = apply_delta(dt, enable_date, enable_time, axis, direction);
161        let formatted = format_value(&new_dt, enable_date, enable_time);
162
163        Some(EventResult::DataUpdate {
164            path: binding.path.clone(),
165            value: serde_json::json!(formatted),
166        })
167    }
168}
169
170/// Which axis an arrow key maps to (Up/Down = primary, Left/Right = secondary).
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum Axis {
173    Primary,
174    Secondary,
175}
176
177/// Whether the increment should be positive or negative.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum Direction {
180    Forward,
181    Backward,
182}
183
184/// Parse an ISO datetime string into a [`NaiveDateTime`].
185///
186/// Accepts full datetime (`YYYY-MM-DDTHH:MM:SS`), date-only (`YYYY-MM-DD`),
187/// and time-only (`HH:MM:SS`) shapes.
188fn parse_value(value: &str) -> Option<NaiveDateTime> {
189    let trimmed = value.trim();
190    if trimmed.is_empty() {
191        return None;
192    }
193    // Full ISO datetime (optionally with fractional seconds / space separator).
194    NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
195        .or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f"))
196        .or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S"))
197        .ok()
198        // Date-only: anchor to midnight.
199        .or_else(|| {
200            chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
201                .ok()
202                .and_then(|d| d.and_hms_opt(0, 0, 0))
203        })
204        // Time-only: anchor to today's date.
205        .or_else(|| {
206            NaiveTime::parse_from_str(trimmed, "%H:%M:%S")
207                .ok()
208                .map(|t| chrono::Local::now().naive_local().date().and_time(t))
209        })
210}
211
212/// Apply an increment to a datetime based on the enabled modes and the key axis.
213///
214/// Increment rules:
215/// - `enableDate && enableTime`: primary(Up/Down) = ±1 day, secondary(Left/Right) = ±1 hour
216/// - `enableDate` only: primary = ±1 day, secondary = ±1 month
217/// - `enableTime` only: primary = ±1 minute, secondary = ±1 hour
218///
219/// Month arithmetic is clamped to the last valid day of the target month
220/// (chrono's behavior on `with_month` overflow would otherwise return `None`).
221fn apply_delta(
222    dt: NaiveDateTime,
223    enable_date: bool,
224    enable_time: bool,
225    axis: Axis,
226    direction: Direction,
227) -> NaiveDateTime {
228    let sign: i64 = if direction == Direction::Forward { 1 } else { -1 };
229
230    let duration_step = |days: i64, secs: i64| -> NaiveDateTime {
231        dt + Duration::days(days) + Duration::seconds(secs)
232    };
233
234    match (enable_date, enable_time) {
235        (true, true) => match axis {
236            Axis::Primary => duration_step(sign, 0),            // ±1 day
237            Axis::Secondary => duration_step(0, sign * 3600),   // ±1 hour
238        },
239        (true, false) => match axis {
240            Axis::Primary => duration_step(sign, 0),            // ±1 day
241            Axis::Secondary => add_months(dt, sign),            // ±1 month
242        },
243        (false, true) => match axis {
244            Axis::Primary => duration_step(0, sign * 60),       // ±1 minute
245            Axis::Secondary => duration_step(0, sign * 3600),   // ±1 hour
246        },
247        // Neither enabled is a degenerate config; fall back to day stepping so the
248        // key still does *something* rather than silently swallowing the event.
249        (false, false) => duration_step(sign, 0),
250    }
251}
252
253/// Add (or subtract) `n` months from a datetime, clamping the day to the
254/// last valid day of the resulting month (e.g. Jan 31 -> Feb 28/29).
255fn add_months(dt: NaiveDateTime, n: i64) -> NaiveDateTime {
256    let date = dt.date();
257    let year = date.year() as i64;
258    let month = date.month() as i64;
259    let day = date.day();
260
261    let total = year * 12 + (month - 1) + n;
262    let new_year = total.div_euclid(12) as i32;
263    let new_month = total.rem_euclid(12) as u32 + 1;
264
265    let last_day = days_in_month(new_year, new_month);
266    let clamped_day = day.min(last_day);
267
268    match chrono::NaiveDate::from_ymd_opt(new_year, new_month, clamped_day) {
269        Some(d) => d.and_hms_opt(dt.hour(), dt.minute(), dt.second())
270            .unwrap_or(dt),
271        None => dt,
272    }
273}
274
275/// Number of days in a given month, accounting for leap years.
276fn days_in_month(year: i32, month: u32) -> u32 {
277    match month {
278        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
279        4 | 6 | 9 | 11 => 30,
280        2 => {
281            if is_leap_year(year) {
282                29
283            } else {
284                28
285            }
286        }
287        _ => 30,
288    }
289}
290
291/// Whether `year` is a leap year under the Gregorian calendar.
292fn is_leap_year(year: i32) -> bool {
293    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
294}
295
296/// Format a datetime back to the ISO shape appropriate for the enabled modes.
297///
298/// - `!enable_time`: `YYYY-MM-DD` (date-only)
299/// - `!enable_date`: `HH:MM:SS` (time-only)
300/// - both: `YYYY-MM-DDTHH:MM:SS`
301fn format_value(dt: &NaiveDateTime, enable_date: bool, enable_time: bool) -> String {
302    match (enable_date, enable_time) {
303        (true, true) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
304        (true, false) => dt.format("%Y-%m-%d").to_string(),
305        (false, true) => dt.format("%H:%M:%S").to_string(),
306        (false, false) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn dt(year: i32, month: u32, day: u32, h: u32, m: u32, s: u32) -> NaiveDateTime {
315        chrono::NaiveDate::from_ymd_opt(year, month, day)
316            .unwrap()
317            .and_hms_opt(h, m, s)
318            .unwrap()
319    }
320
321    #[test]
322    fn parse_full_iso_datetime() {
323        let parsed = parse_value("2026-06-13T14:30:00").unwrap();
324        assert_eq!(parsed, dt(2026, 6, 13, 14, 30, 0));
325    }
326
327    #[test]
328    fn parse_date_only_anchors_midnight() {
329        let parsed = parse_value("2026-01-15").unwrap();
330        assert_eq!(parsed, dt(2026, 1, 15, 0, 0, 0));
331    }
332
333    #[test]
334    fn parse_empty_returns_none() {
335        assert!(parse_value("").is_none());
336        assert!(parse_value("   ").is_none());
337        assert!(parse_value("not-a-date").is_none());
338    }
339
340    #[test]
341    fn date_and_time_mode_day_increment() {
342        let start = dt(2026, 6, 13, 14, 30, 0);
343        // Up -> +1 day
344        let after = apply_delta(start, true, true, Axis::Primary, Direction::Forward);
345        assert_eq!(after, dt(2026, 6, 14, 14, 30, 0));
346        // Down -> -1 day (back to start)
347        let back = apply_delta(after, true, true, Axis::Primary, Direction::Backward);
348        assert_eq!(back, start);
349    }
350
351    #[test]
352    fn date_and_time_mode_hour_increment() {
353        let start = dt(2026, 6, 13, 14, 30, 0);
354        // Right -> +1 hour
355        let after = apply_delta(start, true, true, Axis::Secondary, Direction::Forward);
356        assert_eq!(after, dt(2026, 6, 13, 15, 30, 0));
357        // Left -> -1 hour
358        let back = apply_delta(after, true, true, Axis::Secondary, Direction::Backward);
359        assert_eq!(back, start);
360    }
361
362    #[test]
363    fn date_and_time_hour_wraps_across_day() {
364        let start = dt(2026, 6, 13, 23, 30, 0);
365        let after = apply_delta(start, true, true, Axis::Secondary, Direction::Forward);
366        assert_eq!(after, dt(2026, 6, 14, 0, 30, 0));
367    }
368
369    #[test]
370    fn date_only_mode_month_increment() {
371        let start = dt(2026, 1, 15, 0, 0, 0);
372        // Right -> +1 month
373        let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
374        assert_eq!(after, dt(2026, 2, 15, 0, 0, 0));
375        // Left -> -1 month
376        let back = apply_delta(after, true, false, Axis::Secondary, Direction::Backward);
377        assert_eq!(back, start);
378    }
379
380    #[test]
381    fn date_only_month_clamps_jan31_to_feb() {
382        let start = dt(2026, 1, 31, 0, 0, 0);
383        let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
384        // 2026 is not a leap year, so Feb has 28 days.
385        assert_eq!(after, dt(2026, 2, 28, 0, 0, 0));
386    }
387
388    #[test]
389    fn date_only_month_clamps_to_leap_feb() {
390        // 2024 is a leap year.
391        let start = dt(2024, 1, 31, 0, 0, 0);
392        let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
393        assert_eq!(after, dt(2024, 2, 29, 0, 0, 0));
394    }
395
396    #[test]
397    fn date_only_month_wraps_across_year() {
398        let start = dt(2026, 12, 15, 0, 0, 0);
399        let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
400        assert_eq!(after, dt(2027, 1, 15, 0, 0, 0));
401        // Backward one month from December 2026 is November 2026 (same year).
402        let back = apply_delta(start, true, false, Axis::Secondary, Direction::Backward);
403        assert_eq!(back, dt(2026, 11, 15, 0, 0, 0));
404        // A genuine year-wrap backward: January minus one month.
405        let jan = dt(2026, 1, 15, 0, 0, 0);
406        let prev_year = apply_delta(jan, true, false, Axis::Secondary, Direction::Backward);
407        assert_eq!(prev_year, dt(2025, 12, 15, 0, 0, 0));
408    }
409
410    #[test]
411    fn time_only_mode_minute_increment() {
412        let start = dt(2026, 6, 13, 14, 30, 0);
413        // Up -> +1 minute
414        let after = apply_delta(start, false, true, Axis::Primary, Direction::Forward);
415        assert_eq!(after, dt(2026, 6, 13, 14, 31, 0));
416        // Down -> -1 minute
417        let back = apply_delta(after, false, true, Axis::Primary, Direction::Backward);
418        assert_eq!(back, start);
419    }
420
421    #[test]
422    fn time_only_mode_hour_increment() {
423        let start = dt(2026, 6, 13, 14, 30, 0);
424        // Right -> +1 hour
425        let after = apply_delta(start, false, true, Axis::Secondary, Direction::Forward);
426        assert_eq!(after, dt(2026, 6, 13, 15, 30, 0));
427    }
428
429    #[test]
430    fn format_full_datetime() {
431        let value = dt(2026, 6, 13, 14, 30, 5);
432        assert_eq!(format_value(&value, true, true), "2026-06-13T14:30:05");
433    }
434
435    #[test]
436    fn format_date_only() {
437        let value = dt(2026, 6, 13, 14, 30, 5);
438        assert_eq!(format_value(&value, true, false), "2026-06-13");
439    }
440
441    #[test]
442    fn format_time_only() {
443        let value = dt(2026, 6, 13, 14, 30, 5);
444        assert_eq!(format_value(&value, false, true), "14:30:05");
445    }
446
447    #[test]
448    fn leap_year_detection() {
449        assert!(is_leap_year(2024));
450        assert!(!is_leap_year(2026));
451        assert!(!is_leap_year(1900)); // divisible by 100 but not 400
452        assert!(is_leap_year(2000)); // divisible by 400
453    }
454}