1use gpui::{
29 App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
30 div, prelude::*, px,
31};
32use icons::Icon;
33
34use theme::{TextStyle, Theme, Typeset};
35
36use crate::{icons, popover, widgets, widgets::Controls};
37
38#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
44pub struct Date {
45 year: i32,
46 month: u8,
47 day: u8,
48}
49
50impl Date {
51 pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
55 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
56 return None;
57 }
58 Some(Self { year, month, day })
59 }
60
61 pub fn year(self) -> i32 {
62 self.year
63 }
64
65 pub fn month(self) -> u8 {
66 self.month
67 }
68
69 pub fn day(self) -> u8 {
70 self.day
71 }
72
73 pub fn to_days(self) -> i64 {
81 let year = self.year as i64 - (self.month <= 2) as i64;
82 let era = if year >= 0 { year } else { year - 399 } / 400;
83 let year_of_era = year - era * 400;
84 let month = self.month as i64;
85 let day_of_year =
86 (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
87 let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
88 era * 146097 + day_of_era - 719468
89 }
90
91 pub fn from_days(days: i64) -> Self {
94 let days = days + 719468;
95 let era = if days >= 0 { days } else { days - 146096 } / 146097;
96 let day_of_era = days - era * 146097;
97 let year_of_era =
98 (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365;
99 let year = year_of_era + era * 400;
100 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
101 let month_position = (5 * day_of_year + 2) / 153;
102 let day = (day_of_year - (153 * month_position + 2) / 5 + 1) as u8;
103 let month = (month_position + if month_position < 10 { 3 } else { -9 }) as u8;
104 Self {
105 year: (year + (month <= 2) as i64) as i32,
106 month,
107 day,
108 }
109 }
110
111 pub fn add_days(self, days: i64) -> Self {
112 Self::from_days(self.to_days() + days)
113 }
114
115 pub fn add_months(self, months: i32) -> Self {
119 let total = self.year as i64 * 12 + self.month as i64 - 1 + months as i64;
122 let year = total.div_euclid(12) as i32;
123 let month = total.rem_euclid(12) as u8 + 1;
124 Self {
125 year,
126 month,
127 day: self.day.min(days_in_month(year, month)),
128 }
129 }
130
131 pub fn weekday(self) -> Weekday {
132 Weekday::from_index(((self.to_days() + 3).rem_euclid(7)) as u8)
134 }
135}
136
137impl std::fmt::Display for Date {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
143 }
144}
145
146#[derive(Clone, Copy, PartialEq, Eq, Debug)]
147pub enum Weekday {
148 Monday,
149 Tuesday,
150 Wednesday,
151 Thursday,
152 Friday,
153 Saturday,
154 Sunday,
155}
156
157impl Weekday {
158 pub fn index(self) -> u8 {
161 self as u8
162 }
163
164 fn from_index(index: u8) -> Self {
165 use Weekday::*;
166 [
167 Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday,
168 ][(index % 7) as usize]
169 }
170
171 pub fn offset_from(self, start: Weekday) -> u8 {
173 (7 + self.index() - start.index()) % 7
174 }
175}
176
177pub fn is_leap(year: i32) -> bool {
178 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
179}
180
181pub fn days_in_month(year: i32, month: u8) -> u8 {
184 match month {
185 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
186 4 | 6 | 9 | 11 => 30,
187 2 if is_leap(year) => 29,
188 2 => 28,
189 _ => 0,
190 }
191}
192
193pub const MONTHS: [&str; 12] = [
198 "January",
199 "February",
200 "March",
201 "April",
202 "May",
203 "June",
204 "July",
205 "August",
206 "September",
207 "October",
208 "November",
209 "December",
210];
211
212pub fn weekday_labels(start: Weekday) -> [&'static str; 7] {
214 const FROM_MONDAY: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
215 std::array::from_fn(|column| FROM_MONDAY[((start.index() as usize) + column) % 7])
216}
217
218pub const GRID_ROWS: usize = 6;
220pub const GRID_CELLS: usize = GRID_ROWS * 7;
222
223pub fn month_grid(month: Date, start: Weekday) -> [Date; GRID_CELLS] {
233 let first = Date {
234 year: month.year,
235 month: month.month,
236 day: 1,
237 };
238 let origin = first.add_days(-(first.weekday().offset_from(start) as i64));
239 std::array::from_fn(|cell| origin.add_days(cell as i64))
240}
241
242actions!(
247 bezel_calendar,
248 [
249 PrevDay, NextDay, PrevWeek, NextWeek, PrevMonth, NextMonth, Confirm, Dismiss
250 ]
251);
252
253pub const KEY_CONTEXT: &str = "Calendar";
256
257pub fn init(cx: &mut App) {
260 cx.bind_keys(bindings());
261}
262
263pub fn bindings() -> Vec<KeyBinding> {
271 let mut bindings = Vec::new();
272 let ctx = Some(KEY_CONTEXT);
273 bindings.extend([
274 KeyBinding::new("left", PrevDay, ctx),
275 KeyBinding::new("right", NextDay, ctx),
276 KeyBinding::new("up", PrevWeek, ctx),
277 KeyBinding::new("down", NextWeek, ctx),
278 KeyBinding::new("pageup", PrevMonth, ctx),
279 KeyBinding::new("pagedown", NextMonth, ctx),
280 KeyBinding::new("enter", Confirm, ctx),
281 KeyBinding::new("space", Confirm, ctx),
282 KeyBinding::new("escape", Dismiss, ctx),
283 ]);
284
285 bindings
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291pub enum CalendarEvent {
292 Selected(Date),
293}
294
295pub struct Calendar {
296 today: Date,
299 selected: Option<Date>,
300 cursor: Date,
304 menu: popover::Popup<()>,
305 placeholder: SharedString,
306 focus_handle: FocusHandle,
307 week_start: Weekday,
308}
309
310impl EventEmitter<CalendarEvent> for Calendar {}
311
312impl Calendar {
313 pub fn new(today: Date, cx: &mut Context<Self>) -> Self {
314 Self {
315 today,
316 selected: None,
317 cursor: today,
318 menu: popover::Popup::default(),
319 placeholder: SharedString::from("Pick a date"),
320 focus_handle: cx.focus_handle().tab_stop(true),
323 week_start: Weekday::Monday,
324 }
325 }
326
327 pub fn with_selection(mut self, date: Date) -> Self {
329 self.selected = Some(date);
330 self.cursor = date;
331 self
332 }
333
334 pub fn with_week_start(mut self, start: Weekday) -> Self {
335 self.week_start = start;
336 self
337 }
338
339 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
340 self.placeholder = placeholder.into();
341 self
342 }
343
344 pub fn selection(&self) -> Option<Date> {
345 self.selected
346 }
347
348 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
349 if self.menu.take_press_was_open() {
352 self.close(cx);
353 } else {
354 self.open(window, cx);
355 }
356 }
357
358 fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
359 self.cursor = self.selected.unwrap_or(self.today);
361 self.menu.open(());
362 window.focus(&self.focus_handle, cx);
363 cx.notify();
364 }
365
366 fn close(&mut self, cx: &mut Context<Self>) {
367 popover::close_popup(self, cx, |calendar: &mut Self| &mut calendar.menu);
368 cx.notify();
369 }
370
371 fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
372 self.selected = Some(date);
373 self.cursor = date;
374 cx.emit(CalendarEvent::Selected(date));
375 self.close(cx);
376 }
377
378 fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
382 if self.menu.is_open() {
383 self.cursor = self.cursor.add_days(days);
384 cx.notify();
385 }
386 }
387
388 fn page(&mut self, months: i32, cx: &mut Context<Self>) {
389 if self.menu.is_open() {
390 self.cursor = self.cursor.add_months(months);
391 cx.notify();
392 }
393 }
394
395 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
396 if self.menu.is_open() {
399 self.choose(self.cursor, cx);
400 } else {
401 self.open(window, cx);
402 }
403 }
404
405 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
406 self.close(cx);
407 }
408
409 fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
410 let month = self.cursor;
411 let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
412 let grid = month_grid(month, self.week_start);
413
414 popover::popover_card(theme)
415 .p(px(8.0))
416 .gap(px(6.0))
417 .flex()
418 .flex_col()
419 .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
420 .child(
421 div()
422 .flex()
423 .flex_row()
424 .items_center()
425 .justify_between()
426 .child(
427 month_step(theme, icons::glyph::ChevronLeft)
428 .id("calendar-prev")
429 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
430 )
431 .child(
432 div()
433 .flex_1()
434 .text_align(gpui::TextAlign::Center)
435 .text_style(TextStyle::Headline)
436 .text_color(theme.text)
437 .child(SharedString::from(heading)),
438 )
439 .child(
440 month_step(theme, icons::glyph::ChevronRight)
441 .id("calendar-next")
442 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
443 ),
444 )
445 .child(
446 div()
447 .flex()
448 .flex_row()
449 .children(weekday_labels(self.week_start).map(|label| {
450 div()
451 .w(px(CELL))
452 .text_align(gpui::TextAlign::Center)
453 .text_style(TextStyle::Caption)
454 .text_color(theme.text_faint)
455 .child(SharedString::from(label))
456 })),
457 )
458 .children(grid.chunks(7).enumerate().map(|(row, week)| {
459 div()
460 .flex()
461 .flex_row()
462 .children(week.iter().enumerate().map(|(column, &day)| {
463 let cell = row * 7 + column;
464 day_cell(
465 theme,
466 day,
467 day.month() == month.month(),
468 Some(day) == self.selected,
469 day == self.today,
470 day == self.cursor,
471 )
472 .id(SharedString::from(format!("day-{cell}")))
473 .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
474 }))
475 }))
476 .into_any_element()
477 }
478}
479
480const CELL: f32 = 30.0;
482
483fn month_step(theme: &Theme, icon: impl Into<Icon>) -> gpui::Div {
485 div()
486 .size(px(24.0))
487 .rounded(px(Theme::control_radius()))
488 .flex()
489 .items_center()
490 .justify_center()
491 .cursor_pointer()
492 .hover(|s| s.bg(theme.element_hover))
493 .child(
494 icons::icon(icon)
495 .size(px(14.0))
496 .text_color(theme.text_muted),
497 )
498}
499
500fn day_cell(
505 theme: &Theme,
506 day: Date,
507 in_month: bool,
508 selected: bool,
509 is_today: bool,
510 cursor: bool,
511) -> gpui::Div {
512 let text = match (selected, in_month, is_today) {
513 (true, _, _) => theme.on_accent,
514 (_, _, true) => theme.accent,
515 (_, true, _) => theme.text,
516 (_, false, _) => theme.text_faint,
517 };
518 div()
519 .size(px(CELL))
520 .rounded(px(7.0))
521 .flex()
522 .items_center()
523 .justify_center()
524 .text_style(TextStyle::Callout)
525 .text_color(text)
526 .when(selected, |cell| {
527 cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
528 })
529 .border_1()
532 .border_color(if cursor {
533 theme.ring
534 } else {
535 widgets::RING_SLOT
536 })
537 .cursor_pointer()
538 .hover(|s| {
539 s.bg(if selected {
540 theme.accent
541 } else {
542 theme.element_hover
543 })
544 })
545 .child(SharedString::from(day.day().to_string()))
546}
547
548impl Focusable for Calendar {
549 fn focus_handle(&self, _: &App) -> FocusHandle {
550 self.focus_handle.clone()
551 }
552}
553
554impl Render for Calendar {
555 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
556 let theme = Theme::of(cx).clone();
557 let open = self.menu.is_open() || self.menu.is_closing();
558 let label = match self.selected {
559 Some(date) => SharedString::from(date.to_string()),
560 None => self.placeholder.clone(),
561 };
562 let card = open.then(|| self.card(&theme, cx));
563
564 div()
565 .key_context(KEY_CONTEXT)
566 .track_focus(&self.focus_handle)
567 .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
568 .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
569 .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
570 .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
571 .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
572 .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
573 .on_action(cx.listener(Self::confirm))
574 .on_action(cx.listener(Self::dismiss))
575 .relative()
576 .w_full()
577 .child(popover::trigger_press(
578 div()
579 .id("calendar-trigger")
580 .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
581 .child(theme.select_trigger(label)),
582 |calendar: &mut Self| &mut calendar.menu,
583 cx,
584 ))
585 .when_some(card, |trigger, card| {
586 trigger.child(popover::anchored_menu_below(
587 "calendar-menu",
588 card,
589 self.menu.closing_since(),
590 ))
591 })
592 }
593}