1use gpui::{
5 prelude::*, px, App, Entity, IntoElement, RenderOnce, SharedString, StatefulInteractiveElement,
6 Styled, Window,
7};
8use std::sync::OnceLock;
9
10use herogpui_core::element_id;
11use herogpui_theme::ActiveTheme;
12
13use crate::a11y::{self, A11y as _};
14use crate::calendar_keys;
15use crate::calendar_view::{self, PageBehavior, SelectionAlignment, VisibleDuration};
16use crate::date_constraints::{DateConstraints, Weekday};
17
18use crate::icons;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct Date {
23 pub year: i32,
25 pub month: u32,
27 pub day: u32,
29}
30
31impl Date {
32 pub fn new(year: i32, month: u32, day: u32) -> Self {
34 Self { year, month, day }
35 }
36
37 pub fn today() -> Self {
39 let now = jiff::Zoned::now();
43 civil_date_at(now.timestamp().as_second(), now.offset().seconds())
44 }
45
46 pub fn format_iso(&self) -> String {
48 format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
49 }
50}
51
52fn civil_date_at(unix_secs: i64, utc_offset_secs: i32) -> Date {
56 civil_from_days((unix_secs + i64::from(utc_offset_secs)).div_euclid(86_400))
57}
58
59pub fn civil_from_days(z: i64) -> Date {
61 let z = z + 719_468;
62 let era = z.div_euclid(146_097);
63 let doe = z.rem_euclid(146_097);
64 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
65 let y = yoe + era * 400;
66 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
67 let mp = (5 * doy + 2) / 153;
68 let d = doy - (153 * mp + 2) / 5 + 1;
69 let m = if mp < 10 { mp + 3 } else { mp - 9 };
70 Date::new((if m <= 2 { y + 1 } else { y }) as i32, m as u32, d as u32)
71}
72
73pub fn add_days(date: &Date, delta: i64) -> Date {
75 civil_from_days(days_from_civil(date) + delta)
76}
77
78pub fn weekday_index(date: Date) -> usize {
80 (days_from_civil(&date) + 3).rem_euclid(7) as usize
82}
83
84pub fn month_abbr(month: u32) -> &'static str {
90 &system_month_abbrs()[(month.clamp(1, 12) - 1) as usize]
91}
92
93pub fn days_from_civil(d: &Date) -> i64 {
95 let y = if d.month <= 2 { d.year - 1 } else { d.year } as i64;
96 let era = y.div_euclid(400);
97 let yoe = y - era * 400;
98 let m = d.month as i64;
99 let mp = if m > 2 { m - 3 } else { m + 9 };
100 let doy = (153 * mp + 2) / 5 + d.day as i64 - 1;
101 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
102 era * 146_097 + doe - 719_468
103}
104
105pub fn days_in_month(year: i32, month: u32) -> u32 {
107 match month {
108 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
109 4 | 6 | 9 | 11 => 30,
110 2 if is_leap(year) => 29,
111 2 => 28,
112 _ => 28,
113 }
114}
115
116fn is_leap(year: i32) -> bool {
117 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
118}
119
120fn first_weekday(year: i32, month: u32) -> usize {
122 let days = days_from_civil(&Date::new(year, month, 1));
123 ((days + 3).rem_euclid(7)) as usize
125}
126
127pub const CALENDAR_WIDTH: gpui::Pixels = px(252.);
130
131const FALLBACK_MONTH_NAMES: [&str; 12] = [
134 "January",
135 "February",
136 "March",
137 "April",
138 "May",
139 "June",
140 "July",
141 "August",
142 "September",
143 "October",
144 "November",
145 "December",
146];
147
148pub(crate) fn month_names_for_locale(locale: &str) -> Option<[String; 12]> {
154 month_labels_for_locale(locale, icu_datetime::fieldsets::M::long())
155}
156
157fn month_labels_for_locale(
159 locale: &str,
160 fieldset: icu_datetime::fieldsets::M,
161) -> Option<[String; 12]> {
162 use icu_datetime::{input::Date as IcuDate, DateTimeFormatter};
163 use icu_locale_core::Locale as IcuLocale;
164
165 let locale = locale.parse::<IcuLocale>().ok()?;
166 let formatter = DateTimeFormatter::try_new(locale.into(), fieldset).ok()?;
167 let mut labels: Vec<String> = Vec::with_capacity(12);
168 for month in 1..=12u8 {
169 let date = IcuDate::try_new_iso(2000, month, 1).ok()?;
171 labels.push(formatter.format(&date).to_string());
172 }
173 labels.try_into().ok()
174}
175
176pub(crate) fn month_abbrs_for_locale(locale: &str) -> Option<[String; 12]> {
178 month_labels_for_locale(locale, icu_datetime::fieldsets::M::medium())
179}
180
181fn system_month_names() -> &'static [String; 12] {
183 static SYSTEM_MONTH_NAMES: OnceLock<[String; 12]> = OnceLock::new();
184 SYSTEM_MONTH_NAMES.get_or_init(|| {
185 crate::date_constraints::system_locale_tags()
186 .iter()
187 .find_map(|tag| month_names_for_locale(tag))
188 .unwrap_or_else(|| FALLBACK_MONTH_NAMES.map(str::to_owned))
189 })
190}
191
192pub(crate) fn month_heading_for_locale(locale: &str, year: i32, month: u32) -> Option<String> {
198 date_heading_for_locale(locale, Date::new(year, month.clamp(1, 12), 1))
199}
200
201pub(crate) fn date_heading_for_locale(locale: &str, date: Date) -> Option<String> {
203 use icu_datetime::{fieldsets, input::Date as IcuDate, options::YearStyle, DateTimeFormatter};
204 use icu_locale_core::Locale as IcuLocale;
205
206 let locale = locale.parse::<IcuLocale>().ok()?;
207 let formatter = DateTimeFormatter::try_new(
208 locale.into(),
209 fieldsets::YM::long().with_year_style(YearStyle::Full),
210 )
211 .ok()?;
212 let month = u8::try_from(date.month).ok()?;
213 let day = u8::try_from(date.day).ok()?;
214 Some(
215 formatter
216 .format(&IcuDate::try_new_iso(date.year, month, day).ok()?)
217 .to_string(),
218 )
219}
220
221fn system_month_heading_locale() -> Option<&'static String> {
223 static SYSTEM_HEADING_LOCALE: OnceLock<Option<String>> = OnceLock::new();
224 SYSTEM_HEADING_LOCALE
225 .get_or_init(|| {
226 crate::date_constraints::system_locale_tags()
227 .into_iter()
228 .find(|tag| month_heading_for_locale(tag, 2000, 1).is_some())
229 })
230 .as_ref()
231}
232
233pub fn month_year_heading(year: i32, month: u32) -> String {
235 system_month_heading_locale()
236 .and_then(|locale| month_heading_for_locale(locale, year, month))
237 .unwrap_or_else(|| format!("{} {year}", month_name(month)))
238}
239
240pub(crate) fn day_cell_name(date: String, is_selected: bool, cx: &App) -> SharedString {
244 if is_selected {
245 crate::i18n::ui_string_with(crate::i18n::UiString::DateSelected, &date, cx)
246 } else {
247 date.into()
248 }
249}
250
251fn system_month_abbrs() -> &'static [String; 12] {
253 static SYSTEM_MONTH_ABBRS: OnceLock<[String; 12]> = OnceLock::new();
254 SYSTEM_MONTH_ABBRS.get_or_init(|| {
255 crate::date_constraints::system_locale_tags()
256 .iter()
257 .find_map(|tag| month_abbrs_for_locale(tag))
258 .unwrap_or_else(|| {
259 FALLBACK_MONTH_NAMES.map(|name| name.chars().take(3).collect::<String>())
260 })
261 })
262}
263
264fn next_month(y: i32, m: u32) -> (i32, u32) {
265 if m == 12 {
266 (y + 1, 1)
267 } else {
268 (y, m + 1)
269 }
270}
271
272fn prev_month(y: i32, m: u32) -> (i32, u32) {
273 if m == 1 {
274 (y - 1, 12)
275 } else {
276 (y, m - 1)
277 }
278}
279
280pub fn bump_month(y: i32, m: u32, dir: i32) -> (i32, u32) {
282 if dir >= 0 {
283 next_month(y, m)
284 } else {
285 prev_month(y, m)
286 }
287}
288
289pub fn add_months(year: i32, month: u32, delta: i32) -> (i32, u32) {
296 let total = i64::from(year) * 12 + i64::from(month) - 1 + i64::from(delta);
297 #[allow(clippy::cast_possible_truncation)]
298 let y = total.div_euclid(12) as i32;
299 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
300 let m = total.rem_euclid(12) as u32 + 1;
301 (y, m)
302}
303
304pub fn month_name(month: u32) -> &'static str {
306 &system_month_names()[(month.clamp(1, 12) - 1) as usize]
307}
308
309pub fn first_weekday_pub(year: i32, month: u32) -> usize {
311 first_weekday(year, month)
312}
313
314pub struct CalendarState {
320 pub view_year: i32,
322 pub view_month: u32,
324 pub view_day: u32,
326 pub selected: Option<Date>,
328 pub selected_dates: Vec<Date>,
331 pub user_navigated: bool,
334}
335
336impl CalendarState {
337 pub fn new(_cx: &mut App) -> Self {
339 let t = Date::today();
340 Self {
341 view_year: t.year,
342 view_month: t.month,
343 view_day: t.day,
344 selected: None,
345 selected_dates: Vec::new(),
346 user_navigated: false,
347 }
348 }
349
350 pub fn with_selected(_cx: &mut App, selected: Date) -> Self {
352 Self {
353 view_year: selected.year,
354 view_month: selected.month,
355 view_day: selected.day,
356 selected: Some(selected),
357 selected_dates: vec![selected],
358 user_navigated: false,
359 }
360 }
361
362 pub fn selected(&self) -> Option<Date> {
364 self.selected
365 }
366
367 pub fn selected_dates(&self) -> &[Date] {
369 &self.selected_dates
370 }
371
372 pub fn toggle(&mut self, date: Date, mode: herogpui_core::SelectionMode) {
375 match mode {
376 herogpui_core::SelectionMode::None => {}
377 herogpui_core::SelectionMode::Single => {
378 self.selected = Some(date);
379 self.selected_dates = vec![date];
380 }
381 herogpui_core::SelectionMode::Multiple => {
382 match self.selected_dates.iter().position(|d| *d == date) {
383 Some(i) => {
384 self.selected_dates.remove(i);
385 self.selected = self.selected_dates.last().copied();
386 }
387 None => {
388 self.selected_dates.push(date);
389 self.selected = Some(date);
390 }
391 }
392 }
393 }
394 self.user_navigated = true;
395 }
396
397 pub fn anchor(&self) -> Date {
399 Date::new(self.view_year, self.view_month, self.view_day.max(1))
400 }
401
402 pub fn set_anchor(&mut self, date: Date) {
404 self.view_year = date.year;
405 self.view_month = date.month;
406 self.view_day = date.day;
407 self.user_navigated = true;
408 }
409}
410
411type OnChange = std::sync::Arc<dyn Fn(&Option<Date>, &mut Window, &mut App) + 'static>;
412type OnChangeAll = std::sync::Arc<dyn Fn(&[Date], &mut Window, &mut App) + 'static>;
413
414#[derive(Clone, Debug)]
419#[non_exhaustive]
420pub struct CalendarCellState {
421 pub date: Date,
423 pub formatted_date: SharedString,
425 pub is_selected: bool,
427 pub is_unavailable: bool,
429 pub is_outside_month: bool,
431 pub is_today: bool,
433 pub is_disabled: bool,
435}
436
437#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
439#[derive(IntoElement)]
440pub struct Calendar {
441 value: Option<Option<Date>>,
445 values: Option<Vec<Date>>,
449 default_value: Option<Date>,
451 default_values: Option<Vec<Date>>,
453 id: gpui::ElementId,
454 state: Entity<CalendarState>,
455 constraints: DateConstraints,
456 is_disabled: bool,
457 inert: bool,
461 is_read_only: bool,
462 autofocus_grid: bool,
465 calendar_system: Option<crate::calendar_system::CalendarSystem>,
468 cell_indicator: Option<Box<dyn Fn(Date) -> bool + 'static>>,
471 cell: Option<Box<dyn Fn(&CalendarCellState) -> gpui::AnyElement + 'static>>,
474 nav_icons: Option<(&'static str, &'static str)>,
476 is_invalid: bool,
477 focused_value: Option<Date>,
478 selection_mode: herogpui_core::SelectionMode,
479 duration: VisibleDuration,
480 page_behavior: PageBehavior,
481 selection_alignment: SelectionAlignment,
482 year_picker_open: Option<bool>,
485 default_year_picker_open: bool,
486 visible_years: Option<usize>,
489 year_heading_offset_months: i32,
491 on_year_picker_open_change:
492 Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
493 on_focus_change: Option<std::sync::Arc<dyn Fn(&Date, &mut Window, &mut App) + 'static>>,
494 on_change: Option<OnChange>,
495 on_change_all: Option<OnChangeAll>,
496 nav_hover_bg: Option<gpui::Hsla>,
498 year_hover_bg: Option<gpui::Hsla>,
500 day_hover_bg: Option<gpui::Hsla>,
503 sx: Option<Box<gpui::StyleRefinement>>,
505}
506
507impl Calendar {
508 pub fn locale(mut self, tag: impl AsRef<str>) -> Self {
517 self.calendar_system = crate::calendar_system::CalendarSystem::for_locale(tag.as_ref());
518 self
519 }
520
521 fn system(&self) -> &crate::calendar_system::CalendarSystem {
523 self.calendar_system
524 .as_ref()
525 .unwrap_or_else(|| crate::calendar_system::system())
526 }
527
528 pub fn focused_value(mut self, date: Date) -> Self {
531 self.focused_value = Some(date);
532 self
533 }
534
535 pub fn on_focus_change(
537 mut self,
538 handler: impl Fn(&Date, &mut Window, &mut App) + 'static,
539 ) -> Self {
540 self.on_focus_change = Some(std::sync::Arc::new(handler));
541 self
542 }
543
544 pub fn value(mut self, date: Option<Date>) -> Self {
555 self.value = Some(date);
556 self
557 }
558
559 pub fn values(mut self, dates: impl IntoIterator<Item = Date>) -> Self {
570 self.values = Some(dates.into_iter().collect());
571 self
572 }
573
574 pub fn new(state: Entity<CalendarState>) -> Self {
576 Self {
577 value: None,
578 values: None,
579 default_value: None,
580 default_values: None,
581 id: gpui::ElementId::named_usize("cal", state.entity_id().as_u64() as usize),
582 state,
583 constraints: DateConstraints::new().with_hero_calendar_bounds(),
584 is_disabled: false,
585 inert: false,
586 is_read_only: false,
587 autofocus_grid: false,
588 calendar_system: None,
589 cell_indicator: None,
590 cell: None,
591 nav_icons: None,
592 is_invalid: false,
593 focused_value: None,
594 selection_mode: herogpui_core::SelectionMode::Single,
595 duration: VisibleDuration::default(),
596 page_behavior: PageBehavior::default(),
597 selection_alignment: SelectionAlignment::default(),
598 year_picker_open: None,
599 default_year_picker_open: false,
600 visible_years: None,
601 year_heading_offset_months: 0,
602 on_year_picker_open_change: None,
603 on_focus_change: None,
604 on_change: None,
605 on_change_all: None,
606 day_hover_bg: None,
607 nav_hover_bg: None,
608 year_hover_bg: None,
609 sx: None,
610 }
611 }
612
613 pub fn default_value(mut self, value: Date) -> Self {
618 self.default_value = Some(value);
619 self
620 }
621
622 pub fn default_values(mut self, values: impl IntoIterator<Item = Date>) -> Self {
624 self.default_values = Some(values.into_iter().collect());
625 self
626 }
627
628 pub fn min_value(mut self, date: Date) -> Self {
630 self.constraints.min_value = Some(date);
631 self
632 }
633
634 pub fn max_value(mut self, date: Date) -> Self {
636 self.constraints.max_value = Some(date);
637 self
638 }
639
640 pub fn is_date_unavailable(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
642 self.constraints.is_date_unavailable = Some(std::sync::Arc::new(f));
643 self
644 }
645
646 pub fn first_day_of_week(mut self, day: Weekday) -> Self {
648 self.constraints.first_day_of_week = day;
649 self
650 }
651
652 pub fn weeks_in_month(mut self, rows: usize) -> Self {
654 self.constraints.weeks_in_month = Some(rows);
655 self
656 }
657
658 pub fn is_disabled(mut self, v: bool) -> Self {
660 self.is_disabled = v;
661 self
662 }
663
664 pub(crate) fn inert(mut self, v: bool) -> Self {
670 self.inert = v;
671 self
672 }
673
674 pub fn cell(
682 mut self,
683 render: impl Fn(&CalendarCellState) -> gpui::AnyElement + 'static,
684 ) -> Self {
685 self.cell = Some(Box::new(render));
686 self
687 }
688
689 pub fn cell_indicator(mut self, f: impl Fn(Date) -> bool + 'static) -> Self {
691 self.cell_indicator = Some(Box::new(f));
692 self
693 }
694
695 pub fn nav_icons(mut self, previous: &'static str, next: &'static str) -> Self {
697 self.nav_icons = Some((previous, next));
698 self
699 }
700
701 pub fn is_read_only(mut self, v: bool) -> Self {
703 self.is_read_only = v;
704 self
705 }
706
707 pub(crate) fn autofocus_grid(mut self, v: bool) -> Self {
715 self.autofocus_grid = v;
716 self
717 }
718
719 pub fn is_invalid(mut self, v: bool) -> Self {
721 self.is_invalid = v;
722 self
723 }
724
725 pub fn constraints(mut self, constraints: DateConstraints) -> Self {
727 self.constraints = constraints.with_hero_calendar_bounds();
728 self
729 }
730
731 pub fn selection_mode(mut self, mode: herogpui_core::SelectionMode) -> Self {
736 self.selection_mode = mode;
737 self
738 }
739
740 pub fn visible_duration(mut self, duration: VisibleDuration) -> Self {
743 self.duration = duration;
744 self
745 }
746
747 pub fn page_behavior(mut self, behavior: PageBehavior) -> Self {
750 self.page_behavior = behavior;
751 self
752 }
753
754 pub fn selection_alignment(mut self, alignment: SelectionAlignment) -> Self {
757 self.selection_alignment = alignment;
758 self
759 }
760
761 pub fn is_year_picker_open(mut self, v: bool) -> Self {
763 self.year_picker_open = Some(v);
764 self
765 }
766
767 pub fn default_year_picker_open(mut self, v: bool) -> Self {
772 self.default_year_picker_open = v;
773 self
774 }
775
776 pub fn visible_years(mut self, count: usize) -> Self {
778 self.visible_years = Some(count.max(1));
779 self
780 }
781
782 pub fn offset(mut self, months: i32) -> Self {
784 self.year_heading_offset_months = months;
785 self
786 }
787
788 pub fn on_year_picker_open_change(
791 mut self,
792 f: impl Fn(&bool, &mut Window, &mut App) + 'static,
793 ) -> Self {
794 self.on_year_picker_open_change = Some(std::sync::Arc::new(f));
795 self
796 }
797
798 pub fn on_change(mut self, f: impl Fn(&Option<Date>, &mut Window, &mut App) + 'static) -> Self {
800 self.on_change = Some(std::sync::Arc::new(f));
801 self
802 }
803
804 pub fn on_change_all(mut self, f: impl Fn(&[Date], &mut Window, &mut App) + 'static) -> Self {
806 self.on_change_all = Some(std::sync::Arc::new(f));
807 self
808 }
809
810 pub fn nav_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
812 self.nav_hover_bg = Some(color.into());
813 self
814 }
815
816 pub fn year_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
818 self.year_hover_bg = Some(color.into());
819 self
820 }
821
822 pub fn day_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
825 self.day_hover_bg = Some(color.into());
826 self
827 }
828
829 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
834 crate::util::refine_sx(&mut self.sx, style);
835 self
836 }
837}
838
839struct Frame<'a> {
842 selected: Option<Date>,
843 selected_dates: &'a [Date],
845 today: Date,
846 cursor: &'a Entity<Option<Date>>,
847 base: &'a str,
848 focused: Option<Date>,
852 anchor: Date,
853 visible_start: Option<Date>,
854 cell_size: gpui::Pixels,
855}
856
857impl Calendar {
858 fn day_cell(
860 &self,
861 date: Date,
862 outside_month: bool,
863 frame: &Frame<'_>,
864 key: String,
865 cx: &App,
866 ) -> gpui::AnyElement {
867 let colors = cx.colors();
868 let accent = colors.accent;
869 let unavailable = self.constraints.is_unavailable(date);
870 let disabled = outside_month
871 || self.is_disabled
872 || self.constraints.out_of_range(date)
873 || frame
874 .visible_start
875 .is_some_and(|start| days_from_civil(&date) < days_from_civil(&start));
876 let focusable = !disabled;
877 let eligible = focusable && !unavailable;
878 let selectable = eligible && !self.is_read_only && !self.inert;
879 let is_sel = eligible
881 && if self.selection_mode == herogpui_core::SelectionMode::Multiple {
882 frame.selected_dates.contains(&date)
883 } else {
884 frame.selected == Some(date)
885 };
886 let is_today = date == frame.today;
887
888 let indicator_key = format!("{key}-indicator");
891 let mut circle = gpui::div()
895 .id(gpui::ElementId::Name(key.clone().into()))
896 .debug_selector(move || key)
897 .size(frame.cell_size)
898 .rounded_full()
899 .flex()
900 .items_center()
901 .justify_center()
902 .text_size(px(14.))
903 .line_height(px(20.))
904 .font_weight(gpui::FontWeight::MEDIUM)
905 .child(match &self.cell {
906 Some(render) => render(&CalendarCellState {
907 date,
908 formatted_date: self.day_label(date).into(),
909 is_selected: is_sel,
910 is_unavailable: unavailable,
911 is_outside_month: outside_month,
912 is_today,
913 is_disabled: disabled,
914 }),
915 None => self.day_label(date).into_any_element(),
916 });
917
918 let marker = if self.is_invalid {
919 colors.danger.color
920 } else {
921 accent.color
922 };
923
924 let press_box = crate::anim::PressBox {
927 height: frame.cell_size,
928 padding_x: None,
929 width: Some(frame.cell_size),
930 min_width: None,
931 text_size: px(14.),
932 line_height: px(20.),
933 gap: px(0.),
934 radius: frame.cell_size / 2.,
935 shrink_x: true,
936 scale: crate::anim::PRESSED_SCALE_DEEP,
937 };
938 if outside_month {
939 circle = circle.text_color(colors.muted);
940 } else if is_sel {
941 circle = circle.bg(marker).text_color(if self.is_invalid {
942 colors.danger.foreground
943 } else {
944 accent.foreground
945 });
946 if selectable {
947 let pressed_bg = if self.is_invalid {
951 colors.danger.hover()
952 } else {
953 accent.hover()
954 };
955 circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
956 }
957 } else if disabled || unavailable {
958 circle = circle
961 .text_color(colors.muted)
962 .cursor(gpui::CursorStyle::OperationNotAllowed)
963 .when(!self.is_disabled, |cell| cell.opacity(cx.layout().disabled_opacity));
966 if disabled {
967 circle = circle.line_through();
968 }
969 } else if is_today {
970 circle = circle
974 .bg(accent.soft())
975 .text_color(accent.soft_foreground(colors.foreground));
976 if selectable {
977 let hover_bg = accent.soft_hover();
978 let pressed_bg = colors.default.color;
979 circle = circle
980 .cursor(crate::util::interactive_cursor(cx))
981 .hover(move |s| s.bg(hover_bg));
982 circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
986 }
987 } else {
988 circle = circle.text_color(colors.foreground);
989 if selectable {
990 let hover_bg = self.day_hover_bg.unwrap_or(colors.default.color);
993 let pressed_bg = hover_bg;
994 circle = circle
995 .cursor(crate::util::interactive_cursor(cx))
996 .hover(move |s| s.bg(hover_bg));
997 circle = crate::anim::pressed_with_background(circle, press_box, pressed_bg, cx);
998 }
999 }
1000
1001 let circle = crate::util::with_focus_ring_overlay(
1013 circle,
1014 crate::util::shows_focus_ring(
1015 !self.inert && !outside_month && frame.focused == Some(date),
1016 cx,
1017 ),
1018 true,
1019 frame.cell_size / 2.,
1020 Vec::new(),
1021 cx,
1022 );
1023 let mut circle = circle
1030 .a11y_named(
1031 a11y::Role::Button,
1032 &a11y::Name::labelled(day_cell_name(
1033 format!("{} {}", date.day, month_year_heading(date.year, date.month)),
1034 is_sel,
1035 cx,
1036 )),
1037 )
1038 .a11y_selected(is_sel);
1039
1040 if selectable {
1041 let cursor = frame.cursor.clone();
1042 let st = self.state.clone();
1043 let selection_mode = self.selection_mode;
1044 let on_change = self.on_change.clone();
1045 let on_change_all = self.on_change_all.clone();
1046 let on_focus = self.on_focus_change.clone();
1047 let anchor = frame.anchor;
1048 circle = circle.on_click(move |_, window, cx| {
1049 cursor.update(cx, |focused, cx| {
1050 *focused = Some(date);
1051 cx.notify();
1052 });
1053 if let Some(cb) = &on_focus {
1054 cb(&date, window, cx);
1055 }
1056 let mode = selection_mode;
1057 let selected_dates = st.update(cx, |s, cx| {
1058 s.toggle(date, mode);
1061 s.set_anchor(anchor);
1062 cx.notify();
1063 s.selected_dates.clone()
1064 });
1065 if let Some(cb) = &on_change {
1066 cb(&Some(date), window, cx);
1067 }
1068 if let Some(cb) = &on_change_all {
1069 cb(&selected_dates, window, cx);
1070 }
1071 });
1072 }
1073
1074 let marked = self.cell_indicator.as_ref().is_some_and(|f| f(date));
1077 gpui::div()
1078 .flex_1()
1079 .h(frame.cell_size)
1080 .relative()
1081 .flex()
1082 .items_center()
1083 .justify_center()
1084 .child(circle)
1085 .when(marked, |cell| {
1086 cell.child(
1087 gpui::div()
1088 .absolute()
1089 .debug_selector(move || indicator_key)
1092 .bottom(px(4.))
1097 .size(px(3.))
1101 .rounded(px(2.))
1102 .bg(if is_sel { accent.foreground } else { marker }),
1103 )
1104 })
1105 .when(outside_month, |cell| cell.opacity(0.5))
1106 .into_any_element()
1107 }
1108
1109 fn weekday_header(&self, cx: &App) -> gpui::Div {
1112 let muted = cx.colors().muted;
1113 gpui::div().flex().children(
1114 self.constraints
1115 .first_day_of_week
1116 .header_row()
1117 .into_iter()
1118 .enumerate()
1119 .map(|(column, d)| {
1120 gpui::div()
1121 .flex_1()
1122 .debug_selector({
1123 let key = format!("{:?}-weekday-{column}", self.id);
1124 move || key
1125 })
1126 .pb(px(8.))
1127 .text_center()
1128 .text_size(px(12.))
1130 .line_height(px(16.))
1131 .font_weight(gpui::FontWeight::MEDIUM)
1132 .text_color(muted)
1133 .child(d.to_owned())
1134 }),
1135 )
1136 }
1137
1138 fn month_heading_text(&self, year: i32, month: u32) -> String {
1145 let system = self.system();
1146 let (year, month) = system.add_months(year, month, self.year_heading_offset_months);
1147 let Some(date) = system.to_gregorian(year, month, 1) else {
1148 return String::new();
1149 };
1150 date_heading_for_locale(system.locale(), date)
1151 .unwrap_or_else(|| month_year_heading(date.year, date.month))
1152 }
1153
1154 fn day_label(&self, date: Date) -> String {
1160 self.system().from_gregorian(date).2.to_string()
1161 }
1162
1163 fn spill_cell(
1170 &self,
1171 date: Option<Date>,
1172 outside_month: bool,
1173 frame: &Frame<'_>,
1174 key: String,
1175 cx: &App,
1176 ) -> gpui::AnyElement {
1177 match date {
1178 Some(date) => self.day_cell(date, outside_month, frame, key, cx),
1179 None => gpui::div().size(frame.cell_size).into_any_element(),
1180 }
1181 }
1182
1183 fn month_grid(&self, y: i32, m: u32, frame: &Frame<'_>, cx: &App) -> gpui::AnyElement {
1185 let system = self.system();
1186 let lead = self.constraints.lead_cells_in(system, y, m);
1187 let dim = system.days_in_month(y, m) as usize;
1188 let rows = self.constraints.rows_in(system, y, m);
1189
1190 let mut grid = gpui::div()
1196 .id(element_id::scoped(&self.id, format!("grid-{y}-{m}")))
1197 .flex()
1198 .flex_col();
1199 for r in 0..rows {
1200 let mut line = gpui::div().flex();
1201 for c in 0..7 {
1202 let idx = r * 7 + c;
1203 let slot: gpui::AnyElement = if idx < lead {
1204 let (py, pm) = system.add_months(y, m, -1);
1205 let day = system.days_in_month(py, pm) as usize - lead + idx + 1;
1206 self.spill_cell(
1207 system.to_gregorian(py, pm, day as u32),
1208 true,
1209 frame,
1210 format!("{}-{y}-{m}-outside-{py}-{pm}-d{day}", frame.base),
1211 cx,
1212 )
1213 } else {
1214 let day_num = idx - lead + 1;
1215 if day_num > dim {
1216 let nd = day_num - dim;
1223 let (ny, nm) = system.add_months(y, m, 1);
1224 self.spill_cell(
1225 system.to_gregorian(ny, nm, nd as u32),
1226 true,
1227 frame,
1228 format!("{}-{y}-{m}-outside-{ny}-{nm}-d{nd}", frame.base),
1229 cx,
1230 )
1231 } else {
1232 self.spill_cell(
1233 system.to_gregorian(y, m, day_num as u32),
1234 false,
1235 frame,
1236 format!("{}-{y}-{m}-d{day_num}", frame.base),
1237 cx,
1238 )
1239 }
1240 };
1241 line = line.child(slot);
1242 }
1243 grid = grid.child(line);
1244 }
1245 grid.a11y(a11y::Role::Grid).into_any_element()
1246 }
1247
1248 fn year_grid(
1250 &self,
1251 view: calendar_view::YearGridView<'_>,
1252 year_focus: &gpui::FocusHandle,
1253 heading_focus: &gpui::FocusHandle,
1254 year_picker_own: Option<Entity<bool>>,
1255 window: &Window,
1256 cx: &App,
1257 ) -> gpui::AnyElement {
1258 let colors = cx.colors();
1259 let accent = colors.accent;
1260 let active_year = view.active_year;
1261 let base = format!("{:?}", view.base);
1264 let mut grid = gpui::div()
1266 .id(element_id::scoped(view.base, "year-viewport"))
1267 .debug_selector({
1268 let key = format!("{base}-year-viewport");
1269 move || key
1270 })
1271 .size_full()
1272 .overflow_y_scroll()
1273 .track_scroll(&view.scroll.handle)
1274 .flex()
1275 .flex_col()
1276 .gap(px(4.));
1277 for chunk in view.years.chunks(3) {
1278 let mut row = gpui::div().flex().flex_shrink_0().gap(px(4.));
1279 for &year in chunk {
1280 let is_active = year == active_year;
1281 let mut cell = gpui::div()
1282 .id(element_id::scoped(view.base, format!("y{year}")))
1283 .debug_selector({
1284 let key = format!("{base}-y{year}");
1285 move || key
1286 })
1287 .when(!self.is_disabled && !self.inert && is_active, |cell| {
1288 cell.track_focus(year_focus)
1289 })
1290 .flex_1()
1291 .h(px(32.))
1294 .px(px(10.))
1295 .flex()
1296 .items_center()
1297 .justify_center()
1298 .text_size(px(14.))
1299 .line_height(px(20.))
1300 .font_weight(gpui::FontWeight::MEDIUM)
1301 .rounded(crate::util::control_radius(cx));
1302
1303 if is_active {
1304 cell = cell.bg(accent.color).text_color(accent.foreground);
1305 } else if !self.is_disabled && !self.inert {
1306 let hover_bg = self.year_hover_bg.unwrap_or(colors.default.color);
1309 let hover_fg = colors.default.foreground;
1310 cell = cell
1311 .text_color(colors.foreground)
1312 .cursor(crate::util::interactive_cursor(cx))
1313 .hover(move |s| s.bg(hover_bg).text_color(hover_fg));
1314 }
1315
1316 if !self.is_disabled && !self.inert {
1317 let st = self.state.clone();
1318 let on_open = self.on_year_picker_open_change.clone();
1319 let on_focus = self.on_focus_change.clone();
1320 let own = year_picker_own.clone();
1321 let back_to_trigger = heading_focus.clone();
1322 let system = self.system().clone();
1323 cell = cell.on_click(move |_, window, cx| {
1324 let next = st.update(cx, |s, cx| {
1325 let anchor = s.anchor();
1326 let next =
1327 system.add_years(anchor, year - system.from_gregorian(anchor).0);
1328 s.set_anchor(next);
1329 cx.notify();
1330 next
1331 });
1332 if year != active_year {
1333 if let Some(cb) = &on_focus {
1334 cb(&next, window, cx);
1335 }
1336 }
1337 if let Some(held) = &own {
1338 held.update(cx, |open, cx| {
1339 *open = false;
1340 cx.notify();
1341 });
1342 }
1343 if let Some(cb) = &on_open {
1344 cb(&false, window, cx);
1345 }
1346 window.focus(&back_to_trigger, cx);
1347 });
1348 }
1349
1350 if is_active {
1351 cell = crate::util::ring_overlay_if_focused(
1352 cell,
1353 year_focus,
1354 false,
1355 crate::util::control_radius(cx),
1356 Vec::new(),
1357 window,
1358 cx,
1359 );
1360 }
1361 cell = cell
1362 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(year.to_string()))
1363 .a11y_selected(is_active);
1364 row = row.child(cell.child(year.to_string()));
1365 }
1366 row = row.children((chunk.len()..3).map(|_| gpui::div().flex_1().px(px(10.))));
1367 grid = grid.child(row);
1368 }
1369 grid = grid.a11y(a11y::Role::Grid);
1370 gpui::div()
1371 .absolute()
1372 .inset_0()
1373 .p(px(4.))
1374 .child(grid)
1375 .when_some(view.reveal_row, |viewport, row| {
1376 let scroll = view.scroll.clone();
1377 viewport.on_children_prepainted(move |_, window, cx| {
1378 scroll.last_target.set(Some((active_year, row)));
1379 scroll.handle.scroll_to_item(row);
1380 window.defer(cx, |window, _| window.refresh());
1381 })
1382 })
1383 .into_any_element()
1384 }
1385}
1386
1387impl RenderOnce for Calendar {
1388 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1389 if let Some(date) = self.value {
1394 let state = self.state.clone();
1395 crate::util::seed_once(
1396 window,
1397 cx,
1398 gpui::ElementId::named_usize(
1399 "calendar-default",
1400 self.state.entity_id().as_u64() as usize,
1401 ),
1402 move |cx| {
1403 state.update(cx, |s, cx| {
1404 s.selected = date;
1405 s.selected_dates = date.into_iter().collect();
1406 if let Some(date) = date {
1407 s.view_year = date.year;
1408 s.view_month = date.month;
1409 s.view_day = date.day;
1410 }
1411 cx.notify();
1412 });
1413 },
1414 );
1415 } else if let Some(values) = self.values.clone() {
1416 let state = self.state.clone();
1417 crate::util::seed_once(
1418 window,
1419 cx,
1420 gpui::ElementId::named_usize(
1421 "calendar-default",
1422 self.state.entity_id().as_u64() as usize,
1423 ),
1424 move |cx| {
1425 state.update(cx, |s, cx| {
1426 s.selected = values.last().copied();
1427 s.selected_dates = values;
1428 if let Some(value) = s.selected {
1429 s.view_year = value.year;
1430 s.view_month = value.month;
1431 s.view_day = value.day;
1432 }
1433 cx.notify();
1434 });
1435 },
1436 );
1437 } else if let Some(values) = self.default_values.clone() {
1438 let state = self.state.clone();
1439 crate::util::seed_once(
1440 window,
1441 cx,
1442 gpui::ElementId::named_usize(
1443 "calendar-default",
1444 self.state.entity_id().as_u64() as usize,
1445 ),
1446 move |cx| {
1447 state.update(cx, |s, cx| {
1448 s.selected = values.last().copied();
1449 s.selected_dates = values;
1450 if let Some(value) = s.selected {
1451 s.view_year = value.year;
1452 s.view_month = value.month;
1453 s.view_day = value.day;
1454 }
1455 cx.notify();
1456 });
1457 },
1458 );
1459 } else if let Some(value) = self.default_value {
1460 let state = self.state.clone();
1461 crate::util::seed_once(
1462 window,
1463 cx,
1464 gpui::ElementId::named_usize(
1465 "calendar-default",
1466 self.state.entity_id().as_u64() as usize,
1467 ),
1468 move |cx| {
1469 state.update(cx, |s, cx| {
1470 s.selected = Some(value);
1471 s.selected_dates = vec![value];
1472 s.view_year = value.year;
1473 s.view_month = value.month;
1474 s.view_day = value.day;
1475 cx.notify();
1476 });
1477 },
1478 );
1479 }
1480
1481 let base = format!("{:?}", self.id);
1482
1483 let (year_picker_open, year_picker_own) = crate::util::controlled(
1487 window,
1488 cx,
1489 element_id::scoped(&self.id, "yearpicker"),
1490 self.year_picker_open,
1491 self.default_year_picker_open,
1492 );
1493
1494 let grid_focus =
1498 crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
1499 let prev_focus =
1500 crate::util::tab_stop_handle(element_id::scoped(&self.id, "prev-focus"), window, cx);
1501 let next_focus =
1502 crate::util::tab_stop_handle(element_id::scoped(&self.id, "next-focus"), window, cx);
1503 let year_focus =
1504 crate::util::tab_stop_handle(element_id::scoped(&self.id, "year-focus"), window, cx);
1505 if self.autofocus_grid && !self.is_disabled && !self.inert && !year_picker_open {
1508 crate::util::focus_once(
1509 window,
1510 cx,
1511 element_id::scoped(&self.id, "autofocus"),
1512 &grid_focus,
1513 );
1514 }
1515 let cursor = window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| {
1516 None::<Date>
1517 });
1518 let year_cursor =
1519 window.use_keyed_state(element_id::scoped(&self.id, "year-cursor"), cx, |_, _| {
1520 None::<i32>
1521 });
1522 let year_was_open =
1523 window.use_keyed_state(element_id::scoped(&self.id, "year-was-open"), cx, |_, _| {
1524 false
1525 });
1526 let year_trigger_index = window.use_keyed_state(
1527 element_id::scoped(&self.id, "year-trigger-index"),
1528 cx,
1529 |_, _| 0usize,
1530 );
1531 let cursor_at = *cursor.read(cx);
1532
1533 let first_day = self.constraints.first_day_of_week;
1534 let focused_value = self
1535 .focused_value
1536 .map(|date| self.constraints.constrain(date));
1537
1538 let (stored_anchor, selected, selected_dates, navigated) = {
1539 let st = self.state.read(cx);
1540 (
1541 st.anchor(),
1542 st.selected,
1543 st.selected_dates.clone(),
1544 st.user_navigated,
1545 )
1546 };
1547 let anchor = match (navigated, selected) {
1550 (false, Some(sel)) => calendar_view::aligned_anchor_in(
1551 self.system(),
1552 self.duration,
1553 self.selection_alignment,
1554 first_day,
1555 sel,
1556 ),
1557 _ => stored_anchor,
1558 };
1559 let anchor = focused_value.map_or(anchor, |focused| {
1560 let (visible_start, visible_end) =
1561 calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1562 calendar_view::anchor_following_focus_in(
1563 self.system(),
1564 self.duration,
1565 first_day,
1566 anchor,
1567 visible_start,
1568 visible_end,
1569 focused,
1570 )
1571 });
1572 let initial_year = self
1573 .system()
1574 .from_gregorian(focused_value.unwrap_or(anchor))
1575 .0;
1576 let years = calendar_view::year_window(
1577 self.system(),
1578 initial_year,
1579 self.visible_years,
1580 self.constraints.min_value,
1581 self.constraints.max_value,
1582 );
1583 let first_year = years.first().copied().unwrap_or(initial_year);
1584 let last_year = years.last().copied().unwrap_or(initial_year);
1585 if year_picker_open && !*year_was_open.read(cx) && !self.is_disabled && !self.inert {
1586 year_cursor.update(cx, |year, _| *year = Some(initial_year));
1587 window.focus(&year_focus, cx);
1588 }
1589 let year_just_opened = year_picker_open && !*year_was_open.read(cx);
1590 year_was_open.update(cx, |was_open, _| *was_open = year_picker_open);
1591 let active_year = focused_value
1592 .map(|date| self.system().from_gregorian(date).0)
1593 .or(*year_cursor.read(cx))
1594 .unwrap_or(initial_year)
1595 .max(first_year)
1596 .min(last_year);
1597 let year_scroll_state =
1598 window.use_keyed_state(element_id::scoped(&self.id, "year-scroll"), cx, |_, _| {
1599 std::rc::Rc::new(calendar_view::YearGridScroll::default())
1600 });
1601 let year_scroll = year_scroll_state.read(cx).clone();
1602 let reveal_year_row =
1603 years
1604 .iter()
1605 .position(|year| *year == active_year)
1606 .and_then(|index| {
1607 let row = index / 3;
1608 (year_picker_open
1609 && (year_just_opened
1610 || year_scroll.last_target.get() != Some((active_year, row))))
1611 .then_some(row)
1612 });
1613
1614 let ring_at = focused_value
1620 .or(cursor_at)
1621 .or(selected)
1622 .or_else(|| Some(Date::today()))
1623 .filter(|_| grid_focus.is_focused(window));
1624 let column_width = if matches!(self.duration, VisibleDuration::Months(n) if n > 1) {
1625 px(256.)
1626 } else {
1627 CALENDAR_WIDTH
1628 };
1629 let frame = Frame {
1630 selected,
1631 selected_dates: &selected_dates,
1632 today: Date::today(),
1633 cursor: &cursor,
1634 base: &base,
1635 focused: ring_at,
1636 anchor,
1637 visible_start: matches!(self.duration, VisibleDuration::Days(_)).then_some(anchor),
1638 cell_size: column_width / 7.,
1639 };
1640
1641 let months = calendar_view::month_headings_in(self.system(), self.duration, anchor);
1642 let linear = calendar_view::linear_cells(self.duration, first_day, anchor);
1643 let columns = months.len().max(1);
1644 let mut heading_focuses = Vec::with_capacity(columns);
1645 for index in 0..columns {
1646 heading_focuses.push(crate::util::tab_stop_handle(
1647 element_id::scoped(&element_id::indexed(&self.id, "heading", index), "focus"),
1648 window,
1649 cx,
1650 ));
1651 }
1652 let active_heading_index = (*year_trigger_index.read(cx)).min(columns - 1);
1653 let active_heading_focus = heading_focuses[active_heading_index].clone();
1654
1655 let colors = cx.colors().clone();
1656 let layout = cx.layout().clone();
1657
1658 let nav_target = |dir: i32| {
1659 calendar_view::page_in(
1660 self.system(),
1661 self.duration,
1662 self.page_behavior,
1663 anchor,
1664 dir,
1665 )
1666 };
1667 let (visible_start, visible_end) =
1668 calendar_view::visible_range(self.system(), self.duration, first_day, anchor);
1669 let previous_disabled =
1673 self.is_disabled || self.constraints.out_of_range(add_days(&visible_start, -1));
1674 let next_disabled =
1675 self.is_disabled || self.constraints.out_of_range(add_days(&visible_end, 1));
1676 let state_for_nav = self.state.clone();
1677 let (prev_icon, next_icon) = self
1679 .nav_icons
1680 .unwrap_or((icons::CHEVRON_LEFT, icons::CHEVRON_RIGHT));
1681 let nav_btn = |icon_path: &'static str,
1682 target: Date,
1683 key: String,
1684 focus: &gpui::FocusHandle,
1685 disabled: bool,
1686 inert: bool,
1687 window: &mut Window,
1688 cx: &mut App| {
1689 let state = state_for_nav.clone();
1690 let hover_bg = self.nav_hover_bg.unwrap_or(colors.default.color);
1692 let press = crate::anim::PressBox {
1696 height: px(24.),
1697 padding_x: None,
1698 width: Some(px(24.)),
1699 min_width: None,
1700 text_size: px(14.),
1701 line_height: px(20.),
1702 gap: px(0.),
1703 radius: crate::util::soft_radius(cx),
1704 shrink_x: true,
1705 scale: crate::anim::PRESSED_SCALE_DEEP,
1706 };
1707 let selector = key.clone();
1708 let button_id = gpui::ElementId::Name(key.clone().into());
1709 let radius = crate::util::soft_radius(cx);
1710 let nav_name = crate::i18n::ui_string(
1713 if key.contains("-prev") {
1714 crate::i18n::UiString::Previous
1715 } else {
1716 crate::i18n::UiString::Next
1717 },
1718 cx,
1719 );
1720 let button = gpui::div()
1723 .id(button_id.clone())
1724 .debug_selector(move || selector)
1725 .when(!disabled && !inert, |b| b.track_focus(focus))
1726 .flex()
1727 .items_center()
1728 .justify_center()
1729 .size(px(24.))
1731 .rounded(crate::util::soft_radius(cx))
1732 .child(
1733 gpui::svg()
1734 .size(px(16.))
1736 .path(icon_path)
1737 .text_color(colors.accent.soft_foreground(colors.foreground)),
1740 )
1741 .when(!disabled && !inert, |b| {
1742 let b = b
1743 .cursor(crate::util::interactive_cursor(cx))
1744 .on_click(move |_, _, cx| {
1745 state.update(cx, |s, cx| {
1746 s.set_anchor(target);
1747 cx.notify();
1748 });
1749 });
1750 let b = crate::anim::hover_fade_with_duration(
1751 b,
1752 element_id::scoped(&button_id, "hover-fade"),
1753 (herogpui_core::with_alpha(colors.default.color, 0.0), hover_bg),
1754 None,
1755 None,
1756 move |fill| fill.rounded(radius),
1757 Some(100),
1758 window,
1759 cx,
1760 );
1761 crate::anim::pressed(b, press, cx)
1762 })
1763 .when(disabled, |b| b.opacity(layout.disabled_opacity))
1764 .when(year_picker_open, |b| b.invisible());
1765 let button = if inert {
1766 button
1767 } else {
1768 crate::util::ring_overlay_if_focused(
1773 button,
1774 focus,
1775 true,
1776 radius,
1777 Vec::new(),
1778 window,
1779 cx,
1780 )
1781 };
1782 button.a11y_named(a11y::Role::Button, &a11y::Name::labelled(nav_name))
1783 };
1784
1785 let heading = |text: String,
1788 key: String,
1789 focus: &gpui::FocusHandle,
1790 index: usize,
1791 window: &mut Window,
1792 cx: &mut App|
1793 -> gpui::AnyElement {
1794 let heading_name = text.clone();
1795 let label = gpui::div()
1796 .text_size(px(14.))
1797 .line_height(px(20.))
1798 .font_weight(gpui::FontWeight::MEDIUM)
1799 .child(text);
1800 let indicator_id =
1801 element_id::scoped(&self.id, format!("year-picker-indicator-{index}"));
1802 match &self.on_year_picker_open_change {
1803 None if year_picker_own.is_none() => label.into_any_element(),
1805 None => {
1806 let own = year_picker_own.clone();
1807 let opener = year_trigger_index.clone();
1808 let open = year_picker_open;
1809 let trigger = gpui::div()
1810 .id(gpui::ElementId::Name(key.into()))
1811 .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1812 .flex()
1813 .items_center()
1814 .gap(px(4.))
1818 .px(px(6.))
1819 .py(px(2.))
1820 .rounded(crate::util::key_radius(cx))
1821 .when(!self.is_disabled && !self.inert, |trigger| {
1822 trigger
1823 .cursor(crate::util::interactive_cursor(cx))
1824 .on_click(move |_, _, cx| {
1825 opener.update(cx, |value, _| *value = index);
1826 if let Some(held) = &own {
1827 held.update(cx, |value, cx| {
1828 *value = !open;
1829 cx.notify();
1830 });
1831 }
1832 })
1833 })
1834 .when(self.is_disabled, |trigger| {
1835 trigger.opacity(layout.disabled_opacity)
1836 });
1837 let trigger = if self.inert {
1838 trigger
1839 } else {
1840 crate::util::ring_overlay_if_focused(
1841 trigger,
1842 focus,
1843 true,
1844 crate::util::key_radius(cx),
1845 Vec::new(),
1846 window,
1847 cx,
1848 )
1849 };
1850 trigger
1851 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1852 .a11y_expanded(open)
1853 .child(label)
1854 .child(crate::anim::rotating_indicator_with_angle_ease_out(
1855 &indicator_id,
1856 open,
1857 gpui::svg()
1858 .size(px(12.))
1859 .path(icons::CHEVRON_DOWN)
1860 .text_color(colors.muted),
1861 crate::anim::YEAR_PICKER_INDICATOR_MS,
1862 crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1863 window,
1864 cx,
1865 ))
1866 .into_any_element()
1867 }
1868 Some(cb) => {
1869 let cb = cb.clone();
1870 let open = year_picker_open;
1871 let own = year_picker_own.clone();
1872 let opener = year_trigger_index.clone();
1873 let trigger = gpui::div()
1874 .id(gpui::ElementId::Name(key.into()))
1875 .when(!self.is_disabled && !self.inert, |trigger| trigger.track_focus(focus))
1876 .flex()
1877 .items_center()
1878 .gap(px(4.))
1882 .px(px(6.))
1883 .py(px(2.))
1884 .rounded(crate::util::key_radius(cx))
1885 .when(!self.is_disabled && !self.inert, |trigger| {
1886 trigger
1887 .cursor(crate::util::interactive_cursor(cx))
1888 .on_click(move |_, window, cx| {
1889 opener.update(cx, |value, _| *value = index);
1890 if let Some(held) = &own {
1893 held.update(cx, |value, cx| {
1894 *value = !open;
1895 cx.notify();
1896 });
1897 }
1898 cb(&!open, window, cx);
1899 })
1900 })
1901 .when(self.is_disabled, |trigger| {
1902 trigger.opacity(layout.disabled_opacity)
1903 });
1904 let trigger = if self.inert {
1905 trigger
1906 } else {
1907 crate::util::ring_overlay_if_focused(
1908 trigger,
1909 focus,
1910 true,
1911 crate::util::key_radius(cx),
1912 Vec::new(),
1913 window,
1914 cx,
1915 )
1916 };
1917 trigger
1918 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(heading_name))
1919 .a11y_expanded(open)
1920 .child(label)
1921 .child(crate::anim::rotating_indicator_with_angle_ease_out(
1922 &indicator_id,
1923 open,
1924 gpui::svg()
1925 .size(px(12.))
1926 .path(icons::CHEVRON_DOWN)
1927 .text_color(colors.muted),
1928 crate::anim::YEAR_PICKER_INDICATOR_MS,
1929 crate::anim::YEAR_PICKER_INDICATOR_ANGLE,
1930 window,
1931 cx,
1932 ))
1933 .into_any_element()
1934 }
1935 }
1936 };
1937
1938 let mut root = gpui::div()
1939 .id(self.id.clone())
1940 .flex()
1941 .flex_col()
1942 .text_color(colors.surface.foreground)
1943 .when(!self.is_disabled && !self.inert && !year_picker_open, |el| {
1945 el.track_focus(&grid_focus)
1946 });
1947
1948 if !self.is_disabled && !self.inert && !year_picker_open {
1952 let held = cursor.clone();
1953 let focus = grid_focus.clone();
1954 let prev_control = prev_focus.clone();
1955 let next_control = next_focus.clone();
1956 let heading_controls = heading_focuses.clone();
1957 let controlled_focus = focused_value;
1958 let start = focused_value.or(selected).unwrap_or_else(Date::today);
1959 let constraints = self.constraints.clone();
1960 let state = self.state.clone();
1961 let mode = self.selection_mode;
1962 let read_only = self.is_read_only;
1963 let on_change = self.on_change.clone();
1964 let on_change_all = self.on_change_all.clone();
1965 let on_focus = self.on_focus_change.clone();
1966 let system = self.system().clone();
1967 let duration = self.duration;
1968 let page_behavior = self.page_behavior;
1969 let grid = calendar_keys::DayGrid {
1970 system,
1971 duration,
1972 page_behavior,
1973 first_day,
1974 anchor,
1975 visible_start,
1976 visible_end,
1977 };
1978 root = root.on_key_down(move |event, window, cx| {
1979 let header_focused = prev_control.is_focused(window)
1980 || next_control.is_focused(window)
1981 || heading_controls
1982 .iter()
1983 .any(|handle| handle.is_focused(window));
1984 if header_focused || !focus.contains_focused(window, cx) {
1985 return;
1986 }
1987 let at = controlled_focus.or(*held.read(cx)).unwrap_or(start);
1988 let key = event.keystroke.key.as_str();
1989 let shift = event.keystroke.modifiers.shift;
1990 if matches!(key, "enter" | "space") {
1991 cx.stop_propagation();
1992 if event.is_held || read_only || !constraints.allows(at) {
1993 return;
1994 }
1995 let selected_dates = state.update(cx, |s, cx| {
1996 s.toggle(at, mode);
1997 s.set_anchor(anchor);
1998 cx.notify();
1999 s.selected_dates.clone()
2000 });
2001 if let Some(cb) = &on_change {
2002 cb(&Some(at), window, cx);
2003 }
2004 if let Some(cb) = &on_change_all {
2005 cb(&selected_dates, window, cx);
2006 }
2007 return;
2008 }
2009 let Some(next) = grid.key_target(key, at, shift) else {
2010 return;
2011 };
2012 if controlled_focus.is_none() {
2013 held.update(cx, |v, cx| {
2014 *v = Some(next);
2015 cx.notify();
2016 });
2017 state.update(cx, |s, cx| {
2021 let next_anchor = grid.anchor_after_key(key, next, shift);
2022 if next_anchor != anchor {
2023 s.set_anchor(next_anchor);
2024 cx.notify();
2025 }
2026 });
2027 }
2028 if let Some(cb) = &on_focus {
2029 cb(&next, window, cx);
2030 }
2031 });
2032 }
2033
2034 if !self.is_disabled && !self.inert && year_picker_open {
2035 let held = year_cursor;
2036 let focus = year_focus.clone();
2037 let years_for_keys = years.clone();
2038 let own = year_picker_own.clone();
2039 let on_open = self.on_year_picker_open_change.clone();
2040 let on_focus = self.on_focus_change.clone();
2041 let system = self.system().clone();
2042 let controlled_year = focused_value.map(|date| system.from_gregorian(date).0);
2043 let back_to_trigger = active_heading_focus.clone();
2044 root = calendar_keys::on_year_picker_keys(
2045 root,
2046 calendar_keys::YearPickerKeys {
2047 held,
2048 focus,
2049 years: years_for_keys,
2050 own,
2051 on_open,
2052 on_focus,
2053 system,
2054 controlled_year,
2055 back_to_trigger,
2056 active_year,
2057 anchor,
2058 },
2059 );
2060 }
2061
2062 let mut body = gpui::div().flex().flex_col().gap(px(4.));
2063 if self.duration.is_month_view() {
2064 root = root.w(column_width * columns as f32 + px(32.) * (columns - 1) as f32);
2065 let mut row = gpui::div().flex().gap(px(32.));
2066 let mut headings = gpui::div().flex().gap(px(32.)).pb(px(16.));
2067 for (i, &(y, m)) in months.iter().enumerate() {
2068 let first = i == 0;
2069 let last = i + 1 == columns;
2070 let mut col = gpui::div().w(column_width).flex().flex_col().gap(px(4.));
2071 let spacer = || gpui::div().size(px(24.)).into_any_element();
2075 headings = headings.child(
2076 gpui::div().w(column_width).child(
2077 gpui::div()
2078 .flex()
2079 .items_center()
2080 .justify_between()
2081 .px(px(2.))
2083 .child(if first {
2084 nav_btn(
2085 prev_icon,
2086 nav_target(-1),
2087 format!("{base}-prev"),
2088 &prev_focus,
2089 previous_disabled,
2090 self.inert,
2091 window,
2092 cx,
2093 )
2094 .into_any_element()
2095 } else {
2096 spacer()
2097 })
2098 .child(heading(
2099 self.month_heading_text(y, m),
2100 format!("{base}-heading{i}"),
2101 &heading_focuses[i],
2102 i,
2103 window,
2104 cx,
2105 ))
2106 .child(if last {
2107 nav_btn(
2108 next_icon,
2109 nav_target(1),
2110 format!("{base}-next"),
2111 &next_focus,
2112 next_disabled,
2113 self.inert,
2114 window,
2115 cx,
2116 )
2117 .into_any_element()
2118 } else {
2119 spacer()
2120 }),
2121 ),
2122 );
2123 col = col.child(self.weekday_header(cx));
2124 col = col.child(self.month_grid(y, m, &frame, cx));
2125 row = row.child(col);
2126 }
2127 root = root.child(headings);
2128 body = body.child(row);
2129 } else {
2130 root = root.w(CALENDAR_WIDTH);
2131 root = root.child(
2132 gpui::div()
2133 .flex()
2134 .items_center()
2135 .justify_between()
2136 .px(px(2.))
2138 .pb(px(16.))
2139 .child(nav_btn(
2140 icons::CHEVRON_LEFT,
2141 nav_target(-1),
2142 format!("{base}-prev"),
2143 &prev_focus,
2144 previous_disabled,
2145 self.inert,
2146 window,
2147 cx,
2148 ))
2149 .child(heading(
2150 calendar_view::range_heading(&linear),
2151 format!("{base}-heading"),
2152 &heading_focuses[0],
2153 0,
2154 window,
2155 cx,
2156 ))
2157 .child(nav_btn(
2158 icons::CHEVRON_RIGHT,
2159 nav_target(1),
2160 format!("{base}-next"),
2161 &next_focus,
2162 next_disabled,
2163 self.inert,
2164 window,
2165 cx,
2166 )),
2167 );
2168 body = body.child(self.weekday_header(cx));
2169 let mut grid = gpui::div()
2170 .id(element_id::scoped(&self.id, "grid"))
2171 .flex()
2172 .flex_col();
2173 for row in calendar_view::week_aligned_rows(visible_start, visible_end, first_day) {
2174 let mut line = gpui::div().flex();
2175 for date in row {
2176 line = line.child(match date {
2177 Some(date) => self.day_cell(
2178 date,
2179 false,
2180 &frame,
2181 format!("{base}-{}", date.format_iso()),
2182 cx,
2183 ),
2184 None => gpui::div().flex_1().h(px(36.)).into_any_element(),
2185 });
2186 }
2187 grid = grid.child(line);
2188 }
2189 body = body.child(grid.a11y(a11y::Role::Grid));
2190 }
2191
2192 let mut viewport = gpui::div()
2193 .relative()
2194 .child(body.when(year_picker_open, |body| body.invisible()));
2195 if year_picker_open {
2196 viewport = viewport.child(self.year_grid(
2197 calendar_view::YearGridView {
2198 years: &years,
2199 active_year,
2200 base: &self.id,
2201 scroll: &year_scroll,
2202 reveal_row: reveal_year_row,
2203 },
2204 &year_focus,
2205 &active_heading_focus,
2206 year_picker_own.clone(),
2207 window,
2208 cx,
2209 ));
2210 }
2211 root = root.child(viewport);
2212
2213 if self.is_disabled {
2214 root = root.opacity(layout.disabled_opacity);
2215 }
2216
2217 root = crate::util::apply_sx(root, &self.sx);
2218 let app_name = if let Some(&(year, month)) = months.first() {
2221 self.month_heading_text(year, month)
2222 } else {
2223 calendar_view::range_heading(&linear)
2224 };
2225 root = root.a11y_named(a11y::Role::Application, &a11y::Name::labelled(app_name));
2226 root = crate::util::record_focus_bounds(root, &grid_focus, window, cx);
2227
2228 if columns > 1 {
2229 let heading = heading_focuses
2230 .iter()
2231 .position(|focus| focus.is_focused(window))
2232 .or_else(|| prev_focus.is_focused(window).then_some(0))
2233 .or_else(|| next_focus.is_focused(window).then_some(columns - 1));
2234 let reveal = heading
2235 .map(|index| {
2236 let left = (column_width + px(32.)) * index as f32;
2237 (
2238 calendar_view::MonthScrollFocus::Heading(index),
2239 left,
2240 left + column_width,
2241 )
2242 })
2243 .or_else(|| {
2244 if year_picker_open {
2245 years
2246 .iter()
2247 .position(|year| *year == active_year)
2248 .map(|index| {
2249 let width =
2250 column_width * columns as f32 + px(32.) * (columns - 1) as f32;
2251 let cell = (width - px(16.)) / 3.;
2253 let left = px(4.) + (cell + px(4.)) * (index % 3) as f32;
2254 (
2255 calendar_view::MonthScrollFocus::Year(active_year),
2256 left,
2257 left + cell,
2258 )
2259 })
2260 } else {
2261 let date = focused_value.or(ring_at)?;
2262 let (year, month, _) = self.system().from_gregorian(date);
2263 let index = months.iter().position(|value| *value == (year, month))?;
2264 let column = (weekday_index(date) + 7 - first_day.monday_index()) % 7;
2265 let left = (column_width + px(32.)) * index as f32
2266 + frame.cell_size * column as f32;
2267 Some((
2268 calendar_view::MonthScrollFocus::Day(date),
2269 left,
2270 left + frame.cell_size,
2271 ))
2272 }
2273 });
2274 calendar_view::scrolling_months(
2275 root.flex_shrink_0().mx_auto().into_any_element(),
2276 &self.id,
2277 reveal,
2278 window,
2279 cx,
2280 )
2281 } else {
2282 root.into_any_element()
2283 }
2284 }
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289 #[test]
2290 fn calendar_heading_keeps_the_day_that_identifies_a_non_gregorian_month() {
2291 assert_eq!(
2292 date_heading_for_locale("en-US-u-ca-indian", Date::new(2025, 12, 22)),
2293 month_heading_for_locale("en-US-u-ca-indian", 2026, 1)
2294 );
2295 assert!(
2296 date_heading_for_locale("en-US-u-ca-hebrew", Date::new(2024, 3, 11))
2297 .unwrap()
2298 .contains("Adar II")
2299 );
2300 }
2301
2302 #[test]
2303 fn a_locale_extension_moves_the_grid_into_that_calendar() {
2304 use crate::calendar_system::CalendarSystem;
2305 let system = CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
2306
2307 let anchor = Date::new(2026, 1, 15);
2310 let months = calendar_view::month_headings_in(&system, VisibleDuration::Months(1), anchor);
2311 assert_eq!(months, vec![(1947, 10)], "January 2026 is Pausha 1947");
2312
2313 let days = system.days_in_month(1947, 10);
2316 assert_eq!(days, 30);
2317 for day in 1..=days {
2318 assert!(
2319 system.to_gregorian(1947, 10, day).is_some(),
2320 "Pausha {day} must be a date"
2321 );
2322 }
2323 assert_eq!(system.to_gregorian(1947, 10, 25), Some(anchor));
2324 }
2325
2326 #[test]
2327 fn the_heading_names_the_view_calendars_month() {
2328 let heading = month_heading_for_locale("hi-IN-u-ca-indian", 2026, 1).unwrap();
2331 assert!(
2332 heading.contains("1947"),
2333 "an Indian heading names the Saka year, got {heading:?}"
2334 );
2335 assert_ne!(
2336 heading,
2337 month_heading_for_locale("en-US", 2026, 1).unwrap(),
2338 "the two calendars must not head the same month identically"
2339 );
2340 }
2341
2342 #[test]
2343 fn month_names_follow_the_locale() {
2344 assert_eq!(month_names_for_locale("en-US").unwrap()[0], "January");
2345 assert_eq!(month_names_for_locale("de-DE").unwrap()[0], "Januar");
2346 assert_eq!(month_names_for_locale("fr-FR").unwrap()[0], "janvier");
2347 }
2348
2349 #[test]
2350 fn month_abbreviations_come_from_cldr_not_a_byte_slice() {
2351 assert_eq!(month_abbrs_for_locale("en-US").unwrap()[0], "Jan");
2352 let japanese = month_abbrs_for_locale("ja-JP").unwrap();
2355 assert!(japanese[0].chars().count() < japanese[0].len());
2356 }
2357
2358 #[test]
2359 fn the_heading_takes_its_field_order_from_the_locale() {
2360 let english = month_heading_for_locale("en-US", 2026, 1).unwrap();
2361 assert_eq!(english, "January 2026");
2362 assert_eq!(
2363 month_heading_for_locale("de-DE", 2026, 1).unwrap(),
2364 "Januar 2026"
2365 );
2366 let japanese = month_heading_for_locale("ja-JP", 2026, 1).unwrap();
2369 assert!(
2370 japanese.starts_with("2026"),
2371 "expected a year-first heading, got {japanese}"
2372 );
2373 }
2374
2375 #[test]
2376 fn an_unknown_locale_reports_nothing_rather_than_guessing() {
2377 assert!(month_names_for_locale("not a locale").is_none());
2378 assert!(month_heading_for_locale("not a locale", 2026, 1).is_none());
2379 }
2380 use super::*;
2381
2382 #[test]
2383 fn add_months_wraps_the_year_both_ways() {
2384 assert_eq!(add_months(2026, 8, 1), (2026, 9));
2385 assert_eq!(add_months(2026, 12, 1), (2027, 1));
2386 assert_eq!(add_months(2026, 1, -1), (2025, 12));
2387 }
2388
2389 #[test]
2390 fn add_months_counts_rather_than_stepping() {
2391 assert_eq!(add_months(2026, 8, -12), (2025, 8));
2394 assert_eq!(add_months(2026, 8, 12), (2027, 8));
2395 assert_eq!(add_months(2026, 8, -20), (2024, 12));
2396 }
2397
2398 #[test]
2405 fn utc_offset_west_moves_today_to_the_previous_day() {
2406 assert_eq!(civil_date_at(1_710_037_800, -25_200), Date::new(2024, 3, 9));
2408 assert_eq!(
2410 civil_date_at(1_704_074_400, -18_000),
2411 Date::new(2023, 12, 31)
2412 );
2413 }
2414
2415 #[test]
2416 fn utc_offset_east_moves_today_to_the_next_day() {
2417 assert_eq!(civil_date_at(1_704_121_200, 32_400), Date::new(2024, 1, 2));
2419 assert_eq!(civil_date_at(1_704_060_000, 10_800), Date::new(2024, 1, 1));
2421 }
2422
2423 #[test]
2424 fn utc_offset_day_crossings_survive_leap_and_year_boundaries() {
2425 assert_eq!(civil_date_at(1_709_150_400, 18_000), Date::new(2024, 2, 29));
2427 assert_eq!(civil_date_at(1_709_236_800, 18_000), Date::new(2024, 3, 1));
2429 }
2430
2431 #[test]
2432 fn fractional_offsets_resolve_midnight_aligned_local_dates() {
2433 assert_eq!(civil_date_at(1_717_266_000, 20_700), Date::new(2024, 6, 2));
2435 }
2436
2437 #[test]
2438 fn pre_epoch_instants_carry_the_crossing_too() {
2439 assert_eq!(civil_date_at(-3_600, 7_200), Date::new(1970, 1, 1));
2441 }
2442
2443 #[test]
2444 fn today_reads_the_live_local_zone() {
2445 let now = jiff::Zoned::now();
2449 let utc_date = civil_date_at(now.timestamp().as_second(), 0);
2450 let diff = days_from_civil(&Date::today()) - days_from_civil(&utc_date);
2451 assert!(
2452 diff.abs() <= 1,
2453 "today {} vs UTC date {}",
2454 Date::today().format_iso(),
2455 utc_date.format_iso()
2456 );
2457 }
2458
2459 #[test]
2463 fn the_day_cell_hovers_the_full_default() {
2464 let source = include_str!("calendar.rs")
2467 .split("#[cfg(test)]")
2468 .next()
2469 .expect("the implementation section is always present");
2470 assert!(
2471 source.contains(
2472 "let hover_bg = self.day_hover_bg.unwrap_or(colors.default.color);\n let pressed_bg = hover_bg;"
2473 ),
2474 "the day cell must default to the full `bg-default` and honor \
2475 the named override (pinned \
2476 `.calendar__cell:hover:not([data-selected])`)"
2477 );
2478 assert!(
2479 !source.contains("colors.default.soft()"),
2480 "the day cell must not hover a soft token"
2481 );
2482 }
2483
2484 #[test]
2488 fn the_today_cell_uses_the_accent_soft_tokens() {
2489 let source = include_str!("calendar.rs")
2492 .split("#[cfg(test)]")
2493 .next()
2494 .expect("the implementation section is always present");
2495 assert!(
2496 source.contains(".bg(accent.soft())")
2497 && source.contains(".text_color(accent.soft_foreground(colors.foreground))"),
2498 "the today cell must fill `bg-accent-soft` with \
2499 `text-accent-soft-foreground` (pinned `.calendar__cell[data-today]`)"
2500 );
2501 assert!(
2502 source.contains("let hover_bg = accent.soft_hover();"),
2503 "the today cell must hover `bg-accent-soft-hover` \
2504 (pinned `.calendar__cell[data-today]:hover`)"
2505 );
2506 assert!(
2507 !source.contains("circle.border_1()"),
2508 "the today cell must not invent an accent border"
2509 );
2510 }
2511
2512 #[test]
2517 fn the_day_cell_press_merges_background_with_the_scale() {
2518 let source = include_str!("calendar.rs")
2521 .split("#[cfg(test)]")
2522 .next()
2523 .expect("the implementation section is always present");
2524 assert_eq!(
2525 source
2526 .matches("crate::anim::pressed_with_background(")
2527 .count(),
2528 3,
2529 "every pressable day branch must merge the pressed background \
2530 with the press geometry in one refinement"
2531 );
2532 assert!(
2533 !source.contains(".active("),
2534 "a chained `.active` after `anim::pressed` replaces the pressed \
2535 scale (gpui overwrites the active refinement)"
2536 );
2537 }
2538
2539 #[test]
2543 fn the_year_picker_trigger_hovers_nothing() {
2544 let source = include_str!("calendar.rs")
2547 .split("#[cfg(test)]")
2548 .next()
2549 .expect("the implementation section is always present");
2550 assert!(
2551 !source.contains("colors.default.soft_hover()"),
2552 "the year-picker trigger must not invent a hover background"
2553 );
2554 }
2555
2556 #[test]
2557 fn the_year_picker_indicator_rotates_one_down_chevron() {
2558 let source = include_str!("calendar.rs")
2559 .split("#[cfg(test)]")
2560 .next()
2561 .expect("the implementation section is always present");
2562 assert_eq!(
2563 source
2564 .matches("crate::anim::rotating_indicator_with_angle_ease_out(")
2565 .count(),
2566 2,
2567 "both heading render paths must share the animated indicator"
2568 );
2569 assert_eq!(
2570 source
2571 .matches("crate::anim::YEAR_PICKER_INDICATOR_ANGLE")
2572 .count(),
2573 2,
2574 "the year picker must use the pinned 90-degree angle"
2575 );
2576 assert_eq!(
2577 source.matches(".path(icons::CHEVRON_DOWN)").count(),
2578 2,
2579 "open and closed states must reuse one down-chevron asset"
2580 );
2581 assert!(
2582 !source.contains("icons::CHEVRON_UP"),
2583 "the year picker must rotate one asset instead of swapping glyphs"
2584 );
2585 }
2586
2587 #[test]
2592 fn the_nav_button_presses_the_deep_scale_over_its_hover() {
2593 let source = include_str!("calendar.rs")
2596 .split("#[cfg(test)]")
2597 .next()
2598 .expect("the implementation section is always present");
2599 assert_eq!(
2600 source.matches("crate::anim::pressed(").count(),
2601 1,
2602 "the nav button must carry the backgroundless press (the pinned \
2603 `[data-pressed]` only scales), and the day cells keep \
2604 `pressed_with_background`"
2605 );
2606 assert!(
2607 source.contains("scale: crate::anim::PRESSED_SCALE_DEEP,"),
2608 "the nav button must press to v3's 0.95 scale"
2609 );
2610 assert_eq!(
2611 source.matches(".hover(move |s| s.bg(hover_bg))").count(),
2612 2,
2613 "the two day-cell branches keep their immediate hover endpoints"
2614 );
2615 assert!(
2616 source.contains("crate::anim::hover_fade_with_duration(")
2617 && source.contains("Some(100)"),
2618 "the nav button must interpolate its `bg-default` hover fill over \
2619 the pinned 100ms ease-out transition"
2620 );
2621 }
2622}
2623
2624crate::util::impl_component_styled!(Calendar);