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