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}