Skip to main content

egui_charts/ui/timeframe_toolbar/
date_picker.rs

1//! Go to date picker
2//!
3//! A modal dialog with a date field and a month calendar for moving the chart to a date or a
4//! range of dates.
5use 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
20/// Side of a day cell.
21const CELL: f32 = 32.0;
22/// Height of the line under the date field and of the weekday row.
23const LINE: f32 = 16.0;
24/// Format of the date field.
25const DATE_FORMAT: &str = "%Y-%m-%d";
26
27/// Today in UTC, the time zone the chart shows times in by default.
28fn today() -> NaiveDate {
29    chrono::Utc::now().date_naive()
30}
31
32/// The date in `text`, if it is a valid date between the years 1 and 9999.
33fn 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/// Action from date picker
40#[derive(Debug, Clone, PartialEq)]
41#[non_exhaustive]
42pub enum DatePickerAction {
43    /// No action requested.
44    None,
45    /// Navigate to the supplied calendar date.
46    GoToDate(NaiveDate),
47    /// The first and the last day of the range, both included.
48    SetRange(NaiveDate, NaiveDate),
49    /// Cancel the current interaction.
50    Cancel,
51}
52
53/// Date picker component
54pub struct DatePicker {
55    /// Is dialog open
56    pub is_open: bool,
57    /// Selected date
58    pub sel_date: NaiveDate,
59    /// Currently displayed month
60    displayed_month: NaiveDate,
61    /// Date input text
62    date_input: String,
63    /// Custom range mode
64    custom_range_mode: bool,
65    /// Range start date
66    range_start: Option<NaiveDate>,
67    /// Range end date
68    range_end: Option<NaiveDate>,
69    /// Focus the date field on the next frame the dialog is drawn.
70    focus_field: bool,
71}
72
73impl Default for DatePicker {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl DatePicker {
80    /// Create a [`DatePicker`] with default settings.
81    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    /// Open the date picker
96    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    /// Close the date picker
106    pub fn close(&mut self) {
107        self.is_open = false;
108    }
109
110    /// Show the date picker dialog
111    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        // `Modal::width` is the outer width: the padding and the frame's stroke on both sides.
115        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        // Go, Cancel, the close button and Escape all close the picker.
129        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                    // In range mode a typed date counts as a pick once the field is left.
160                    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                // The sizing pass draws nothing, so take the focus on the first visible frame.
177                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            // Always one line, so the dialog keeps its height while a range is picked.
193            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        // Enter applies Go unless a focused control takes it; the field gives up its focus on Enter.
308        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    /// Select `date` as a click on its day does; in range mode it starts a range or ends the
327    /// started one.
328    fn pick(&mut self, date: NaiveDate) {
329        self.sel_date = date;
330        // A day of the previous or next month shows that month.
331        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    /// Six weeks from the Sunday on or before the first of the displayed month.
342    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}