Skip to main content

gpui_kit/datetime/
time_input.rs

1//! Hour, minute, and optionally second, as separate segments.
2//!
3//! How far the hours run and what the two halves of a twelve-hour day are
4//! called both come from [`Clock`], which the adapter supplies. The crate
5//! contains no meridiem label in any language and never assumes a
6//! twenty-four-hour day.
7
8use 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/// Which part of the time the keyboard is on.
26#[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/// What a time field reports. The owner decides what any of it means.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TimeInputEvent {
57    Changed(TimeOfDay),
58}
59
60impl EventEmitter<TimeInputEvent> for TimeInput {}
61
62/// A segmented time field over the host's own clock.
63pub struct TimeInput {
64    ident: Ident,
65    focus_handle: FocusHandle,
66    adapter: SharedDateAdapter,
67    value: TimeOfDay,
68    /// Whether a seconds segment is drawn at all.
69    seconds: bool,
70    active: Segment,
71    /// The digits typed into the active segment since it was entered. Typing
72    /// overwrites rather than inserts, so this restarts whenever the segment
73    /// changes.
74    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    /// Seeds the time the caller holds.
120    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    /// Whether the seconds segment is drawn.
127    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    /// The inclusive bounds of a segment, from the host's clock.
170    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    /// Steps the active segment, stopping at its bounds rather than rolling
201    /// over into a neighbour the typist did not point at.
202    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    /// Overwrites the active segment with what is being typed.
234    ///
235    /// Two digits complete a segment and move on. A second digit that would
236    /// leave the segment's bounds starts the segment over on that digit, so
237    /// nothing lands on a value the clock does not have.
238    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        // The segments are written in reading order, so moving between them
267        // follows the reading direction. Up and down change the value, which
268        // has nothing to do with which way the field reads.
269        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
422/// The value `delta` steps away, inside the clock's own bounds.
423///
424/// A segment has ends: a minute past the last one is not the first one, it is
425/// nowhere, so the step is refused rather than rolled over.
426fn 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}