Skip to main content

armas_basic/components/
calendar.rs

1//! Calendar Component (shadcn/ui style)
2//!
3//! A standalone month calendar for date selection.
4//!
5//! ```rust,no_run
6//! # use egui::Ui;
7//! # fn example(ui: &mut Ui) {
8//! use armas_basic::prelude::*;
9//!
10//! let mut selected = None;
11//! let mut calendar = Calendar::new("cal");
12//! calendar.show(ui, &mut selected);
13//! # }
14//! ```
15
16use crate::ext::ArmasContextExt;
17use crate::icon;
18use crate::Theme;
19use egui::{vec2, Color32, Id, Rect, Sense, Ui};
20
21use super::date_picker::Date;
22
23// shadcn calendar constants
24const CELL_SIZE: f32 = 32.0;
25const CALENDAR_PADDING: f32 = 12.0;
26const CALENDAR_WIDTH: f32 = 252.0;
27const NAV_BUTTON_SIZE: f32 = 32.0;
28
29/// A standalone calendar for date selection.
30pub struct Calendar {
31    id: Id,
32    show_footer: bool,
33    show_outside_days: bool,
34}
35
36/// Response from a calendar.
37pub struct CalendarResponse {
38    /// The UI response.
39    pub response: egui::Response,
40    /// Whether the selected date changed this frame.
41    pub changed: bool,
42}
43
44impl Calendar {
45    /// Create a new calendar with a unique ID.
46    pub fn new(id: impl Into<Id>) -> Self {
47        Self {
48            id: id.into(),
49            show_footer: false,
50            show_outside_days: true,
51        }
52    }
53
54    /// Show Today/Clear footer buttons.
55    #[must_use]
56    pub const fn show_footer(mut self, show: bool) -> Self {
57        self.show_footer = show;
58        self
59    }
60
61    /// Show days from adjacent months in leading/trailing cells.
62    #[must_use]
63    pub const fn show_outside_days(mut self, show: bool) -> Self {
64        self.show_outside_days = show;
65        self
66    }
67
68    /// Show the calendar.
69    ///
70    /// # Panics
71    ///
72    /// Panics if internal calendar calculations produce an invalid date.
73    pub fn show(&mut self, ui: &mut Ui, selected_date: &mut Option<Date>) -> CalendarResponse {
74        let theme = ui.ctx().armas_theme();
75        let mut date_changed = false;
76
77        let today_id = Id::new("calendar_today_cache");
78        let today = ui
79            .ctx()
80            .data(|d| d.get_temp::<Date>(today_id))
81            .unwrap_or_else(|| {
82                let today = Date::today();
83                ui.ctx().data_mut(|d| d.insert_temp(today_id, today));
84                today
85            });
86
87        let state_id = self.id.with("cal_state");
88        let (viewing_year, viewing_month) = ui.ctx().data(|d| {
89            d.get_temp::<(i32, u32)>(state_id)
90                .unwrap_or((today.year, today.month))
91        });
92
93        let mut viewing_year = viewing_year;
94        let mut viewing_month = viewing_month;
95        let mut action = CalendarAction::new();
96
97        // Render
98        let response = egui::Frame::new()
99            .inner_margin(CALENDAR_PADDING)
100            .show(ui, |ui| {
101                ui.set_min_width(CALENDAR_WIDTH);
102                ui.vertical(|ui| {
103                    ui.spacing_mut().item_spacing.y = 4.0;
104
105                    render_header(ui, &theme, viewing_year, viewing_month, &mut action);
106                    ui.add_space(4.0);
107                    render_day_grid(
108                        ui,
109                        &theme,
110                        viewing_year,
111                        viewing_month,
112                        today,
113                        selected_date.as_ref(),
114                        self.show_outside_days,
115                        &mut action,
116                    );
117
118                    if self.show_footer {
119                        render_footer(ui, &theme, &mut action);
120                    }
121                });
122            })
123            .response;
124
125        // Handle actions
126        if action.prev_month {
127            if viewing_month == 1 {
128                viewing_month = 12;
129                viewing_year -= 1;
130            } else {
131                viewing_month -= 1;
132            }
133        }
134        if action.next_month {
135            if viewing_month == 12 {
136                viewing_month = 1;
137                viewing_year += 1;
138            } else {
139                viewing_month += 1;
140            }
141        }
142        if let Some(date) = action.date_clicked {
143            *selected_date = Some(date);
144            date_changed = true;
145        }
146        if action.goto_today {
147            *selected_date = Some(today);
148            viewing_year = today.year;
149            viewing_month = today.month;
150            date_changed = true;
151        }
152        if action.clear_date {
153            *selected_date = None;
154            date_changed = true;
155        }
156
157        // Save state
158        ui.ctx()
159            .data_mut(|d| d.insert_temp(state_id, (viewing_year, viewing_month)));
160
161        CalendarResponse {
162            response,
163            changed: date_changed,
164        }
165    }
166
167    /// Navigate the calendar to the given year and month.
168    pub fn set_viewing(&self, ctx: &egui::Context, year: i32, month: u32) {
169        let state_id = self.id.with("cal_state");
170        ctx.data_mut(|d| d.insert_temp(state_id, (year, month)));
171    }
172}
173
174/// Accumulated user interactions from within the calendar.
175#[derive(Default)]
176pub(crate) struct CalendarAction {
177    pub(crate) date_clicked: Option<Date>,
178    pub(crate) goto_today: bool,
179    pub(crate) clear_date: bool,
180    pub(crate) prev_month: bool,
181    pub(crate) next_month: bool,
182}
183
184impl CalendarAction {
185    pub(crate) const fn new() -> Self {
186        Self {
187            date_clicked: None,
188            goto_today: false,
189            clear_date: false,
190            prev_month: false,
191            next_month: false,
192        }
193    }
194}
195
196/// Render the month/year navigation header with prev/next arrows.
197pub(crate) fn render_header(
198    ui: &mut Ui,
199    theme: &Theme,
200    viewing_year: i32,
201    viewing_month: u32,
202    action: &mut CalendarAction,
203) {
204    let font_size = theme.typography.base;
205
206    ui.horizontal(|ui| {
207        // Previous month button
208        let (prev_rect, prev_response) =
209            ui.allocate_exact_size(vec2(NAV_BUTTON_SIZE, NAV_BUTTON_SIZE), Sense::click());
210
211        if ui.is_rect_visible(prev_rect) {
212            if prev_response.hovered() {
213                ui.painter().rect_filled(prev_rect, 4.0, theme.accent());
214            }
215
216            let icon_rect = Rect::from_center_size(prev_rect.center(), vec2(16.0, 16.0));
217            icon::draw_chevron_left(
218                ui.painter(),
219                icon_rect,
220                if prev_response.hovered() {
221                    theme.accent_foreground()
222                } else {
223                    theme.foreground()
224                },
225            );
226        }
227
228        if prev_response.clicked() {
229            action.prev_month = true;
230        }
231
232        // Month/Year label
233        let label_width = CALENDAR_WIDTH - NAV_BUTTON_SIZE * 2.0 - 8.0;
234        ui.allocate_ui(vec2(label_width, NAV_BUTTON_SIZE), |ui| {
235            ui.centered_and_justified(|ui| {
236                ui.label(
237                    egui::RichText::new(format!(
238                        "{} {}",
239                        Date::new(viewing_year, viewing_month, 1)
240                            .expect("First day of month should always be valid")
241                            .month_name(),
242                        viewing_year
243                    ))
244                    .size(font_size)
245                    .strong()
246                    .color(theme.foreground()),
247                );
248            });
249        });
250
251        // Next month button
252        let (next_rect, next_response) =
253            ui.allocate_exact_size(vec2(NAV_BUTTON_SIZE, NAV_BUTTON_SIZE), Sense::click());
254
255        if ui.is_rect_visible(next_rect) {
256            if next_response.hovered() {
257                ui.painter().rect_filled(next_rect, 4.0, theme.accent());
258            }
259
260            let icon_rect = Rect::from_center_size(next_rect.center(), vec2(16.0, 16.0));
261            icon::draw_chevron_right(
262                ui.painter(),
263                icon_rect,
264                if next_response.hovered() {
265                    theme.accent_foreground()
266                } else {
267                    theme.foreground()
268                },
269            );
270        }
271
272        if next_response.clicked() {
273            action.next_month = true;
274        }
275    });
276}
277
278/// Render the weekday header row and calendar day grid.
279#[allow(clippy::too_many_arguments)]
280pub(crate) fn render_day_grid(
281    ui: &mut Ui,
282    theme: &Theme,
283    viewing_year: i32,
284    viewing_month: u32,
285    today: Date,
286    selected_date: Option<&Date>,
287    show_outside_days: bool,
288    action: &mut CalendarAction,
289) {
290    let font_size = theme.typography.base;
291    let small_font_size = theme.typography.sm;
292
293    // Weekday headers
294    ui.horizontal(|ui| {
295        ui.spacing_mut().item_spacing.x = 2.0;
296        for day in &["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
297            ui.allocate_ui(vec2(CELL_SIZE, CELL_SIZE), |ui| {
298                ui.centered_and_justified(|ui| {
299                    ui.label(
300                        egui::RichText::new(*day)
301                            .size(small_font_size)
302                            .color(theme.muted_foreground()),
303                    );
304                });
305            });
306        }
307    });
308
309    // Calendar grid
310    let first_day = Date::new(viewing_year, viewing_month, 1)
311        .expect("First day of month should always be valid");
312    let first_weekday = first_day.day_of_week();
313    let days_in_month = Date::days_in_month(viewing_year, viewing_month);
314
315    let (prev_year, prev_month_num) = if viewing_month == 1 {
316        (viewing_year - 1, 12)
317    } else {
318        (viewing_year, viewing_month - 1)
319    };
320    let (next_year, next_month_num) = if viewing_month == 12 {
321        (viewing_year + 1, 1)
322    } else {
323        (viewing_year, viewing_month + 1)
324    };
325    let prev_month_days = Date::days_in_month(prev_year, prev_month_num);
326
327    let mut day_counter = 1u32;
328
329    for row in 0..6 {
330        ui.horizontal(|ui| {
331            ui.spacing_mut().item_spacing.x = 2.0;
332
333            for col in 0..7 {
334                let cell_index = row * 7 + col;
335
336                let (day, is_current_month, actual_year, actual_month) =
337                    if cell_index < first_weekday {
338                        let day = prev_month_days - (first_weekday - cell_index - 1);
339                        (day, false, prev_year, prev_month_num)
340                    } else if day_counter <= days_in_month {
341                        let day = day_counter;
342                        day_counter += 1;
343                        (day, true, viewing_year, viewing_month)
344                    } else {
345                        let day = day_counter - days_in_month;
346                        day_counter += 1;
347                        (day, false, next_year, next_month_num)
348                    };
349
350                let show_cell = is_current_month || show_outside_days;
351
352                let date = Date::new(actual_year, actual_month, day)
353                    .expect("Calendar day should be valid");
354                let is_today = date == today;
355                let is_selected = selected_date == Some(&date);
356
357                let sense = if is_current_month {
358                    Sense::click()
359                } else {
360                    Sense::hover()
361                };
362
363                let (rect, cell_response) =
364                    ui.allocate_exact_size(vec2(CELL_SIZE, CELL_SIZE), sense);
365
366                if ui.is_rect_visible(rect) && show_cell {
367                    let hovered = cell_response.hovered() && is_current_month;
368
369                    let (bg_color, text_color) = if is_selected {
370                        (Some(theme.primary()), theme.primary_foreground())
371                    } else if is_today || hovered {
372                        (Some(theme.accent()), theme.accent_foreground())
373                    } else if !is_current_month {
374                        (None, theme.muted_foreground())
375                    } else {
376                        (None, theme.foreground())
377                    };
378
379                    if let Some(bg) = bg_color {
380                        ui.painter().rect_filled(rect, 4.0, bg);
381                    }
382
383                    ui.painter().text(
384                        rect.center(),
385                        egui::Align2::CENTER_CENTER,
386                        day.to_string(),
387                        egui::FontId::proportional(font_size),
388                        text_color,
389                    );
390                }
391
392                if cell_response.clicked() && is_current_month {
393                    action.date_clicked = Some(date);
394                }
395            }
396        });
397    }
398}
399
400/// Render the optional footer with Today/Clear buttons.
401pub(crate) fn render_footer(ui: &mut Ui, theme: &Theme, action: &mut CalendarAction) {
402    let font_size = theme.typography.base;
403
404    ui.add_space(8.0);
405
406    let sep_rect = ui.allocate_space(vec2(ui.available_width(), 1.0)).1;
407    ui.painter().rect_filled(sep_rect, 0.0, theme.border());
408
409    ui.add_space(8.0);
410
411    ui.horizontal(|ui| {
412        ui.spacing_mut().item_spacing.x = 8.0;
413
414        // Today button
415        let today_btn_size = vec2(60.0, 32.0);
416        let (today_rect, today_response) = ui.allocate_exact_size(today_btn_size, Sense::click());
417
418        if ui.is_rect_visible(today_rect) {
419            if today_response.hovered() {
420                ui.painter().rect_filled(today_rect, 4.0, theme.accent());
421            }
422
423            ui.painter().text(
424                today_rect.center(),
425                egui::Align2::CENTER_CENTER,
426                "Today",
427                egui::FontId::proportional(font_size),
428                if today_response.hovered() {
429                    theme.accent_foreground()
430                } else {
431                    theme.foreground()
432                },
433            );
434        }
435
436        if today_response.clicked() {
437            action.goto_today = true;
438        }
439
440        // Clear button
441        let clear_btn_size = vec2(60.0, 32.0);
442        let (clear_rect, clear_response) = ui.allocate_exact_size(clear_btn_size, Sense::click());
443
444        if ui.is_rect_visible(clear_rect) {
445            if clear_response.hovered() {
446                ui.painter().rect_filled(
447                    clear_rect,
448                    4.0,
449                    Color32::from_rgba_unmultiplied(
450                        theme.destructive().r(),
451                        theme.destructive().g(),
452                        theme.destructive().b(),
453                        25,
454                    ),
455                );
456            }
457
458            ui.painter().text(
459                clear_rect.center(),
460                egui::Align2::CENTER_CENTER,
461                "Clear",
462                egui::FontId::proportional(font_size),
463                if clear_response.hovered() {
464                    theme.destructive()
465                } else {
466                    theme.muted_foreground()
467                },
468            );
469        }
470
471        if clear_response.clicked() {
472            action.clear_date = true;
473        }
474    });
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_calendar_builder() {
483        let cal = Calendar::new("test")
484            .show_footer(true)
485            .show_outside_days(false);
486        assert!(cal.show_footer);
487        assert!(!cal.show_outside_days);
488    }
489}