Skip to main content

freya_components/
calendar.rs

1/// Determines which day the week starts on.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum WeekStart {
4    Sunday,
5    Monday,
6}
7
8use chrono::{
9    Datelike,
10    Local,
11    Month,
12    NaiveDate,
13};
14use freya_core::prelude::*;
15use torin::{
16    content::Content,
17    gaps::Gaps,
18    prelude::Alignment,
19    size::Size,
20};
21
22use crate::{
23    button::{
24        Button,
25        ButtonColorsThemePartialExt,
26        ButtonLayoutThemePartialExt,
27    },
28    define_theme,
29    get_theme,
30    icons::arrow::ArrowIcon,
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/// A simple date representation for the calendar.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct CalendarDate {
54    pub year: i32,
55    pub month: u32,
56    pub day: u32,
57}
58
59impl CalendarDate {
60    pub fn new(year: i32, month: u32, day: u32) -> Self {
61        Self { year, month, day }
62    }
63
64    /// Returns the current local date.
65    pub fn now() -> Self {
66        let today = Local::now().date_naive();
67        Self {
68            year: today.year(),
69            month: today.month(),
70            day: today.day(),
71        }
72    }
73
74    /// Returns the number of days in the given month.
75    fn days_in_month(year: i32, month: u32) -> u32 {
76        let next_month = if month == 12 { 1 } else { month + 1 };
77        let next_year = if month == 12 { year + 1 } else { year };
78        NaiveDate::from_ymd_opt(next_year, next_month, 1)
79            .and_then(|d| d.pred_opt())
80            .map(|d| d.day())
81            .unwrap_or(30)
82    }
83
84    /// Returns the day of the week for the first day of the month.
85    fn first_day_of_month(year: i32, month: u32, week_start: WeekStart) -> u32 {
86        NaiveDate::from_ymd_opt(year, month, 1)
87            .map(|d| match week_start {
88                WeekStart::Sunday => d.weekday().num_days_from_sunday(),
89                WeekStart::Monday => d.weekday().num_days_from_monday(),
90            })
91            .unwrap_or(0)
92    }
93
94    /// Returns the full name of the month.
95    fn month_name(month: u32) -> String {
96        Month::try_from(month as u8)
97            .map(|m| m.name().to_string())
98            .unwrap_or_else(|_| "Unknown".to_string())
99    }
100}
101
102/// A calendar component for date selection.
103///
104/// # Example
105///
106/// ```rust
107/// # use freya::prelude::*;
108/// fn app() -> impl IntoElement {
109///     let mut selected = use_state(|| None::<CalendarDate>);
110///     let mut view_date = use_state(|| CalendarDate::new(2025, 1, 1));
111///
112///     Calendar::new()
113///         .selected(selected())
114///         .view_date(view_date())
115///         .on_change(move |date| selected.set(Some(date)))
116///         .on_view_change(move |date| view_date.set(date))
117/// }
118/// # use freya_testing::prelude::*;
119/// # launch_doc(|| {
120/// #   rect().center().expanded().child(app())
121/// # }, "./images/gallery_calendar.png").with_hook(|_| {}).with_scale_factor(0.8).render();
122/// ```
123///
124/// # Preview
125///
126/// ![Calendar Preview][gallery_calendar]
127#[cfg_attr(feature = "docs", doc = embed_doc_image::embed_image!("gallery_calendar", "images/gallery_calendar.png"))]
128#[derive(Clone, PartialEq)]
129pub struct Calendar {
130    pub(crate) theme: Option<CalendarThemePartial>,
131    selected: Option<CalendarDate>,
132    view_date: CalendarDate,
133    week_start: WeekStart,
134    on_change: Option<EventHandler<CalendarDate>>,
135    on_view_change: Option<EventHandler<CalendarDate>>,
136    key: DiffKey,
137}
138
139impl Default for Calendar {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl Calendar {
146    pub fn new() -> Self {
147        Self {
148            theme: None,
149            selected: None,
150            view_date: CalendarDate::now(),
151            week_start: WeekStart::Monday,
152            on_change: None,
153            on_view_change: None,
154            key: DiffKey::None,
155        }
156    }
157
158    pub fn selected(mut self, selected: Option<CalendarDate>) -> Self {
159        self.selected = selected;
160        self
161    }
162
163    pub fn view_date(mut self, view_date: CalendarDate) -> Self {
164        self.view_date = view_date;
165        self
166    }
167
168    /// Set which day the week starts on (Sunday or Monday)
169    pub fn week_start(mut self, week_start: WeekStart) -> Self {
170        self.week_start = week_start;
171        self
172    }
173
174    pub fn on_change(mut self, on_change: impl Into<EventHandler<CalendarDate>>) -> Self {
175        self.on_change = Some(on_change.into());
176        self
177    }
178
179    pub fn on_view_change(mut self, on_view_change: impl Into<EventHandler<CalendarDate>>) -> Self {
180        self.on_view_change = Some(on_view_change.into());
181        self
182    }
183}
184
185impl KeyExt for Calendar {
186    fn write_key(&mut self) -> &mut DiffKey {
187        &mut self.key
188    }
189}
190
191impl Component for Calendar {
192    fn render(&self) -> impl IntoElement {
193        let theme = get_theme!(&self.theme, CalendarThemePreference, "calendar");
194
195        let CalendarTheme {
196            background,
197            day_background,
198            day_hover_background,
199            day_selected_background,
200            color,
201            day_other_month_color,
202            header_color,
203            corner_radius,
204            padding,
205            day_corner_radius,
206            nav_button_hover_background,
207        } = theme;
208
209        let view_year = self.view_date.year;
210        let view_month = self.view_date.month;
211
212        let days_in_month = CalendarDate::days_in_month(view_year, view_month);
213        let first_day = CalendarDate::first_day_of_month(view_year, view_month, self.week_start);
214        let month_name = CalendarDate::month_name(view_month);
215
216        let prev_month = if view_month == 1 { 12 } else { view_month - 1 };
217        let prev_year = if view_month == 1 {
218            view_year - 1
219        } else {
220            view_year
221        };
222        let days_in_prev_month = CalendarDate::days_in_month(prev_year, prev_month);
223
224        let on_change = self.on_change.clone();
225        let on_view_change = self.on_view_change.clone();
226        let selected = self.selected;
227
228        let on_prev = EventHandler::from({
229            let on_view_change = on_view_change.clone();
230            move |_: Event<PressEventData>| {
231                if let Some(handler) = &on_view_change {
232                    let new_month = if view_month == 1 { 12 } else { view_month - 1 };
233                    let new_year = if view_month == 1 {
234                        view_year - 1
235                    } else {
236                        view_year
237                    };
238                    handler.call(CalendarDate::new(new_year, new_month, 1));
239                }
240            }
241        });
242
243        let on_next = EventHandler::from(move |_: Event<PressEventData>| {
244            if let Some(handler) = &on_view_change {
245                let new_month = if view_month == 12 { 1 } else { view_month + 1 };
246                let new_year = if view_month == 12 {
247                    view_year + 1
248                } else {
249                    view_year
250                };
251                handler.call(CalendarDate::new(new_year, new_month, 1));
252            }
253        });
254
255        let nav_button = |on_press: EventHandler<Event<PressEventData>>, rotate| {
256            Button::new()
257                .flat()
258                .width(Size::px(32.))
259                .height(Size::px(32.))
260                .hover_background(nav_button_hover_background)
261                .on_press(on_press)
262                .child(
263                    ArrowIcon::new()
264                        .fill(color)
265                        .width(Size::px(16.))
266                        .height(Size::px(16.))
267                        .rotate(rotate),
268                )
269        };
270
271        let weekday_names = match self.week_start {
272            WeekStart::Sunday => ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
273            WeekStart::Monday => ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
274        };
275
276        let header_cells = weekday_names.iter().map(|day_name| {
277            rect()
278                .width(Size::px(36.))
279                .height(Size::px(36.))
280                .center()
281                .child(label().text(*day_name).color(header_color).font_size(12.))
282                .into()
283        });
284
285        let total_cells = (first_day + days_in_month).div_ceil(7) * 7;
286        let day_cells = (0..total_cells).map(|i| {
287            let current_day = i as i32 - first_day as i32 + 1;
288
289            let (day, day_color, enabled) = if current_day < 1 {
290                let day = (days_in_prev_month as i32 + current_day) as u32;
291                (day, day_other_month_color, false)
292            } else if current_day as u32 > days_in_month {
293                let day = current_day as u32 - days_in_month;
294                (day, day_other_month_color, false)
295            } else {
296                (current_day as u32, color, true)
297            };
298
299            let date = CalendarDate::new(view_year, view_month, current_day as u32);
300            let is_selected = enabled && selected == Some(date);
301            let on_change = on_change.clone();
302
303            let (bg, hover_bg) = if is_selected {
304                (day_selected_background, day_selected_background)
305            } else if enabled {
306                (day_background, day_hover_background)
307            } else {
308                (Color::TRANSPARENT, Color::TRANSPARENT)
309            };
310
311            CalendarDay::new()
312                .day(day)
313                .background(bg)
314                .hover_background(hover_bg)
315                .color(day_color)
316                .corner_radius(day_corner_radius)
317                .enabled(enabled)
318                .maybe(enabled, |el| {
319                    el.map(on_change, |el, on_change| {
320                        el.on_press(move |_| on_change.call(date))
321                    })
322                })
323                .into()
324        });
325
326        rect()
327            .background(background)
328            .corner_radius(corner_radius)
329            .padding(padding)
330            .width(Size::px(280.))
331            .child(
332                rect()
333                    .horizontal()
334                    .width(Size::fill())
335                    .padding((0., 0., 8., 0.))
336                    .cross_align(Alignment::center())
337                    .content(Content::flex())
338                    .child(nav_button(on_prev, 90.))
339                    .child(
340                        label()
341                            .width(Size::flex(1.))
342                            .text_align(TextAlign::Center)
343                            .text(format!("{} {}", month_name, view_year))
344                            .color(header_color)
345                            .max_lines(1)
346                            .font_size(16.),
347                    )
348                    .child(nav_button(on_next, -90.)),
349            )
350            .child(
351                rect()
352                    .horizontal()
353                    .content(Content::wrap())
354                    .width(Size::fill())
355                    .children(header_cells)
356                    .children(day_cells),
357            )
358    }
359
360    fn render_key(&self) -> DiffKey {
361        self.key.clone().or(self.default_key())
362    }
363}
364
365#[derive(Clone, PartialEq)]
366struct CalendarDay {
367    day: u32,
368    background: Color,
369    hover_background: Color,
370    color: Color,
371    corner_radius: CornerRadius,
372    on_press: Option<EventHandler<Event<PressEventData>>>,
373    enabled: bool,
374    key: DiffKey,
375}
376
377impl CalendarDay {
378    fn new() -> Self {
379        Self {
380            day: 1,
381            background: Color::TRANSPARENT,
382            hover_background: Color::TRANSPARENT,
383            color: Color::BLACK,
384            corner_radius: CornerRadius::default(),
385            on_press: None,
386            enabled: true,
387            key: DiffKey::None,
388        }
389    }
390
391    fn day(mut self, day: u32) -> Self {
392        self.day = day;
393        self
394    }
395
396    fn background(mut self, background: Color) -> Self {
397        self.background = background;
398        self
399    }
400
401    fn hover_background(mut self, hover_background: Color) -> Self {
402        self.hover_background = hover_background;
403        self
404    }
405
406    fn color(mut self, color: Color) -> Self {
407        self.color = color;
408        self
409    }
410
411    fn corner_radius(mut self, corner_radius: CornerRadius) -> Self {
412        self.corner_radius = corner_radius;
413        self
414    }
415
416    fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
417        self.on_press = Some(on_press.into());
418        self
419    }
420
421    fn enabled(mut self, enabled: bool) -> Self {
422        self.enabled = enabled;
423        self
424    }
425}
426
427impl KeyExt for CalendarDay {
428    fn write_key(&mut self) -> &mut DiffKey {
429        &mut self.key
430    }
431}
432
433impl Component for CalendarDay {
434    fn render(&self) -> impl IntoElement {
435        Button::new()
436            .flat()
437            .padding(0.)
438            .enabled(self.enabled)
439            .width(Size::px(36.))
440            .height(Size::px(36.))
441            .background(self.background)
442            .hover_background(self.hover_background)
443            .maybe(self.enabled, |el| {
444                el.map(self.on_press.clone(), |el, on_press| el.on_press(on_press))
445            })
446            .child(
447                label()
448                    .text(self.day.to_string())
449                    .color(self.color)
450                    .font_size(14.),
451            )
452    }
453
454    fn render_key(&self) -> DiffKey {
455        self.key.clone().or(self.default_key())
456    }
457}