Skip to main content

gpui_component/time/
calendar.rs

1use chrono::Weekday;
2use gpui::{
3    App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
4    SharedString, StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px,
5};
6use rust_i18n::t;
7
8use crate::{ActiveTheme, Icon, IconName, Sizable, Size, StyledExt as _};
9
10use gpui_base::{Calendar as BaseCalendar, CalendarItemKind};
11pub use gpui_base::{CalendarEvent, CalendarState, Date, Matcher};
12
13fn month_name(month: i32) -> SharedString {
14    match month {
15        1 => t!("Calendar.month.January"),
16        2 => t!("Calendar.month.February"),
17        3 => t!("Calendar.month.March"),
18        4 => t!("Calendar.month.April"),
19        5 => t!("Calendar.month.May"),
20        6 => t!("Calendar.month.June"),
21        7 => t!("Calendar.month.July"),
22        8 => t!("Calendar.month.August"),
23        9 => t!("Calendar.month.September"),
24        10 => t!("Calendar.month.October"),
25        11 => t!("Calendar.month.November"),
26        12 => t!("Calendar.month.December"),
27        _ => "".into(),
28    }
29    .into()
30}
31
32fn uses_compact_text(kind: CalendarItemKind) -> bool {
33    kind == CalendarItemKind::Month
34}
35
36/// Styled facade for the complete behavior and structure in `gpui-base`.
37#[derive(IntoElement)]
38pub struct Calendar {
39    id: ElementId,
40    size: Size,
41    state: Entity<CalendarState>,
42    style: StyleRefinement,
43    number_of_months: usize,
44    first_day_of_week: Weekday,
45}
46
47impl Calendar {
48    pub fn new(state: &Entity<CalendarState>) -> Self {
49        Self {
50            id: ("calendar", state.entity_id()).into(),
51            size: Size::default(),
52            state: state.clone(),
53            style: StyleRefinement::default(),
54            number_of_months: 1,
55            first_day_of_week: Weekday::Sun,
56        }
57    }
58    pub fn number_of_months(mut self, count: usize) -> Self {
59        self.number_of_months = count;
60        self
61    }
62    /// Set the first day of the week for the calendar.
63    pub fn first_day_of_week(mut self, day: Weekday) -> Self {
64        self.first_day_of_week = day;
65        self
66    }
67}
68impl Sizable for Calendar {
69    fn with_size(mut self, size: impl Into<Size>) -> Self {
70        self.size = size.into();
71        self
72    }
73}
74impl Styled for Calendar {
75    fn style(&mut self) -> &mut StyleRefinement {
76        &mut self.style
77    }
78}
79
80impl RenderOnce for Calendar {
81    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
82        let size = self.size;
83        let month_count = self.number_of_months.max(1) as f32;
84        BaseCalendar::new(self.id, &self.state)
85            .number_of_months(self.number_of_months)
86            .first_day_of_week(self.first_day_of_week)
87            .label(|kind, value| match kind {
88                CalendarItemKind::Previous => "‹".into(),
89                CalendarItemKind::Next => "›".into(),
90                CalendarItemKind::MonthToggle | CalendarItemKind::Month => month_name(value),
91                CalendarItemKind::Weekday => match value {
92                    0 => t!("Calendar.week.0"),
93                    1 => t!("Calendar.week.1"),
94                    2 => t!("Calendar.week.2"),
95                    3 => t!("Calendar.week.3"),
96                    4 => t!("Calendar.week.4"),
97                    5 => t!("Calendar.week.5"),
98                    _ => t!("Calendar.week.6"),
99                }
100                .into(),
101                _ => value.to_string().into(),
102            })
103            .item(move |item, state, _, cx| {
104                let item = match state.kind() {
105                    CalendarItemKind::Previous => item
106                        .clear_children()
107                        .child(Icon::new(IconName::ChevronLeft).size_4()),
108                    CalendarItemKind::Next => item
109                        .clear_children()
110                        .child(Icon::new(IconName::ChevronRight).size_4()),
111                    _ => item,
112                };
113                item.map(|this| match size {
114                    Size::Small => this.size_7().rounded(cx.theme().radius / 2.),
115                    Size::Large => this.size_10().rounded(cx.theme().radius * 2.),
116                    _ => this.size_8().rounded(cx.theme().radius),
117                })
118                .flex()
119                .items_center()
120                .justify_center()
121                .when(state.kind() != CalendarItemKind::Weekday, |this| {
122                    this.text_sm()
123                })
124                .when(state.kind() == CalendarItemKind::Weekday, |this| {
125                    this.text_xs()
126                        .font_normal()
127                        .text_color(cx.theme().muted_foreground)
128                })
129                .when(uses_compact_text(state.kind()), |this| this.text_xs())
130                .when(
131                    matches!(
132                        state.kind(),
133                        CalendarItemKind::MonthToggle | CalendarItemKind::YearToggle
134                    ),
135                    |this| this.text_sm().font_medium(),
136                )
137                .when(
138                    matches!(
139                        state.kind(),
140                        CalendarItemKind::Month | CalendarItemKind::MonthToggle
141                    ),
142                    |this| {
143                        this.when(state.kind() == CalendarItemKind::Month, |this| {
144                            this.w_full().my_1()
145                        })
146                        .when(state.kind() == CalendarItemKind::MonthToggle, |this| {
147                            this.w_auto().px_2()
148                        })
149                    },
150                )
151                .when(
152                    matches!(
153                        state.kind(),
154                        CalendarItemKind::Year | CalendarItemKind::YearToggle
155                    ),
156                    |this| {
157                        this.when(state.kind() == CalendarItemKind::Year, |this| {
158                            this.w_full().my_1()
159                        })
160                        .when(state.kind() == CalendarItemKind::YearToggle, |this| {
161                            this.w_auto().px_2()
162                        })
163                    },
164                )
165                .when(state.is_muted(), |this| {
166                    this.text_color(cx.theme().muted_foreground)
167                        .when(state.is_disabled(), |this| this.opacity(0.5))
168                })
169                .when(state.is_in_range(), |this| {
170                    this.bg(cx.theme().accent)
171                        .text_color(cx.theme().accent_foreground)
172                })
173                .when(
174                    !state.is_active()
175                        && !state.is_disabled()
176                        && state.kind() != CalendarItemKind::Weekday,
177                    |this| {
178                        this.hover(|this| {
179                            this.bg(cx.theme().tokens.secondary_hover)
180                                .text_color(cx.theme().foreground)
181                        })
182                    },
183                )
184                .when(state.is_active(), |this| {
185                    this.bg(cx.theme().tokens.primary)
186                        .text_color(cx.theme().primary_foreground)
187                })
188                .when(state.is_today() && !state.is_active(), |this| {
189                    this.bg(cx.theme().accent)
190                        .text_color(cx.theme().accent_foreground)
191                })
192                .into_any_element()
193            })
194            .border_1()
195            .border_color(cx.theme().border)
196            .rounded(cx.theme().radius_lg)
197            .p_3()
198            .gap_0p5()
199            .map(|this| match size {
200                Size::Small => this.w(px(220.) * month_count),
201                Size::Large => this.w(px(304.) * month_count),
202                _ => this.w(px(248.) * month_count),
203            })
204            .refine_style(&self.style)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::{CalendarItemKind, Date, uses_compact_text};
211    use chrono::NaiveDate;
212
213    #[test]
214    fn date_display_is_stable() {
215        assert_eq!(
216            Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())).to_string(),
217            "2024-08-03"
218        );
219        assert_eq!(Date::Single(None).to_string(), "nil");
220    }
221
222    #[test]
223    fn only_month_options_use_compact_text() {
224        assert!(uses_compact_text(CalendarItemKind::Month));
225        assert!(!uses_compact_text(CalendarItemKind::MonthToggle));
226        assert!(!uses_compact_text(CalendarItemKind::Day));
227    }
228}