1use 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
17pub 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 let inner = crate::layout_engine::padded_content(area);
44
45 if inner.width == 0 || inner.height == 0 {
46 return;
47 }
48
49 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 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 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 let icon = match (enable_date, enable_time) {
71 (true, true) => "\u{1F4C5}", (true, false) => "\u{1F4C5}", (false, true) => "\u{23F0}", (false, false) => "\u{1F4C5}", };
76
77 let display_text = format!("{} {}", icon, value);
79
80 let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
82
83 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 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 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 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 let current_str =
143 ctx.data_context.resolve_dynamic_string(&DynamicString::Binding(binding.clone()));
144 let dt = parse_value(¤t_str).unwrap_or_else(|| chrono::Local::now().naive_local());
145
146 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172enum Axis {
173 Primary,
174 Secondary,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum Direction {
180 Forward,
181 Backward,
182}
183
184fn parse_value(value: &str) -> Option<NaiveDateTime> {
189 let trimmed = value.trim();
190 if trimmed.is_empty() {
191 return None;
192 }
193 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 .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 .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
212fn 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), Axis::Secondary => duration_step(0, sign * 3600), },
239 (true, false) => match axis {
240 Axis::Primary => duration_step(sign, 0), Axis::Secondary => add_months(dt, sign), },
243 (false, true) => match axis {
244 Axis::Primary => duration_step(0, sign * 60), Axis::Secondary => duration_step(0, sign * 3600), },
247 (false, false) => duration_step(sign, 0),
250 }
251}
252
253fn 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
275fn 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
291fn is_leap_year(year: i32) -> bool {
293 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
294}
295
296fn 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 let after = apply_delta(start, true, true, Axis::Primary, Direction::Forward);
345 assert_eq!(after, dt(2026, 6, 14, 14, 30, 0));
346 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 let after = apply_delta(start, true, true, Axis::Secondary, Direction::Forward);
356 assert_eq!(after, dt(2026, 6, 13, 15, 30, 0));
357 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 let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
374 assert_eq!(after, dt(2026, 2, 15, 0, 0, 0));
375 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 assert_eq!(after, dt(2026, 2, 28, 0, 0, 0));
386 }
387
388 #[test]
389 fn date_only_month_clamps_to_leap_feb() {
390 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 let back = apply_delta(start, true, false, Axis::Secondary, Direction::Backward);
403 assert_eq!(back, dt(2026, 11, 15, 0, 0, 0));
404 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 let after = apply_delta(start, false, true, Axis::Primary, Direction::Forward);
415 assert_eq!(after, dt(2026, 6, 13, 14, 31, 0));
416 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 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)); assert!(is_leap_year(2000)); }
454}