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::style::TextOverflowExt;
19use crate::theme::{theme, Size};
20
21#[derive(Debug, Clone)]
23pub struct TimePickerEvent(pub Time);
24
25pub 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 pub fn twenty_four_hour(mut self) -> Self {
62 self.twelve_hour = false;
63 self
64 }
65
66 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 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 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}