Skip to main content

gpui_kit_grid/state/editors/
date.rs

1//! `DateEditor`: a date, or a date and a time, from a calendar under the
2//! cell.
3//!
4//! The calendar is gpui-kit's public `Calendar` (and `TimeField` for a
5//! time) in the editor's own popup, not its `DatePicker`: a picker has no
6//! public way to open, and its calendar is a layer whose bounds the grid
7//! cannot know, so a press on it would read as a press outside the editor
8//! and end the edit.
9
10use std::cell::{Cell, RefCell};
11
12use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime, TimeZone as _, Utc};
13use gpui_kit::component::calendar::{Calendar, CalendarEvent, CalendarState, Date};
14use gpui_kit::component::time_field::{TimeField, TimeFieldState, TimePrecision};
15use gpui_kit::component::{Sizable as _, Size as ComponentSize};
16use gpui_kit::{
17    AnyElement, App, AppContext as _, Entity, FocusHandle, InteractiveElement as _,
18    IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, div, px,
19};
20
21use crate::{
22    actions::CommitEdit,
23    model::{CellValue, GridRow, OwnedValue},
24    state::edit::{
25        CellEditor, EditError, EditInput, EditorCell, EditorField, EditorOpen, EditorStart,
26    },
27};
28
29/// Edits a `Timestamp` cell with a calendar under it.
30///
31/// [`Self::date`] commits a date at 00:00 of its offset as soon as a day is
32/// picked; [`Self::date_time`] also edits the time, and commits on Enter or
33/// Tab. The offset is UTC unless [`Self::with_offset`] sets another: the
34/// day a cell shows and the day picked are days of that offset. Escape
35/// cancels. Backspace and Delete start with no date, which commits `Empty`;
36/// typing takes no edit.
37#[derive(Clone, Debug)]
38pub struct DateEditor {
39    with_time: bool,
40    offset: FixedOffset,
41}
42
43impl DateEditor {
44    /// A date: a day picked commits that day at 00:00 of the offset.
45    pub fn date() -> Self {
46        Self {
47            with_time: false,
48            offset: FixedOffset::east_opt(0).expect("UTC is an offset"),
49        }
50    }
51
52    /// A date and a time of day, to the minute.
53    pub fn date_time() -> Self {
54        Self {
55            with_time: true,
56            ..Self::date()
57        }
58    }
59
60    /// Sets the offset whose days and times the editor shows and commits
61    /// (default UTC).
62    pub fn with_offset(mut self, offset: FixedOffset) -> Self {
63        self.offset = offset;
64        self
65    }
66
67    /// The offset of the days and times.
68    pub fn offset(&self) -> FixedOffset {
69        self.offset
70    }
71
72    /// Returns true if the editor edits the time of day too.
73    pub fn has_time(&self) -> bool {
74        self.with_time
75    }
76
77    /// The day and time a start shows: the cell's, in the offset; no day for
78    /// an empty cell or `Clear`. `None` for typing, or a value that is not a
79    /// point in time.
80    fn start_draft(
81        &self,
82        value: CellValue<'_>,
83        input: &EditInput,
84    ) -> Option<(Option<NaiveDate>, NaiveTime)> {
85        match (input, value) {
86            (EditInput::Typing(_), _) => None,
87            (EditInput::Clear, CellValue::Timestamp(_) | CellValue::Empty) => {
88                Some((None, NaiveTime::MIN))
89            }
90            (_, CellValue::Empty) => Some((None, NaiveTime::MIN)),
91            (_, CellValue::Timestamp(at)) => {
92                let local = at.with_timezone(&self.offset);
93                let time = if self.with_time {
94                    TimePrecision::Minute.truncate(local.time())
95                } else {
96                    NaiveTime::MIN
97                };
98                Some((Some(local.date_naive()), time))
99            }
100            _ => None,
101        }
102    }
103}
104
105/// The value of a day and a time of day in `offset`.
106fn to_value(day: Option<NaiveDate>, time: NaiveTime, offset: FixedOffset) -> OwnedValue {
107    let Some(day) = day else {
108        return OwnedValue::Empty;
109    };
110    // A fixed offset maps every local time to exactly one instant.
111    offset
112        .from_local_datetime(&day.and_time(time))
113        .single()
114        .map_or(OwnedValue::Empty, |at| {
115            OwnedValue::Timestamp(at.with_timezone(&Utc))
116        })
117}
118
119impl<R: GridRow> CellEditor<R> for DateEditor {
120    fn open(&self, start: &EditorStart<'_, R>, window: &mut Window, cx: &mut App) -> EditorOpen {
121        let Some((day, time)) = self.start_draft(start.value(), start.input()) else {
122            return EditorOpen::Refuse;
123        };
124        let calendar = cx.new(|cx| {
125            let mut calendar = CalendarState::new(window, cx);
126            calendar.set_date(Date::Single(day), window, cx);
127            calendar
128        });
129        let time_field = self.with_time.then(|| {
130            cx.new(|cx| {
131                let mut field = TimeFieldState::new(window, cx).precision(TimePrecision::Minute);
132                field.set_time(time, window, cx);
133                field
134            })
135        });
136        // A day picked commits a date, as the editor's Enter does.
137        let with_time = self.with_time;
138        let picked = window.subscribe(
139            &calendar,
140            cx,
141            move |_, event: &CalendarEvent, window, cx| {
142                let CalendarEvent::Selected(_) = event;
143                if !with_time {
144                    window.dispatch_action(Box::new(CommitEdit), cx);
145                }
146            },
147        );
148        EditorOpen::Field(Box::new(DateField {
149            calendar,
150            time_field,
151            offset: self.offset,
152            start: RefCell::new(OwnedValue::from_cell(start.value())),
153            initial: Cell::new((day, time)),
154            focus: cx.focus_handle(),
155            _picked: picked,
156        }))
157    }
158}
159
160/// An open [`DateEditor`]: the draft's text in the cell, the calendar under
161/// it.
162struct DateField {
163    calendar: Entity<CalendarState>,
164    /// For [`DateEditor::date_time`].
165    time_field: Option<Entity<TimeFieldState>>,
166    offset: FixedOffset,
167    /// The cell's value: the draft until a day or a time is picked, so an
168    /// untouched editor keeps a time of day it does not show.
169    start: RefCell<OwnedValue>,
170    /// The day and time it opened with, or the store gave it since.
171    initial: Cell<(Option<NaiveDate>, NaiveTime)>,
172    /// The cell part's focus: the keys of the editor go there.
173    focus: FocusHandle,
174    _picked: Subscription,
175}
176
177impl DateField {
178    /// The draft: the picked day and the time of day.
179    fn draft(&self, cx: &App) -> (Option<NaiveDate>, NaiveTime) {
180        let day = match self.calendar.read(cx).date() {
181            Date::Single(day) => day,
182            Date::Range(start, _) => start,
183        };
184        let time = self
185            .time_field
186            .as_ref()
187            .map_or(NaiveTime::MIN, |field| field.read(cx).time());
188        (day, time)
189    }
190
191    /// The draft as the cell shows it in the editor.
192    fn text(&self, cx: &App) -> String {
193        let (day, time) = self.draft(cx);
194        let Some(day) = day else {
195            return String::new();
196        };
197        let at: DateTime<FixedOffset> = match self.offset.from_local_datetime(&day.and_time(time)) {
198            chrono::LocalResult::Single(at) => at,
199            _ => return String::new(),
200        };
201        if self.time_field.is_some() {
202            at.format("%Y-%m-%d %H:%M").to_string()
203        } else {
204            at.format("%Y-%m-%d").to_string()
205        }
206    }
207}
208
209impl EditorField for DateField {
210    fn render(&self, cell: &EditorCell<'_>, _: &mut Window, cx: &mut App) -> AnyElement {
211        let size = match cell.size() {
212            ComponentSize::XSmall | ComponentSize::Small => ComponentSize::Small,
213            size => size,
214        };
215        let width = if size == ComponentSize::Small {
216            px(196.)
217        } else {
218            px(224.)
219        };
220        div()
221            .flex_1()
222            .min_w_0()
223            .px(px(4.))
224            .truncate()
225            .track_focus(&self.focus)
226            .child(self.text(cx))
227            .child(
228                cell.popup(
229                    div()
230                        .p(px(8.))
231                        .flex()
232                        .flex_col()
233                        .gap(px(8.))
234                        .child(
235                            // As gpui-kit's `DatePicker` draws it: the popup is the
236                            // frame, so the calendar's own border and padding go.
237                            Calendar::new(&self.calendar)
238                                .border_0()
239                                .rounded_none()
240                                .p_0()
241                                .with_size(size)
242                                .w(width),
243                        )
244                        .children(
245                            self.time_field
246                                .as_ref()
247                                .map(|field| TimeField::new(field).with_size(size)),
248                        ),
249                    cx,
250                ),
251            )
252            .into_any_element()
253    }
254
255    fn focus(&self, window: &mut Window, cx: &mut App) {
256        self.focus.focus(window, cx);
257    }
258
259    fn value(&self, cx: &App) -> Result<OwnedValue, EditError> {
260        let draft = self.draft(cx);
261        if draft == self.initial.get() {
262            return Ok(self.start.borrow().clone());
263        }
264        Ok(to_value(draft.0, draft.1, self.offset))
265    }
266
267    fn set_value(&self, value: &OwnedValue, window: &mut Window, cx: &mut App) {
268        let (day, time) = match value {
269            OwnedValue::Timestamp(at) => {
270                let local = at.with_timezone(&self.offset);
271                let time = if self.time_field.is_some() {
272                    TimePrecision::Minute.truncate(local.time())
273                } else {
274                    NaiveTime::MIN
275                };
276                (Some(local.date_naive()), time)
277            }
278            _ => (None, NaiveTime::MIN),
279        };
280        self.calendar.update(cx, |calendar, cx| {
281            calendar.set_date(Date::Single(day), window, cx)
282        });
283        if let Some(field) = &self.time_field {
284            field.update(cx, |field, cx| field.set_time(time, window, cx));
285        }
286        // The store's value is the new starting point: untouched again.
287        *self.start.borrow_mut() = value.clone();
288        self.initial.set((day, time));
289    }
290
291    fn is_dirty(&self, cx: &App) -> bool {
292        self.draft(cx) != self.initial.get()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn at(text: &str) -> DateTime<Utc> {
301        DateTime::parse_from_rfc3339(text)
302            .expect("a time")
303            .with_timezone(&Utc)
304    }
305
306    #[test]
307    fn test_date_editor_values() {
308        let day = NaiveDate::from_ymd_opt(2026, 11, 4).expect("a day");
309        let utc = DateEditor::date();
310        assert!(!utc.has_time());
311        assert_eq!(utc.offset().local_minus_utc(), 0);
312        assert_eq!(
313            to_value(Some(day), NaiveTime::MIN, utc.offset()),
314            OwnedValue::Timestamp(at("2026-11-04T00:00:00Z"))
315        );
316        assert_eq!(
317            to_value(None, NaiveTime::MIN, utc.offset()),
318            OwnedValue::Empty
319        );
320        // 00:00 in Moscow is 21:00 UTC the day before.
321        let moscow = DateEditor::date().with_offset(FixedOffset::east_opt(3 * 3600).expect("+03"));
322        assert_eq!(
323            to_value(Some(day), NaiveTime::MIN, moscow.offset()),
324            OwnedValue::Timestamp(at("2026-11-03T21:00:00Z"))
325        );
326        // The cell's day is the offset's: 22:30 UTC is the next day there.
327        let placed = at("2026-11-04T22:30:45Z");
328        let start = |editor: &DateEditor, input: &EditInput| {
329            editor.start_draft(CellValue::Timestamp(placed), input)
330        };
331        assert_eq!(
332            start(&moscow, &EditInput::Command),
333            Some((NaiveDate::from_ymd_opt(2026, 11, 5), NaiveTime::MIN))
334        );
335        let with_time = DateEditor::date_time();
336        assert!(with_time.has_time());
337        assert_eq!(
338            start(&with_time, &EditInput::Command),
339            Some((
340                Some(day),
341                NaiveTime::from_hms_opt(22, 30, 0).expect("a time")
342            ))
343        );
344        assert_eq!(
345            start(&with_time, &EditInput::Clear),
346            Some((None, NaiveTime::MIN))
347        );
348        assert_eq!(start(&with_time, &EditInput::Typing("1".into())), None);
349        assert_eq!(
350            utc.start_draft(CellValue::Int(1), &EditInput::Command),
351            None
352        );
353    }
354}