Skip to main content

egui_charts/ui/timeframe_toolbar/
range_switcher.rs

1//! Range Switcher Widget
2//!
3//! Provides quick date range selection buttons: 1D, 5D, 1M, 6M, YTD, 1Y, 5Y, All
4use 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/// Configuration for the Range Switcher widget
12#[derive(Debug, Clone)]
13pub struct RangeSwitcherConfig {
14    /// Available date ranges to show
15    pub ranges: Vec<DateRange>,
16    /// Button height in pixels
17    pub btn_height: f32,
18    /// Button min width in pixels
19    pub btn_min_width: f32,
20    /// Spacing between btns
21    pub spacing: f32,
22    /// Background color (inactive button)
23    pub background_color: Color32,
24    /// Active button background color
25    pub active_color: Color32,
26    /// Hover background color
27    pub hover_color: Color32,
28    /// Text color (inactive)
29    pub text_color: Color32,
30    /// Text color (active)
31    pub active_text_color: Color32,
32    /// Border radius
33    pub border_radius: f32,
34    /// Font size
35    pub font_size: f32,
36    /// Show border around btns
37    pub show_border: bool,
38    /// Border color
39    pub border_color: Color32,
40}
41
42impl RangeSwitcherConfig {
43    /// Create config from `theme`
44    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, // ~32px
50            spacing: theme.spacing.xs,
51            background_color: Color32::TRANSPARENT, // Let parent show through
52            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    /// Create config with standard presets
64    pub fn standard() -> Self {
65        Self::default()
66    }
67
68    /// Create config with minimal presets (1D, 1W, 1M, 1Y)
69    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    /// Create config with custom ranges
82    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        // Default uses light UI chrome theme
93        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
113/// Range Switcher Widget
114///
115/// Displays horizontal row of date range btns (1D, 5D, 1M, etc.)
116/// Returns the selected range when a button is clicked.
117pub struct RangeSwitcher {
118    /// Currently selected range
119    selected: DateRange,
120    /// Configuration
121    config: RangeSwitcherConfig,
122    /// Theme and config `config` was last derived from; `None` when the caller supplied it.
123    config_theme: Option<(Theme, RangeSwitcherConfig)>,
124    /// Unique ID for the widget
125    id: egui::Id,
126}
127
128impl Default for RangeSwitcher {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl RangeSwitcher {
135    /// Create a new range switcher with default config
136    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    /// Create with custom config
146    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    /// Create with custom ID (for multiple instances)
156    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    /// Set the selected range
162    pub fn set_sel(&mut self, range: DateRange) {
163        self.selected = range;
164    }
165
166    /// Get the currently selected range
167    pub fn selected(&self) -> DateRange {
168        self.selected
169    }
170
171    /// Calculate the total width needed for the widget
172    pub fn preferred_width(&self, _ui: &Ui) -> f32 {
173        let mut total_width = 0.0;
174
175        for range in &self.config.ranges {
176            // Estimate width based on character count (roughly 7px per char at 11pt)
177            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; // padding
180            total_width += self.config.spacing;
181        }
182
183        total_width - self.config.spacing // Remove last spacing
184    }
185
186    /// Render the range switcher and return the newly selected range (if changed)
187    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    /// Render at a specific position (for overlay positioning)
214    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    /// Render a single button
230    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        // Calculate button size (estimate width based on character count)
235        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        // Allocate space and get response
241        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        // Determine colors based on state
254        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        // Draw background
263        ui.painter()
264            .rect_filled(rect, self.config.border_radius, bg_color);
265
266        // Draw border if enabled
267        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        // Draw text
277        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        // 1 minute bars
311        let minute_bars = DateRange::Day1.estimated_bars(60);
312        assert_eq!(minute_bars, 24 * 60); // 1440 bars for 1 day
313
314        // 1 hour bars
315        let hour_bars = DateRange::Month1.estimated_bars(3600);
316        assert_eq!(hour_bars, 30 * 24); // 720 bars for 1 month
317    }
318
319    #[test]
320    fn test_start_timestamp() {
321        // All should return None
322        assert!(DateRange::All.start_timestamp().is_none());
323
324        // Others should return Some
325        assert!(DateRange::Day1.start_timestamp().is_some());
326        assert!(DateRange::Year1.start_timestamp().is_some());
327    }
328}