1use std::sync::Arc;
8
9use gpui::{
10 div, prelude::*, px, App, ElementId, Entity, InteractiveElement, IntoElement, RenderOnce,
11 Styled, Window,
12};
13use herogpui_core::element_id;
14use herogpui_theme::ActiveTheme;
15
16use crate::{
17 a11y::{self, A11y as _},
18 calendar::{add_days, days_from_civil, month_year_heading, Date},
19 calendar_keys,
20 calendar_view::{self, PageBehavior, SelectionAlignment, VisibleDuration},
21 date_constraints::{DateConstraints, Weekday},
22 date_picker::DateRangeState,
23 icons, util,
24};
25
26type OnRangeChange = Arc<dyn Fn(&Date, &Date, &mut Window, &mut App) + 'static>;
27type RangeDateUnavailable = Arc<dyn Fn(Date, Option<Date>) -> bool + 'static>;
28
29#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
31#[derive(IntoElement)]
32pub struct RangeCalendar {
33 value: Option<(Option<Date>, Option<Date>)>,
37 default_value: Option<(Date, Date)>,
39 id: ElementId,
40 state: Entity<DateRangeState>,
41 constraints: DateConstraints,
42 range_date_unavailable: Option<RangeDateUnavailable>,
43 is_disabled: bool,
44 inert: bool,
47 is_read_only: bool,
48 autofocus_grid: bool,
51 calendar_system: Option<crate::calendar_system::CalendarSystem>,
54 is_invalid: bool,
55 focused_value: Option<Date>,
56 duration: VisibleDuration,
57 page_behavior: PageBehavior,
58 selection_alignment: Option<SelectionAlignment>,
59 year_picker_open: Option<bool>,
62 default_year_picker_open: bool,
63 visible_years: Option<usize>,
66 year_heading_offset_months: i32,
68 on_year_picker_open_change: Option<Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
69 on_focus_change: Option<Arc<dyn Fn(&Date, &mut Window, &mut App) + 'static>>,
70 allows_non_contiguous_ranges: bool,
72 cell_indicator: Option<Box<dyn Fn(Date) -> bool + 'static>>,
75 cell: Option<Box<dyn Fn(&RangeCalendarCellState) -> gpui::AnyElement + 'static>>,
78 on_change: Option<OnRangeChange>,
79 nav_hover_bg: Option<gpui::Hsla>,
81 year_hover_bg: Option<gpui::Hsla>,
83 day_hover_bg: Option<gpui::Hsla>,
86 sx: Option<Box<gpui::StyleRefinement>>,
88}
89
90#[derive(Clone, Debug)]
93#[non_exhaustive]
94pub struct RangeCalendarCellState {
95 pub date: Date,
97 pub formatted_date: gpui::SharedString,
99 pub is_selected: bool,
101 pub is_selection_start: bool,
103 pub is_selection_end: bool,
105 pub is_unavailable: bool,
107 pub is_outside_month: bool,
109 pub is_today: bool,
111 pub is_disabled: bool,
113}
114
115impl RangeCalendar {
116 pub fn locale(mut self, tag: impl AsRef<str>) -> Self {
122 self.calendar_system = crate::calendar_system::CalendarSystem::for_locale(tag.as_ref());
123 self
124 }
125
126 fn system(&self) -> &crate::calendar_system::CalendarSystem {
128 self.calendar_system
129 .as_ref()
130 .unwrap_or_else(|| crate::calendar_system::system())
131 }
132
133 pub fn focused_value(mut self, date: Date) -> Self {
136 self.focused_value = Some(date);
137 self
138 }
139
140 pub fn on_focus_change(
142 mut self,
143 handler: impl Fn(&Date, &mut Window, &mut App) + 'static,
144 ) -> Self {
145 self.on_focus_change = Some(Arc::new(handler));
146 self
147 }
148
149 pub fn value(mut self, start: Option<Date>, end: Option<Date>) -> Self {
158 self.value = Some((start, end));
159 self
160 }
161
162 pub fn new(state: Entity<DateRangeState>) -> Self {
164 Self {
165 value: None,
166 default_value: None,
167 id: ElementId::named_usize("range-cal", state.entity_id().as_u64() as usize),
168 state,
169 constraints: DateConstraints::new().with_hero_calendar_bounds(),
170 range_date_unavailable: None,
171 calendar_system: None,
172 is_disabled: false,
173 inert: false,
174 is_read_only: false,
175 autofocus_grid: false,
176 is_invalid: false,
177 focused_value: None,
178 duration: VisibleDuration::default(),
179 page_behavior: PageBehavior::default(),
180 selection_alignment: None,
181 year_picker_open: None,
182 default_year_picker_open: false,
183 visible_years: None,
184 year_heading_offset_months: 0,
185 on_year_picker_open_change: None,
186 on_focus_change: None,
187 allows_non_contiguous_ranges: false,
188 cell_indicator: None,
189 cell: None,
190 on_change: None,
191 day_hover_bg: None,
192 nav_hover_bg: None,
193 year_hover_bg: None,
194 sx: None,
195 }
196 }
197
198 pub fn default_value(mut self, value: (Date, Date)) -> Self {
203 self.default_value = Some(value);
204 self
205 }
206
207 pub fn visible_duration(mut self, duration: VisibleDuration) -> Self {
210 self.duration = duration;
211 self
212 }
213
214 pub fn page_behavior(mut self, behavior: PageBehavior) -> Self {
217 self.page_behavior = behavior;
218 self
219 }
220
221 pub fn selection_alignment(mut self, alignment: SelectionAlignment) -> Self {
224 self.selection_alignment = Some(alignment);
225 self
226 }
227
228 pub fn is_year_picker_open(mut self, v: bool) -> Self {
230 self.year_picker_open = Some(v);
231 self
232 }
233
234 pub fn default_year_picker_open(mut self, v: bool) -> Self {
239 self.default_year_picker_open = v;
240 self
241 }
242
243 pub fn visible_years(mut self, count: usize) -> Self {
246 self.visible_years = Some(count.max(1));
247 self
248 }
249
250 pub fn offset(mut self, months: i32) -> Self {
253 self.year_heading_offset_months = months;
254 self
255 }
256
257 pub fn on_year_picker_open_change(
260 mut self,
261 f: impl Fn(&bool, &mut Window, &mut App) + 'static,
262 ) -> Self {
263 self.on_year_picker_open_change = Some(Arc::new(f));
264 self
265 }
266
267 pub fn min_value(mut self, date: Date) -> Self {
269 self.constraints.min_value = Some(date);
270 self
271 }
272
273 pub fn max_value(mut self, date: Date) -> Self {
275 self.constraints.max_value = Some(date);
276 self
277 }
278
279 pub fn is_date_unavailable(mut self, f: impl Fn(Date, Option<Date>) -> bool + 'static) -> Self {
283 self.constraints.is_date_unavailable = None;
284 self.range_date_unavailable = Some(Arc::new(f));
285 self
286 }
287
288 pub fn cell(
295 mut self,
296 render: impl Fn(&RangeCalendarCellState) -> gpui::AnyElement + 'static,
297 ) -> Self {
298 self.cell = Some(Box::new(render));
299 self
300 }
301
302 pub fn cell_indicator(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
304 self.cell_indicator = Some(Box::new(f));
305 self
306 }
307
308 pub fn first_day_of_week(mut self, day: Weekday) -> Self {
310 self.constraints.first_day_of_week = day;
311 self
312 }
313
314 pub fn weeks_in_month(mut self, rows: usize) -> Self {
316 self.constraints.weeks_in_month = Some(rows);
317 self
318 }
319
320 pub fn is_invalid(mut self, v: bool) -> Self {
322 self.is_invalid = v;
323 self
324 }
325
326 pub fn allows_non_contiguous_ranges(mut self, v: bool) -> Self {
329 self.allows_non_contiguous_ranges = v;
330 self
331 }
332
333 pub fn constraints(mut self, constraints: DateConstraints) -> Self {
335 self.constraints = constraints.with_hero_calendar_bounds();
336 self.range_date_unavailable = None;
337 self
338 }
339
340 pub fn is_disabled(mut self, v: bool) -> Self {
342 self.is_disabled = v;
343 self
344 }
345
346 pub(crate) fn inert(mut self, v: bool) -> Self {
350 self.inert = v;
351 self
352 }
353
354 pub fn is_read_only(mut self, v: bool) -> Self {
356 self.is_read_only = v;
357 self
358 }
359
360 pub(crate) fn autofocus_grid(mut self, v: bool) -> Self {
364 self.autofocus_grid = v;
365 self
366 }
367
368 pub fn on_change(
372 mut self,
373 handler: impl Fn(&Date, &Date, &mut Window, &mut App) + 'static,
374 ) -> Self {
375 self.on_change = Some(Arc::new(handler));
376 self
377 }
378
379 pub fn nav_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
381 self.nav_hover_bg = Some(color.into());
382 self
383 }
384
385 pub fn year_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
387 self.year_hover_bg = Some(color.into());
388 self
389 }
390
391 pub fn day_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
395 self.day_hover_bg = Some(color.into());
396 self
397 }
398
399 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
404 util::refine_sx(&mut self.sx, style);
405 self
406 }
407}
408
409fn resolve_pick(
414 start: Option<Date>,
415 end: Option<Date>,
416 requested: Date,
417 constraints: &DateConstraints,
418 range_date_unavailable: Option<&RangeDateUnavailable>,
419) -> Option<(Date, Option<Date>)> {
420 if start.is_none() || end.is_some() {
421 return range_allows(constraints, range_date_unavailable, requested, None)
422 .then_some((requested, None));
423 }
424
425 let anchor = start?;
426 let target = constraints.constrain(requested);
427 if range_is_unavailable(constraints, range_date_unavailable, target, Some(anchor)) {
428 return None;
429 }
430
431 if days_from_civil(&target) < days_from_civil(&anchor) {
432 Some((target, Some(anchor)))
433 } else {
434 Some((anchor, Some(target)))
435 }
436}
437
438fn range_is_unavailable(
439 constraints: &DateConstraints,
440 range_date_unavailable: Option<&RangeDateUnavailable>,
441 date: Date,
442 anchor: Option<Date>,
443) -> bool {
444 match range_date_unavailable {
445 Some(predicate) => predicate(date, anchor),
446 None => constraints.is_unavailable(date),
447 }
448}
449
450fn range_allows(
451 constraints: &DateConstraints,
452 range_date_unavailable: Option<&RangeDateUnavailable>,
453 date: Date,
454 anchor: Option<Date>,
455) -> bool {
456 !constraints.out_of_range(date)
457 && !range_is_unavailable(constraints, range_date_unavailable, date, anchor)
458}
459
460fn unavailable_range_boundary(
464 system: &crate::calendar_system::CalendarSystem,
465 anchor: Date,
466 direction: i32,
467 duration: VisibleDuration,
468 constraints: &DateConstraints,
469 range_date_unavailable: Option<&RangeDateUnavailable>,
470) -> Option<Date> {
471 let limit = calendar_view::page_in(system, duration, PageBehavior::Visible, anchor, direction);
472 let mut next = add_days(&anchor, i64::from(direction));
473 let within_limit = |date: Date| {
474 if direction < 0 {
475 days_from_civil(&date) >= days_from_civil(&limit)
476 } else {
477 days_from_civil(&date) <= days_from_civil(&limit)
478 }
479 };
480
481 while within_limit(next) {
482 if range_is_unavailable(constraints, range_date_unavailable, next, Some(anchor)) {
483 return Some(add_days(&next, -i64::from(direction)));
484 }
485 next = add_days(&next, i64::from(direction));
486 }
487
488 range_is_unavailable(constraints, range_date_unavailable, next, Some(anchor))
489 .then(|| add_days(&next, -i64::from(direction)))
490}
491
492fn effective_range_constraints(
493 system: &crate::calendar_system::CalendarSystem,
494 anchor: Option<Date>,
495 duration: VisibleDuration,
496 constraints: &DateConstraints,
497 range_date_unavailable: Option<&RangeDateUnavailable>,
498 allows_non_contiguous_ranges: bool,
499) -> DateConstraints {
500 let mut effective = constraints.clone();
501 let Some(anchor) = anchor else {
502 return effective;
503 };
504 if allows_non_contiguous_ranges
505 || (range_date_unavailable.is_none() && constraints.is_date_unavailable.is_none())
506 {
507 return effective;
508 }
509
510 if let Some(boundary) = unavailable_range_boundary(
511 system,
512 anchor,
513 -1,
514 duration,
515 constraints,
516 range_date_unavailable,
517 ) {
518 effective.min_value = Some(match effective.min_value {
519 Some(min) if days_from_civil(&min) > days_from_civil(&boundary) => min,
520 _ => boundary,
521 });
522 }
523 if let Some(boundary) = unavailable_range_boundary(
524 system,
525 anchor,
526 1,
527 duration,
528 constraints,
529 range_date_unavailable,
530 ) {
531 effective.max_value = Some(match effective.max_value {
532 Some(max) if days_from_civil(&max) < days_from_civil(&boundary) => max,
533 _ => boundary,
534 });
535 }
536
537 effective
538}
539
540fn keyboard_range_focus(
543 anchor: Date,
544 constraints: &DateConstraints,
545 range_date_unavailable: Option<&RangeDateUnavailable>,
546 allows_non_contiguous_ranges: bool,
547) -> Option<Date> {
548 [add_days(&anchor, 1), add_days(&anchor, -1)]
549 .into_iter()
550 .find(|date| {
551 !constraints.out_of_range(*date)
552 && (allows_non_contiguous_ranges
553 || !range_is_unavailable(
554 constraints,
555 range_date_unavailable,
556 *date,
557 Some(anchor),
558 ))
559 })
560}
561
562struct Frame<'a> {
565 start: Option<Date>,
566 preview_end: Option<Date>,
567 unavailable_anchor: Option<Date>,
568 constraints: &'a DateConstraints,
569 today: Date,
570 cursor: &'a Entity<Option<Date>>,
571 focus_preview: &'a Entity<bool>,
572 selection_before_anchor: &'a Entity<Option<(Date, Date)>>,
573 base: &'a str,
574 focused: Option<Date>,
578 anchor: Date,
579 visible_start: Option<Date>,
580 cell_size: gpui::Pixels,
581}
582
583struct CellSlot {
586 column: usize,
587 columns: usize,
588}
589
590impl CellSlot {
591 fn is_first(&self) -> bool {
592 self.column == 0
593 }
594
595 fn is_last(&self) -> bool {
596 self.column + 1 == self.columns
597 }
598}
599
600impl RangeCalendar {
601 fn range_cell(
604 &self,
605 date: Date,
606 outside_month: bool,
607 frame: &Frame<'_>,
608 key: String,
609 slot: CellSlot,
610 cx: &App,
611 ) -> gpui::AnyElement {
612 let colors = cx.colors();
613 let accent = if self.is_invalid {
614 colors.danger
615 } else {
616 colors.accent
617 };
618 let serial = days_from_civil(&date);
619 let start_day = frame.start.map(|d| days_from_civil(&d));
620 let end_day = frame.preview_end.map(|d| days_from_civil(&d));
621 let unavailable = range_is_unavailable(
622 frame.constraints,
623 self.range_date_unavailable.as_ref(),
624 date,
625 frame.unavailable_anchor,
626 );
627 let disabled = outside_month
628 || self.is_disabled
629 || frame.constraints.out_of_range(date)
630 || frame
631 .visible_start
632 .is_some_and(|start| days_from_civil(&date) < days_from_civil(&start));
633 let eligible = !disabled && !unavailable;
634 let selectable = eligible && !self.is_read_only && !self.inert;
635
636 let is_start = start_day == Some(serial);
637 let is_end = end_day == Some(serial);
638 let in_range = eligible
639 && match (start_day, end_day) {
640 (Some(a), Some(b)) => serial > a.min(b) && serial < a.max(b),
641 _ => false,
642 };
643 let is_selected = eligible && (is_start || is_end || in_range);
644 let draw_start = is_start && !outside_month;
645 let draw_end = is_end && !outside_month;
646 let is_today = serial == days_from_civil(&frame.today);
647
648 let track_key = format!("{key}-track");
655 let indicator_key = format!("{key}-indicator");
656 let mut track = div()
657 .relative()
658 .debug_selector(move || track_key)
661 .flex()
662 .items_center()
663 .justify_center()
664 .flex_1()
665 .min_w_0()
666 .h(frame.cell_size);
667 if is_selected {
668 let cap_radius = util::control_radius(cx);
677 let edge_radius = util::key_radius(cx);
678 let left = if draw_start {
679 cap_radius
680 } else if slot.is_first() {
681 edge_radius
682 } else {
683 px(0.)
684 };
685 let right = if draw_end {
686 cap_radius
687 } else if slot.is_last() {
688 edge_radius
689 } else {
690 px(0.)
691 };
692 track = track.bg(accent.soft());
693 track = track
694 .rounded_tl(left)
695 .rounded_bl(left)
696 .rounded_tr(right)
697 .rounded_br(right);
698 }
699
700 let mut cell = div()
701 .id(ElementId::Name(key.clone().into()))
702 .debug_selector(move || key)
705 .flex()
706 .items_center()
707 .justify_center()
708 .size(frame.cell_size)
709 .text_size(px(14.))
710 .line_height(px(20.))
711 .font_weight(gpui::FontWeight::MEDIUM)
712 .child(match &self.cell {
713 Some(render) => render(&RangeCalendarCellState {
714 date,
715 formatted_date: self.day_label(date).into(),
716 is_selected,
717 is_selection_start: is_start,
718 is_selection_end: is_end,
719 is_unavailable: unavailable,
720 is_outside_month: outside_month,
721 is_today,
722 is_disabled: disabled,
723 }),
724 None => self.day_label(date).into_any_element(),
725 });
726
727 if draw_start || draw_end {
728 cell = cell
729 .rounded_full()
730 .bg(accent.color)
731 .text_color(accent.foreground);
732 } else if is_today {
733 cell = cell
738 .rounded_full()
739 .bg(accent.soft())
740 .text_color(accent.soft_foreground(colors.foreground));
741 if selectable {
742 cell = util::cursor_interactive(cell, cx);
743 if !is_selected {
744 let hover_bg = accent.soft_hover();
745 cell = cell.hover(move |s| s.bg(hover_bg));
746 }
747 }
748 } else if in_range {
749 } else {
753 cell = cell.rounded_full();
754 if selectable {
755 let hover_bg = self.day_hover_bg.unwrap_or(colors.default.color);
756 cell = cell
757 .cursor(util::interactive_cursor(cx))
758 .hover(move |s| s.bg(hover_bg));
759 }
760 }
761
762 let cell = if selectable {
770 let press_box = crate::anim::PressBox {
771 height: frame.cell_size,
772 padding_x: None,
773 width: Some(frame.cell_size),
774 min_width: None,
775 text_size: px(14.),
776 line_height: px(20.),
777 gap: px(0.),
778 radius: frame.cell_size / 2.,
779 shrink_x: true,
780 scale: crate::anim::PRESSED_SCALE_RANGE,
781 };
782 if draw_start || draw_end {
783 crate::anim::pressed_with_background(cell, press_box, accent.hover(), cx)
784 } else if is_today || in_range {
785 crate::anim::pressed(cell, press_box, cx)
786 } else {
787 let pressed_bg = colors.default.color;
788 crate::anim::pressed_with_background(cell, press_box, pressed_bg, cx)
789 }
790 } else {
791 cell
792 };
793
794 let mut cell = util::with_focus_ring_overlay(
805 cell,
806 util::shows_focus_ring(
807 !self.inert && !outside_month && frame.focused == Some(date),
808 cx,
809 ),
810 true,
811 frame.cell_size / 2.,
812 Vec::new(),
813 cx,
814 );
815
816 if disabled || unavailable {
817 cell = cell
818 .text_color(colors.muted)
819 .cursor(gpui::CursorStyle::OperationNotAllowed)
820 .when(!self.is_disabled, |day| day.opacity(cx.layout().disabled_opacity));
823 if disabled && !outside_month {
824 cell = cell.line_through();
825 }
826 }
827
828 if selectable {
829 let hover_state = self.state.clone();
832 let hover_cursor = frame.cursor.clone();
833 let hover_preview = frame.focus_preview.clone();
834 let hover_focus = self.on_focus_change.clone();
835 cell = cell.on_hover(move |over, window, cx| {
836 let over = *over;
837 let selecting = hover_state.update(cx, |s, cx| {
838 if over {
839 s.hovered = Some(date);
840 } else if s.hovered == Some(date) {
841 s.hovered = None;
842 }
843 cx.notify();
844 s.start.is_some() && s.end.is_none()
845 });
846 if over && selecting {
847 let focus_changed = hover_cursor.update(cx, |focused, cx| {
848 let changed = *focused != Some(date);
849 *focused = Some(date);
850 cx.notify();
851 changed
852 });
853 hover_preview.update(cx, |preview, _| *preview = true);
854 if focus_changed {
855 if let Some(cb) = &hover_focus {
856 cb(&date, window, cx);
857 }
858 }
859 }
860 });
861
862 let state = self.state.clone();
863 let on_change = self.on_change.clone();
864 let on_focus = self.on_focus_change.clone();
865 let constraints = frame.constraints.clone();
866 let range_date_unavailable = self.range_date_unavailable.clone();
867 let cursor = frame.cursor.clone();
868 let focus_preview = frame.focus_preview.clone();
869 let selection_before_anchor = frame.selection_before_anchor.clone();
870 let anchor = frame.anchor;
871 cell = cell.on_click(move |_, window, cx| {
872 if let Some(cb) = &on_focus {
873 cb(&date, window, cx);
874 }
875 let (next, previous) = state.update(cx, |s, cx| {
876 let previous = s.start.zip(s.end);
877 let next = resolve_pick(
878 s.start,
879 s.end,
880 date,
881 &constraints,
882 range_date_unavailable.as_ref(),
883 );
884 if let Some((start, end)) = next {
885 s.start = Some(start);
886 s.end = end;
887 s.set_anchor(anchor);
888 cx.notify();
889 }
890 (next, previous)
891 });
892 cursor.update(cx, |focused, cx| {
893 *focused = Some(date);
894 cx.notify();
895 });
896 focus_preview.update(cx, |preview, _| *preview = false);
897 selection_before_anchor.update(cx, |saved, _| {
898 if let Some((_, end)) = next {
899 *saved = if end.is_none() { previous } else { None };
900 }
901 });
902 if let (Some(cb), Some((start, Some(end)))) = (&on_change, next) {
903 cb(&start, &end, window, cx);
904 }
905 });
906 }
907
908 cell = cell
909 .a11y_named(
910 a11y::Role::Button,
911 &a11y::Name::labelled(crate::calendar::day_cell_name(
912 format!("{} {}", date.day, month_year_heading(date.year, date.month)),
913 is_selected,
914 cx,
915 )),
916 )
917 .a11y_selected(is_selected);
918
919 let mut track = track.child(cell);
920
921 if self.cell_indicator.as_ref().is_some_and(|f| f(date)) {
925 let marker = if is_start || is_end || in_range {
926 accent.foreground
927 } else {
928 colors.muted
929 };
930 track = track.child(
931 div()
932 .absolute()
933 .debug_selector(move || indicator_key)
936 .left((frame.cell_size - px(3.)) / 2.)
937 .bottom(px(4.))
938 .size(px(3.))
939 .rounded(px(2.))
940 .bg(marker),
941 );
942 }
943
944 track
945 .when(outside_month, |track| track.opacity(0.5))
946 .into_any_element()
947 }
948
949 fn weekday_header(&self, cx: &App) -> gpui::Div {
951 let muted = cx.colors().muted;
952 let mut row = div().flex().flex_row().w_full();
953 for (column, label) in self
954 .constraints
955 .first_day_of_week
956 .header_row()
957 .into_iter()
958 .enumerate()
959 {
960 row = row.child(
961 div()
962 .flex_1()
963 .debug_selector({
964 let key = format!("{:?}-weekday-{column}", self.id);
965 move || key
966 })
967 .pb(px(8.))
968 .text_center()
969 .text_size(px(12.))
971 .line_height(px(16.))
972 .font_weight(gpui::FontWeight::MEDIUM)
973 .text_color(muted)
974 .child(label),
975 );
976 }
977 row
978 }
979
980 fn month_heading_text(&self, year: i32, month: u32) -> String {
982 let system = self.system();
983 let (year, month) = system.add_months(year, month, self.year_heading_offset_months);
984 let Some(date) = system.to_gregorian(year, month, 1) else {
985 return String::new();
986 };
987 crate::calendar::date_heading_for_locale(system.locale(), date)
988 .unwrap_or_else(|| month_year_heading(date.year, date.month))
989 }
990
991 fn day_label(&self, date: Date) -> String {
993 self.system().from_gregorian(date).2.to_string()
994 }
995
996 #[allow(clippy::too_many_arguments)]
999 fn range_spill_cell(
1000 &self,
1001 date: Option<Date>,
1002 outside_month: bool,
1003 frame: &Frame<'_>,
1004 key: String,
1005 slot: CellSlot,
1006 cx: &App,
1007 ) -> gpui::AnyElement {
1008 match date {
1009 Some(date) => self.range_cell(date, outside_month, frame, key, slot, cx),
1010 None => div().size(frame.cell_size).into_any_element(),
1011 }
1012 }
1013
1014 fn month_grid(&self, y: i32, m: u32, frame: &Frame<'_>, cx: &App) -> gpui::AnyElement {
1016 let system = self.system();
1017 let lead = self.constraints.lead_cells_in(system, y, m);
1018 let total = system.days_in_month(y, m) as usize;
1019 let rows = self.constraints.rows_in(system, y, m);
1020
1021 let mut grid = div()
1026 .id(element_id::scoped(&self.id, format!("grid-{y}-{m}")))
1027 .flex()
1028 .flex_col()
1029 .gap(px(4.))
1030 .py(px(2.));
1031 for row_index in 0..rows {
1032 let mut row = div().flex().flex_row();
1033 for column in 0..7 {
1034 let index = row_index * 7 + column;
1035 let cell = if index < lead {
1036 let (previous_year, previous_month) = system.add_months(y, m, -1);
1037 let day = system.days_in_month(previous_year, previous_month) as usize - lead
1038 + index
1039 + 1;
1040 self.range_spill_cell(
1041 system.to_gregorian(previous_year, previous_month, day as u32),
1042 true,
1043 frame,
1044 format!(
1045 "{}-{y}-{m}-outside-{previous_year}-{previous_month}-day-{day}",
1046 frame.base
1047 ),
1048 CellSlot { column, columns: 7 },
1049 cx,
1050 )
1051 } else {
1052 let day = index - lead + 1;
1053 if day <= total {
1054 self.range_spill_cell(
1055 system.to_gregorian(y, m, day as u32),
1056 false,
1057 frame,
1058 format!("{}-{y}-{m}-day-{day}", frame.base),
1059 CellSlot { column, columns: 7 },
1060 cx,
1061 )
1062 } else {
1063 let (next_year, next_month) = system.add_months(y, m, 1);
1064 let next_day = day - total;
1065 self.range_spill_cell(
1066 system.to_gregorian(next_year, next_month, next_day as u32),
1067 true,
1068 frame,
1069 format!(
1070 "{}-{y}-{m}-outside-{next_year}-{next_month}-day-{next_day}",
1071 frame.base
1072 ),
1073 CellSlot { column, columns: 7 },
1074 cx,
1075 )
1076 }
1077 };
1078 row = row.child(cell);
1079 }
1080 grid = grid.child(row);
1081 }
1082 grid.a11y(a11y::Role::Grid).into_any_element()
1083 }
1084
1085 fn year_grid(
1087 &self,
1088 view: calendar_view::YearGridView<'_>,
1089 year_focus: &gpui::FocusHandle,
1090 heading_focus: &gpui::FocusHandle,
1091 year_picker_own: Option<Entity<bool>>,
1092 window: &Window,
1093 cx: &App,
1094 ) -> gpui::AnyElement {
1095 let colors = cx.colors();
1096 let accent = if self.is_invalid {
1097 colors.danger
1098 } else {
1099 colors.accent
1100 };
1101 let active_year = view.active_year;
1102 let base = format!("{:?}", view.base);
1105 let mut grid = div()
1106 .id(element_id::scoped(view.base, "year-viewport"))
1107 .debug_selector({
1108 let key = format!("{base}-year-viewport");
1109 move || key
1110 })
1111 .size_full()
1112 .overflow_y_scroll()
1113 .track_scroll(&view.scroll.handle)
1114 .flex()
1115 .flex_col()
1116 .gap(px(4.));
1117 for chunk in view.years.chunks(3) {
1118 let mut row = div().flex().flex_shrink_0().gap(px(4.));
1119 for &year in chunk {
1120 let is_active = year == active_year;
1121 let mut cell = div()
1122 .id(element_id::scoped(view.base, format!("y{year}")))
1123 .debug_selector({
1124 let key = format!("{base}-y{year}");
1125 move || key
1126 })
1127 .when(!self.is_disabled && !self.inert && is_active, |cell| {
1128 cell.track_focus(year_focus)
1129 })
1130 .flex_1()
1131 .h(px(32.))
1132 .px(px(10.))
1133 .flex()
1134 .items_center()
1135 .justify_center()
1136 .text_size(px(14.))
1137 .line_height(px(20.))
1138 .font_weight(gpui::FontWeight::MEDIUM)
1139 .rounded(util::control_radius(cx));
1140 if is_active {
1141 cell = cell.bg(accent.color).text_color(accent.foreground);
1142 } else if !self.is_disabled && !self.inert {
1143 let hover_bg = self.year_hover_bg.unwrap_or(colors.default.color);
1146 let hover_fg = colors.default.foreground;
1147 cell = cell
1148 .text_color(colors.foreground)
1149 .cursor(util::interactive_cursor(cx))
1150 .hover(move |s| s.bg(hover_bg).text_color(hover_fg));
1151 }
1152 if !self.is_disabled && !self.inert {
1153 let st = self.state.clone();
1154 let on_open = self.on_year_picker_open_change.clone();
1155 let on_focus = self.on_focus_change.clone();
1156 let own = year_picker_own.clone();
1157 let back_to_trigger = heading_focus.clone();
1158 let system = self.system().clone();
1159 cell = cell.on_click(move |_, window, cx| {
1160 let next = st.update(cx, |s, cx| {
1161 let anchor = s.anchor();
1162 let next =
1163 system.add_years(anchor, year - system.from_gregorian(anchor).0);
1164 s.set_anchor(next);
1165 cx.notify();
1166 next
1167 });
1168 if year != active_year {
1169 if let Some(cb) = &on_focus {
1170 cb(&next, window, cx);
1171 }
1172 }
1173 if let Some(held) = &own {
1174 held.update(cx, |open, cx| {
1175 *open = false;
1176 cx.notify();
1177 });
1178 }
1179 if let Some(cb) = &on_open {
1180 cb(&false, window, cx);
1181 }
1182 window.focus(&back_to_trigger, cx);
1183 });
1184 }
1185 if is_active {
1186 cell = util::ring_overlay_if_focused(
1187 cell,
1188 year_focus,
1189 false,
1190 util::control_radius(cx),
1191 Vec::new(),
1192 window,
1193 cx,
1194 );
1195 }
1196 cell = cell
1197 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(year.to_string()))
1198 .a11y_selected(is_active);
1199 row = row.child(cell.child(year.to_string()));
1200 }
1201 row = row.children((chunk.len()..3).map(|_| div().flex_1().px(px(10.))));
1202 grid = grid.child(row);
1203 }
1204 grid = grid.a11y(a11y::Role::Grid);
1205 div()
1206 .absolute()
1207 .inset_0()
1208 .p(px(4.))
1209 .child(grid)
1210 .when_some(view.reveal_row, |viewport, row| {
1211 let scroll = view.scroll.clone();
1212 viewport.on_children_prepainted(move |_, window, cx| {
1213 scroll.last_target.set(Some((active_year, row)));
1214 scroll.handle.scroll_to_item(row);
1215 window.defer(cx, |window, _| window.refresh());
1216 })
1217 })
1218 .into_any_element()
1219 }
1220}
1221
1222impl RenderOnce for RangeCalendar {
1223 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1224 if let Some((start, end)) = self.value {
1229 let state = self.state.clone();
1230 util::seed_once(
1231 window,
1232 cx,
1233 ElementId::named_usize(
1234 "rangecalendar-default",
1235 self.state.entity_id().as_u64() as usize,
1236 ),
1237 move |cx| {
1238 state.update(cx, |s, cx| {
1239 s.start = start;
1240 s.end = end;
1241 if let Some(start) = start {
1242 s.view_year = start.year;
1243 s.view_month = start.month;
1244 s.view_day = start.day;
1245 }
1246 cx.notify();
1247 });
1248 },
1249 );
1250 } else if let Some(value) = self.default_value {
1251 let state = self.state.clone();
1252 util::seed_once(
1253 window,
1254 cx,
1255 ElementId::named_usize(
1256 "rangecalendar-default",
1257 self.state.entity_id().as_u64() as usize,
1258 ),
1259 move |cx| {
1260 state.update(cx, |s, cx| {
1261 s.start = Some(value.0);
1262 s.end = Some(value.1);
1263 s.view_year = value.0.year;
1264 s.view_month = value.0.month;
1265 s.view_day = value.0.day;
1266 cx.notify();
1267 });
1268 },
1269 );
1270 }
1271
1272 let base = format!("{:?}", self.id);
1273
1274 let (year_picker_open, year_picker_own) = util::controlled(
1278 window,
1279 cx,
1280 element_id::scoped(&self.id, "yearpicker"),
1281 self.year_picker_open,
1282 self.default_year_picker_open,
1283 );
1284
1285 let grid_focus = util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
1288 let prev_focus =
1289 util::tab_stop_handle(element_id::scoped(&self.id, "prev-focus"), window, cx);
1290 let next_focus =
1291 util::tab_stop_handle(element_id::scoped(&self.id, "next-focus"), window, cx);
1292 let year_focus =
1293 util::tab_stop_handle(element_id::scoped(&self.id, "year-focus"), window, cx);
1294 if self.autofocus_grid && !self.is_disabled && !self.inert && !year_picker_open {
1297 util::focus_once(
1298 window,
1299 cx,
1300 element_id::scoped(&self.id, "autofocus"),
1301 &grid_focus,
1302 );
1303 }
1304 let cursor = window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| {
1305 None::<Date>
1306 });
1307 let focus_preview =
1308 window.use_keyed_state(element_id::scoped(&self.id, "focus-preview"), cx, |_, _| {
1309 false
1310 });
1311 let selection_before_anchor = window.use_keyed_state(
1312 element_id::scoped(&self.id, "selection-before-anchor"),
1313 cx,
1314 |_, _| None::<(Date, Date)>,
1315 );
1316 let year_cursor =
1317 window.use_keyed_state(element_id::scoped(&self.id, "year-cursor"), cx, |_, _| {
1318 None::<i32>
1319 });
1320 let year_was_open =
1321 window.use_keyed_state(element_id::scoped(&self.id, "year-was-open"), cx, |_, _| {
1322 false
1323 });
1324 let year_trigger_index = window.use_keyed_state(
1325 element_id::scoped(&self.id, "year-trigger-index"),
1326 cx,
1327 |_, _| 0usize,
1328 );
1329 let cursor_at = *cursor.read(cx);
1330 let focus_preview_at = *focus_preview.read(cx);
1331
1332 let (stored_anchor, selection_start, selection_end, hovered, navigated) = {
1333 let st = self.state.read(cx);
1334 (st.anchor(), st.start, st.end, st.hovered, st.user_navigated)
1335 };
1336 let effective_constraints = effective_range_constraints(
1337 self.system(),
1338 selection_start.filter(|_| selection_end.is_none()),
1339 self.duration,
1340 &self.constraints,
1341 self.range_date_unavailable.as_ref(),
1342 self.allows_non_contiguous_ranges,
1343 );
1344 let first_day = effective_constraints.first_day_of_week;
1345 let focused_value = self
1346 .focused_value
1347 .map(|date| effective_constraints.constrain(date));
1348 let active_preview = hovered.or(focus_preview_at.then_some(cursor_at).flatten());
1349 let (paint_start, preview_end) = match (selection_start, selection_end, active_preview) {
1350 (Some(start), None, Some(preview)) => resolve_pick(
1351 Some(start),
1352 None,
1353 preview,
1354 &effective_constraints,
1355 self.range_date_unavailable.as_ref(),
1356 )
1357 .map_or((Some(start), None), |(start, end)| (Some(start), end)),
1358 _ => (selection_start, selection_end),
1359 };
1360 let selection_alignment =
1364 self.selection_alignment
1365 .unwrap_or_else(|| match (selection_start, selection_end) {
1366 (Some(start), Some(end)) => {
1367 let centered_anchor = calendar_view::aligned_anchor_in(
1368 self.system(),
1369 self.duration,
1370 SelectionAlignment::Center,
1371 first_day,
1372 start,
1373 );
1374 let (_, centered_end) = calendar_view::visible_range(
1375 self.system(),
1376 self.duration,
1377 first_day,
1378 centered_anchor,
1379 );
1380 if days_from_civil(&end) > days_from_civil(¢ered_end) {
1381 SelectionAlignment::Start
1382 } else {
1383 SelectionAlignment::Center
1384 }
1385 }
1386 _ => SelectionAlignment::Center,
1387 });
1388 let anchor = match (navigated, selection_start) {
1391 (false, Some(sel)) => calendar_view::aligned_anchor_in(
1392 self.system(),
1393 self.duration,
1394 selection_alignment,
1395 first_day,
1396 sel,
1397 ),
1398 _ => stored_anchor,
1399 };
1400 let anchor = focused_value.map_or(anchor, |focused| {
1401 let (visible_start, visible_end) =
1402 calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1403 calendar_view::anchor_following_focus_in(
1404 self.system(),
1405 self.duration,
1406 first_day,
1407 anchor,
1408 visible_start,
1409 visible_end,
1410 focused,
1411 )
1412 });
1413 let initial_year = self
1414 .system()
1415 .from_gregorian(focused_value.unwrap_or(anchor))
1416 .0;
1417 let years = calendar_view::year_window(
1418 self.system(),
1419 initial_year,
1420 self.visible_years,
1421 effective_constraints.min_value,
1422 effective_constraints.max_value,
1423 );
1424 let first_year = years.first().copied().unwrap_or(initial_year);
1425 let last_year = years.last().copied().unwrap_or(initial_year);
1426 if year_picker_open && !*year_was_open.read(cx) && !self.is_disabled && !self.inert {
1427 year_cursor.update(cx, |year, _| *year = Some(initial_year));
1428 window.focus(&year_focus, cx);
1429 }
1430 let year_just_opened = year_picker_open && !*year_was_open.read(cx);
1431 year_was_open.update(cx, |was_open, _| *was_open = year_picker_open);
1432 let active_year = focused_value
1433 .map(|date| self.system().from_gregorian(date).0)
1434 .or(*year_cursor.read(cx))
1435 .unwrap_or(initial_year)
1436 .max(first_year)
1437 .min(last_year);
1438 let year_scroll_state =
1439 window.use_keyed_state(element_id::scoped(&self.id, "year-scroll"), cx, |_, _| {
1440 std::rc::Rc::new(calendar_view::YearGridScroll::default())
1441 });
1442 let year_scroll = year_scroll_state.read(cx).clone();
1443 let reveal_year_row =
1444 years
1445 .iter()
1446 .position(|year| *year == active_year)
1447 .and_then(|index| {
1448 let row = index / 3;
1449 (year_picker_open
1450 && (year_just_opened
1451 || year_scroll.last_target.get() != Some((active_year, row))))
1452 .then_some(row)
1453 });
1454
1455 let ring_at = focused_value
1458 .or(cursor_at)
1459 .or(selection_start)
1460 .or_else(|| Some(Date::today()))
1461 .filter(|_| grid_focus.is_focused(window));
1462 let column_width = if matches!(self.duration, VisibleDuration::Months(n) if n > 1) {
1463 px(256.)
1464 } else {
1465 crate::calendar::CALENDAR_WIDTH
1466 };
1467 let frame = Frame {
1468 start: paint_start,
1469 preview_end,
1470 unavailable_anchor: selection_start.filter(|_| selection_end.is_none()),
1471 constraints: &effective_constraints,
1472 today: Date::today(),
1473 cursor: &cursor,
1474 focus_preview: &focus_preview,
1475 selection_before_anchor: &selection_before_anchor,
1476 base: &base,
1477 focused: ring_at,
1478 anchor,
1479 visible_start: matches!(self.duration, VisibleDuration::Days(_)).then_some(anchor),
1480 cell_size: column_width / 7.,
1481 };
1482
1483 let months = calendar_view::month_headings_in(self.system(), self.duration, anchor);
1484 let linear = calendar_view::linear_cells(self.duration, first_day, anchor);
1485 let columns = months.len().max(1);
1486 let mut heading_focuses = Vec::with_capacity(columns);
1487 for index in 0..columns {
1488 heading_focuses.push(util::tab_stop_handle(
1489 element_id::scoped(&element_id::indexed(&self.id, "heading", index), "focus"),
1490 window,
1491 cx,
1492 ));
1493 }
1494 let active_heading_index = (*year_trigger_index.read(cx)).min(columns - 1);
1495 let active_heading_focus = heading_focuses[active_heading_index].clone();
1496
1497 let colors = cx.colors().clone();
1498 let layout = cx.layout().clone();
1499
1500 let nav_target = |dir: i32| {
1501 calendar_view::page_in(
1502 self.system(),
1503 self.duration,
1504 self.page_behavior,
1505 anchor,
1506 dir,
1507 )
1508 };
1509 let (visible_start, visible_end) =
1510 calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1511 let previous_disabled =
1516 self.is_disabled || effective_constraints.out_of_range(add_days(&visible_start, -1));
1517 let next_disabled =
1518 self.is_disabled || effective_constraints.out_of_range(add_days(&visible_end, 1));
1519 let state_for_nav = self.state.clone();
1520 let nav_btn = |icon: &'static str,
1521 target: Date,
1522 key: String,
1523 focus: &gpui::FocusHandle,
1524 disabled: bool,
1525 inert: bool,
1526 window: &mut Window,
1527 cx: &mut App| {
1528 let state = state_for_nav.clone();
1529 let hover_bg = self.nav_hover_bg.unwrap_or(colors.default.color);
1531 let debug_key = key.clone();
1532 let button_id = ElementId::Name(key.clone().into());
1533 let radius = util::small_radius(cx);
1534 let nav_name = crate::i18n::ui_string(
1535 if key.contains("-prev") {
1536 crate::i18n::UiString::Previous
1537 } else {
1538 crate::i18n::UiString::Next
1539 },
1540 cx,
1541 );
1542 let press_box = crate::anim::PressBox {
1544 height: px(24.),
1545 padding_x: None,
1546 width: Some(px(24.)),
1547 min_width: None,
1548 text_size: px(16.),
1549 line_height: px(0.),
1550 gap: px(0.),
1551 radius: util::small_radius(cx),
1552 shrink_x: true,
1553 scale: crate::anim::PRESSED_SCALE_DEEP,
1554 };
1555 let button = div()
1556 .id(button_id.clone())
1557 .debug_selector(move || debug_key)
1560 .when(!disabled && !inert, |b| b.track_focus(focus))
1561 .flex()
1562 .items_center()
1563 .justify_center()
1564 .size(px(24.))
1568 .rounded(util::small_radius(cx))
1569 .child(
1573 gpui::svg()
1574 .size(px(16.))
1577 .path(icon)
1578 .text_color(colors.accent.soft_foreground(colors.foreground)),
1579 )
1580 .when(!disabled && !inert, |b| {
1581 let pressed = b
1582 .cursor(util::interactive_cursor(cx))
1583 .on_click(move |_, _, cx| {
1584 state.update(cx, |s, cx| {
1585 s.set_anchor(target);
1586 cx.notify();
1587 });
1588 });
1589 let pressed = crate::anim::hover_fade_with_duration(
1590 pressed,
1591 element_id::scoped(&button_id, "hover-fade"),
1592 (herogpui_core::with_alpha(colors.default.color, 0.0), hover_bg),
1593 None,
1594 None,
1595 move |fill| fill.rounded(radius),
1596 Some(100),
1597 window,
1598 cx,
1599 );
1600 crate::anim::pressed(pressed, press_box, cx)
1601 })
1602 .when(disabled, |b| b.opacity(layout.disabled_opacity))
1603 .when(year_picker_open, |b| b.invisible());
1604 let button = if inert {
1605 button
1606 } else {
1607 util::ring_overlay_if_focused(button, focus, true, radius, Vec::new(), window, cx)
1611 };
1612 button.a11y_named(a11y::Role::Button, &a11y::Name::labelled(nav_name))
1613 };
1614
1615 let heading = |text: String,
1618 key: String,
1619 focus: &gpui::FocusHandle,
1620 index: usize,
1621 window: &mut Window,
1622 cx: &mut App|
1623 -> gpui::AnyElement {
1624 let heading_name = text.clone();
1625 let label = div()
1626 .text_size(px(14.))
1627 .line_height(px(20.))
1628 .font_weight(gpui::FontWeight::MEDIUM)
1629 .child(text);
1630 let indicator_id =
1631 element_id::scoped(&self.id, format!("year-picker-indicator-{index}"));
1632 match &self.on_year_picker_open_change {
1633 None if year_picker_own.is_none() => label.into_any_element(),
1635 None => {
1636 let own = year_picker_own.clone();
1637 let opener = year_trigger_index.clone();
1638 let open = year_picker_open;
1639 let trigger = div()
1640 .id(ElementId::Name(key.into()))
1641 .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1642 .flex()
1643 .items_center()
1644 .gap(px(4.))
1648 .px(px(6.))
1649 .py(px(2.))
1650 .rounded(util::key_radius(cx))
1651 .when(!self.is_disabled && !self.inert, |trigger| {
1652 trigger
1653 .cursor(util::interactive_cursor(cx))
1654 .on_click(move |_, _, cx| {
1655 opener.update(cx, |value, _| *value = index);
1656 if let Some(held) = &own {
1657 held.update(cx, |value, cx| {
1658 *value = !open;
1659 cx.notify();
1660 });
1661 }
1662 })
1663 })
1664 .when(self.is_disabled, |trigger| {
1665 trigger.opacity(layout.disabled_opacity)
1666 });
1667 let trigger = if self.inert {
1668 trigger
1669 } else {
1670 util::ring_overlay_if_focused(
1671 trigger,
1672 focus,
1673 true,
1674 util::key_radius(cx),
1675 Vec::new(),
1676 window,
1677 cx,
1678 )
1679 };
1680 trigger
1681 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1682 .a11y_expanded(open)
1683 .child(label)
1684 .child(crate::anim::rotating_indicator_with_angle_ease_out(
1685 &indicator_id,
1686 open,
1687 gpui::svg()
1688 .size(px(12.))
1689 .path(icons::CHEVRON_DOWN)
1690 .text_color(colors.muted),
1691 crate::anim::YEAR_PICKER_INDICATOR_MS,
1692 crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1693 window,
1694 cx,
1695 ))
1696 .into_any_element()
1697 }
1698 Some(cb) => {
1699 let cb = cb.clone();
1700 let open = year_picker_open;
1701 let own = year_picker_own.clone();
1702 let opener = year_trigger_index.clone();
1703 let trigger = div()
1704 .id(ElementId::Name(key.into()))
1705 .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1706 .flex()
1707 .items_center()
1708 .gap(px(4.))
1712 .px(px(6.))
1713 .py(px(2.))
1714 .rounded(util::key_radius(cx))
1715 .when(!self.is_disabled && !self.inert, |trigger| {
1716 trigger
1717 .cursor(util::interactive_cursor(cx))
1718 .on_click(move |_, window, cx| {
1719 opener.update(cx, |value, _| *value = index);
1720 if let Some(held) = &own {
1723 held.update(cx, |value, cx| {
1724 *value = !open;
1725 cx.notify();
1726 });
1727 }
1728 cb(&!open, window, cx);
1729 })
1730 })
1731 .when(self.is_disabled, |trigger| {
1732 trigger.opacity(layout.disabled_opacity)
1733 });
1734 let trigger = if self.inert {
1735 trigger
1736 } else {
1737 util::ring_overlay_if_focused(
1738 trigger,
1739 focus,
1740 true,
1741 util::key_radius(cx),
1742 Vec::new(),
1743 window,
1744 cx,
1745 )
1746 };
1747 trigger
1748 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1749 .a11y_expanded(open)
1750 .child(label)
1751 .child(crate::anim::rotating_indicator_with_angle_ease_out(
1752 &indicator_id,
1753 open,
1754 gpui::svg()
1755 .size(px(12.))
1756 .path(icons::CHEVRON_DOWN)
1757 .text_color(colors.muted),
1758 crate::anim::YEAR_PICKER_INDICATOR_MS,
1759 crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1760 window,
1761 cx,
1762 ))
1763 .into_any_element()
1764 }
1765 }
1766 };
1767
1768 let mut root = div()
1769 .id(self.id.clone())
1770 .flex()
1771 .flex_col()
1772 .text_color(colors.surface.foreground)
1773 .when(!self.is_disabled && !self.inert && !year_picker_open, |el| {
1775 el.track_focus(&grid_focus)
1776 });
1777
1778 if !self.is_disabled && !self.inert && !year_picker_open {
1782 let held = cursor.clone();
1783 let focus_preview = focus_preview.clone();
1784 let selection_before_anchor = selection_before_anchor.clone();
1785 let focus = grid_focus.clone();
1786 let prev_control = prev_focus.clone();
1787 let next_control = next_focus.clone();
1788 let heading_controls = heading_focuses.clone();
1789 let controlled_focus = focused_value;
1790 let from_start = focused_value
1791 .or(selection_start)
1792 .unwrap_or_else(Date::today);
1793 let state = self.state.clone();
1794 let on_change = self.on_change.clone();
1795 let on_focus = self.on_focus_change.clone();
1796 let constraints = effective_constraints.clone();
1797 let range_date_unavailable = self.range_date_unavailable.clone();
1798 let allows_non_contiguous_ranges = self.allows_non_contiguous_ranges;
1799 let read_only = self.is_read_only;
1800 let system = self.system().clone();
1801 let duration = self.duration;
1802 let page_behavior = self.page_behavior;
1803 let grid = calendar_keys::DayGrid {
1804 system: system.clone(),
1805 duration,
1806 page_behavior,
1807 first_day,
1808 anchor,
1809 visible_start,
1810 visible_end,
1811 };
1812 root = root.on_key_down(move |event, window, cx| {
1813 let header_focused = prev_control.is_focused(window)
1814 || next_control.is_focused(window)
1815 || heading_controls
1816 .iter()
1817 .any(|handle| handle.is_focused(window));
1818 if header_focused || !focus.contains_focused(window, cx) {
1819 return;
1820 }
1821 let at = controlled_focus.or(*held.read(cx)).unwrap_or(from_start);
1822 let key = event.keystroke.key.as_str();
1823 let shift = event.keystroke.modifiers.shift;
1824 if key == "escape" {
1825 let restore = *selection_before_anchor.read(cx);
1826 let cancelled = state.update(cx, |s, cx| {
1827 if s.start.is_some() && s.end.is_none() {
1828 if let Some((start, end)) = restore {
1829 s.start = Some(start);
1830 s.end = Some(end);
1831 } else {
1832 s.start = None;
1833 }
1834 s.hovered = None;
1835 cx.notify();
1836 true
1837 } else {
1838 false
1839 }
1840 });
1841 focus_preview.update(cx, |preview, _| *preview = false);
1842 if cancelled {
1843 selection_before_anchor.update(cx, |saved, _| *saved = None);
1844 }
1845 return;
1846 }
1847 if matches!(key, "enter" | "space") {
1848 cx.stop_propagation();
1849 if event.is_held || read_only {
1850 return;
1851 }
1852 let (next, previous) = state.update(cx, |s, cx| {
1853 let previous = s.start.zip(s.end);
1854 let next = resolve_pick(
1855 s.start,
1856 s.end,
1857 at,
1858 &constraints,
1859 range_date_unavailable.as_ref(),
1860 );
1861 if let Some((start, end)) = next {
1862 s.start = Some(start);
1863 s.end = end;
1864 s.hovered = None;
1865 s.set_anchor(anchor);
1866 cx.notify();
1867 }
1868 (next, previous)
1869 });
1870 if let (Some(cb), Some((start, Some(end)))) = (&on_change, next) {
1871 cb(&start, &end, window, cx);
1872 }
1873 if let Some((_, end)) = next {
1874 focus_preview.update(cx, |preview, _| *preview = end.is_none());
1875 selection_before_anchor.update(cx, |saved, _| {
1876 *saved = if end.is_none() { previous } else { None };
1877 });
1878 }
1879 if let Some(next_focus) = next.and_then(|(_, end)| {
1880 end.is_none().then(|| {
1881 keyboard_range_focus(
1882 at,
1883 &constraints,
1884 range_date_unavailable.as_ref(),
1885 allows_non_contiguous_ranges,
1886 )
1887 })?
1888 }) {
1889 if controlled_focus.is_none() {
1890 held.update(cx, |focused, cx| {
1891 *focused = Some(next_focus);
1892 cx.notify();
1893 });
1894 state.update(cx, |s, cx| {
1895 let next_anchor = calendar_view::anchor_following_focus_in(
1896 &system,
1897 duration,
1898 first_day,
1899 anchor,
1900 visible_start,
1901 visible_end,
1902 next_focus,
1903 );
1904 if next_anchor != anchor {
1905 s.set_anchor(next_anchor);
1906 cx.notify();
1907 }
1908 });
1909 }
1910 if let Some(cb) = &on_focus {
1911 cb(&next_focus, window, cx);
1912 }
1913 }
1914 return;
1915 }
1916 let Some(next) = grid.key_target(key, at, shift) else {
1917 return;
1918 };
1919 let next = constraints.constrain(next);
1920 if controlled_focus.is_none() {
1921 held.update(cx, |v, cx| {
1922 *v = Some(next);
1923 cx.notify();
1924 });
1925 focus_preview.update(cx, |preview, _| *preview = true);
1926 state.update(cx, |s, cx| {
1929 s.hovered = None;
1930 let next_anchor = grid.anchor_after_key(key, next, shift);
1931 if next_anchor != anchor {
1932 s.set_anchor(next_anchor);
1933 cx.notify();
1934 }
1935 });
1936 }
1937 if let Some(cb) = &on_focus {
1938 cb(&next, window, cx);
1939 }
1940 });
1941 }
1942
1943 if !self.is_disabled && !self.inert && year_picker_open {
1944 let held = year_cursor;
1945 let focus = year_focus.clone();
1946 let years_for_keys = years.clone();
1947 let own = year_picker_own.clone();
1948 let on_open = self.on_year_picker_open_change.clone();
1949 let on_focus = self.on_focus_change.clone();
1950 let system = self.system().clone();
1951 let controlled_year = focused_value.map(|date| system.from_gregorian(date).0);
1952 let back_to_trigger = active_heading_focus.clone();
1953 root = calendar_keys::on_year_picker_keys(
1954 root,
1955 calendar_keys::YearPickerKeys {
1956 held,
1957 focus,
1958 years: years_for_keys,
1959 own,
1960 on_open,
1961 on_focus,
1962 system,
1963 controlled_year,
1964 back_to_trigger,
1965 active_year,
1966 anchor,
1967 },
1968 );
1969 }
1970
1971 let mut body = div().flex().flex_col().gap(px(4.));
1975 if self.duration.is_month_view() {
1976 root = root.w(column_width * columns as f32 + px(32.) * (columns - 1) as f32);
1977 let mut row = div().flex().gap(px(32.));
1978 let mut headings = div().flex().gap(px(32.)).pb(px(16.));
1979 for (i, &(y, m)) in months.iter().enumerate() {
1980 let first = i == 0;
1981 let last = i + 1 == columns;
1982 let mut col = div().flex().flex_col().gap(px(4.)).w(column_width);
1984 let spacer = || div().size(px(24.)).into_any_element();
1988 headings = headings.child(
1989 div().w(column_width).child(
1990 div()
1991 .flex()
1992 .flex_row()
1993 .items_center()
1994 .justify_between()
1995 .w_full()
1996 .px(px(2.))
1998 .child(if first {
1999 nav_btn(
2000 icons::CHEVRON_LEFT,
2001 nav_target(-1),
2002 format!("{base}-prev"),
2003 &prev_focus,
2004 previous_disabled,
2005 self.inert,
2006 window,
2007 cx,
2008 )
2009 .into_any_element()
2010 } else {
2011 spacer()
2012 })
2013 .child(heading(
2014 self.month_heading_text(y, m),
2015 format!("{base}-heading{i}"),
2016 &heading_focuses[i],
2017 i,
2018 window,
2019 cx,
2020 ))
2021 .child(if last {
2022 nav_btn(
2023 icons::CHEVRON_RIGHT,
2024 nav_target(1),
2025 format!("{base}-next"),
2026 &next_focus,
2027 next_disabled,
2028 self.inert,
2029 window,
2030 cx,
2031 )
2032 .into_any_element()
2033 } else {
2034 spacer()
2035 }),
2036 ),
2037 );
2038 col = col.child(self.weekday_header(cx));
2039 col = col.child(self.month_grid(y, m, &frame, cx));
2040 row = row.child(col);
2041 }
2042 root = root.child(headings);
2043 body = body.child(row);
2044 } else {
2045 root = root.w(crate::calendar::CALENDAR_WIDTH);
2046 root = root.child(
2047 div()
2048 .flex()
2049 .flex_row()
2050 .items_center()
2051 .justify_between()
2052 .w_full()
2053 .px(px(2.))
2055 .pb(px(16.))
2056 .child(nav_btn(
2057 icons::CHEVRON_LEFT,
2058 nav_target(-1),
2059 format!("{base}-prev"),
2060 &prev_focus,
2061 previous_disabled,
2062 self.inert,
2063 window,
2064 cx,
2065 ))
2066 .child(heading(
2067 calendar_view::range_heading(&linear),
2068 format!("{base}-heading"),
2069 &heading_focuses[0],
2070 0,
2071 window,
2072 cx,
2073 ))
2074 .child(nav_btn(
2075 icons::CHEVRON_RIGHT,
2076 nav_target(1),
2077 format!("{base}-next"),
2078 &next_focus,
2079 next_disabled,
2080 self.inert,
2081 window,
2082 cx,
2083 )),
2084 );
2085 body = body.child(self.weekday_header(cx));
2086 let mut grid = div()
2090 .id(element_id::scoped(&self.id, "grid"))
2091 .flex()
2092 .flex_col()
2093 .gap(px(4.))
2094 .py(px(2.));
2095 for row in calendar_view::week_aligned_rows(visible_start, visible_end, first_day) {
2096 let mut line = div().flex().flex_row();
2097 for (column, date) in row.into_iter().enumerate() {
2098 line = line.child(match date {
2099 Some(date) => self.range_cell(
2100 date,
2101 false,
2102 &frame,
2103 format!("{base}-{}", date.format_iso()),
2104 CellSlot { column, columns: 7 },
2105 cx,
2106 ),
2107 None => div().size(px(36.)).into_any_element(),
2108 });
2109 }
2110 grid = grid.child(line);
2111 }
2112 body = body.child(grid.a11y(a11y::Role::Grid));
2113 }
2114
2115 let mut viewport = div()
2116 .relative()
2117 .child(body.when(year_picker_open, |body| body.invisible()));
2118 if year_picker_open {
2119 viewport = viewport.child(self.year_grid(
2120 calendar_view::YearGridView {
2121 years: &years,
2122 active_year,
2123 base: &self.id,
2124 scroll: &year_scroll,
2125 reveal_row: reveal_year_row,
2126 },
2127 &year_focus,
2128 &active_heading_focus,
2129 year_picker_own.clone(),
2130 window,
2131 cx,
2132 ));
2133 }
2134 root = root.child(viewport);
2135
2136 if self.is_disabled {
2137 root = root.opacity(layout.disabled_opacity);
2138 }
2139
2140 root = util::apply_sx(root, &self.sx);
2141 let app_name = if let Some(&(year, month)) = months.first() {
2142 self.month_heading_text(year, month)
2143 } else {
2144 String::new()
2145 };
2146 root = root.a11y_named(a11y::Role::Application, &a11y::Name::labelled(app_name));
2147 root = util::record_focus_bounds(root, &grid_focus, window, cx);
2148
2149 if columns > 1 {
2150 let heading = heading_focuses
2151 .iter()
2152 .position(|focus| focus.is_focused(window))
2153 .or_else(|| prev_focus.is_focused(window).then_some(0))
2154 .or_else(|| next_focus.is_focused(window).then_some(columns - 1));
2155 let reveal = heading
2156 .map(|index| {
2157 let left = (column_width + px(32.)) * index as f32;
2158 (
2159 calendar_view::MonthScrollFocus::Heading(index),
2160 left,
2161 left + column_width,
2162 )
2163 })
2164 .or_else(|| {
2165 if year_picker_open {
2166 years
2167 .iter()
2168 .position(|year| *year == active_year)
2169 .map(|index| {
2170 let width =
2171 column_width * columns as f32 + px(32.) * (columns - 1) as f32;
2172 let cell = (width - px(16.)) / 3.;
2174 let left = px(4.) + (cell + px(4.)) * (index % 3) as f32;
2175 (
2176 calendar_view::MonthScrollFocus::Year(active_year),
2177 left,
2178 left + cell,
2179 )
2180 })
2181 } else {
2182 let date = focused_value.or(ring_at)?;
2183 let (year, month, _) = self.system().from_gregorian(date);
2184 let index = months.iter().position(|value| *value == (year, month))?;
2185 let column = (crate::calendar::weekday_index(date) + 7
2186 - first_day.monday_index())
2187 % 7;
2188 let left = (column_width + px(32.)) * index as f32
2189 + frame.cell_size * column as f32;
2190 Some((
2191 calendar_view::MonthScrollFocus::Day(date),
2192 left,
2193 left + frame.cell_size,
2194 ))
2195 }
2196 });
2197 calendar_view::scrolling_months(
2198 root.flex_shrink_0().mx_auto().into_any_element(),
2199 &self.id,
2200 reveal,
2201 window,
2202 cx,
2203 )
2204 } else {
2205 root.into_any_element()
2206 }
2207 }
2208}
2209
2210#[cfg(test)]
2214mod hover_tokens {
2215 #[test]
2216 fn the_today_cell_uses_the_accent_soft_tokens() {
2217 let source = include_str!("range_calendar.rs")
2220 .split("#[cfg(test)]")
2221 .next()
2222 .expect("the implementation section is always present");
2223 assert!(
2224 source.matches(".bg(accent.soft())").count() >= 2,
2225 "the today cell must fill `bg-accent-soft` like the range \
2226 interior (pinned `.range-calendar__cell[data-today]`)"
2227 );
2228 assert!(
2229 source.contains("let hover_bg = accent.soft_hover();"),
2230 "the today cell must hover `bg-accent-soft-hover` \
2231 (pinned `.range-calendar__cell[data-today]:hover`)"
2232 );
2233 assert!(
2234 !source.contains("cell.border_1()"),
2235 "the today cell must not invent an accent border"
2236 );
2237 }
2238
2239 #[test]
2244 fn the_pressed_cell_merges_background_with_the_scale() {
2245 let source = include_str!("range_calendar.rs")
2248 .split("#[cfg(test)]")
2249 .next()
2250 .expect("the implementation section is always present");
2251 assert!(
2252 !source.contains(".active("),
2253 "a chained `.active` after `anim::pressed` replaces the pressed \
2254 scale (gpui overwrites the active refinement)"
2255 );
2256 }
2257
2258 #[test]
2263 fn the_pressed_caps_recolour_and_the_interior_keeps_its_fill() {
2264 let source = include_str!("range_calendar.rs")
2265 .split("#[cfg(test)]")
2266 .next()
2267 .expect("the implementation section is always present");
2268 assert!(
2269 source.contains("pressed_with_background(cell, press_box, accent.hover(), cx)"),
2270 "a pressed range cap must fill `bg-accent-hover` \
2271 (pinned `[data-pressed] [data-selection-start|end]`)"
2272 );
2273 assert!(
2274 source.contains("} else if is_today || in_range {"),
2275 "a pressed middle or today cell must keep its `bg-accent-soft` \
2276 fill (the pinned pressed state recolours only the caps)"
2277 );
2278 }
2279
2280 #[test]
2281 fn the_year_picker_trigger_hovers_nothing() {
2282 let source = include_str!("range_calendar.rs")
2285 .split("#[cfg(test)]")
2286 .next()
2287 .expect("the implementation section is always present");
2288 assert!(
2289 !source.contains("colors.default.soft_hover()"),
2290 "the year-picker trigger must not invent a hover background"
2291 );
2292 }
2293
2294 #[test]
2295 fn the_year_picker_indicator_rotates_one_down_chevron() {
2296 let source = include_str!("range_calendar.rs")
2297 .split("#[cfg(test)]")
2298 .next()
2299 .expect("the implementation section is always present");
2300 assert_eq!(
2301 source
2302 .matches("crate::anim::rotating_indicator_with_angle_ease_out(")
2303 .count(),
2304 2,
2305 "both heading render paths must share the animated indicator"
2306 );
2307 assert_eq!(
2308 source
2309 .matches("crate::anim::YEAR_PICKER_INDICATOR_ANGLE")
2310 .count(),
2311 2,
2312 "the year picker must use the pinned 90-degree angle"
2313 );
2314 assert_eq!(
2315 source.matches(".path(icons::CHEVRON_DOWN)").count(),
2316 2,
2317 "open and closed states must reuse one down-chevron asset"
2318 );
2319 assert!(
2320 !source.contains("icons::CHEVRON_UP"),
2321 "the year picker must rotate one asset instead of swapping glyphs"
2322 );
2323 }
2324
2325 #[test]
2326 fn the_nav_button_fades_its_hover_fill_without_replacing_press_scale() {
2327 let source = include_str!("range_calendar.rs")
2328 .split("#[cfg(test)]")
2329 .next()
2330 .expect("the implementation section is always present");
2331 assert!(
2332 source.contains("crate::anim::hover_fade_with_duration(")
2333 && source.contains("Some(100)"),
2334 "the range navigation button must use HeroUI's 100ms hover fill"
2335 );
2336 assert!(
2337 source.contains("crate::anim::pressed(pressed, press_box, cx)"),
2338 "the navigation button must retain the separate 0.95 press skin"
2339 );
2340 }
2341
2342 #[test]
2347 fn the_interior_text_stays_base_foreground_unless_today() {
2348 let source = include_str!("range_calendar.rs")
2349 .split("#[cfg(test)]")
2350 .next()
2351 .expect("the implementation section is always present");
2352 assert!(
2353 source.contains("track = track.bg(accent.soft());"),
2354 "the range track fill must not set a text colour (pinned \
2355 `.range-calendar__cell-button` keeps `text-foreground`)"
2356 );
2357 let today = source
2358 .find("} else if is_today {")
2359 .expect("the today branch exists");
2360 let interior = source
2361 .find("} else if in_range {")
2362 .expect("the interior branch exists");
2363 assert!(
2364 today < interior,
2365 "the today branch must precede the interior branch, or a today \
2366 cell inside the range would fall through to the base foreground"
2367 );
2368 }
2369
2370 #[test]
2374 fn the_track_rounds_row_boundaries_and_cap_sides() {
2375 let source = include_str!("range_calendar.rs")
2376 .split("#[cfg(test)]")
2377 .next()
2378 .expect("the implementation section is always present");
2379 for snippet in [
2380 "let cap_radius = util::control_radius(cx);",
2381 "let edge_radius = util::key_radius(cx);",
2382 "} else if slot.is_first() {",
2383 "} else if slot.is_last() {",
2384 ".rounded_tl(left)",
2385 ".rounded_bl(left)",
2386 ".rounded_tr(right)",
2387 ".rounded_br(right)",
2388 ] {
2389 assert!(
2390 source.contains(snippet),
2391 "the track rounding must implement the pinned row-boundary \
2392 and cap variants; missing {snippet:?}"
2393 );
2394 }
2395 }
2396}
2397
2398#[cfg(test)]
2399mod calendar_system_tests {
2400 use super::*;
2401
2402 #[test]
2403 fn unavailable_scan_uses_the_selected_calendar_month() {
2404 let system =
2405 crate::calendar_system::CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
2406 let constraints = DateConstraints {
2407 is_date_unavailable: Some(Arc::new(|date| date == Date::new(2026, 2, 21))),
2408 ..Default::default()
2409 };
2410 let effective = effective_range_constraints(
2411 &system,
2412 Some(Date::new(2026, 1, 20)),
2413 VisibleDuration::Months(1),
2414 &constraints,
2415 None,
2416 false,
2417 );
2418 assert_eq!(effective.max_value, None);
2419 }
2420}
2421
2422crate::util::impl_component_styled!(RangeCalendar);