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
16pub(crate) fn tabular_figures() -> FontFeatures {
19 FontFeatures(Arc::new(vec![("tnum".into(), 1)]))
20}
21
22fn 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#[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 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 .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 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}