Skip to main content

ui/components/
calendar.rs

1//! Single-month calendar grid with day selection and today indicator.
2//!
3//! Multi-month views and full locale/i18n month-name support are deferred
4//! (time budget within Phase 6, not infeasibility).
5
6use 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
59/// Stateful single-month calendar.
60///
61/// Create with `cx.new(|_| Calendar::new())`.
62pub 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                    // `palette::primary(600)` fill + `white()` text is the
174                    // established "filled/checked" pairing used across this
175                    // crate for solid primary backgrounds (see
176                    // `segmented_control.rs`, `stepper.rs`, `toggle.rs`) —
177                    // kept consistent here rather than introducing a
178                    // one-off token.
179                    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                        // Test-only, no-op in release builds (mirrors the
187                        // `Tab`/`ContextMenu`/`ActionPanel` `debug_selector`
188                        // precedent): lets integration tests locate a
189                        // specific day cell's real rendered bounds to drive a
190                        // genuine `simulate_click`, since `Calendar` exposes
191                        // no public mutator to select a day directly (only
192                        // the render-time `on_click` wiring below actually
193                        // sets `selected`).
194                        .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/// Gallery catalog entry for [`Calendar`].
241#[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}