Skip to main content

tui_lipan/widgets/date_picker/
mod.rs

1//! Date picker widget.
2
3mod types;
4mod utils;
5
6pub use types::*;
7pub(crate) use utils::*;
8
9use crate::callback::{Callback, KeyHandler};
10use crate::core::element::{Element, IntoElement};
11use crate::core::event::{KeyCode, KeyEvent, MouseEvent};
12use crate::style::{BorderStyle, Length, Padding, Style, StyleSlot};
13use crate::widgets::{BorderLabels, Button, Center, Frame, FrameLabel, HStack, Text, VStack};
14use std::sync::Arc;
15
16/// A simple calendar-based date selection widget.
17///
18/// Keyboard model (WAI-ARIA grid / roving tabindex):
19/// - The selected day is the sole tab stop and focus entry.
20/// - Other in-month days stay mouse-activatable but are not focusable, so Tab
21///   cannot walk cell-by-cell.
22/// - Left/Right move ±1 day, Up/Down ±7 days (emitting [`DateEvent`] via
23///   `on_select`, including across month boundaries).
24/// - PageUp / PageDown move to the previous / next month with the day clamped
25///   to that month's length (via `on_select` when set, otherwise the month
26///   callbacks).
27/// - Home / End go to the first / last day of the **month** (a picker-oriented
28///   variant of the ARIA grid pattern, which uses week bounds).
29#[derive(Clone)]
30pub struct DatePicker {
31    pub(crate) year: i32,
32    pub(crate) month: u32,
33    pub(crate) day: u32,
34    pub(crate) title: Option<Arc<str>>,
35    pub(crate) title_style: Style,
36    pub(crate) style: Style,
37    pub(crate) header_style: Style,
38    pub(crate) weekday_style: Style,
39    pub(crate) day_style: Style,
40    pub(crate) day_hover_style: StyleSlot,
41    pub(crate) selected_style: Style,
42    pub(crate) outside_month_style: Style,
43    pub(crate) nav_style: Style,
44    pub(crate) nav_hover_style: StyleSlot,
45    pub(crate) nav_disabled_style: Style,
46    pub(crate) show_outside_days: bool,
47    pub(crate) border: bool,
48    pub(crate) border_style: BorderStyle,
49    pub(crate) padding: Padding,
50    pub(crate) width: Length,
51    pub(crate) height: Length,
52    pub(crate) disabled: bool,
53    pub(crate) disabled_style: Style,
54    pub(crate) focusable: bool,
55    pub(crate) tab_stop: bool,
56    pub(crate) focus_key: Option<Arc<str>>,
57    pub(crate) on_focus: Option<Callback<DateEvent>>,
58    pub(crate) on_blur: Option<Callback<DateEvent>>,
59    pub(crate) on_select: Option<Callback<DateEvent>>,
60    pub(crate) on_prev_month: Option<Callback<()>>,
61    pub(crate) on_next_month: Option<Callback<()>>,
62    pub(crate) on_key: Option<KeyHandler>,
63}
64
65fn derived_focus_key(title: Option<&str>) -> Arc<str> {
66    match title {
67        Some(title) if !title.is_empty() => Arc::from(format!("__tui_lipan_datepicker:{title}")),
68        _ => Arc::from("__tui_lipan_datepicker"),
69    }
70}
71
72impl DatePicker {
73    /// Create a new date picker.
74    pub fn new() -> Self {
75        Self {
76            year: 2024,
77            month: 1,
78            day: 1,
79            title: Some("Select Date".into()),
80            title_style: Style::default(),
81            style: Style::default(),
82            header_style: Style::default(),
83            weekday_style: Style::default(),
84            day_style: Style::default(),
85            day_hover_style: StyleSlot::Inherit,
86            selected_style: Style::default(),
87            outside_month_style: Style::default(),
88            nav_style: Style::default(),
89            nav_hover_style: StyleSlot::Inherit,
90            nav_disabled_style: Style::default(),
91            show_outside_days: false,
92            border: true,
93            border_style: BorderStyle::Rounded,
94            padding: Padding::default(),
95            width: Length::Auto,
96            height: Length::Auto,
97            disabled: false,
98            disabled_style: Style::default(),
99            focusable: true,
100            tab_stop: true,
101            focus_key: None,
102            on_focus: None,
103            on_blur: None,
104            on_select: None,
105            on_prev_month: None,
106            on_next_month: None,
107            on_key: None,
108        }
109    }
110
111    /// Set the year.
112    pub fn year(mut self, year: i32) -> Self {
113        self.year = year;
114        self
115    }
116
117    /// Set the month.
118    pub fn month(mut self, month: u32) -> Self {
119        self.month = month;
120        self
121    }
122
123    /// Set the day.
124    pub fn day(mut self, day: u32) -> Self {
125        self.day = day;
126        self
127    }
128
129    /// Set the title (None disables the title).
130    pub fn title(mut self, title: Option<impl Into<Arc<str>>>) -> Self {
131        self.title = title.map(Into::into);
132        self
133    }
134
135    /// Set title style.
136    pub fn title_style(mut self, style: Style) -> Self {
137        self.title_style = style;
138        self
139    }
140
141    /// Set base style.
142    pub fn style(mut self, style: Style) -> Self {
143        self.style = style;
144        self
145    }
146
147    /// Set header style.
148    pub fn header_style(mut self, style: Style) -> Self {
149        self.header_style = style;
150        self
151    }
152
153    /// Set weekday label style.
154    pub fn weekday_style(mut self, style: Style) -> Self {
155        self.weekday_style = style;
156        self
157    }
158
159    /// Set day style.
160    pub fn day_style(mut self, style: Style) -> Self {
161        self.day_style = style;
162        self
163    }
164
165    /// Set day hover style.
166    pub fn day_hover_style(mut self, style: Style) -> Self {
167        self.day_hover_style = StyleSlot::Replace(style);
168        self
169    }
170
171    /// Extend the themed day hover style.
172    pub fn extend_day_hover_style(mut self, style: Style) -> Self {
173        self.day_hover_style = StyleSlot::Extend(style);
174        self
175    }
176
177    /// Inherit the themed day hover style.
178    pub fn inherit_day_hover_style(mut self) -> Self {
179        self.day_hover_style = StyleSlot::Inherit;
180        self
181    }
182
183    /// Set day hover style slot directly for composite forwarding.
184    pub fn day_hover_style_slot(mut self, slot: StyleSlot) -> Self {
185        self.day_hover_style = slot;
186        self
187    }
188
189    /// Set selected day style.
190    pub fn selected_style(mut self, style: Style) -> Self {
191        self.selected_style = style;
192        self
193    }
194
195    /// Set outside-month day style.
196    pub fn outside_month_style(mut self, style: Style) -> Self {
197        self.outside_month_style = style;
198        self
199    }
200
201    /// Set navigation button style.
202    pub fn nav_style(mut self, style: Style) -> Self {
203        self.nav_style = style;
204        self
205    }
206
207    /// Set navigation button hover style.
208    pub fn nav_hover_style(mut self, style: Style) -> Self {
209        self.nav_hover_style = StyleSlot::Replace(style);
210        self
211    }
212
213    /// Extend the themed navigation button hover style.
214    pub fn extend_nav_hover_style(mut self, style: Style) -> Self {
215        self.nav_hover_style = StyleSlot::Extend(style);
216        self
217    }
218
219    /// Inherit the themed navigation button hover style.
220    pub fn inherit_nav_hover_style(mut self) -> Self {
221        self.nav_hover_style = StyleSlot::Inherit;
222        self
223    }
224
225    /// Set navigation button hover style slot directly for composite forwarding.
226    pub fn nav_hover_style_slot(mut self, slot: StyleSlot) -> Self {
227        self.nav_hover_style = slot;
228        self
229    }
230
231    /// Set navigation button disabled style.
232    pub fn nav_disabled_style(mut self, style: Style) -> Self {
233        self.nav_disabled_style = style;
234        self
235    }
236
237    /// Toggle rendering days from adjacent months.
238    pub fn show_outside_days(mut self, show: bool) -> Self {
239        self.show_outside_days = show;
240        self
241    }
242
243    /// Draw a border.
244    pub fn border(mut self, border: bool) -> Self {
245        self.border = border;
246        self
247    }
248
249    /// Set border style.
250    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
251        self.border_style = border_style;
252        self
253    }
254
255    /// Set padding.
256    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
257        self.padding = padding.into();
258        self
259    }
260
261    /// Set width.
262    pub fn width(mut self, width: Length) -> Self {
263        self.width = width;
264        self
265    }
266
267    /// Set height.
268    pub fn height(mut self, height: Length) -> Self {
269        self.height = height;
270        self
271    }
272
273    /// Set disabled state.
274    pub fn disabled(mut self, disabled: bool) -> Self {
275        self.disabled = disabled;
276        self
277    }
278
279    /// Set disabled style.
280    pub fn disabled_style(mut self, style: Style) -> Self {
281        self.disabled_style = style;
282        self
283    }
284
285    /// Control whether the selected day cell is focusable.
286    pub fn focusable(mut self, focusable: bool) -> Self {
287        self.focusable = focusable;
288        self
289    }
290
291    /// Control whether the selected day participates in tab traversal.
292    ///
293    /// Non-selected days are never tab stops (roving focus).
294    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
295        self.tab_stop = tab_stop;
296        self
297    }
298
299    /// Key applied to the selected day so focus follows arrow-driven selection.
300    ///
301    /// When omitted, the key is derived from `title` when set (so distinct titled
302    /// pickers do not collide), otherwise a shared default. The key must stay
303    /// stable across month changes — do not encode year/month. Override when
304    /// two untitled pickers share a tree (or to give the picker an app-owned key).
305    pub fn focus_key(mut self, key: impl Into<Arc<str>>) -> Self {
306        self.focus_key = Some(key.into());
307        self
308    }
309
310    /// Set the callback fired when the selected day cell gains focus.
311    pub fn on_focus(mut self, cb: Callback<DateEvent>) -> Self {
312        self.on_focus = Some(cb);
313        self
314    }
315
316    /// Set the callback fired when the selected day cell loses focus.
317    pub fn on_blur(mut self, cb: Callback<DateEvent>) -> Self {
318        self.on_blur = Some(cb);
319        self
320    }
321
322    /// Set day selection callback.
323    pub fn on_select(mut self, cb: Callback<DateEvent>) -> Self {
324        self.on_select = Some(cb);
325        self
326    }
327
328    /// Set previous-month callback.
329    pub fn on_prev_month(mut self, cb: Callback<()>) -> Self {
330        self.on_prev_month = Some(cb);
331        self
332    }
333
334    /// Set next-month callback.
335    pub fn on_next_month(mut self, cb: Callback<()>) -> Self {
336        self.on_next_month = Some(cb);
337        self
338    }
339
340    /// Set focused key handler on the selected day (runs before built-in arrows).
341    pub fn on_key(mut self, handler: KeyHandler) -> Self {
342        self.on_key = Some(handler);
343        self
344    }
345}
346
347impl Default for DatePicker {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353impl From<DatePicker> for Element {
354    fn from(picker: DatePicker) -> Self {
355        let months = [
356            "January",
357            "February",
358            "March",
359            "April",
360            "May",
361            "June",
362            "July",
363            "August",
364            "September",
365            "October",
366            "November",
367            "December",
368        ];
369
370        let year = picker.year;
371        let month = picker.month.clamp(1, 12);
372        let day = picker.day.clamp(1, days_in_month(year, month));
373        let focus_key = picker
374            .focus_key
375            .clone()
376            .unwrap_or_else(|| derived_focus_key(picker.title.as_deref()));
377
378        let header_label = format!(
379            "{} {}",
380            months[(month.saturating_sub(1) % 12) as usize],
381            year
382        );
383
384        let mut prev_button = Button::filled("◀")
385            .padding(0)
386            .style(picker.nav_style)
387            .hover_style_slot(picker.nav_hover_style)
388            .width(Length::Px(2))
389            .focusable(false)
390            .tab_stop(false);
391        let mut next_button = Button::filled("▶")
392            .padding(0)
393            .style(picker.nav_style)
394            .hover_style_slot(picker.nav_hover_style)
395            .width(Length::Px(2))
396            .focusable(false)
397            .tab_stop(false);
398
399        if picker.disabled {
400            prev_button = prev_button
401                .disabled(true)
402                .disabled_style(picker.nav_disabled_style);
403            next_button = next_button
404                .disabled(true)
405                .disabled_style(picker.nav_disabled_style);
406        } else if let Some(cb) = picker.on_prev_month.clone() {
407            prev_button = prev_button.on_click(Callback::new(move |_: MouseEvent| cb.emit(())));
408        } else {
409            prev_button = prev_button
410                .disabled(true)
411                .disabled_style(picker.nav_disabled_style);
412        }
413
414        if !picker.disabled {
415            if let Some(cb) = picker.on_next_month.clone() {
416                next_button = next_button.on_click(Callback::new(move |_: MouseEvent| cb.emit(())));
417            } else {
418                next_button = next_button
419                    .disabled(true)
420                    .disabled_style(picker.nav_disabled_style);
421            }
422        }
423
424        let header = HStack::new()
425            .gap(1)
426            .height(Length::Px(1))
427            .child(prev_button)
428            .child(Center::new().child(Text::new(header_label).style(picker.header_style)))
429            .child(next_button);
430
431        let days_header = HStack::new()
432            .gap(1)
433            .height(Length::Px(1))
434            .child(
435                Text::new("Su")
436                    .style(picker.weekday_style)
437                    .width(Length::Px(2)),
438            )
439            .child(
440                Text::new("Mo")
441                    .style(picker.weekday_style)
442                    .width(Length::Px(2)),
443            )
444            .child(
445                Text::new("Tu")
446                    .style(picker.weekday_style)
447                    .width(Length::Px(2)),
448            )
449            .child(
450                Text::new("We")
451                    .style(picker.weekday_style)
452                    .width(Length::Px(2)),
453            )
454            .child(
455                Text::new("Th")
456                    .style(picker.weekday_style)
457                    .width(Length::Px(2)),
458            )
459            .child(
460                Text::new("Fr")
461                    .style(picker.weekday_style)
462                    .width(Length::Px(2)),
463            )
464            .child(
465                Text::new("Sa")
466                    .style(picker.weekday_style)
467                    .width(Length::Px(2)),
468            );
469
470        let first_weekday = weekday(year, month, 1) as usize;
471        let days_in_current_month = days_in_month(year, month);
472        let (prev_year, prev_month_val) = prev_month(year, month);
473        let days_in_prev = days_in_month(prev_year, prev_month_val);
474
475        let mut calendar = VStack::new().gap(0).height(Length::Auto);
476        let mut day_counter = 1u32;
477        let mut next_day = 1u32;
478
479        for week in 0..6 {
480            let mut row = HStack::new().gap(1).height(Length::Px(1));
481            for weekday_idx in 0..7 {
482                let cell_index = week * 7 + weekday_idx;
483
484                if cell_index < first_weekday {
485                    if picker.show_outside_days {
486                        let day_val = days_in_prev - (first_weekday as u32 - cell_index as u32) + 1;
487                        let label = format!("{:>2}", day_val);
488                        let cell = Text::new(label)
489                            .style(picker.outside_month_style)
490                            .width(Length::Px(2));
491                        row = row.child(cell);
492                    } else {
493                        row = row.child(Text::new("  ").width(Length::Px(2)));
494                    }
495                    continue;
496                }
497
498                if day_counter <= days_in_current_month {
499                    let is_selected = day_counter == day;
500                    let label = format!("{:>2}", day_counter);
501                    let cell_event = DateEvent {
502                        year,
503                        month,
504                        day: day_counter,
505                    };
506                    let mut button = Button::filled(label)
507                        .padding(0)
508                        .width(Length::Px(2))
509                        .style(if is_selected {
510                            picker.selected_style
511                        } else {
512                            picker.day_style
513                        })
514                        .hover_style_slot(picker.day_hover_style)
515                        // Only the selected day is focusable so arrow-driven
516                        // selection can reclaim focus via `focus_key`.
517                        .focusable(picker.focusable && is_selected && !picker.disabled)
518                        .tab_stop(picker.tab_stop && is_selected && !picker.disabled);
519
520                    if is_selected {
521                        if let Some(cb) = picker.on_focus.clone() {
522                            button = button.on_focus(Callback::new(move |_| cb.emit(cell_event)));
523                        }
524                        if let Some(cb) = picker.on_blur.clone() {
525                            button = button.on_blur(Callback::new(move |_| cb.emit(cell_event)));
526                        }
527                    }
528
529                    if picker.disabled {
530                        button = button.disabled(true).disabled_style(picker.disabled_style);
531                    } else if let Some(cb) = picker.on_select.clone() {
532                        let event = cell_event;
533                        button =
534                            button.on_click(Callback::new(move |_: MouseEvent| cb.emit(event)));
535                    } else {
536                        button = button.disabled(true).disabled_style(picker.day_style);
537                    }
538
539                    if is_selected && !picker.disabled {
540                        let on_select = picker.on_select.clone();
541                        let on_prev = picker.on_prev_month.clone();
542                        let on_next = picker.on_next_month.clone();
543                        let caller_on_key = picker.on_key.clone();
544                        button = button.on_key(KeyHandler::new(move |key: KeyEvent| {
545                            if caller_on_key
546                                .as_ref()
547                                .is_some_and(|handler| handler.handle(key))
548                            {
549                                return true;
550                            }
551                            handle_datepicker_key(
552                                key, year, month, day, &on_select, &on_prev, &on_next,
553                            )
554                        }));
555                    }
556
557                    let cell: Element = if is_selected {
558                        button.key(focus_key.clone())
559                    } else {
560                        button.into()
561                    };
562                    row = row.child(cell);
563                    day_counter = day_counter.saturating_add(1);
564                } else if picker.show_outside_days {
565                    let label = format!("{:>2}", next_day);
566                    let cell = Text::new(label)
567                        .style(picker.outside_month_style)
568                        .width(Length::Px(2));
569                    row = row.child(cell);
570                    next_day = next_day.saturating_add(1);
571                } else {
572                    row = row.child(Text::new("  ").width(Length::Px(2)));
573                }
574            }
575            calendar = calendar.child(row);
576        }
577
578        let content = VStack::new()
579            .gap(1)
580            .child(header)
581            .child(days_header)
582            .child(calendar);
583
584        let mut frame = Frame::new()
585            .border(picker.border)
586            .border_style(picker.border_style)
587            .padding(picker.padding)
588            .style(picker.style)
589            .child(content)
590            .width(picker.width)
591            .height(picker.height);
592
593        if let Some(title) = picker.title.clone() {
594            frame = frame
595                .header(BorderLabels::new().left(FrameLabel::new(title).style(picker.title_style)));
596        }
597
598        frame.into()
599    }
600}
601
602fn handle_datepicker_key(
603    key: KeyEvent,
604    year: i32,
605    month: u32,
606    day: u32,
607    on_select: &Option<Callback<DateEvent>>,
608    on_prev: &Option<Callback<()>>,
609    on_next: &Option<Callback<()>>,
610) -> bool {
611    if key.mods.ctrl || key.mods.alt || key.mods.super_key {
612        return false;
613    }
614
615    match key.code {
616        KeyCode::Left => {
617            emit_day_delta(year, month, day, -1, on_select);
618            true
619        }
620        KeyCode::Right => {
621            emit_day_delta(year, month, day, 1, on_select);
622            true
623        }
624        KeyCode::Up => {
625            emit_day_delta(year, month, day, -7, on_select);
626            true
627        }
628        KeyCode::Down => {
629            emit_day_delta(year, month, day, 7, on_select);
630            true
631        }
632        KeyCode::PageUp => {
633            let (y, m) = prev_month(year, month);
634            let d = day.min(days_in_month(y, m));
635            if let Some(cb) = on_select {
636                cb.emit(DateEvent {
637                    year: y,
638                    month: m,
639                    day: d,
640                });
641            } else if let Some(cb) = on_prev {
642                cb.emit(());
643            }
644            true
645        }
646        KeyCode::PageDown => {
647            let (y, m) = next_month(year, month);
648            let d = day.min(days_in_month(y, m));
649            if let Some(cb) = on_select {
650                cb.emit(DateEvent {
651                    year: y,
652                    month: m,
653                    day: d,
654                });
655            } else if let Some(cb) = on_next {
656                cb.emit(());
657            }
658            true
659        }
660        KeyCode::Home => {
661            if let Some(cb) = on_select {
662                cb.emit(DateEvent {
663                    year,
664                    month,
665                    day: 1,
666                });
667            }
668            true
669        }
670        KeyCode::End => {
671            if let Some(cb) = on_select {
672                cb.emit(DateEvent {
673                    year,
674                    month,
675                    day: days_in_month(year, month),
676                });
677            }
678            true
679        }
680        _ => false,
681    }
682}
683
684fn emit_day_delta(
685    year: i32,
686    month: u32,
687    day: u32,
688    delta: i32,
689    on_select: &Option<Callback<DateEvent>>,
690) {
691    let Some(cb) = on_select else {
692        return;
693    };
694    let (y, m, d) = shift_day(year, month, day, delta);
695    if y == year && m == month && d == day {
696        return;
697    }
698    cb.emit(DateEvent {
699        year: y,
700        month: m,
701        day: d,
702    });
703}