Skip to main content

herogpui_components/
time_field.rs

1//! TimeField — port of `@heroui/time-field` (v3).
2//!
3//! Segment-by-segment time entry. Clicking a segment focuses it; the stepper
4//! buttons adjust whichever segment has focus.
5
6use std::{
7    cell::RefCell,
8    collections::HashMap,
9    rc::Rc,
10    sync::{Arc, OnceLock},
11};
12
13use gpui::{
14    div, prelude::*, px, App, ElementId, Entity, InteractiveElement, IntoElement, Pixels,
15    RenderOnce, SharedString, Styled, Window,
16};
17use herogpui_core::{element_id, FieldVariant};
18use herogpui_theme::ActiveTheme;
19
20use crate::{
21    a11y::{self, A11y as _},
22    icons, util,
23};
24
25/// Whether a [`TimeField`] shows a 12- or 24-hour clock (`hourCycle`).
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum HourCycle {
28    /// The 12-hour clock with an AM/PM segment.
29    H12,
30    /// The 24-hour clock.
31    H24,
32}
33
34impl Default for HourCycle {
35    fn default() -> Self {
36        system_time_format().hour_cycle
37    }
38}
39
40impl HourCycle {
41    /// Both hour cycles, in display order.
42    pub const ALL: [HourCycle; 2] = [HourCycle::H12, HourCycle::H24];
43
44    /// The human-readable name of this hour cycle.
45    pub fn label(self) -> &'static str {
46        match self {
47            HourCycle::H12 => "12-hour",
48            HourCycle::H24 => "24-hour",
49        }
50    }
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54struct RegionalTimeFormat {
55    hour_cycle: HourCycle,
56    minute_pattern: RegionalTimePattern,
57    second_pattern: RegionalTimePattern,
58    am: String,
59    pm: String,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub(crate) struct RegionalTimePattern {
64    pub(crate) order: Vec<TimeSegment>,
65    pub(crate) literals: Vec<String>,
66    pub(crate) hour_has_leading_zero: bool,
67    pub(crate) hour_zero_based: bool,
68    pub(crate) minute_has_leading_zero: bool,
69    pub(crate) second_has_leading_zero: bool,
70    pub(crate) am: String,
71    pub(crate) pm: String,
72}
73
74impl RegionalTimeFormat {
75    fn for_locale(locale: &str) -> Option<Self> {
76        Self::for_locale_with_cycle(locale, None)
77    }
78
79    fn for_locale_with_cycle(locale: &str, hour_cycle: Option<HourCycle>) -> Option<Self> {
80        use icu_datetime::{
81            fieldsets,
82            input::Time as IcuTime,
83            provider::{
84                fields::{FieldLength, FieldSymbol, Hour as IcuHour},
85                pattern::{reference, runtime, PatternItem},
86            },
87            DateTimeFormatterPreferences, NoCalendarFormatter,
88        };
89        use icu_locale_core::{
90            preferences::extensions::unicode::keywords::HourCycle as IcuHourCycle,
91            Locale as IcuLocale,
92        };
93
94        let locale = locale.parse::<IcuLocale>().ok()?;
95        let mut preferences = DateTimeFormatterPreferences::from(locale);
96        if let Some(hour_cycle) = hour_cycle {
97            preferences.hour_cycle = Some(match hour_cycle {
98                HourCycle::H12 => IcuHourCycle::Clock12,
99                HourCycle::H24 => IcuHourCycle::Clock24,
100            });
101        }
102        let parse_pattern = |precision| {
103            let formatter = NoCalendarFormatter::try_new(preferences, precision).ok()?;
104            let formatted = formatter.format(&IcuTime::start_of_day());
105            let pattern: runtime::Pattern<'_> = formatted.pattern().into();
106            let pattern = reference::Pattern::from(&pattern);
107            let mut order = Vec::with_capacity(4);
108            let mut literals = Vec::with_capacity(5);
109            let mut literal = String::new();
110            let mut hour_has_leading_zero = None;
111            let mut hour_zero_based = None;
112            let mut hour_cycle = None;
113            let mut minute_has_leading_zero = None;
114            let mut second_has_leading_zero = None;
115            for item in pattern.into_items() {
116                let field = match item {
117                    PatternItem::Literal(ch) => {
118                        literal.push(ch);
119                        continue;
120                    }
121                    PatternItem::Field(field) => field,
122                };
123                let segment = match field.symbol {
124                    FieldSymbol::Hour(hour) => {
125                        hour_has_leading_zero = Some(field.length == FieldLength::Two);
126                        hour_zero_based = Some(hour == IcuHour::H11);
127                        hour_cycle = Some(match hour {
128                            IcuHour::H11 | IcuHour::H12 => HourCycle::H12,
129                            IcuHour::H23 => HourCycle::H24,
130                        });
131                        TimeSegment::Hour
132                    }
133                    FieldSymbol::Minute => {
134                        minute_has_leading_zero = Some(field.length == FieldLength::Two);
135                        TimeSegment::Minute
136                    }
137                    FieldSymbol::Second(_) => {
138                        second_has_leading_zero = Some(field.length == FieldLength::Two);
139                        TimeSegment::Second
140                    }
141                    FieldSymbol::DayPeriod(_) => TimeSegment::Meridiem,
142                    _ => return None,
143                };
144                if order.contains(&segment) {
145                    return None;
146                }
147                literals.push(std::mem::take(&mut literal));
148                order.push(segment);
149            }
150            literals.push(literal);
151            Some((
152                RegionalTimePattern {
153                    order,
154                    literals,
155                    hour_has_leading_zero: hour_has_leading_zero?,
156                    hour_zero_based: hour_zero_based?,
157                    minute_has_leading_zero: minute_has_leading_zero?,
158                    second_has_leading_zero: second_has_leading_zero.unwrap_or(false),
159                    am: String::new(),
160                    pm: String::new(),
161                },
162                hour_cycle?,
163            ))
164        };
165
166        let (minute_pattern, hour_cycle) = parse_pattern(fieldsets::T::hm())?;
167        let (second_pattern, second_hour_cycle) = parse_pattern(fieldsets::T::hms())?;
168        if hour_cycle != second_hour_cycle {
169            return None;
170        }
171        let (am, pm) = if hour_cycle == HourCycle::H12 {
172            let formatter = NoCalendarFormatter::try_new(preferences, fieldsets::T::hm()).ok()?;
173            (
174                minute_pattern
175                    .day_period_from_formatted(
176                        &formatter
177                            .format(&IcuTime::try_new(1, 5, 0, 0).ok()?)
178                            .to_string(),
179                    )
180                    .unwrap_or_else(|| "AM".to_owned()),
181                minute_pattern
182                    .day_period_from_formatted(
183                        &formatter
184                            .format(&IcuTime::try_new(13, 5, 0, 0).ok()?)
185                            .to_string(),
186                    )
187                    .unwrap_or_else(|| "PM".to_owned()),
188            )
189        } else {
190            ("AM".to_owned(), "PM".to_owned())
191        };
192        Some(Self {
193            hour_cycle,
194            minute_pattern,
195            second_pattern,
196            am,
197            pm,
198        })
199    }
200
201    fn for_preferences(tags: &[String]) -> Option<Self> {
202        tags.iter().find_map(|tag| Self::for_locale(tag))
203    }
204}
205
206fn system_time_format() -> &'static RegionalTimeFormat {
207    static SYSTEM_TIME_FORMAT: OnceLock<RegionalTimeFormat> = OnceLock::new();
208    SYSTEM_TIME_FORMAT.get_or_init(|| {
209        RegionalTimeFormat::for_preferences(&crate::date_constraints::system_locale_tags())
210            .unwrap_or(RegionalTimeFormat {
211                hour_cycle: HourCycle::H24,
212                minute_pattern: RegionalTimePattern::fallback(TimeGranularity::Minute, false),
213                second_pattern: RegionalTimePattern::fallback(TimeGranularity::Second, false),
214                am: "AM".to_owned(),
215                pm: "PM".to_owned(),
216            })
217    })
218}
219
220fn system_time_format_for_cycle(hour_cycle: HourCycle) -> &'static RegionalTimeFormat {
221    static SYSTEM_H12_FORMAT: OnceLock<RegionalTimeFormat> = OnceLock::new();
222    static SYSTEM_H24_FORMAT: OnceLock<RegionalTimeFormat> = OnceLock::new();
223    let cache = match hour_cycle {
224        HourCycle::H12 => &SYSTEM_H12_FORMAT,
225        HourCycle::H24 => &SYSTEM_H24_FORMAT,
226    };
227    cache.get_or_init(|| {
228        crate::date_constraints::system_locale_tags()
229            .iter()
230            .find_map(|tag| RegionalTimeFormat::for_locale_with_cycle(tag, Some(hour_cycle)))
231            .unwrap_or(RegionalTimeFormat {
232                hour_cycle,
233                minute_pattern: RegionalTimePattern::fallback(
234                    TimeGranularity::Minute,
235                    hour_cycle == HourCycle::H12,
236                ),
237                second_pattern: RegionalTimePattern::fallback(
238                    TimeGranularity::Second,
239                    hour_cycle == HourCycle::H12,
240                ),
241                am: "AM".to_owned(),
242                pm: "PM".to_owned(),
243            })
244    })
245}
246
247impl RegionalTimeFormat {
248    fn pattern(&self, granularity: TimeGranularity) -> RegionalTimePattern {
249        let mut pattern = match granularity {
250            TimeGranularity::Hour | TimeGranularity::Minute => self.minute_pattern.clone(),
251            TimeGranularity::Second => self.second_pattern.clone(),
252        };
253        if granularity == TimeGranularity::Hour {
254            let mut order = Vec::with_capacity(2);
255            let mut literals = Vec::with_capacity(3);
256            for (index, segment) in pattern.order.iter().copied().enumerate() {
257                if matches!(segment, TimeSegment::Hour | TimeSegment::Meridiem) {
258                    literals.push(pattern.literals[index].clone());
259                    order.push(segment);
260                }
261            }
262            literals.push(String::new());
263            pattern.order = order;
264            pattern.literals = literals;
265        }
266        pattern.am.clone_from(&self.am);
267        pattern.pm.clone_from(&self.pm);
268        pattern
269    }
270}
271
272impl RegionalTimePattern {
273    fn fallback(granularity: TimeGranularity, twelve_hour: bool) -> Self {
274        let order = TimeSegment::order(granularity, twelve_hour);
275        let literals = order
276            .iter()
277            .enumerate()
278            .map(|(index, segment)| {
279                if index == 0 {
280                    String::new()
281                } else if *segment == TimeSegment::Meridiem {
282                    " ".to_owned()
283                } else {
284                    ":".to_owned()
285                }
286            })
287            .chain(std::iter::once(String::new()))
288            .collect();
289        Self {
290            order,
291            literals,
292            hour_has_leading_zero: !twelve_hour,
293            hour_zero_based: false,
294            minute_has_leading_zero: true,
295            second_has_leading_zero: true,
296            am: "AM".to_owned(),
297            pm: "PM".to_owned(),
298        }
299    }
300
301    fn day_period_from_formatted(&self, formatted: &str) -> Option<String> {
302        let index = self
303            .order
304            .iter()
305            .position(|segment| *segment == TimeSegment::Meridiem)?;
306        let name = if index == 0 {
307            let formatted = formatted.strip_prefix(self.literals.first()?.as_str())?;
308            let numeric_start = formatted
309                .char_indices()
310                .find_map(|(index, ch)| ch.is_numeric().then_some(index))?;
311            formatted[..numeric_start].strip_suffix(self.literals.get(1)?.as_str())?
312        } else if index + 1 == self.order.len() {
313            let numeric_end = formatted
314                .char_indices()
315                .filter_map(|(index, ch)| ch.is_numeric().then_some(index + ch.len_utf8()))
316                .next_back()?;
317            formatted[numeric_end..]
318                .strip_prefix(self.literals.get(index)?.as_str())?
319                .strip_suffix(self.literals.get(index + 1)?.as_str())?
320        } else {
321            return None;
322        };
323        (!name.is_empty()).then(|| name.to_owned())
324    }
325
326    pub(crate) fn hint(&self) -> String {
327        let mut hint = String::new();
328        for (index, segment) in self.order.iter().copied().enumerate() {
329            hint.push_str(&self.literals[index]);
330            hint.push_str(match segment {
331                TimeSegment::Hour => "HH",
332                TimeSegment::Minute => "MM",
333                TimeSegment::Second => "SS",
334                TimeSegment::Meridiem => self.am.as_str(),
335            });
336        }
337        hint.push_str(self.literals.last().map_or("", String::as_str));
338        hint
339    }
340}
341
342pub(crate) fn regional_time_pattern(
343    granularity: TimeGranularity,
344    hour_cycle: HourCycle,
345) -> RegionalTimePattern {
346    system_time_format_for_cycle(hour_cycle).pattern(granularity)
347}
348
349/// A wall-clock time — the `TimeValue` of `@internationalized/date`.
350#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
351pub struct Time {
352    /// Hour of day, 0 to 23.
353    pub hour: u32,
354    /// Minute, 0 to 59.
355    pub minute: u32,
356    /// Second, 0 to 59.
357    pub second: u32,
358}
359
360impl Time {
361    /// Creates a time at `hour:minute`, clamping to 23 and 59 and setting the second to 0.
362    pub fn new(hour: u32, minute: u32) -> Self {
363        Self {
364            hour: hour.min(23),
365            minute: minute.min(59),
366            second: 0,
367        }
368    }
369
370    /// Returns the time with its second replaced, clamped to 59.
371    pub fn with_second(mut self, second: u32) -> Self {
372        self.second = second.min(59);
373        self
374    }
375
376    /// Adds `delta` to one segment, wrapping within that segment's range.
377    pub fn bump(self, segment: TimeSegment, delta: i32) -> Self {
378        let wrap =
379            |value: u32, len: i32| -> u32 { (((value as i32 + delta) % len + len) % len) as u32 };
380        match segment {
381            TimeSegment::Hour => Self {
382                hour: wrap(self.hour, 24),
383                ..self
384            },
385            TimeSegment::Minute => Self {
386                minute: wrap(self.minute, 60),
387                ..self
388            },
389            TimeSegment::Second => Self {
390                second: wrap(self.second, 60),
391                ..self
392            },
393            // Flipping the meridiem moves the hour by half a day.
394            TimeSegment::Meridiem => Self {
395                hour: (self.hour + 12) % 24,
396                ..self
397            },
398        }
399    }
400
401    /// The displayed hour and suffix for a 12-hour clock.
402    pub fn twelve_hour(self) -> (u32, &'static str) {
403        let suffix = if self.hour < 12 { "AM" } else { "PM" };
404        let hour = match self.hour % 12 {
405            0 => 12,
406            h => h,
407        };
408        (hour, suffix)
409    }
410}
411
412/// A segment's digits, padded to two unless `shouldForceLeadingZeros` is off.
413fn pad2(value: u32, pad: bool) -> String {
414    if pad {
415        format!("{value:02}")
416    } else {
417        value.to_string()
418    }
419}
420
421/// `granularity` — the smallest unit the field shows. v3 defaults a time to
422/// `minute`.
423#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
424pub enum TimeGranularity {
425    /// Hour only.
426    Hour,
427    /// Hour and minute, which is v3's default for a time.
428    #[default]
429    Minute,
430    /// Hour, minute and second.
431    Second,
432}
433
434/// The editable segments of a [`TimeField`].
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436pub enum TimeSegment {
437    /// The hour segment.
438    Hour,
439    /// The minute segment.
440    Minute,
441    /// The second segment.
442    Second,
443    /// The AM/PM segment.
444    Meridiem,
445}
446
447impl TimeSegment {
448    /// The segments a field of this granularity shows, in reading order.
449    ///
450    /// `pub(crate)` because a `DateField` below `day` granularity shows the same
451    /// segments, edited the same way: one implementation, so the two fields
452    /// cannot disagree about what a minute field looks like.
453    pub(crate) fn order(granularity: TimeGranularity, twelve_hour: bool) -> Vec<TimeSegment> {
454        let mut out = vec![TimeSegment::Hour];
455        if granularity != TimeGranularity::Hour {
456            out.push(TimeSegment::Minute);
457        }
458        if granularity == TimeGranularity::Second {
459            out.push(TimeSegment::Second);
460        }
461        if twelve_hour {
462            out.push(TimeSegment::Meridiem);
463        }
464        out
465    }
466
467    /// How many digits this segment holds — the point at which typing moves on.
468    pub(crate) fn digits(self) -> usize {
469        match self {
470            TimeSegment::Meridiem => 0,
471            _ => 2,
472        }
473    }
474
475    /// The accessible name `react-aria/dist/private/datepicker/useDateSegment.js`
476    /// gives this segment: `displayNames.of(segment.type)`, the localized
477    /// display name of the `Intl.DateTimeFormat` part. `pub(crate)` because a
478    /// `DateField` below `day` granularity draws the very same segments.
479    ///
480    /// `Meridiem` is upstream's `dayPeriod` part, whose display name in
481    /// `en-US` is "AM/PM". The name resolves in the active locale.
482    pub(crate) fn a11y_label(self, cx: &App) -> SharedString {
483        use crate::i18n::{ui_string, UiString};
484        ui_string(
485            match self {
486                TimeSegment::Hour => UiString::Hour,
487                TimeSegment::Minute => UiString::Minute,
488                TimeSegment::Second => UiString::Second,
489                TimeSegment::Meridiem => UiString::DayPeriod,
490            },
491            cx,
492        )
493    }
494
495    /// `time` with this segment set to `value`, clamped to its range.
496    pub(crate) fn with_value(
497        self,
498        time: Time,
499        value: u32,
500        twelve_hour: bool,
501        zero_based_twelve_hour: bool,
502    ) -> Time {
503        match self {
504            TimeSegment::Hour => {
505                let hour = if twelve_hour {
506                    // 12-hour entry keeps the half of the day the field is in.
507                    let pm = time.hour >= 12;
508                    let base = if zero_based_twelve_hour {
509                        value.min(11)
510                    } else {
511                        value.clamp(1, 12) % 12
512                    };
513                    if pm {
514                        base + 12
515                    } else {
516                        base
517                    }
518                } else {
519                    value.min(23)
520                };
521                Time::new(hour, time.minute).with_second(time.second)
522            }
523            TimeSegment::Minute => Time::new(time.hour, value.min(59)).with_second(time.second),
524            TimeSegment::Second => Time::new(time.hour, time.minute).with_second(value.min(59)),
525            TimeSegment::Meridiem => time,
526        }
527    }
528}
529
530/// State entity for [`TimeField`].
531pub struct TimeState {
532    /// The complete value exposed to callbacks, validation and forms.
533    pub value: Option<Time>,
534    /// The segment that currently has focus.
535    pub focused: TimeSegment,
536    /// The local value used to draw segments while an edit is incomplete.
537    display_value: Option<Time>,
538    /// Segments cleared from an otherwise complete display. React Stately
539    /// keeps this incomplete value locally and defers `onChange` until every
540    /// displayed segment is complete again.
541    cleared: Vec<TimeSegment>,
542    segment_order: Vec<TimeSegment>,
543    /// The last controlled prop seen. The outer `Option` distinguishes an
544    /// uncontrolled field from an explicitly controlled `None`.
545    last_controlled: Option<Option<Time>>,
546    /// The field's tab stop, carried on the state the way
547    /// [`crate::input::InputState`] carries its own: a `content` closure and
548    /// the replacement field it draws must share one handle, or the closure
549    /// reads a handle Tab and clicks never reach.
550    pub(crate) focus_handle: gpui::FocusHandle,
551}
552
553impl TimeState {
554    /// Creates an empty state focused on the hour segment.
555    pub fn new(cx: &mut App) -> Self {
556        Self {
557            value: None,
558            focused: TimeSegment::Hour,
559            display_value: None,
560            cleared: Vec::new(),
561            segment_order: Vec::new(),
562            last_controlled: None,
563            // A field is a tab stop: the handle carries that, not the element.
564            focus_handle: cx.focus_handle().tab_stop(true),
565        }
566    }
567
568    /// Creates a state holding `value`, focused on the hour segment.
569    pub fn with_value(cx: &mut App, value: Time) -> Self {
570        Self {
571            value: Some(value),
572            focused: TimeSegment::Hour,
573            display_value: Some(value),
574            cleared: Vec::new(),
575            segment_order: Vec::new(),
576            last_controlled: None,
577            focus_handle: cx.focus_handle().tab_stop(true),
578        }
579    }
580
581    /// Adjusts the focused segment, seeding an empty field from `seed`.
582    pub fn bump_focused_from(&mut self, delta: i32, seed: Time) {
583        let base = self.display_value.unwrap_or(seed);
584        let value = base.bump(self.focused, delta);
585        self.value = Some(value);
586        self.display_value = Some(value);
587        self.cleared.retain(|segment| *segment != self.focused);
588    }
589
590    /// [`bump_focused_from`](Self::bump_focused_from) seeded at midnight.
591    pub fn bump_focused(&mut self, delta: i32) {
592        self.bump_focused_from(delta, Time::new(0, 0));
593    }
594
595    fn visible_is_complete(&self, segments: &[TimeSegment]) -> bool {
596        !segments
597            .iter()
598            .any(|segment| self.cleared.contains(segment))
599    }
600
601    fn set_uncontrolled_value(&mut self, value: Option<Time>) {
602        self.value = value;
603        self.display_value = value;
604        self.cleared.clear();
605    }
606
607    fn sync_controlled(&mut self, value: Option<Time>) {
608        if self.last_controlled != Some(value) {
609            self.last_controlled = Some(value);
610            self.display_value = value;
611            self.cleared.clear();
612        }
613        // The controlled prop is always the committed value, but an unchanged
614        // prop must not erase the local incomplete display on every render.
615        self.value = value;
616    }
617
618    fn sync_external_value(&mut self) {
619        if self.last_controlled.is_none()
620            && self.cleared.is_empty()
621            && self.display_value != self.value
622        {
623            self.display_value = self.value;
624        }
625    }
626
627    fn sync_segment_order(&mut self, order: &[TimeSegment]) {
628        if self.segment_order != order {
629            if self.segment_order.is_empty() || !order.contains(&self.focused) {
630                self.focused = order[0];
631            }
632            self.segment_order.clear();
633            self.segment_order.extend_from_slice(order);
634        }
635    }
636
637    /// Writes one display segment and returns whether the currently visible
638    /// value became complete. Controlled fields report the candidate and then
639    /// keep drawing their prop until the caller supplies the new value.
640    fn edit_focused(&mut self, value: Time, segments: &[TimeSegment]) -> bool {
641        if self.display_value.is_none() {
642            self.cleared = segments.to_vec();
643        }
644        self.display_value = Some(value);
645        self.cleared.retain(|segment| *segment != self.focused);
646        if !self.visible_is_complete(segments) {
647            return false;
648        }
649
650        match self.last_controlled {
651            Some(controlled) => {
652                self.value = controlled;
653                self.display_value = controlled;
654                self.cleared.clear();
655            }
656            None => self.set_uncontrolled_value(Some(value)),
657        }
658        true
659    }
660
661    /// Clears the active display segment. Once every currently displayed
662    /// segment is empty, the field's value becomes null and reports that
663    /// transition; otherwise React Stately keeps the incomplete display local.
664    fn clear_focused(&mut self, segments: &[TimeSegment]) -> bool {
665        if self.display_value.is_none() {
666            return false;
667        }
668        if !self.cleared.contains(&self.focused) {
669            self.cleared.push(self.focused);
670        }
671        if segments
672            .iter()
673            .all(|segment| self.cleared.contains(segment))
674        {
675            match self.last_controlled {
676                Some(Some(value)) => {
677                    self.value = Some(value);
678                    self.display_value = Some(value);
679                    self.cleared.clear();
680                }
681                Some(None) | None => self.set_uncontrolled_value(None),
682            }
683            return true;
684        }
685        false
686    }
687}
688
689type Segment = Arc<dyn Fn(TimeSegment, SharedString) -> gpui::AnyElement + 'static>;
690
691type OnTimeChange = Arc<dyn Fn(&Option<Time>, &mut Window, &mut App) + 'static>;
692
693type TimeFieldFormState = Rc<RefCell<crate::form::LiveFormFieldState>>;
694
695thread_local! {
696    static TIME_FIELD_FORM_STATES: RefCell<HashMap<u64, std::rc::Weak<RefCell<crate::form::LiveFormFieldState>>>> =
697        RefCell::new(HashMap::new());
698}
699
700fn registered_time_field_form_state(entity_id: u64) -> Option<TimeFieldFormState> {
701    TIME_FIELD_FORM_STATES.with(|states| {
702        states
703            .borrow()
704            .get(&entity_id)
705            .and_then(|state| state.upgrade())
706    })
707}
708
709fn time_field_form_state(entity_id: u64) -> TimeFieldFormState {
710    TIME_FIELD_FORM_STATES.with(|states| {
711        let mut states = states.borrow_mut();
712        if let Some(state) = states.get(&entity_id).and_then(|state| state.upgrade()) {
713            return state;
714        }
715        let state = Rc::new(RefCell::new(crate::form::LiveFormFieldState {
716            value: crate::form::FormValue::Text(SharedString::default()),
717            is_invalid: false,
718            is_successful: true,
719            focus: None,
720            restore: None,
721        }));
722        states.insert(entity_id, Rc::downgrade(&state));
723        state
724    })
725}
726
727/// The time an HTML `<input type="time">` submits at the field's granularity.
728fn time_form_text(value: Option<Time>, granularity: TimeGranularity) -> SharedString {
729    value
730        .map(|time| match granularity {
731            TimeGranularity::Hour | TimeGranularity::Minute => {
732                format!("{:02}:{:02}", time.hour, time.minute)
733            }
734            TimeGranularity::Second => {
735                format!("{:02}:{:02}:{:02}", time.hour, time.minute, time.second)
736            }
737        })
738        .unwrap_or_default()
739        .into()
740}
741
742fn sync_time_field_form(
743    form_state: &TimeFieldFormState,
744    time_state: &Entity<TimeState>,
745    is_disabled: bool,
746    is_invalid: bool,
747    granularity: TimeGranularity,
748    cx: &App,
749) {
750    let mut state = form_state.borrow_mut();
751    state.value =
752        crate::form::FormValue::Text(time_form_text(time_state.read(cx).value, granularity));
753    state.is_successful = !is_disabled;
754    state.is_invalid = is_invalid;
755    state.focus = Some(time_state.read(cx).focus_handle.clone());
756}
757
758fn install_time_field_restore(
759    form_state: &TimeFieldFormState,
760    time_state: Entity<TimeState>,
761    default: Option<Time>,
762    granularity: TimeGranularity,
763    controlled: bool,
764    on_change: Option<OnTimeChange>,
765) {
766    if default.is_none() && !controlled {
767        return;
768    }
769    let restore_form = Rc::downgrade(form_state);
770    form_state.borrow_mut().restore = Some(util::shared(move |window: &mut Window, cx: &mut App| {
771        time_state.update(&mut *cx, |state, cx| {
772            if controlled {
773                state.sync_controlled(default);
774            } else {
775                state.set_uncontrolled_value(default);
776            }
777            cx.notify();
778        });
779        if let Some(state) = restore_form.upgrade() {
780            let mut state = state.borrow_mut();
781            state.value = crate::form::FormValue::Text(time_form_text(default, granularity));
782            state.is_invalid = false;
783        }
784        if controlled {
785            if let Some(callback) = &on_change {
786                callback(&default, window, cx);
787            }
788        }
789    }) as Arc<dyn Fn(&mut Window, &mut App)>);
790}
791
792/// State supplied to v3's TimeField children render function.
793#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
794#[non_exhaustive]
795pub struct TimeFieldRenderState {
796    /// Whether the field is disabled.
797    pub is_disabled: bool,
798    /// Whether controlled, server, custom or bound validation is invalid.
799    pub is_invalid: bool,
800    /// Whether segments can be focused but not edited.
801    pub is_read_only: bool,
802    /// Whether the field is required.
803    pub is_required: bool,
804    /// Whether the field's input owns focus.
805    pub is_focused: bool,
806    /// Whether focus is inside the field.
807    pub is_focus_within: bool,
808    /// Whether keyboard-visible focus chrome should be shown.
809    pub is_focus_visible: bool,
810}
811
812/// HeroUI TimeField.
813#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
814#[derive(IntoElement)]
815pub struct TimeField {
816    /// `segment` — v3's render prop for one editable segment, handed which
817    /// segment it is and the text the field would have shown.
818    segment: Option<Segment>,
819    /// `name` — read back by [`TimeField::form_field`].
820    name: Option<SharedString>,
821    /// `validationBehavior` — carried on this field's form field.
822    validation_behavior: crate::form::ValidationBehavior,
823    /// `defaultValue` — seeds the state on the first render only.
824    default_value: Option<Time>,
825    /// `value` — `Some(None)` is an explicitly controlled empty field.
826    controlled_value: Option<Option<Time>>,
827    state: Entity<TimeState>,
828    label: Option<SharedString>,
829    description: Option<SharedString>,
830    error_message: Option<SharedString>,
831    /// `validate` — run by the component, not the caller.
832    validate: Option<crate::validation::Validator<Option<Time>>>,
833    /// `validationErrors` — messages from a server round-trip.
834    validation_errors: Vec<SharedString>,
835    /// `TimeField.Prefix` — content before the segments, drawn in the
836    /// placeholder colour and inert.
837    prefix: Option<gpui::AnyElement>,
838    /// See [`TimeField::content`].
839    content: Option<Arc<dyn Fn(TimeFieldRenderState) -> gpui::AnyElement + 'static>>,
840    /// `TimeField.Suffix` — content after the segments
841    /// (`.date-input-group__suffix`: `shrink-0 me-3` in the placeholder colour).
842    suffix: Option<gpui::AnyElement>,
843    variant: FieldVariant,
844    /// Optional box geometry/chrome overrides; defaults are the stock box.
845    field: util::FieldBox,
846    /// The fill a hovered stepper arrow takes, in place of `--default`.
847    stepper_hover_bg: Option<gpui::Hsla>,
848    /// The family the segments are drawn with; unset keeps the mono token.
849    font_family: Option<SharedString>,
850    /// The corner radius, in place of the owning `field_radius` helper.
851    radius: Option<Pixels>,
852    hour_cycle: HourCycle,
853    /// `granularity` — the smallest unit shown.
854    granularity: TimeGranularity,
855    /// `autoFocus` — take focus on the first render.
856    auto_focus: bool,
857    /// `shouldForceLeadingZeros` — force the hour to two digits instead of
858    /// using the system regional format.
859    should_force_leading_zeros: bool,
860    full_width: bool,
861    is_disabled: bool,
862    is_read_only: bool,
863    is_required: bool,
864    is_invalid: bool,
865    min_value: Option<Time>,
866    max_value: Option<Time>,
867    /// `placeholderValue` — seeds the steppers when the field is empty.
868    placeholder_value: Option<Time>,
869    on_change: Option<OnTimeChange>,
870    /// The `sx` slot, refined over the root style at the end of render.
871    sx: Option<Box<gpui::StyleRefinement>>,
872}
873
874impl TimeField {
875    /// `value` — v3's controlled plain-time spelling, as a pure builder.
876    ///
877    /// The stored prop is what `render` syncs into the bound [`TimeState`] —
878    /// every frame, but only when it actually changes, so an unchanged prop
879    /// never erases a segment the user is still editing. `None` is v3's
880    /// `null`: an explicitly controlled empty field that outranks
881    /// [`TimeField::default_value`]. Calling `.value(..)` twice keeps the
882    /// last call, like every other builder here.
883    pub fn value(mut self, time: Option<Time>) -> Self {
884        self.controlled_value = Some(time);
885        self
886    }
887
888    /// Creates a time field bound to `state`.
889    pub fn new(state: Entity<TimeState>) -> Self {
890        Self {
891            segment: None,
892            name: None,
893            validation_behavior: crate::form::ValidationBehavior::Native,
894            default_value: None,
895            controlled_value: None,
896            state,
897            label: None,
898            description: None,
899            error_message: None,
900            content: None,
901            prefix: None,
902            suffix: None,
903            variant: FieldVariant::Primary,
904            hour_cycle: HourCycle::default(),
905            granularity: TimeGranularity::default(),
906            auto_focus: false,
907            should_force_leading_zeros: false,
908            full_width: false,
909            is_disabled: false,
910            is_read_only: false,
911            is_required: false,
912            validate: None,
913            validation_errors: Vec::new(),
914            is_invalid: false,
915            min_value: None,
916            max_value: None,
917            placeholder_value: None,
918            on_change: None,
919            sx: None,
920            field: util::FieldBox::default(),
921            stepper_hover_bg: None,
922            font_family: None,
923            radius: None,
924        }
925    }
926
927    /// `segment` — replaces the contents of each editable segment.
928    ///
929    /// The closure receives which [`TimeSegment`] it is drawing and the text
930    /// the field would have shown, the values v3 passes into the same render
931    /// prop.
932    pub fn segment(
933        mut self,
934        render: impl Fn(TimeSegment, SharedString) -> gpui::AnyElement + 'static,
935    ) -> Self {
936        self.segment = Some(Arc::new(render));
937        self
938    }
939
940    /// `name` — the name this field submits under.
941    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
942        self.name = Some(name.into());
943        self
944    }
945
946    /// `validationBehavior` — `Allow` shows the message without blocking form
947    /// submission.
948    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
949        self.validation_behavior = behavior;
950        self
951    }
952
953    /// The `Form` field this control submits, when it has a `name`.
954    ///
955    /// The time is written `HH:MM`, or `HH:MM:SS` at second granularity, which
956    /// matches an HTML `<input type="time">`. Needs `cx` because the value
957    /// lives in the state entity. The
958    /// returned field stays live: submit reads the entity, a disabled field is
959    /// unsuccessful, and reset restores `defaultValue` (or reports it to a
960    /// controlled owner).
961    pub fn form_field(&self, cx: &App) -> Option<crate::form::FormField> {
962        let name = self.name.clone()?;
963        let form_state = time_field_form_state(self.state.entity_id().as_u64());
964        sync_time_field_form(
965            &form_state,
966            &self.state,
967            self.is_disabled,
968            self.is_invalid,
969            self.granularity,
970            cx,
971        );
972        install_time_field_restore(
973            &form_state,
974            self.state.clone(),
975            self.default_value,
976            self.granularity,
977            self.controlled_value.is_some(),
978            self.on_change.clone(),
979        );
980        Some(
981            crate::form::FormField::live(name, form_state)
982                .is_required(self.is_required)
983                .validation_behavior(self.validation_behavior),
984        )
985    }
986
987    /// `defaultValue` — the uncontrolled initial time.
988    ///
989    /// Written into the state on the first render only, so it seeds the
990    /// component without fighting the user afterwards.
991    pub fn default_value(mut self, value: Time) -> Self {
992        self.default_value = Some(value);
993        self
994    }
995
996    /// Sets the label shown above the field.
997    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
998        self.label = Some(text.into());
999        self
1000    }
1001
1002    /// Sets the description shown below the field.
1003    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
1004        self.description = Some(text.into());
1005        self
1006    }
1007
1008    /// Sets the error message shown when the field is invalid.
1009    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
1010        self.error_message = Some(text.into());
1011        self
1012    }
1013
1014    /// `TimeField.Prefix` — content before the segments.
1015    pub fn prefix(mut self, el: impl IntoElement) -> Self {
1016        self.prefix = Some(el.into_any_element());
1017        self
1018    }
1019
1020    /// `TimeField.Suffix` — content after the segments.
1021    /// v3's field `children`-as-a-function, handed the complete
1022    /// [`TimeFieldRenderState`].
1023    pub fn content(
1024        mut self,
1025        render: impl Fn(TimeFieldRenderState) -> gpui::AnyElement + 'static,
1026    ) -> Self {
1027        self.content = Some(Arc::new(render));
1028        self
1029    }
1030
1031    /// Sets content shown after the segments (`TimeField.Suffix`).
1032    pub fn suffix(mut self, el: impl IntoElement) -> Self {
1033        self.suffix = Some(el.into_any_element());
1034        self
1035    }
1036
1037    /// Sets the field variant.
1038    pub fn variant(mut self, variant: FieldVariant) -> Self {
1039        self.variant = variant;
1040        self
1041    }
1042
1043    /// Replaces the 36px box height. Only the single-line box changes: the
1044    /// segments keep their 14px type and 20px line and stay centred.
1045    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
1046        self.field.height = Some(h.into());
1047        self
1048    }
1049
1050    /// Replaces the box's `px-3` horizontal padding.
1051    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
1052        self.field.padding_x = Some(p.into());
1053        self
1054    }
1055
1056    /// Renders the box with no background, border, field shadow or focus ring,
1057    /// for a caller painting around it. The field stays editable and focusable.
1058    pub fn is_bare(mut self, v: bool) -> Self {
1059        self.field.is_bare = v;
1060        self.field.is_bare_is_set = true;
1061        self
1062    }
1063
1064    /// Shows or hides only the field's visual focus ring. The segmented time
1065    /// control remains focusable and editable when set to `false`.
1066    pub fn focus_ring(mut self, v: bool) -> Self {
1067        self.field.focus_ring = Some(v);
1068        self
1069    }
1070
1071    /// The fill a hovered stepper arrow takes, in place of `--default`.
1072    pub fn stepper_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
1073        self.stepper_hover_bg = Some(color.into());
1074        self
1075    }
1076
1077    /// The family the segments are drawn with; unset keeps the mono token.
1078    pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
1079        self.font_family = Some(family.into());
1080        self
1081    }
1082
1083    /// The corner radius, in place of the owning `field_radius` helper. Not a
1084    /// v3 prop; the removed v2 `radius` prop is prohibited and this is a
1085    /// per-component repository extension.
1086    ///
1087    /// The box and shared field chrome use the same resolved radius. A bare
1088    /// field retains it without painting chrome.
1089    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
1090        self.radius = Some(radius.into());
1091        self
1092    }
1093
1094    /// Sets the hour cycle (`hourCycle`).
1095    pub fn hour_cycle(mut self, cycle: HourCycle) -> Self {
1096        self.hour_cycle = cycle;
1097        self
1098    }
1099
1100    /// `granularity` — the smallest unit the field shows.
1101    pub fn granularity(mut self, granularity: TimeGranularity) -> Self {
1102        self.granularity = granularity;
1103        self
1104    }
1105
1106    /// `autoFocus` — take focus on the first render.
1107    pub fn auto_focus(mut self, v: bool) -> Self {
1108        self.auto_focus = v;
1109        self
1110    }
1111
1112    /// `shouldForceLeadingZeros` — force the hour to two digits. Without this
1113    /// flag, each numeric segment follows the system regional time pattern.
1114    pub fn should_force_leading_zeros(mut self, v: bool) -> Self {
1115        self.should_force_leading_zeros = v;
1116        self
1117    }
1118
1119    /// `granularity="second"`, kept as its own flag because it reads better at
1120    /// the call site.
1121    pub fn show_seconds(mut self, v: bool) -> Self {
1122        self.granularity = if v {
1123            TimeGranularity::Second
1124        } else {
1125            TimeGranularity::Minute
1126        };
1127        self
1128    }
1129
1130    /// Sets whether the field fills the available width.
1131    pub fn full_width(mut self, v: bool) -> Self {
1132        self.full_width = v;
1133        self
1134    }
1135
1136    /// The one slot for caller-owned low-level styling: GPUI's styling methods
1137    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
1138    /// applied to the field's root element after every value the variant and
1139    /// the active theme chose, so they win. The root is the `.date-field`
1140    /// column, so an override reaches the box the segments' chrome sits in,
1141    /// not that chrome.
1142    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
1143        util::refine_sx(&mut self.sx, style);
1144        self
1145    }
1146
1147    /// Sets whether the field is disabled (`isDisabled`).
1148    pub fn is_disabled(mut self, v: bool) -> Self {
1149        self.is_disabled = v;
1150        self
1151    }
1152
1153    /// Sets whether the field is read-only (`isReadOnly`).
1154    pub fn is_read_only(mut self, v: bool) -> Self {
1155        self.is_read_only = v;
1156        self
1157    }
1158
1159    /// Sets whether the field is required (`isRequired`).
1160    pub fn is_required(mut self, v: bool) -> Self {
1161        self.is_required = v;
1162        self
1163    }
1164
1165    /// `validate` — returns the message to show, or `None` when the time is
1166    /// fine. Receives `None` when the field is empty, as v3's `TimeValue | null`.
1167    ///
1168    /// The component runs it and surfaces the result.
1169    pub fn validate(mut self, f: impl Fn(&Option<Time>) -> Option<SharedString> + 'static) -> Self {
1170        self.validate = Some(Arc::new(f));
1171        self
1172    }
1173
1174    /// `validationErrors` — messages produced elsewhere, shown ahead of
1175    /// whatever `validate` returns.
1176    pub fn validation_errors(
1177        mut self,
1178        errors: impl IntoIterator<Item = impl Into<SharedString>>,
1179    ) -> Self {
1180        self.validation_errors = errors.into_iter().map(Into::into).collect();
1181        self
1182    }
1183
1184    /// Sets whether the field is invalid (`isInvalid`).
1185    pub fn is_invalid(mut self, v: bool) -> Self {
1186        self.is_invalid = v;
1187        self
1188    }
1189
1190    /// `minValue` — the earliest selectable time.
1191    pub fn min_value(mut self, time: Time) -> Self {
1192        self.min_value = Some(time);
1193        self
1194    }
1195
1196    /// `maxValue` — the latest selectable time.
1197    pub fn max_value(mut self, time: Time) -> Self {
1198        self.max_value = Some(time);
1199        self
1200    }
1201
1202    /// `placeholderValue` — the time the steppers start from when empty.
1203    pub fn placeholder_value(mut self, time: Time) -> Self {
1204        self.placeholder_value = Some(time);
1205        self
1206    }
1207
1208    /// Sets the handler called when the time changes (`onChange`).
1209    pub fn on_change(
1210        mut self,
1211        handler: impl Fn(&Option<Time>, &mut Window, &mut App) + 'static,
1212    ) -> Self {
1213        self.on_change = Some(Arc::new(handler));
1214        self
1215    }
1216}
1217
1218impl RenderOnce for TimeField {
1219    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1220        // Every keyed slot and every segment hangs off the state entity: a
1221        // `TimeField` takes no id of its own, and the entity is the identity it
1222        // does have.
1223        let entity_id = self.state.entity_id().as_u64();
1224        let base_id = ElementId::named_usize("timefield", entity_id as usize);
1225
1226        // `defaultValue` seeds the state once, before anything reads it.
1227        if self.controlled_value.is_none() {
1228            if let Some(value) = self.default_value {
1229                let state = self.state.clone();
1230                util::seed_once(
1231                    window,
1232                    cx,
1233                    element_id::scoped(&base_id, "default"),
1234                    move |cx| {
1235                        state.update(cx, |s, cx| {
1236                            s.set_uncontrolled_value(Some(value));
1237                            cx.notify();
1238                        });
1239                    },
1240                );
1241            }
1242        }
1243        if let Some(value) = self.controlled_value {
1244            self.state.update(cx, |state, _| {
1245                state.sync_controlled(value);
1246            });
1247        } else {
1248            self.state.update(cx, |state, _| {
1249                state.sync_external_value();
1250            });
1251        }
1252
1253        if let Some(form_state) = registered_time_field_form_state(entity_id) {
1254            sync_time_field_form(
1255                &form_state,
1256                &self.state,
1257                self.is_disabled,
1258                self.is_invalid,
1259                self.granularity,
1260                cx,
1261            );
1262            install_time_field_restore(
1263                &form_state,
1264                self.state.clone(),
1265                self.default_value,
1266                self.granularity,
1267                self.controlled_value.is_some(),
1268                self.on_change.clone(),
1269            );
1270        }
1271        // A time field has no inner `Input` to hold the focus, so the handle
1272        // lives on the state itself -- the same answer `InputState` and
1273        // `NumberState` give. That is what lets a `content` closure and the
1274        // replacement field it draws share one handle: the field the closure
1275        // draws tracks the state's handle, so Tab and clicks move the focus
1276        // the closure is asked to report.
1277        let focus_handle = self.state.read(cx).focus_handle.clone();
1278        if self.auto_focus {
1279            util::focus_once(
1280                window,
1281                cx,
1282                element_id::scoped(&base_id, "autofocus"),
1283                &focus_handle,
1284            );
1285        }
1286        // Digits typed into the focused segment but not yet complete, so `1` in
1287        // the hour segment can still become `12`.
1288        let typing = window.use_keyed_state(element_id::scoped(&base_id, "typing"), cx, |_, _| {
1289            String::new()
1290        });
1291
1292        let regional_time = regional_time_pattern(self.granularity, self.hour_cycle);
1293        self.state.update(cx, |state, _| {
1294            state.sync_segment_order(&regional_time.order);
1295        });
1296
1297        let colors = cx.colors().clone();
1298        let layout = cx.layout().clone();
1299        let navigable = !self.is_disabled;
1300        let editable = navigable && !self.is_read_only;
1301
1302        let (value, display_value, focused, cleared) = {
1303            let st = self.state.read(cx);
1304            (st.value, st.display_value, st.focused, st.cleared.clone())
1305        };
1306
1307        // v3 order: the controlled flag, then server errors, then `validate`,
1308        // with `errorMessage` as the fallback.
1309        let validity = crate::validation::resolve(
1310            self.is_invalid,
1311            &self.validation_errors,
1312            self.validate.as_ref().and_then(|f| f(&value)),
1313            self.error_message.clone(),
1314        );
1315        let is_invalid = validity.is_invalid;
1316        if let Some(form_state) = registered_time_field_form_state(entity_id) {
1317            sync_time_field_form(
1318                &form_state,
1319                &self.state,
1320                self.is_disabled,
1321                is_invalid,
1322                self.granularity,
1323                cx,
1324            );
1325        }
1326        if let Some(render) = self.content.clone() {
1327            let focused = focus_handle.is_focused(window);
1328            return render(TimeFieldRenderState {
1329                is_disabled: self.is_disabled,
1330                is_invalid,
1331                is_read_only: self.is_read_only,
1332                is_required: self.is_required,
1333                is_focused: focused,
1334                is_focus_within: focus_handle.contains_focused(window, cx),
1335                is_focus_visible: focused && util::focus_visible(cx),
1336            })
1337            .into_any_element();
1338        }
1339
1340        let segments = regional_time.order.clone();
1341
1342        let hour_cycle = self.hour_cycle;
1343        let pad_hour = self.should_force_leading_zeros || regional_time.hour_has_leading_zero;
1344        let pad_minute = regional_time.minute_has_leading_zero;
1345        let pad_second = regional_time.second_has_leading_zero;
1346        let zero_based_twelve_hour = regional_time.hour_zero_based;
1347        let am = regional_time.am.clone();
1348        let pm = regional_time.pm.clone();
1349        let segment_text = move |segment: TimeSegment| -> String {
1350            if cleared.contains(&segment) {
1351                return if segment == TimeSegment::Meridiem {
1352                    am.clone()
1353                } else {
1354                    "--".to_owned()
1355                };
1356            }
1357            let Some(t) = display_value else {
1358                return "--".to_owned();
1359            };
1360            match segment {
1361                TimeSegment::Hour => {
1362                    if hour_cycle == HourCycle::H12 {
1363                        let hour = if zero_based_twelve_hour {
1364                            t.hour % 12
1365                        } else {
1366                            t.twelve_hour().0
1367                        };
1368                        pad2(hour, pad_hour)
1369                    } else {
1370                        pad2(t.hour, pad_hour)
1371                    }
1372                }
1373                TimeSegment::Minute => pad2(t.minute, pad_minute),
1374                TimeSegment::Second => pad2(t.second, pad_second),
1375                TimeSegment::Meridiem => {
1376                    if t.hour < 12 {
1377                        am.clone()
1378                    } else {
1379                        pm.clone()
1380                    }
1381                }
1382            }
1383        };
1384
1385        // `.date-input-group__input-container` is `flex flex-1 items-center` with
1386        // its own horizontal scroll, so a long value stays reachable without
1387        // widening the field.
1388        // `useTimeField` is `useDateField`, i.e. `role: 'group'` on the box.
1389        let field_box = self.field;
1390        // The box and shared field chrome use the same resolved radius.
1391        let radius = self.radius.unwrap_or_else(|| util::field_radius(cx));
1392        let mut group = div()
1393            .id(base_id.clone())
1394            .a11y_named(
1395                a11y::Role::Group,
1396                &a11y::Name::field(self.label.as_ref(), self.description.as_ref(), &validity),
1397            )
1398            .flex()
1399            .flex_row()
1400            .items_center()
1401            .gap(px(2.))
1402            .px(field_box.resolved_padding_x())
1403            .h(field_box.resolved_height())
1404            .rounded(radius)
1405            .text_size(util::FIELD_TEXT)
1406            .line_height(px(20.))
1407            .font_family(util::MONO_FONT)
1408            .when_some(self.font_family.clone(), |group, family| {
1409                group.font_family(family)
1410            })
1411            .text_color(colors.field.foreground);
1412
1413        if !field_box.is_bare {
1414            // The segment group clips nothing of its own, so both rings the
1415            // field chrome can draw move from the blurred spread shadow into
1416            // an overlay child at the group's resolved radius.
1417            group = util::apply_field_chrome_overlay(
1418                group,
1419                self.variant,
1420                is_invalid,
1421                focus_handle.is_focused(window),
1422                field_box.focus_ring.unwrap_or(true),
1423                Some(radius),
1424                cx,
1425            );
1426
1427            let focused = focus_handle.is_focused(window);
1428            if !self.is_disabled && !is_invalid && !focused {
1429                let idle_bg = match self.variant {
1430                    FieldVariant::Primary => colors.field.background,
1431                    FieldVariant::Secondary => colors.default.color,
1432                };
1433                let hover_bg = match self.variant {
1434                    FieldVariant::Primary => colors.field.hover(),
1435                    FieldVariant::Secondary => colors.default.hover(),
1436                };
1437                let hover_border = colors.field.border_hover();
1438                group = crate::anim::hover_fade_with_duration_and_easing(
1439                    group,
1440                    element_id::scoped(&base_id, "hover-fade"),
1441                    (idle_bg, hover_bg),
1442                    None,
1443                    Some(hover_border),
1444                    move |fill| fill.rounded(radius),
1445                    Some(150),
1446                    crate::anim::HoverFadeEasing::EaseSmooth,
1447                    window,
1448                    cx,
1449                );
1450            }
1451        }
1452
1453        // v3 drives a time field from the keyboard: the arrows step the focused
1454        // segment and walk between segments, and digits type into it.
1455        if navigable {
1456            let state = self.state.clone();
1457            let on_change = self.on_change.clone();
1458            let buffer = typing;
1459            let fh = focus_handle.clone();
1460            let order = segments.clone();
1461            let twelve_hour = self.hour_cycle == HourCycle::H12;
1462            let seed = self.placeholder_value.unwrap_or(Time::new(0, 0));
1463            let min_value = self.min_value;
1464            let max_value = self.max_value;
1465            let is_read_only = self.is_read_only;
1466            group = group
1467                .track_focus(&focus_handle)
1468                .key_context("TimeField")
1469                .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1470                    window.focus(&fh, cx);
1471                })
1472                .on_key_down(move |event, window, cx| {
1473                    let key = event.keystroke.key.as_str();
1474                    let here = order.iter().position(|s| *s == focused).unwrap_or(0);
1475                    let commit = |time: Time, window: &mut Window, cx: &mut App| {
1476                        let complete = state.update(cx, |s, cx| {
1477                            let complete = s.edit_focused(time, &order);
1478                            cx.notify();
1479                            complete
1480                        });
1481                        if complete {
1482                            if let Some(cb) = &on_change {
1483                                cb(&Some(time), window, cx);
1484                            }
1485                        }
1486                    };
1487                    match key {
1488                        "left" | "right" => {
1489                            let delta: i32 = if key == "right" { 1 } else { -1 };
1490                            let next =
1491                                (here as i32 + delta).clamp(0, order.len() as i32 - 1) as usize;
1492                            buffer.update(cx, |b, _| b.clear());
1493                            let segment = order[next];
1494                            state.update(cx, |s, cx| {
1495                                s.focused = segment;
1496                                cx.notify();
1497                            });
1498                        }
1499                        _ if is_read_only => {}
1500                        "up" | "down" => {
1501                            let delta = if key == "up" { 1 } else { -1 };
1502                            buffer.update(cx, |b, _| b.clear());
1503                            let base = state.read(cx).display_value.unwrap_or(seed);
1504                            let time = clamp_time(base.bump(focused, delta), min_value, max_value);
1505                            commit(time, window, cx);
1506                        }
1507                        "backspace" | "delete" => {
1508                            buffer.update(cx, |b, _| b.clear());
1509                            let emptied = state.update(cx, |s, cx| {
1510                                let emptied = s.clear_focused(&order);
1511                                cx.notify();
1512                                emptied
1513                            });
1514                            if emptied {
1515                                if let Some(cb) = &on_change {
1516                                    cb(&None, window, cx);
1517                                }
1518                            }
1519                        }
1520                        // The meridiem segment answers `a` and `p`, the way
1521                        // React Aria's does.
1522                        "a" | "p" if focused == TimeSegment::Meridiem => {
1523                            let base = state.read(cx).display_value.unwrap_or(seed);
1524                            let hour = base.hour % 12 + if key == "p" { 12 } else { 0 };
1525                            commit(
1526                                Time::new(hour, base.minute).with_second(base.second),
1527                                window,
1528                                cx,
1529                            );
1530                        }
1531                        digit if digit.len() == 1 && digit.chars().all(|c| c.is_ascii_digit()) => {
1532                            let width = focused.digits();
1533                            if width == 0 {
1534                                return;
1535                            }
1536                            let text = buffer.update(cx, |b, _| {
1537                                if b.len() >= width {
1538                                    b.clear();
1539                                }
1540                                b.push_str(digit);
1541                                b.clone()
1542                            });
1543                            let Ok(value) = text.parse::<u32>() else {
1544                                return;
1545                            };
1546                            let base = state.read(cx).display_value.unwrap_or(seed);
1547                            commit(
1548                                focused.with_value(
1549                                    base,
1550                                    value,
1551                                    twelve_hour,
1552                                    zero_based_twelve_hour,
1553                                ),
1554                                window,
1555                                cx,
1556                            );
1557                            if text.len() >= width {
1558                                buffer.update(cx, |b, _| b.clear());
1559                                if let Some(segment) = order.get(here + 1).copied() {
1560                                    state.update(cx, |s, cx| {
1561                                        s.focused = segment;
1562                                        cx.notify();
1563                                    });
1564                                }
1565                            }
1566                        }
1567                        _ => {}
1568                    }
1569                });
1570        }
1571        if self.is_disabled {
1572            group = group.opacity(layout.disabled_opacity);
1573        }
1574        if self.full_width {
1575            group = group.w_full();
1576        }
1577
1578        // `.date-input-group__prefix` is `ms-3 me-0`; the group's own padding
1579        // already provides the outer inset.
1580        if let Some(prefix) = self.prefix.take() {
1581            group = group.child(
1582                div()
1583                    .flex()
1584                    .items_center()
1585                    .flex_shrink_0()
1586                    .mr(px(4.))
1587                    .text_color(colors.field.placeholder)
1588                    .child(prefix),
1589            );
1590        }
1591
1592        for (index, segment) in segments.iter().copied().enumerate() {
1593            if let Some(literal) = regional_time
1594                .literals
1595                .get(index)
1596                .filter(|literal| !literal.is_empty())
1597            {
1598                group = group.child(div().text_color(colors.muted).child(literal.clone()));
1599            }
1600
1601            let mut seg = div()
1602                .id(element_id::indexed(&base_id, "seg", index))
1603                // `.date-input-group__segment` is `rounded-md px-0.5`.
1604                .px(px(2.))
1605                .py(px(1.))
1606                .rounded(cx.layout().radius_md())
1607                // `segment` is v3's render prop on `TimeField.Segment`: the
1608                // closure is handed which segment it is drawing.
1609                .child(match &self.segment {
1610                    Some(render) => render(segment, segment_text(segment).into()),
1611                    None => segment_text(segment).into_any_element(),
1612                });
1613
1614            if focused == segment && navigable {
1615                seg = seg
1616                    .bg(colors.accent.soft())
1617                    .text_color(colors.accent.soft_foreground(colors.foreground));
1618            }
1619
1620            if navigable {
1621                let state = self.state.clone();
1622                seg = seg
1623                    .cursor(util::interactive_cursor(cx))
1624                    .on_click(move |_, _, cx| {
1625                        state.update(cx, |s, cx| {
1626                            s.focused = segment;
1627                            cx.notify();
1628                        });
1629                    });
1630            }
1631
1632            let seg_text = segment_text(segment);
1633            seg = seg
1634                .a11y_named(
1635                    a11y::Role::TextInput,
1636                    &a11y::Name::labelled(segment.a11y_label(cx)),
1637                )
1638                .a11y_text(&seg_text, None);
1639
1640            group = group.child(seg);
1641        }
1642        if let Some(literal) = regional_time
1643            .literals
1644            .last()
1645            .filter(|literal| !literal.is_empty())
1646        {
1647            group = group.child(div().text_color(colors.muted).child(literal.clone()));
1648        }
1649
1650        // Steppers adjust whichever segment is focused.
1651        if editable {
1652            let seed = self.placeholder_value.unwrap_or(Time::new(0, 0));
1653            let min_value = self.min_value;
1654            let max_value = self.max_value;
1655            let visible_segments = segments;
1656            let mut steppers = div().flex().flex_col().ml(px(8.)).flex_shrink_0();
1657            for (icon, delta, key) in [
1658                (icons::CHEVRON_UP, 1i32, "up"),
1659                (icons::CHEVRON_DOWN, -1i32, "down"),
1660            ] {
1661                let state = self.state.clone();
1662                let on_change = self.on_change.clone();
1663                let visible_segments = visible_segments.clone();
1664                let hover_bg = self.stepper_hover_bg.unwrap_or(colors.default.color);
1665                let stepper_name = crate::i18n::ui_string_with(
1666                    if key == "up" {
1667                        crate::i18n::UiString::Increase
1668                    } else {
1669                        crate::i18n::UiString::Decrease
1670                    },
1671                    "",
1672                    cx,
1673                );
1674                steppers = steppers.child(
1675                    div()
1676                        .id(element_id::scoped(&base_id, key))
1677                        .flex()
1678                        .items_center()
1679                        .justify_center()
1680                        .w(px(18.))
1681                        .h(px(14.))
1682                        .rounded(px(4.))
1683                        .cursor(util::interactive_cursor(cx))
1684                        .text_color(colors.muted)
1685                        .hover(move |s| s.bg(hover_bg))
1686                        .child(
1687                            gpui::svg()
1688                                .size(px(10.))
1689                                .path(icon)
1690                                .text_color(colors.muted),
1691                        )
1692                        .a11y_named(a11y::Role::Button, &a11y::Name::labelled(stepper_name))
1693                        .on_click(move |_, window, cx| {
1694                            let (next, complete) = state.update(cx, |s, cx| {
1695                                let base = s.display_value.unwrap_or(seed);
1696                                let mut next = base.bump(s.focused, delta);
1697                                // `minValue`/`maxValue` clamp the result.
1698                                next = clamp_time(next, min_value, max_value);
1699                                let complete = s.edit_focused(next, &visible_segments);
1700                                cx.notify();
1701                                (next, complete)
1702                            });
1703                            if complete {
1704                                if let Some(cb) = &on_change {
1705                                    cb(&Some(next), window, cx);
1706                                }
1707                            }
1708                        }),
1709                );
1710            }
1711            group = group.child(steppers);
1712        }
1713
1714        if let Some(suffix) = self.suffix.take() {
1715            group = group.child(
1716                div()
1717                    .flex()
1718                    .items_center()
1719                    .flex_shrink_0()
1720                    .ml(px(4.))
1721                    .text_color(colors.field.placeholder)
1722                    .child(suffix),
1723            );
1724        }
1725        if navigable {
1726            group = util::record_focus_bounds(group, &focus_handle, window, cx);
1727        }
1728
1729        // `.date-field` is `flex flex-col gap-1`.
1730        let mut root = div()
1731            .flex()
1732            .flex_col()
1733            .gap(px(4.))
1734            .when(self.full_width, |root| root.w_full());
1735        if let Some(label) = self.label {
1736            root = root.child(
1737                crate::field::Label::new(label)
1738                    .is_required(self.is_required)
1739                    .is_disabled(self.is_disabled)
1740                    .is_invalid(is_invalid),
1741            );
1742        }
1743        root = root.child(group);
1744
1745        if is_invalid {
1746            if let Some(message) = validity.first() {
1747                root = root.child(crate::field::ErrorMessage::new(message));
1748            }
1749        } else if let Some(description) = self.description {
1750            root = root.child(crate::field::Description::new(description));
1751        }
1752
1753        root = util::apply_sx(root, &self.sx);
1754        root.into_any_element()
1755    }
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760    use super::*;
1761
1762    #[test]
1763    fn enabled_time_field_hover_uses_the_pinned_smooth_fill_transition() {
1764        let source = include_str!("time_field.rs")
1765            .split("#[cfg(test)]")
1766            .next()
1767            .expect("the implementation section is always present");
1768        assert!(
1769            source.contains("hover_fade_with_duration_and_easing(")
1770                && source.contains("Some(150)")
1771                && source.contains("HoverFadeEasing::EaseSmooth")
1772        );
1773        assert!(source.contains("!self.is_disabled && !is_invalid && !focused"));
1774    }
1775
1776    #[test]
1777    fn hour_wraps_at_midnight() {
1778        assert_eq!(Time::new(23, 0).bump(TimeSegment::Hour, 1).hour, 0);
1779        assert_eq!(Time::new(0, 0).bump(TimeSegment::Hour, -1).hour, 23);
1780    }
1781
1782    #[test]
1783    fn minute_wraps_without_touching_the_hour() {
1784        let t = Time::new(10, 59).bump(TimeSegment::Minute, 1);
1785        assert_eq!((t.hour, t.minute), (10, 0));
1786    }
1787
1788    #[test]
1789    fn meridiem_flips_half_a_day() {
1790        assert_eq!(Time::new(9, 30).bump(TimeSegment::Meridiem, 1).hour, 21);
1791        assert_eq!(Time::new(21, 30).bump(TimeSegment::Meridiem, 1).hour, 9);
1792    }
1793
1794    #[test]
1795    fn twelve_hour_display() {
1796        assert_eq!(Time::new(0, 0).twelve_hour(), (12, "AM"));
1797        assert_eq!(Time::new(12, 0).twelve_hour(), (12, "PM"));
1798        assert_eq!(Time::new(13, 0).twelve_hour(), (1, "PM"));
1799        assert_eq!(Time::new(11, 0).twelve_hour(), (11, "AM"));
1800    }
1801
1802    #[test]
1803    fn zero_based_twelve_hour_entry_preserves_the_half_day() {
1804        assert_eq!(
1805            TimeSegment::Hour
1806                .with_value(Time::new(1, 0), 0, true, true)
1807                .hour,
1808            0
1809        );
1810        assert_eq!(
1811            TimeSegment::Hour
1812                .with_value(Time::new(13, 0), 0, true, true)
1813                .hour,
1814            12
1815        );
1816        assert_eq!(
1817            TimeSegment::Hour
1818                .with_value(Time::new(13, 0), 12, true, false)
1819                .hour,
1820            12
1821        );
1822    }
1823
1824    #[test]
1825    fn hour_cycle_follows_locale_time_data_and_overrides() {
1826        assert_eq!(
1827            RegionalTimeFormat::for_locale("en-US").map(|format| format.hour_cycle),
1828            Some(HourCycle::H12)
1829        );
1830        assert_eq!(
1831            RegionalTimeFormat::for_locale("de-DE").map(|format| format.hour_cycle),
1832            Some(HourCycle::H24)
1833        );
1834        assert_eq!(
1835            RegionalTimeFormat::for_locale("en-US-u-hc-h23").map(|format| format.hour_cycle),
1836            Some(HourCycle::H24)
1837        );
1838        assert_eq!(
1839            RegionalTimeFormat::for_locale("de-DE-u-hc-h12").map(|format| format.hour_cycle),
1840            Some(HourCycle::H12)
1841        );
1842        assert_eq!(RegionalTimeFormat::for_locale("not_a_locale"), None);
1843    }
1844
1845    #[test]
1846    fn hour_cycle_prefers_the_system_time_category() {
1847        // The time category comes first in the system chain (`system_locale`).
1848        let tags = ["en-US".to_owned(), "de-DE".to_owned()];
1849        assert_eq!(
1850            RegionalTimeFormat::for_preferences(&tags).map(|format| format.hour_cycle),
1851            Some(HourCycle::H12)
1852        );
1853    }
1854
1855    #[test]
1856    fn hour_padding_follows_locale_time_patterns() {
1857        assert_eq!(
1858            RegionalTimeFormat::for_locale("en-US")
1859                .map(|format| format.minute_pattern.hour_has_leading_zero),
1860            Some(false)
1861        );
1862        assert_eq!(
1863            RegionalTimeFormat::for_locale("de-DE")
1864                .map(|format| format.minute_pattern.hour_has_leading_zero),
1865            Some(true)
1866        );
1867        assert_eq!(
1868            RegionalTimeFormat::for_locale("en-US-u-hc-h23")
1869                .map(|format| format.minute_pattern.hour_has_leading_zero),
1870            Some(true)
1871        );
1872        let explicit_twelve =
1873            RegionalTimeFormat::for_locale_with_cycle("en-US-u-hc-h23", Some(HourCycle::H12))
1874                .unwrap();
1875        assert_eq!(explicit_twelve.hour_cycle, HourCycle::H12);
1876        assert!(!explicit_twelve.minute_pattern.hour_has_leading_zero);
1877    }
1878
1879    #[test]
1880    fn segment_order_literals_padding_and_day_periods_follow_locale_patterns() {
1881        let us = RegionalTimeFormat::for_locale_with_cycle("en-US", Some(HourCycle::H12))
1882            .unwrap()
1883            .pattern(TimeGranularity::Second);
1884        assert_eq!(
1885            us.order,
1886            [
1887                TimeSegment::Hour,
1888                TimeSegment::Minute,
1889                TimeSegment::Second,
1890                TimeSegment::Meridiem,
1891            ]
1892        );
1893        assert_eq!(us.literals, ["", ":", ":", "\u{202f}", ""]);
1894        assert!(!us.hour_has_leading_zero);
1895        assert!(!us.hour_zero_based);
1896        assert!(us.minute_has_leading_zero);
1897        assert!(us.second_has_leading_zero);
1898        assert_eq!((us.am.as_str(), us.pm.as_str()), ("AM", "PM"));
1899        assert_eq!(us.hint(), "HH:MM:SS\u{202f}AM");
1900
1901        let japanese = RegionalTimeFormat::for_locale_with_cycle("ja-JP", Some(HourCycle::H12))
1902            .unwrap()
1903            .pattern(TimeGranularity::Minute);
1904        assert_eq!(
1905            japanese.order,
1906            [
1907                TimeSegment::Meridiem,
1908                TimeSegment::Hour,
1909                TimeSegment::Minute,
1910            ]
1911        );
1912        assert_eq!(japanese.literals, ["", "", ":", ""]);
1913        assert!(japanese.hour_zero_based);
1914        assert_eq!(
1915            (japanese.am.as_str(), japanese.pm.as_str()),
1916            ("午前", "午後")
1917        );
1918        assert_eq!(japanese.hint(), "午前HH:MM");
1919
1920        let arabic = RegionalTimeFormat::for_locale_with_cycle("ar-EG", Some(HourCycle::H12))
1921            .unwrap()
1922            .pattern(TimeGranularity::Minute);
1923        assert_eq!((arabic.am.as_str(), arabic.pm.as_str()), ("ص", "م"));
1924
1925        let korean = RegionalTimeFormat::for_locale_with_cycle("ko-KR", Some(HourCycle::H24))
1926            .unwrap()
1927            .pattern(TimeGranularity::Second);
1928        assert_eq!(
1929            korean.order,
1930            [TimeSegment::Hour, TimeSegment::Minute, TimeSegment::Second,]
1931        );
1932        assert_eq!(korean.literals, ["", ":", ":", ""]);
1933        assert!(korean.hour_has_leading_zero);
1934        assert!(korean.minute_has_leading_zero);
1935        assert!(korean.second_has_leading_zero);
1936        assert_eq!(korean.hint(), "HH:MM:SS");
1937    }
1938
1939    #[test]
1940    fn hour_granularity_keeps_day_period_order_without_minute_punctuation() {
1941        let us = RegionalTimeFormat::for_locale_with_cycle("en-US", Some(HourCycle::H12))
1942            .unwrap()
1943            .pattern(TimeGranularity::Hour);
1944        assert_eq!(us.order, [TimeSegment::Hour, TimeSegment::Meridiem]);
1945        assert_eq!(us.literals, ["", "\u{202f}", ""]);
1946
1947        let japanese = RegionalTimeFormat::for_locale_with_cycle("ja-JP", Some(HourCycle::H12))
1948            .unwrap()
1949            .pattern(TimeGranularity::Hour);
1950        assert_eq!(japanese.order, [TimeSegment::Meridiem, TimeSegment::Hour]);
1951        assert_eq!(japanese.literals, ["", "", ""]);
1952    }
1953
1954    #[test]
1955    fn constructors_clamp() {
1956        assert_eq!(Time::new(99, 99).hour, 23);
1957        assert_eq!(Time::new(99, 99).minute, 59);
1958        assert_eq!(Time::new(1, 1).with_second(99).second, 59);
1959    }
1960}
1961
1962/// Clamps `t` into `[min, max]` by total seconds; either bound may be absent.
1963fn clamp_time(t: Time, min: Option<Time>, max: Option<Time>) -> Time {
1964    let secs = |x: Time| x.hour * 3600 + x.minute * 60 + x.second;
1965    let mut out = t;
1966    if let Some(lo) = min {
1967        if secs(out) < secs(lo) {
1968            out = lo;
1969        }
1970    }
1971    if let Some(hi) = max {
1972        if secs(out) > secs(hi) {
1973            out = hi;
1974        }
1975    }
1976    out
1977}
1978
1979#[cfg(test)]
1980mod clamp_tests {
1981    use super::*;
1982
1983    #[test]
1984    fn no_bounds_is_identity() {
1985        let t = Time::new(9, 30);
1986        assert_eq!(clamp_time(t, None, None), t);
1987    }
1988
1989    #[test]
1990    fn clamps_up_to_min() {
1991        let min = Time::new(9, 0);
1992        assert_eq!(clamp_time(Time::new(7, 30), Some(min), None), min);
1993        // Already inside the range.
1994        assert_eq!(
1995            clamp_time(Time::new(10, 0), Some(min), None),
1996            Time::new(10, 0)
1997        );
1998    }
1999
2000    #[test]
2001    fn clamps_down_to_max() {
2002        let max = Time::new(17, 0);
2003        assert_eq!(clamp_time(Time::new(23, 30), None, Some(max)), max);
2004        assert_eq!(
2005            clamp_time(Time::new(12, 0), None, Some(max)),
2006            Time::new(12, 0)
2007        );
2008    }
2009
2010    #[test]
2011    fn bounds_are_inclusive() {
2012        let min = Time::new(9, 0);
2013        let max = Time::new(17, 0);
2014        assert_eq!(clamp_time(min, Some(min), Some(max)), min);
2015        assert_eq!(clamp_time(max, Some(min), Some(max)), max);
2016    }
2017
2018    #[test]
2019    fn compares_by_total_seconds_not_by_field() {
2020        // 09:59 is below 10:00 even though its minute is larger.
2021        let min = Time::new(10, 0);
2022        assert_eq!(clamp_time(Time::new(9, 59), Some(min), None), min);
2023    }
2024
2025    #[test]
2026    fn seconds_participate_in_the_comparison() {
2027        let min = Time::new(9, 0).with_second(30);
2028        assert_eq!(
2029            clamp_time(Time::new(9, 0).with_second(10), Some(min), None),
2030            min
2031        );
2032        let inside = Time::new(9, 0).with_second(45);
2033        assert_eq!(clamp_time(inside, Some(min), None), inside);
2034    }
2035}
2036
2037crate::util::impl_component_styled!(TimeField);