use crate::a11y::{A11y, Announce};
use crate::date::{month_name, Date, Weekday};
use crate::element_id::scoped;
use crate::icons::Icons;
use crate::theme::{focus_ring, ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
use gpui::{
actions, div, prelude::*, App, Context, ElementId, EventEmitter, FocusHandle, Focusable,
IntoElement, KeyBinding, ParentElement, Rems, Render, Role, SharedString, Styled, Window,
};
use std::rc::Rc;
actions!(
calendar,
[
FocusNextDay,
FocusPreviousDay,
FocusNextWeek,
FocusPreviousWeek,
FocusWeekStart,
FocusWeekEnd,
FocusNextMonth,
FocusPreviousMonth,
FocusNextYear,
FocusPreviousYear,
SelectFocusedDay,
]
);
pub const CALENDAR_CONTEXT: &str = "Calendar";
pub fn bind_calendar_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("right", FocusNextDay, Some(CALENDAR_CONTEXT)),
KeyBinding::new("left", FocusPreviousDay, Some(CALENDAR_CONTEXT)),
KeyBinding::new("down", FocusNextWeek, Some(CALENDAR_CONTEXT)),
KeyBinding::new("up", FocusPreviousWeek, Some(CALENDAR_CONTEXT)),
KeyBinding::new("home", FocusWeekStart, Some(CALENDAR_CONTEXT)),
KeyBinding::new("end", FocusWeekEnd, Some(CALENDAR_CONTEXT)),
KeyBinding::new("pagedown", FocusNextMonth, Some(CALENDAR_CONTEXT)),
KeyBinding::new("pageup", FocusPreviousMonth, Some(CALENDAR_CONTEXT)),
KeyBinding::new("shift-pagedown", FocusNextYear, Some(CALENDAR_CONTEXT)),
KeyBinding::new("shift-pageup", FocusPreviousYear, Some(CALENDAR_CONTEXT)),
KeyBinding::new("enter", SelectFocusedDay, Some(CALENDAR_CONTEXT)),
KeyBinding::new("space", SelectFocusedDay, Some(CALENDAR_CONTEXT)),
]);
}
const CELL_RATIO: f32 = 1.15;
const WEEKS: usize = 6;
const DAYS_IN_WEEK: usize = 7;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CalendarEvent {
Selected(Date),
MonthChanged(Date),
}
pub struct Calendar {
id: ElementId,
visible_month: Date,
selected: Option<Date>,
today: Option<Date>,
focused_day: Date,
first_day_of_week: Weekday,
disabled_days: Option<Rc<dyn Fn(Date) -> bool>>,
month_labels: Option<[SharedString; 12]>,
weekday_labels: Option<[SharedString; 7]>,
disabled: bool,
size: ControlSize,
focus_handle: FocusHandle,
}
impl EventEmitter<CalendarEvent> for Calendar {}
impl Focusable for Calendar {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Calendar {
pub fn new(id: impl Into<ElementId>, anchor: Date, cx: &mut Context<Self>) -> Self {
Self {
id: id.into(),
visible_month: anchor.first_of_month(),
selected: None,
today: None,
focused_day: anchor,
first_day_of_week: Weekday::Sunday,
disabled_days: None,
month_labels: None,
weekday_labels: None,
disabled: false,
size: ControlSize::default(),
focus_handle: cx.focus_handle(),
}
}
pub fn selected(mut self, selected: Option<Date>) -> Self {
if let Some(date) = selected {
self.visible_month = date.first_of_month();
self.focused_day = date;
}
self.selected = selected;
self
}
pub fn today(mut self, today: Date) -> Self {
self.today = Some(today);
self
}
pub fn disabled_days(mut self, predicate: impl Fn(Date) -> bool + 'static) -> Self {
self.disabled_days = Some(Rc::new(predicate));
self
}
pub fn first_day_of_week(mut self, day: Weekday) -> Self {
self.first_day_of_week = day;
self
}
pub fn month_labels(mut self, labels: [impl Into<SharedString>; 12]) -> Self {
self.month_labels = Some(labels.map(Into::into));
self
}
pub fn weekday_labels(mut self, labels: [impl Into<SharedString>; 7]) -> Self {
self.weekday_labels = Some(labels.map(Into::into));
self
}
pub fn selection(&self) -> Option<Date> {
self.selected
}
pub fn set_selected(&mut self, selected: Option<Date>, cx: &mut Context<Self>) {
self.selected = selected;
if let Some(date) = selected {
self.show_month(date, cx);
self.focused_day = date;
}
cx.notify();
}
pub fn visible_month(&self) -> Date {
self.visible_month
}
pub fn set_visible_month(&mut self, date: Date, cx: &mut Context<Self>) {
self.show_month(date, cx);
cx.notify();
}
fn show_month(&mut self, date: Date, cx: &mut Context<Self>) {
let first = date.first_of_month();
if first == self.visible_month {
return;
}
self.visible_month = first;
cx.emit(CalendarEvent::MonthChanged(first));
}
pub fn days(&self) -> Vec<Date> {
let first = self.visible_month;
let lead = first.weekday().days_from(self.first_day_of_week) as i64;
let start = first.add_days(-lead);
(0..(WEEKS * DAYS_IN_WEEK) as i64)
.map(|offset| start.add_days(offset))
.collect()
}
pub fn is_day_disabled(&self, date: Date) -> bool {
self.disabled
|| self
.disabled_days
.as_ref()
.is_some_and(|predicate| predicate(date))
}
fn title(&self) -> SharedString {
let month = match &self.month_labels {
Some(labels) => labels[self.visible_month.month() as usize - 1].clone(),
None => month_name(self.visible_month.month()).into(),
};
format!("{month} {}", self.visible_month.year()).into()
}
fn weekday_label(&self, column: usize) -> SharedString {
let weekday = self.first_day_of_week.week_from()[column];
match &self.weekday_labels {
Some(labels) => labels[weekday.index()].clone(),
None => weekday.min_name().into(),
}
}
pub(crate) fn day_a11y(&self, date: Date) -> A11y {
let name: SharedString = if date.is_same_month(self.visible_month) {
format!("{date}").into()
} else {
format!("{date}, outside {}", self.title()).into()
};
A11y::new(Role::GridCell)
.name(name)
.selected(self.selected == Some(date))
.active_descendant(self.focused_day == date)
}
fn focus_day(&mut self, date: Date, cx: &mut Context<Self>) {
self.focused_day = date;
self.show_month(date, cx);
cx.notify();
}
fn select(&mut self, date: Date, cx: &mut Context<Self>) {
if self.is_day_disabled(date) {
return;
}
self.selected = Some(date);
self.focus_day(date, cx);
cx.emit(CalendarEvent::Selected(date));
}
fn select_focused(&mut self, cx: &mut Context<Self>) {
let date = self.focused_day;
self.select(date, cx);
}
fn focus_week_edge(&mut self, last: bool, cx: &mut Context<Self>) {
let offset = self.focused_day.weekday().days_from(self.first_day_of_week) as i64;
let target = if last {
self.focused_day.add_days(6 - offset)
} else {
self.focused_day.add_days(-offset)
};
self.focus_day(target, cx);
}
fn month_button(
&self,
part: &'static str,
name: &'static str,
delta: i32,
icon: gpui::Svg,
cx: &mut Context<Self>,
) -> impl IntoElement {
let theme = cx.theme();
let metrics = theme.control(self.size);
let fg = theme.fg_muted();
let hover_bg = theme.button_bg_hover();
let side = metrics.height;
div()
.id(scoped(&self.id, part))
.announce(A11y::new(Role::Button).name(name).not_focusable(
"the grid owns the one tab stop; PageUp and PageDown reach this from it",
))
.flex()
.items_center()
.justify_center()
.size(side)
.rounded(metrics.radius)
.text_color(fg)
.cursor_pointer()
.hover(move |style| style.bg(hover_bg))
.on_click(cx.listener(move |this, _, _window, cx| {
let target = this.visible_month.add_months(delta);
this.show_month(target, cx);
this.focused_day = this.focused_day.add_months(delta);
cx.notify();
}))
.child(icon.size(metrics.text_size))
}
}
impl Accessible for Calendar {
fn a11y(&self) -> A11y {
A11y::new(Role::Grid)
.name(self.title())
.focus_handle(self.focus_handle.clone())
}
}
impl Disableable for Calendar {
fn is_disabled(&self) -> bool {
self.disabled
}
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl ControlSized for Calendar {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl Render for Calendar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let metrics = theme.control(self.size);
let fg = theme.fg();
let fg_muted = theme.fg_muted();
let fg_disabled = theme.fg_disabled();
let accent = theme.accent();
let on_accent = theme.bg();
let surface_hover = theme.button_bg_hover();
let border = theme.border();
let cell: Rems = metrics.height * CELL_RATIO;
let a11y = self.a11y();
let title = self.title();
let days = self.days();
let visible_month = self.visible_month;
let selected = self.selected;
let today = self.today;
let focused_day = self.focused_day;
let focus_handle = self.focus_handle.clone();
let header = div()
.flex()
.items_center()
.justify_between()
.gap(metrics.gap)
.child(self.month_button(
"previous-month",
"Previous month",
-1,
Icons::chevron_left(),
cx,
))
.child(
div()
.flex_1()
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.text_color(fg)
.font_weight(gpui::FontWeight::MEDIUM)
.text_center()
.child(title.clone()),
)
.child(self.month_button("next-month", "Next month", 1, Icons::chevron_right(), cx));
let headings = div()
.id(scoped(&self.id, "weekdays"))
.announce(A11y::new(Role::Row))
.flex()
.children((0..DAYS_IN_WEEK).map(|column| {
let label = self.weekday_label(column);
div()
.id(scoped(&self.id, format!("weekday-{column}")))
.announce(A11y::new(Role::ColumnHeader).name(label.clone()))
.flex()
.items_center()
.justify_center()
.w(cell)
.h(metrics.height)
.text_size(metrics.text_size)
.text_color(fg_muted)
.child(label)
}));
let rows = (0..WEEKS).map(|week| {
let cells = (0..DAYS_IN_WEEK).map(|column| {
let date = days[week * DAYS_IN_WEEK + column];
let in_month = date.is_same_month(visible_month);
let is_selected = selected == Some(date);
let is_today = today == Some(date);
let is_focused = focused_day == date;
let is_disabled = self.is_day_disabled(date);
let a11y = self.day_a11y(date);
let color = if is_selected {
on_accent
} else if is_disabled {
fg_disabled
} else if in_month {
fg
} else {
fg_muted
};
let day_cell = div()
.id(scoped(&self.id, format!("day-{}", date)))
.announce(a11y)
.flex()
.items_center()
.justify_center()
.size(cell)
.rounded(metrics.radius)
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.text_color(color)
.when(is_selected, |this| this.bg(accent))
.when(!is_selected && is_focused, |this| {
this.border_1().border_color(accent)
})
.when(is_today && !is_selected, |this| {
this.font_weight(gpui::FontWeight::BOLD)
})
.when(is_disabled, |this| this.cursor_not_allowed())
.when(!is_disabled, |this| {
this.cursor_pointer()
.when(!is_selected, |this| {
this.hover(move |style| style.bg(surface_hover))
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.select(date, cx);
}))
})
.child(format!("{}", date.day()));
#[cfg(test)]
let day_cell = day_cell.debug_selector(move || format!("gpuikit-calendar-{date}"));
day_cell
});
div()
.id(scoped(&self.id, format!("week-{week}")))
.announce(A11y::new(Role::Row))
.flex()
.children(cells)
});
div()
.id(self.id.clone())
.announce(a11y)
.key_context(CALENDAR_CONTEXT)
.track_focus(&focus_handle)
.focus_visible(|style| style.shadow(focus_ring(accent)))
.on_action(cx.listener(|this, _: &FocusNextDay, _window, cx| {
let target = this.focused_day.add_days(1);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusPreviousDay, _window, cx| {
let target = this.focused_day.add_days(-1);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusNextWeek, _window, cx| {
let target = this.focused_day.add_days(7);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusPreviousWeek, _window, cx| {
let target = this.focused_day.add_days(-7);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusWeekStart, _window, cx| {
this.focus_week_edge(false, cx);
}))
.on_action(cx.listener(|this, _: &FocusWeekEnd, _window, cx| {
this.focus_week_edge(true, cx);
}))
.on_action(cx.listener(|this, _: &FocusNextMonth, _window, cx| {
let target = this.focused_day.add_months(1);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusPreviousMonth, _window, cx| {
let target = this.focused_day.add_months(-1);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusNextYear, _window, cx| {
let target = this.focused_day.add_months(12);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &FocusPreviousYear, _window, cx| {
let target = this.focused_day.add_months(-12);
this.focus_day(target, cx);
}))
.on_action(cx.listener(|this, _: &SelectFocusedDay, _window, cx| {
this.select_focused(cx);
}))
.flex()
.flex_col()
.gap(metrics.gap)
.p(metrics.padding_x)
.rounded(metrics.radius)
.border_1()
.border_color(border)
.when(self.disabled, |this| this.opacity(0.65))
.child(header)
.child(headings)
.children(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{px, size, Entity, TestAppContext, VisualTestContext};
use std::cell::RefCell;
use std::ops::Deref;
fn date(year: i32, month: u32, day: u32) -> Date {
Date::new(year, month, day).expect("a date the test wrote by hand exists")
}
fn calendar(cx: &mut TestAppContext) -> gpui::Entity<Calendar> {
cx.update(|cx| cx.new(|cx| Calendar::new("calendar", date(2026, 8, 20), cx)))
}
#[gpui::test]
fn the_grid_is_always_forty_two_days_starting_on_the_first_day_of_week(
cx: &mut TestAppContext,
) {
let calendar = calendar(cx);
calendar.update(cx, |this, _cx| {
let days = this.days();
assert_eq!(days.len(), 42);
assert_eq!(days[0].weekday(), Weekday::Sunday);
assert_eq!(days[0], date(2026, 7, 26));
assert!(days.contains(&date(2026, 8, 1)));
assert!(days.contains(&date(2026, 8, 31)));
});
calendar.update(cx, |this, _cx| {
this.first_day_of_week = Weekday::Monday;
let days = this.days();
assert_eq!(days.len(), 42);
assert_eq!(days[0].weekday(), Weekday::Monday);
for (index, day) in days.iter().enumerate() {
assert_eq!(*day, days[0].add_days(index as i64));
}
});
}
#[gpui::test]
fn paging_from_the_thirty_first_clamps_and_changes_the_month_once(cx: &mut TestAppContext) {
let calendar =
cx.update(|cx| cx.new(|cx| Calendar::new("calendar", date(2026, 1, 31), cx)));
let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
{
let events = events.clone();
cx.update(|cx| {
cx.subscribe(&calendar, move |_, event: &CalendarEvent, _| {
events.borrow_mut().push(*event);
})
.detach();
});
}
calendar.update(cx, |this, cx| {
let target = this.focused_day.add_months(1);
this.focus_day(target, cx);
});
cx.run_until_parked();
calendar.update(cx, |this, _cx| {
assert_eq!(this.focused_day, date(2026, 2, 28));
assert_eq!(this.visible_month(), date(2026, 2, 1));
});
assert_eq!(
*events.borrow(),
vec![CalendarEvent::MonthChanged(date(2026, 2, 1))],
"paging a month emits exactly one MonthChanged"
);
}
#[gpui::test]
fn a_day_cell_announces_a_grid_cell_with_the_two_states_on_the_right_days(
cx: &mut TestAppContext,
) {
let calendar = calendar(cx);
calendar.update(cx, |this, cx| {
this.set_selected(Some(date(2026, 8, 12)), cx);
let target = date(2026, 8, 19);
this.focus_day(target, cx);
});
calendar.update(cx, |this, _cx| {
let chosen = this.day_a11y(date(2026, 8, 12));
assert_eq!(chosen.role(), Role::GridCell);
assert_eq!(
chosen.accessible_name().map(SharedString::to_string),
Some("2026-08-12".to_string())
);
assert!(!chosen.is_active_descendant());
let focused = this.day_a11y(date(2026, 8, 19));
assert!(focused.is_active_descendant());
let claims = this
.days()
.into_iter()
.filter(|date| this.day_a11y(*date).is_active_descendant())
.count();
assert_eq!(claims, 1);
});
}
#[gpui::test]
fn the_month_can_be_set_from_outside_and_says_so_once(cx: &mut TestAppContext) {
let calendar = calendar(cx);
let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
{
let events = events.clone();
cx.update(|cx| {
cx.subscribe(&calendar, move |_, event: &CalendarEvent, _| {
events.borrow_mut().push(*event);
})
.detach();
});
}
calendar.update(cx, |this, cx| {
this.set_visible_month(date(2026, 11, 4), cx);
this.set_visible_month(date(2026, 11, 30), cx);
});
cx.run_until_parked();
calendar.update(cx, |this, _cx| {
assert_eq!(this.visible_month(), date(2026, 11, 1));
});
assert_eq!(
*events.borrow(),
vec![CalendarEvent::MonthChanged(date(2026, 11, 1))]
);
}
struct GridView {
calendar: Entity<Calendar>,
}
impl Render for GridView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.calendar.clone())
}
}
struct Drawn {
calendar: Entity<Calendar>,
cx: &'static mut VisualTestContext,
events: Rc<RefCell<Vec<CalendarEvent>>>,
}
fn draw(cx: &mut TestAppContext, anchor: Date) -> Drawn {
cx.update(crate::init);
let window = cx.open_window(size(px(480.), px(480.)), move |_window, cx| {
let calendar = cx.new(|cx| Calendar::new("calendar", anchor, cx).today(anchor));
GridView { calendar }
});
let calendar = window
.read_with(cx, |view, _cx| view.calendar.clone())
.expect("the window's root view is the grid view");
let events = Rc::new(RefCell::new(Vec::new()));
{
let events = events.clone();
cx.update(|cx| {
cx.subscribe(&calendar, move |_, event: &CalendarEvent, _| {
events.borrow_mut().push(*event);
})
.detach();
});
}
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
cx.run_until_parked();
let handle = calendar.read_with(cx, |this, _cx| this.focus_handle.clone());
cx.update(|window, cx| window.focus(&handle, cx));
cx.run_until_parked();
Drawn {
calendar,
cx,
events,
}
}
impl Drawn {
fn press(&mut self, keys: &str) {
self.cx.simulate_keystrokes(keys);
self.cx.run_until_parked();
}
fn focused_day(&mut self) -> Date {
self.calendar
.read_with(self.cx, |this, _cx| this.focused_day)
}
fn visible_month(&mut self) -> Date {
self.calendar
.read_with(self.cx, |this, _cx| this.visible_month())
}
fn selection(&mut self) -> Option<Date> {
self.calendar
.read_with(self.cx, |this, _cx| this.selection())
}
}
#[gpui::test]
fn the_bound_actions_move_the_focused_day_and_select_it(cx: &mut TestAppContext) {
let mut drawn = draw(cx, date(2026, 8, 20));
drawn.press("right");
assert_eq!(drawn.focused_day(), date(2026, 8, 21));
drawn.press("left left");
assert_eq!(drawn.focused_day(), date(2026, 8, 19));
drawn.press("down");
assert_eq!(drawn.focused_day(), date(2026, 8, 26));
drawn.press("up up");
assert_eq!(drawn.focused_day(), date(2026, 8, 12));
drawn.press("home");
assert_eq!(drawn.focused_day(), date(2026, 8, 9));
drawn.press("end");
assert_eq!(drawn.focused_day(), date(2026, 8, 15));
assert_eq!(drawn.selection(), None, "moving does not choose");
drawn.press("enter");
assert_eq!(drawn.selection(), Some(date(2026, 8, 15)));
drawn.press("right space");
assert_eq!(drawn.selection(), Some(date(2026, 8, 16)));
assert!(
drawn
.events
.borrow()
.iter()
.all(|event| !matches!(event, CalendarEvent::MonthChanged(_))),
"none of that left August"
);
}
#[gpui::test]
fn pagedown_from_the_thirty_first_clamps_and_brings_the_month_into_view(
cx: &mut TestAppContext,
) {
let mut drawn = draw(cx, date(2026, 1, 31));
drawn.press("pagedown");
assert_eq!(drawn.focused_day(), date(2026, 2, 28), "February clamps");
assert_eq!(drawn.visible_month(), date(2026, 2, 1));
assert_eq!(
*drawn.events.borrow(),
vec![CalendarEvent::MonthChanged(date(2026, 2, 1))],
"one keystroke, one MonthChanged"
);
drawn.press("shift-pagedown");
assert_eq!(drawn.focused_day(), date(2027, 2, 28));
assert_eq!(drawn.visible_month(), date(2027, 2, 1));
drawn.press("pageup");
assert_eq!(drawn.focused_day(), date(2027, 1, 28));
}
#[gpui::test]
fn a_disabled_day_cannot_be_chosen(cx: &mut TestAppContext) {
let calendar = cx.update(|cx| {
cx.new(|cx| {
Calendar::new("calendar", date(2026, 8, 20), cx)
.disabled_days(|date| date.weekday() == Weekday::Sunday)
})
});
calendar.update(cx, |this, cx| {
assert!(this.is_day_disabled(date(2026, 8, 16)));
this.select(date(2026, 8, 16), cx);
assert_eq!(this.selection(), None);
this.select(date(2026, 8, 17), cx);
assert_eq!(this.selection(), Some(date(2026, 8, 17)));
});
}
}