1use 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
26const CALENDAR_PADDING: f32 = 12.0;
28const CALENDAR_WIDTH: f32 = 252.0; const TRIGGER_WIDTH: f32 = 280.0;
30const TRIGGER_HEIGHT: f32 = 40.0;
31const CORNER_RADIUS: f32 = 6.0;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct Date {
37 pub year: i32,
39 pub month: u32,
41 pub day: u32,
43}
44
45impl Date {
46 #[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 #[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 #[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 #[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 #[must_use]
96 #[allow(clippy::many_single_char_names, clippy::cast_possible_wrap)]
97 pub const fn day_of_week(&self) -> u32 {
98 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 #[must_use]
116 pub fn format_display(&self) -> String {
117 format!("{} {}, {}", self.month_name(), self.day, self.year)
118 }
119
120 #[must_use]
122 pub fn format(&self) -> String {
123 format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
124 }
125
126 #[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 #[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#[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 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 #[must_use]
207 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
208 self.placeholder = text.into();
209 self
210 }
211
212 #[must_use]
214 pub fn label(mut self, label: impl Into<String>) -> Self {
215 self.label = Some(label.into());
216 self
217 }
218
219 #[must_use]
221 pub const fn show_footer(mut self, show: bool) -> Self {
222 self.show_footer = show;
223 self
224 }
225
226 #[must_use]
228 pub const fn width(mut self, width: f32) -> Self {
229 self.width = width;
230 self
231 }
232
233 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 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 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 let trigger_rect = Self::render_trigger(
280 ui,
281 &theme,
282 selected_date.as_ref(),
283 &self.placeholder,
284 self.width,
285 );
286
287 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 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, &mut calendar_action,
333 );
334
335 if show_footer {
336 render_footer(ui, &theme, &mut calendar_action);
337 }
338 });
339 });
340 });
341
342 if popover_response.clicked_outside || popover_response.should_close {
344 is_open = false;
345 }
346
347 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 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 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 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 ui.painter()
416 .rect_filled(trigger_rect, CORNER_RADIUS, theme.background());
417
418 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 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 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 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 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 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
481pub struct DatePickerResponse {
483 pub response: egui::Response,
485 pub changed: bool,
487}