Skip to main content

guise/input/
calendar.rs

1//! `Calendar` — a controlled month grid.
2//!
3//! The parent owns the displayed month and the selection; the calendar just
4//! draws and reports clicks. [`DatePicker`](super::DatePicker) wraps one in a
5//! dropdown, but it also works standalone (inline pickers, booking ranges).
6
7use std::rc::Rc;
8
9use gpui::prelude::*;
10use gpui::{div, px, App, ElementId, FontWeight, IntoElement, SharedString, Window};
11
12use super::date::{month_grid, Date, Weekday};
13use crate::devtools::Probed;
14use crate::icon::{Icon, IconName};
15use crate::theme::{theme, Size};
16
17type SelectHandler = Rc<dyn Fn(Date, &mut Window, &mut App) + 'static>;
18type MonthHandler = Rc<dyn Fn(i32, u32, &mut Window, &mut App) + 'static>;
19
20/// Day-cell edge (px) per size token.
21fn cell_size(size: Size) -> f32 {
22    match size {
23        Size::Xs => 26.0,
24        Size::Sm => 30.0,
25        Size::Md => 34.0,
26        Size::Lg => 40.0,
27        Size::Xl => 46.0,
28    }
29}
30
31/// A controlled month-view calendar. Pass the visible `month`, the current
32/// selection (`value` or `range`), and handlers; the parent owns all state.
33#[derive(IntoElement)]
34pub struct Calendar {
35    id: ElementId,
36    year: i32,
37    month: u32,
38    value: Option<Date>,
39    range: Option<(Date, Option<Date>)>,
40    min: Option<Date>,
41    max: Option<Date>,
42    week_start: Weekday,
43    size: Size,
44    on_select: Option<SelectHandler>,
45    on_month_change: Option<MonthHandler>,
46}
47
48impl Calendar {
49    /// A calendar showing the month containing `Date::today()`.
50    pub fn new(id: impl Into<ElementId>) -> Self {
51        let today = Date::today();
52        Calendar {
53            id: id.into(),
54            year: today.year(),
55            month: today.month(),
56            value: None,
57            range: None,
58            min: None,
59            max: None,
60            week_start: Weekday::Sunday,
61            size: Size::Sm,
62            on_select: None,
63            on_month_change: None,
64        }
65    }
66
67    /// The month to display (`month` is 1–12).
68    pub fn month(mut self, year: i32, month: u32) -> Self {
69        self.year = year;
70        self.month = month.clamp(1, 12);
71        self
72    }
73
74    /// Single selected date.
75    pub fn value(mut self, value: Option<Date>) -> Self {
76        self.value = value;
77        self
78    }
79
80    /// Selected range: a start and an optional end (while picking).
81    pub fn range(mut self, start: Date, end: Option<Date>) -> Self {
82        self.range = Some((start, end));
83        self
84    }
85
86    pub fn min(mut self, min: Date) -> Self {
87        self.min = Some(min);
88        self
89    }
90
91    pub fn max(mut self, max: Date) -> Self {
92        self.max = Some(max);
93        self
94    }
95
96    pub fn week_start(mut self, week_start: Weekday) -> Self {
97        self.week_start = week_start;
98        self
99    }
100
101    pub fn size(mut self, size: Size) -> Self {
102        self.size = size;
103        self
104    }
105
106    /// A day cell was clicked (never fires for min/max-disabled days).
107    pub fn on_select(mut self, handler: impl Fn(Date, &mut Window, &mut App) + 'static) -> Self {
108        self.on_select = Some(Rc::new(handler));
109        self
110    }
111
112    /// Prev/next was clicked; receives the new (year, month) to display.
113    pub fn on_month_change(
114        mut self,
115        handler: impl Fn(i32, u32, &mut Window, &mut App) + 'static,
116    ) -> Self {
117        self.on_month_change = Some(Rc::new(handler));
118        self
119    }
120}
121
122impl RenderOnce for Calendar {
123    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
124        let t = theme(cx);
125        let cell = cell_size(self.size);
126        let font = t.font_size(self.size);
127        let radius = t.radius(Size::Xs) + 2.0;
128        let text_color = t.text().hsla();
129        let dimmed = t.dimmed().hsla();
130        let surface_hover = t.surface_hover().hsla();
131        let accent = t.primary();
132        let accent_bg = accent.hsla();
133        let accent_fg = accent.contrasting().hsla();
134        let in_range_bg = accent.alpha(0.12);
135
136        let today = Date::today();
137        let first = Date::new(self.year, self.month, 1).unwrap_or(today);
138        let title: SharedString = format!("{} {}", first.month_name(), self.year).into();
139
140        let mut header = div().flex().items_center().justify_between().child(
141            div()
142                .text_size(px(font))
143                .font_weight(FontWeight::MEDIUM)
144                .text_color(text_color)
145                .child(title),
146        );
147        let mut nav = div().flex().items_center().gap(px(4.0));
148        for (key, icon, delta) in [
149            ("guise-cal-prev", IconName::ChevronLeft, -1),
150            ("guise-cal-next", IconName::ChevronRight, 1),
151        ] {
152            let target = first.add_months(delta);
153            let handler = self.on_month_change.clone();
154            nav = nav.child(
155                div()
156                    .id(key)
157                    .flex()
158                    .items_center()
159                    .justify_center()
160                    .w(px(cell * 0.8))
161                    .h(px(cell * 0.8))
162                    .rounded(px(radius))
163                    .text_color(dimmed)
164                    .hover(move |s| s.bg(surface_hover))
165                    .child(Icon::new(icon).size(Size::Sm))
166                    .on_click(move |_ev, window, cx| {
167                        if let Some(handler) = &handler {
168                            handler(target.year(), target.month(), window, cx);
169                        }
170                    }),
171            );
172        }
173        header = header.child(nav);
174
175        let mut weekdays = div().flex();
176        for i in 0..7 {
177            let day = Weekday::from_index(self.week_start.index() + i);
178            weekdays = weekdays.child(
179                div()
180                    .w(px(cell))
181                    .h(px(cell * 0.8))
182                    .flex()
183                    .items_center()
184                    .justify_center()
185                    .text_size(px(font - 3.0))
186                    .text_color(dimmed)
187                    .child(SharedString::new_static(day.short())),
188            );
189        }
190
191        let grid = month_grid(self.year, self.month, self.week_start);
192        let (range_start, range_end) = match self.range {
193            Some((start, end)) => (Some(start), end),
194            None => (None, None),
195        };
196
197        let mut days = div().flex().flex_col();
198        for week in grid.chunks(7) {
199            let mut row = div().flex();
200            for date in week {
201                let date = *date;
202                let outside = date.month() != self.month;
203                let disabled = self.min.is_some_and(|min| date < min)
204                    || self.max.is_some_and(|max| date > max);
205                let selected = self.value == Some(date)
206                    || range_start == Some(date)
207                    || range_end == Some(date);
208                let in_range = match (range_start, range_end) {
209                    (Some(start), Some(end)) => date > start && date < end,
210                    _ => false,
211                };
212
213                let mut day = div()
214                    .id(("guise-cal-day", date.to_days() as usize))
215                    .w(px(cell))
216                    .h(px(cell))
217                    .flex()
218                    .items_center()
219                    .justify_center()
220                    .rounded(px(radius))
221                    .text_size(px(font - 1.0))
222                    .text_color(if outside { dimmed } else { text_color })
223                    .child(SharedString::from(date.day().to_string()));
224
225                if selected {
226                    day = day
227                        .bg(accent_bg)
228                        .text_color(accent_fg)
229                        .font_weight(FontWeight::MEDIUM);
230                } else if in_range {
231                    day = day.bg(in_range_bg);
232                } else if date == today {
233                    day = day.border_1().border_color(accent_bg);
234                }
235
236                if disabled {
237                    day = day.opacity(0.35);
238                } else {
239                    if !selected {
240                        day = day.hover(move |s| s.bg(surface_hover));
241                    }
242                    let handler = self.on_select.clone();
243                    day = day.on_click(move |_ev, window, cx| {
244                        if let Some(handler) = &handler {
245                            handler(date, window, cx);
246                        }
247                    });
248                }
249                row = row.child(day);
250            }
251            days = days.child(row);
252        }
253
254        div()
255            .id(self.id)
256            .flex()
257            .flex_col()
258            .gap(px(6.0))
259            .child(header)
260            .child(weekdays)
261            .child(days)
262            .probe("Calendar")
263    }
264}