1use gpui::{
9 App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
10 KeyDownEvent, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
11 div, prelude::FluentBuilder, px,
12};
13use gpui_kit_semantics::{NodeSpec, Role, Semantic};
14use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, TypeScale};
15
16use crate::controls::field::{FieldState, field_shell};
17use crate::datetime::adapter::{Clock, SharedDateAdapter, TimeOfDay};
18use crate::foundation::direction::ActiveDirection;
19use crate::foundation::stepping::bounded_step;
20use crate::foundation::{
21 Disableable, FocusRing, Ident, Sizable, StyledExt, text as foundation_text,
22};
23use crate::strings::{ActiveStrings, StringKey};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Segment {
28 Hour,
29 Minute,
30 Second,
31 Meridiem,
32}
33
34impl Segment {
35 fn key(self) -> &'static str {
36 match self {
37 Self::Hour => "hour",
38 Self::Minute => "minute",
39 Self::Second => "second",
40 Self::Meridiem => "meridiem",
41 }
42 }
43
44 fn label(self) -> StringKey {
45 match self {
46 Self::Hour => StringKey::TimeHour,
47 Self::Minute => StringKey::TimeMinute,
48 Self::Second => StringKey::TimeSecond,
49 Self::Meridiem => StringKey::TimeMeridiem,
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TimeInputEvent {
57 Changed(TimeOfDay),
58}
59
60impl EventEmitter<TimeInputEvent> for TimeInput {}
61
62pub struct TimeInput {
64 ident: Ident,
65 focus_handle: FocusHandle,
66 adapter: SharedDateAdapter,
67 value: TimeOfDay,
68 seconds: bool,
70 active: Segment,
71 typed: String,
75 size: ControlSize,
76 disabled: bool,
77}
78
79impl std::fmt::Debug for TimeInput {
80 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 formatter
82 .debug_struct("TimeInput")
83 .field("ident", &self.ident)
84 .field("value", &self.value)
85 .field("seconds", &self.seconds)
86 .field("active", &self.active)
87 .field("disabled", &self.disabled)
88 .finish()
89 }
90}
91
92impl TimeInput {
93 pub fn new(
94 ident: impl Into<Ident>,
95 adapter: SharedDateAdapter,
96 _window: &mut Window,
97 cx: &mut Context<Self>,
98 ) -> Self {
99 let clock = adapter.clock();
100 let value = TimeOfDay {
101 hour: clock.hour_min,
102 minute: 0,
103 second: None,
104 meridiem: clock.meridiem.as_ref().map(|_| 0),
105 };
106 Self {
107 ident: ident.into(),
108 focus_handle: cx.focus_handle(),
109 adapter,
110 value,
111 seconds: false,
112 active: Segment::Hour,
113 typed: String::new(),
114 size: ControlSize::Md,
115 disabled: false,
116 }
117 }
118
119 pub fn value(mut self, value: TimeOfDay) -> Self {
121 self.value = value;
122 self.seconds = self.seconds || value.second.is_some();
123 self
124 }
125
126 pub fn seconds(mut self, seconds: bool) -> Self {
128 self.seconds = seconds;
129 if seconds && self.value.second.is_none() {
130 self.value.second = Some(0);
131 }
132 self
133 }
134
135 pub fn set_value(&mut self, value: TimeOfDay, cx: &mut Context<Self>) {
136 self.value = value;
137 self.typed.clear();
138 cx.notify();
139 }
140
141 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
142 self.disabled = disabled;
143 cx.notify();
144 }
145
146 pub fn current(&self) -> TimeOfDay {
147 self.value
148 }
149
150 pub fn active_segment(&self) -> Segment {
151 self.active
152 }
153
154 pub fn clock(&self) -> Clock {
155 self.adapter.clock()
156 }
157
158 fn segments(&self) -> Vec<Segment> {
159 let mut segments = vec![Segment::Hour, Segment::Minute];
160 if self.seconds {
161 segments.push(Segment::Second);
162 }
163 if self.adapter.clock().is_twelve_hour() {
164 segments.push(Segment::Meridiem);
165 }
166 segments
167 }
168
169 fn bounds(&self, segment: Segment) -> (u32, u32) {
171 let clock = self.adapter.clock();
172 match segment {
173 Segment::Hour => (clock.hour_min, clock.hour_max),
174 Segment::Minute => (0, clock.minute_max),
175 Segment::Second => (0, clock.second_max),
176 Segment::Meridiem => (0, 1),
177 }
178 }
179
180 fn raw(&self, segment: Segment) -> u32 {
181 match segment {
182 Segment::Hour => self.value.hour,
183 Segment::Minute => self.value.minute,
184 Segment::Second => self.value.second.unwrap_or(0),
185 Segment::Meridiem => self.value.meridiem.unwrap_or(0) as u32,
186 }
187 }
188
189 fn write(&mut self, segment: Segment, raw: u32, cx: &mut Context<Self>) {
190 match segment {
191 Segment::Hour => self.value.hour = raw,
192 Segment::Minute => self.value.minute = raw,
193 Segment::Second => self.value.second = Some(raw),
194 Segment::Meridiem => self.value.meridiem = Some(raw as usize),
195 }
196 cx.emit(TimeInputEvent::Changed(self.value));
197 cx.notify();
198 }
199
200 fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
203 if self.disabled {
204 return;
205 }
206 let segment = self.active;
207 let (min, max) = self.bounds(segment);
208 let Some(next) = step_within(min, max, self.raw(segment), delta) else {
209 return;
210 };
211 self.typed.clear();
212 self.write(segment, next, cx);
213 }
214
215 fn focus_segment(&mut self, segment: Segment, cx: &mut Context<Self>) {
216 if self.active == segment {
217 return;
218 }
219 self.active = segment;
220 self.typed.clear();
221 cx.notify();
222 }
223
224 fn move_segment(&mut self, delta: isize, cx: &mut Context<Self>) {
225 let segments = self.segments();
226 let from = segments.iter().position(|segment| *segment == self.active);
227 let Some(next) = bounded_step(segments.len(), from, delta, |_| false) else {
228 return;
229 };
230 self.focus_segment(segments[next], cx);
231 }
232
233 fn type_digit(&mut self, digit: char, cx: &mut Context<Self>) {
239 if self.disabled || self.active == Segment::Meridiem {
240 return;
241 }
242 let segment = self.active;
243 let (min, max) = self.bounds(segment);
244 let mut candidate = format!("{}{digit}", self.typed);
245 let mut value: u32 = candidate.parse().unwrap_or(0);
246 if value > max || value < min && candidate.len() >= 2 {
247 candidate = digit.to_string();
248 value = candidate.parse().unwrap_or(min);
249 }
250 if value > max {
251 return;
252 }
253 self.typed = candidate;
254 self.write(segment, value, cx);
255 if self.typed.len() >= 2 {
256 self.typed.clear();
257 self.move_segment(1, cx);
258 }
259 }
260
261 fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
262 if self.disabled {
263 return;
264 }
265 let key = event.keystroke.key.as_str();
266 if let Some(step) = cx.layout_direction().arrow_step(key) {
270 self.move_segment(step as isize, cx);
271 cx.stop_propagation();
272 return;
273 }
274 match key {
275 "up" => self.step(1, cx),
276 "down" => self.step(-1, cx),
277 _ => {
278 let mut characters = key.chars();
279 match (characters.next(), characters.next()) {
280 (Some(digit), None) if digit.is_ascii_digit() => self.type_digit(digit, cx),
281 _ => return,
282 }
283 }
284 }
285 cx.stop_propagation();
286 }
287
288 fn segment_text(&self, segment: Segment) -> SharedString {
289 match segment {
290 Segment::Meridiem => self
291 .adapter
292 .clock()
293 .meridiem_label(self.value.meridiem.unwrap_or(0))
294 .unwrap_or_default(),
295 other => SharedString::from(format!("{:02}", self.raw(other))),
296 }
297 }
298}
299
300impl Disableable for TimeInput {
301 fn disabled(mut self, disabled: bool) -> Self {
302 self.disabled = disabled;
303 self
304 }
305}
306
307impl Sizable for TimeInput {
308 fn control_size(mut self, size: ControlSize) -> Self {
309 self.size = size;
310 self
311 }
312}
313
314impl Focusable for TimeInput {
315 fn focus_handle(&self, _cx: &App) -> FocusHandle {
316 self.focus_handle.clone()
317 }
318}
319
320impl Render for TimeInput {
321 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
322 let theme = cx.theme().clone();
323 let focused = self.focus_handle.is_focused(window);
324 let segments = self.segments();
325 let last = segments.len().saturating_sub(1);
326
327 let cells: Vec<gpui::AnyElement> = segments
328 .iter()
329 .enumerate()
330 .map(|(index, segment)| {
331 let segment = *segment;
332 let ident = self.ident.child(segment.key());
333 let text = self.segment_text(segment);
334 let active = self.active == segment && focused;
335 let (min, max) = self.bounds(segment);
336
337 let cell = div()
338 .id(ident.element_id())
339 .px(px(theme.space(Space::Xs)))
340 .rounded(px(theme.radii.small))
341 .when(active, |element| element.bg(theme.colors.selected))
342 .when(!self.disabled, |element| {
343 element.cursor_pointer().on_click(cx.listener(
344 move |input, _, window, cx| {
345 input.focus_handle.focus(window, cx);
346 input.focus_segment(segment, cx);
347 },
348 ))
349 })
350 .child(
351 foundation_text(&theme, TypeScale::Body, text.clone()).text_tone(
352 &theme,
353 if self.disabled {
354 TextTone::Faint
355 } else {
356 TextTone::Primary
357 },
358 ),
359 )
360 .semantic_in(
361 cx,
362 NodeSpec::new(ident.semantic_id(), Role::Input)
363 .parent(self.ident.semantic_id())
364 .text(cx.strings().text(segment.label()))
365 .value(text)
366 .disabled(self.disabled)
367 .range(min as f32, max as f32, self.raw(segment) as f32)
368 .selected(active),
369 )
370 .into_any_element();
371
372 if index == last || segment == Segment::Meridiem {
373 cell
374 } else {
375 div()
376 .row()
377 .child(cell)
378 .child(
379 foundation_text(
380 &theme,
381 TypeScale::Body,
382 if segments[index + 1] == Segment::Meridiem {
383 SharedString::new_static(" ")
384 } else {
385 SharedString::new_static(":")
386 },
387 )
388 .text_tone(&theme, TextTone::Faint),
389 )
390 .into_any_element()
391 }
392 })
393 .collect();
394
395 div()
396 .id(self.ident.element_id())
397 .track_focus(&self.focus_handle)
398 .when(!self.disabled, |element| {
399 element.tab_index(0).focus_ring(&theme)
400 })
401 .on_key_down(cx.listener(Self::on_key_down))
402 .child(
403 field_shell(
404 &theme,
405 self.size,
406 FieldState::default()
407 .focused(focused)
408 .disabled(self.disabled),
409 )
410 .children(cells),
411 )
412 .semantic_in(
413 cx,
414 NodeSpec::new(self.ident.semantic_id(), Role::Group)
415 .focus(&self.focus_handle)
416 .disabled(self.disabled)
417 .value(self.adapter.format_time(self.value)),
418 )
419 }
420}
421
422fn step_within(min: u32, max: u32, value: u32, delta: isize) -> Option<u32> {
427 if max < min {
428 return None;
429 }
430 let count = (max - min + 1) as usize;
431 let from = value.checked_sub(min).map(|offset| offset as usize);
432 bounded_step(count, from, delta, |_| false).map(|index| min + index as u32)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn a_segment_steps_inside_the_bounds_the_clock_gave_it() {
441 assert_eq!(step_within(0, 59, 30, 1), Some(31));
442 assert_eq!(step_within(0, 59, 30, -1), Some(29));
443 }
444
445 #[test]
446 fn a_segment_has_ends_rather_than_rolling_over() {
447 assert_eq!(step_within(0, 59, 59, 1), None);
448 assert_eq!(step_within(0, 59, 0, -1), None);
449 }
450
451 #[test]
452 fn a_twelve_hour_clock_starts_where_its_host_says_it_does() {
453 assert_eq!(step_within(1, 12, 12, 1), None);
454 assert_eq!(step_within(1, 12, 1, -1), None);
455 assert_eq!(step_within(1, 12, 1, 1), Some(2));
456 }
457}