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}