1use 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#[derive(Debug, Clone)]
22pub struct TimePickerEvent(pub Time);
23
24pub 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 pub fn twenty_four_hour(mut self) -> Self {
61 self.twelve_hour = false;
62 self
63 }
64
65 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 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 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}