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            .when(self.disabled, |this| this.opacity(0.5))
115            .when(focused && !self.disabled, |this| {
116                this.focus_ring_style(window, cx)
117            })
118            .when(self.invalid, |this| this.border_color(cx.theme().danger))
119            .child(
120                BaseTimeField::new(self.id, &self.state)
121                    .disabled(self.disabled)
122                    // The base field fills the frame, so a press anywhere inside it lands on the
123                    // field rather than on the frame around it.
124                    .flex()
125                    .size_full()
126                    .px_1()
127                    .items_center()
128                    .render_segment(move |segment, state, _, cx| {
129                        segment
130                            .px_0p5()
131                            .when(state.segment() == TimeSegment::Period, |this| {
132                                this.ml_1()
133                                    .clear_children()
134                                    .child(period_label(state.value() == 1))
135                            })
136                            .rounded(segment_radius)
137                            .when(state.is_selected(), |this| this.bg(cx.theme().selection))
138                            .into_any_element()
139                    }),
140            )
141            .refine_style(&self.style)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use gpui::{AppContext as _, px};
148
149    use super::*;
150
151    #[gpui::test]
152    fn test_time_field_builder(cx: &mut gpui::TestAppContext) {
153        let window = cx.add_empty_window();
154        let state = window.update(|window, cx| {
155            cx.new(|cx| {
156                TimeFieldState::new(window, cx)
157                    .precision(TimePrecision::Second)
158                    .hour_cycle(HourCycle::H12)
159            })
160        });
161        let field = TimeField::new(&state)
162            .with_id("start")
163            .large()
164            .disabled(true)
165            .invalid(true)
166            .w(px(120.));
167
168        assert_eq!(field.id, "start".into());
169        assert_eq!(field.size, Size::Large);
170        assert!(field.disabled);
171        assert!(field.invalid);
172    }
173
174    #[cfg(feature = "test-support")]
175    struct FieldHarness {
176        state: Entity<TimeFieldState>,
177    }
178
179    #[cfg(feature = "test-support")]
180    impl gpui::Render for FieldHarness {
181        fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
182            div().child(TimeField::new(&self.state).with_id("time").w(px(200.)))
183        }
184    }
185
186    #[cfg(feature = "test-support")]
187    fn click_at(cx: &mut gpui::VisualTestContext, position: gpui::Point<gpui::Pixels>) {
188        cx.simulate_click(position, gpui::Modifiers::default());
189        cx.update(|window, cx| window.draw(cx).clear(cx));
190    }
191
192    #[cfg(feature = "test-support")]
193    #[gpui::test]
194    fn pressing_outside_the_segments_focuses_the_first_segment(cx: &mut gpui::TestAppContext) {
195        cx.update(crate::init);
196        let (view, cx) = cx.add_window_view(|window, cx| FieldHarness {
197            state: cx.new(|cx| TimeFieldState::new(window, cx)),
198        });
199        cx.update(|window, cx| window.draw(cx).clear(cx));
200        let state = cx.update(|_, cx| view.read(cx).state.clone());
201        let segment = |cx: &mut gpui::VisualTestContext, id: &'static str| {
202            cx.update(|window, _| {
203                gpui_base::test_support::find(window, &[], &ElementId::from(id))
204                    .expect("segment is painted")
205                    .bounds()
206                    .center()
207            })
208        };
209        let blank = gpui::point(px(180.), segment(cx, "hour").y);
210
211        click_at(cx, blank);
212        cx.update(|window, cx| {
213            assert!(state.read(cx).focus_handle(cx).is_focused(window));
214        });
215        assert_eq!(
216            cx.update(|_, cx| state.read(cx).selected_segment()),
217            TimeSegment::Hour
218        );
219
220        // A press on a segment still selects that segment, not the first one.
221        let minute = segment(cx, "minute");
222        click_at(cx, minute);
223        assert_eq!(
224            cx.update(|_, cx| state.read(cx).selected_segment()),
225            TimeSegment::Minute
226        );
227
228        click_at(cx, blank);
229        assert_eq!(
230            cx.update(|_, cx| state.read(cx).selected_segment()),
231            TimeSegment::Hour
232        );
233    }
234}