Skip to main content

armas_basic/components/
date_picker.rs

1//! `DatePicker` Component
2//!
3//! Calendar date selection styled like shadcn/ui.
4//! Combines a Button trigger with a Calendar popover.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::{Context, Ui};
10//! # fn example(ctx: &Context, ui: &mut Ui) {
11//! use armas_basic::{DatePicker, Date};
12//!
13//! let mut date_picker = DatePicker::new("birthday");
14//! let mut selected_date = None;
15//!
16//! date_picker.show(ctx, ui, &mut selected_date);
17//! # }
18//! ```
19
20use crate::ext::ArmasContextExt;
21use crate::{Popover, PopoverPosition, Theme};
22use egui::{vec2, Id, Rect, Sense, Ui};
23
24use super::calendar::{render_day_grid, render_footer, render_header, CalendarAction};
25
26// shadcn calendar constants
27const CALENDAR_PADDING: f32 = 12.0;
28const CALENDAR_WIDTH: f32 = 252.0; // 7 * 32px + 6 * 2px gaps + padding
29const TRIGGER_WIDTH: f32 = 280.0;
30const TRIGGER_HEIGHT: f32 = 40.0;
31// Font size resolved from theme.typography.base at show-time
32const CORNER_RADIUS: f32 = 6.0;
33
34/// A date value (year, month, day)
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct Date {
37    /// Year
38    pub year: i32,
39    /// Month (1-12)
40    pub month: u32,
41    /// Day of month (1-31)
42    pub day: u32,
43}
44
45impl Date {
46    /// Create a new date
47    #[must_use]
48    pub fn new(year: i32, month: u32, day: u32) -> Option<Self> {
49        if !(1..=12).contains(&month) {
50            return None;
51        }
52        let days_in_month = Self::days_in_month(year, month);
53        if day < 1 || day > days_in_month {
54            return None;
55        }
56        Some(Self { year, month, day })
57    }
58
59    /// Get today's date (using chrono)
60    #[must_use]
61    pub fn today() -> Self {
62        use chrono::Datelike;
63        let now = chrono::Local::now().date_naive();
64        Self {
65            year: now.year(),
66            month: now.month(),
67            day: now.day(),
68        }
69    }
70
71    /// Check if a year is a leap year
72    #[must_use]
73    pub const fn is_leap_year(year: i32) -> bool {
74        (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
75    }
76
77    /// Get the number of days in a month
78    #[must_use]
79    pub const fn days_in_month(year: i32, month: u32) -> u32 {
80        match month {
81            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
82            4 | 6 | 9 | 11 => 30,
83            2 => {
84                if Self::is_leap_year(year) {
85                    29
86                } else {
87                    28
88                }
89            }
90            _ => 0,
91        }
92    }
93
94    /// Get the day of week (0 = Sunday, 6 = Saturday)
95    #[must_use]
96    #[allow(clippy::many_single_char_names, clippy::cast_possible_wrap)]
97    pub const fn day_of_week(&self) -> u32 {
98        // Zeller's congruence algorithm
99        let mut m = self.month as i32;
100        let mut y = self.year;
101
102        if m < 3 {
103            m += 12;
104            y -= 1;
105        }
106
107        let k = y % 100;
108        let j = y / 100;
109
110        let h = (self.day as i32 + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
111        ((h + 6) % 7) as u32
112    }
113
114    /// Format as human-readable (e.g., "January 15, 2024")
115    #[must_use]
116    pub fn format_display(&self) -> String {
117        format!("{} {}, {}", self.month_name(), self.day, self.year)
118    }
119
120    /// Format as YYYY-MM-DD
121    #[must_use]
122    pub fn format(&self) -> String {
123        format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
124    }
125
126    /// Parse from YYYY-MM-DD format
127    #[must_use]
128    pub fn parse(s: &str) -> Option<Self> {
129        let parts: Vec<&str> = s.split('-').collect();
130        if parts.len() != 3 {
131            return None;
132        }
133
134        let year = parts[0].parse().ok()?;
135        let month = parts[1].parse().ok()?;
136        let day = parts[2].parse().ok()?;
137
138        Self::new(year, month, day)
139    }
140
141    /// Get month name
142    #[must_use]
143    pub const fn month_name(&self) -> &'static str {
144        match self.month {
145            1 => "January",
146            2 => "February",
147            3 => "March",
148            4 => "April",
149            5 => "May",
150            6 => "June",
151            7 => "July",
152            8 => "August",
153            9 => "September",
154            10 => "October",
155            11 => "November",
156            12 => "December",
157            _ => "Unknown",
158        }
159    }
160}
161
162/// `DatePicker` component styled like shadcn/ui
163///
164/// # Example
165///
166/// ```rust,no_run
167/// # use egui::{Context, Ui};
168/// # fn example(ctx: &Context, ui: &mut Ui) {
169/// use armas_basic::{DatePicker, Date};
170///
171/// let mut date_picker = DatePicker::new("birthday");
172/// let mut selected_date = None;
173///
174/// date_picker.show(ctx, ui, &mut selected_date);
175/// # }
176/// ```
177#[derive(Clone)]
178pub struct DatePicker {
179    id: Id,
180    popover: Popover,
181    placeholder: String,
182    label: Option<String>,
183    show_footer: bool,
184    width: f32,
185}
186
187impl DatePicker {
188    /// Create a new date picker
189    pub fn new(id: impl Into<Id>) -> Self {
190        let id = id.into();
191        Self {
192            id,
193            popover: Popover::new(id.with("popover"))
194                .position(PopoverPosition::Bottom)
195                .style(crate::PopoverStyle::Default)
196                .padding(0.0)
197                .width(CALENDAR_WIDTH + CALENDAR_PADDING * 2.0),
198            placeholder: "Pick a date".to_string(),
199            label: None,
200            show_footer: false,
201            width: TRIGGER_WIDTH,
202        }
203    }
204
205    /// Set the placeholder text
206    #[must_use]
207    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
208        self.placeholder = text.into();
209        self
210    }
211
212    /// Set a label for the date picker
213    #[must_use]
214    pub fn label(mut self, label: impl Into<String>) -> Self {
215        self.label = Some(label.into());
216        self
217    }
218
219    /// Show Today/Clear footer buttons
220    #[must_use]
221    pub const fn show_footer(mut self, show: bool) -> Self {
222        self.show_footer = show;
223        self
224    }
225
226    /// Set trigger button width
227    #[must_use]
228    pub const fn width(mut self, width: f32) -> Self {
229        self.width = width;
230        self
231    }
232
233    /// Show the date picker
234    ///
235    /// # Panics
236    ///
237    /// Panics if internal calendar calculations produce an invalid date.
238    pub fn show(
239        &mut self,
240        ctx: &egui::Context,
241        ui: &mut Ui,
242        selected_date: &mut Option<Date>,
243    ) -> DatePickerResponse {
244        let theme = ui.ctx().armas_theme();
245        let mut date_changed = false;
246
247        // Load internal state from context
248        let state_id = self.id.with("state");
249
250        let today_id = Id::new("datepicker_today_cache");
251        let today = ctx
252            .data(|d| d.get_temp::<Date>(today_id))
253            .unwrap_or_else(|| {
254                let today = Date::today();
255                ctx.data_mut(|d| d.insert_temp(today_id, today));
256                today
257            });
258
259        let (is_open, viewing_year, viewing_month) = ctx.data(|d| {
260            d.get_temp::<(bool, i32, u32)>(state_id)
261                .unwrap_or((false, today.year, today.month))
262        });
263
264        let mut is_open = is_open;
265        let mut viewing_year = viewing_year;
266        let mut viewing_month = viewing_month;
267
268        // Label
269        if let Some(label) = &self.label {
270            ui.label(
271                egui::RichText::new(label)
272                    .size(theme.typography.base)
273                    .color(theme.foreground()),
274            );
275            ui.add_space(4.0);
276        }
277
278        // Trigger button
279        let trigger_rect = Self::render_trigger(
280            ui,
281            &theme,
282            selected_date.as_ref(),
283            &self.placeholder,
284            self.width,
285        );
286
287        // Toggle popover on click
288        if ui
289            .interact(trigger_rect, self.id.with("trigger"), Sense::click())
290            .clicked()
291        {
292            is_open = !is_open;
293            if is_open {
294                if let Some(date) = selected_date {
295                    viewing_year = date.year;
296                    viewing_month = date.month;
297                }
298            }
299        }
300
301        // Calendar popover — delegates to shared calendar rendering functions
302        let mut calendar_action = CalendarAction::new();
303        let show_footer = self.show_footer;
304
305        self.popover.set_open(is_open);
306
307        let popover_response = self.popover.show(ctx, &theme, trigger_rect, |ui| {
308            ui.set_min_width(CALENDAR_WIDTH);
309
310            egui::Frame::new()
311                .inner_margin(CALENDAR_PADDING)
312                .show(ui, |ui| {
313                    ui.vertical(|ui| {
314                        ui.spacing_mut().item_spacing.y = 4.0;
315
316                        render_header(
317                            ui,
318                            &theme,
319                            viewing_year,
320                            viewing_month,
321                            &mut calendar_action,
322                        );
323                        ui.add_space(4.0);
324                        render_day_grid(
325                            ui,
326                            &theme,
327                            viewing_year,
328                            viewing_month,
329                            today,
330                            selected_date.as_ref(),
331                            true, // show_outside_days
332                            &mut calendar_action,
333                        );
334
335                        if show_footer {
336                            render_footer(ui, &theme, &mut calendar_action);
337                        }
338                    });
339                });
340        });
341
342        // Handle clicking outside the popover to close
343        if popover_response.clicked_outside || popover_response.should_close {
344            is_open = false;
345        }
346
347        // Handle month navigation
348        if calendar_action.prev_month {
349            if viewing_month == 1 {
350                viewing_month = 12;
351                viewing_year -= 1;
352            } else {
353                viewing_month -= 1;
354            }
355        }
356        if calendar_action.next_month {
357            if viewing_month == 12 {
358                viewing_month = 1;
359                viewing_year += 1;
360            } else {
361                viewing_month += 1;
362            }
363        }
364
365        // Handle date selection
366        if let Some(date) = calendar_action.date_clicked {
367            *selected_date = Some(date);
368            is_open = false;
369            date_changed = true;
370        }
371
372        if calendar_action.goto_today {
373            *selected_date = Some(today);
374            viewing_year = today.year;
375            viewing_month = today.month;
376            is_open = false;
377            date_changed = true;
378        }
379
380        if calendar_action.clear_date {
381            *selected_date = None;
382            is_open = false;
383            date_changed = true;
384        }
385
386        // Save internal state back to context
387        ctx.data_mut(|d| {
388            d.insert_temp(state_id, (is_open, viewing_year, viewing_month));
389        });
390
391        let response = ui.interact(ui.min_rect(), self.id.with("response"), Sense::hover());
392
393        DatePickerResponse {
394            response,
395            changed: date_changed,
396        }
397    }
398
399    /// Render the trigger button that opens the calendar popover.
400    fn render_trigger(
401        ui: &mut Ui,
402        theme: &Theme,
403        selected_date: Option<&Date>,
404        placeholder: &str,
405        width: f32,
406    ) -> Rect {
407        let trigger_size = vec2(width, TRIGGER_HEIGHT);
408        let (trigger_rect, trigger_response) = ui.allocate_exact_size(trigger_size, Sense::click());
409
410        if ui.is_rect_visible(trigger_rect) {
411            let hovered = trigger_response.hovered();
412            let has_value = selected_date.is_some();
413
414            // Background
415            ui.painter()
416                .rect_filled(trigger_rect, CORNER_RADIUS, theme.background());
417
418            // Border (outline variant)
419            let border_color = if hovered { theme.ring() } else { theme.input() };
420            ui.painter().rect_stroke(
421                trigger_rect,
422                CORNER_RADIUS,
423                egui::Stroke::new(1.0, border_color),
424                egui::StrokeKind::Inside,
425            );
426
427            // Calendar icon (left side)
428            let icon_size = 16.0;
429            let icon_rect = Rect::from_center_size(
430                trigger_rect.left_center() + vec2(16.0, 0.0),
431                vec2(icon_size, icon_size),
432            );
433
434            let icon_color = theme.muted_foreground();
435            let ir = icon_rect;
436
437            // Calendar outline
438            ui.painter().rect_stroke(
439                Rect::from_min_size(ir.min + vec2(1.0, 2.0), vec2(14.0, 12.0)),
440                2.0,
441                egui::Stroke::new(1.5, icon_color),
442                egui::StrokeKind::Inside,
443            );
444            // Calendar top hooks
445            ui.painter().line_segment(
446                [ir.min + vec2(5.0, 0.0), ir.min + vec2(5.0, 4.0)],
447                egui::Stroke::new(1.5, icon_color),
448            );
449            ui.painter().line_segment(
450                [ir.min + vec2(11.0, 0.0), ir.min + vec2(11.0, 4.0)],
451                egui::Stroke::new(1.5, icon_color),
452            );
453            // Calendar horizontal line
454            ui.painter().line_segment(
455                [ir.min + vec2(1.0, 7.0), ir.min + vec2(15.0, 7.0)],
456                egui::Stroke::new(1.0, icon_color),
457            );
458
459            // Text (date or placeholder)
460            let text = selected_date.map_or_else(|| placeholder.to_string(), Date::format_display);
461
462            let text_color = if has_value {
463                theme.foreground()
464            } else {
465                theme.muted_foreground()
466            };
467
468            ui.painter().text(
469                trigger_rect.left_center() + vec2(36.0, 0.0),
470                egui::Align2::LEFT_CENTER,
471                &text,
472                egui::FontId::proportional(theme.typography.base),
473                text_color,
474            );
475        }
476
477        trigger_rect
478    }
479}
480
481/// Response from a date picker
482pub struct DatePickerResponse {
483    /// The UI response
484    pub response: egui::Response,
485    /// Whether the selected date changed
486    pub changed: bool,
487}