1use herogpui_core::element_id;
10
11use crate::calendar::{add_days, bump_month, days_from_civil, days_in_month, Date};
12use crate::date_constraints::Weekday;
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum VisibleDuration {
20 Months(usize),
22 Weeks(usize),
24 Days(usize),
27}
28
29impl Default for VisibleDuration {
30 fn default() -> Self {
31 VisibleDuration::Months(1)
32 }
33}
34
35impl VisibleDuration {
36 pub fn count(self) -> usize {
38 match self {
39 VisibleDuration::Months(n) | VisibleDuration::Weeks(n) | VisibleDuration::Days(n) => {
40 n.max(1)
41 }
42 }
43 }
44
45 pub fn is_month_view(self) -> bool {
48 matches!(self, VisibleDuration::Months(_))
49 }
50}
51
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
55pub enum PageBehavior {
56 #[default]
58 Visible,
59 Single,
61}
62
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum SelectionAlignment {
67 Start,
69 #[default]
70 Center,
72 End,
74}
75
76impl SelectionAlignment {
77 pub fn lead_units(self, count: usize) -> usize {
80 let count = count.max(1);
81 match self {
82 SelectionAlignment::Start => 0,
83 SelectionAlignment::Center => (count - 1) / 2,
85 SelectionAlignment::End => count - 1,
86 }
87 }
88}
89
90pub fn week_start(date: Date, first_day: Weekday) -> Date {
92 let dow = crate::calendar::weekday_index(date);
95 let offset = (dow + 7 - first_day.monday_index()) % 7;
96 add_days(&date, -(offset as i64))
97}
98
99fn clamp_day(year: i32, month: u32, day: u32) -> Date {
102 Date::new(year, month, day.min(days_in_month(year, month)))
103}
104
105pub fn page(duration: VisibleDuration, behavior: PageBehavior, anchor: Date, dir: i32) -> Date {
107 page_in(
108 crate::calendar_system::system(),
109 duration,
110 behavior,
111 anchor,
112 dir,
113 )
114}
115
116pub fn page_in(
122 system: &crate::calendar_system::CalendarSystem,
123 duration: VisibleDuration,
124 behavior: PageBehavior,
125 anchor: Date,
126 dir: i32,
127) -> Date {
128 let step = match behavior {
129 PageBehavior::Visible => duration.count(),
130 PageBehavior::Single => 1,
131 };
132 match duration {
133 VisibleDuration::Months(_) => {
134 if system.is_gregorian() {
135 let (mut y, mut m) = (anchor.year, anchor.month);
136 for _ in 0..step {
137 let (ny, nm) = bump_month(y, m, dir);
138 y = ny;
139 m = nm;
140 }
141 return clamp_day(y, m, anchor.day);
142 }
143 let (year, month, day) = system.from_gregorian(anchor);
144 let (year, month) = system.add_months(year, month, dir.signum() * step as i32);
145 let day = day.min(system.days_in_month(year, month)).max(1);
148 system.to_gregorian(year, month, day).unwrap_or(anchor)
149 }
150 VisibleDuration::Weeks(_) => add_days(&anchor, dir as i64 * step as i64 * 7),
151 VisibleDuration::Days(_) => add_days(&anchor, dir as i64 * step as i64),
152 }
153}
154
155pub fn focus_section(
161 duration: VisibleDuration,
162 behavior: PageBehavior,
163 anchor: Date,
164 dir: i32,
165 larger: bool,
166) -> Date {
167 focus_section_in(
168 crate::calendar_system::system(),
169 duration,
170 behavior,
171 anchor,
172 dir,
173 larger,
174 )
175}
176
177pub(crate) fn focus_section_in(
179 system: &crate::calendar_system::CalendarSystem,
180 duration: VisibleDuration,
181 behavior: PageBehavior,
182 anchor: Date,
183 dir: i32,
184 larger: bool,
185) -> Date {
186 match (duration, larger) {
187 (VisibleDuration::Days(_), _) => page_in(system, duration, behavior, anchor, dir),
188 (VisibleDuration::Weeks(_), false) => page_in(
189 system,
190 VisibleDuration::Weeks(1),
191 PageBehavior::Single,
192 anchor,
193 dir,
194 ),
195 (VisibleDuration::Weeks(_), true) => page_in(
196 system,
197 VisibleDuration::Months(1),
198 PageBehavior::Single,
199 anchor,
200 dir,
201 ),
202 (VisibleDuration::Months(_), false) => page_in(
203 system,
204 VisibleDuration::Months(1),
205 PageBehavior::Single,
206 anchor,
207 dir,
208 ),
209 (VisibleDuration::Months(_), true) => system.add_years(anchor, dir),
210 }
211}
212
213pub fn section_start(duration: VisibleDuration, visible_start: Date, focused: Date) -> Date {
215 section_start_in(
216 crate::calendar_system::system(),
217 duration,
218 visible_start,
219 focused,
220 )
221}
222
223pub(crate) fn section_start_in(
225 system: &crate::calendar_system::CalendarSystem,
226 duration: VisibleDuration,
227 visible_start: Date,
228 focused: Date,
229) -> Date {
230 match duration {
231 VisibleDuration::Days(_) => visible_start,
232 VisibleDuration::Weeks(_) => week_start(focused, Weekday::default()),
235 VisibleDuration::Months(_) => {
236 let (year, month, _) = system.from_gregorian(focused);
237 system.to_gregorian(year, month, 1).unwrap_or(focused)
238 }
239 }
240}
241
242pub fn section_end(duration: VisibleDuration, visible_end: Date, focused: Date) -> Date {
244 section_end_in(
245 crate::calendar_system::system(),
246 duration,
247 visible_end,
248 focused,
249 )
250}
251
252pub(crate) fn section_end_in(
254 system: &crate::calendar_system::CalendarSystem,
255 duration: VisibleDuration,
256 visible_end: Date,
257 focused: Date,
258) -> Date {
259 match duration {
260 VisibleDuration::Days(_) => visible_end,
261 VisibleDuration::Weeks(_) => add_days(&week_start(focused, Weekday::default()), 6),
262 VisibleDuration::Months(_) => {
263 let (year, month, _) = system.from_gregorian(focused);
264 system
265 .to_gregorian(year, month, system.days_in_month(year, month))
266 .unwrap_or(focused)
267 }
268 }
269}
270
271pub fn anchor_following_focus(
273 duration: VisibleDuration,
274 first_day: Weekday,
275 anchor: Date,
276 visible_start: Date,
277 visible_end: Date,
278 focused: Date,
279) -> Date {
280 anchor_following_focus_in(
281 crate::calendar_system::system(),
282 duration,
283 first_day,
284 anchor,
285 visible_start,
286 visible_end,
287 focused,
288 )
289}
290
291pub(crate) fn anchor_following_focus_in(
293 system: &crate::calendar_system::CalendarSystem,
294 duration: VisibleDuration,
295 first_day: Weekday,
296 anchor: Date,
297 visible_start: Date,
298 visible_end: Date,
299 focused: Date,
300) -> Date {
301 if days_from_civil(&focused) < days_from_civil(&visible_start) {
302 aligned_anchor_in(
303 system,
304 duration,
305 SelectionAlignment::End,
306 first_day,
307 focused,
308 )
309 } else if days_from_civil(&focused) > days_from_civil(&visible_end) {
310 aligned_anchor_in(
311 system,
312 duration,
313 SelectionAlignment::Start,
314 first_day,
315 focused,
316 )
317 } else {
318 anchor
319 }
320}
321
322pub fn aligned_anchor(
327 duration: VisibleDuration,
328 alignment: SelectionAlignment,
329 first_day: Weekday,
330 selection: Date,
331) -> Date {
332 aligned_anchor_in(
333 crate::calendar_system::system(),
334 duration,
335 alignment,
336 first_day,
337 selection,
338 )
339}
340
341pub(crate) fn aligned_anchor_in(
343 system: &crate::calendar_system::CalendarSystem,
344 duration: VisibleDuration,
345 alignment: SelectionAlignment,
346 first_day: Weekday,
347 selection: Date,
348) -> Date {
349 let lead = alignment.lead_units(duration.count());
350 match duration {
351 VisibleDuration::Months(_) => {
352 let (year, month, day) = system.from_gregorian(selection);
353 let (year, month) = system.add_months(year, month, -(lead as i32));
354 system
355 .to_gregorian(year, month, day.min(system.days_in_month(year, month)))
356 .unwrap_or(selection)
357 }
358 VisibleDuration::Weeks(_) => {
359 add_days(&week_start(selection, first_day), -(lead as i64) * 7)
360 }
361 VisibleDuration::Days(_) => add_days(&selection, -(lead as i64)),
362 }
363}
364
365pub fn month_headings(duration: VisibleDuration, anchor: Date) -> Vec<(i32, u32)> {
367 month_headings_in(crate::calendar_system::system(), duration, anchor)
368}
369
370pub fn month_headings_in(
376 system: &crate::calendar_system::CalendarSystem,
377 duration: VisibleDuration,
378 anchor: Date,
379) -> Vec<(i32, u32)> {
380 if !duration.is_month_view() {
381 return Vec::new();
382 }
383 let mut out = Vec::with_capacity(duration.count());
384 let (mut y, mut m, _) = system.from_gregorian(anchor);
385 for _ in 0..duration.count() {
386 out.push((y, m));
387 let (ny, nm) = system.add_months(y, m, 1);
388 y = ny;
389 m = nm;
390 }
391 out
392}
393
394pub fn linear_cells(duration: VisibleDuration, first_day: Weekday, anchor: Date) -> Vec<Date> {
397 let n = duration.count();
398 match duration {
399 VisibleDuration::Months(_) => Vec::new(),
400 VisibleDuration::Weeks(_) => {
401 let start = week_start(anchor, first_day);
402 (0..n as i64 * 7).map(|i| add_days(&start, i)).collect()
403 }
404 VisibleDuration::Days(_) => (0..n as i64).map(|i| add_days(&anchor, i)).collect(),
405 }
406}
407
408pub(crate) fn week_aligned_rows(
411 start: Date,
412 end: Date,
413 first_day: Weekday,
414) -> Vec<[Option<Date>; 7]> {
415 let first = week_start(start, first_day);
416 let rows = (days_from_civil(&end) - days_from_civil(&first)) / 7 + 1;
417 (0..rows)
418 .map(|row| {
419 std::array::from_fn(|column| {
420 let date = add_days(&first, row * 7 + column as i64);
421 (days_from_civil(&date) <= days_from_civil(&end)).then_some(date)
422 })
423 })
424 .collect()
425}
426
427pub(crate) fn visible_range(
431 system: &crate::calendar_system::CalendarSystem,
432 duration: VisibleDuration,
433 first_day: Weekday,
434 anchor: Date,
435) -> (Date, Date) {
436 match duration {
437 VisibleDuration::Months(_) => {
438 let months = month_headings_in(system, duration, anchor);
439 let (start_year, start_month) = months[0];
440 let (end_year, end_month) = months[months.len() - 1];
441 (
442 system
443 .to_gregorian(start_year, start_month, 1)
444 .unwrap_or(anchor),
445 system
446 .to_gregorian(
447 end_year,
448 end_month,
449 system.days_in_month(end_year, end_month),
450 )
451 .unwrap_or(anchor),
452 )
453 }
454 VisibleDuration::Weeks(_) | VisibleDuration::Days(_) => {
455 let cells = linear_cells(duration, first_day, anchor);
456 (cells[0], cells[cells.len() - 1])
457 }
458 }
459}
460
461pub fn range_heading(cells: &[Date]) -> String {
463 match (cells.first(), cells.last()) {
464 (Some(a), Some(b)) if a == b => {
465 format!(
466 "{} {}, {}",
467 crate::calendar::month_abbr(a.month),
468 a.day,
469 a.year
470 )
471 }
472 (Some(a), Some(b)) if a.year == b.year => format!(
473 "{} {} \u{2013} {} {}, {}",
474 crate::calendar::month_abbr(a.month),
475 a.day,
476 crate::calendar::month_abbr(b.month),
477 b.day,
478 b.year
479 ),
480 (Some(a), Some(b)) => format!(
481 "{} {}, {} \u{2013} {} {}, {}",
482 crate::calendar::month_abbr(a.month),
483 a.day,
484 a.year,
485 crate::calendar::month_abbr(b.month),
486 b.day,
487 b.year
488 ),
489 _ => String::new(),
490 }
491}
492
493pub(crate) fn year_window(
499 system: &crate::calendar_system::CalendarSystem,
500 view_year: i32,
501 visible_years: Option<usize>,
502 min_value: Option<Date>,
503 max_value: Option<Date>,
504) -> Vec<i32> {
505 let min_year = min_value.map(|date| system.from_gregorian(date).0);
506 let max_year = max_value.map(|date| system.from_gregorian(date).0);
507 let available = min_year.zip(max_year).map(|(min, max)| {
508 let span = i64::from(max) - i64::from(min) + 1;
509 usize::try_from(span.max(1)).unwrap_or(usize::MAX)
510 });
511 let requested = visible_years.or(available).unwrap_or(20).max(1);
512 let count = requested.min(available.unwrap_or(requested));
513 let count_i64 = i64::try_from(count).unwrap_or(i64::MAX);
514 let mut start = i64::from(view_year) - count_i64 / 2;
515
516 if let Some(min) = min_year {
517 start = start.max(i64::from(min));
518 }
519 if let Some(max) = max_year {
520 start = start.min(i64::from(max) - count_i64 + 1);
521 }
522 if let Some(min) = min_year {
523 start = start.max(i64::from(min));
524 }
525
526 (0..count)
527 .filter_map(|offset| {
528 let year = start + i64::try_from(offset).ok()?;
529 i32::try_from(year).ok()
530 })
531 .collect()
532}
533
534#[derive(Clone, Copy, PartialEq, Eq)]
535pub(crate) enum MonthScrollFocus {
536 Day(Date),
537 Year(i32),
538 Heading(usize),
539}
540
541#[derive(Default)]
542struct MonthGridScroll {
543 handle: gpui::ScrollHandle,
544 last_target:
545 std::cell::Cell<Option<(MonthScrollFocus, gpui::Pixels, gpui::Pixels, gpui::Pixels)>>,
546}
547
548pub(crate) fn scrolling_months(
549 content: gpui::AnyElement,
550 base: &gpui::ElementId,
551 reveal: Option<(MonthScrollFocus, gpui::Pixels, gpui::Pixels)>,
552 window: &mut gpui::Window,
553 cx: &mut gpui::App,
554) -> gpui::AnyElement {
555 use gpui::{prelude::*, InteractiveElement, StatefulInteractiveElement};
556 let state = window.use_keyed_state(
557 element_id::scoped(base, "months-scroll-state"),
558 cx,
559 |_, _| std::rc::Rc::new(MonthGridScroll::default()),
560 );
561 let scroll = state.read(cx).clone();
562 let handle = scroll.handle.clone();
563 gpui::div()
564 .on_children_prepainted(move |_, window, cx| {
565 let width = scroll.handle.bounds().size.width;
566 let target = reveal.map(|(focus, left, right)| (focus, left, right, width));
567 if scroll.last_target.replace(target) == target {
568 return;
569 }
570 if let Some((_, left, right, _)) = target {
571 let offset = scroll.handle.offset();
572 let x = if left < -offset.x {
573 -left
574 } else if right > width - offset.x {
575 width - right
576 } else {
577 offset.x
578 };
579 if x != offset.x {
580 scroll.handle.set_offset(gpui::point(x, offset.y));
581 window.defer(cx, |window, _| window.refresh());
582 }
583 }
584 })
585 .id(element_id::scoped(base, "months-scroll"))
586 .w_full()
587 .min_w_0()
588 .overflow_x_scroll()
589 .track_scroll(&handle)
590 .flex()
591 .child(content)
592 .into_any_element()
593}
594
595#[derive(Default)]
596pub(crate) struct YearGridScroll {
597 pub(crate) handle: gpui::ScrollHandle,
598 pub(crate) last_target: std::cell::Cell<Option<(i32, usize)>>,
599}
600
601pub(crate) struct YearGridView<'a> {
603 pub(crate) years: &'a [i32],
604 pub(crate) active_year: i32,
605 pub(crate) base: &'a gpui::ElementId,
606 pub(crate) scroll: &'a std::rc::Rc<YearGridScroll>,
607 pub(crate) reveal_row: Option<usize>,
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn day_rows_keep_leading_dates_and_pad_the_visible_end() {
616 let rows = week_aligned_rows(d(2026, 9, 2), d(2026, 9, 4), Weekday::Mon);
617 assert_eq!(
618 rows,
619 vec![[
620 Some(d(2026, 8, 31)),
621 Some(d(2026, 9, 1)),
622 Some(d(2026, 9, 2)),
623 Some(d(2026, 9, 3)),
624 Some(d(2026, 9, 4)),
625 None,
626 None
627 ]]
628 );
629 let rows = week_aligned_rows(d(2026, 9, 2), d(2026, 9, 9), Weekday::Sun);
630 assert_eq!(rows.len(), 2);
631 assert_eq!(rows[0][0], Some(d(2026, 8, 30)));
632 assert_eq!(
633 rows[1],
634 [
635 Some(d(2026, 9, 6)),
636 Some(d(2026, 9, 7)),
637 Some(d(2026, 9, 8)),
638 Some(d(2026, 9, 9)),
639 None,
640 None,
641 None
642 ]
643 );
644 }
645
646 #[test]
647 fn explicit_calendar_bounds_and_year_pages_keep_gregorian_values() {
648 let indian =
649 crate::calendar_system::CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
650 assert_eq!(
651 visible_range(
652 &indian,
653 VisibleDuration::Months(1),
654 Weekday::Mon,
655 d(2026, 1, 15)
656 ),
657 (d(2025, 12, 22), d(2026, 1, 20))
658 );
659 assert_eq!(
660 year_window(
661 &indian,
662 1947,
663 None,
664 Some(d(2025, 3, 22)),
665 Some(d(2027, 3, 21))
666 ),
667 vec![1947, 1948]
668 );
669 let hebrew =
670 crate::calendar_system::CalendarSystem::for_locale("en-US-u-ca-hebrew").unwrap();
671 assert_eq!(
672 focus_section_in(
673 &hebrew,
674 VisibleDuration::Months(1),
675 PageBehavior::Single,
676 d(2024, 3, 25),
677 1,
678 true
679 ),
680 d(2025, 3, 15)
681 );
682 }
683
684 fn d(y: i32, m: u32, day: u32) -> Date {
685 Date::new(y, m, day)
686 }
687
688 #[test]
689 fn visible_duration_never_zero() {
690 assert_eq!(VisibleDuration::Months(0).count(), 1);
691 assert_eq!(VisibleDuration::Weeks(0).count(), 1);
692 }
693
694 #[test]
695 fn month_view_pages_by_visible_range() {
696 let two = VisibleDuration::Months(2);
697 assert_eq!(
699 page(two, PageBehavior::Visible, d(2026, 8, 1), 1),
700 d(2026, 10, 1)
701 );
702 assert_eq!(
703 page(two, PageBehavior::Single, d(2026, 8, 1), 1),
704 d(2026, 9, 1)
705 );
706 }
707
708 #[test]
709 fn month_paging_clamps_into_short_months() {
710 let one = VisibleDuration::Months(1);
712 assert_eq!(
713 page(one, PageBehavior::Visible, d(2026, 1, 31), 1),
714 d(2026, 2, 28)
715 );
716 assert_eq!(
717 page(one, PageBehavior::Visible, d(2024, 1, 31), 1),
718 d(2024, 2, 29)
719 );
720 }
721
722 #[test]
723 fn month_paging_crosses_the_year() {
724 let one = VisibleDuration::Months(1);
725 assert_eq!(
726 page(one, PageBehavior::Visible, d(2026, 12, 5), 1),
727 d(2027, 1, 5)
728 );
729 assert_eq!(
730 page(one, PageBehavior::Visible, d(2026, 1, 5), -1),
731 d(2025, 12, 5)
732 );
733 }
734
735 #[test]
736 fn week_and_day_views_page_in_days() {
737 let w = VisibleDuration::Weeks(2);
738 assert_eq!(
739 page(w, PageBehavior::Visible, d(2026, 8, 3), 1),
740 d(2026, 8, 17)
741 );
742 assert_eq!(
743 page(w, PageBehavior::Single, d(2026, 8, 3), 1),
744 d(2026, 8, 10)
745 );
746 let day = VisibleDuration::Days(3);
747 assert_eq!(
748 page(day, PageBehavior::Visible, d(2026, 8, 3), 1),
749 d(2026, 8, 6)
750 );
751 assert_eq!(
752 page(day, PageBehavior::Single, d(2026, 8, 3), -1),
753 d(2026, 8, 2)
754 );
755 }
756
757 #[test]
758 fn focused_sections_follow_the_displayed_unit() {
759 let at = d(2026, 8, 15);
760 assert_eq!(
761 focus_section(
762 VisibleDuration::Weeks(2),
763 PageBehavior::Visible,
764 at,
765 1,
766 false
767 ),
768 d(2026, 8, 22)
769 );
770 assert_eq!(
771 focus_section(
772 VisibleDuration::Weeks(2),
773 PageBehavior::Visible,
774 at,
775 1,
776 true
777 ),
778 d(2026, 9, 15)
779 );
780 assert_eq!(
781 focus_section(VisibleDuration::Days(3), PageBehavior::Visible, at, 1, true),
782 d(2026, 8, 18)
783 );
784 assert_eq!(
785 focus_section(
786 VisibleDuration::Days(3),
787 PageBehavior::Single,
788 at,
789 -1,
790 false
791 ),
792 d(2026, 8, 14)
793 );
794 }
795
796 #[test]
797 fn week_start_honours_first_day_of_week() {
798 assert_eq!(week_start(d(2026, 8, 22), Weekday::Mon), d(2026, 8, 17));
800 assert_eq!(week_start(d(2026, 8, 22), Weekday::Sun), d(2026, 8, 16));
801 assert_eq!(week_start(d(2026, 8, 22), Weekday::Sat), d(2026, 8, 22));
802 }
803
804 #[test]
805 fn alignment_places_the_selection() {
806 let two = VisibleDuration::Months(2);
807 assert_eq!(
810 aligned_anchor(
811 two,
812 SelectionAlignment::Center,
813 Weekday::Mon,
814 d(2026, 8, 10)
815 ),
816 d(2026, 8, 10)
817 );
818 assert_eq!(
819 aligned_anchor(two, SelectionAlignment::Start, Weekday::Mon, d(2026, 8, 10)),
820 d(2026, 8, 10)
821 );
822 assert_eq!(
824 aligned_anchor(
825 VisibleDuration::Months(3),
826 SelectionAlignment::Center,
827 Weekday::Mon,
828 d(2026, 8, 10)
829 ),
830 d(2026, 7, 10)
831 );
832 assert_eq!(
833 aligned_anchor(two, SelectionAlignment::End, Weekday::Mon, d(2026, 8, 10)),
834 d(2026, 7, 10)
835 );
836 let three = VisibleDuration::Months(3);
837 assert_eq!(
838 aligned_anchor(three, SelectionAlignment::End, Weekday::Mon, d(2026, 8, 10)),
839 d(2026, 6, 10)
840 );
841 }
842
843 #[test]
844 fn paging_a_non_gregorian_grid_moves_one_of_its_own_months() {
845 let system =
846 crate::calendar_system::CalendarSystem::for_locale("hi-IN-u-ca-indian").unwrap();
847 let anchor = d(2026, 1, 15);
850 let next = page_in(
851 &system,
852 VisibleDuration::Months(1),
853 PageBehavior::Single,
854 anchor,
855 1,
856 );
857 assert_eq!(system.from_gregorian(next).0, 1947);
858 assert_eq!(
859 system.from_gregorian(next).1,
860 11,
861 "paging forward from Pausha must reach Magha"
862 );
863 let back = page_in(
865 &system,
866 VisibleDuration::Months(1),
867 PageBehavior::Single,
868 next,
869 -1,
870 );
871 assert_eq!(system.from_gregorian(back).1, 10);
872 }
873
874 #[test]
875 fn month_headings_walk_forward_from_the_anchor() {
876 assert_eq!(
877 month_headings(VisibleDuration::Months(3), d(2026, 11, 1)),
878 vec![(2026, 11), (2026, 12), (2027, 1)]
879 );
880 assert!(month_headings(VisibleDuration::Weeks(2), d(2026, 8, 1)).is_empty());
881 }
882
883 #[test]
884 fn linear_cells_are_contiguous() {
885 let cells = linear_cells(VisibleDuration::Weeks(2), Weekday::Mon, d(2026, 8, 22));
886 assert_eq!(cells.len(), 14);
887 assert_eq!(cells[0], d(2026, 8, 17));
888 assert_eq!(cells[13], d(2026, 8, 30));
889 let days = linear_cells(VisibleDuration::Days(3), Weekday::Mon, d(2026, 8, 30));
890 assert_eq!(days, vec![d(2026, 8, 30), d(2026, 8, 31), d(2026, 9, 1)]);
891 }
892
893 #[test]
894 fn visible_range_excludes_month_spill_and_spans_linear_views() {
895 assert_eq!(
896 visible_range(
897 crate::calendar_system::system(),
898 VisibleDuration::Months(2),
899 Weekday::Mon,
900 d(2026, 8, 15)
901 ),
902 (d(2026, 8, 1), d(2026, 9, 30))
903 );
904 assert_eq!(
905 visible_range(
906 crate::calendar_system::system(),
907 VisibleDuration::Weeks(1),
908 Weekday::Mon,
909 d(2026, 8, 22)
910 ),
911 (d(2026, 8, 17), d(2026, 8, 23))
912 );
913 assert_eq!(
914 visible_range(
915 crate::calendar_system::system(),
916 VisibleDuration::Days(3),
917 Weekday::Mon,
918 d(2026, 8, 30)
919 ),
920 (d(2026, 8, 30), d(2026, 9, 1))
921 );
922 }
923
924 #[test]
925 fn range_heading_collapses_and_spans() {
926 let one = linear_cells(VisibleDuration::Days(1), Weekday::Mon, d(2026, 8, 3));
927 assert_eq!(range_heading(&one), "Aug 3, 2026");
928 let week = linear_cells(VisibleDuration::Weeks(1), Weekday::Mon, d(2026, 8, 5));
929 assert_eq!(range_heading(&week), "Aug 3 \u{2013} Aug 9, 2026");
930 let span = linear_cells(VisibleDuration::Days(3), Weekday::Mon, d(2026, 12, 31));
931 assert_eq!(span, vec![d(2026, 12, 31), d(2027, 1, 1), d(2027, 1, 2)]);
932 assert_eq!(range_heading(&span), "Dec 31, 2026 \u{2013} Jan 2, 2027");
933 }
934
935 #[test]
936 fn year_window_centers_and_clamps_to_bounds() {
937 let years = year_window(crate::calendar_system::system(), 2026, None, None, None);
938 assert_eq!(years.len(), 20);
939 assert_eq!(years[10], 2026);
940
941 assert_eq!(
942 year_window(
943 crate::calendar_system::system(),
944 2026,
945 None,
946 Some(d(2024, 6, 1)),
947 Some(d(2028, 6, 1))
948 ),
949 vec![2024, 2025, 2026, 2027, 2028]
950 );
951 assert_eq!(
952 year_window(
953 crate::calendar_system::system(),
954 2026,
955 Some(3),
956 Some(d(2024, 6, 1)),
957 Some(d(2028, 6, 1))
958 ),
959 vec![2025, 2026, 2027]
960 );
961 assert_eq!(
962 year_window(
963 crate::calendar_system::system(),
964 2024,
965 Some(3),
966 Some(d(2024, 6, 1)),
967 None
968 ),
969 vec![2024, 2025, 2026]
970 );
971 assert_eq!(
972 year_window(
973 crate::calendar_system::system(),
974 2028,
975 Some(3),
976 None,
977 Some(d(2028, 6, 1))
978 ),
979 vec![2026, 2027, 2028]
980 );
981 }
982
983 #[test]
987 fn month_heading_offset_crosses_the_year() {
988 let system = crate::calendar_system::CalendarSystem::for_locale("en-US").unwrap();
989 let heading = |year: i32, month: u32, offset: i32| {
990 let (year, month) = system.add_months(year, month, offset);
991 crate::calendar::month_heading_for_locale(system.locale(), year, month).unwrap()
992 };
993 assert_eq!(heading(2026, 8, 0), "August 2026");
994 assert_eq!(heading(2026, 12, 2), "February 2027");
995 assert_eq!(heading(2026, 1, -2), "November 2025");
996 }
997}