1use 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#[derive(Clone, Debug)]
38pub struct DateEditor {
39 with_time: bool,
40 offset: FixedOffset,
41}
42
43impl DateEditor {
44 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 pub fn date_time() -> Self {
54 Self {
55 with_time: true,
56 ..Self::date()
57 }
58 }
59
60 pub fn with_offset(mut self, offset: FixedOffset) -> Self {
63 self.offset = offset;
64 self
65 }
66
67 pub fn offset(&self) -> FixedOffset {
69 self.offset
70 }
71
72 pub fn has_time(&self) -> bool {
74 self.with_time
75 }
76
77 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
105fn to_value(day: Option<NaiveDate>, time: NaiveTime, offset: FixedOffset) -> OwnedValue {
107 let Some(day) = day else {
108 return OwnedValue::Empty;
109 };
110 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 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
160struct DateField {
163 calendar: Entity<CalendarState>,
164 time_field: Option<Entity<TimeFieldState>>,
166 offset: FixedOffset,
167 start: RefCell<OwnedValue>,
170 initial: Cell<(Option<NaiveDate>, NaiveTime)>,
172 focus: FocusHandle,
174 _picked: Subscription,
175}
176
177impl DateField {
178 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 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 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 *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 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 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}