Skip to main content

gpui_kit/datetime/
calendar.rs

1//! A month grid over a host-owned calendar.
2//!
3//! Every fact on screen — the weekday headings, the month name, which cells
4//! hold days, what a day is called, whether it may be picked, and what day it
5//! is — comes from the [`DateAdapter`](crate::datetime::DateAdapter). The
6//! calendar owns the month it is
7//! looking at, where the keyboard is, and what the pointer is over.
8
9use std::rc::Rc;
10
11use gpui::{
12    AnimationElement, AnimationExt, AnyElement, App, Context, ElementId, EventEmitter, FocusHandle,
13    Focusable, InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, SharedString,
14    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
15};
16use gpui_kit_assets::Icon;
17use gpui_kit_semantics::{NodeSpec, Role, Semantic};
18use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale};
19
20use crate::strings::{ActiveStrings, StringKey};
21
22use crate::controls::button::IconButton;
23use crate::datetime::adapter::{Day, MonthCell, MonthGrid, MonthKey, SharedDateAdapter};
24use crate::datetime::range::DayRange;
25use crate::display::badge::Tone;
26use crate::display::empty::{EmptyKind, EmptyState};
27use crate::foundation::direction::ActiveDirection;
28use crate::foundation::{
29    Disableable, FocusRing, Ident, Sizable, StyledExt, text as foundation_text,
30};
31use crate::motion;
32use crate::overlay::Tooltipped;
33
34/// How wide one day cell is, and how tall. This geometry occurs once, so it
35/// stays beside the component rather than in the token document.
36const CELL_SIZE: f32 = 34.0;
37
38/// How far a month slides as it arrives.
39const MONTH_TRAVEL: f32 = 12.0;
40
41/// A mark the host puts on a day, such as "three runs finished here".
42///
43/// The calendar draws the dot and publishes the wording; what it means is the
44/// host's business.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct DayMark {
47    pub label: SharedString,
48    pub tone: Tone,
49}
50
51impl DayMark {
52    pub fn new(label: impl Into<SharedString>) -> Self {
53        Self {
54            label: label.into(),
55            tone: Tone::Accent,
56        }
57    }
58
59    pub fn tone(mut self, tone: Tone) -> Self {
60        self.tone = tone;
61        self
62    }
63}
64
65type Overlay = Rc<dyn Fn(Day) -> Option<DayMark>>;
66
67/// What a calendar reports. The owner decides what any of it means.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum CalendarEvent {
70    /// A day was asked for. The selection stays the caller's.
71    Picked(Day),
72    /// The month on screen changed, always through
73    /// [`DateAdapter::shift_month`](crate::datetime::DateAdapter::shift_month).
74    MonthShown(MonthKey),
75    /// The pointer moved onto or off a day, which is what a range preview
76    /// follows.
77    Hovered(Option<Day>),
78}
79
80impl EventEmitter<CalendarEvent> for Calendar {}
81
82/// A month of days.
83pub struct Calendar {
84    ident: Ident,
85    focus_handle: FocusHandle,
86    adapter: SharedDateAdapter,
87    selection: Vec<Day>,
88    multi: bool,
89    /// The month the caller asked to open on, if any.
90    requested_month: Option<MonthKey>,
91    /// The month navigation has moved to. Until something navigates, the
92    /// month is resolved from the selection, then from today, and if neither
93    /// is known the calendar says so instead of picking one.
94    month: Option<MonthKey>,
95    cursor: Option<Day>,
96    hovered: Option<Day>,
97    range: Option<DayRange>,
98    overlay: Option<Overlay>,
99    disabled: bool,
100    /// Which way the last navigation travelled, and how many have happened.
101    /// The count keys the arrival animation; zero means the first frame, which
102    /// arrives without motion so a capture of a settled calendar is settled.
103    travel: i32,
104    navigations: usize,
105}
106
107impl std::fmt::Debug for Calendar {
108    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        formatter
110            .debug_struct("Calendar")
111            .field("ident", &self.ident)
112            .field("selection", &self.selection)
113            .field("multi", &self.multi)
114            .field("month", &self.month)
115            .field("cursor", &self.cursor)
116            .field("disabled", &self.disabled)
117            .finish()
118    }
119}
120
121impl Calendar {
122    pub fn new(
123        ident: impl Into<Ident>,
124        adapter: SharedDateAdapter,
125        _window: &mut Window,
126        cx: &mut Context<Self>,
127    ) -> Self {
128        Self {
129            ident: ident.into(),
130            focus_handle: cx.focus_handle(),
131            adapter,
132            selection: Vec::new(),
133            multi: false,
134            requested_month: None,
135            month: None,
136            cursor: None,
137            hovered: None,
138            range: None,
139            overlay: None,
140            disabled: false,
141            travel: 0,
142            navigations: 0,
143        }
144    }
145
146    /// Seeds the days the caller says are chosen.
147    pub fn selected(mut self, days: impl IntoIterator<Item = Day>) -> Self {
148        self.selection = days.into_iter().collect();
149        self
150    }
151
152    /// Whether more than one day may be chosen. The calendar reports either
153    /// way; this only decides what it draws and publishes.
154    pub fn multi(mut self, multi: bool) -> Self {
155        self.multi = multi;
156        self
157    }
158
159    /// The month to open on, for a calendar whose host knows where it wants
160    /// to start but has nothing selected and no today.
161    pub fn month(mut self, month: MonthKey) -> Self {
162        self.requested_month = Some(month);
163        self
164    }
165
166    /// Marks days with a dot the host supplies.
167    pub fn overlay(mut self, overlay: impl Fn(Day) -> Option<DayMark> + 'static) -> Self {
168        self.overlay = Some(Rc::new(overlay));
169        self
170    }
171
172    /// Draws a range across the grid, with its endpoints picked out.
173    pub fn range(mut self, range: DayRange) -> Self {
174        self.range = Some(range);
175        self
176    }
177
178    pub fn set_selection(&mut self, days: Vec<Day>, cx: &mut Context<Self>) {
179        if self.selection == days {
180            return;
181        }
182        self.selection = days;
183        cx.notify();
184    }
185
186    pub fn set_overlay(
187        &mut self,
188        overlay: impl Fn(Day) -> Option<DayMark> + 'static,
189        cx: &mut Context<Self>,
190    ) {
191        self.overlay = Some(Rc::new(overlay));
192        cx.notify();
193    }
194
195    /// A `RangePicker` pushes its caller's range down on every frame, so this
196    /// has to be quiet when nothing moved or the two of them redraw each other
197    /// forever.
198    pub fn set_range(&mut self, range: Option<DayRange>, cx: &mut Context<Self>) {
199        if self.range == range {
200            return;
201        }
202        self.range = range;
203        cx.notify();
204    }
205
206    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
207        if self.disabled == disabled {
208            return;
209        }
210        self.disabled = disabled;
211        cx.notify();
212    }
213
214    /// Pins what the pointer is over, so a range preview can be staged for
215    /// review without a pointer in the window.
216    pub fn set_hovered_day(&mut self, day: Option<Day>, cx: &mut Context<Self>) {
217        self.hover(day, cx);
218    }
219
220    pub fn adapter(&self) -> &SharedDateAdapter {
221        &self.adapter
222    }
223
224    pub fn selection(&self) -> &[Day] {
225        &self.selection
226    }
227
228    pub fn cursor(&self) -> Option<Day> {
229        self.cursor
230    }
231
232    pub fn hovered_day(&self) -> Option<Day> {
233        self.hovered
234    }
235
236    /// The month on screen, or `None` when nothing has established one.
237    ///
238    /// Navigation, then the caller's month, then the first selected day, then
239    /// today. A calendar with none of those does not invent one.
240    pub fn shown_month(&self) -> Option<MonthKey> {
241        if let Some(month) = self.month {
242            return Some(month);
243        }
244        if let Some(month) = self.requested_month {
245            return Some(month);
246        }
247        if let Some(day) = self.selection.first() {
248            return Some(self.adapter.month_of(*day));
249        }
250        self.adapter
251            .today()
252            .map(|today| self.adapter.month_of(today))
253    }
254
255    /// Moves the month through the adapter. Nothing here adds or subtracts.
256    pub fn shift(&mut self, delta: i32, cx: &mut Context<Self>) {
257        let Some(current) = self.shown_month() else {
258            return;
259        };
260        let Some(next) = self.adapter.shift_month(current, delta) else {
261            return;
262        };
263        self.month = Some(next);
264        self.travel = delta.signum();
265        self.navigations += 1;
266        cx.emit(CalendarEvent::MonthShown(next));
267        cx.notify();
268    }
269
270    pub fn show_month(&mut self, month: MonthKey, cx: &mut Context<Self>) {
271        self.month = Some(month);
272        self.navigations += 1;
273        cx.emit(CalendarEvent::MonthShown(month));
274        cx.notify();
275    }
276
277    fn grid(&self) -> Option<MonthGrid> {
278        self.shown_month()
279            .map(|month| self.adapter.month_grid(month))
280    }
281
282    fn pick(&mut self, day: Day, cx: &mut Context<Self>) {
283        if self.disabled || !self.adapter.is_selectable(day).is_selectable() {
284            return;
285        }
286        self.cursor = Some(day);
287        cx.emit(CalendarEvent::Picked(day));
288        cx.notify();
289    }
290
291    fn hover(&mut self, day: Option<Day>, cx: &mut Context<Self>) {
292        if self.hovered == day {
293            return;
294        }
295        self.hovered = day;
296        cx.emit(CalendarEvent::Hovered(day));
297        cx.notify();
298    }
299
300    /// Where the keyboard is, or where it would enter.
301    fn anchor(&self, grid: &MonthGrid) -> Option<Day> {
302        if let Some(cursor) = self.cursor
303            && grid.position_of(cursor).is_some()
304        {
305            return Some(cursor);
306        }
307        self.selection
308            .iter()
309            .copied()
310            .find(|day| grid.position_of(*day).is_some())
311            .or_else(|| {
312                self.adapter
313                    .today()
314                    .filter(|today| grid.position_of(*today).is_some())
315            })
316            .or_else(|| first_day(grid))
317    }
318
319    /// Moves the cursor one day, one week, or to the end of a week.
320    ///
321    /// Stepping off the grid moves to the neighbouring month through the
322    /// adapter and lands on the cell the travel would have reached.
323    fn move_cursor(&mut self, motion: Motion, cx: &mut Context<Self>) {
324        let Some(grid) = self.grid() else {
325            return;
326        };
327        let Some(from) = self.anchor(&grid) else {
328            return;
329        };
330        // The first movement on a calendar the keyboard has not been in yet
331        // places the cursor rather than moving it, so nothing is skipped over.
332        if self.cursor.is_none() {
333            self.cursor = Some(from);
334            cx.notify();
335            return;
336        }
337        self.cursor = Some(from);
338        match step(&grid, from, motion) {
339            Step::To(day) => {
340                self.cursor = Some(day);
341                cx.notify();
342            }
343            Step::OffGrid { delta, landing } => {
344                let Some(current) = self.shown_month() else {
345                    return;
346                };
347                let Some(next) = self.adapter.shift_month(current, delta) else {
348                    return;
349                };
350                let grid = self.adapter.month_grid(next);
351                self.month = Some(next);
352                self.travel = delta.signum();
353                self.navigations += 1;
354                self.cursor = landing.resolve(&grid);
355                cx.emit(CalendarEvent::MonthShown(next));
356                cx.notify();
357            }
358            Step::Nowhere => {}
359        }
360    }
361
362    fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
363        if self.disabled {
364            return;
365        }
366        // A month grid is laid out in reading order, so the horizontal arrows
367        // step through the calendar, not across the screen: the arrow that
368        // reaches yesterday is the one pointing back the way the row was
369        // written.
370        let direction = cx.layout_direction();
371        let key = event.keystroke.key.as_str();
372        let motion = match direction.arrow_step(key) {
373            Some(1) => Motion::NextDay,
374            Some(_) => Motion::PreviousDay,
375            None => match key {
376                "up" => Motion::PreviousWeek,
377                "down" => Motion::NextWeek,
378                "home" => Motion::WeekStart,
379                "end" => Motion::WeekEnd,
380                "pageup" => {
381                    self.shift(-1, cx);
382                    cx.stop_propagation();
383                    return;
384                }
385                "pagedown" => {
386                    self.shift(1, cx);
387                    cx.stop_propagation();
388                    return;
389                }
390                "enter" | "space" => {
391                    if let Some(day) = self.cursor {
392                        self.pick(day, cx);
393                    } else if let Some(grid) = self.grid()
394                        && let Some(day) = self.anchor(&grid)
395                    {
396                        self.cursor = Some(day);
397                        cx.notify();
398                    }
399                    cx.stop_propagation();
400                    return;
401                }
402                _ => return,
403            },
404        };
405        self.move_cursor(motion, cx);
406        cx.stop_propagation();
407    }
408
409    fn header(&self, month: Option<MonthKey>, cx: &mut Context<Self>) -> AnyElement {
410        let theme = cx.theme().clone();
411        let label = month
412            .map(|month| self.adapter.month_label(month))
413            .unwrap_or_else(|| cx.strings().text(StringKey::CalendarNoMonth));
414        let can_move = month.is_some() && !self.disabled;
415        let calendar = cx.entity().downgrade();
416        let backward = calendar.clone();
417
418        div()
419            .row()
420            .w_full()
421            .gap_token(&theme, Space::Sm)
422            .child(
423                IconButton::new(
424                    self.ident.child("previous"),
425                    Icon::AltArrowLeft,
426                    cx.strings().text(StringKey::CalendarPreviousMonth),
427                )
428                .ghost()
429                .small()
430                .semantic_parent(self.ident.semantic_id())
431                .disabled(!can_move)
432                .on_click(move |_window, cx| {
433                    backward
434                        .update(cx, |calendar, cx| calendar.shift(-1, cx))
435                        .ok();
436                }),
437            )
438            .child(
439                foundation_text(&theme, TypeScale::Label, label)
440                    .flex_1()
441                    .text_align(gpui::TextAlign::Center),
442            )
443            .child(
444                IconButton::new(
445                    self.ident.child("next"),
446                    Icon::AltArrowRight,
447                    cx.strings().text(StringKey::CalendarNextMonth),
448                )
449                .ghost()
450                .small()
451                .semantic_parent(self.ident.semantic_id())
452                .disabled(!can_move)
453                .on_click(move |_window, cx| {
454                    calendar
455                        .update(cx, |calendar, cx| calendar.shift(1, cx))
456                        .ok();
457                }),
458            )
459            .into_any_element()
460    }
461
462    fn weekday_header(&self, theme: &Theme) -> AnyElement {
463        div()
464            .row()
465            .children(self.adapter.weekday_labels().into_iter().map(|label| {
466                foundation_text(theme, TypeScale::Caption, label)
467                    .w(px(CELL_SIZE))
468                    .flex_none()
469                    .text_align(gpui::TextAlign::Center)
470                    .text_tone(theme, TextTone::Faint)
471            }))
472            .into_any_element()
473    }
474
475    fn cell(&self, cell: MonthCell, cx: &mut Context<Self>) -> AnyElement {
476        let theme = cx.theme().clone();
477        let Some(day) = cell.day() else {
478            return div().size(px(CELL_SIZE)).flex_none().into_any_element();
479        };
480
481        let day_id = format!("day-{}", day.0);
482        let ident = self.ident.child(day_id);
483        let selectability = self.adapter.is_selectable(day);
484        let blocked = selectability.reason().cloned();
485        let selectable = selectability.is_selectable() && !self.disabled;
486        let selected = self.selection.contains(&day);
487        let is_today = self.adapter.today() == Some(day);
488        let cursored = self.cursor == Some(day);
489        let mark = self.overlay.as_ref().and_then(|overlay| overlay(day));
490        let banded = self.band(day);
491        let endpoint = self.is_endpoint(day);
492        let label = self.adapter.day_label(day);
493
494        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Option)
495            .parent(self.ident.child("grid").semantic_id())
496            .text(label.clone())
497            .checked(selected || endpoint)
498            .disabled(!selectable)
499            .hovered(self.hovered == Some(day));
500        if let Some(reason) = blocked.clone() {
501            spec = spec.value(reason);
502        } else if let Some(mark) = &mark {
503            spec = spec.value(mark.label.clone());
504        }
505
506        let background = if selected || endpoint {
507            Some(theme.colors.accent)
508        } else if banded {
509            Some(theme.colors.selected)
510        } else {
511            None
512        };
513
514        let cell = div()
515            .id(ident.element_id())
516            .size(px(CELL_SIZE))
517            .flex_none()
518            .flex()
519            .flex_col()
520            .items_center()
521            .justify_center()
522            .gap(px(2.0))
523            .radius(&theme, Radius::Control)
524            .when_some(background, |element, color| element.bg(color))
525            .when(is_today && !selected && !endpoint, |element| {
526                element
527                    .border(px(theme.borders.thick))
528                    .border_color(theme.colors.accent)
529            })
530            .when(cursored, |element| element.shadow(theme.focus_ring()))
531            .when(selectable, |element| {
532                element
533                    .cursor_pointer()
534                    .hover(|style| style.bg(theme.colors.hover))
535                    .on_click(cx.listener(move |calendar, _, _window, cx| {
536                        calendar.pick(day, cx);
537                    }))
538                    .on_hover(cx.listener(move |calendar, over: &bool, _window, cx| {
539                        calendar.hover(over.then_some(day), cx);
540                    }))
541            })
542            .when_some(blocked.clone(), |element, reason| {
543                element
544                    .opacity(theme.opacity.disabled)
545                    .tip(ident.clone(), reason)
546            })
547            .child(if selected || endpoint {
548                foundation_text(&theme, TypeScale::Label, label)
549                    .text_color(theme.colors.text_on_accent)
550            } else if !selectable {
551                foundation_text(&theme, TypeScale::Label, label).text_tone(&theme, TextTone::Faint)
552            } else if cell.is_adjacent() {
553                foundation_text(&theme, TypeScale::Label, label).text_tone(&theme, TextTone::Muted)
554            } else {
555                foundation_text(&theme, TypeScale::Label, label)
556            })
557            .children(mark.as_ref().map(|mark| {
558                div()
559                    .size(px(4.0))
560                    .rounded_full()
561                    .bg(if selected || endpoint {
562                        theme.colors.text_on_accent
563                    } else {
564                        mark.tone.color(&theme)
565                    })
566                    .semantic_in(
567                        cx,
568                        NodeSpec::new(ident.child("mark").semantic_id(), Role::Status)
569                            .parent(ident.semantic_id())
570                            .text(mark.label.clone()),
571                    )
572            }))
573            .semantic_in(cx, spec);
574
575        cell.into_any_element()
576    }
577
578    /// Whether the day falls inside the drawn range, including the length a
579    /// hover is currently previewing.
580    fn band(&self, day: Day) -> bool {
581        let Some(range) = &self.range else {
582            return false;
583        };
584        let end = range.end.or(self.hovered);
585        let Some(end) = end else {
586            return false;
587        };
588        let (low, high) = if range.start <= end {
589            (range.start, end)
590        } else {
591            (end, range.start)
592        };
593        low <= day && day <= high
594    }
595
596    fn is_endpoint(&self, day: Day) -> bool {
597        self.range
598            .as_ref()
599            .is_some_and(|range| range.start == day || range.end == Some(day))
600    }
601
602    fn body(&self, grid: &MonthGrid, cx: &mut Context<Self>) -> AnyElement {
603        let theme = cx.theme().clone();
604        let grid_ident = self.ident.child("grid");
605        let weeks: Vec<AnyElement> = grid
606            .weeks
607            .iter()
608            .map(|week| {
609                div()
610                    .row()
611                    .children(
612                        week.iter()
613                            .map(|cell| self.cell(*cell, cx))
614                            .collect::<Vec<_>>(),
615                    )
616                    .into_any_element()
617            })
618            .collect();
619
620        div()
621            .column()
622            .child(self.weekday_header(&theme))
623            .child(div().column().children(weeks))
624            .semantic_in(
625                cx,
626                NodeSpec::new(grid_ident.semantic_id(), Role::Group)
627                    .parent(self.ident.semantic_id()),
628            )
629            .into_any_element()
630    }
631}
632
633impl Disableable for Calendar {
634    fn disabled(mut self, disabled: bool) -> Self {
635        self.disabled = disabled;
636        self
637    }
638}
639
640impl Focusable for Calendar {
641    fn focus_handle(&self, _cx: &App) -> FocusHandle {
642        self.focus_handle.clone()
643    }
644}
645
646impl Render for Calendar {
647    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
648        let theme = cx.theme().clone();
649        let month = self.shown_month();
650        let today = self.adapter.today();
651        let header = self.header(month, cx);
652
653        let body = match (month, self.grid()) {
654            (Some(month), Some(grid)) => {
655                let body = div().column().child(self.body(&grid, cx));
656                if self.navigations == 0 {
657                    body.into_any_element()
658                } else {
659                    let animation_id = format!("month.{}.{}", month.0, self.navigations);
660                    month_in(
661                        self.ident.child(animation_id).element_id(),
662                        &theme,
663                        self.travel,
664                        body,
665                    )
666                    .into_any_element()
667                }
668            }
669            _ => EmptyState::new(
670                self.ident.child("unknown-month"),
671                cx.strings().text(StringKey::CalendarUnknownMonth),
672            )
673            .kind(EmptyKind::Unavailable)
674            .detail(cx.strings().text(StringKey::CalendarUnknownMonthDetail))
675            .into_any_element(),
676        };
677
678        let today_marker = today
679            .filter(|today| {
680                self.grid()
681                    .is_some_and(|grid| grid.position_of(*today).is_some())
682            })
683            .map(|today| {
684                foundation_text(&theme, TypeScale::Caption, self.adapter.format_day(today))
685                    .text_tone(&theme, TextTone::Muted)
686                    .semantic_in(
687                        cx,
688                        NodeSpec::new(self.ident.child("today").semantic_id(), Role::Status)
689                            .parent(self.ident.semantic_id())
690                            .text(self.adapter.format_day(today)),
691                    )
692            });
693
694        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Group)
695            .focus(&self.focus_handle)
696            .disabled(self.disabled);
697        match month {
698            Some(month) => spec = spec.text(self.adapter.month_label(month)),
699            None => spec = spec.value("month unknown"),
700        }
701
702        div()
703            .id(self.ident.element_id())
704            .column()
705            .flex_none()
706            .gap_token(&theme, Space::Sm)
707            .p_token(&theme, Space::Sm)
708            .radius(&theme, Radius::Card)
709            .frame(&theme, Surface::Panel, Elevation::Raised)
710            .track_focus(&self.focus_handle)
711            .when(!self.disabled, |element| {
712                element.tab_index(0).focus_ring(&theme)
713            })
714            .on_key_down(cx.listener(Self::on_key_down))
715            .child(header)
716            .child(body)
717            .children(today_marker)
718            .semantic_in(cx, spec)
719    }
720}
721
722/// The arrival a month makes when navigation replaces it.
723///
724/// Only a month that was travelled to gets this. The first month drawn is
725/// rendered bare rather than animated to a standstill, so a calendar nobody
726/// has touched photographs the same way twice.
727fn month_in<E>(
728    id: impl Into<ElementId>,
729    theme: &Theme,
730    direction: i32,
731    element: E,
732) -> AnimationElement<E>
733where
734    E: Styled + IntoElement + 'static,
735{
736    let offset = MONTH_TRAVEL * direction as f32;
737    element.with_animation(
738        id,
739        motion::menu(theme).animation(),
740        move |element, progress| {
741            element
742                .relative()
743                .opacity(0.4 + 0.6 * progress)
744                .left(px(offset * (1.0 - progress)))
745        },
746    )
747}
748
749/// Which way a keystroke moves the cursor.
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub(crate) enum Motion {
752    PreviousDay,
753    NextDay,
754    PreviousWeek,
755    NextWeek,
756    WeekStart,
757    WeekEnd,
758}
759
760/// Where a step that left the grid should land in the neighbouring month.
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub(crate) enum Landing {
763    First,
764    Last,
765    /// The same position in the first or last week of the new month.
766    Column {
767        column: usize,
768        from_start: bool,
769    },
770}
771
772impl Landing {
773    fn resolve(self, grid: &MonthGrid) -> Option<Day> {
774        match self {
775            Self::First => first_day(grid),
776            Self::Last => last_day(grid),
777            Self::Column { column, from_start } => {
778                let week = if from_start {
779                    grid.weeks.first()
780                } else {
781                    grid.weeks.last()
782                };
783                week.and_then(|week| week.get(column).and_then(|cell| cell.day()))
784                    .or_else(|| {
785                        if from_start {
786                            first_day(grid)
787                        } else {
788                            last_day(grid)
789                        }
790                    })
791            }
792        }
793    }
794}
795
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub(crate) enum Step {
798    To(Day),
799    OffGrid { delta: i32, landing: Landing },
800    Nowhere,
801}
802
803pub(crate) fn first_day(grid: &MonthGrid) -> Option<Day> {
804    grid.weeks
805        .iter()
806        .flat_map(|week| week.iter())
807        .find_map(|cell| cell.day())
808}
809
810pub(crate) fn last_day(grid: &MonthGrid) -> Option<Day> {
811    grid.weeks
812        .iter()
813        .rev()
814        .flat_map(|week| week.iter().rev())
815        .find_map(|cell| cell.day())
816}
817
818/// Where one keystroke takes the cursor, using only the grid the adapter
819/// produced. Nothing here adds a day to a date.
820pub(crate) fn step(grid: &MonthGrid, from: Day, motion: Motion) -> Step {
821    let Some((week, column)) = grid.position_of(from) else {
822        return Step::Nowhere;
823    };
824    match motion {
825        Motion::WeekStart => grid.weeks[week]
826            .iter()
827            .find_map(|cell| cell.day())
828            .map_or(Step::Nowhere, Step::To),
829        Motion::WeekEnd => grid.weeks[week]
830            .iter()
831            .rev()
832            .find_map(|cell| cell.day())
833            .map_or(Step::Nowhere, Step::To),
834        Motion::PreviousDay | Motion::NextDay => {
835            let forward = motion == Motion::NextDay;
836            match neighbour(grid, week, column, forward) {
837                Some(day) => Step::To(day),
838                None => Step::OffGrid {
839                    delta: if forward { 1 } else { -1 },
840                    landing: if forward {
841                        Landing::First
842                    } else {
843                        Landing::Last
844                    },
845                },
846            }
847        }
848        Motion::PreviousWeek | Motion::NextWeek => {
849            let forward = motion == Motion::NextWeek;
850            let target = if forward {
851                week.checked_add(1)
852            } else {
853                week.checked_sub(1)
854            };
855            match target
856                .and_then(|week| grid.weeks.get(week))
857                .and_then(|week| week.get(column))
858                .and_then(|cell| cell.day())
859            {
860                Some(day) => Step::To(day),
861                None => Step::OffGrid {
862                    delta: if forward { 1 } else { -1 },
863                    landing: Landing::Column {
864                        column,
865                        from_start: forward,
866                    },
867                },
868            }
869        }
870    }
871}
872
873/// The next cell holding a day, walking the grid in reading order.
874fn neighbour(grid: &MonthGrid, week: usize, column: usize, forward: bool) -> Option<Day> {
875    let flat: Vec<Option<Day>> = grid
876        .weeks
877        .iter()
878        .flat_map(|week| week.iter().map(|cell| cell.day()))
879        .collect();
880    let width = grid.weeks.first().map_or(0, |week| week.len());
881    if width == 0 {
882        return None;
883    }
884    let index = week * width + column;
885    if forward {
886        flat.get(index + 1..)?.iter().flatten().copied().next()
887    } else {
888        flat.get(..index)?.iter().rev().flatten().copied().next()
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895
896    fn grid() -> MonthGrid {
897        MonthGrid::new([
898            vec![
899                MonthCell::Empty,
900                MonthCell::Day(Day(1)),
901                MonthCell::Day(Day(2)),
902            ],
903            vec![
904                MonthCell::Day(Day(3)),
905                MonthCell::Day(Day(4)),
906                MonthCell::Day(Day(5)),
907            ],
908        ])
909    }
910
911    #[test]
912    fn a_day_step_walks_the_grid_in_reading_order() {
913        assert_eq!(step(&grid(), Day(2), Motion::NextDay), Step::To(Day(3)));
914        assert_eq!(step(&grid(), Day(3), Motion::PreviousDay), Step::To(Day(2)));
915        assert_eq!(
916            step(&grid(), Day(1), Motion::PreviousDay),
917            Step::OffGrid {
918                delta: -1,
919                landing: Landing::Last
920            }
921        );
922    }
923
924    #[test]
925    fn home_and_end_reach_the_ends_of_the_week_that_holds_the_cursor() {
926        assert_eq!(step(&grid(), Day(2), Motion::WeekStart), Step::To(Day(1)));
927        assert_eq!(step(&grid(), Day(1), Motion::WeekEnd), Step::To(Day(2)));
928        assert_eq!(step(&grid(), Day(4), Motion::WeekStart), Step::To(Day(3)));
929    }
930
931    #[test]
932    fn a_week_step_keeps_the_column() {
933        assert_eq!(step(&grid(), Day(1), Motion::NextWeek), Step::To(Day(4)));
934        assert_eq!(
935            step(&grid(), Day(5), Motion::PreviousWeek),
936            Step::To(Day(2))
937        );
938    }
939
940    #[test]
941    fn stepping_off_the_grid_asks_for_the_neighbouring_month() {
942        assert_eq!(
943            step(&grid(), Day(5), Motion::NextDay),
944            Step::OffGrid {
945                delta: 1,
946                landing: Landing::First
947            }
948        );
949        assert_eq!(
950            step(&grid(), Day(4), Motion::NextWeek),
951            Step::OffGrid {
952                delta: 1,
953                landing: Landing::Column {
954                    column: 1,
955                    from_start: true
956                }
957            }
958        );
959    }
960
961    #[test]
962    fn the_ends_of_a_grid_are_found_by_content_rather_than_by_slot() {
963        assert_eq!(first_day(&grid()), Some(Day(1)));
964        assert_eq!(last_day(&grid()), Some(Day(5)));
965    }
966}