Skip to main content

ez_tui/components/concrete/forms/inputs/
date_input.rs

1use crate::components::concrete::forms::inputs::core::{FormFieldValue, InputCpt};
2use crate::components::concrete::forms::inputs::date_buffer::DateBuffer;
3use crate::forms::field::DateTimeConfig;
4use crate::{
5    AttrValue, Attribute, Component, EzArgs, EzCptIds, EzEvent, EzMsg, EzState, FormField,
6    FormFieldType, MockComponent, MockProps, Props, State, Theme,
7};
8use chrono::{DateTime, Duration, Local};
9use crossterm::event::KeyCode;
10use eztui_derive::MockProps;
11use ratatui::buffer::Buffer;
12use ratatui::layout::{Alignment, Layout, Rect};
13use ratatui::prelude::{Color, Line, Stylize, Widget};
14use ratatui::style::Style;
15use ratatui::style::palette::tailwind::GRAY;
16use ratatui::text::Span;
17use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
18use ratatui::widgets::{BorderType, Paragraph};
19use std::fmt::Debug;
20use time::{Date, OffsetDateTime, UtcOffset};
21
22#[derive(Debug, Eq, PartialEq, Clone, Default)]
23pub(crate) enum TimePart {
24    #[default]
25    Hour,
26    Minute,
27    Second,
28    Nanosecond,
29}
30
31impl TimePart {
32    fn next(&self) -> Option<Self> {
33        match self {
34            TimePart::Hour => Some(TimePart::Minute),
35            TimePart::Minute => Some(TimePart::Second),
36            TimePart::Second => Some(TimePart::Nanosecond),
37            TimePart::Nanosecond => None,
38        }
39    }
40}
41/// A component drawing a [`FormField`] of type [`FormFieldType::DateTime`], [`FormFieldType::Date`], or [`FormFieldType::Time`].
42#[derive(Debug, MockProps)]
43pub struct DateTimeInputCpt<FID>
44where
45    FID: EzCptIds,
46{
47    props: Props,
48    config: DateTimeConfig,
49    #[allow(dead_code)]
50    // I need a phantom if i remove this. So it's better keeping it as i need it in the constructor
51    field: FormField<FID>,
52    value: DateTime<Local>,
53    selected_time_part: Option<TimePart>,
54    buffer: DateBuffer,
55    local: bool,
56}
57
58impl<FID> DateTimeInputCpt<FID>
59where
60    FID: EzCptIds,
61{
62    /// Create a new [`DateTimeInputCpt`] component with a given field
63    #[must_use]
64    pub(crate) fn datetime(field: FormField<FID>) -> Self {
65        assert_eq!(field.field_type(), &FormFieldType::DateTime {});
66        Self::init(field, DateTimeConfig::DateTime)
67    }
68
69    fn init(field: FormField<FID>, config: DateTimeConfig) -> DateTimeInputCpt<FID> {
70        let mut props = Props::default();
71        props.set(
72            Attribute::Title,
73            AttrValue::Title((field.name(), Alignment::Left)),
74        );
75        let buffer = DateBuffer::new(config == DateTimeConfig::Time);
76        let time_part = if config == DateTimeConfig::Time {
77            Some(TimePart::default())
78        } else {
79            None
80        };
81        Self {
82            props,
83            config,
84            field,
85            value: chrono::Utc::now().with_timezone(&Local),
86            buffer,
87            selected_time_part: time_part,
88            local: false,
89        }
90    }
91
92    /// Create a new [`DateTimeInputCpt`] component with a given field allowing selection of the time part only
93    #[must_use]
94    pub(crate) fn time_only(field: FormField<FID>) -> Self {
95        assert_eq!(field.field_type(), &FormFieldType::Time {});
96        Self::init(field, DateTimeConfig::Time)
97    }
98    /// Create a new [`DateTimeInputCpt`] component with a given field allowing selection of the date part only
99    #[must_use]
100    pub(crate) fn date_only(field: FormField<FID>) -> Self {
101        assert_eq!(field.field_type(), &FormFieldType::Date {});
102        Self::init(field, DateTimeConfig::Date)
103    }
104    pub(crate) fn split_layout(&self, area: Rect) -> (Rect, Rect) {
105        let constraint_date = if self.config == DateTimeConfig::Time {
106            ratatui::layout::Constraint::Max(0)
107        } else {
108            ratatui::layout::Constraint::Min(7)
109        };
110        let constraint_time = if self.config == DateTimeConfig::Date {
111            ratatui::layout::Constraint::Max(0)
112        } else {
113            ratatui::layout::Constraint::Min(1)
114        };
115        let areas = Layout::vertical(vec![constraint_date, constraint_time]).split(area);
116        (areas[0], areas[1])
117    }
118    fn make_dates(date: Date, color: Color) -> CalendarEventStore {
119        let mut list = CalendarEventStore::today(Style::default().bold().fg(GRAY.c500));
120        list.add(date, Style::default().bold().fg(color));
121        list
122    }
123    fn increment_time_part(&mut self, amount: i8) {
124        let delta = i64::from(amount);
125
126        let updated = match self.selected_time_part {
127            Some(TimePart::Hour) => self.value + Duration::hours(delta),
128            Some(TimePart::Minute) => self.value + Duration::minutes(delta),
129            Some(TimePart::Second) => self.value + Duration::seconds(delta),
130            Some(TimePart::Nanosecond) => self.value + Duration::nanoseconds(delta),
131            None => return,
132        };
133
134        self.value = updated;
135    }
136}
137
138impl<FID, CID, CA, CS, CM> InputCpt<CID, CA, CS, CM> for DateTimeInputCpt<FID>
139where
140    FID: EzCptIds,
141    CID: EzCptIds,
142    CA: EzArgs,
143    CS: EzState,
144    CM: EzMsg,
145{
146    fn get_value(&self) -> FormFieldValue {
147        FormFieldValue::DateTime(self.value.to_utc())
148    }
149
150    fn capture_validation(&mut self) -> bool {
151        if self.buffer.raw().is_empty() {
152            false
153        } else {
154            if let Some(date) = self.buffer.parsed() {
155                self.value = date.with_timezone(&Local);
156                self.buffer.reset();
157            }
158            true
159        }
160    }
161}
162impl<FID> MockComponent for DateTimeInputCpt<FID>
163where
164    FID: EzCptIds,
165{
166    fn draw(&mut self, area: Rect, buf: &mut Buffer, theme: &Theme) {
167        let ts = self.value.to_utc().timestamp();
168        let offset = if self.local {
169            OffsetDateTime::now_local().map_or(UtcOffset::UTC, OffsetDateTime::offset)
170        } else {
171            UtcOffset::UTC
172        };
173
174        let offset_date = OffsetDateTime::from_unix_timestamp(ts)
175            .expect("Failed to convert timestamp to OffsetDateTime")
176            .to_offset(offset);
177        let time = offset_date.time();
178        let date = offset_date.date();
179
180        let block = theme
181            .block(self.props())
182            .border_type(BorderType::Double)
183            .title_bottom(format!(" {} ", self.buffer.raw()));
184        let b_area = block.inner(area);
185        block.render(area, buf);
186        let (date_area, time_area) = self.split_layout(b_area);
187
188        // Render calendar for date
189        if self.config != DateTimeConfig::Time {
190            Monthly::new(
191                date,
192                Self::make_dates(
193                    date,
194                    theme.accent_or_text(self.selected_time_part.is_none()).c500,
195                ),
196            )
197            .show_month_header(Style::new().bold())
198            .show_weekdays_header(Style::new().italic())
199            .render(date_area, buf);
200        }
201
202        // Render time
203        if self.config != DateTimeConfig::Date {
204            let hour = time.hour();
205            let minute = time.minute();
206            let second = time.second();
207            let nanosecond = time.nanosecond();
208            let time_line = Line::from(vec![
209                Span::styled(
210                    format!("{hour:02}"),
211                    theme
212                        .accent_or_text(self.selected_time_part == Some(TimePart::Hour))
213                        .c500,
214                ),
215                Span::styled(":", Style::default().fg(Color::Gray)),
216                Span::styled(
217                    format!("{minute:02}"),
218                    theme
219                        .accent_or_text(self.selected_time_part == Some(TimePart::Minute))
220                        .c500,
221                ),
222                Span::styled(":", Style::default().fg(Color::Gray)),
223                Span::styled(
224                    format!("{second:02}"),
225                    theme
226                        .accent_or_text(self.selected_time_part == Some(TimePart::Second))
227                        .c500,
228                ),
229                Span::styled(".", Style::default().fg(Color::Gray)),
230                Span::styled(
231                    format!("{nanosecond:09}"),
232                    theme
233                        .accent_or_text(self.selected_time_part == Some(TimePart::Nanosecond))
234                        .c500,
235                ),
236            ]);
237
238            Paragraph::new(time_line).render(time_area, buf);
239        }
240    }
241}
242
243impl<FID, CID, CA, CS, CM> Component<CID, CA, CS, CM> for DateTimeInputCpt<FID>
244where
245    FID: EzCptIds,
246    CID: EzCptIds,
247    CA: EzArgs,
248    CS: EzState,
249    CM: EzMsg,
250{
251    fn on_event(
252        &mut self,
253        event: EzEvent<CID, CM>,
254        _state: &mut State<CS>,
255    ) -> Vec<EzEvent<CID, CM>> {
256        if let EzEvent::Keyboard(kev) = event {
257            if let KeyCode::PageUp = kev.code {
258                if self.selected_time_part.is_none() {
259                    self.value -= Duration::days(30);
260                } else {
261                    self.increment_time_part(5);
262                }
263            } else if let KeyCode::PageDown = kev.code {
264                if self.selected_time_part.is_none() {
265                    self.value += Duration::days(30);
266                } else {
267                    self.increment_time_part(-5);
268                }
269            } else if let KeyCode::Up = kev.code {
270                if self.selected_time_part.is_none() {
271                    self.value -= Duration::days(7);
272                } else {
273                    self.increment_time_part(1);
274                }
275            } else if let KeyCode::Down = kev.code {
276                if self.selected_time_part.is_none() {
277                    self.value += Duration::days(7);
278                } else {
279                    self.increment_time_part(-1);
280                }
281            } else if let KeyCode::Left = kev.code {
282                if self.selected_time_part.is_none() {
283                    self.value -= Duration::days(1);
284                }
285            } else if let KeyCode::Right = kev.code {
286                if self.selected_time_part.is_none() {
287                    self.value += Duration::days(1);
288                }
289            } else if let KeyCode::PrintScreen = kev.code {
290                self.local = !self.local;
291            } else if let KeyCode::Backspace = kev.code {
292                self.buffer.pop();
293            } else if let KeyCode::Char(' ') = kev.code {
294                self.selected_time_part = match &self.selected_time_part {
295                    None => {
296                        if self.config == DateTimeConfig::Time {
297                            None
298                        } else {
299                            Some(TimePart::default())
300                        }
301                    }
302                    Some(part) => part.next(),
303                }
304            } else if let KeyCode::Esc = kev.code {
305                self.buffer.reset();
306            } else {
307                self.buffer.handle(kev);
308            }
309        }
310        vec![]
311    }
312
313    fn focusable(&self) -> bool {
314        true
315    }
316}