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