1use crate::gpui_compat::element_id;
2use crate::motion::pop_in;
3use gpui::{
4 App, Bounds, Context, Element, ElementId, Entity, GlobalElementId, 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!(time_picker, [TimePickerClose]);
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub struct TimeValue {
16 pub hour: u32,
17 pub minute: u32,
18 pub second: u32,
19}
20
21pub struct TimePicker {
22 id: SharedString,
23 value: Option<TimeValue>,
24 is_open: bool,
25 placeholder: SharedString,
26 display_format: SharedString,
27 width: Option<Pixels>,
28 disabled: bool,
29 minute_step: u32,
30 second_step: u32,
31 show_seconds: bool,
32 last_bounds: Option<Bounds<Pixels>>,
33 close_on_click_outside: bool,
34 close_on_escape: bool,
35 on_change: Option<Box<dyn Fn(Option<TimeValue>, &mut Window, &mut App) + 'static>>,
36}
37
38impl TimeValue {
39 pub fn new(hour: u32, minute: u32, second: u32) -> Option<Self> {
40 if hour > 23 || minute > 59 || second > 59 {
41 return None;
42 }
43 Some(Self {
44 hour,
45 minute,
46 second,
47 })
48 }
49
50 pub fn format(&self) -> String {
51 format!("{:02}:{:02}:{:02}", self.hour, self.minute, self.second)
52 }
53}
54
55impl TimePicker {
56 pub fn new() -> Self {
57 Self {
58 id: liora_core::unique_id("time-picker"),
59 value: None,
60 is_open: false,
61 placeholder: "请选择时间".into(),
62 display_format: "HH:mm:ss".into(),
63 width: None,
64 disabled: false,
65 minute_step: 1,
66 second_step: 1,
67 show_seconds: true,
68 last_bounds: None,
69 close_on_click_outside: true,
70 close_on_escape: true,
71 on_change: None,
72 }
73 }
74
75 pub fn id(mut self, id: impl Into<SharedString>) -> Self {
76 self.id = id.into();
77 self
78 }
79
80 pub fn value(mut self, value: TimeValue) -> Self {
81 self.value = Some(value);
82 self
83 }
84
85 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
86 self.placeholder = placeholder.into();
87 self
88 }
89
90 pub fn format(mut self, format: impl Into<SharedString>) -> Self {
91 self.display_format = format.into();
92 self
93 }
94
95 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
96 self.width = Some(width.into());
97 self
98 }
99
100 pub fn width_md(self) -> Self {
101 self.width(px(240.0))
102 }
103
104 pub fn width_lg(self) -> Self {
105 self.width(px(280.0))
106 }
107
108 pub fn disabled(mut self, disabled: bool) -> Self {
109 self.disabled = disabled;
110 self
111 }
112
113 pub fn minute_step(mut self, step: u32) -> Self {
114 self.minute_step = step.clamp(1, 60);
115 self
116 }
117
118 pub fn second_step(mut self, step: u32) -> Self {
119 self.second_step = step.clamp(1, 60);
120 self
121 }
122
123 pub fn without_seconds(mut self) -> Self {
124 self.show_seconds = false;
125 self.display_format = "HH:mm".into();
126 self
127 }
128
129 pub fn close_on_escape(mut self, close: bool) -> Self {
130 self.close_on_escape = close;
131 self
132 }
133
134 pub fn close_on_click_outside(mut self, close: bool) -> Self {
135 self.close_on_click_outside = close;
136 self
137 }
138
139 pub fn register_key_bindings(cx: &mut App) {
140 cx.bind_keys([gpui::KeyBinding::new("escape", TimePickerClose, None)]);
141 }
142
143 fn close_on_escape_action(
144 &mut self,
145 _: &TimePickerClose,
146 _: &mut Window,
147 cx: &mut Context<Self>,
148 ) {
149 if self.close_on_escape && self.is_open {
150 self.close(cx);
151 }
152 }
153
154 pub fn on_change(
155 mut self,
156 f: impl Fn(Option<TimeValue>, &mut Window, &mut App) + 'static,
157 ) -> Self {
158 self.on_change = Some(Box::new(f));
159 self
160 }
161
162 pub fn set_on_change(
163 &mut self,
164 f: impl Fn(Option<TimeValue>, &mut Window, &mut App) + 'static,
165 _cx: &mut Context<Self>,
166 ) {
167 self.on_change = Some(Box::new(f));
168 }
169
170 pub fn set_value(&mut self, value: Option<TimeValue>, cx: &mut Context<Self>) {
171 self.value = value;
172 cx.notify();
173 }
174
175 pub fn value_ref(&self) -> Option<TimeValue> {
176 self.value
177 }
178
179 fn display_text(&self) -> String {
180 self.value
181 .map(|value| format_time_value(value, self.display_format.as_ref()))
182 .unwrap_or_else(|| self.placeholder.to_string())
183 }
184
185 fn toggle_open(&mut self, cx: &mut Context<Self>) {
186 if self.disabled {
187 return;
188 }
189 self.is_open = !self.is_open;
190 cx.notify();
191 }
192
193 fn close(&mut self, cx: &mut Context<Self>) {
194 if self.is_open {
195 self.is_open = false;
196 cx.notify();
197 }
198 }
199
200 fn select_time(&mut self, value: TimeValue, window: &mut Window, cx: &mut Context<Self>) {
201 self.value = Some(value);
202 self.is_open = false;
203 if let Some(ref on_change) = self.on_change {
204 on_change(Some(value), window, cx);
205 }
206 cx.notify();
207 }
208}
209
210impl Render for TimePicker {
211 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
212 let theme = cx.global::<Config>().theme.clone();
213 let entity = cx.entity().clone();
214 let display = self.display_text();
215 let has_value = self.value.is_some();
216 let border_color = if self.is_open {
217 theme.primary.base
218 } else {
219 theme.neutral.border
220 };
221
222 if self.is_open {
223 let entity = entity.clone();
224 let picker_id = self.id.clone();
225 let bounds = self.last_bounds;
226 let panel_min_width = if self.show_seconds {
227 px(312.0)
228 } else {
229 px(232.0)
230 };
231 let close_on_click_outside = self.close_on_click_outside;
232 push_portal(
233 move |_window, _cx| {
234 let (top, left, width) = if let Some(bounds) = bounds {
235 (bounds.bottom() + px(4.0), bounds.left(), bounds.size.width)
236 } else {
237 (px(100.0), px(100.0), px(220.0))
238 };
239 let close_entity = entity.clone();
240
241 div()
242 .absolute()
243 .top_0()
244 .left_0()
245 .size_full()
246 .bg(gpui::transparent_black())
247 .when(close_on_click_outside, |s| {
248 s.on_mouse_down(MouseButton::Left, move |_, _, cx| {
249 close_entity.update(cx, |picker, cx| picker.close(cx));
250 })
251 })
252 .child(pop_in(
253 element_id(format!("{}-panel-motion", picker_id)),
254 div()
255 .absolute()
256 .top(top)
257 .left(left)
258 .w(width.max(panel_min_width))
259 .child(render_time_panel(picker_id, entity, _cx)),
260 ))
261 .into_any_element()
262 },
263 cx,
264 );
265 }
266
267 div()
268 .relative()
269 .when_some(self.width, |s, width| s.w(width))
270 .when(self.width.is_none(), |s| s.w(px(220.0)))
271 .h(px(34.0))
272 .id(element_id(format!("{}-trigger", self.id)))
273 .flex()
274 .items_center()
275 .justify_between()
276 .gap_2()
277 .px_3()
278 .bg(if self.disabled {
279 theme.neutral.hover
280 } else {
281 theme.neutral.card
282 })
283 .border_1()
284 .border_color(border_color)
285 .rounded(px(theme.radius.md))
286 .cursor_pointer()
287 .hover(|s| s.cursor_pointer().border_color(theme.primary.base))
288 .child(
289 div()
290 .flex_1()
291 .min_w(px(0.0))
292 .text_size(px(theme.font_size.md))
293 .text_color(if has_value {
294 theme.neutral.text_1
295 } else {
296 theme.neutral.placeholder
297 })
298 .child(display),
299 )
300 .child(
301 Icon::new(IconName::Clock)
302 .size(px(16.0))
303 .color(theme.neutral.icon),
304 )
305 .child(
306 div()
307 .absolute()
308 .top_0()
309 .left_0()
310 .size_full()
311 .child(TimePickerBoundsCapturer { picker: entity }),
312 )
313 .on_mouse_down(
314 MouseButton::Left,
315 cx.listener(|this, _, _, cx| {
316 this.toggle_open(cx);
317 }),
318 )
319 .on_action(cx.listener(Self::close_on_escape_action))
320 }
321}
322
323fn render_time_panel(
324 id: SharedString,
325 picker: Entity<TimePicker>,
326 cx: &mut App,
327) -> gpui::AnyElement {
328 let theme = cx.global::<Config>().theme.clone();
329 let (selected, minute_step, second_step, show_seconds, display_format) =
330 picker.update(cx, |picker, _| {
331 (
332 picker.value,
333 picker.minute_step,
334 picker.second_step,
335 picker.show_seconds,
336 picker.display_format.clone(),
337 )
338 });
339
340 let hours: Vec<u32> = (0..24).collect();
341 let minutes: Vec<u32> = stepped_values(minute_step);
342 let seconds: Vec<u32> = stepped_values(second_step);
343 let preview = selected
344 .map(|value| format_time_value(value, display_format.as_ref()))
345 .unwrap_or_else(|| "--:--".to_string());
346
347 div()
348 .id(element_id(format!("{}-panel", id)))
349 .cursor_default()
350 .occlude()
351 .on_mouse_down(MouseButton::Left, |_, _, cx| {
352 cx.stop_propagation();
353 })
354 .flex()
355 .flex_col()
356 .gap_2()
357 .p_2()
358 .bg(theme.neutral.card)
359 .border_1()
360 .border_color(theme.neutral.border)
361 .rounded(px(theme.radius.lg))
362 .shadow_lg()
363 .child(
364 div()
365 .h(px(34.0))
366 .flex()
367 .items_center()
368 .justify_between()
369 .px_2()
370 .child(
371 div()
372 .text_sm()
373 .font_weight(gpui::FontWeight::BOLD)
374 .text_color(theme.neutral.text_1)
375 .child("时间"),
376 )
377 .child(
378 div()
379 .px_2()
380 .py_1()
381 .rounded(px(theme.radius.sm))
382 .bg(theme.primary.light_9)
383 .text_sm()
384 .font_weight(gpui::FontWeight::BOLD)
385 .text_color(theme.primary.base)
386 .child(preview),
387 ),
388 )
389 .child(
390 div()
391 .flex()
392 .gap_1()
393 .p_1()
394 .rounded(px(theme.radius.md))
395 .border_1()
396 .border_color(theme.neutral.border)
397 .bg(theme.neutral.body)
398 .child(time_column(
399 format!("{}-hour", id),
400 "时",
401 hours,
402 selected.map(|value| value.hour),
403 &theme,
404 picker.clone(),
405 move |current, hour| TimeValue {
406 hour,
407 minute: current.map(|value| value.minute).unwrap_or(0),
408 second: current.map(|value| value.second).unwrap_or(0),
409 },
410 ))
411 .child(time_column(
412 format!("{}-minute", id),
413 "分",
414 minutes,
415 selected.map(|value| value.minute),
416 &theme,
417 picker.clone(),
418 move |current, minute| TimeValue {
419 hour: current.map(|value| value.hour).unwrap_or(0),
420 minute,
421 second: current.map(|value| value.second).unwrap_or(0),
422 },
423 ))
424 .when(show_seconds, |s| {
425 s.child(time_column(
426 format!("{}-second", id),
427 "秒",
428 seconds,
429 selected.map(|value| value.second),
430 &theme,
431 picker.clone(),
432 move |current, second| TimeValue {
433 hour: current.map(|value| value.hour).unwrap_or(0),
434 minute: current.map(|value| value.minute).unwrap_or(0),
435 second,
436 },
437 ))
438 }),
439 )
440 .into_any_element()
441}
442
443fn time_column(
444 id: impl Into<SharedString>,
445 title: &'static str,
446 values: Vec<u32>,
447 selected: Option<u32>,
448 theme: &liora_theme::Theme,
449 picker: Entity<TimePicker>,
450 build_value: impl Fn(Option<TimeValue>, u32) -> TimeValue + Clone + 'static,
451) -> impl IntoElement {
452 let id = id.into();
453 div()
454 .flex_1()
455 .min_w(px(64.0))
456 .flex()
457 .flex_col()
458 .child(
459 div()
460 .h(px(24.0))
461 .flex()
462 .items_center()
463 .justify_center()
464 .text_xs()
465 .font_weight(gpui::FontWeight::BOLD)
466 .text_color(theme.neutral.text_3)
467 .child(title),
468 )
469 .child(
470 div()
471 .id(element_id(format!("{}-scroll", id)))
472 .max_h(px(210.0))
473 .overflow_y_scroll()
474 .flex()
475 .flex_col()
476 .gap_1()
477 .children(values.into_iter().map(move |value| {
478 let is_selected = selected == Some(value);
479 let picker = picker.clone();
480 let build_value = build_value.clone();
481 time_option(
482 format!("{}-{}", id, value),
483 value,
484 is_selected,
485 theme.clone(),
486 picker,
487 build_value,
488 )
489 })),
490 )
491}
492
493fn time_option(
494 id: impl Into<SharedString>,
495 value: u32,
496 is_selected: bool,
497 theme: liora_theme::Theme,
498 picker: Entity<TimePicker>,
499 build_value: impl Fn(Option<TimeValue>, u32) -> TimeValue + 'static,
500) -> impl IntoElement {
501 div()
502 .id(id.into())
503 .h(px(30.0))
504 .flex()
505 .items_center()
506 .justify_center()
507 .rounded(px(theme.radius.sm))
508 .cursor_pointer()
509 .bg(if is_selected {
510 theme.primary.base
511 } else {
512 gpui::transparent_black()
513 })
514 .text_color(if is_selected {
515 theme.neutral.card
516 } else {
517 theme.neutral.text_1
518 })
519 .hover(|s| {
520 if is_selected {
521 s.cursor_pointer().bg(theme.primary.hover)
522 } else {
523 s.cursor_pointer().bg(theme.neutral.card)
524 }
525 })
526 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
527 picker.update(cx, |picker, cx| {
528 let next = build_value(picker.value, value);
529 picker.select_time(next, window, cx);
530 });
531 })
532 .child(
533 div()
534 .text_sm()
535 .font_weight(if is_selected {
536 gpui::FontWeight::BOLD
537 } else {
538 gpui::FontWeight::NORMAL
539 })
540 .child(format!("{:02}", value)),
541 )
542}
543
544fn stepped_values(step: u32) -> Vec<u32> {
545 let step = step.clamp(1, 60) as usize;
546 (0..60).step_by(step).collect()
547}
548
549fn format_time_value(value: TimeValue, format: &str) -> String {
550 format
551 .replace("HH", &format!("{:02}", value.hour))
552 .replace("H", &value.hour.to_string())
553 .replace("mm", &format!("{:02}", value.minute))
554 .replace("m", &value.minute.to_string())
555 .replace("ss", &format!("{:02}", value.second))
556 .replace("s", &value.second.to_string())
557}
558
559struct TimePickerBoundsCapturer {
560 picker: Entity<TimePicker>,
561}
562
563impl IntoElement for TimePickerBoundsCapturer {
564 type Element = Self;
565 fn into_element(self) -> Self::Element {
566 self
567 }
568}
569
570impl Element for TimePickerBoundsCapturer {
571 type RequestLayoutState = ();
572 type PrepaintState = ();
573
574 fn id(&self) -> Option<ElementId> {
575 None
576 }
577
578 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
579 None
580 }
581
582 fn request_layout(
583 &mut self,
584 _id: Option<&GlobalElementId>,
585 _id2: Option<&InspectorElementId>,
586 window: &mut Window,
587 cx: &mut App,
588 ) -> (LayoutId, Self::RequestLayoutState) {
589 let mut style = gpui::Style::default();
590 style.size.width = gpui::relative(1.0).into();
591 style.size.height = gpui::relative(1.0).into();
592 (window.request_layout(style, [], cx), ())
593 }
594
595 fn prepaint(
596 &mut self,
597 _id: Option<&GlobalElementId>,
598 _id2: Option<&InspectorElementId>,
599 bounds: Bounds<Pixels>,
600 _rl: &mut Self::RequestLayoutState,
601 _window: &mut Window,
602 cx: &mut App,
603 ) -> Self::PrepaintState {
604 self.picker.update(cx, |picker, _| {
605 picker.last_bounds = Some(bounds);
606 });
607 }
608
609 fn paint(
610 &mut self,
611 _id: Option<&GlobalElementId>,
612 _id2: Option<&InspectorElementId>,
613 _bounds: Bounds<Pixels>,
614 _rl: &mut Self::RequestLayoutState,
615 _ps: &mut Self::PrepaintState,
616 _window: &mut Window,
617 _cx: &mut App,
618 ) {
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn time_picker_width_helpers_set_demo_widths() {
628 assert_eq!(TimePicker::new().width_md().width, Some(px(240.0)));
629 assert_eq!(TimePicker::new().width_lg().width, Some(px(280.0)));
630 }
631}