use chrono::{Datelike, Duration, NaiveDateTime, NaiveTime, Timelike};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use a2ui_base::event::{EventResult, InputEvent, InputKey};
use a2ui_base::model::component_context::ComponentContext;
use a2ui_base::protocol::common_types::DynamicString;
use crate::component_impl::TuiComponent;
pub struct DateTimeInputComponent;
impl TuiComponent for DateTimeInputComponent {
fn name(&self) -> &'static str {
"DateTimeInput"
}
fn render(
&self,
ctx: &ComponentContext,
area: Rect,
frame: &mut Frame,
_render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) {
let comp_model = match ctx.components.get(&ctx.component_id) {
Some(m) => m,
None => return,
};
let inner = crate::layout_engine::padded_content(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let label = match comp_model.get_property::<DynamicString>("label") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
let value = match comp_model.get_property::<DynamicString>("value") {
Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
None => String::new(),
};
let enable_date: bool = comp_model.get_property("enableDate").unwrap_or(true);
let enable_time: bool = comp_model.get_property("enableTime").unwrap_or(true);
let _min = comp_model.get_property::<DynamicString>("min")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds));
let _max = comp_model.get_property::<DynamicString>("max")
.map(|ds| ctx.data_context.resolve_dynamic_string(&ds));
let icon = match (enable_date, enable_time) {
(true, true) => "\u{1F4C5}", (true, false) => "\u{1F4C5}", (false, true) => "\u{23F0}", (false, false) => "\u{1F4C5}", };
let display_text = format!("{} {}", icon, value);
let is_focused = ctx.focused_id.as_deref() == Some(ctx.component_id.as_str());
let block_style = if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let mut block = Block::default().borders(Borders::ALL).style(block_style);
if !label.is_empty() {
block = block.title(Span::styled(
format!(" {} ", label),
Style::default().fg(Color::White),
));
}
let content_area = block.inner(inner);
frame.render_widget(block, inner);
if content_area.width == 0 || content_area.height == 0 {
return;
}
let paragraph = Paragraph::new(Line::from(Span::styled(
display_text,
Style::default().fg(Color::White),
)));
frame.render_widget(paragraph, content_area);
}
fn natural_height(
&self,
_ctx: &ComponentContext,
_available_width: u16,
_measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
) -> Option<u16> {
Some(5)
}
fn handle_event(
&self,
ctx: &ComponentContext,
event: &a2ui_base::event::InputEvent,
) -> Option<a2ui_base::event::EventResult> {
let comp_model = ctx.components.get(&ctx.component_id)?;
let value_ds = comp_model.get_property::<DynamicString>("value")?;
let binding = match value_ds {
DynamicString::Binding(b) => b,
_ => return None,
};
let enable_date: bool = comp_model.get_property("enableDate").unwrap_or(true);
let enable_time: bool = comp_model.get_property("enableTime").unwrap_or(true);
let current_str =
ctx.data_context.resolve_dynamic_string(&DynamicString::Binding(binding.clone()));
let dt = parse_value(¤t_str).unwrap_or_else(|| chrono::Local::now().naive_local());
let direction = match event {
InputEvent::KeyPress { key: InputKey::Up } => Direction::Forward,
InputEvent::KeyPress { key: InputKey::Right } => Direction::Forward,
InputEvent::KeyPress { key: InputKey::Down } => Direction::Backward,
InputEvent::KeyPress { key: InputKey::Left } => Direction::Backward,
_ => return None,
};
let axis = match event {
InputEvent::KeyPress { key: InputKey::Up } | InputEvent::KeyPress { key: InputKey::Down } => Axis::Primary,
InputEvent::KeyPress { key: InputKey::Left } | InputEvent::KeyPress { key: InputKey::Right } => Axis::Secondary,
_ => return None,
};
let new_dt = apply_delta(dt, enable_date, enable_time, axis, direction);
let formatted = format_value(&new_dt, enable_date, enable_time);
Some(EventResult::DataUpdate {
path: binding.path.clone(),
value: serde_json::json!(formatted),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Axis {
Primary,
Secondary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
Forward,
Backward,
}
fn parse_value(value: &str) -> Option<NaiveDateTime> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.f"))
.or_else(|_| NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S"))
.ok()
.or_else(|| {
chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
})
.or_else(|| {
NaiveTime::parse_from_str(trimmed, "%H:%M:%S")
.ok()
.map(|t| chrono::Local::now().naive_local().date().and_time(t))
})
}
fn apply_delta(
dt: NaiveDateTime,
enable_date: bool,
enable_time: bool,
axis: Axis,
direction: Direction,
) -> NaiveDateTime {
let sign: i64 = if direction == Direction::Forward { 1 } else { -1 };
let duration_step = |days: i64, secs: i64| -> NaiveDateTime {
dt + Duration::days(days) + Duration::seconds(secs)
};
match (enable_date, enable_time) {
(true, true) => match axis {
Axis::Primary => duration_step(sign, 0), Axis::Secondary => duration_step(0, sign * 3600), },
(true, false) => match axis {
Axis::Primary => duration_step(sign, 0), Axis::Secondary => add_months(dt, sign), },
(false, true) => match axis {
Axis::Primary => duration_step(0, sign * 60), Axis::Secondary => duration_step(0, sign * 3600), },
(false, false) => duration_step(sign, 0),
}
}
fn add_months(dt: NaiveDateTime, n: i64) -> NaiveDateTime {
let date = dt.date();
let year = date.year() as i64;
let month = date.month() as i64;
let day = date.day();
let total = year * 12 + (month - 1) + n;
let new_year = total.div_euclid(12) as i32;
let new_month = total.rem_euclid(12) as u32 + 1;
let last_day = days_in_month(new_year, new_month);
let clamped_day = day.min(last_day);
match chrono::NaiveDate::from_ymd_opt(new_year, new_month, clamped_day) {
Some(d) => d.and_hms_opt(dt.hour(), dt.minute(), dt.second())
.unwrap_or(dt),
None => dt,
}
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => 30,
}
}
fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
fn format_value(dt: &NaiveDateTime, enable_date: bool, enable_time: bool) -> String {
match (enable_date, enable_time) {
(true, true) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
(true, false) => dt.format("%Y-%m-%d").to_string(),
(false, true) => dt.format("%H:%M:%S").to_string(),
(false, false) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dt(year: i32, month: u32, day: u32, h: u32, m: u32, s: u32) -> NaiveDateTime {
chrono::NaiveDate::from_ymd_opt(year, month, day)
.unwrap()
.and_hms_opt(h, m, s)
.unwrap()
}
#[test]
fn parse_full_iso_datetime() {
let parsed = parse_value("2026-06-13T14:30:00").unwrap();
assert_eq!(parsed, dt(2026, 6, 13, 14, 30, 0));
}
#[test]
fn parse_date_only_anchors_midnight() {
let parsed = parse_value("2026-01-15").unwrap();
assert_eq!(parsed, dt(2026, 1, 15, 0, 0, 0));
}
#[test]
fn parse_empty_returns_none() {
assert!(parse_value("").is_none());
assert!(parse_value(" ").is_none());
assert!(parse_value("not-a-date").is_none());
}
#[test]
fn date_and_time_mode_day_increment() {
let start = dt(2026, 6, 13, 14, 30, 0);
let after = apply_delta(start, true, true, Axis::Primary, Direction::Forward);
assert_eq!(after, dt(2026, 6, 14, 14, 30, 0));
let back = apply_delta(after, true, true, Axis::Primary, Direction::Backward);
assert_eq!(back, start);
}
#[test]
fn date_and_time_mode_hour_increment() {
let start = dt(2026, 6, 13, 14, 30, 0);
let after = apply_delta(start, true, true, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2026, 6, 13, 15, 30, 0));
let back = apply_delta(after, true, true, Axis::Secondary, Direction::Backward);
assert_eq!(back, start);
}
#[test]
fn date_and_time_hour_wraps_across_day() {
let start = dt(2026, 6, 13, 23, 30, 0);
let after = apply_delta(start, true, true, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2026, 6, 14, 0, 30, 0));
}
#[test]
fn date_only_mode_month_increment() {
let start = dt(2026, 1, 15, 0, 0, 0);
let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2026, 2, 15, 0, 0, 0));
let back = apply_delta(after, true, false, Axis::Secondary, Direction::Backward);
assert_eq!(back, start);
}
#[test]
fn date_only_month_clamps_jan31_to_feb() {
let start = dt(2026, 1, 31, 0, 0, 0);
let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2026, 2, 28, 0, 0, 0));
}
#[test]
fn date_only_month_clamps_to_leap_feb() {
let start = dt(2024, 1, 31, 0, 0, 0);
let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2024, 2, 29, 0, 0, 0));
}
#[test]
fn date_only_month_wraps_across_year() {
let start = dt(2026, 12, 15, 0, 0, 0);
let after = apply_delta(start, true, false, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2027, 1, 15, 0, 0, 0));
let back = apply_delta(start, true, false, Axis::Secondary, Direction::Backward);
assert_eq!(back, dt(2026, 11, 15, 0, 0, 0));
let jan = dt(2026, 1, 15, 0, 0, 0);
let prev_year = apply_delta(jan, true, false, Axis::Secondary, Direction::Backward);
assert_eq!(prev_year, dt(2025, 12, 15, 0, 0, 0));
}
#[test]
fn time_only_mode_minute_increment() {
let start = dt(2026, 6, 13, 14, 30, 0);
let after = apply_delta(start, false, true, Axis::Primary, Direction::Forward);
assert_eq!(after, dt(2026, 6, 13, 14, 31, 0));
let back = apply_delta(after, false, true, Axis::Primary, Direction::Backward);
assert_eq!(back, start);
}
#[test]
fn time_only_mode_hour_increment() {
let start = dt(2026, 6, 13, 14, 30, 0);
let after = apply_delta(start, false, true, Axis::Secondary, Direction::Forward);
assert_eq!(after, dt(2026, 6, 13, 15, 30, 0));
}
#[test]
fn format_full_datetime() {
let value = dt(2026, 6, 13, 14, 30, 5);
assert_eq!(format_value(&value, true, true), "2026-06-13T14:30:05");
}
#[test]
fn format_date_only() {
let value = dt(2026, 6, 13, 14, 30, 5);
assert_eq!(format_value(&value, true, false), "2026-06-13");
}
#[test]
fn format_time_only() {
let value = dt(2026, 6, 13, 14, 30, 5);
assert_eq!(format_value(&value, false, true), "14:30:05");
}
#[test]
fn leap_year_detection() {
assert!(is_leap_year(2024));
assert!(!is_leap_year(2026));
assert!(!is_leap_year(1900)); assert!(is_leap_year(2000)); }
}