Skip to main content

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