Skip to main content

gpui_component/time/
time_field.rs

1use std::sync::Arc;
2
3use gpui::{
4    App, ElementId, Entity, Focusable as _, FontFeatures, IntoElement, ParentElement as _,
5    RenderOnce, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
6};
7
8use crate::{
9    ActiveTheme as _, Disableable, Sizable, Size, StyleSized as _, StyledExt as _,
10    ThemeStyled as _, input::input_style,
11};
12
13use gpui_base::TimeField as BaseTimeField;
14pub use gpui_base::{HourCycle, TimeFieldEvent, TimeFieldState, TimePrecision, TimeSegment};
15
16/// Digits of equal width (OpenType `tnum`), so a value that changes while it
17/// is edited keeps its width instead of shifting with each digit.
18pub(crate) fn tabular_figures() -> FontFeatures {
19    FontFeatures(Arc::new(vec![("tnum".into(), 1)]))
20}
21
22/// Both period labels stacked in one grid cell, the inactive one transparent,
23/// so the segment is as wide as the wider label whichever is shown.
24fn period_label(pm: bool) -> impl IntoElement {
25    let label = |text: &'static str, active: bool| {
26        div()
27            .col_start(1)
28            .row_start(1)
29            .when(!active, |this| this.text_color(gpui::transparent_black()))
30            .child(text)
31    };
32    div()
33        .grid()
34        .grid_cols(1)
35        .child(label("AM", !pm))
36        .child(label("PM", pm))
37}
38
39/// A segmented time editor, e.g. `09:30`, `09:30:15` or `09:30 PM`.
40///
41/// The value lives in [`TimeFieldState`]; see it for the keyboard model.
42#[derive(IntoElement)]
43pub struct TimeField {
44    id: ElementId,
45    state: Entity<TimeFieldState>,
46    size: Size,
47    style: StyleRefinement,
48    disabled: bool,
49    invalid: bool,
50}
51
52impl TimeField {
53    pub fn new(state: &Entity<TimeFieldState>) -> Self {
54        Self {
55            id: ("time-field", state.entity_id()).into(),
56            state: state.clone(),
57            size: Size::default(),
58            style: StyleRefinement::default(),
59            disabled: false,
60            invalid: false,
61        }
62    }
63
64    pub(crate) fn with_id(mut self, id: impl Into<ElementId>) -> Self {
65        self.id = id.into();
66        self
67    }
68
69    /// Display the caller's validation result. This does not reject edits.
70    pub fn invalid(mut self, invalid: bool) -> Self {
71        self.invalid = invalid;
72        self
73    }
74}
75
76impl Sizable for TimeField {
77    fn with_size(mut self, size: impl Into<Size>) -> Self {
78        self.size = size.into();
79        self
80    }
81}
82
83impl Disableable for TimeField {
84    fn disabled(mut self, disabled: bool) -> Self {
85        self.disabled = disabled;
86        self
87    }
88}
89
90impl Styled for TimeField {
91    fn style(&mut self) -> &mut StyleRefinement {
92        &mut self.style
93    }
94}
95
96impl RenderOnce for TimeField {
97    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
98        let focused = self.state.read(cx).focus_handle(cx).is_focused(window);
99        let (bg, fg) = input_style(self.disabled, cx);
100        let segment_radius = cx.theme().radius / 2.;
101
102        div()
103            .flex()
104            .items_center()
105            .flex_none()
106            .font_features(tabular_figures())
107            .bg(bg)
108            .text_color(fg)
109            .border_1()
110            .border_color(cx.theme().input)
111            .rounded(cx.theme().radius)
112            .input_text_size(self.size)
113            .input_h(self.size)
114            .px_1()
115            .when(self.disabled, |this| this.opacity(0.5))
116            .when(focused && !self.disabled, |this| {
117                this.focus_ring_style(window, cx)
118            })
119            .when(self.invalid, |this| this.border_color(cx.theme().danger))
120            .child(
121                BaseTimeField::new(self.id, &self.state)
122                    .disabled(self.disabled)
123                    .flex()
124                    .h_full()
125                    .items_center()
126                    .render_segment(move |segment, state, _, cx| {
127                        segment
128                            .px_0p5()
129                            .when(state.segment() == TimeSegment::Period, |this| {
130                                this.ml_1()
131                                    .clear_children()
132                                    .child(period_label(state.value() == 1))
133                            })
134                            .rounded(segment_radius)
135                            .when(state.is_selected(), |this| this.bg(cx.theme().selection))
136                            .into_any_element()
137                    }),
138            )
139            .refine_style(&self.style)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use gpui::{AppContext as _, px};
146
147    use super::*;
148
149    #[gpui::test]
150    fn test_time_field_builder(cx: &mut gpui::TestAppContext) {
151        let window = cx.add_empty_window();
152        let state = window.update(|window, cx| {
153            cx.new(|cx| {
154                TimeFieldState::new(window, cx)
155                    .precision(TimePrecision::Second)
156                    .hour_cycle(HourCycle::H12)
157            })
158        });
159        let field = TimeField::new(&state)
160            .with_id("start")
161            .large()
162            .disabled(true)
163            .invalid(true)
164            .w(px(120.));
165
166        assert_eq!(field.id, "start".into());
167        assert_eq!(field.size, Size::Large);
168        assert!(field.disabled);
169        assert!(field.invalid);
170    }
171}