use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
div, prelude::*, px,
};
use theme::{TextStyle, Theme, Typeset};
use crate::{icons, popover, widgets, widgets::Controls};
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct Date {
year: i32,
month: u8,
day: u8,
}
impl Date {
pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return None;
}
Some(Self { year, month, day })
}
pub fn year(self) -> i32 {
self.year
}
pub fn month(self) -> u8 {
self.month
}
pub fn day(self) -> u8 {
self.day
}
pub fn to_days(self) -> i64 {
let year = self.year as i64 - (self.month <= 2) as i64;
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let month = self.month as i64;
let day_of_year =
(153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
era * 146097 + day_of_era - 719468
}
pub fn from_days(days: i64) -> Self {
let days = days + 719468;
let era = if days >= 0 { days } else { days - 146096 } / 146097;
let day_of_era = days - era * 146097;
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_position = (5 * day_of_year + 2) / 153;
let day = (day_of_year - (153 * month_position + 2) / 5 + 1) as u8;
let month = (month_position + if month_position < 10 { 3 } else { -9 }) as u8;
Self {
year: (year + (month <= 2) as i64) as i32,
month,
day,
}
}
pub fn add_days(self, days: i64) -> Self {
Self::from_days(self.to_days() + days)
}
pub fn add_months(self, months: i32) -> Self {
let total = self.year as i64 * 12 + self.month as i64 - 1 + months as i64;
let year = total.div_euclid(12) as i32;
let month = total.rem_euclid(12) as u8 + 1;
Self {
year,
month,
day: self.day.min(days_in_month(year, month)),
}
}
pub fn weekday(self) -> Weekday {
Weekday::from_index(((self.to_days() + 3).rem_euclid(7)) as u8)
}
}
impl std::fmt::Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Weekday {
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday,
}
impl Weekday {
pub fn index(self) -> u8 {
self as u8
}
fn from_index(index: u8) -> Self {
use Weekday::*;
[
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday,
][(index % 7) as usize]
}
pub fn offset_from(self, start: Weekday) -> u8 {
(7 + self.index() - start.index()) % 7
}
}
pub fn is_leap(year: i32) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
pub fn days_in_month(year: i32, month: u8) -> u8 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
pub const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
pub fn weekday_labels(start: Weekday) -> [&'static str; 7] {
const FROM_MONDAY: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
std::array::from_fn(|column| FROM_MONDAY[((start.index() as usize) + column) % 7])
}
pub const GRID_ROWS: usize = 6;
pub const GRID_CELLS: usize = GRID_ROWS * 7;
pub fn month_grid(month: Date, start: Weekday) -> [Date; GRID_CELLS] {
let first = Date {
year: month.year,
month: month.month,
day: 1,
};
let origin = first.add_days(-(first.weekday().offset_from(start) as i64));
std::array::from_fn(|cell| origin.add_days(cell as i64))
}
actions!(
bezel_calendar,
[
PrevDay, NextDay, PrevWeek, NextWeek, PrevMonth, NextMonth, Confirm, Dismiss
]
);
pub const KEY_CONTEXT: &str = "Calendar";
pub fn init(cx: &mut App) {
let ctx = Some(KEY_CONTEXT);
cx.bind_keys([
KeyBinding::new("left", PrevDay, ctx),
KeyBinding::new("right", NextDay, ctx),
KeyBinding::new("up", PrevWeek, ctx),
KeyBinding::new("down", NextWeek, ctx),
KeyBinding::new("pageup", PrevMonth, ctx),
KeyBinding::new("pagedown", NextMonth, ctx),
KeyBinding::new("enter", Confirm, ctx),
KeyBinding::new("space", Confirm, ctx),
KeyBinding::new("escape", Dismiss, ctx),
]);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CalendarEvent {
Selected(Date),
}
pub struct Calendar {
today: Date,
selected: Option<Date>,
cursor: Date,
menu: popover::Popup<()>,
placeholder: SharedString,
focus_handle: FocusHandle,
week_start: Weekday,
}
impl EventEmitter<CalendarEvent> for Calendar {}
impl Calendar {
pub fn new(today: Date, cx: &mut Context<Self>) -> Self {
Self {
today,
selected: None,
cursor: today,
menu: popover::Popup::default(),
placeholder: SharedString::from("Pick a date"),
focus_handle: cx.focus_handle().tab_stop(true),
week_start: Weekday::Monday,
}
}
pub fn with_selection(mut self, date: Date) -> Self {
self.selected = Some(date);
self.cursor = date;
self
}
pub fn with_week_start(mut self, start: Weekday) -> Self {
self.week_start = start;
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn selection(&self) -> Option<Date> {
self.selected
}
fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.menu.take_press_was_open() {
self.close(cx);
} else {
self.open(window, cx);
}
}
fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.cursor = self.selected.unwrap_or(self.today);
self.menu.open(());
window.focus(&self.focus_handle, cx);
cx.notify();
}
fn close(&mut self, cx: &mut Context<Self>) {
if self.menu.begin_close() {
popover::reap_popup(cx, |calendar: &mut Self| &mut calendar.menu);
}
cx.notify();
}
fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
self.selected = Some(date);
self.cursor = date;
cx.emit(CalendarEvent::Selected(date));
self.close(cx);
}
fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
if self.menu.is_open() {
self.cursor = self.cursor.add_days(days);
cx.notify();
}
}
fn page(&mut self, months: i32, cx: &mut Context<Self>) {
if self.menu.is_open() {
self.cursor = self.cursor.add_months(months);
cx.notify();
}
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if self.menu.is_open() {
self.choose(self.cursor, cx);
} else {
self.open(window, cx);
}
}
fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
self.close(cx);
}
fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
let month = self.cursor;
let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
let grid = month_grid(month, self.week_start);
popover::popover_card(theme)
.p(px(8.0))
.gap(px(6.0))
.flex()
.flex_col()
.on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
.child(
div()
.flex()
.flex_row()
.items_center()
.justify_between()
.child(
month_step(theme, icons::ALT_ARROW_LEFT)
.id("calendar-prev")
.on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
)
.child(
div()
.flex_1()
.text_align(gpui::TextAlign::Center)
.text_style(TextStyle::Headline)
.text_color(theme.text)
.child(SharedString::from(heading)),
)
.child(
month_step(theme, icons::ALT_ARROW_RIGHT)
.id("calendar-next")
.on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
),
)
.child(
div()
.flex()
.flex_row()
.children(weekday_labels(self.week_start).map(|label| {
div()
.w(px(CELL))
.text_align(gpui::TextAlign::Center)
.text_style(TextStyle::Caption)
.text_color(theme.text_faint)
.child(SharedString::from(label))
})),
)
.children(grid.chunks(7).enumerate().map(|(row, week)| {
div()
.flex()
.flex_row()
.children(week.iter().enumerate().map(|(column, &day)| {
let cell = row * 7 + column;
day_cell(
theme,
day,
day.month() == month.month(),
Some(day) == self.selected,
day == self.today,
day == self.cursor,
)
.id(SharedString::from(format!("day-{cell}")))
.on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
}))
}))
.into_any_element()
}
}
const CELL: f32 = 30.0;
fn month_step(theme: &Theme, icon: &'static str) -> gpui::Div {
div()
.size(px(24.0))
.rounded(px(Theme::control_radius()))
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.hover(|s| s.bg(theme.element_hover))
.child(
icons::icon(icon)
.size(px(14.0))
.text_color(theme.text_muted),
)
}
fn day_cell(
theme: &Theme,
day: Date,
in_month: bool,
selected: bool,
is_today: bool,
cursor: bool,
) -> gpui::Div {
let text = match (selected, in_month, is_today) {
(true, _, _) => theme.on_accent,
(_, _, true) => theme.accent,
(_, true, _) => theme.text,
(_, false, _) => theme.text_faint,
};
div()
.size(px(CELL))
.rounded(px(7.0))
.flex()
.items_center()
.justify_center()
.text_style(TextStyle::Callout)
.text_color(text)
.when(selected, |cell| {
cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
})
.border_1()
.border_color(if cursor {
theme.ring
} else {
widgets::RING_SLOT
})
.cursor_pointer()
.hover(|s| {
s.bg(if selected {
theme.accent
} else {
theme.element_hover
})
})
.child(SharedString::from(day.day().to_string()))
}
impl Focusable for Calendar {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Calendar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
let open = self.menu.is_open() || self.menu.is_closing();
let label = match self.selected {
Some(date) => SharedString::from(date.to_string()),
None => self.placeholder.clone(),
};
let card = open.then(|| self.card(&theme, cx));
div()
.key_context(KEY_CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
.on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
.on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
.on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
.on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
.on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::dismiss))
.relative()
.w_full()
.child(
div()
.id("calendar-trigger")
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|calendar, _, _, _| calendar.menu.note_trigger_press()),
)
.on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
.child(theme.select_trigger(label)),
)
.when_some(card, |trigger, card| {
trigger.child(popover::anchored_menu_below(
"calendar-menu",
card,
self.menu.closing_since(),
))
})
}
}