1use crate::ext::ArmasContextExt;
17use crate::icon;
18use crate::Theme;
19use egui::{vec2, Color32, Id, Rect, Sense, Ui};
20
21use super::date_picker::Date;
22
23const 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
29pub struct Calendar {
31 id: Id,
32 show_footer: bool,
33 show_outside_days: bool,
34}
35
36pub struct CalendarResponse {
38 pub response: egui::Response,
40 pub changed: bool,
42}
43
44impl Calendar {
45 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 #[must_use]
56 pub const fn show_footer(mut self, show: bool) -> Self {
57 self.show_footer = show;
58 self
59 }
60
61 #[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 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 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 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 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 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#[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
196pub(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 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 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 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#[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 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 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
400pub(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 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 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}