egui_charts/ui/top_toolbar/components/timeframe_selector/
custom.rs1use 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#[derive(Debug, Clone)]
13pub struct CustomTimeframeDialog {
14 pub value_input: String,
16 pub unit: TimeframeUnit,
18 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 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 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 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 ui.spacing_mut().interact_size.y = DESIGN_TOKENS.sizing.button_md;
94 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 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}