Skip to main content

gpui_kit/datetime/
date_input.rs

1//! A date field with a calendar hanging off it.
2//!
3//! Typing goes through [`DateAdapter::parse_day`](crate::datetime::DateAdapter::parse_day).
4//! Text the adapter will not read stays exactly where the typist left it, the
5//! field publishes itself invalid, and the adapter's own message is shown word
6//! for word — the same rule `NumberInput` keeps, for the same reason: silently
7//! rewriting what somebody typed hides the disagreement instead of reporting
8//! it.
9
10use gpui::{
11    App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
12    InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, SharedString, Styled,
13    Subscription, Window, div, px,
14};
15use gpui_kit_assets::Icon;
16use gpui_kit_semantics::{NodeSpec, Role, Semantic};
17use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
18
19use crate::controls::button::IconButton;
20use crate::controls::field::{FieldState, field_shell};
21use crate::controls::input::{TextInput, TextInputEvent};
22use crate::datetime::adapter::{Day, SharedDateAdapter};
23use crate::datetime::calendar::{Calendar, CalendarEvent};
24use crate::foundation::{Disableable, Ident, Sizable, StyledExt, text as foundation_text};
25use crate::overlay::popover;
26use crate::strings::{ActiveStrings, StringKey};
27
28/// What a date field reports. The owner decides what any of it means.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum DateInputEvent {
31    /// A day the adapter read, by typing or from the calendar.
32    Changed(Day),
33    /// The adapter would not read what is in the field. Both the text as it
34    /// was typed and the adapter's message travel with it.
35    Unparsable {
36        text: SharedString,
37        message: SharedString,
38    },
39    Opened,
40    Closed,
41    Submit,
42}
43
44impl EventEmitter<DateInputEvent> for DateInput {}
45
46/// A text field over a host-owned calendar, with that calendar in a popover.
47pub struct DateInput {
48    ident: Ident,
49    focus_handle: FocusHandle,
50    adapter: SharedDateAdapter,
51    field: Entity<TextInput>,
52    calendar: Entity<Calendar>,
53    value: Option<Day>,
54    /// The adapter's refusal to read the current text, held verbatim.
55    message: Option<SharedString>,
56    open: bool,
57    size: ControlSize,
58    disabled: bool,
59    required: bool,
60    /// Whether the seeded day has been put on screen. The text belongs to the
61    /// typist afterwards, so it is written once.
62    seeded: bool,
63    /// Held so the field and calendar subscriptions live as long as this does.
64    _subscriptions: Vec<Subscription>,
65}
66
67impl std::fmt::Debug for DateInput {
68    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        formatter
70            .debug_struct("DateInput")
71            .field("ident", &self.ident)
72            .field("value", &self.value)
73            .field("open", &self.open)
74            .field("disabled", &self.disabled)
75            .finish()
76    }
77}
78
79impl DateInput {
80    pub fn new(
81        ident: impl Into<Ident>,
82        adapter: SharedDateAdapter,
83        window: &mut Window,
84        cx: &mut Context<Self>,
85    ) -> Self {
86        let ident = ident.into();
87        let field = cx.new(|cx| TextInput::new(ident.child("field"), window, cx).bare(true));
88        let calendar =
89            cx.new(|cx| Calendar::new(ident.child("calendar"), adapter.clone(), window, cx));
90        let subscriptions = vec![
91            cx.subscribe(&field, |input, _field, event, cx| match event {
92                TextInputEvent::Change(text) => input.read_typed(text.clone(), cx),
93                TextInputEvent::Submit => cx.emit(DateInputEvent::Submit),
94                _ => {}
95            }),
96            cx.subscribe(&calendar, |input, _calendar, event, cx| {
97                if let CalendarEvent::Picked(day) = event {
98                    input.take(*day, cx);
99                }
100            }),
101        ];
102
103        Self {
104            ident,
105            focus_handle: cx.focus_handle(),
106            adapter,
107            field,
108            calendar,
109            value: None,
110            message: None,
111            open: false,
112            size: ControlSize::Md,
113            disabled: false,
114            required: false,
115            seeded: false,
116            _subscriptions: subscriptions,
117        }
118    }
119
120    /// Seeds the day the caller holds.
121    pub fn value(mut self, day: Day) -> Self {
122        self.value = Some(day);
123        self
124    }
125
126    pub fn required(mut self, required: bool) -> Self {
127        self.required = required;
128        self
129    }
130
131    /// Replaces the day from the host side.
132    ///
133    /// The host already knows the day it just set, so this reports nothing.
134    pub fn set_value(&mut self, day: Option<Day>, cx: &mut Context<Self>) {
135        self.value = day;
136        self.message = None;
137        self.seeded = true;
138        let text = day
139            .map(|day| self.adapter.format_day(day))
140            .unwrap_or_default();
141        self.field
142            .update(cx, |field, cx| field.set_text_quietly(text, cx));
143        if let Some(day) = day {
144            self.calendar
145                .update(cx, |calendar, cx| calendar.set_selection(vec![day], cx));
146        }
147        cx.notify();
148    }
149
150    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
151        self.disabled = disabled;
152        self.field
153            .update(cx, |field, cx| field.set_disabled(disabled, cx));
154        if disabled {
155            self.open = false;
156        }
157        cx.notify();
158    }
159
160    pub fn field(&self) -> &Entity<TextInput> {
161        &self.field
162    }
163
164    pub fn calendar(&self) -> &Entity<Calendar> {
165        &self.calendar
166    }
167
168    pub fn current(&self) -> Option<Day> {
169        self.value
170    }
171
172    /// The adapter's reason for refusing the current text, or `None`.
173    pub fn message(&self) -> Option<&SharedString> {
174        self.message.as_ref()
175    }
176
177    pub fn is_open(&self) -> bool {
178        self.open
179    }
180
181    /// The text as the typist left it, which is not the host's value while an
182    /// edit is in progress.
183    pub fn shown_text(&self, cx: &App) -> SharedString {
184        self.field.read(cx).value().clone()
185    }
186
187    pub fn is_invalid(&self) -> bool {
188        self.message.is_some()
189    }
190
191    pub fn open(&mut self, cx: &mut Context<Self>) {
192        if self.disabled || self.open {
193            return;
194        }
195        self.open = true;
196        cx.emit(DateInputEvent::Opened);
197        cx.notify();
198    }
199
200    pub fn close(&mut self, cx: &mut Context<Self>) {
201        if !self.open {
202            return;
203        }
204        self.open = false;
205        cx.emit(DateInputEvent::Closed);
206        cx.notify();
207    }
208
209    pub fn toggle(&mut self, cx: &mut Context<Self>) {
210        if self.open {
211            self.close(cx);
212        } else {
213            self.open(cx);
214        }
215    }
216
217    /// Reads what was typed, and keeps it whatever the answer is.
218    fn read_typed(&mut self, text: SharedString, cx: &mut Context<Self>) {
219        if text.trim().is_empty() {
220            self.message = None;
221            cx.notify();
222            return;
223        }
224        match self.adapter.parse_day(text.as_ref()) {
225            Ok(day) => {
226                self.message = None;
227                self.calendar
228                    .update(cx, |calendar, cx| calendar.set_selection(vec![day], cx));
229                cx.emit(DateInputEvent::Changed(day));
230            }
231            Err(message) => {
232                self.message = Some(message.clone());
233                cx.emit(DateInputEvent::Unparsable { text, message });
234            }
235        }
236        cx.notify();
237    }
238
239    /// Takes a day from the calendar, writing it into the field so the typist
240    /// sees what they asked for while the host decides about it.
241    fn take(&mut self, day: Day, cx: &mut Context<Self>) {
242        let text = self.adapter.format_day(day);
243        self.field
244            .update(cx, |field, cx| field.set_text_quietly(text, cx));
245        self.message = None;
246        self.calendar
247            .update(cx, |calendar, cx| calendar.set_selection(vec![day], cx));
248        cx.emit(DateInputEvent::Changed(day));
249        self.close(cx);
250    }
251
252    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
253        if self.disabled {
254            return;
255        }
256        match event.keystroke.key.as_str() {
257            "escape" if self.open => {
258                self.close(cx);
259                cx.stop_propagation();
260            }
261            "down" if !self.open => {
262                self.open(cx);
263                cx.stop_propagation();
264            }
265            _ => {}
266        }
267    }
268}
269
270impl Disableable for DateInput {
271    fn disabled(mut self, disabled: bool) -> Self {
272        self.disabled = disabled;
273        self
274    }
275}
276
277impl Sizable for DateInput {
278    fn control_size(mut self, size: ControlSize) -> Self {
279        self.size = size;
280        self
281    }
282}
283
284impl Focusable for DateInput {
285    fn focus_handle(&self, _cx: &App) -> FocusHandle {
286        self.focus_handle.clone()
287    }
288}
289
290impl Render for DateInput {
291    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
292        let theme = cx.theme().clone();
293        if !self.seeded {
294            self.seeded = true;
295            if let Some(day) = self.value {
296                let text = self.adapter.format_day(day);
297                self.field
298                    .update(cx, |field, cx| field.set_text_quietly(text, cx));
299                self.calendar
300                    .update(cx, |calendar, cx| calendar.set_selection(vec![day], cx));
301            }
302        }
303        if self.disabled != self.field.read(cx).is_disabled() {
304            let disabled = self.disabled;
305            self.field
306                .update(cx, |field, cx| field.set_disabled(disabled, cx));
307        }
308
309        let focused = self.field.read(cx).focus_handle(cx).is_focused(window);
310        let invalid = self.is_invalid();
311        let input = cx.entity().downgrade();
312
313        let trigger = IconButton::new(
314            self.ident.child("open"),
315            Icon::Checklist,
316            cx.strings().text(StringKey::DateInputOpen),
317        )
318        .ghost()
319        .control_size(self.size)
320        .semantic_parent(self.ident.semantic_id())
321        .disabled(self.disabled)
322        .on_click(move |_window, cx| {
323            input.update(cx, |input, cx| input.toggle(cx)).ok();
324        });
325
326        let surface = self.open.then(|| {
327            let card = popover::card(&theme)
328                .child(self.calendar.clone())
329                .into_any_element();
330            popover::anchored_below(
331                ElementId::from(self.ident.child("surface").semantic_id()),
332                &theme,
333                card,
334            )
335        });
336
337        let message = self.message.clone().map(|message| {
338            foundation_text(&theme, TypeScale::Caption, message.clone())
339                .text_color(theme.colors.danger)
340                .semantic_in(
341                    cx,
342                    NodeSpec::new(self.ident.child("message").semantic_id(), Role::Status)
343                        .parent(self.ident.semantic_id())
344                        .text(message),
345                )
346        });
347
348        div()
349            .id(self.ident.element_id())
350            .column()
351            .w_full()
352            .gap(px(theme.space(Space::Xs)))
353            .track_focus(&self.focus_handle)
354            .on_key_down(cx.listener(Self::on_key_down))
355            .child(
356                field_shell(
357                    &theme,
358                    self.size,
359                    FieldState::default()
360                        .focused(focused)
361                        .invalid(invalid)
362                        .disabled(self.disabled),
363                )
364                .child(div().flex_1().child(self.field.clone()))
365                .child(trigger),
366            )
367            .child(div().relative().children(surface))
368            .children(message)
369            .semantic_in(
370                cx,
371                NodeSpec::new(self.ident.semantic_id(), Role::Input)
372                    .focus(&self.field.read(cx).focus_handle(cx))
373                    .disabled(self.disabled)
374                    .required(self.required)
375                    .invalid(invalid)
376                    .expanded(self.open)
377                    .value(self.field.read(cx).value().clone())
378                    .placeholder(cx.strings().text(StringKey::DateInputPlaceholder)),
379            )
380    }
381}