1use crate::icons::icons;
6use crate::localization as l10n;
7use crate::styles::focus::draw_focus_ring;
8use crate::theme::ThemeExt;
9use crate::ui_kit::buttons::{Button, ButtonSize, IconButton};
10use crate::ui_kit::dialog::DialogFooter;
11use crate::ui_kit::modal::Modal;
12use chrono::{Datelike, Days, Months, NaiveDate};
13use egui::text::CCursorRange;
14use egui::{
15 Align, Align2, FontId, Key, Label, Layout, Modifiers, RichText, Role, Sense, Stroke,
16 StrokeKind, TextEdit, Ui, Vec2, WidgetInfo,
17};
18use std::fmt::Write;
19
20const CELL: f32 = 32.0;
22const LINE: f32 = 16.0;
24const DATE_FORMAT: &str = "%Y-%m-%d";
26
27fn today() -> NaiveDate {
29 chrono::Utc::now().date_naive()
30}
31
32fn parse_date(text: &str) -> Option<NaiveDate> {
34 NaiveDate::parse_from_str(text.trim(), DATE_FORMAT)
35 .ok()
36 .filter(|date| (1..=9999).contains(&date.year()))
37}
38
39#[derive(Debug, Clone, PartialEq)]
41#[non_exhaustive]
42pub enum DatePickerAction {
43 None,
45 GoToDate(NaiveDate),
47 SetRange(NaiveDate, NaiveDate),
49 Cancel,
51}
52
53pub struct DatePicker {
55 pub is_open: bool,
57 pub sel_date: NaiveDate,
59 displayed_month: NaiveDate,
61 date_input: String,
63 custom_range_mode: bool,
65 range_start: Option<NaiveDate>,
67 range_end: Option<NaiveDate>,
69 focus_field: bool,
71}
72
73impl Default for DatePicker {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl DatePicker {
80 pub fn new() -> Self {
82 let today = today();
83 Self {
84 is_open: false,
85 sel_date: today,
86 displayed_month: today,
87 date_input: today.format(DATE_FORMAT).to_string(),
88 custom_range_mode: false,
89 range_start: None,
90 range_end: None,
91 focus_field: false,
92 }
93 }
94
95 pub fn open(&mut self) {
97 self.is_open = true;
98 self.date_input = self.sel_date.format(DATE_FORMAT).to_string();
99 self.displayed_month = self.sel_date;
100 self.range_start = None;
101 self.range_end = None;
102 self.focus_field = true;
103 }
104
105 pub fn close(&mut self) {
107 self.is_open = false;
108 }
109
110 pub fn show(&mut self, ctx: &egui::Context) -> DatePickerAction {
112 let theme = ctx.active_theme();
113 let grid_width = 7.0 * CELL + 6.0 * theme.spacing.sm;
114 let stroke = ctx.global_style().visuals.window_stroke.width;
116 let width = grid_width + 2.0 * (theme.spacing.xxl + stroke);
117
118 let mut open = self.is_open;
119 let action =
120 match Modal::new("go_to_date", "Go to date")
121 .width(width)
122 .show(ctx, &mut open, |ui| self.draw_content(ui))
123 {
124 Some(response) if response.close_requested => DatePickerAction::Cancel,
125 Some(response) => response.inner,
126 None => DatePickerAction::None,
127 };
128 self.is_open = open && action == DatePickerAction::None;
130 action
131 }
132
133 fn draw_content(&mut self, ui: &mut Ui) -> DatePickerAction {
134 let theme = ui.active_theme();
135 let row_height = ButtonSize::MD.pixels();
136 let mut field_valid = true;
137 let mut typed = None;
138
139 ui.scope(|ui| {
140 ui.spacing_mut().item_spacing.y = theme.spacing.lg;
141 let row = Vec2::new(ui.available_width(), row_height);
142
143 ui.allocate_ui_with_layout(row, Layout::left_to_right(Align::Center), |ui| {
144 let label = ui.label(l10n::text(ui.ctx(), "Date"));
145 let mut field = TextEdit::singleline(&mut self.date_input)
146 .desired_width(f32::INFINITY)
147 .min_size(Vec2::new(0.0, row_height))
148 .align(Align2::LEFT_CENTER)
149 .hint_text(l10n::text(ui.ctx(), "YYYY-MM-DD"))
150 .show(ui);
151 let date = parse_date(&self.date_input);
152 if let Some(date) = date {
153 if field.response.changed() {
154 self.displayed_month = date;
155 if !self.custom_range_mode {
156 self.sel_date = date;
157 }
158 }
159 if self.custom_range_mode
161 && field.response.lost_focus()
162 && date != self.sel_date
163 {
164 typed = Some(date);
165 }
166 }
167 field_valid = date.is_some();
168 if !field_valid {
169 ui.painter().rect_stroke(
170 field.response.rect,
171 ui.visuals().widgets.inactive.corner_radius,
172 Stroke::new(1.0, theme.ui.error),
173 StrokeKind::Inside,
174 );
175 }
176 if self.focus_field && !ui.is_sizing_pass() {
178 self.focus_field = false;
179 field.response.request_focus();
180 field
181 .state
182 .cursor
183 .set_char_range(Some(CCursorRange::select_all(&field.galley)));
184 field.state.store(ui.ctx(), field.response.id);
185 }
186 field.response.response.labelled_by(label.id);
187 });
188 if let Some(date) = typed {
189 self.pick(date);
190 }
191
192 let (status, color) = if !field_valid {
194 (
195 l10n::text(ui.ctx(), "Enter a date as YYYY-MM-DD"),
196 theme.ui.error,
197 )
198 } else if !self.custom_range_mode {
199 (
200 self.sel_date.format("%A %-d %B %Y").to_string(),
201 theme.ui.text_secondary,
202 )
203 } else {
204 let text = match (self.range_start, self.range_end) {
205 (Some(start), Some(end)) => {
206 let mut start_text = String::with_capacity(10);
207 let mut end_text = String::with_capacity(10);
208 let _ = write!(start_text, "{start}");
209 let _ = write!(end_text, "{end}");
210 l10n::format_text(
211 ui.ctx(),
212 "From {start} to {end}",
213 &[("start", start_text.as_str()), ("end", end_text.as_str())],
214 )
215 }
216 (Some(start), None) => {
217 let mut start_text = String::with_capacity(10);
218 let _ = write!(start_text, "{start}");
219 l10n::format_text(
220 ui.ctx(),
221 "From {start}, pick the last day",
222 &[("start", start_text.as_str())],
223 )
224 }
225 _ => l10n::text(ui.ctx(), "Pick the first day"),
226 };
227 (text, theme.ui.text_secondary)
228 };
229 ui.allocate_ui_with_layout(
230 Vec2::new(row.x, LINE),
231 Layout::left_to_right(Align::Center),
232 |ui| ui.label(RichText::new(status).size(theme.text.label).color(color)),
233 );
234
235 ui.allocate_ui_with_layout(row, Layout::left_to_right(Align::Center), |ui| {
236 if ui
237 .checkbox(
238 &mut self.custom_range_mode,
239 l10n::text(ui.ctx(), "Custom range"),
240 )
241 .changed()
242 {
243 self.range_start = None;
244 self.range_end = None;
245 }
246 });
247
248 ui.allocate_ui_with_layout(row, Layout::left_to_right(Align::Center), |ui| {
249 let previous = IconButton::new(&icons::CHEVRON_LEFT)
250 .tooltip("Previous month")
251 .show(ui)
252 .clicked();
253 let next = ui
254 .with_layout(Layout::right_to_left(Align::Center), |ui| {
255 let next = IconButton::new(&icons::CHEVRON_RIGHT)
256 .tooltip("Next month")
257 .show(ui)
258 .clicked();
259 ui.centered_and_justified(|ui| {
260 ui.label(
261 RichText::new(l10n::text(
262 ui.ctx(),
263 self.displayed_month.format("%B %Y").to_string(),
264 ))
265 .strong()
266 .size(theme.text.body),
267 );
268 });
269 next
270 })
271 .inner;
272 let month = Months::new(1);
273 let shown = if previous {
274 self.displayed_month.checked_sub_months(month)
275 } else if next {
276 self.displayed_month.checked_add_months(month)
277 } else {
278 None
279 };
280 self.displayed_month = shown.unwrap_or(self.displayed_month);
281 });
282
283 if let Some(date) = self.draw_calendar(ui) {
284 self.pick(date);
285 field_valid = true;
286 }
287 });
288
289 let range = self.range_start.zip(self.range_end);
290 let can_go = if self.custom_range_mode {
291 range.is_some()
292 } else {
293 field_valid
294 };
295
296 let mut today_clicked = false;
297 let (go, cancel) = DialogFooter::new("Go")
298 .secondary("Cancel")
299 .primary_enabled(can_go)
300 .show_with_left(ui, |ui| {
301 today_clicked = Button::new("Today").ghost().show(ui).clicked();
302 });
303 if today_clicked {
304 self.pick(today());
305 }
306
307 let enter = can_go
309 && ui.memory(|m| m.focused().is_none())
310 && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
311
312 if go || enter {
313 match range {
314 Some((start, end)) if self.custom_range_mode => {
315 DatePickerAction::SetRange(start, end)
316 }
317 _ => DatePickerAction::GoToDate(self.sel_date),
318 }
319 } else if cancel {
320 DatePickerAction::Cancel
321 } else {
322 DatePickerAction::None
323 }
324 }
325
326 fn pick(&mut self, date: NaiveDate) {
329 self.sel_date = date;
330 self.displayed_month = date;
332 self.date_input = date.format(DATE_FORMAT).to_string();
333 if self.custom_range_mode {
334 (self.range_start, self.range_end) = match (self.range_start, self.range_end) {
335 (Some(start), None) => (Some(start.min(date)), Some(start.max(date))),
336 _ => (Some(date), None),
337 };
338 }
339 }
340
341 fn draw_calendar(&mut self, ui: &mut Ui) -> Option<NaiveDate> {
343 let theme = ui.active_theme();
344 let today = today();
345 let first = self.displayed_month.with_day(1)?;
346 let grid_start =
347 first.checked_sub_days(Days::new(first.weekday().num_days_from_sunday().into()))?;
348 let ends = if self.custom_range_mode {
349 [self.range_start, self.range_end]
350 } else {
351 [Some(self.sel_date), None]
352 };
353
354 let mut picked = None;
355 ui.scope(|ui| {
356 ui.spacing_mut().item_spacing = Vec2::splat(theme.spacing.sm);
357
358 ui.horizontal(|ui| {
359 for day in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
360 ui.add_sized(
361 [CELL, LINE],
362 Label::new(
363 RichText::new(l10n::text(ui.ctx(), day))
364 .size(theme.text.small)
365 .color(theme.ui.text_muted),
366 ),
367 );
368 }
369 });
370
371 for week in 0..6 {
372 ui.horizontal(|ui| {
373 for weekday in 0..7 {
374 let Some(date) = grid_start.checked_add_days(Days::new(week * 7 + weekday))
375 else {
376 continue;
377 };
378 let (rect, response) =
379 ui.allocate_exact_size(Vec2::splat(CELL), Sense::click());
380 let is_end = ends.contains(&Some(date));
381 let is_between =
382 matches!(ends, [Some(start), Some(end)] if start < date && date < end);
383 let name = date.format("%A %-d %B %Y").to_string();
384 let name = if date == today {
385 l10n::format_text(ui.ctx(), "{name}, today", &[("name", name)])
386 } else {
387 name
388 };
389 response.widget_info(|| {
390 WidgetInfo::selected(Role::Button, true, is_end, &name)
391 });
392
393 let (fill, text) = if is_end {
394 (Some(theme.ui.accent), theme.ui.accent_text)
395 } else if is_between {
396 (Some(theme.ui.accent.gamma_multiply(0.35)), theme.ui.text)
397 } else if response.hovered() {
398 (Some(theme.ui.btn_bg_hover), theme.ui.text)
399 } else if date.month() == first.month() {
400 (None, theme.ui.text)
401 } else {
402 (None, theme.ui.text_muted)
403 };
404 let painter = ui.painter();
405 if let Some(fill) = fill {
406 painter.rect_filled(rect, theme.radius.sm, fill);
407 }
408 if date == today && !is_end && !is_between {
409 painter.rect_stroke(
410 rect,
411 theme.radius.sm,
412 Stroke::new(1.0, theme.ui.text_muted),
413 StrokeKind::Inside,
414 );
415 }
416 painter.text(
417 rect.center(),
418 Align2::CENTER_CENTER,
419 l10n::text(ui.ctx(), date.day().to_string()),
420 FontId::proportional(theme.text.body),
421 text,
422 );
423 draw_focus_ring(ui, &response, theme.radius.sm);
424
425 if response.clicked() {
426 picked = Some(date);
427 }
428 }
429 });
430 }
431 });
432 picked
433 }
434}