egui_charts/ui/timeframe_toolbar/
range_switcher.rs1use crate::localization as l10n;
5use crate::theme::{Theme, follow_active_theme, theme_derived};
6use crate::tokens::DESIGN_TOKENS;
7use egui::{Color32, FontId, Pos2, Response, Stroke, Ui, Vec2};
8
9pub use crate::model::DateRange;
10
11#[derive(Debug, Clone)]
13pub struct RangeSwitcherConfig {
14 pub ranges: Vec<DateRange>,
16 pub btn_height: f32,
18 pub btn_min_width: f32,
20 pub spacing: f32,
22 pub background_color: Color32,
24 pub active_color: Color32,
26 pub hover_color: Color32,
28 pub text_color: Color32,
30 pub active_text_color: Color32,
32 pub border_radius: f32,
34 pub font_size: f32,
36 pub show_border: bool,
38 pub border_color: Color32,
40}
41
42impl RangeSwitcherConfig {
43 pub fn from_theme(theme: &Theme) -> Self {
45 let ui = &theme.ui;
46 Self {
47 ranges: DateRange::default_presets(),
48 btn_height: DESIGN_TOKENS.sizing.button_sm,
49 btn_min_width: DESIGN_TOKENS.sizing.button_md + theme.spacing.sm, spacing: theme.spacing.xs,
51 background_color: Color32::TRANSPARENT, active_color: ui.btn_bg_active,
53 hover_color: ui.btn_bg_hover,
54 text_color: ui.text_secondary,
55 active_text_color: ui.text,
56 border_radius: theme.radius.md,
57 font_size: theme.text.small,
58 show_border: true,
59 border_color: ui.border,
60 }
61 }
62
63 pub fn standard() -> Self {
65 Self::default()
66 }
67
68 pub fn minimal() -> Self {
70 Self {
71 ranges: vec![
72 DateRange::Day1,
73 DateRange::Month1,
74 DateRange::Year1,
75 DateRange::All,
76 ],
77 ..Default::default()
78 }
79 }
80
81 pub fn with_ranges(ranges: Vec<DateRange>) -> Self {
83 Self {
84 ranges,
85 ..Default::default()
86 }
87 }
88}
89
90impl Default for RangeSwitcherConfig {
91 fn default() -> Self {
92 Self::from_theme(&Theme::dark())
94 }
95}
96
97theme_derived!(RangeSwitcherConfig {
98 ranges,
99 btn_height,
100 btn_min_width,
101 spacing,
102 background_color,
103 active_color,
104 hover_color,
105 text_color,
106 active_text_color,
107 border_radius,
108 font_size,
109 show_border,
110 border_color
111});
112
113pub struct RangeSwitcher {
118 selected: DateRange,
120 config: RangeSwitcherConfig,
122 config_theme: Option<(Theme, RangeSwitcherConfig)>,
124 id: egui::Id,
126}
127
128impl Default for RangeSwitcher {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl RangeSwitcher {
135 pub fn new() -> Self {
137 Self {
138 selected: DateRange::Month1,
139 config: RangeSwitcherConfig::default(),
140 config_theme: Some((Theme::dark(), RangeSwitcherConfig::default())),
141 id: egui::Id::unique("range_switcher"),
142 }
143 }
144
145 pub fn with_config(config: RangeSwitcherConfig) -> Self {
147 Self {
148 selected: config.ranges.first().copied().unwrap_or(DateRange::Month1),
149 config,
150 config_theme: None,
151 id: egui::Id::unique("range_switcher"),
152 }
153 }
154
155 pub fn with_id(mut self, id: impl std::hash::Hash + std::fmt::Debug) -> Self {
157 self.id = egui::Id::unique(id);
158 self
159 }
160
161 pub fn set_sel(&mut self, range: DateRange) {
163 self.selected = range;
164 }
165
166 pub fn selected(&self) -> DateRange {
168 self.selected
169 }
170
171 pub fn preferred_width(&self, _ui: &Ui) -> f32 {
173 let mut total_width = 0.0;
174
175 for range in &self.config.ranges {
176 let char_width = self.config.font_size * 0.6;
178 let text_width = char_width * range.label().len() as f32;
179 total_width += text_width.max(self.config.btn_min_width) + 16.0; total_width += self.config.spacing;
181 }
182
183 total_width - self.config.spacing }
185
186 pub fn show(&mut self, ui: &mut Ui) -> Option<DateRange> {
188 follow_active_theme(
189 ui.ctx(),
190 &mut self.config_theme,
191 &mut self.config,
192 RangeSwitcherConfig::from_theme,
193 );
194 let mut new_selection = None;
195
196 ui.horizontal(|ui| {
197 ui.spacing_mut().item_spacing.x = self.config.spacing;
198
199 for &range in &self.config.ranges {
200 let is_sel = self.selected == range;
201 let response = self.render_btn(ui, range, is_sel);
202
203 if response.clicked() && !is_sel {
204 self.selected = range;
205 new_selection = Some(range);
206 }
207 }
208 });
209
210 new_selection
211 }
212
213 pub fn show_at(&mut self, ui: &mut Ui, pos: Pos2) -> Option<DateRange> {
215 let mut new_selection = None;
216
217 egui::Area::new(self.id)
218 .fixed_pos(pos)
219 .order(egui::Order::Foreground)
220 .show(ui.ctx(), |ui| {
221 egui::Frame::NONE.fill(Color32::TRANSPARENT).show(ui, |ui| {
222 new_selection = self.show(ui);
223 });
224 });
225
226 new_selection
227 }
228
229 fn render_btn(&self, ui: &mut Ui, range: DateRange, is_sel: bool) -> Response {
231 let label = range.label();
232 let font_id = FontId::proportional(self.config.font_size);
233
234 let char_width = self.config.font_size * 0.6;
236 let text_width = char_width * label.len() as f32;
237 let btn_width = (text_width + 16.0).max(self.config.btn_min_width);
238 let btn_size = Vec2::new(btn_width, self.config.btn_height);
239
240 let (rect, response) = ui.allocate_exact_size(btn_size, egui::Sense::click());
242 let translated_label = l10n::text(ui.ctx(), label);
243 response.widget_info(|| {
244 egui::WidgetInfo::selected(
245 egui::Role::Button,
246 response.enabled(),
247 is_sel,
248 &translated_label,
249 )
250 });
251 crate::styles::focus::draw_focus_ring(ui, &response, self.config.border_radius);
252
253 let (bg_color, text_color) = if is_sel {
255 (self.config.active_color, self.config.active_text_color)
256 } else if response.hovered() {
257 (self.config.hover_color, self.config.text_color)
258 } else {
259 (self.config.background_color, self.config.text_color)
260 };
261
262 ui.painter()
264 .rect_filled(rect, self.config.border_radius, bg_color);
265
266 if self.config.show_border {
268 ui.painter().rect_stroke(
269 rect,
270 self.config.border_radius,
271 Stroke::new(DESIGN_TOKENS.stroke.hairline, self.config.border_color),
272 egui::epaint::StrokeKind::Inside,
273 );
274 }
275
276 ui.painter().text(
278 rect.center(),
279 egui::Align2::CENTER_CENTER,
280 l10n::text(ui.ctx(), label),
281 font_id,
282 text_color,
283 );
284
285 response
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_date_range_labels() {
295 assert_eq!(DateRange::Day1.label(), "1D");
296 assert_eq!(DateRange::YTD.label(), "YTD");
297 assert_eq!(DateRange::All.label(), "All");
298 }
299
300 #[test]
301 fn test_standard_presets() {
302 let presets = DateRange::default_presets();
303 assert_eq!(presets.len(), 9);
304 assert_eq!(presets[0], DateRange::Day1);
305 assert_eq!(presets[8], DateRange::All);
306 }
307
308 #[test]
309 fn test_estimated_bars() {
310 let minute_bars = DateRange::Day1.estimated_bars(60);
312 assert_eq!(minute_bars, 24 * 60); let hour_bars = DateRange::Month1.estimated_bars(3600);
316 assert_eq!(hour_bars, 30 * 24); }
318
319 #[test]
320 fn test_start_timestamp() {
321 assert!(DateRange::All.start_timestamp().is_none());
323
324 assert!(DateRange::Day1.start_timestamp().is_some());
326 assert!(DateRange::Year1.start_timestamp().is_some());
327 }
328}