Skip to main content

liora_components/
date_picker.rs

1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4    App, Bounds, Context, Element, ElementId, Entity, GlobalElementId, Hsla, InspectorElementId,
5    IntoElement, LayoutId, MouseButton, Pixels, Render, SharedString, Window, actions, div,
6    prelude::*, px,
7};
8use liora_core::{Config, push_portal};
9use liora_icons::Icon;
10use liora_icons_lucide::IconName;
11
12actions!(date_picker, [DatePickerClose]);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub struct DateValue {
16    pub year: i32,
17    pub month: u32,
18    pub day: u32,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum DatePickerType {
23    #[default]
24    Date,
25    DateRange,
26    Month,
27    MonthRange,
28    Year,
29    YearRange,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DatePickerSelection {
34    Single(Option<DateValue>),
35    Range {
36        start: Option<DateValue>,
37        end: Option<DateValue>,
38    },
39}
40
41pub struct DatePicker {
42    id: SharedString,
43    picker_type: DatePickerType,
44    value: Option<DateValue>,
45    range_start: Option<DateValue>,
46    range_end: Option<DateValue>,
47    view_year: i32,
48    view_month: u32,
49    is_open: bool,
50    placeholder: SharedString,
51    display_format: Option<SharedString>,
52    range_separator: SharedString,
53    width: Option<Pixels>,
54    disabled: bool,
55    last_bounds: Option<Bounds<Pixels>>,
56    close_on_click_outside: bool,
57    close_on_escape: bool,
58    on_change: Option<Box<dyn Fn(Option<DateValue>, &mut Window, &mut App) + 'static>>,
59    on_range_change:
60        Option<Box<dyn Fn(Option<DateValue>, Option<DateValue>, &mut Window, &mut App) + 'static>>,
61    on_selection_change: Option<Box<dyn Fn(DatePickerSelection, &mut Window, &mut App) + 'static>>,
62}
63
64impl DateValue {
65    pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
66        if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
67            return None;
68        }
69        Some(Self { year, month, day })
70    }
71
72    pub fn format(&self) -> String {
73        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
74    }
75}
76
77impl DatePicker {
78    pub fn new() -> Self {
79        Self {
80            id: liora_core::unique_id("date-picker"),
81            picker_type: DatePickerType::Date,
82            value: None,
83            range_start: None,
84            range_end: None,
85            view_year: 2026,
86            view_month: 5,
87            is_open: false,
88            placeholder: "请选择日期".into(),
89            display_format: None,
90            range_separator: " 至 ".into(),
91            width: None,
92            disabled: false,
93            last_bounds: None,
94            close_on_click_outside: true,
95            close_on_escape: true,
96            on_change: None,
97            on_range_change: None,
98            on_selection_change: None,
99        }
100    }
101
102    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
103        self.id = id.into();
104        self
105    }
106
107    pub fn picker_type(mut self, picker_type: DatePickerType) -> Self {
108        self.picker_type = picker_type;
109        if self.placeholder == SharedString::from("请选择日期") {
110            self.placeholder = default_placeholder(picker_type).into();
111        }
112        self
113    }
114
115    pub fn date(self) -> Self {
116        self.picker_type(DatePickerType::Date)
117    }
118
119    pub fn date_range(self) -> Self {
120        self.picker_type(DatePickerType::DateRange)
121    }
122
123    pub fn month(self) -> Self {
124        self.picker_type(DatePickerType::Month)
125    }
126
127    pub fn month_range(self) -> Self {
128        self.picker_type(DatePickerType::MonthRange)
129    }
130
131    pub fn year(self) -> Self {
132        self.picker_type(DatePickerType::Year)
133    }
134
135    pub fn year_range(self) -> Self {
136        self.picker_type(DatePickerType::YearRange)
137    }
138
139    pub fn value(mut self, value: DateValue) -> Self {
140        self.view_year = value.year;
141        self.view_month = value.month;
142        self.value = Some(normalize_value(value, self.picker_type));
143        self
144    }
145
146    pub fn range(mut self, start: DateValue, end: DateValue) -> Self {
147        let (start, end) = ordered_pair(
148            normalize_value(start, self.picker_type),
149            normalize_value(end, self.picker_type),
150        );
151        self.view_year = start.year;
152        self.view_month = start.month;
153        self.range_start = Some(start);
154        self.range_end = Some(end);
155        self
156    }
157
158    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
159        self.placeholder = placeholder.into();
160        self
161    }
162
163    pub fn format(mut self, format: impl Into<SharedString>) -> Self {
164        self.display_format = Some(format.into());
165        self
166    }
167
168    pub fn range_separator(mut self, separator: impl Into<SharedString>) -> Self {
169        self.range_separator = separator.into();
170        self
171    }
172
173    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
174        self.width = Some(width.into());
175        self
176    }
177
178    pub fn width_md(self) -> Self {
179        self.width(px(260.0))
180    }
181
182    pub fn width_lg(self) -> Self {
183        self.width(px(320.0))
184    }
185
186    pub fn disabled(mut self, disabled: bool) -> Self {
187        self.disabled = disabled;
188        self
189    }
190
191    pub fn close_on_escape(mut self, close: bool) -> Self {
192        self.close_on_escape = close;
193        self
194    }
195
196    pub fn close_on_click_outside(mut self, close: bool) -> Self {
197        self.close_on_click_outside = close;
198        self
199    }
200
201    pub fn register_key_bindings(cx: &mut App) {
202        cx.bind_keys([gpui::KeyBinding::new("escape", DatePickerClose, None)]);
203    }
204
205    fn close_on_escape_action(
206        &mut self,
207        _: &DatePickerClose,
208        _: &mut Window,
209        cx: &mut Context<Self>,
210    ) {
211        if self.close_on_escape && self.is_open {
212            self.close(cx);
213        }
214    }
215
216    pub fn on_change(
217        mut self,
218        f: impl Fn(Option<DateValue>, &mut Window, &mut App) + 'static,
219    ) -> Self {
220        self.on_change = Some(Box::new(f));
221        self
222    }
223
224    pub fn on_range_change(
225        mut self,
226        f: impl Fn(Option<DateValue>, Option<DateValue>, &mut Window, &mut App) + 'static,
227    ) -> Self {
228        self.on_range_change = Some(Box::new(f));
229        self
230    }
231
232    pub fn on_selection_change(
233        mut self,
234        f: impl Fn(DatePickerSelection, &mut Window, &mut App) + 'static,
235    ) -> Self {
236        self.on_selection_change = Some(Box::new(f));
237        self
238    }
239
240    pub fn set_on_change(
241        &mut self,
242        f: impl Fn(Option<DateValue>, &mut Window, &mut App) + 'static,
243        _cx: &mut Context<Self>,
244    ) {
245        self.on_change = Some(Box::new(f));
246    }
247
248    pub fn set_on_range_change(
249        &mut self,
250        f: impl Fn(Option<DateValue>, Option<DateValue>, &mut Window, &mut App) + 'static,
251        _cx: &mut Context<Self>,
252    ) {
253        self.on_range_change = Some(Box::new(f));
254    }
255
256    pub fn set_on_selection_change(
257        &mut self,
258        f: impl Fn(DatePickerSelection, &mut Window, &mut App) + 'static,
259        _cx: &mut Context<Self>,
260    ) {
261        self.on_selection_change = Some(Box::new(f));
262    }
263
264    pub fn set_value(&mut self, value: Option<DateValue>, cx: &mut Context<Self>) {
265        self.value = value.map(|value| normalize_value(value, self.picker_type));
266        if let Some(value) = self.value {
267            self.view_year = value.year;
268            self.view_month = value.month;
269        }
270        cx.notify();
271    }
272
273    pub fn set_range(
274        &mut self,
275        start: Option<DateValue>,
276        end: Option<DateValue>,
277        cx: &mut Context<Self>,
278    ) {
279        match (start, end) {
280            (Some(start), Some(end)) => {
281                let (start, end) = ordered_pair(
282                    normalize_value(start, self.picker_type),
283                    normalize_value(end, self.picker_type),
284                );
285                self.range_start = Some(start);
286                self.range_end = Some(end);
287                self.view_year = start.year;
288                self.view_month = start.month;
289            }
290            (start, end) => {
291                self.range_start = start.map(|value| normalize_value(value, self.picker_type));
292                self.range_end = end.map(|value| normalize_value(value, self.picker_type));
293            }
294        }
295        cx.notify();
296    }
297
298    pub fn value_ref(&self) -> Option<DateValue> {
299        self.value
300    }
301
302    pub fn range_ref(&self) -> (Option<DateValue>, Option<DateValue>) {
303        (self.range_start, self.range_end)
304    }
305
306    fn display_text(&self) -> String {
307        if self.picker_type.is_range() {
308            match (self.range_start, self.range_end) {
309                (Some(start), Some(end)) => format!(
310                    "{}{}{}",
311                    self.format_value(start),
312                    self.range_separator,
313                    self.format_value(end)
314                ),
315                (Some(start), None) => {
316                    format!("{}{}", self.format_value(start), self.range_separator)
317                }
318                _ => self.placeholder.to_string(),
319            }
320        } else {
321            self.value
322                .map(|value| self.format_value(value))
323                .unwrap_or_else(|| self.placeholder.to_string())
324        }
325    }
326
327    fn format_value(&self, value: DateValue) -> String {
328        let format = self
329            .display_format
330            .as_ref()
331            .map(|format| format.as_ref())
332            .unwrap_or_else(|| default_format(self.picker_type));
333        format_date_value(value, format)
334    }
335
336    fn has_display_value(&self) -> bool {
337        if self.picker_type.is_range() {
338            self.range_start.is_some()
339        } else {
340            self.value.is_some()
341        }
342    }
343
344    fn toggle_open(&mut self, cx: &mut Context<Self>) {
345        if self.disabled {
346            return;
347        }
348        self.is_open = !self.is_open;
349        cx.notify();
350    }
351
352    fn close(&mut self, cx: &mut Context<Self>) {
353        if self.is_open {
354            self.is_open = false;
355            cx.notify();
356        }
357    }
358
359    fn select_value(&mut self, value: DateValue, window: &mut Window, cx: &mut Context<Self>) {
360        let value = normalize_value(value, self.picker_type);
361        if self.picker_type.is_range() {
362            match (self.range_start, self.range_end) {
363                (None, _) | (Some(_), Some(_)) => {
364                    self.range_start = Some(value);
365                    self.range_end = None;
366                }
367                (Some(start), None) => {
368                    let (start, end) = ordered_pair(start, value);
369                    self.range_start = Some(start);
370                    self.range_end = Some(end);
371                    self.is_open = false;
372                }
373            }
374        } else {
375            self.value = Some(value);
376            self.is_open = false;
377        }
378
379        self.view_year = value.year;
380        self.view_month = value.month;
381        self.emit_change(window, cx);
382        cx.notify();
383    }
384
385    fn emit_change(&self, window: &mut Window, cx: &mut App) {
386        if self.picker_type.is_range() {
387            if let Some(ref on_range_change) = self.on_range_change {
388                on_range_change(self.range_start, self.range_end, window, cx);
389            }
390            if let Some(ref on_selection_change) = self.on_selection_change {
391                on_selection_change(
392                    DatePickerSelection::Range {
393                        start: self.range_start,
394                        end: self.range_end,
395                    },
396                    window,
397                    cx,
398                );
399            }
400        } else {
401            if let Some(ref on_change) = self.on_change {
402                on_change(self.value, window, cx);
403            }
404            if let Some(ref on_selection_change) = self.on_selection_change {
405                on_selection_change(DatePickerSelection::Single(self.value), window, cx);
406            }
407        }
408    }
409
410    fn shift_month(&mut self, delta: i32, cx: &mut Context<Self>) {
411        let month_index = self.view_year * 12 + self.view_month as i32 - 1 + delta;
412        self.view_year = month_index.div_euclid(12);
413        self.view_month = month_index.rem_euclid(12) as u32 + 1;
414        cx.notify();
415    }
416
417    fn shift_year(&mut self, delta: i32, cx: &mut Context<Self>) {
418        self.view_year += delta;
419        cx.notify();
420    }
421}
422
423impl DatePickerType {
424    fn is_range(self) -> bool {
425        matches!(
426            self,
427            DatePickerType::DateRange | DatePickerType::MonthRange | DatePickerType::YearRange
428        )
429    }
430}
431
432impl Render for DatePicker {
433    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
434        let theme = cx.global::<Config>().theme.clone();
435        let entity = cx.entity().clone();
436        let display = self.display_text();
437        let range_start_text = self.range_start.map(|value| self.format_value(value));
438        let range_end_text = self.range_end.map(|value| self.format_value(value));
439        let range_separator = self.range_separator.clone();
440        let is_range = self.picker_type.is_range();
441        let has_value = self.has_display_value();
442        let border_color = if self.is_open {
443            theme.primary.base
444        } else {
445            theme.neutral.border
446        };
447
448        if self.is_open {
449            let entity = entity.clone();
450            let picker_id = self.id.clone();
451            let bounds = self.last_bounds;
452            let close_on_click_outside = self.close_on_click_outside;
453            push_portal(
454                move |_window, _cx| {
455                    let (top, left, width) = if let Some(bounds) = bounds {
456                        (bounds.bottom() + px(4.0), bounds.left(), bounds.size.width)
457                    } else {
458                        (px(100.0), px(100.0), px(240.0))
459                    };
460                    let close_entity = entity.clone();
461
462                    div()
463                        .absolute()
464                        .top_0()
465                        .left_0()
466                        .size_full()
467                        .bg(gpui::transparent_black())
468                        .when(close_on_click_outside, |s| {
469                            s.on_mouse_down(MouseButton::Left, move |_, _, cx| {
470                                close_entity.update(cx, |picker, cx| picker.close(cx));
471                            })
472                        })
473                        .child(pop_in(
474                            element_id(format!("{}-panel-motion", picker_id)),
475                            div()
476                                .absolute()
477                                .top(top)
478                                .left(left)
479                                .w(width.max(px(300.0)))
480                                .child(render_picker_panel(picker_id, entity, _cx)),
481                        ))
482                        .into_any_element()
483                },
484                cx,
485            );
486        }
487
488        div()
489            .relative()
490            .when_some(self.width, |s, width| s.w(width))
491            .when(self.width.is_none(), |s| s.w(px(220.0)))
492            .h(px(34.0))
493            .id(element_id(format!("{}-trigger", self.id)))
494            .flex()
495            .items_center()
496            .gap_2()
497            .px_3()
498            .bg(if self.disabled {
499                theme.neutral.hover
500            } else {
501                theme.neutral.card
502            })
503            .border_1()
504            .border_color(border_color)
505            .rounded(px(theme.radius.md))
506            .cursor_pointer()
507            .hover(|s| s.cursor_pointer().border_color(theme.primary.base))
508            .child(div().flex_1().min_w(px(0.0)).child(if is_range {
509                render_range_trigger_text(
510                    range_start_text,
511                    range_end_text,
512                    range_separator,
513                    self.placeholder.clone(),
514                    has_value,
515                    &theme,
516                )
517            } else {
518                div()
519                    .text_size(px(theme.font_size.md))
520                    .text_color(if has_value {
521                        theme.neutral.text_1
522                    } else {
523                        theme.neutral.placeholder
524                    })
525                    .child(display)
526                    .into_any_element()
527            }))
528            .child(
529                Icon::new(IconName::CalendarDays)
530                    .size(px(16.0))
531                    .color(theme.neutral.icon),
532            )
533            .child(
534                div()
535                    .absolute()
536                    .top_0()
537                    .left_0()
538                    .size_full()
539                    .child(DatePickerBoundsCapturer { picker: entity }),
540            )
541            .on_mouse_down(
542                MouseButton::Left,
543                cx.listener(|this, _, _, cx| {
544                    this.toggle_open(cx);
545                }),
546            )
547            .on_action(cx.listener(Self::close_on_escape_action))
548    }
549}
550
551fn render_range_trigger_text(
552    start: Option<String>,
553    end: Option<String>,
554    separator: SharedString,
555    placeholder: SharedString,
556    has_value: bool,
557    theme: &liora_theme::Theme,
558) -> gpui::AnyElement {
559    if !has_value {
560        return div()
561            .text_size(px(theme.font_size.md))
562            .text_color(theme.neutral.placeholder)
563            .child(placeholder)
564            .into_any_element();
565    }
566
567    let start = start.unwrap_or_default();
568    let end = end.unwrap_or_else(|| "请选择结束".to_string());
569
570    div()
571        .flex()
572        .items_center()
573        .gap_2()
574        .w_full()
575        .child(range_value_text(start, true, theme))
576        .child(
577            div()
578                .flex_shrink_0()
579                .px_2()
580                .py_1()
581                .rounded(px(theme.radius.sm))
582                .bg(theme.neutral.hover)
583                .text_xs()
584                .text_color(theme.neutral.text_3)
585                .child(separator),
586        )
587        .child(range_value_text(end, false, theme))
588        .into_any_element()
589}
590
591fn range_value_text(
592    text: impl Into<SharedString>,
593    filled: bool,
594    theme: &liora_theme::Theme,
595) -> impl IntoElement {
596    div()
597        .flex_1()
598        .min_w(px(0.0))
599        .px_1()
600        .text_size(px(theme.font_size.md))
601        .text_color(if filled {
602            theme.neutral.text_1
603        } else {
604            theme.neutral.text_3
605        })
606        .child(text.into())
607}
608
609fn render_picker_panel(
610    id: SharedString,
611    picker: Entity<DatePicker>,
612    cx: &mut App,
613) -> gpui::AnyElement {
614    let picker_type = picker.update(cx, |picker, _| picker.picker_type);
615    match picker_type {
616        DatePickerType::Date | DatePickerType::DateRange => render_date_panel(id, picker, cx),
617        DatePickerType::Month | DatePickerType::MonthRange => render_month_panel(id, picker, cx),
618        DatePickerType::Year | DatePickerType::YearRange => render_year_panel(id, picker, cx),
619    }
620}
621
622fn panel_shell(id: &SharedString, theme: &liora_theme::Theme) -> gpui::Stateful<gpui::Div> {
623    div()
624        .id(element_id(format!("{}-panel", id)))
625        .cursor_default()
626        .occlude()
627        .on_mouse_down(MouseButton::Left, |_, _, cx| {
628            cx.stop_propagation();
629        })
630        .flex()
631        .flex_col()
632        .p_3()
633        .gap_3()
634        .bg(theme.neutral.card)
635        .border_1()
636        .border_color(theme.neutral.border)
637        .rounded(px(theme.radius.md))
638        .shadow_lg()
639}
640
641fn render_date_panel(
642    id: SharedString,
643    picker: Entity<DatePicker>,
644    cx: &mut App,
645) -> gpui::AnyElement {
646    let theme = cx.global::<Config>().theme.clone();
647    let (view_year, view_month, selected, range_start, range_end) =
648        picker.update(cx, |picker, _| {
649            (
650                picker.view_year,
651                picker.view_month,
652                picker.value,
653                picker.range_start,
654                picker.range_end,
655            )
656        });
657    let days = calendar_cells(view_year, view_month);
658    let picker_prev_year = picker.clone();
659    let picker_prev_month = picker.clone();
660    let picker_next_month = picker.clone();
661    let picker_next_year = picker.clone();
662    let weekdays = ["一", "二", "三", "四", "五", "六", "日"];
663
664    panel_shell(&id, &theme)
665        .child(
666            div()
667                .flex()
668                .items_center()
669                .justify_between()
670                .child(
671                    div()
672                        .flex()
673                        .items_center()
674                        .gap_1()
675                        .child(nav_button(
676                            format!("{}-prev-year", id),
677                            IconName::ChevronsLeft,
678                            theme.neutral.icon,
679                            picker_prev_year,
680                            |picker, cx| picker.shift_year(-1, cx),
681                        ))
682                        .child(nav_button(
683                            format!("{}-prev-month", id),
684                            IconName::ChevronLeft,
685                            theme.neutral.icon,
686                            picker_prev_month,
687                            |picker, cx| picker.shift_month(-1, cx),
688                        )),
689                )
690                .child(
691                    div()
692                        .text_sm()
693                        .font_weight(gpui::FontWeight::BOLD)
694                        .text_color(theme.neutral.text_1)
695                        .child(format!("{} 年 {:02} 月", view_year, view_month)),
696                )
697                .child(
698                    div()
699                        .flex()
700                        .items_center()
701                        .gap_1()
702                        .child(nav_button(
703                            format!("{}-next-month", id),
704                            IconName::ChevronRight,
705                            theme.neutral.icon,
706                            picker_next_month,
707                            |picker, cx| picker.shift_month(1, cx),
708                        ))
709                        .child(nav_button(
710                            format!("{}-next-year", id),
711                            IconName::ChevronsRight,
712                            theme.neutral.icon,
713                            picker_next_year,
714                            |picker, cx| picker.shift_year(1, cx),
715                        )),
716                ),
717        )
718        .child(
719            div()
720                .flex()
721                .flex_row()
722                .children(weekdays.into_iter().map(|day| {
723                    div()
724                        .flex_1()
725                        .h(px(28.0))
726                        .flex()
727                        .items_center()
728                        .justify_center()
729                        .text_xs()
730                        .text_color(theme.neutral.text_3)
731                        .child(day)
732                })),
733        )
734        .child(
735            div()
736                .flex()
737                .flex_col()
738                .children(days.chunks(7).enumerate().map(|(week_idx, week)| {
739                    let id = id.clone();
740                    let week_picker = picker.clone();
741                    let week_theme = theme.clone();
742                    div()
743                        .flex()
744                        .flex_row()
745                        .children(week.iter().enumerate().map(move |(day_idx, cell)| {
746                            let is_current_month = cell.month == view_month;
747                            let is_selected = selected == Some(*cell)
748                                || range_start == Some(*cell)
749                                || range_end == Some(*cell);
750                            let in_range = is_between(*cell, range_start, range_end);
751                            let picker = week_picker.clone();
752                            let date = *cell;
753                            selectable_cell(
754                                format!("{}-day-{}-{}", id, week_idx, day_idx),
755                                cell.day.to_string(),
756                                is_selected,
757                                in_range,
758                                is_current_month,
759                                week_theme.clone(),
760                                picker,
761                                move |picker, window, cx| picker.select_value(date, window, cx),
762                            )
763                        }))
764                })),
765        )
766        .into_any_element()
767}
768
769fn render_month_panel(
770    id: SharedString,
771    picker: Entity<DatePicker>,
772    cx: &mut App,
773) -> gpui::AnyElement {
774    let theme = cx.global::<Config>().theme.clone();
775    let (view_year, selected, range_start, range_end) = picker.update(cx, |picker, _| {
776        (
777            picker.view_year,
778            picker.value,
779            picker.range_start,
780            picker.range_end,
781        )
782    });
783    let picker_prev_year = picker.clone();
784    let picker_next_year = picker.clone();
785    let labels = [
786        "一月",
787        "二月",
788        "三月",
789        "四月",
790        "五月",
791        "六月",
792        "七月",
793        "八月",
794        "九月",
795        "十月",
796        "十一月",
797        "十二月",
798    ];
799
800    panel_shell(&id, &theme)
801        .child(
802            div()
803                .flex()
804                .items_center()
805                .justify_between()
806                .child(nav_button(
807                    format!("{}-prev-year", id),
808                    IconName::ChevronsLeft,
809                    theme.neutral.icon,
810                    picker_prev_year,
811                    |picker, cx| picker.shift_year(-1, cx),
812                ))
813                .child(
814                    div()
815                        .text_sm()
816                        .font_weight(gpui::FontWeight::BOLD)
817                        .text_color(theme.neutral.text_1)
818                        .child(format!("{} 年", view_year)),
819                )
820                .child(nav_button(
821                    format!("{}-next-year", id),
822                    IconName::ChevronsRight,
823                    theme.neutral.icon,
824                    picker_next_year,
825                    |picker, cx| picker.shift_year(1, cx),
826                )),
827        )
828        .child(
829            div()
830                .flex()
831                .flex_col()
832                .gap_2()
833                .children(labels.chunks(3).enumerate().map(|(row_idx, row)| {
834                    let id = id.clone();
835                    let row_picker = picker.clone();
836                    let row_theme = theme.clone();
837                    div()
838                        .flex()
839                        .flex_row()
840                        .gap_2()
841                        .children(row.iter().enumerate().map(move |(col_idx, label)| {
842                            let month = (row_idx * 3 + col_idx + 1) as u32;
843                            let value = DateValue {
844                                year: view_year,
845                                month,
846                                day: 1,
847                            };
848                            let is_selected = selected == Some(value)
849                                || range_start == Some(value)
850                                || range_end == Some(value);
851                            let in_range = is_between(value, range_start, range_end);
852                            let picker = row_picker.clone();
853                            selectable_cell(
854                                format!("{}-month-{}", id, month),
855                                *label,
856                                is_selected,
857                                in_range,
858                                true,
859                                row_theme.clone(),
860                                picker,
861                                move |picker, window, cx| picker.select_value(value, window, cx),
862                            )
863                        }))
864                })),
865        )
866        .into_any_element()
867}
868
869fn render_year_panel(
870    id: SharedString,
871    picker: Entity<DatePicker>,
872    cx: &mut App,
873) -> gpui::AnyElement {
874    let theme = cx.global::<Config>().theme.clone();
875    let (view_year, selected, range_start, range_end) = picker.update(cx, |picker, _| {
876        (
877            picker.view_year,
878            picker.value,
879            picker.range_start,
880            picker.range_end,
881        )
882    });
883    let start_year = view_year.div_euclid(12) * 12;
884    let picker_prev = picker.clone();
885    let picker_next = picker.clone();
886    let years: Vec<i32> = (start_year..start_year + 12).collect();
887
888    panel_shell(&id, &theme)
889        .child(
890            div()
891                .flex()
892                .items_center()
893                .justify_between()
894                .child(nav_button(
895                    format!("{}-prev-years", id),
896                    IconName::ChevronsLeft,
897                    theme.neutral.icon,
898                    picker_prev,
899                    |picker, cx| picker.shift_year(-12, cx),
900                ))
901                .child(
902                    div()
903                        .text_sm()
904                        .font_weight(gpui::FontWeight::BOLD)
905                        .text_color(theme.neutral.text_1)
906                        .child(format!("{} - {}", start_year, start_year + 11)),
907                )
908                .child(nav_button(
909                    format!("{}-next-years", id),
910                    IconName::ChevronsRight,
911                    theme.neutral.icon,
912                    picker_next,
913                    |picker, cx| picker.shift_year(12, cx),
914                )),
915        )
916        .child(
917            div()
918                .flex()
919                .flex_col()
920                .gap_2()
921                .children(years.chunks(4).enumerate().map(|(row_idx, row)| {
922                    let id = id.clone();
923                    let row_picker = picker.clone();
924                    let row_theme = theme.clone();
925                    div()
926                        .flex()
927                        .flex_row()
928                        .gap_2()
929                        .children(row.iter().enumerate().map(move |(col_idx, year)| {
930                            let value = DateValue {
931                                year: *year,
932                                month: 1,
933                                day: 1,
934                            };
935                            let is_selected = selected == Some(value)
936                                || range_start == Some(value)
937                                || range_end == Some(value);
938                            let in_range = is_between(value, range_start, range_end);
939                            let picker = row_picker.clone();
940                            selectable_cell(
941                                format!("{}-year-{}-{}", id, row_idx, col_idx),
942                                year.to_string(),
943                                is_selected,
944                                in_range,
945                                true,
946                                row_theme.clone(),
947                                picker,
948                                move |picker, window, cx| picker.select_value(value, window, cx),
949                            )
950                        }))
951                })),
952        )
953        .into_any_element()
954}
955
956fn selectable_cell(
957    id: impl Into<SharedString>,
958    label: impl Into<SharedString>,
959    is_selected: bool,
960    in_range: bool,
961    is_current_scope: bool,
962    theme: liora_theme::Theme,
963    picker: Entity<DatePicker>,
964    action: impl Fn(&mut DatePicker, &mut Window, &mut Context<DatePicker>) + 'static,
965) -> impl IntoElement {
966    div()
967        .id(id.into())
968        .flex_1()
969        .h(px(34.0))
970        .flex()
971        .items_center()
972        .justify_center()
973        .cursor_pointer()
974        .rounded(px(theme.radius.sm))
975        .bg(if is_selected {
976            theme.primary.base
977        } else if in_range {
978            theme.primary.light_9
979        } else {
980            theme.neutral.card
981        })
982        .text_color(if is_selected {
983            theme.neutral.card
984        } else if is_current_scope {
985            theme.neutral.text_1
986        } else {
987            theme.neutral.text_3.opacity(0.55)
988        })
989        .hover(|s| {
990            if is_selected {
991                s.cursor_pointer()
992            } else {
993                s.cursor_pointer().bg(theme.neutral.hover)
994            }
995        })
996        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
997            picker.update(cx, |picker, cx| action(picker, window, cx));
998        })
999        .child(div().text_sm().child(label.into()))
1000}
1001
1002fn nav_button(
1003    id: impl Into<SharedString>,
1004    icon: IconName,
1005    icon_color: Hsla,
1006    picker: Entity<DatePicker>,
1007    action: impl Fn(&mut DatePicker, &mut Context<DatePicker>) + 'static,
1008) -> impl IntoElement {
1009    div()
1010        .id(id.into())
1011        .cursor_pointer()
1012        .p_1()
1013        .rounded(px(4.0))
1014        .hover(|s| s.cursor_pointer().bg(gpui::black().opacity(0.04)))
1015        .on_mouse_down(MouseButton::Left, move |_, _, cx| {
1016            picker.update(cx, |picker, cx| action(picker, cx));
1017        })
1018        .child(Icon::new(icon).size(px(18.0)).color(icon_color))
1019}
1020
1021struct DatePickerBoundsCapturer {
1022    picker: Entity<DatePicker>,
1023}
1024
1025impl IntoElement for DatePickerBoundsCapturer {
1026    type Element = Self;
1027    fn into_element(self) -> Self::Element {
1028        self
1029    }
1030}
1031
1032impl Element for DatePickerBoundsCapturer {
1033    type RequestLayoutState = ();
1034    type PrepaintState = ();
1035
1036    fn id(&self) -> Option<ElementId> {
1037        None
1038    }
1039
1040    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1041        None
1042    }
1043
1044    fn request_layout(
1045        &mut self,
1046        _id: Option<&GlobalElementId>,
1047        _id2: Option<&InspectorElementId>,
1048        window: &mut Window,
1049        cx: &mut App,
1050    ) -> (LayoutId, Self::RequestLayoutState) {
1051        let mut style = gpui::Style::default();
1052        style.size.width = gpui::relative(1.0).into();
1053        style.size.height = gpui::relative(1.0).into();
1054        (window.request_layout(style, [], cx), ())
1055    }
1056
1057    fn prepaint(
1058        &mut self,
1059        _id: Option<&GlobalElementId>,
1060        _id2: Option<&InspectorElementId>,
1061        bounds: Bounds<Pixels>,
1062        _rl: &mut Self::RequestLayoutState,
1063        _window: &mut Window,
1064        cx: &mut App,
1065    ) -> Self::PrepaintState {
1066        self.picker.update(cx, |picker, _| {
1067            picker.last_bounds = Some(bounds);
1068        });
1069    }
1070
1071    fn paint(
1072        &mut self,
1073        _id: Option<&GlobalElementId>,
1074        _id2: Option<&InspectorElementId>,
1075        _bounds: Bounds<Pixels>,
1076        _rl: &mut Self::RequestLayoutState,
1077        _ps: &mut Self::PrepaintState,
1078        _window: &mut Window,
1079        _cx: &mut App,
1080    ) {
1081    }
1082}
1083
1084fn default_placeholder(picker_type: DatePickerType) -> &'static str {
1085    match picker_type {
1086        DatePickerType::Date => "请选择日期",
1087        DatePickerType::DateRange => "请选择日期范围",
1088        DatePickerType::Month => "请选择月份",
1089        DatePickerType::MonthRange => "请选择月份范围",
1090        DatePickerType::Year => "请选择年份",
1091        DatePickerType::YearRange => "请选择年份范围",
1092    }
1093}
1094
1095fn default_format(picker_type: DatePickerType) -> &'static str {
1096    match picker_type {
1097        DatePickerType::Date | DatePickerType::DateRange => "YYYY-MM-DD",
1098        DatePickerType::Month | DatePickerType::MonthRange => "YYYY-MM",
1099        DatePickerType::Year | DatePickerType::YearRange => "YYYY",
1100    }
1101}
1102
1103fn format_date_value(value: DateValue, format: &str) -> String {
1104    format
1105        .replace("YYYY", &format!("{:04}", value.year))
1106        .replace("YY", &format!("{:02}", value.year.rem_euclid(100)))
1107        .replace("MM", &format!("{:02}", value.month))
1108        .replace("M", &value.month.to_string())
1109        .replace("DD", &format!("{:02}", value.day))
1110        .replace("D", &value.day.to_string())
1111}
1112
1113fn normalize_value(value: DateValue, picker_type: DatePickerType) -> DateValue {
1114    match picker_type {
1115        DatePickerType::Date | DatePickerType::DateRange => value,
1116        DatePickerType::Month | DatePickerType::MonthRange => DateValue {
1117            year: value.year,
1118            month: value.month,
1119            day: 1,
1120        },
1121        DatePickerType::Year | DatePickerType::YearRange => DateValue {
1122            year: value.year,
1123            month: 1,
1124            day: 1,
1125        },
1126    }
1127}
1128
1129fn ordered_pair(a: DateValue, b: DateValue) -> (DateValue, DateValue) {
1130    if a <= b { (a, b) } else { (b, a) }
1131}
1132
1133fn is_between(value: DateValue, start: Option<DateValue>, end: Option<DateValue>) -> bool {
1134    matches!((start, end), (Some(start), Some(end)) if value > start && value < end)
1135}
1136
1137fn calendar_cells(year: i32, month: u32) -> Vec<DateValue> {
1138    let first_weekday = weekday_monday_based(year, month, 1);
1139    let prev_month_index = year * 12 + month as i32 - 2;
1140    let prev_year = prev_month_index.div_euclid(12);
1141    let prev_month = prev_month_index.rem_euclid(12) as u32 + 1;
1142    let current_days = days_in_month(year, month);
1143    let prev_days = days_in_month(prev_year, prev_month);
1144    let mut cells = Vec::with_capacity(42);
1145
1146    for i in (0..first_weekday).rev() {
1147        cells.push(DateValue {
1148            year: prev_year,
1149            month: prev_month,
1150            day: prev_days - i,
1151        });
1152    }
1153
1154    for day in 1..=current_days {
1155        cells.push(DateValue { year, month, day });
1156    }
1157
1158    let next_month_index = year * 12 + month as i32;
1159    let next_year = next_month_index.div_euclid(12);
1160    let next_month = next_month_index.rem_euclid(12) as u32 + 1;
1161    let mut next_day = 1;
1162    while cells.len() < 42 {
1163        cells.push(DateValue {
1164            year: next_year,
1165            month: next_month,
1166            day: next_day,
1167        });
1168        next_day += 1;
1169    }
1170
1171    cells
1172}
1173
1174fn days_in_month(year: i32, month: u32) -> u32 {
1175    match month {
1176        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1177        4 | 6 | 9 | 11 => 30,
1178        2 if is_leap_year(year) => 29,
1179        2 => 28,
1180        _ => 30,
1181    }
1182}
1183
1184fn is_leap_year(year: i32) -> bool {
1185    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1186}
1187
1188fn weekday_monday_based(year: i32, month: u32, day: u32) -> u32 {
1189    let mut y = year;
1190    let mut m = month as i32;
1191    if m < 3 {
1192        m += 12;
1193        y -= 1;
1194    }
1195    let k = y % 100;
1196    let j = y / 100;
1197    let h = (day as i32 + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j).rem_euclid(7);
1198    ((h + 5) % 7) as u32
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    #[test]
1206    fn date_picker_width_helpers_set_demo_widths() {
1207        assert_eq!(DatePicker::new().width_md().width, Some(px(260.0)));
1208        assert_eq!(DatePicker::new().width_lg().width, Some(px(320.0)));
1209    }
1210}