1use 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
34const CELL_SIZE: f32 = 34.0;
37
38const MONTH_TRAVEL: f32 = 12.0;
40
41#[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#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum CalendarEvent {
70 Picked(Day),
72 MonthShown(MonthKey),
75 Hovered(Option<Day>),
78}
79
80impl EventEmitter<CalendarEvent> for Calendar {}
81
82pub struct Calendar {
84 ident: Ident,
85 focus_handle: FocusHandle,
86 adapter: SharedDateAdapter,
87 selection: Vec<Day>,
88 multi: bool,
89 requested_month: Option<MonthKey>,
91 month: Option<MonthKey>,
95 cursor: Option<Day>,
96 hovered: Option<Day>,
97 range: Option<DayRange>,
98 overlay: Option<Overlay>,
99 disabled: bool,
100 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 pub fn selected(mut self, days: impl IntoIterator<Item = Day>) -> Self {
148 self.selection = days.into_iter().collect();
149 self
150 }
151
152 pub fn multi(mut self, multi: bool) -> Self {
155 self.multi = multi;
156 self
157 }
158
159 pub fn month(mut self, month: MonthKey) -> Self {
162 self.requested_month = Some(month);
163 self
164 }
165
166 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 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 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 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 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 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 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 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 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 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 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
722fn 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
762pub(crate) enum Landing {
763 First,
764 Last,
765 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
818pub(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
873fn 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}