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