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 =
204          self.min.is_some_and(|min| date < min) || self.max.is_some_and(|max| date > max);
205        let selected =
206          self.value == Some(date) || range_start == Some(date) || range_end == Some(date);
207        let in_range = match (range_start, range_end) {
208          (Some(start), Some(end)) => date > start && date < end,
209          _ => false,
210        };
211
212        let mut day = div()
213          .id(("guise-cal-day", date.to_days() as usize))
214          .w(px(cell))
215          .h(px(cell))
216          .flex()
217          .items_center()
218          .justify_center()
219          .rounded(px(radius))
220          .text_size(px(font - 1.0))
221          .text_color(if outside { dimmed } else { text_color })
222          .child(SharedString::from(date.day().to_string()));
223
224        if selected {
225          day = day
226            .bg(accent_bg)
227            .text_color(accent_fg)
228            .font_weight(FontWeight::MEDIUM);
229        } else if in_range {
230          day = day.bg(in_range_bg);
231        } else if date == today {
232          day = day.border_1().border_color(accent_bg);
233        }
234
235        if disabled {
236          day = day.opacity(0.35);
237        } else {
238          if !selected {
239            day = day.hover(move |s| s.bg(surface_hover));
240          }
241          let handler = self.on_select.clone();
242          day = day.on_click(move |_ev, window, cx| {
243            if let Some(handler) = &handler {
244              handler(date, window, cx);
245            }
246          });
247        }
248        row = row.child(day);
249      }
250      days = days.child(row);
251    }
252
253    div()
254      .id(self.id)
255      .flex()
256      .flex_col()
257      .gap(px(6.0))
258      .child(header)
259      .child(weekdays)
260      .child(days)
261      .probe("Calendar")
262  }
263}