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::{TextStyle, Theme, Typeset};
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        popover::close_popup(self, cx, |calendar: &mut Self| &mut calendar.menu);
357        cx.notify();
358    }
359
360    fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
361        self.selected = Some(date);
362        self.cursor = date;
363        cx.emit(CalendarEvent::Selected(date));
364        self.close(cx);
365    }
366
367    /// Move the cursor, if there is one to move. Every arrow lands here, and
368    /// they differ only in how many days — a week is seven of them, and a month
369    /// is the one step that is not a fixed number of days at all.
370    fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
371        if self.menu.is_open() {
372            self.cursor = self.cursor.add_days(days);
373            cx.notify();
374        }
375    }
376
377    fn page(&mut self, months: i32, cx: &mut Context<Self>) {
378        if self.menu.is_open() {
379            self.cursor = self.cursor.add_months(months);
380            cx.notify();
381        }
382    }
383
384    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
385        // Closed, `enter` opens — the same key means "act on this control"
386        // either way, which is what makes it reachable by keyboard at all.
387        if self.menu.is_open() {
388            self.choose(self.cursor, cx);
389        } else {
390            self.open(window, cx);
391        }
392    }
393
394    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
395        self.close(cx);
396    }
397
398    fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
399        let month = self.cursor;
400        let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
401        let grid = month_grid(month, self.week_start);
402
403        popover::popover_card(theme)
404            .p(px(8.0))
405            .gap(px(6.0))
406            .flex()
407            .flex_col()
408            .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
409            .child(
410                div()
411                    .flex()
412                    .flex_row()
413                    .items_center()
414                    .justify_between()
415                    .child(
416                        month_step(theme, icons::glyph::ChevronLeft)
417                            .id("calendar-prev")
418                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
419                    )
420                    .child(
421                        div()
422                            .flex_1()
423                            .text_align(gpui::TextAlign::Center)
424                            .text_style(TextStyle::Headline)
425                            .text_color(theme.text)
426                            .child(SharedString::from(heading)),
427                    )
428                    .child(
429                        month_step(theme, icons::glyph::ChevronRight)
430                            .id("calendar-next")
431                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
432                    ),
433            )
434            .child(
435                div()
436                    .flex()
437                    .flex_row()
438                    .children(weekday_labels(self.week_start).map(|label| {
439                        div()
440                            .w(px(CELL))
441                            .text_align(gpui::TextAlign::Center)
442                            .text_style(TextStyle::Caption)
443                            .text_color(theme.text_faint)
444                            .child(SharedString::from(label))
445                    })),
446            )
447            .children(grid.chunks(7).enumerate().map(|(row, week)| {
448                div()
449                    .flex()
450                    .flex_row()
451                    .children(week.iter().enumerate().map(|(column, &day)| {
452                        let cell = row * 7 + column;
453                        day_cell(
454                            theme,
455                            day,
456                            day.month() == month.month(),
457                            Some(day) == self.selected,
458                            day == self.today,
459                            day == self.cursor,
460                        )
461                        .id(SharedString::from(format!("day-{cell}")))
462                        .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
463                    }))
464            }))
465            .into_any_element()
466    }
467}
468
469/// Side of a square day cell, and of a weekday heading above it.
470const CELL: f32 = 30.0;
471
472/// A month arrow in the card's header.
473fn month_step(theme: &Theme, icon: &'static [u8]) -> gpui::Div {
474    div()
475        .size(px(24.0))
476        .rounded(px(Theme::control_radius()))
477        .flex()
478        .items_center()
479        .justify_center()
480        .cursor_pointer()
481        .hover(|s| s.bg(theme.element_hover))
482        .child(
483            icons::icon(icon)
484                .size(px(14.0))
485                .text_color(theme.text_muted),
486        )
487}
488
489/// One day. The four states are carried by four different properties on
490/// purpose, so no two of them can collide: the fill says selected, the text
491/// tone says today or another month, the border says where the keyboard is, and
492/// the wash says where the pointer is.
493fn day_cell(
494    theme: &Theme,
495    day: Date,
496    in_month: bool,
497    selected: bool,
498    is_today: bool,
499    cursor: bool,
500) -> gpui::Div {
501    let text = match (selected, in_month, is_today) {
502        (true, _, _) => theme.on_accent,
503        (_, _, true) => theme.accent,
504        (_, true, _) => theme.text,
505        (_, false, _) => theme.text_faint,
506    };
507    div()
508        .size(px(CELL))
509        .rounded(px(7.0))
510        .flex()
511        .items_center()
512        .justify_center()
513        .text_style(TextStyle::Callout)
514        .text_color(text)
515        .when(selected, |cell| {
516            cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
517        })
518        // The ring slot again: always a border, so moving the cursor across the
519        // grid can never nudge a single cell by a pixel.
520        .border_1()
521        .border_color(if cursor {
522            theme.ring
523        } else {
524            widgets::RING_SLOT
525        })
526        .cursor_pointer()
527        .hover(|s| {
528            s.bg(if selected {
529                theme.accent
530            } else {
531                theme.element_hover
532            })
533        })
534        .child(SharedString::from(day.day().to_string()))
535}
536
537impl Focusable for Calendar {
538    fn focus_handle(&self, _: &App) -> FocusHandle {
539        self.focus_handle.clone()
540    }
541}
542
543impl Render for Calendar {
544    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
545        let theme = Theme::of(cx).clone();
546        let open = self.menu.is_open() || self.menu.is_closing();
547        let label = match self.selected {
548            Some(date) => SharedString::from(date.to_string()),
549            None => self.placeholder.clone(),
550        };
551        let card = open.then(|| self.card(&theme, cx));
552
553        div()
554            .key_context(KEY_CONTEXT)
555            .track_focus(&self.focus_handle)
556            .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
557            .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
558            .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
559            .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
560            .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
561            .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
562            .on_action(cx.listener(Self::confirm))
563            .on_action(cx.listener(Self::dismiss))
564            .relative()
565            .w_full()
566            .child(popover::trigger_press(
567                div()
568                    .id("calendar-trigger")
569                    .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
570                    .child(theme.select_trigger(label)),
571                |calendar: &mut Self| &mut calendar.menu,
572                cx,
573            ))
574            .when_some(card, |trigger, card| {
575                trigger.child(popover::anchored_menu_below(
576                    "calendar-menu",
577                    card,
578                    self.menu.closing_since(),
579                ))
580            })
581    }
582}