Skip to main content

egui_charts/ui/top_toolbar/components/timeframe_selector/
custom.rs

1//! Custom interval dialog of the timeframe selector.
2
3use super::{TimeframeUnit, interval};
4use crate::localization as l10n;
5use crate::model::Timeframe;
6use crate::theme::ThemeExt;
7use crate::tokens::DESIGN_TOKENS;
8use crate::ui_kit::buttons::Button;
9use egui::{RichText, Ui, vec2};
10
11/// Custom timeframe dialog state
12#[derive(Debug, Clone)]
13pub struct CustomTimeframeDialog {
14    /// The numeric value input
15    pub value_input: String,
16    /// The selected unit
17    pub unit: TimeframeUnit,
18    /// Validation error message
19    pub error: Option<String>,
20}
21
22impl Default for CustomTimeframeDialog {
23    fn default() -> Self {
24        Self {
25            value_input: "1".to_string(),
26            unit: TimeframeUnit::Minute,
27            error: None,
28        }
29    }
30}
31
32impl CustomTimeframeDialog {
33    /// Validate and return the custom Timeframe, or None if invalid
34    pub fn validate(&mut self) -> Option<Timeframe> {
35        let trimmed = self.value_input.trim();
36        if trimmed.is_empty() {
37            self.error = Some("Enter a number".to_string());
38            return None;
39        }
40        match trimmed.parse::<u32>() {
41            Ok(0) => {
42                self.error = Some("Value must be > 0".to_string());
43                None
44            }
45            Ok(v) if v > 10_000 => {
46                self.error = Some("Value too large (max 10000)".to_string());
47                None
48            }
49            Ok(v) if self.unit == TimeframeUnit::Millisecond && ![100, 250, 500].contains(&v) => {
50                self.error = Some("Use 100, 250 or 500 milliseconds".to_owned());
51                None
52            }
53            Ok(v) => {
54                self.error = None;
55                Some(interval(v, self.unit))
56            }
57            Err(_) => {
58                self.error = Some("Enter a valid number".to_string());
59                None
60            }
61        }
62    }
63
64    /// Available units for the custom dialog
65    fn available_units() -> &'static [(TimeframeUnit, &'static str)] {
66        &[
67            (TimeframeUnit::Tick, "Ticks"),
68            (TimeframeUnit::Volume, "Volume"),
69            (TimeframeUnit::Range, "Range"),
70            (TimeframeUnit::Delta, "Delta"),
71            (TimeframeUnit::Second, "Seconds"),
72            (TimeframeUnit::Minute, "Minutes"),
73            (TimeframeUnit::Hour, "Hours"),
74            (TimeframeUnit::Day, "Days"),
75            (TimeframeUnit::Week, "Weeks"),
76            (TimeframeUnit::Month, "Months"),
77        ]
78    }
79
80    /// Show the custom timeframe dialog inside the dropdown.
81    /// Returns Some(Timeframe) if the user confirms, None otherwise.
82    pub fn show(&mut self, ui: &mut Ui) -> Option<Timeframe> {
83        let theme = ui.active_theme();
84        let mut result = None;
85
86        ui.add_space(theme.spacing.lg);
87        ui.allocate_ui_with_layout(
88            vec2(ui.available_width(), DESIGN_TOKENS.sizing.button_md),
89            egui::Layout::left_to_right(egui::Align::Center),
90            |ui| {
91                ui.add_space(theme.spacing.lg);
92                // The controls share the height of the button.
93                ui.spacing_mut().interact_size.y = DESIGN_TOKENS.sizing.button_md;
94                // The menu style draws inactive controls without a frame; the form's fields need one.
95                let inactive = &mut ui.visuals_mut().widgets.inactive;
96                inactive.bg_stroke = egui::Stroke::new(1.0, theme.ui.border_control);
97                inactive.weak_bg_fill = theme.ui.btn_bg_hover;
98                let width_id = ui.scope_id().with("interval_actions_width");
99                let actions_width = ui.data(|d| d.get_temp::<f32>(width_id)).unwrap_or(0.0);
100                let field_width =
101                    (ui.available_width() - actions_width - 2.0 * theme.spacing.lg).max(0.0);
102                ui.add_sized(
103                    vec2(field_width, DESIGN_TOKENS.sizing.button_md),
104                    egui::TextEdit::singleline(&mut self.value_input)
105                        .desired_width(0.0)
106                        .align(egui::Align2::LEFT_CENTER)
107                        .font(egui::FontId::proportional(theme.text.body)),
108                )
109                .accessible_name(l10n::text(ui.ctx(), "Interval value"));
110                let width = ui
111                    .horizontal(|ui| {
112                        let unit = Self::available_units()
113                            .iter()
114                            .find(|(u, _)| *u == self.unit)
115                            .map_or_else(|| self.unit.name(), |(_, label)| label);
116                        // A submenu keeps the interval menu open when a unit is picked.
117                        ui.menu_button(l10n::text(ui.ctx(), unit), |ui| {
118                            for (u, label) in Self::available_units() {
119                                if ui
120                                    .selectable_label(self.unit == *u, l10n::text(ui.ctx(), *label))
121                                    .clicked()
122                                {
123                                    self.unit = *u;
124                                }
125                            }
126                        });
127                        if Button::new("Add").primary().show(ui).clicked() {
128                            result = self.validate();
129                        }
130                    })
131                    .response
132                    .rect
133                    .width();
134                if (width - actions_width).abs() > 0.5 {
135                    ui.data_mut(|d| d.insert_temp(width_id, width));
136                    ui.ctx().request_discard("Interval actions changed width");
137                }
138            },
139        );
140
141        if let Some(err) = &self.error {
142            ui.horizontal(|ui| {
143                ui.add_space(theme.spacing.lg);
144                ui.label(
145                    RichText::new(l10n::text(ui.ctx(), err))
146                        .size(theme.text.small)
147                        .color(ui.visuals().error_fg_color),
148                );
149            });
150        }
151
152        result
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::ui::top_toolbar::components::timeframe_selector::parts;
160    use egui::Rect;
161
162    #[test]
163    fn custom_interval_tabs_from_value_to_unit_to_add() {
164        for unit in [TimeframeUnit::Week, TimeframeUnit::Month] {
165            assert!(
166                CustomTimeframeDialog::available_units()
167                    .iter()
168                    .any(|(u, _)| *u == unit)
169            );
170            let mut dialog = CustomTimeframeDialog {
171                value_input: "3".into(),
172                unit,
173                error: None,
174            };
175            assert_eq!(parts(dialog.validate().unwrap()), (3, unit));
176            dialog.value_input = "0".into();
177            assert!(dialog.validate().is_none());
178        }
179        for width in [320.0, 500.0] {
180            let ctx = egui::Context::default();
181            let mut dialog = CustomTimeframeDialog::default();
182            let mut frame = |events| {
183                ctx.run_ui(
184                    egui::RawInput {
185                        screen_rect: Some(Rect::from_min_size(
186                            egui::Pos2::ZERO,
187                            vec2(width, 200.0),
188                        )),
189                        events,
190                        ..Default::default()
191                    },
192                    |ui| {
193                        dialog.show(ui);
194                    },
195                )
196                .drop_without_applying_deltas();
197                ctx.memory(|m| m.focused())
198                    .and_then(|id| ctx.read_response(id))
199                    .map(|r| r.rect)
200            };
201            frame(vec![]);
202            let mut right = 0.0;
203            for _ in 0..3 {
204                let rect = frame(vec![egui::Event::Key {
205                    key: egui::Key::Tab,
206                    physical_key: None,
207                    pressed: true,
208                    repeat: false,
209                    modifiers: egui::Modifiers::NONE,
210                }])
211                .unwrap();
212                assert!(rect.left() >= right, "{rect:?} follows {right}");
213                assert!(rect.right() <= width, "{rect:?}");
214                right = rect.right();
215            }
216        }
217    }
218}