Skip to main content

herogpui_components/
range_calendar.rs

1//! RangeCalendar — port of `@heroui/range-calendar` (v3).
2//!
3//! A month grid for selecting a start and end date. Selection is a two-step
4//! anchor/extend interaction; the days between the anchor and the hovered day
5//! preview as part of the range.
6
7use 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/// HeroUI RangeCalendar.
30#[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` — v3's controlled range, stored for the first render only.
34    /// Each end is its own `Option` so a half-open controlled range keeps its
35    /// null end, exactly as the builder spelled it.
36    value: Option<(Option<Date>, Option<Date>)>,
37    /// `defaultValue` — seeds the state on the first render only.
38    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    /// Retained range-picker exits keep their calendar painted while making
45    /// descendants visual-only until the exit animation finishes.
46    inert: bool,
47    is_read_only: bool,
48    /// Set by a picker: take the focus as the panel opens. See
49    /// [`RangeCalendar::autofocus_grid`].
50    autofocus_grid: bool,
51    /// The calendar system the grid is drawn in, when the caller names one.
52    /// `None` follows the operating system's locale, which is v3's default.
53    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    /// `isYearPickerOpen` — `None` leaves the component holding the state,
60    /// seeded from `defaultYearPickerOpen`.
61    year_picker_open: Option<bool>,
62    default_year_picker_open: bool,
63    /// `RangeCalendar.YearPickerGrid.visibleYears` — `None` uses the v3
64    /// min/max span or 20-year default.
65    visible_years: Option<usize>,
66    /// `RangeCalendar.YearPickerTriggerHeading.offset.months`.
67    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    /// `allowsNonContiguousRanges` — lets a range span unavailable dates.
71    allows_non_contiguous_ranges: bool,
72    /// `RangeCalendar.CellIndicator` — the dot under a marked day, the same part
73    /// a [`Calendar`](crate::calendar::Calendar) draws.
74    cell_indicator: Option<Box<dyn Fn(Date) -> bool + 'static>>,
75    /// `RangeCalendar.Cell`'s render props: the closure replaces the day label
76    /// and is handed the state v3 passes it, the two range ends included.
77    cell: Option<Box<dyn Fn(&RangeCalendarCellState) -> gpui::AnyElement + 'static>>,
78    on_change: Option<OnRangeChange>,
79    /// The fill a hovered nav arrow takes, in place of `--default`.
80    nav_hover_bg: Option<gpui::Hsla>,
81    /// The fill a hovered year cell takes, in place of `--default`.
82    year_hover_bg: Option<gpui::Hsla>,
83    /// The fill a hovered plain (unselected, out-of-range) day takes, in
84    /// place of `--default`. Today, range and selected cells keep their owns.
85    day_hover_bg: Option<gpui::Hsla>,
86    /// The `sx` slot, refined over the root style at the end of render.
87    sx: Option<Box<gpui::StyleRefinement>>,
88}
89
90/// What `RangeCalendar.Cell`'s render function is handed -- v3's render props
91/// for the cell, one field each.
92#[derive(Clone, Debug)]
93#[non_exhaustive]
94pub struct RangeCalendarCellState {
95    /// The date this cell draws.
96    pub date: Date,
97    /// `formattedDate` — the day label, as this port writes it.
98    pub formatted_date: gpui::SharedString,
99    /// `isSelected` — an end of the range or inside it.
100    pub is_selected: bool,
101    /// `isSelectionStart`
102    pub is_selection_start: bool,
103    /// `isSelectionEnd`
104    pub is_selection_end: bool,
105    /// `isUnavailable`
106    pub is_unavailable: bool,
107    /// `isOutsideMonth`
108    pub is_outside_month: bool,
109    /// Today, which v3 marks with `data-today`.
110    pub is_today: bool,
111    /// Outside the min/max range, or the calendar is disabled.
112    pub is_disabled: bool,
113}
114
115impl RangeCalendar {
116    /// The locale whose calendar system this grid is drawn in.
117    ///
118    /// The same override [`crate::Calendar::locale`] takes, and for the same
119    /// reason: v3 wraps the calendar in an `I18nProvider` and gpui has no
120    /// subtree context to put one in.
121    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    /// The system this grid measures in: the caller's, else the platform's.
127    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    /// `focusedValue` — the date carrying the focus ring, independent of the
134    /// selection.
135    pub fn focused_value(mut self, date: Date) -> Self {
136        self.focused_value = Some(date);
137        self
138    }
139
140    /// `onFocusChange` — fires when a different date takes focus.
141    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    /// `value` — v3's controlled-range spelling, as a pure builder.
150    ///
151    /// The bound [`DateRangeState`] owns the range once the grid renders, so
152    /// this seeds the state on the first render only, winning over
153    /// [`RangeCalendar::default_value`] the way v3's controlled prop outranks
154    /// the uncontrolled seed; calling `.value(..)` twice keeps the last call,
155    /// like every other builder here. A later range is an imperative update
156    /// rather than a builder: write the caller-owned state entity.
157    pub fn value(mut self, start: Option<Date>, end: Option<Date>) -> Self {
158        self.value = Some((start, end));
159        self
160    }
161
162    /// Creates a range calendar bound to `state`.
163    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    /// `defaultValue` — the uncontrolled initial range.
199    ///
200    /// Written into the state on the first render only, so it seeds the
201    /// component without fighting the user afterwards.
202    pub fn default_value(mut self, value: (Date, Date)) -> Self {
203        self.default_value = Some(value);
204        self
205    }
206
207    /// `visibleDuration` — a month view, a run of weeks, or a rolling window
208    /// of days.
209    pub fn visible_duration(mut self, duration: VisibleDuration) -> Self {
210        self.duration = duration;
211        self
212    }
213
214    /// `pageBehavior` — whether navigation steps the whole visible range or a
215    /// single month/week/day.
216    pub fn page_behavior(mut self, behavior: PageBehavior) -> Self {
217        self.page_behavior = behavior;
218        self
219    }
220
221    /// `selectionAlignment` — where the range start sits inside the visible
222    /// range, until the user navigates for themselves.
223    pub fn selection_alignment(mut self, alignment: SelectionAlignment) -> Self {
224        self.selection_alignment = Some(alignment);
225        self
226    }
227
228    /// `isYearPickerOpen` — swaps the day grid for a year grid.
229    pub fn is_year_picker_open(mut self, v: bool) -> Self {
230        self.year_picker_open = Some(v);
231        self
232    }
233
234    /// `defaultYearPickerOpen` — the uncontrolled initial state.
235    ///
236    /// Only consulted when `isYearPickerOpen` is not supplied; the component
237    /// then owns the state and the heading toggles it directly.
238    pub fn default_year_picker_open(mut self, v: bool) -> Self {
239        self.default_year_picker_open = v;
240        self
241    }
242
243    /// `RangeCalendar.YearPickerGrid.visibleYears` — the size of its sliding
244    /// window.
245    pub fn visible_years(mut self, count: usize) -> Self {
246        self.visible_years = Some(count.max(1));
247        self
248    }
249
250    /// `RangeCalendar.YearPickerTriggerHeading.offset` — shifts its displayed
251    /// month.
252    pub fn offset(mut self, months: i32) -> Self {
253        self.year_heading_offset_months = months;
254        self
255    }
256
257    /// `onYearPickerOpenChange` — reports the built-in heading trigger opening
258    /// or closing the year grid.
259    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    /// Sets the earliest selectable date (`minValue`).
268    pub fn min_value(mut self, date: Date) -> Self {
269        self.constraints.min_value = Some(date);
270        self
271    }
272
273    /// Sets the latest selectable date (`maxValue`).
274    pub fn max_value(mut self, date: Date) -> Self {
275        self.constraints.max_value = Some(date);
276        self
277    }
278
279    /// `isDateUnavailable` — blocks a date relative to the active range anchor.
280    /// The anchor is `None` until the first endpoint is selected and after the
281    /// range is complete.
282    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    /// `firstDayOfWeek`
289    /// `RangeCalendar.Cell`'s render function — draw the day yourself.
290    ///
291    /// v3 hands it `{formattedDate, isSelected, isUnavailable, isOutsideMonth,
292    /// isSelectionStart, isSelectionEnd}`; this port computes every one of those
293    /// to draw the cell, so the closure is handed the same state.
294    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    /// `RangeCalendar.CellIndicator` — mark the days this returns `true` for.
303    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    /// Sets the first day of the week (`firstDayOfWeek`).
309    pub fn first_day_of_week(mut self, day: Weekday) -> Self {
310        self.constraints.first_day_of_week = day;
311        self
312    }
313
314    /// `weeksInMonth`
315    pub fn weeks_in_month(mut self, rows: usize) -> Self {
316        self.constraints.weeks_in_month = Some(rows);
317        self
318    }
319
320    /// Sets whether the calendar is invalid (`isInvalid`).
321    pub fn is_invalid(mut self, v: bool) -> Self {
322        self.is_invalid = v;
323        self
324    }
325
326    /// `allowsNonContiguousRanges` — permits a selection that spans dates the
327    /// unavailable predicate rejects.
328    pub fn allows_non_contiguous_ranges(mut self, v: bool) -> Self {
329        self.allows_non_contiguous_ranges = v;
330        self
331    }
332
333    /// All the date constraints at once.
334    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    /// Sets whether the calendar is disabled (`isDisabled`).
341    pub fn is_disabled(mut self, v: bool) -> Self {
342        self.is_disabled = v;
343        self
344    }
345
346    /// Makes the range calendar visual-only for a retained picker exit.
347    /// Unlike `is_disabled`, this preserves normal painting and only removes
348    /// focus, pointer and keyboard activation.
349    pub(crate) fn inert(mut self, v: bool) -> Self {
350        self.inert = v;
351        self
352    }
353
354    /// Sets whether the calendar is read-only (`isReadOnly`).
355    pub fn is_read_only(mut self, v: bool) -> Self {
356        self.is_read_only = v;
357        self
358    }
359
360    /// Takes the focus the first time the grid renders; see
361    /// [`crate::calendar::Calendar::autofocus_grid`]. Crate-only for the same
362    /// reason: a standalone calendar must not steal the focus.
363    pub(crate) fn autofocus_grid(mut self, v: bool) -> Self {
364        self.autofocus_grid = v;
365        self
366    }
367
368    /// `onChange` — called with `(start, end)` once both endpoints complete a
369    /// range. The first pick remains the internal anchor, matching React
370    /// Stately's `useRangeCalendarState`.
371    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    /// The fill a hovered nav arrow takes, in place of `--default`.
380    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    /// The fill a hovered year cell takes, in place of `--default`.
386    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    /// The fill a hovered plain (unselected, out-of-range) day takes, in
392    /// place of `--default`. Today, range and selected cells keep their own
393    /// fills.
394    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    /// The one slot for caller-owned low-level styling: GPUI's styling methods
400    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
401    /// applied to the range calendar's root element after every value the
402    /// component and the active theme chose, so they win.
403    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
409/// Resolves one range selection through the same constraint path for clicks
410/// and keyboard activation. The effective constraints already include the
411/// anchor-derived range bounds, so the requested endpoint only needs clamping
412/// and an availability check here.
413fn 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
460/// Pinned React Stately searches through one visible duration on either side
461/// of the active anchor, then probes the next sentinel day. The last available
462/// neighbour becomes a temporary min/max bound for cells, focus and navigation.
463fn 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
540/// Where pinned React Stately moves focus after the first keyboard endpoint:
541/// prefer tomorrow, fall back to yesterday, and stay put if neither is valid.
542fn 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
562/// The per-frame facts every cell needs, bundled so the helpers below take a
563/// readable argument list.
564struct 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    /// The date wearing the focus ring: `focusedValue` when the caller controls
575    /// it, otherwise wherever the arrow keys have walked to. `None` while the
576    /// grid does not hold the keyboard.
577    focused: Option<Date>,
578    anchor: Date,
579    visible_start: Option<Date>,
580    cell_size: gpui::Pixels,
581}
582
583/// Where a cell sits in its row: the row's first and last columns drive the
584/// pinned row-boundary rounding of the range track.
585struct 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    /// One day cell. The range track lives on the outer cell and runs under
602    /// the caps too; the inner button is the circle that takes the press.
603    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        // The pinned anatomy splits the cell in two. The outer
649        // `.range-calendar__cell` carries the range track -- the middle
650        // segment's `rounded-none bg-accent-soft`, painted unscaled so the
651        // run stays continuous across neighbours -- and the inner
652        // `.range-calendar__cell-button`, the circle holding the day glyph,
653        // is what takes the 0.9 press scale.
654        let track_key = format!("{key}-track");
655        let indicator_key = format!("{key}-indicator");
656        let mut track = div()
657            .relative()
658            // The debug selector lets the headless tests read the outer
659            // cell's laid-out bounds separately from the pressed button.
660            .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            // `[data-selected]:not([data-outside-month])` is
669            // `rounded-none bg-accent-soft` -- on the caps too, whose solid
670            // button paints over it. The track carries no text colour: the
671            // inner button keeps the base `text-foreground` unless
672            // `data-today` recolours it. Row ends round off with `lg` so a
673            // run crossing a week boundary reads as one shape, and a cap
674            // rounds its own side with `3xl`
675            // (`rounded-ss-3xl rounded-es-3xl` / `rounded-se-3xl rounded-ee-3xl`).
676            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            // The debug selector lets the headless tests read the cell's
703            // laid-out bounds.
704            .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            // `.range-calendar__cell[data-today]` fills the button with
734            // `bg-accent-soft text-accent-soft-foreground`, today or inside
735            // the range alike; only its hover (never on a selected cell)
736            // deepens the same soft fill rather than `bg-default`.
737            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            // The track fill lives on the outer cell, so the pressed inner
750            // button stays transparent with the base foreground text and the
751            // run never breaks.
752        } 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        // `.range-calendar__cell[data-pressed]` scales the inner button to
763        // 0.9 and only the range caps recolour, to `bg-accent-hover` even
764        // when the cap is also today; a pressed middle or today cell keeps
765        // its soft fill, while a pressed plain day uses the default fill,
766        // independently of `day_hover_bg`. The recolour merges with the press geometry
767        // in one refinement -- a chained `.active` would overwrite it and
768        // drop the scale.
769        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        // `.range-calendar__cell` takes `status-focused` -- a ring, not a border,
795        // which would shrink the cell as the cursor arrived.
796        // An overlay ring rather than a spread shadow: the shadow inherits the
797        // element's own radius (a squarer arc than the `r + gap + 2` CSS draws)
798        // and only paints with a blur. The asymmetric `rounded-ss-3xl` run caps
799        // belong to the `track` underneath, not to this node -- the cell itself
800        // is a uniform circle, or unrounded in mid-range, where the press slot
801        // resolves the fallback `cell_size / 2` anyway -- so one radius says it
802        // all. `cell` is that press slot once selectable: the resting footprint
803        // holding the id, focus and corners while the skin takes the scale.
804        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                // The root owns the dim when the whole range calendar is
821                // disabled, so cells do not compound that opacity.
822                .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            // Tracking the hovered cell is what makes the half-open range
830            // preview between the anchor and the cursor.
831            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        // `.range-calendar__cell-indicator` is a `size-[3px] rounded-xs` dot
922        // at `bottom-1`, centred in the cell, in the selected cell's
923        // foreground when the day is chosen.
924        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                    // The debug selector lets the headless tests read the
934                    // dot's laid-out bounds.
935                    .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    /// The seven column headers -- `.range-calendar__grid-header`.
950    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                    // `.range-calendar__header-cell` is `text-xs`.
970                    .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    /// The heading over one visible month, named in the view calendar.
981    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    /// The number a cell prints: the day of the month in the *view* calendar.
992    fn day_label(&self, date: Date) -> String {
993        self.system().from_gregorian(date).2.to_string()
994    }
995
996    /// One cell whose date the calendar system may not have; a triple it does
997    /// not hold draws a blank rather than a wrong day.
998    #[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    /// The 7-column grid for one month.
1015    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        // `.range-calendar__grid-body` and `.range-calendar__grid-row` use
1022        // `display: contents` upstream. These flex rows preserve the seven
1023        // touching columns; the cells' `my-[2px]` becomes 4px between rows
1024        // and 2px at each body edge. The parent adds the first row's `mt-1`.
1025        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    /// The year grid shown while the year picker is open.
1086    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        // The debug selectors keep the component id's Debug form, which is what
1103        // the headless tests read; the element ids above stay structured.
1104        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                    // `.calendar-year-picker__year-cell:hover` fills
1144                    // `bg-default text-default-foreground`.
1145                    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        // `value` / `defaultValue` seed the state once, before anything reads
1225        // it. `value` is v3's controlled spelling, so it outranks the
1226        // uncontrolled seed; the state owns the range afterwards, and a write
1227        // to the caller-owned entity is the imperative update.
1228        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        // `isYearPickerOpen` wins; without it the component holds the flag and
1275        // the heading toggles it, which is what `defaultYearPickerOpen`
1276        // promises. This borrows `cx` mutably, so it precedes the tokens.
1277        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        // The grid is one tab stop with a cursor inside it, as the Calendar's is.
1286        // `use_keyed_state` takes `cx` mutably, so both precede the theme.
1287        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        // Inside a picker the grid takes the focus as the panel opens, so the
1295        // arrows work without hunting for it with Tab.
1296        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        // Pinned React Stately starts a long range at the first visible unit
1361        // when its end would fall beyond the default centered window. An
1362        // explicit `selectionAlignment` always wins.
1363        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(&centered_end) {
1381                            SelectionAlignment::Start
1382                        } else {
1383                            SelectionAlignment::Center
1384                        }
1385                    }
1386                    _ => SelectionAlignment::Center,
1387                });
1388        // `selectionAlignment` frames the range around the selection start,
1389        // until the user drives navigation themselves.
1390        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        // Taking the focus puts the ring where v3 would have focused -- the
1456        // range's start, or today.
1457        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        // React Stately checks only the day immediately outside the visible
1512        // range against minValue/maxValue. A raw unavailable date does not
1513        // block paging, but an open contiguous range promotes the nearest
1514        // barrier to an effective bound above. Read-only still permits paging.
1515        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            // `.range-calendar__nav-button:hover` fills with `bg-default`.
1530            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            // `.range-calendar__nav-button:active` scales the box to 0.95.
1543            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                // The debug selector lets the headless tests read the
1558                // button's laid-out bounds.
1559                .debug_selector(move || debug_key)
1560                .when(!disabled && !inert, |b| b.track_focus(focus))
1561                .flex()
1562                .items_center()
1563                .justify_center()
1564                // `.range-calendar__nav-button` is `size-6 rounded-xl` -- one
1565                // radius step tighter than the single calendar's, which is
1566                // `rounded-2xl`.
1567                .size(px(24.))
1568                .rounded(util::small_radius(cx))
1569                // The icon joins the skin before the press wrap: children
1570                // added after `pressed` land on the slot and fight the skin
1571                // for width.
1572                .child(
1573                    gpui::svg()
1574                        // `.range-calendar__nav-button-icon` is `size-4`, painted
1575                        // `text-accent-soft-foreground` like its button.
1576                        .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                // The ring goes on the press slot `pressed` returned, which is
1608                // the node carrying `rounded(small_radius)` and the focus; the
1609                // skin inside only carries the 0.95 scale.
1610                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        // A heading is a plain label only when the picker is controlled without
1616        // a change handler; otherwise it is the built-in year-picker trigger.
1617        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                // No handler and no state of our own: a plain label.
1634                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                        // `.calendar-year-picker__trigger` is `gap-1 rounded-lg`
1645                        // and hovers nothing: only focus and the open state
1646                        // recolour it.
1647                        .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                        // `.calendar-year-picker__trigger` is `gap-1 rounded-lg`
1709                        // and hovers nothing: only focus and the open state
1710                        // recolour it.
1711                        .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                                    // Uncontrolled: flip our own copy too, or
1721                                    // the callback would be the only opener.
1722                                    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            // readOnly blocks selection, not focus or navigation.
1774            .when(!self.is_disabled && !self.inert && !year_picker_open, |el| {
1775                el.track_focus(&grid_focus)
1776            });
1777
1778        // The same keys the Calendar answers, and Enter picks: the first press
1779        // sets the range's start, the second its end, which is what `pick` does
1780        // for a click.
1781        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                    // React Aria realigns a week/month window only after focus
1927                    // leaves it. Day views page the whole window directly.
1928                    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        // `.range-calendar__grid-body > tr:first-child > td` has `mt-1`.
1972        // Day view moves that 4px onto the body itself; this header/body gap
1973        // owns it for both linear views. Month views use the column gap below.
1974        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                // First-body-row margin, separate from each cell's 2px margin.
1983                let mut col = div().flex().flex_col().gap(px(4.)).w(column_width);
1984                // Only the outer columns carry nav buttons; the rest keep a
1985                // same-size spacer so every heading lines up.
1986                // The same box as a nav button, so every heading lines up.
1987                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                        // `.range-calendar__header` is `px-0.5`.
1997                        .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                    // `.range-calendar__header` is `px-0.5`.
2054                    .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            // The linear `.range-calendar__grid-body` / `__grid-row` use the
2087            // same cell margins and seven columns as month_grid, including
2088            // empty slots at the boundaries of a rolling day view.
2089            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                                // The year grid has 4px padding and two 4px column gaps.
2173                                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// The pinned today cell fills `bg-accent-soft` with
2211// `text-accent-soft-foreground` and hovers `bg-accent-soft-hover`; the old
2212// port invented an accent border, so the check is mechanical.
2213#[cfg(test)]
2214mod hover_tokens {
2215    #[test]
2216    fn the_today_cell_uses_the_accent_soft_tokens() {
2217        // Scan the implementation only; this test's own text names the
2218        // forbidden accessor.
2219        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    // gpui's `active` refinement overwrites the previous one, so chaining
2240    // `.active` after `anim::pressed` would drop the 0.9 press scale. The
2241    // pressed recolour must merge with the geometry in one refinement, which
2242    // is what `pressed_with_background` exists for.
2243    #[test]
2244    fn the_pressed_cell_merges_background_with_the_scale() {
2245        // Scan the implementation only; this test's own text names the
2246        // forbidden chaining.
2247        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    // Pinned pressed state: only the range caps recolour, to
2259    // `bg-accent-hover` even when the cap is also today; a pressed middle or
2260    // today cell keeps its `bg-accent-soft` fill, and a pressed plain day
2261    // shows the hover fill it already wears.
2262    #[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        // Scan the implementation only; this test's own text names the
2283        // forbidden accessor.
2284        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    // Pinned anatomy: the selected track fill lives on the outer cell and
2343    // recolours nothing, so the interior `.range-calendar__cell-button`
2344    // keeps the base `text-foreground`; only `data-today` recolours its text,
2345    // today or inside the range alike.
2346    #[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    // Pinned row-boundary rounding: a selected cell in the first column
2371    // rounds its left side `lg`, the last column its right side, and a cap
2372    // rounds its own side `3xl` on any column.
2373    #[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);