1use gpui::{
29 App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
30 div, prelude::*, px,
31};
32
33use theme::{Theme, ink};
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 if self.menu.begin_close() {
357 popover::reap_popup(cx, |calendar: &mut Self| &mut calendar.menu);
358 }
359 cx.notify();
360 }
361
362 fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
363 self.selected = Some(date);
364 self.cursor = date;
365 cx.emit(CalendarEvent::Selected(date));
366 self.close(cx);
367 }
368
369 fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
373 if self.menu.is_open() {
374 self.cursor = self.cursor.add_days(days);
375 cx.notify();
376 }
377 }
378
379 fn page(&mut self, months: i32, cx: &mut Context<Self>) {
380 if self.menu.is_open() {
381 self.cursor = self.cursor.add_months(months);
382 cx.notify();
383 }
384 }
385
386 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
387 if self.menu.is_open() {
390 self.choose(self.cursor, cx);
391 } else {
392 self.open(window, cx);
393 }
394 }
395
396 fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
397 self.close(cx);
398 }
399
400 fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
401 let month = self.cursor;
402 let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
403 let grid = month_grid(month, self.week_start);
404
405 popover::popover_card(theme)
406 .p(px(8.0))
407 .gap(px(6.0))
408 .flex()
409 .flex_col()
410 .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
411 .child(
412 div()
413 .flex()
414 .flex_row()
415 .items_center()
416 .justify_between()
417 .child(
418 month_step(theme, icons::ALT_ARROW_LEFT)
419 .id("calendar-prev")
420 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
421 )
422 .child(
423 div()
424 .flex_1()
425 .text_align(gpui::TextAlign::Center)
426 .text_size(px(13.0))
427 .font_weight(gpui::FontWeight::MEDIUM)
428 .text_color(theme.text)
429 .child(SharedString::from(heading)),
430 )
431 .child(
432 month_step(theme, icons::ALT_ARROW_RIGHT)
433 .id("calendar-next")
434 .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
435 ),
436 )
437 .child(
438 div()
439 .flex()
440 .flex_row()
441 .children(weekday_labels(self.week_start).map(|label| {
442 div()
443 .w(px(CELL))
444 .text_align(gpui::TextAlign::Center)
445 .text_size(px(10.5))
446 .text_color(theme.text_faint)
447 .child(SharedString::from(label))
448 })),
449 )
450 .children(grid.chunks(7).enumerate().map(|(row, week)| {
451 div()
452 .flex()
453 .flex_row()
454 .children(week.iter().enumerate().map(|(column, &day)| {
455 let cell = row * 7 + column;
456 day_cell(
457 theme,
458 day,
459 day.month() == month.month(),
460 Some(day) == self.selected,
461 day == self.today,
462 day == self.cursor,
463 )
464 .id(SharedString::from(format!("day-{cell}")))
465 .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
466 }))
467 }))
468 .into_any_element()
469 }
470}
471
472const CELL: f32 = 30.0;
474
475fn month_step(theme: &Theme, icon: &'static str) -> gpui::Div {
477 div()
478 .size(px(24.0))
479 .rounded(px(Theme::control_radius()))
480 .flex()
481 .items_center()
482 .justify_center()
483 .cursor_pointer()
484 .hover(|s| s.bg(ink(0.06)))
485 .child(
486 icons::icon(icon)
487 .size(px(14.0))
488 .text_color(theme.text_muted),
489 )
490}
491
492fn day_cell(
497 theme: &Theme,
498 day: Date,
499 in_month: bool,
500 selected: bool,
501 is_today: bool,
502 cursor: bool,
503) -> gpui::Div {
504 let text = match (selected, in_month, is_today) {
505 (true, _, _) => theme.on_accent,
506 (_, _, true) => theme.accent,
507 (_, true, _) => theme.text,
508 (_, false, _) => theme.text_faint,
509 };
510 div()
511 .size(px(CELL))
512 .rounded(px(7.0))
513 .flex()
514 .items_center()
515 .justify_center()
516 .text_size(px(12.5))
517 .text_color(text)
518 .when(selected, |cell| {
519 cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
520 })
521 .border_1()
524 .border_color(if cursor {
525 theme.caret
526 } else {
527 widgets::RING_SLOT
528 })
529 .cursor_pointer()
530 .hover(|s| s.bg(if selected { theme.accent } else { ink(0.06) }))
531 .child(SharedString::from(day.day().to_string()))
532}
533
534impl Focusable for Calendar {
535 fn focus_handle(&self, _: &App) -> FocusHandle {
536 self.focus_handle.clone()
537 }
538}
539
540impl Render for Calendar {
541 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
542 let theme = Theme::of(cx).clone();
543 let open = self.menu.is_open() || self.menu.is_closing();
544 let label = match self.selected {
545 Some(date) => SharedString::from(date.to_string()),
546 None => self.placeholder.clone(),
547 };
548 let card = open.then(|| self.card(&theme, cx));
549
550 div()
551 .key_context(KEY_CONTEXT)
552 .track_focus(&self.focus_handle)
553 .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
554 .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
555 .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
556 .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
557 .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
558 .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
559 .on_action(cx.listener(Self::confirm))
560 .on_action(cx.listener(Self::dismiss))
561 .relative()
562 .w_full()
563 .child(
564 div()
565 .id("calendar-trigger")
566 .on_mouse_down(
567 gpui::MouseButton::Left,
568 cx.listener(|calendar, _, _, _| calendar.menu.note_trigger_press()),
569 )
570 .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
571 .child(theme.select_trigger(label, open)),
572 )
573 .when_some(card, |trigger, card| {
574 trigger.child(popover::anchored_menu_below(
575 "calendar-menu",
576 card,
577 self.menu.closing_since(),
578 ))
579 })
580 }
581}