Skip to main content

freya_components/
calendar.rs

1use chrono::{
2    Datelike,
3    Duration,
4    Local,
5    Months,
6    NaiveDate,
7};
8use freya_core::prelude::*;
9use torin::{
10    content::Content,
11    gaps::Gaps,
12    prelude::Alignment,
13    size::Size,
14};
15
16use crate::{
17    button::{
18        Button,
19        ButtonColorsThemePartialExt,
20        ButtonLayoutThemePartialExt,
21    },
22    define_theme,
23    get_theme,
24    icons::arrow::ArrowIcon,
25};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum WeekStart {
29    Sunday,
30    Monday,
31}
32
33define_theme! {
34    %[component]
35    pub Calendar {
36        %[fields]
37        background: Color,
38        day_background: Color,
39        day_hover_background: Color,
40        day_selected_background: Color,
41        color: Color,
42        day_other_month_color: Color,
43        header_color: Color,
44        corner_radius: CornerRadius,
45        padding: Gaps,
46        day_corner_radius: CornerRadius,
47        nav_button_hover_background: Color,
48    }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct CalendarDate {
53    pub year: i32,
54    pub month: u32,
55    pub day: u32,
56}
57
58impl CalendarDate {
59    pub fn new(year: i32, month: u32, day: u32) -> Self {
60        Self { year, month, day }
61    }
62
63    /// Returns the current local date.
64    pub fn now() -> Self {
65        Local::now().date_naive().into()
66    }
67}
68
69impl From<NaiveDate> for CalendarDate {
70    fn from(date: NaiveDate) -> Self {
71        Self::new(date.year(), date.month(), date.day())
72    }
73}
74
75/// A calendar component for date selection.
76///
77/// # Example
78///
79/// ```rust
80/// # use freya::prelude::*;
81/// fn app() -> impl IntoElement {
82///     let mut selected = use_state(|| None::<CalendarDate>);
83///     let mut view_date = use_state(|| CalendarDate::new(2025, 1, 1));
84///
85///     Calendar::new()
86///         .selected(selected())
87///         .view_date(view_date())
88///         .on_change(move |date| selected.set(Some(date)))
89///         .on_view_change(move |date| view_date.set(date))
90/// }
91/// # use freya_testing::prelude::*;
92/// # launch_doc(|| {
93/// #   rect().center().expanded().child(app())
94/// # }, "./images/gallery_calendar.png").with_hook(|_| {}).with_scale_factor(0.8).render();
95/// ```
96///
97/// # Preview
98///
99/// ![Calendar Preview][gallery_calendar]
100#[cfg_attr(feature = "docs", doc = embed_doc_image::embed_image!("gallery_calendar", "images/gallery_calendar.png"))]
101#[derive(Clone, PartialEq)]
102pub struct Calendar {
103    pub(crate) theme: Option<CalendarThemePartial>,
104    selected: Option<CalendarDate>,
105    view_date: CalendarDate,
106    week_start: WeekStart,
107    on_change: Option<EventHandler<CalendarDate>>,
108    on_view_change: Option<EventHandler<CalendarDate>>,
109    key: DiffKey,
110}
111
112impl Default for Calendar {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl Calendar {
119    pub fn new() -> Self {
120        Self {
121            theme: None,
122            selected: None,
123            view_date: CalendarDate::now(),
124            week_start: WeekStart::Monday,
125            on_change: None,
126            on_view_change: None,
127            key: DiffKey::None,
128        }
129    }
130
131    pub fn selected(mut self, selected: Option<CalendarDate>) -> Self {
132        self.selected = selected;
133        self
134    }
135
136    pub fn view_date(mut self, view_date: CalendarDate) -> Self {
137        self.view_date = view_date;
138        self
139    }
140
141    /// Set which day the week starts on (Sunday or Monday)
142    pub fn week_start(mut self, week_start: WeekStart) -> Self {
143        self.week_start = week_start;
144        self
145    }
146
147    pub fn on_change(mut self, on_change: impl Into<EventHandler<CalendarDate>>) -> Self {
148        self.on_change = Some(on_change.into());
149        self
150    }
151
152    pub fn on_view_change(mut self, on_view_change: impl Into<EventHandler<CalendarDate>>) -> Self {
153        self.on_view_change = Some(on_view_change.into());
154        self
155    }
156}
157
158impl KeyExt for Calendar {
159    fn write_key(&mut self) -> &mut DiffKey {
160        &mut self.key
161    }
162}
163
164impl Component for Calendar {
165    fn render(&self) -> impl IntoElement {
166        let CalendarTheme {
167            background,
168            day_background,
169            day_hover_background,
170            day_selected_background,
171            color,
172            day_other_month_color,
173            header_color,
174            corner_radius,
175            padding,
176            day_corner_radius,
177            nav_button_hover_background,
178        } = get_theme!(&self.theme, CalendarThemePreference, "calendar");
179
180        let first_day = NaiveDate::from_ymd_opt(self.view_date.year, self.view_date.month, 1)
181            .unwrap_or_default();
182        let prev_month = first_day
183            .checked_sub_months(Months::new(1))
184            .unwrap_or(first_day);
185        let next_month = first_day
186            .checked_add_months(Months::new(1))
187            .unwrap_or(first_day);
188        let days_in_month = next_month.pred_opt().map(|d| d.day()).unwrap_or(30);
189
190        let (leading, weekday_names) = match self.week_start {
191            WeekStart::Sunday => (
192                first_day.weekday().num_days_from_sunday(),
193                ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
194            ),
195            WeekStart::Monday => (
196                first_day.weekday().num_days_from_monday(),
197                ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
198            ),
199        };
200        let total_cells = (leading + days_in_month).div_ceil(7) * 7;
201
202        let nav_button = |target: NaiveDate, rotate: f32| {
203            let on_view_change = self.on_view_change.clone();
204            Button::new()
205                .flat()
206                .width(Size::px(32.))
207                .height(Size::px(32.))
208                .hover_background(nav_button_hover_background)
209                .on_press(move |_: Event<PressEventData>| {
210                    if let Some(handler) = &on_view_change {
211                        handler.call(target.into());
212                    }
213                })
214                .child(
215                    ArrowIcon::new()
216                        .fill(color)
217                        .width(Size::px(16.))
218                        .height(Size::px(16.))
219                        .rotate(rotate),
220                )
221        };
222
223        let header_cells = weekday_names.iter().map(|name| {
224            rect()
225                .width(Size::px(36.))
226                .height(Size::px(36.))
227                .center()
228                .child(label().text(*name).color(header_color).font_size(12.))
229                .into()
230        });
231
232        let day_cells = (0..total_cells).map(|i| {
233            let date = first_day
234                .checked_add_signed(Duration::days(i as i64 - leading as i64))
235                .unwrap_or(first_day);
236            let in_month = date.month() == first_day.month();
237            let is_selected = in_month && self.selected == Some(date.into());
238            let on_change = self.on_change.clone();
239
240            let (day_color, bg, hover_bg) = if is_selected {
241                (color, day_selected_background, day_selected_background)
242            } else if in_month {
243                (color, day_background, day_hover_background)
244            } else {
245                (
246                    day_other_month_color,
247                    Color::TRANSPARENT,
248                    Color::TRANSPARENT,
249                )
250            };
251
252            Button::new()
253                .key(date)
254                .flat()
255                .padding(0.)
256                .enabled(in_month)
257                .width(Size::px(36.))
258                .height(Size::px(36.))
259                .background(bg)
260                .hover_background(hover_bg)
261                .corner_radius(day_corner_radius)
262                .maybe(in_month, |el| {
263                    el.map(on_change, |el, on_change| {
264                        el.on_press(move |_| on_change.call(date.into()))
265                    })
266                })
267                .child(
268                    label()
269                        .text(date.day().to_string())
270                        .color(day_color)
271                        .font_size(14.),
272                )
273                .into()
274        });
275
276        rect()
277            .background(background)
278            .corner_radius(corner_radius)
279            .padding(padding)
280            .width(Size::px(280.))
281            .child(
282                rect()
283                    .horizontal()
284                    .width(Size::fill())
285                    .padding((0., 0., 8., 0.))
286                    .cross_align(Alignment::center())
287                    .content(Content::flex())
288                    .child(nav_button(prev_month, 90.))
289                    .child(
290                        label()
291                            .width(Size::flex(1.))
292                            .text_align(TextAlign::Center)
293                            .text(first_day.format("%B %Y").to_string())
294                            .color(header_color)
295                            .max_lines(1)
296                            .font_size(16.),
297                    )
298                    .child(nav_button(next_month, -90.)),
299            )
300            .child(
301                rect()
302                    .horizontal()
303                    .content(Content::wrap())
304                    .width(Size::fill())
305                    .children(header_cells)
306                    .children(day_cells),
307            )
308    }
309
310    fn render_key(&self) -> DiffKey {
311        self.key.clone().or(self.default_key())
312    }
313}