ui/components/
calendar.rs1use chrono::{Datelike, Local, NaiveDate, Weekday};
7use gpui::{Context, Render, white};
8
9use crate::prelude::*;
10
11const WEEKDAYS: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
12
13const MONTHS: [&str; 12] = [
14 "January",
15 "February",
16 "March",
17 "April",
18 "May",
19 "June",
20 "July",
21 "August",
22 "September",
23 "October",
24 "November",
25 "December",
26];
27
28fn month_grid(year: i32, month: u32) -> Vec<Option<u32>> {
29 let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
30 let days_in_month = if month == 12 {
31 NaiveDate::from_ymd_opt(year + 1, 1, 1)
32 } else {
33 NaiveDate::from_ymd_opt(year, month + 1, 1)
34 }
35 .expect("valid month")
36 .signed_duration_since(first)
37 .num_days() as u32;
38
39 let leading = match first.weekday() {
40 Weekday::Sun => 0,
41 Weekday::Mon => 1,
42 Weekday::Tue => 2,
43 Weekday::Wed => 3,
44 Weekday::Thu => 4,
45 Weekday::Fri => 5,
46 Weekday::Sat => 6,
47 };
48
49 let mut cells = vec![None; leading as usize];
50 for day in 1..=days_in_month {
51 cells.push(Some(day));
52 }
53 while cells.len() % 7 != 0 {
54 cells.push(None);
55 }
56 cells
57}
58
59pub struct Calendar {
63 view_year: i32,
64 view_month: u32,
65 selected: Option<NaiveDate>,
66 disabled: Vec<NaiveDate>,
67}
68
69impl Calendar {
70 pub fn new() -> Self {
71 let today = Local::now().date_naive();
72 Self {
73 view_year: today.year(),
74 view_month: today.month(),
75 selected: None,
76 disabled: Vec::new(),
77 }
78 }
79
80 pub fn selected(mut self, date: NaiveDate) -> Self {
81 self.selected = Some(date);
82 self.view_year = date.year();
83 self.view_month = date.month();
84 self
85 }
86
87 pub fn disabled_dates(mut self, dates: impl IntoIterator<Item = NaiveDate>) -> Self {
88 self.disabled = dates.into_iter().collect();
89 self
90 }
91
92 pub fn selection(&self) -> Option<NaiveDate> {
93 self.selected
94 }
95
96 fn is_disabled(&self, date: NaiveDate) -> bool {
97 self.disabled.iter().any(|d| *d == date)
98 }
99
100 fn prev_month(&mut self, cx: &mut Context<Self>) {
101 if self.view_month == 1 {
102 self.view_month = 12;
103 self.view_year -= 1;
104 } else {
105 self.view_month -= 1;
106 }
107 cx.notify();
108 }
109
110 fn next_month(&mut self, cx: &mut Context<Self>) {
111 if self.view_month == 12 {
112 self.view_month = 1;
113 self.view_year += 1;
114 } else {
115 self.view_month += 1;
116 }
117 cx.notify();
118 }
119}
120
121impl Render for Calendar {
122 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
123 let today = Local::now().date_naive();
124 let year = self.view_year;
125 let month = self.view_month;
126 let selected = self.selected;
127 let grid = month_grid(year, month);
128 let month_label = format!("{} {}", MONTHS[month as usize - 1], year);
129
130 let weekday_header = h_flex().children(WEEKDAYS.iter().map(|day| {
131 div().flex_1().text_center().child(
132 Label::new(*day)
133 .size(LabelSize::XSmall)
134 .color(Color::Muted)
135 .weight(gpui::FontWeight::MEDIUM),
136 )
137 }));
138
139 let mut weeks = v_flex().gap_1();
140 for week in grid.chunks(7) {
141 let mut row = h_flex().gap_1();
142 for (col, day) in week.iter().enumerate() {
143 let Some(day) = *day else {
144 row = row.child(div().flex_1().h(px(32.)));
145 continue;
146 };
147
148 let date = NaiveDate::from_ymd_opt(year, month, day).expect("valid day");
149 let is_today = date == today;
150 let is_selected = selected == Some(date);
151 let is_disabled = self.is_disabled(date);
152 let hover = semantic::hover_bg(cx);
153
154 let mut cell = div()
155 .id(ElementId::Name(
156 format!("calendar-day-{year}-{month}-{day}").into(),
157 ))
158 .flex_1()
159 .h(px(32.))
160 .flex()
161 .items_center()
162 .justify_center()
163 .rounded_md()
164 .cursor_pointer();
165
166 if is_disabled {
167 cell = cell.cursor_default().child(
168 Label::new(day.to_string())
169 .size(LabelSize::Small)
170 .color(Color::Muted),
171 );
172 } else if is_selected {
173 cell = cell.bg(palette::primary(600)).child(
180 Label::new(day.to_string())
181 .size(LabelSize::Small)
182 .color(Color::Custom(white())),
183 );
184 } else {
185 cell = cell
186 .debug_selector(move || format!("CALENDAR-DAY-{year}-{month}-{day}"))
195 .hover(move |s| s.bg(hover))
196 .when(is_today, |this| {
197 this.border_1().border_color(palette::primary(500))
198 })
199 .on_click(cx.listener(move |this, _, _, cx| {
200 this.selected = Some(date);
201 cx.notify();
202 }))
203 .child(Label::new(day.to_string()).size(LabelSize::Small));
204 }
205
206 row = row.child(cell);
207 let _ = col;
208 }
209 weeks = weeks.child(row);
210 }
211
212 v_flex()
213 .id("calendar")
214 .w(px(280.))
215 .p_3()
216 .gap_3()
217 .rounded_md()
218 .border_1()
219 .border_color(semantic::border(cx))
220 .bg(semantic::elevated_surface(cx))
221 .child(
222 h_flex()
223 .items_center()
224 .justify_between()
225 .child(
226 IconButton::new("calendar-prev", IconName::ChevronLeft)
227 .on_click(cx.listener(|this, _, _, cx| this.prev_month(cx))),
228 )
229 .child(Label::new(month_label).weight(gpui::FontWeight::SEMIBOLD))
230 .child(
231 IconButton::new("calendar-next", IconName::ChevronRight)
232 .on_click(cx.listener(|this, _, _, cx| this.next_month(cx))),
233 ),
234 )
235 .child(weekday_header)
236 .child(weeks)
237 }
238}
239
240#[derive(IntoElement, RegisterComponent)]
242pub struct CalendarPreview;
243
244impl RenderOnce for CalendarPreview {
245 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
246 cx.new(|_| Calendar::new())
247 }
248}
249
250impl Component for CalendarPreview {
251 fn scope() -> ComponentScope {
252 ComponentScope::Input
253 }
254
255 fn description() -> Option<&'static str> {
256 Some("Single-month date grid with day selection and today indicator.")
257 }
258
259 fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
260 CalendarPreview.render(window, cx).into_any_element().into()
261 }
262}