Skip to main content

ui/
date.rs

1//! [`Calendar`] — a date picker: the closed face of a select over an anchored
2//! month grid.
3//!
4//! An entity for the same reason [`crate::combobox::Combobox`] is one. It owns
5//! navigation state the app has no opinion about — which month is on screen,
6//! where the keyboard cursor sits — and reports the one thing the app does care
7//! about, through [`CalendarEvent`]. A select needs no such component, because a
8//! select has no state but the caller's; a calendar does.
9//!
10//! [`Date`] is bezel's own, deliberately. chrono is already in the graph under
11//! gpui, so taking it would cost nothing to compile — and would make it a
12//! *public* dependency, so a consumer declaring its own `chrono` would end up
13//! with two incompatible ones. That is the split-graph failure `bezel::gpui`
14//! exists to prevent, and it buys nothing here: a picker needs no timezones, no
15//! parsing and no formatting. It needs the civil calendar, which is sixty lines
16//! and pure — everything above the horizontal rule below is testable without a
17//! window, and is tested.
18//!
19//! ```ignore
20//! ui::date::init(cx);   // once, at startup
21//! let picker = cx.new(|cx| Calendar::new(today, cx));
22//! cx.subscribe(&picker, |_, _, event, _| match event {
23//!     CalendarEvent::Selected(date) => { /* the chosen day */ }
24//! })
25//! .detach();
26//! ```
27
28use gpui::{
29    App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
30    div, prelude::*, px,
31};
32
33use theme::{Theme, ink};
34
35use crate::{icons, popover, widgets, widgets::Controls};
36
37/// A day in the proleptic Gregorian calendar.
38///
39/// Fields are private and [`Date::new`] is checked, so nothing downstream —
40/// including the grid below — ever has to ask whether a date is real. Ordering
41/// is chronological, which is what the field order buys.
42#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
43pub struct Date {
44    year: i32,
45    month: u8,
46    day: u8,
47}
48
49impl Date {
50    /// `None` unless the day exists: month in `1..=12`, day within that month's
51    /// own length, in that year — so 29 February answers differently depending
52    /// on the year, which is the whole point of asking.
53    pub fn new(year: i32, month: u8, day: u8) -> Option<Self> {
54        if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
55            return None;
56        }
57        Some(Self { year, month, day })
58    }
59
60    pub fn year(self) -> i32 {
61        self.year
62    }
63
64    pub fn month(self) -> u8 {
65        self.month
66    }
67
68    pub fn day(self) -> u8 {
69        self.day
70    }
71
72    /// Days since 1970-01-01, negative before it.
73    ///
74    /// Howard Hinnant's `days_from_civil`, transcribed rather than re-derived
75    /// (<http://howardhinnant.github.io/date_algorithms.html>, public domain).
76    /// It is exact across the whole proleptic Gregorian range, and every other
77    /// operation here is a round trip through it and its inverse — so there is
78    /// one piece of calendar arithmetic in this file, not six.
79    pub fn to_days(self) -> i64 {
80        let year = self.year as i64 - (self.month <= 2) as i64;
81        let era = if year >= 0 { year } else { year - 399 } / 400;
82        let year_of_era = year - era * 400;
83        let month = self.month as i64;
84        let day_of_year =
85            (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + self.day as i64 - 1;
86        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
87        era * 146097 + day_of_era - 719468
88    }
89
90    /// The inverse of [`Date::to_days`] — Hinnant's `civil_from_days`. Total by
91    /// construction: every integer is a day.
92    pub fn from_days(days: i64) -> Self {
93        let days = days + 719468;
94        let era = if days >= 0 { days } else { days - 146096 } / 146097;
95        let day_of_era = days - era * 146097;
96        let year_of_era =
97            (day_of_era - day_of_era / 1460 + day_of_era / 36524 - day_of_era / 146096) / 365;
98        let year = year_of_era + era * 400;
99        let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
100        let month_position = (5 * day_of_year + 2) / 153;
101        let day = (day_of_year - (153 * month_position + 2) / 5 + 1) as u8;
102        let month = (month_position + if month_position < 10 { 3 } else { -9 }) as u8;
103        Self {
104            year: (year + (month <= 2) as i64) as i32,
105            month,
106            day,
107        }
108    }
109
110    pub fn add_days(self, days: i64) -> Self {
111        Self::from_days(self.to_days() + days)
112    }
113
114    /// Whole months, keeping the day where the target month has one: 31 January
115    /// plus a month is 28 February, not 3 March. Clamping is what a calendar's
116    /// month arrows mean — adding thirty days is a different question.
117    pub fn add_months(self, months: i32) -> Self {
118        // Counted in i64 so a far-fetched year cannot overflow the multiply and
119        // panic — this is a library, and no input to it should be able to.
120        let total = self.year as i64 * 12 + self.month as i64 - 1 + months as i64;
121        let year = total.div_euclid(12) as i32;
122        let month = total.rem_euclid(12) as u8 + 1;
123        Self {
124            year,
125            month,
126            day: self.day.min(days_in_month(year, month)),
127        }
128    }
129
130    pub fn weekday(self) -> Weekday {
131        // 1970-01-01 was a Thursday, three days into a week starting Monday.
132        Weekday::from_index(((self.to_days() + 3).rem_euclid(7)) as u8)
133    }
134}
135
136/// ISO 8601, because it is the one written form no locale argues with and it
137/// sorts. An app wanting "17 Aug" formats it from the accessors — that is its
138/// call to make, not a component library's.
139impl std::fmt::Display for Date {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
142    }
143}
144
145#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum Weekday {
147    Monday,
148    Tuesday,
149    Wednesday,
150    Thursday,
151    Friday,
152    Saturday,
153    Sunday,
154}
155
156impl Weekday {
157    /// Days from Monday. Monday rather than Sunday only because something had
158    /// to be zero; [`month_grid`] takes the week's start as an argument.
159    pub fn index(self) -> u8 {
160        self as u8
161    }
162
163    fn from_index(index: u8) -> Self {
164        use Weekday::*;
165        [
166            Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday,
167        ][(index % 7) as usize]
168    }
169
170    /// How many days into a week starting on `start` this weekday falls.
171    pub fn offset_from(self, start: Weekday) -> u8 {
172        (7 + self.index() - start.index()) % 7
173    }
174}
175
176pub fn is_leap(year: i32) -> bool {
177    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
178}
179
180/// Length of a month, or `0` for a month outside `1..=12` — which no [`Date`]
181/// can hold, so only an unchecked caller can see it.
182pub fn days_in_month(year: i32, month: u8) -> u8 {
183    match month {
184        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
185        4 | 6 | 9 | 11 => 30,
186        2 if is_leap(year) => 29,
187        2 => 28,
188        _ => 0,
189    }
190}
191
192/// English month names. There is no locale system here and there will not be
193/// one until something needs it: localizing means shipping ICU, and an app that
194/// needs it can draw its own grid from [`month_grid`], which is the reusable
195/// half.
196pub const MONTHS: [&str; 12] = [
197    "January",
198    "February",
199    "March",
200    "April",
201    "May",
202    "June",
203    "July",
204    "August",
205    "September",
206    "October",
207    "November",
208    "December",
209];
210
211/// Two-letter weekday headings, in the order a week starting on `start` runs.
212pub fn weekday_labels(start: Weekday) -> [&'static str; 7] {
213    const FROM_MONDAY: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
214    std::array::from_fn(|column| FROM_MONDAY[((start.index() as usize) + column) % 7])
215}
216
217/// Rows of a month grid — six, always.
218pub const GRID_ROWS: usize = 6;
219/// Cells in a month grid.
220pub const GRID_CELLS: usize = GRID_ROWS * 7;
221
222/// The block a month is drawn in: 42 real dates, beginning on the `start`
223/// weekday on or before the 1st.
224///
225/// Six rows even for a February that fits in four, so the card never changes
226/// height as you page months — a popover that resizes under the pointer moves
227/// the day you were about to click. Leading and trailing cells are real dates
228/// from the neighbouring months rather than blanks, which makes
229/// `cell.month() != month.month()` the only test a cell needs and leaves
230/// clicking one meaningful.
231pub fn month_grid(month: Date, start: Weekday) -> [Date; GRID_CELLS] {
232    let first = Date {
233        year: month.year,
234        month: month.month,
235        day: 1,
236    };
237    let origin = first.add_days(-(first.weekday().offset_from(start) as i64));
238    std::array::from_fn(|cell| origin.add_days(cell as i64))
239}
240
241// ---------------------------------------------------------------------------
242// The picker
243// ---------------------------------------------------------------------------
244
245actions!(
246    bezel_calendar,
247    [
248        PrevDay, NextDay, PrevWeek, NextWeek, PrevMonth, NextMonth, Confirm, Dismiss
249    ]
250);
251
252/// The key context the picker claims, closed as well as open — `enter` on a
253/// focused-but-closed picker opens it, the way a focused button presses.
254pub const KEY_CONTEXT: &str = "Calendar";
255
256/// Install the picker's bindings. Call once, alongside [`crate::input::init`].
257///
258/// Optional like every other `init` here: the actions are public, so an app
259/// that wants different keys binds those instead. Arrows walk days and weeks
260/// because the grid is two-dimensional, and `pageup`/`pagedown` page months —
261/// the chords a browser's own date input uses.
262pub fn init(cx: &mut App) {
263    let ctx = Some(KEY_CONTEXT);
264    cx.bind_keys([
265        KeyBinding::new("left", PrevDay, ctx),
266        KeyBinding::new("right", NextDay, ctx),
267        KeyBinding::new("up", PrevWeek, ctx),
268        KeyBinding::new("down", NextWeek, ctx),
269        KeyBinding::new("pageup", PrevMonth, ctx),
270        KeyBinding::new("pagedown", NextMonth, ctx),
271        KeyBinding::new("enter", Confirm, ctx),
272        KeyBinding::new("space", Confirm, ctx),
273        KeyBinding::new("escape", Dismiss, ctx),
274    ]);
275}
276
277/// What the picker reports. Emitted on choosing a day, never on merely moving
278/// the cursor over one.
279#[derive(Clone, Copy, Debug, PartialEq, Eq)]
280pub enum CalendarEvent {
281    Selected(Date),
282}
283
284pub struct Calendar {
285    /// The app's, not a clock's: bezel has no time source, and the only thing
286    /// that knows which day it is where you are is the app.
287    today: Date,
288    selected: Option<Date>,
289    /// The keyboard cursor — and, by its own month, the month on screen. One
290    /// value rather than two, so walking off the end of a month and paging to
291    /// the next are the same operation and cannot disagree about where you are.
292    cursor: Date,
293    menu: popover::Popup<()>,
294    placeholder: SharedString,
295    focus_handle: FocusHandle,
296    week_start: Weekday,
297}
298
299impl EventEmitter<CalendarEvent> for Calendar {}
300
301impl Calendar {
302    pub fn new(today: Date, cx: &mut Context<Self>) -> Self {
303        Self {
304            today,
305            selected: None,
306            cursor: today,
307            menu: popover::Popup::default(),
308            placeholder: SharedString::from("Pick a date"),
309            // One stop per picker, like the combobox: the grid is keyboard-
310            // driven from here, so nothing inside it takes focus of its own.
311            focus_handle: cx.focus_handle().tab_stop(true),
312            week_start: Weekday::Monday,
313        }
314    }
315
316    /// The date a form field starts on.
317    pub fn with_selection(mut self, date: Date) -> Self {
318        self.selected = Some(date);
319        self.cursor = date;
320        self
321    }
322
323    pub fn with_week_start(mut self, start: Weekday) -> Self {
324        self.week_start = start;
325        self
326    }
327
328    pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
329        self.placeholder = placeholder.into();
330        self
331    }
332
333    pub fn selection(&self) -> Option<Date> {
334        self.selected
335    }
336
337    fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
338        // The note was taken on mouse-down: a menu mounted then means this
339        // click is the dismissal, not a fresh open.
340        if self.menu.take_press_was_open() {
341            self.close(cx);
342        } else {
343            self.open(window, cx);
344        }
345    }
346
347    fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
348        // Reopening lands where the value is, not where you last wandered.
349        self.cursor = self.selected.unwrap_or(self.today);
350        self.menu.open(());
351        window.focus(&self.focus_handle, cx);
352        cx.notify();
353    }
354
355    fn close(&mut self, cx: &mut Context<Self>) {
356        if self.menu.begin_close() {
357            popover::reap_popup(cx, |calendar: &mut Self| &mut calendar.menu);
358        }
359        cx.notify();
360    }
361
362    fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
363        self.selected = Some(date);
364        self.cursor = date;
365        cx.emit(CalendarEvent::Selected(date));
366        self.close(cx);
367    }
368
369    /// Move the cursor, if there is one to move. Every arrow lands here, and
370    /// they differ only in how many days — a week is seven of them, and a month
371    /// is the one step that is not a fixed number of days at all.
372    fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
373        if self.menu.is_open() {
374            self.cursor = self.cursor.add_days(days);
375            cx.notify();
376        }
377    }
378
379    fn page(&mut self, months: i32, cx: &mut Context<Self>) {
380        if self.menu.is_open() {
381            self.cursor = self.cursor.add_months(months);
382            cx.notify();
383        }
384    }
385
386    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
387        // Closed, `enter` opens — the same key means "act on this control"
388        // either way, which is what makes it reachable by keyboard at all.
389        if self.menu.is_open() {
390            self.choose(self.cursor, cx);
391        } else {
392            self.open(window, cx);
393        }
394    }
395
396    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
397        self.close(cx);
398    }
399
400    fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
401        let month = self.cursor;
402        let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
403        let grid = month_grid(month, self.week_start);
404
405        popover::popover_card(theme)
406            .p(px(8.0))
407            .gap(px(6.0))
408            .flex()
409            .flex_col()
410            .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
411            .child(
412                div()
413                    .flex()
414                    .flex_row()
415                    .items_center()
416                    .justify_between()
417                    .child(
418                        month_step(theme, icons::ALT_ARROW_LEFT)
419                            .id("calendar-prev")
420                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
421                    )
422                    .child(
423                        div()
424                            .flex_1()
425                            .text_align(gpui::TextAlign::Center)
426                            .text_size(px(13.0))
427                            .font_weight(gpui::FontWeight::MEDIUM)
428                            .text_color(theme.text)
429                            .child(SharedString::from(heading)),
430                    )
431                    .child(
432                        month_step(theme, icons::ALT_ARROW_RIGHT)
433                            .id("calendar-next")
434                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
435                    ),
436            )
437            .child(
438                div()
439                    .flex()
440                    .flex_row()
441                    .children(weekday_labels(self.week_start).map(|label| {
442                        div()
443                            .w(px(CELL))
444                            .text_align(gpui::TextAlign::Center)
445                            .text_size(px(10.5))
446                            .text_color(theme.text_faint)
447                            .child(SharedString::from(label))
448                    })),
449            )
450            .children(grid.chunks(7).enumerate().map(|(row, week)| {
451                div()
452                    .flex()
453                    .flex_row()
454                    .children(week.iter().enumerate().map(|(column, &day)| {
455                        let cell = row * 7 + column;
456                        day_cell(
457                            theme,
458                            day,
459                            day.month() == month.month(),
460                            Some(day) == self.selected,
461                            day == self.today,
462                            day == self.cursor,
463                        )
464                        .id(SharedString::from(format!("day-{cell}")))
465                        .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
466                    }))
467            }))
468            .into_any_element()
469    }
470}
471
472/// Side of a square day cell, and of a weekday heading above it.
473const CELL: f32 = 30.0;
474
475/// A month arrow in the card's header.
476fn month_step(theme: &Theme, icon: &'static str) -> gpui::Div {
477    div()
478        .size(px(24.0))
479        .rounded(px(Theme::control_radius()))
480        .flex()
481        .items_center()
482        .justify_center()
483        .cursor_pointer()
484        .hover(|s| s.bg(ink(0.06)))
485        .child(
486            icons::icon(icon)
487                .size(px(14.0))
488                .text_color(theme.text_muted),
489        )
490}
491
492/// One day. The four states are carried by four different properties on
493/// purpose, so no two of them can collide: the fill says selected, the text
494/// tone says today or another month, the border says where the keyboard is, and
495/// the wash says where the pointer is.
496fn day_cell(
497    theme: &Theme,
498    day: Date,
499    in_month: bool,
500    selected: bool,
501    is_today: bool,
502    cursor: bool,
503) -> gpui::Div {
504    let text = match (selected, in_month, is_today) {
505        (true, _, _) => theme.on_accent,
506        (_, _, true) => theme.accent,
507        (_, true, _) => theme.text,
508        (_, false, _) => theme.text_faint,
509    };
510    div()
511        .size(px(CELL))
512        .rounded(px(7.0))
513        .flex()
514        .items_center()
515        .justify_center()
516        .text_size(px(12.5))
517        .text_color(text)
518        .when(selected, |cell| {
519            cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
520        })
521        // The ring slot again: always a border, so moving the cursor across the
522        // grid can never nudge a single cell by a pixel.
523        .border_1()
524        .border_color(if cursor {
525            theme.ring
526        } else {
527            widgets::RING_SLOT
528        })
529        .cursor_pointer()
530        .hover(|s| s.bg(if selected { theme.accent } else { ink(0.06) }))
531        .child(SharedString::from(day.day().to_string()))
532}
533
534impl Focusable for Calendar {
535    fn focus_handle(&self, _: &App) -> FocusHandle {
536        self.focus_handle.clone()
537    }
538}
539
540impl Render for Calendar {
541    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
542        let theme = Theme::of(cx).clone();
543        let open = self.menu.is_open() || self.menu.is_closing();
544        let label = match self.selected {
545            Some(date) => SharedString::from(date.to_string()),
546            None => self.placeholder.clone(),
547        };
548        let card = open.then(|| self.card(&theme, cx));
549
550        div()
551            .key_context(KEY_CONTEXT)
552            .track_focus(&self.focus_handle)
553            .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
554            .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
555            .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
556            .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
557            .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
558            .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
559            .on_action(cx.listener(Self::confirm))
560            .on_action(cx.listener(Self::dismiss))
561            .relative()
562            .w_full()
563            .child(
564                div()
565                    .id("calendar-trigger")
566                    .on_mouse_down(
567                        gpui::MouseButton::Left,
568                        cx.listener(|calendar, _, _, _| calendar.menu.note_trigger_press()),
569                    )
570                    .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
571                    .child(theme.select_trigger(label, open)),
572            )
573            .when_some(card, |trigger, card| {
574                trigger.child(popover::anchored_menu_below(
575                    "calendar-menu",
576                    card,
577                    self.menu.closing_since(),
578                ))
579            })
580    }
581}