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