Skip to main content

guise/input/
timepicker.rs

1//! `TimePicker` — a stateful time-of-day field (gpui entity).
2//!
3//! A trigger plus a deferred dropdown of hour/minute (and AM/PM) columns.
4//! Emits [`TimePickerEvent`] whenever the value changes; picking a minute
5//! closes the dropdown.
6
7use gpui::prelude::*;
8use gpui::{
9    deferred, div, px, App, Context, Div, Entity, EventEmitter, FocusHandle, IntoElement,
10    SharedString, Stateful, Window,
11};
12
13use super::control_metrics;
14use super::time::Time;
15use crate::devtools::Probed;
16use crate::icon::{Icon, IconName};
17use crate::reactive::Signal;
18use crate::theme::{theme, Size};
19
20/// Emitted whenever the picked time changes. Carries the new value.
21#[derive(Debug, Clone)]
22pub struct TimePickerEvent(pub Time);
23
24/// A dropdown time field. Create with `cx.new(|cx| TimePicker::new(cx))`.
25pub struct TimePicker {
26    open: bool,
27    focus: FocusHandle,
28    value: Option<Time>,
29    twelve_hour: bool,
30    minute_step: u32,
31    placeholder: SharedString,
32    label: Option<SharedString>,
33    size: Size,
34    disabled: bool,
35}
36
37impl EventEmitter<TimePickerEvent> for TimePicker {}
38
39impl TimePicker {
40    pub fn new(cx: &mut Context<Self>) -> Self {
41        TimePicker {
42            open: false,
43            focus: cx.focus_handle(),
44            value: None,
45            twelve_hour: true,
46            minute_step: 5,
47            placeholder: SharedString::new_static("Pick a time"),
48            label: None,
49            size: Size::Sm,
50            disabled: false,
51        }
52    }
53
54    pub fn value(mut self, value: Time) -> Self {
55        self.value = Some(value);
56        self
57    }
58
59    /// Show a 24-hour clock (default is 12-hour with AM/PM).
60    pub fn twenty_four_hour(mut self) -> Self {
61        self.twelve_hour = false;
62        self
63    }
64
65    /// Minute list granularity (default 5; use 1, 5, 10, 15, 30…).
66    pub fn minute_step(mut self, step: u32) -> Self {
67        self.minute_step = step.clamp(1, 30);
68        self
69    }
70
71    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
72        self.placeholder = placeholder.into();
73        self
74    }
75
76    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
77        self.label = Some(label.into());
78        self
79    }
80
81    pub fn size(mut self, size: Size) -> Self {
82        self.size = size;
83        self
84    }
85
86    pub fn disabled(mut self, disabled: bool) -> Self {
87        self.disabled = disabled;
88        self
89    }
90
91    pub fn time(&self) -> Option<Time> {
92        self.value
93    }
94
95    /// Two-way bind the value to a `Signal<Option<Time>>`. The signal is the
96    /// source of truth; equality guards on both directions prevent loops.
97    pub fn bind(entity: &Entity<TimePicker>, signal: &Signal<Option<Time>>, cx: &mut App) {
98        let initial = signal.get(cx);
99        entity.update(cx, |this, cx| this.sync_value(initial, cx));
100        let sink = signal.clone();
101        cx.subscribe(entity, move |_picker, event: &TimePickerEvent, cx| {
102            sink.set_if_changed(cx, Some(event.0));
103        })
104        .detach();
105        let picker = entity.downgrade();
106        cx.observe(signal.entity(), move |observed, cx| {
107            let time = *observed.read(cx);
108            picker.update(cx, |this, cx| this.sync_value(time, cx)).ok();
109        })
110        .detach();
111    }
112
113    fn sync_value(&mut self, time: Option<Time>, cx: &mut Context<Self>) {
114        if self.value != time {
115            self.value = time;
116            cx.notify();
117        }
118    }
119
120    fn base(&self) -> Time {
121        self.value
122            .unwrap_or_else(|| Time::new(12, 0).expect("noon is valid"))
123    }
124
125    fn set_value(&mut self, time: Time, close: bool, cx: &mut Context<Self>) {
126        self.value = Some(time);
127        if close {
128            self.open = false;
129        }
130        cx.emit(TimePickerEvent(time));
131        cx.notify();
132    }
133
134    /// One scrollable option column of the dropdown.
135    fn column(
136        &self,
137        id: &'static str,
138        entries: Vec<(SharedString, Time, bool, bool)>,
139        cx: &mut Context<Self>,
140    ) -> Stateful<Div> {
141        let t = theme(cx);
142        let font = t.font_size(self.size);
143        let text_color = t.text().hsla();
144        let surface_hover = t.surface_hover().hsla();
145        let accent = t.primary();
146        let accent_bg = accent.hsla();
147        let accent_fg = accent.contrasting().hsla();
148
149        let mut column = div()
150            .id(id)
151            .flex()
152            .flex_col()
153            .gap(px(2.0))
154            .max_h(px(200.0))
155            .overflow_y_scroll()
156            .pr(px(2.0));
157        for (i, (text, next, is_selected, closes)) in entries.into_iter().enumerate() {
158            let mut option = div()
159                .id((id, i))
160                .px(px(10.0))
161                .py(px(4.0))
162                .rounded(px(4.0))
163                .text_size(px(font))
164                .text_color(text_color)
165                .child(text)
166                .on_click(cx.listener(move |this, _ev, _window, cx| {
167                    this.set_value(next, closes, cx);
168                }));
169            if is_selected {
170                option = option.bg(accent_bg).text_color(accent_fg);
171            } else {
172                option = option.hover(move |s| s.bg(surface_hover));
173            }
174            column = column.child(option);
175        }
176        column
177    }
178}
179
180impl Render for TimePicker {
181    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
182        let t = theme(cx);
183        let (height, pad_x, font) = control_metrics(self.size);
184        let radius = t.radius(t.default_radius);
185        let surface = t.surface().hsla();
186        let border = t.border().hsla();
187        let text_color = t.text().hsla();
188        let dimmed = t.dimmed().hsla();
189        let font_sm = t.font_size(Size::Sm);
190
191        let has_value = self.value.is_some();
192        let shown: SharedString = match self.value {
193            Some(time) if self.twelve_hour => time.format_12().into(),
194            Some(time) => time.format_24().into(),
195            None => self.placeholder.clone(),
196        };
197
198        let trigger = div()
199            .id("guise-timepicker-trigger")
200            .track_focus(&self.focus)
201            .flex()
202            .items_center()
203            .justify_between()
204            .gap(px(8.0))
205            .h(px(height))
206            .px(px(pad_x))
207            .rounded(px(radius))
208            .border_1()
209            .border_color(border)
210            .bg(surface)
211            .text_size(px(font))
212            .text_color(if has_value { text_color } else { dimmed })
213            .child(shown)
214            .child(
215                div()
216                    .text_color(dimmed)
217                    .child(Icon::new(IconName::Clock).size(Size::Sm)),
218            )
219            .on_click(cx.listener(|this, _ev, _window, cx| {
220                if !this.disabled {
221                    this.open = !this.open;
222                    cx.notify();
223                }
224            }));
225
226        let mut wrap = div().relative().child(trigger);
227
228        if self.open && !self.disabled {
229            let base = self.base();
230            let selected = self.value;
231
232            let hours: Vec<(SharedString, Time, bool, bool)> = if self.twelve_hour {
233                let (sel_hour, sel_pm) = base.hour_12();
234                (0..12)
235                    .map(|i| {
236                        let display = if i == 0 { 12 } else { i };
237                        let next = base.with_hour_12(display, sel_pm);
238                        (
239                            SharedString::from(display.to_string()),
240                            next,
241                            selected.is_some() && display == sel_hour,
242                            false,
243                        )
244                    })
245                    .collect()
246            } else {
247                (0..24)
248                    .map(|hour| {
249                        (
250                            SharedString::from(format!("{hour:02}")),
251                            base.with_hour(hour),
252                            selected.is_some() && hour == base.hour(),
253                            false,
254                        )
255                    })
256                    .collect()
257            };
258
259            let minutes: Vec<(SharedString, Time, bool, bool)> = (0..60)
260                .step_by(self.minute_step as usize)
261                .map(|minute| {
262                    (
263                        SharedString::from(format!("{minute:02}")),
264                        base.with_minute(minute),
265                        selected.is_some() && minute == base.minute(),
266                        true,
267                    )
268                })
269                .collect();
270
271            let mut panel = div()
272                .absolute()
273                .top(px(height + 6.0))
274                .left(px(0.0))
275                .flex()
276                .gap(px(6.0))
277                .p(px(8.0))
278                .rounded(px(radius))
279                .border_1()
280                .border_color(border)
281                .bg(surface)
282                .shadow_md()
283                .occlude()
284                .child(self.column("guise-timepicker-hours", hours, cx))
285                .child(self.column("guise-timepicker-minutes", minutes, cx));
286
287            if self.twelve_hour {
288                let (_, pm) = base.hour_12();
289                let meridiem: Vec<(SharedString, Time, bool, bool)> = [(false, "AM"), (true, "PM")]
290                    .into_iter()
291                    .map(|(is_pm, label)| {
292                        let (hour, _) = base.hour_12();
293                        (
294                            SharedString::new_static(label),
295                            base.with_hour_12(hour, is_pm),
296                            selected.is_some() && pm == is_pm,
297                            false,
298                        )
299                    })
300                    .collect();
301                panel = panel.child(self.column("guise-timepicker-meridiem", meridiem, cx));
302            }
303
304            wrap = wrap.child(deferred(panel));
305        }
306
307        let mut column = div().flex().flex_col().gap(px(4.0));
308        if let Some(label) = self.label.clone() {
309            column = column.child(
310                div()
311                    .text_size(px(font_sm))
312                    .text_color(text_color)
313                    .child(label),
314            );
315        }
316        column = column.child(wrap);
317
318        let element = if self.disabled {
319            column.opacity(0.6)
320        } else {
321            column
322        };
323
324        element.probe("TimePicker")
325    }
326}