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 bindings — [`bindings`], bound. Call once, alongside
258/// [`crate::input::init`].
259pub fn init(cx: &mut App) {
260    cx.bind_keys(bindings());
261}
262
263/// The picker's keymap, as data, so an app can have it without having to
264/// take it — see [`crate::keys`] for layering over it or taking a chord
265/// away.
266///
267/// Arrows walk days and weeks
268/// because the grid is two-dimensional, and `pageup`/`pagedown` page months —
269/// the chords a browser's own date input uses.
270pub fn bindings() -> Vec<KeyBinding> {
271    let mut bindings = Vec::new();
272    let ctx = Some(KEY_CONTEXT);
273    bindings.extend([
274        KeyBinding::new("left", PrevDay, ctx),
275        KeyBinding::new("right", NextDay, ctx),
276        KeyBinding::new("up", PrevWeek, ctx),
277        KeyBinding::new("down", NextWeek, ctx),
278        KeyBinding::new("pageup", PrevMonth, ctx),
279        KeyBinding::new("pagedown", NextMonth, ctx),
280        KeyBinding::new("enter", Confirm, ctx),
281        KeyBinding::new("space", Confirm, ctx),
282        KeyBinding::new("escape", Dismiss, ctx),
283    ]);
284
285    bindings
286}
287
288/// What the picker reports. Emitted on choosing a day, never on merely moving
289/// the cursor over one.
290#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291pub enum CalendarEvent {
292    Selected(Date),
293}
294
295pub struct Calendar {
296    /// The app's, not a clock's: bezel has no time source, and the only thing
297    /// that knows which day it is where you are is the app.
298    today: Date,
299    selected: Option<Date>,
300    /// The keyboard cursor — and, by its own month, the month on screen. One
301    /// value rather than two, so walking off the end of a month and paging to
302    /// the next are the same operation and cannot disagree about where you are.
303    cursor: Date,
304    menu: popover::Popup<()>,
305    placeholder: SharedString,
306    focus_handle: FocusHandle,
307    week_start: Weekday,
308}
309
310impl EventEmitter<CalendarEvent> for Calendar {}
311
312impl Calendar {
313    pub fn new(today: Date, cx: &mut Context<Self>) -> Self {
314        Self {
315            today,
316            selected: None,
317            cursor: today,
318            menu: popover::Popup::default(),
319            placeholder: SharedString::from("Pick a date"),
320            // One stop per picker, like the combobox: the grid is keyboard-
321            // driven from here, so nothing inside it takes focus of its own.
322            focus_handle: cx.focus_handle().tab_stop(true),
323            week_start: Weekday::Monday,
324        }
325    }
326
327    /// The date a form field starts on.
328    pub fn with_selection(mut self, date: Date) -> Self {
329        self.selected = Some(date);
330        self.cursor = date;
331        self
332    }
333
334    pub fn with_week_start(mut self, start: Weekday) -> Self {
335        self.week_start = start;
336        self
337    }
338
339    pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
340        self.placeholder = placeholder.into();
341        self
342    }
343
344    pub fn selection(&self) -> Option<Date> {
345        self.selected
346    }
347
348    fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
349        // The note was taken on mouse-down: a menu mounted then means this
350        // click is the dismissal, not a fresh open.
351        if self.menu.take_press_was_open() {
352            self.close(cx);
353        } else {
354            self.open(window, cx);
355        }
356    }
357
358    fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
359        // Reopening lands where the value is, not where you last wandered.
360        self.cursor = self.selected.unwrap_or(self.today);
361        self.menu.open(());
362        window.focus(&self.focus_handle, cx);
363        cx.notify();
364    }
365
366    fn close(&mut self, cx: &mut Context<Self>) {
367        popover::close_popup(self, cx, |calendar: &mut Self| &mut calendar.menu);
368        cx.notify();
369    }
370
371    fn choose(&mut self, date: Date, cx: &mut Context<Self>) {
372        self.selected = Some(date);
373        self.cursor = date;
374        cx.emit(CalendarEvent::Selected(date));
375        self.close(cx);
376    }
377
378    /// Move the cursor, if there is one to move. Every arrow lands here, and
379    /// they differ only in how many days — a week is seven of them, and a month
380    /// is the one step that is not a fixed number of days at all.
381    fn walk(&mut self, days: i64, cx: &mut Context<Self>) {
382        if self.menu.is_open() {
383            self.cursor = self.cursor.add_days(days);
384            cx.notify();
385        }
386    }
387
388    fn page(&mut self, months: i32, cx: &mut Context<Self>) {
389        if self.menu.is_open() {
390            self.cursor = self.cursor.add_months(months);
391            cx.notify();
392        }
393    }
394
395    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
396        // Closed, `enter` opens — the same key means "act on this control"
397        // either way, which is what makes it reachable by keyboard at all.
398        if self.menu.is_open() {
399            self.choose(self.cursor, cx);
400        } else {
401            self.open(window, cx);
402        }
403    }
404
405    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
406        self.close(cx);
407    }
408
409    fn card(&self, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
410        let month = self.cursor;
411        let heading = format!("{} {}", MONTHS[month.month() as usize - 1], month.year());
412        let grid = month_grid(month, self.week_start);
413
414        popover::popover_card(theme)
415            .p(px(8.0))
416            .gap(px(6.0))
417            .flex()
418            .flex_col()
419            .on_mouse_down_out(cx.listener(|calendar, _, _, cx| calendar.close(cx)))
420            .child(
421                div()
422                    .flex()
423                    .flex_row()
424                    .items_center()
425                    .justify_between()
426                    .child(
427                        month_step(theme, icons::glyph::ChevronLeft)
428                            .id("calendar-prev")
429                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(-1, cx))),
430                    )
431                    .child(
432                        div()
433                            .flex_1()
434                            .text_align(gpui::TextAlign::Center)
435                            .text_style(TextStyle::Headline)
436                            .text_color(theme.text)
437                            .child(SharedString::from(heading)),
438                    )
439                    .child(
440                        month_step(theme, icons::glyph::ChevronRight)
441                            .id("calendar-next")
442                            .on_click(cx.listener(|calendar, _, _, cx| calendar.page(1, cx))),
443                    ),
444            )
445            .child(
446                div()
447                    .flex()
448                    .flex_row()
449                    .children(weekday_labels(self.week_start).map(|label| {
450                        div()
451                            .w(px(CELL))
452                            .text_align(gpui::TextAlign::Center)
453                            .text_style(TextStyle::Caption)
454                            .text_color(theme.text_faint)
455                            .child(SharedString::from(label))
456                    })),
457            )
458            .children(grid.chunks(7).enumerate().map(|(row, week)| {
459                div()
460                    .flex()
461                    .flex_row()
462                    .children(week.iter().enumerate().map(|(column, &day)| {
463                        let cell = row * 7 + column;
464                        day_cell(
465                            theme,
466                            day,
467                            day.month() == month.month(),
468                            Some(day) == self.selected,
469                            day == self.today,
470                            day == self.cursor,
471                        )
472                        .id(SharedString::from(format!("day-{cell}")))
473                        .on_click(cx.listener(move |calendar, _, _, cx| calendar.choose(day, cx)))
474                    }))
475            }))
476            .into_any_element()
477    }
478}
479
480/// Side of a square day cell, and of a weekday heading above it.
481const CELL: f32 = 30.0;
482
483/// A month arrow in the card's header.
484fn month_step(theme: &Theme, icon: impl Into<Icon>) -> gpui::Div {
485    div()
486        .size(px(24.0))
487        .rounded(px(Theme::control_radius()))
488        .flex()
489        .items_center()
490        .justify_center()
491        .cursor_pointer()
492        .hover(|s| s.bg(theme.element_hover))
493        .child(
494            icons::icon(icon)
495                .size(px(14.0))
496                .text_color(theme.text_muted),
497        )
498}
499
500/// One day. The four states are carried by four different properties on
501/// purpose, so no two of them can collide: the fill says selected, the text
502/// tone says today or another month, the border says where the keyboard is, and
503/// the wash says where the pointer is.
504fn day_cell(
505    theme: &Theme,
506    day: Date,
507    in_month: bool,
508    selected: bool,
509    is_today: bool,
510    cursor: bool,
511) -> gpui::Div {
512    let text = match (selected, in_month, is_today) {
513        (true, _, _) => theme.on_accent,
514        (_, _, true) => theme.accent,
515        (_, true, _) => theme.text,
516        (_, false, _) => theme.text_faint,
517    };
518    div()
519        .size(px(CELL))
520        .rounded(px(7.0))
521        .flex()
522        .items_center()
523        .justify_center()
524        .text_style(TextStyle::Callout)
525        .text_color(text)
526        .when(selected, |cell| {
527            cell.bg(theme.accent).font_weight(gpui::FontWeight::MEDIUM)
528        })
529        // The ring slot again: always a border, so moving the cursor across the
530        // grid can never nudge a single cell by a pixel.
531        .border_1()
532        .border_color(if cursor {
533            theme.ring
534        } else {
535            widgets::RING_SLOT
536        })
537        .cursor_pointer()
538        .hover(|s| {
539            s.bg(if selected {
540                theme.accent
541            } else {
542                theme.element_hover
543            })
544        })
545        .child(SharedString::from(day.day().to_string()))
546}
547
548impl Focusable for Calendar {
549    fn focus_handle(&self, _: &App) -> FocusHandle {
550        self.focus_handle.clone()
551    }
552}
553
554impl Render for Calendar {
555    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
556        let theme = Theme::of(cx).clone();
557        let open = self.menu.is_open() || self.menu.is_closing();
558        let label = match self.selected {
559            Some(date) => SharedString::from(date.to_string()),
560            None => self.placeholder.clone(),
561        };
562        let card = open.then(|| self.card(&theme, cx));
563
564        div()
565            .key_context(KEY_CONTEXT)
566            .track_focus(&self.focus_handle)
567            .on_action(cx.listener(|calendar, _: &PrevDay, _, cx| calendar.walk(-1, cx)))
568            .on_action(cx.listener(|calendar, _: &NextDay, _, cx| calendar.walk(1, cx)))
569            .on_action(cx.listener(|calendar, _: &PrevWeek, _, cx| calendar.walk(-7, cx)))
570            .on_action(cx.listener(|calendar, _: &NextWeek, _, cx| calendar.walk(7, cx)))
571            .on_action(cx.listener(|calendar, _: &PrevMonth, _, cx| calendar.page(-1, cx)))
572            .on_action(cx.listener(|calendar, _: &NextMonth, _, cx| calendar.page(1, cx)))
573            .on_action(cx.listener(Self::confirm))
574            .on_action(cx.listener(Self::dismiss))
575            .relative()
576            .w_full()
577            .child(popover::trigger_press(
578                div()
579                    .id("calendar-trigger")
580                    .on_click(cx.listener(|calendar, _, window, cx| calendar.toggle(window, cx)))
581                    .child(theme.select_trigger(label)),
582                |calendar: &mut Self| &mut calendar.menu,
583                cx,
584            ))
585            .when_some(card, |trigger, card| {
586                trigger.child(popover::anchored_menu_below(
587                    "calendar-menu",
588                    card,
589                    self.menu.closing_since(),
590                ))
591            })
592    }
593}