Skip to main content

liora_components/
time_picker.rs

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}