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 {
315 Self {
316 today,
317 selected: None,
318 cursor: today,
319 menu: popover::Popup::default(),
320 placeholder: SharedString::from("Pick a date"),
321 focus_handle: cx.focus_handle().tab_stop(true),
324 week_start: Weekday::Monday,
325 }
326 }
327
328 pub fn with_selection(mut self, date: Date) -> Self {
330 self.selected = Some(date);
331 self.cursor = date;
332 self
333 }
334
335 pub fn with_week_start(mut self, start: Weekday) -> Self {
336 self.week_start = start;
337 self
338 }
339
340 pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
341 self.placeholder = placeholder.into();
342 self
343 }
344
345 pub fn selection(&self) -> Option<Date> {
346 self.selected
347 }
348
349 fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
350 if self.menu.take_press_was_open() {
353 self.close(cx);
354 } else {
355 self.open(window, cx);
356 }
357 }
358
359 fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
360 self.cursor = self.selected.unwrap_or(self.today);
362 self.menu.open(());
363 window.focus(&self.focus_handle, cx);
364 cx.notify();
365 }
366
367 fn close(&mut self, cx: &mut Context<Self>) {
368 popover::close_popup(self, cx, |calendar: &mut Self| &mut calendar.menu);
369 cx.notify();
370 }
371
372 fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
373 self.selected = Some(date);
374 self.cursor = date;
375 cx.emit(CalendarEvent::Selected(date));
376 self.close(cx);
377 }
378
379 fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
383 if self.menu.is_open() {
384 self.cursor = self.cursor.add_days(days);
385 cx.notify();
386 }
387 }
388
389 fn page(&mut self, months: i32, cx: &mut Context<Self>) {
390 if self.menu.is_open() {
391 self.cursor = self.cursor.add_months(months);
392 cx.notify();
393 }
394 }
395
396 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
397 if self.menu.is_open() {
400 self.choose(self.cursor, cx);
401 } else {
402 self.open(window, cx);
403 }
404 }
405
406 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
407 self.close(cx);
408 }
409
410 fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
411 let month = self.cursor;
412 let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
413 let grid = month_grid(month, self.week_start);
414
415 popover::popover_card(theme)
416 .p(px(8.0))
417 .gap(px(6.0))
418 .flex()
419 .flex_col()
420 .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
421 .child(
422 div()
423 .flex()
424 .flex_row()
425 .items_center()
426 .justify_between()
427 .child(
428 month_step(theme, icons::glyph::ChevronLeft)
429 .id("calendar-prev")
430 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
431 )
432 .child(
433 div()
434 .flex_1()
435 .text_align(gpui::TextAlign::Center)
436 .text_style(TextStyle::Headline)
437 .text_color(theme.text)
438 .child(SharedString::from(heading)),
439 )
440 .child(
441 month_step(theme, icons::glyph::ChevronRight)
442 .id("calendar-next")
443 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
444 ),
445 )
446 .child(
447 div()
448 .flex()
449 .flex_row()
450 .children(weekday_labels(self.week_start).map(|label| {
451 div()
452 .w(px(CELL))
453 .text_align(gpui::TextAlign::Center)
454 .text_style(TextStyle::Caption)
455 .text_color(theme.text_faint)
456 .child(SharedString::from(label))
457 })),
458 )
459 .children(grid.chunks(7).enumerate().map(|(row, week)| {
460 div()
461 .flex()
462 .flex_row()
463 .children(week.iter().enumerate().map(|(column, &day)| {
464 let cell = row * 7 + column;
465 day_cell(
466 theme,
467 day,
468 day.month() == month.month(),
469 Some(day) == self.selected,
470 day == self.today,
471 day == self.cursor,
472 )
473 .id(SharedString::from(format!("day-{cell}")))
474 .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
475 }))
476 }))
477 .into_any_element()
478 }
479}
480
481const CELL: f32 = 30.0;
483
484fn month_step(theme: &Theme, icon: impl Into<Icon>) -> gpui::Div {
486 div()
487 .size(px(24.0))
488 .rounded(px(Theme::control_radius()))
489 .flex()
490 .items_center()
491 .justify_center()
492 .cursor_pointer()
493 .hover(|s| s.bg(theme.element_hover))
494 .child(
495 icons::icon(icon)
496 .size(px(14.0))
497 .text_color(theme.text_muted),
498 )
499}
500
501fn day_cell(
506 theme: &Theme,
507 day: Date,
508 in_month: bool,
509 selected: bool,
510 is_today: bool,
511 cursor: bool,
512) -> gpui::Div {
513 let text = match (selected, in_month, is_today) {
514 (true, _, _) => theme.on_accent,
515 (_, _, true) => theme.accent,
516 (_, true, _) => theme.text,
517 (_, false, _) => theme.text_faint,
518 };
519 div()
520 .size(px(CELL))
521 .rounded(px(7.0))
522 .flex()
523 .items_center()
524 .justify_center()
525 .text_style(TextStyle::Callout)
526 .text_color(text)
527 .when(selected, |cell| {
528 cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
529 })
530 .border_1()
533 .border_color(if cursor {
534 theme.ring
535 } else {
536 widgets::RING_SLOT
537 })
538 .cursor_pointer()
539 .hover(|s| {
540 s.bg(if selected {
541 theme.accent
542 } else {
543 theme.element_hover
544 })
545 })
546 .child(SharedString::from(day.day().to_string()))
547}
548
549impl Focusable for Calendar {
550 fn focus_handle(&self, _: &App) -> FocusHandle {
551 self.focus_handle.clone()
552 }
553}
554
555impl Render for Calendar {
556 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
557 let theme = Theme::of(cx).clone();
558 let open = self.menu.is_open() || self.menu.is_closing();
559 let label = match self.selected {
560 Some(date) => SharedString::from(date.to_string()),
561 None => self.placeholder.clone(),
562 };
563 let card = open.then(|| self.card(&theme, cx));
564
565 div()
566 .key_context(KEY_CONTEXT)
567 .track_focus(&self.focus_handle)
568 .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
569 .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
570 .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
571 .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
572 .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
573 .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
574 .on_action(cx.listener(Self::confirm))
575 .on_action(cx.listener(Self::dismiss))
576 .relative()
577 .w_full()
578 .child(popover::trigger_press(
579 div()
580 .id("calendar-trigger")
581 .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
582 .child(theme.select_trigger(label)),
583 |calendar: &mut Self| &mut calendar.menu,
584 cx,
585 ))
586 .when_some(card, |trigger, card| {
587 trigger.child(popover::anchored_menu_below(
588 "calendar-menu",
589 card,
590 self.menu.closing_since(),
591 ))
592 })
593 }
594}