Skip to main content

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

1//! Timeframe Selector
2//!
3//! A dropdown menu for selecting chart timeframes with favorites support.
4mod custom;
5
6use crate::icons::icons as embedded_icons;
7use crate::localization as l10n;
8use crate::model::Timeframe;
9use crate::styles::focus::focus_ring_color;
10use crate::styles::icons as icon_sizes;
11use crate::theme::{Theme, ThemeExt};
12use crate::tokens::DESIGN_TOKENS;
13use crate::ui::top_toolbar::buttons::Trigger;
14use crate::ui_kit::list_item::{ContentContext, LabelContent, ListItem, ListItemContent};
15use egui::{Color32, Rect, RichText, Role, Ui, WidgetInfo, pos2, vec2};
16use std::collections::HashSet;
17use std::hash::Hash;
18use std::num::{NonZeroU32, NonZeroU64};
19
20pub use custom::CustomTimeframeDialog;
21
22/// Height of the interval menu's rows and section headers.
23const ROW_H: f32 = 32.0;
24
25/// Text size of the interval labels.
26const LABEL_SIZE: f32 = 16.0;
27
28/// Width reserved while the custom interval form is expanded.
29const CUSTOM_FORM_W: f32 = 232.0;
30
31pub use crate::model::TimeframeUnit;
32
33fn interval(value: u32, unit: TimeframeUnit) -> Timeframe {
34    let count = std::num::NonZeroU64::new(u64::from(value)).expect("validated interval");
35    match unit {
36        TimeframeUnit::Tick => Timeframe::Ticks(count),
37        TimeframeUnit::Volume => Timeframe::Volume(count),
38        TimeframeUnit::Range => Timeframe::Range(count),
39        TimeframeUnit::Delta => Timeframe::Delta(count),
40        _ => format!(
41            "{value}{}",
42            match unit {
43                TimeframeUnit::Minute => "min",
44                TimeframeUnit::Month => "month",
45                _ => unit.short_name(),
46            }
47        )
48        .parse()
49        .expect("valid interval unit"),
50    }
51}
52
53fn parts(tf: Timeframe) -> (u64, TimeframeUnit) {
54    use TimeframeUnit as Unit;
55    let seconds = match tf {
56        Timeframe::Custom(seconds) => seconds.get(),
57        _ => tf.duration_ms() as u64 / 1000,
58    };
59    match tf {
60        Timeframe::Ms100 => (100, Unit::Millisecond),
61        Timeframe::Ms250 => (250, Unit::Millisecond),
62        Timeframe::Ms500 => (500, Unit::Millisecond),
63        Timeframe::Ticks(n) => (n.get(), Unit::Tick),
64        Timeframe::Volume(n) => (n.get(), Unit::Volume),
65        Timeframe::Range(n) => (n.get(), Unit::Range),
66        Timeframe::Delta(n) => (n.get(), Unit::Delta),
67        Timeframe::Months(n) => (u64::from(n.get()), Unit::Month),
68        Timeframe::Weeks(n) => (u64::from(n.get()), Unit::Week),
69        Timeframe::Month1 => (1, Unit::Month),
70        Timeframe::Week1 => (1, Unit::Week),
71        _ if seconds >= 86_400 && seconds % 86_400 == 0 => (seconds / 86_400, Unit::Day),
72        _ if seconds >= 3_600 && seconds % 3_600 == 0 => (seconds / 3_600, Unit::Hour),
73        _ if seconds >= 60 && seconds % 60 == 0 => (seconds / 60, Unit::Minute),
74        _ => (seconds, Unit::Second),
75    }
76}
77
78fn display(tf: Timeframe) -> String {
79    let (value, unit) = parts(tf);
80    if value == 1
81        && matches!(
82            unit,
83            TimeframeUnit::Day | TimeframeUnit::Week | TimeframeUnit::Month
84        )
85    {
86        unit.short_name().to_owned()
87    } else {
88        format!("{value}{}", unit.short_name())
89    }
90}
91
92pub(crate) fn display_long(tf: Timeframe) -> String {
93    let (value, unit) = parts(tf);
94    let name = if value == 1 {
95        match unit {
96            TimeframeUnit::Tick => "tick",
97            TimeframeUnit::Volume => "volume unit",
98            TimeframeUnit::Range => "range tick",
99            TimeframeUnit::Delta => "delta unit",
100            TimeframeUnit::Millisecond => "millisecond",
101            TimeframeUnit::Second => "second",
102            TimeframeUnit::Minute => "minute",
103            TimeframeUnit::Hour => "hour",
104            TimeframeUnit::Day => "day",
105            TimeframeUnit::Week => "week",
106            TimeframeUnit::Month => "month",
107        }
108    } else {
109        unit.name()
110    };
111    format!("{value} {name}")
112}
113
114const PRESETS: &[Timeframe] = &[
115    // Ticks
116    Timeframe::Ticks(NonZeroU64::new(1).unwrap()),
117    Timeframe::Ticks(NonZeroU64::new(10).unwrap()),
118    Timeframe::Ticks(NonZeroU64::new(100).unwrap()),
119    Timeframe::Ticks(NonZeroU64::new(1000).unwrap()),
120    Timeframe::Volume(NonZeroU64::new(100).unwrap()),
121    Timeframe::Volume(NonZeroU64::new(1000).unwrap()),
122    Timeframe::Range(NonZeroU64::new(10).unwrap()),
123    Timeframe::Delta(NonZeroU64::new(100).unwrap()),
124    // Milliseconds
125    Timeframe::Ms100,
126    Timeframe::Ms250,
127    Timeframe::Ms500,
128    // Seconds
129    Timeframe::Sec1,
130    Timeframe::Sec2,
131    Timeframe::Sec5,
132    Timeframe::Sec10,
133    Timeframe::Custom(NonZeroU64::new(15).unwrap()),
134    Timeframe::Sec30,
135    Timeframe::Custom(NonZeroU64::new(45).unwrap()),
136    // Minutes
137    Timeframe::Min1,
138    Timeframe::Custom(NonZeroU64::new(120).unwrap()),
139    Timeframe::Custom(NonZeroU64::new(180).unwrap()),
140    Timeframe::Min5,
141    Timeframe::Custom(NonZeroU64::new(600).unwrap()),
142    Timeframe::Min15,
143    Timeframe::Min30,
144    Timeframe::Custom(NonZeroU64::new(2700).unwrap()),
145    // Hours
146    Timeframe::Hour1,
147    Timeframe::Custom(NonZeroU64::new(7200).unwrap()),
148    Timeframe::Custom(NonZeroU64::new(10800).unwrap()),
149    Timeframe::Hour4,
150    // Day/Week/Month
151    Timeframe::Day1,
152    Timeframe::Week1,
153    Timeframe::Month1,
154    Timeframe::Months(NonZeroU32::new(3).unwrap()),
155    Timeframe::Months(NonZeroU32::new(6).unwrap()),
156    Timeframe::Months(NonZeroU32::new(12).unwrap()),
157];
158
159/// Configuration for timeframe selector
160#[derive(Debug, Clone)]
161pub struct TimeframeSelectorConfig {
162    /// Minimum width of each interval button; it is otherwise sized to its label.
163    pub btn_width: f32,
164    /// Minimum menu content width; the expanded custom form reserves at least 232 px.
165    pub dropdown_width: f32,
166}
167
168impl Default for TimeframeSelectorConfig {
169    fn default() -> Self {
170        Self {
171            btn_width: 0.0,
172            dropdown_width: 184.0,
173        }
174    }
175}
176
177/// Action from timeframe selector
178#[derive(Debug, Clone, PartialEq)]
179#[non_exhaustive]
180pub enum TimeframeAction {
181    /// No action requested.
182    None,
183    /// Select the supplied value.
184    Select(Timeframe),
185    /// Toggle the supplied interval's favorite state.
186    ToggleFavorite(Timeframe),
187    /// Hide an interval entry without changing the current interval.
188    Hide(Timeframe),
189    /// Restore all hidden interval entries.
190    RestoreHidden,
191}
192
193/// Host-owned interval settings. Save with the user's settings and restore with
194/// [`TimeframeSelector::set_preferences`]. The selector owns no storage.
195#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196pub struct TimeframePreferences {
197    /// Pinned intervals, including those temporarily unavailable from a source.
198    #[serde(default)]
199    pub favorites: Vec<Timeframe>,
200    /// User-created intervals in menu order.
201    #[serde(default)]
202    pub custom_timeframes: Vec<Timeframe>,
203    /// Hidden entries, including those temporarily unavailable from a source.
204    #[serde(default)]
205    pub hidden_timeframes: Vec<Timeframe>,
206}
207
208/// Timeframe selector
209pub struct TimeframeSelector {
210    /// Currently selected timeframe
211    pub current: Timeframe,
212    /// Is dropdown open
213    is_open: bool,
214    /// Favorite timeframes
215    pub favorites: HashSet<Timeframe>,
216    /// Custom timeframes
217    pub custom_timeframes: Vec<Timeframe>,
218    /// Configuration
219    pub config: TimeframeSelectorConfig,
220    /// Show custom input dialog
221    show_custom_input: bool,
222    /// Custom timeframe dialog state
223    custom_dialog: CustomTimeframeDialog,
224    supported_timeframes: Option<HashSet<crate::model::Timeframe>>,
225    feed_timeframes: Option<HashSet<crate::model::Timeframe>>,
226    hidden_timeframes: HashSet<Timeframe>,
227    last_non_favorite: Option<Timeframe>,
228}
229
230impl Default for TimeframeSelector {
231    fn default() -> Self {
232        Self::new(Timeframe::Day1)
233    }
234}
235
236impl TimeframeSelector {
237    /// Create an interval selector with `initial` selected and no favorites.
238    pub fn new(initial: Timeframe) -> Self {
239        Self {
240            current: initial,
241            is_open: false,
242            favorites: HashSet::new(),
243            custom_timeframes: Vec::new(),
244            config: TimeframeSelectorConfig::default(),
245            show_custom_input: false,
246            custom_dialog: CustomTimeframeDialog::default(),
247            supported_timeframes: None,
248            feed_timeframes: None,
249            hidden_timeframes: HashSet::new(),
250            last_non_favorite: None,
251        }
252    }
253
254    /// Restrict interval choices to those the connected source can serve, such as the chart
255    /// instrument's [`supported_timeframes`](crate::model::Instrument::supported_timeframes).
256    /// `None` allows every interval; an empty list allows none. Update this when the source or
257    /// symbol changes. Saved favourites and custom intervals are retained while unavailable.
258    pub fn set_supported_timeframes(&mut self, timeframes: Option<Vec<crate::model::Timeframe>>) {
259        self.supported_timeframes = timeframes.map(|values| values.into_iter().collect());
260    }
261
262    /// Restrict interval choices to those the feed serves at all, known before any symbol
263    /// resolves: pass [`Chart::feed_capabilities`](crate::widget::Chart::feed_capabilities).
264    /// An unreported list allows every interval; an empty one allows none. A symbol's
265    /// [`set_supported_timeframes`](Self::set_supported_timeframes) narrows this further.
266    pub fn set_feed_capabilities(&mut self, capabilities: &crate::data::FeedCapabilities) {
267        self.feed_timeframes = capabilities
268            .timeframes
269            .as_ref()
270            .map(|values| values.iter().copied().collect());
271    }
272
273    pub(crate) fn supports(&self, timeframe: Timeframe) -> bool {
274        [&self.supported_timeframes, &self.feed_timeframes]
275            .into_iter()
276            .all(|supported| {
277                supported
278                    .as_ref()
279                    .is_none_or(|supported| supported.contains(&timeframe))
280            })
281    }
282
283    /// Export the interval lists for the host's persisted user settings.
284    pub fn preferences(&self) -> TimeframePreferences {
285        let mut favorites: Vec<_> = self.favorites.iter().copied().collect();
286        favorites.sort_by_key(|tf| (parts(*tf).1 as u8, parts(*tf).0));
287        let mut hidden_timeframes: Vec<_> = self.hidden_timeframes.iter().copied().collect();
288        hidden_timeframes.sort_by_key(|tf| (parts(*tf).1 as u8, parts(*tf).0));
289        TimeframePreferences {
290            favorites,
291            hidden_timeframes,
292            custom_timeframes: self.custom_timeframes.clone(),
293        }
294    }
295
296    /// Restore saved lists, discarding duplicate custom entries.
297    pub fn set_preferences(&mut self, preferences: TimeframePreferences) {
298        self.hidden_timeframes = preferences.hidden_timeframes.into_iter().collect();
299        self.favorites = preferences
300            .favorites
301            .into_iter()
302            .filter(|tf| !self.hidden_timeframes.contains(tf))
303            .collect();
304        self.custom_timeframes.clear();
305        for tf in preferences.custom_timeframes {
306            if !self.custom_timeframes.contains(&tf) && !PRESETS.contains(&tf) {
307                self.custom_timeframes.push(tf);
308            }
309        }
310    }
311
312    /// Use the supplied display and layout options.
313    pub fn with_config(mut self, config: TimeframeSelectorConfig) -> Self {
314        self.config = config;
315        self
316    }
317
318    /// Show favorite interval buttons and the interval menu trigger.
319    pub fn show(&mut self, ui: &mut Ui) -> TimeframeAction {
320        let extra = retain_non_favorite(&mut self.last_non_favorite, self.current, &self.favorites)
321            .filter(|tf| self.supports(*tf));
322        let favorites: Vec<_> = PRESETS
323            .iter()
324            .chain(&self.custom_timeframes)
325            .copied()
326            .filter(|tf| {
327                self.favorites.contains(tf)
328                    && self.supports(*tf)
329                    && !self.hidden_timeframes.contains(tf)
330            })
331            .collect();
332        let (btn_res, clicked) = favorite_strip(
333            ui,
334            self.current,
335            favorites,
336            extra,
337            self.is_open,
338            "Chart interval",
339            |ui, tf| self.interval_button(ui, tf),
340        );
341        let mut selected = TimeframeAction::None;
342        if let Some(tf) = clicked {
343            self.current = tf;
344            selected = TimeframeAction::Select(tf);
345        }
346
347        let theme = ui.active_theme();
348        let id = ui.make_persistent_id("timeframe_menu");
349        let max_height = menu_list_height(ui, btn_res.rect, ROW_H);
350        let action = egui::Popup::menu(&btn_res)
351            .id(id)
352            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
353            .align_alternatives(&[])
354            .frame(menu_frame(ui, &theme))
355            .gap(theme.spacing.xs)
356            .show(|ui| self.menu_ui(ui, &theme, max_height))
357            .map_or(TimeframeAction::None, |menu| menu.inner);
358        self.is_open = egui::Popup::is_id_open(ui.ctx(), id);
359        if action == TimeframeAction::None {
360            selected
361        } else {
362            action
363        }
364    }
365
366    /// A toolbar button showing `tf`'s short label, named by its long form.
367    fn interval_button(&self, ui: &Ui, tf: Timeframe) -> Trigger<'static> {
368        let theme = ui.active_theme();
369        let label = display(tf);
370        let text = ui.painter().layout_no_wrap(
371            l10n::text(ui.ctx(), &label),
372            egui::FontId::proportional(theme.text.title),
373            Color32::PLACEHOLDER,
374        );
375        Trigger {
376            icon: None,
377            name: display_long(tf),
378            label,
379            // Whole points keep the end of the scrolling toolbar reachable.
380            min_width: (text.size().x + 2.0 * theme.spacing.lg)
381                .ceil()
382                .max(self.config.btn_width),
383            filled: false,
384        }
385    }
386
387    /// Width of the widest interval label in the menu.
388    fn label_width(&self, ui: &Ui) -> f32 {
389        let font = egui::FontId::proportional(LABEL_SIZE);
390        let width = |text: String| {
391            ui.painter()
392                .layout_no_wrap(
393                    l10n::text(ui.ctx(), text),
394                    font.clone(),
395                    Color32::PLACEHOLDER,
396                )
397                .size()
398                .x
399        };
400        PRESETS
401            .iter()
402            .chain(&self.custom_timeframes)
403            .filter(|tf| self.supports(**tf) && !self.hidden_timeframes.contains(tf))
404            .map(|tf| width(display_long(*tf)))
405            .fold(0.0, f32::max)
406    }
407
408    fn menu_ui(&mut self, ui: &mut Ui, theme: &Theme, max_height: f32) -> TimeframeAction {
409        let mut action = TimeframeAction::None;
410        // Rows sit edge to edge, and the scroll bar is visible whenever the list overflows.
411        ui.spacing_mut().item_spacing.y = 0.0;
412        crate::styles::menu_scroll_bar(ui);
413        let bar = ui.spacing().scroll.allocated_width();
414
415        // Room for the star; the hide button shows over a long label only on a hovered row.
416        let desc_w = self.label_width(ui);
417        let reserved = desc_w + 2.0 * theme.spacing.lg + STAR_SLOT_W + bar;
418        ui.set_width(
419            reserved
420                .max(self.config.dropdown_width)
421                .max(if self.show_custom_input {
422                    CUSTOM_FORM_W
423                } else {
424                    0.0
425                }),
426        );
427        ui.visuals_mut().selection.bg_fill = theme.ui.text;
428        ui.visuals_mut().selection.stroke.color = theme.ui.panel_bg;
429        // A popup is first measured inside a 400 px high area; this lets the list use the room below.
430        ui.set_max_height(max_height);
431
432        let row = |ui: &mut Ui, tf: Timeframe, favorite: bool, current: Timeframe| {
433            let mut toggle_favorite = false;
434            let mut hide = false;
435            let content = TimeframeRow {
436                tf,
437                favorite,
438                theme,
439                toggle_favorite: &mut toggle_favorite,
440                hide: &mut hide,
441            };
442            let response = ListItem::new()
443                .selected(current == tf)
444                .height(ROW_H)
445                .show(ui, content);
446            if hide {
447                TimeframeAction::Hide(tf)
448            } else if toggle_favorite {
449                TimeframeAction::ToggleFavorite(tf)
450            } else if response.clicked() {
451                TimeframeAction::Select(tf)
452            } else {
453                TimeframeAction::None
454            }
455        };
456
457        let current = self.current;
458        egui::ScrollArea::vertical()
459            .max_height(max_height)
460            .auto_shrink([false, true])
461            .show(ui, |ui| {
462                let custom = ListItem::new().height(ROW_H).show(
463                    ui,
464                    LabelContent::new("Custom interval\u{2026}").with_icon(&embedded_icons::PLUS),
465                );
466                if custom.clicked() {
467                    self.show_custom_input = !self.show_custom_input;
468                }
469                if self.show_custom_input
470                    && let Some(tf) = self.custom_dialog.show(ui)
471                {
472                    if self.supports(tf) {
473                        self.hidden_timeframes.remove(&tf);
474                        if !PRESETS.contains(&tf) && !self.custom_timeframes.contains(&tf) {
475                            self.custom_timeframes.push(tf);
476                        }
477                        action = TimeframeAction::Select(tf);
478                    } else {
479                        self.custom_dialog.error =
480                            Some("Interval unavailable from this source".to_owned());
481                    }
482                }
483                let (rule, _) = ui.allocate_exact_size(
484                    vec2(ui.available_width(), DESIGN_TOKENS.stroke.hairline),
485                    egui::Sense::hover(),
486                );
487                ui.painter().rect_filled(rule, 0.0, theme.ui.border);
488
489                let mut first = true;
490                for (unit, header) in [
491                    (TimeframeUnit::Tick, "Ticks"),
492                    (TimeframeUnit::Volume, "Volume"),
493                    (TimeframeUnit::Range, "Range"),
494                    (TimeframeUnit::Delta, "Delta"),
495                    (TimeframeUnit::Millisecond, "Milliseconds"),
496                    (TimeframeUnit::Second, "Seconds"),
497                    (TimeframeUnit::Minute, "Minutes"),
498                    (TimeframeUnit::Hour, "Hours"),
499                    (TimeframeUnit::Day, "Days"),
500                    (TimeframeUnit::Week, "Weeks"),
501                    (TimeframeUnit::Month, "Months"),
502                ] {
503                    let available: Vec<_> = PRESETS
504                        .iter()
505                        .copied()
506                        .filter(|tf| {
507                            parts(*tf).1 == unit
508                                && self.supports(*tf)
509                                && !self.hidden_timeframes.contains(tf)
510                        })
511                        .collect();
512                    if available.is_empty() {
513                        continue;
514                    }
515                    let separated = !first;
516                    first = false;
517                    if !section_header(ui, theme, header, separated) {
518                        continue;
519                    }
520                    for tf in available {
521                        let picked = row(ui, tf, self.favorites.contains(&tf), current);
522                        if picked != TimeframeAction::None {
523                            action = picked;
524                        }
525                    }
526                }
527                let custom: Vec<_> = self
528                    .custom_timeframes
529                    .iter()
530                    .copied()
531                    .filter(|tf| self.supports(*tf) && !self.hidden_timeframes.contains(tf))
532                    .collect();
533                if !custom.is_empty() && section_header(ui, theme, "Custom", !first) {
534                    for tf in &custom {
535                        let picked = row(ui, *tf, self.favorites.contains(tf), current);
536                        if picked != TimeframeAction::None {
537                            action = picked;
538                        }
539                    }
540                }
541
542                if !self.hidden_timeframes.is_empty()
543                    && ListItem::new()
544                        .height(ROW_H)
545                        .show(ui, LabelContent::new("Restore hidden intervals"))
546                        .clicked()
547                {
548                    action = TimeframeAction::RestoreHidden;
549                }
550            });
551
552        match action {
553            TimeframeAction::Hide(tf) => {
554                self.hidden_timeframes.insert(tf);
555                self.favorites.remove(&tf);
556            }
557            TimeframeAction::RestoreHidden => self.hidden_timeframes.clear(),
558            _ => {}
559        }
560        if let TimeframeAction::ToggleFavorite(tf) = action
561            && !self.favorites.remove(&tf)
562        {
563            self.favorites.insert(tf);
564        }
565        if let TimeframeAction::Select(timeframe) = action {
566            self.current = timeframe;
567            self.show_custom_input = false;
568            ui.close();
569        }
570        action
571    }
572}
573
574/// The frame of a toolbar menu, whose row tiles sit 6 px inside its outline.
575pub(super) fn menu_frame(ui: &Ui, theme: &Theme) -> egui::Frame {
576    egui::Frame::popup(ui.style())
577        .fill(theme.ui.panel_bg)
578        .inner_margin(egui::Margin::symmetric(
579            (theme.spacing.md - DESIGN_TOKENS.stroke.hairline) as i8,
580            theme.spacing.sm as i8,
581        ))
582}
583
584/// Height of a menu list that ends on a whole row above the window's bottom edge, so the last
585/// row is never cut. At least three rows.
586pub(super) fn menu_list_height(ui: &Ui, trigger: Rect, row_h: f32) -> f32 {
587    let theme = ui.active_theme();
588    let available = ui.ctx().content_rect().bottom()
589        - trigger.bottom()
590        - theme.spacing.xs
591        - theme.spacing.lg
592        - ui.spacing().menu_margin.sum().y;
593    (available / row_h).floor().max(3.0) * row_h
594}
595
596/// Draws the focus ring inside a menu row, which the scroll area would clip outside it, and keeps
597/// a row focused with the arrow keys in view.
598pub(super) fn menu_row_focus(ui: &Ui, response: &egui::Response) {
599    if response.has_focus() {
600        ui.painter().rect_stroke(
601            response.rect,
602            ui.visuals().menu_corner_radius,
603            egui::Stroke::new(DESIGN_TOKENS.stroke.thick, focus_ring_color(ui)),
604            egui::StrokeKind::Inside,
605        );
606    }
607    if response.gained_focus() {
608        response.scroll_to_me(None);
609    }
610}
611
612/// Remembers `current` as the latest chosen choice that is not a favourite, and returns that
613/// choice unless it has been starred since.
614pub(super) fn retain_non_favorite<T: Copy + Eq + Hash>(
615    last: &mut Option<T>,
616    current: T,
617    favorites: &HashSet<T>,
618) -> Option<T> {
619    if !favorites.contains(&current) {
620        *last = Some(current);
621    }
622    last.filter(|choice| !favorites.contains(choice))
623}
624
625/// The toolbar buttons of a control with a menu of choices and starred favourites. Without
626/// `favorites` (the starred choices in menu order) it is one button for `current` that opens the
627/// menu. Otherwise there is a button for each favourite, then one for `extra`, then a chevron
628/// named `menu_name` that opens the menu, and `current`'s button is pressed. Returns the menu's
629/// anchor and the choice whose button was clicked.
630pub(super) fn favorite_strip<T: Copy + PartialEq>(
631    ui: &mut Ui,
632    current: T,
633    favorites: Vec<T>,
634    extra: Option<T>,
635    open: bool,
636    menu_name: &str,
637    button: impl Fn(&Ui, T) -> Trigger<'static>,
638) -> (egui::Response, Option<T>) {
639    if favorites.is_empty() {
640        return (button(ui, current).show(ui, open), None);
641    }
642    let mut clicked = None;
643    for choice in favorites.into_iter().chain(extra) {
644        if button(ui, choice).show(ui, choice == current).clicked() {
645            clicked = Some(choice);
646        }
647    }
648    let menu = Trigger {
649        icon: Some(&embedded_icons::CHEVRON_DOWN),
650        name: menu_name.to_owned(),
651        label: String::new(),
652        min_width: 0.0,
653        filled: false,
654    }
655    .show(ui, open);
656    (menu, clicked)
657}
658
659/// Width of the star's slot at the end of a menu row.
660pub(super) const STAR_SLOT_W: f32 = 28.0;
661
662/// The star at the end of a menu row, which adds the row's choice to the favourites or removes
663/// it. With `on_hover` it is drawn only on a favourite, a hovered row, or while a key has focus
664/// in the row. Returns whether it was clicked.
665pub(super) fn favorite_star(
666    ui: &Ui,
667    context: &ContentContext<'_>,
668    favorite: bool,
669    on_hover: bool,
670    color: Color32,
671) -> bool {
672    let rect = context.rect;
673    let star_rect = Rect::from_center_size(
674        pos2(rect.right() - icon_sizes::SM / 2.0, rect.center().y),
675        vec2(STAR_SLOT_W, rect.height()),
676    );
677    let star = ui.interact(
678        star_rect,
679        context.response.id.with("favorite"),
680        egui::Sense::click(),
681    );
682    let tooltip = if favorite {
683        "Remove from favorites"
684    } else {
685        "Add to favorites"
686    };
687    let translated_label = l10n::text(ui.ctx(), tooltip);
688    star.widget_info(|| {
689        WidgetInfo::selected(Role::Button, ui.is_enabled(), favorite, &translated_label)
690    });
691    let shown = !on_hover
692        || favorite
693        || ui.rect_contains_pointer(context.response.rect)
694        || context.response.has_focus()
695        || star.has_focus();
696    if shown {
697        let icon = if favorite {
698            &embedded_icons::STAR_FILLED
699        } else {
700            &embedded_icons::STAR_EMPTY
701        };
702        icon.as_image_tinted(vec2(icon_sizes::SM, icon_sizes::SM), color)
703            .paint_at(
704                ui,
705                Rect::from_center_size(star_rect.center(), vec2(icon_sizes::SM, icon_sizes::SM)),
706            );
707    }
708    menu_row_focus(ui, &star);
709    let clicked = star.clicked();
710    star.on_hover_text(translated_label);
711    clicked
712}
713
714/// A section title on the menu's row grid, with its text on the bottom of the row and a chevron
715/// at its end that folds the section for the session. With `separated`, a rule sets it apart
716/// from the section above. Returns whether the section is unfolded.
717fn section_header(ui: &mut Ui, theme: &Theme, text: &str, separated: bool) -> bool {
718    if separated {
719        ui.add_space(12.0);
720        ui.separator();
721    }
722    let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), egui::Sense::hover());
723    // Named by its text, so the fold holds while the custom form above opens and closes.
724    let response = ui.interact(rect, ui.make_persistent_id(text), egui::Sense::click());
725    let folded_id = response.id.with("folded");
726    let mut folded = ui.data(|d| d.get_temp::<bool>(folded_id).unwrap_or(false));
727    if response.clicked() {
728        folded = !folded;
729        ui.data_mut(|d| d.insert_temp(folded_id, folded));
730    }
731    let translated_label = l10n::text(ui.ctx(), text);
732    response.widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
733    ui.ctx()
734        .accesskit_node_builder(response.id, |node| node.set_expanded(!folded));
735    let inner = rect.shrink2(vec2(theme.spacing.lg, theme.spacing.sm));
736    // A child Ui, so that the row keeps its full height in the parent's layout.
737    let mut title = ui.new_child(
738        egui::UiBuilder::new()
739            .max_rect(inner)
740            .layout(egui::Layout::bottom_up(egui::Align::Min)),
741    );
742    let label = title.add(
743        egui::Label::new(
744            RichText::new(l10n::text(ui.ctx(), text.to_uppercase()))
745                .size(theme.text.small)
746                .color(theme.ui.text_muted),
747        )
748        .selectable(false)
749        .sense(egui::Sense::hover()),
750    );
751    // An 8x5 chevron, pointing up while the section is open.
752    let tip = if folded { 2.5 } else { -2.5 };
753    let center = pos2(inner.right() - 4.0, label.rect.center().y);
754    ui.painter().line(
755        vec![
756            center + vec2(-4.0, -tip),
757            center + vec2(0.0, tip),
758            center + vec2(4.0, -tip),
759        ],
760        egui::Stroke::new(DESIGN_TOKENS.stroke.medium, theme.ui.text_muted),
761    );
762    menu_row_focus(ui, &response);
763    !folded
764}
765
766/// One interval row with hide and favourite buttons, shown while the row is hovered or focused.
767struct TimeframeRow<'a> {
768    tf: Timeframe,
769    favorite: bool,
770    theme: &'a Theme,
771    toggle_favorite: &'a mut bool,
772    hide: &'a mut bool,
773}
774
775impl ListItemContent for TimeframeRow<'_> {
776    fn ui(self, ui: &mut Ui, context: &ContentContext<'_>) -> egui::Response {
777        let theme = self.theme;
778        let rect = context.rect;
779        let text = if context.selected {
780            theme.ui.panel_bg
781        } else {
782            theme.ui.text
783        };
784        let translated_label = l10n::text(ui.ctx(), display_long(self.tf));
785        context.response.widget_info(|| {
786            WidgetInfo::selected(
787                Role::MenuItemRadio,
788                context.response.enabled(),
789                context.selected,
790                &translated_label,
791            )
792        });
793
794        let hide_rect = Rect::from_center_size(
795            pos2(rect.right() - icon_sizes::SM / 2.0 - 28.0, rect.center().y),
796            vec2(28.0, ROW_H),
797        );
798        let hide = ui.interact(
799            hide_rect,
800            context.response.id.with("hide"),
801            egui::Sense::click(),
802        );
803        let label = l10n::text(ui.ctx(), "Hide interval");
804        hide.widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &label));
805        *self.hide = hide.clicked();
806        // Like the star, shown on the hovered row or while a key has focus in it.
807        let shown = ui.rect_contains_pointer(context.response.rect)
808            || context.response.has_focus()
809            || hide.has_focus()
810            || ui.memory(|m| m.has_focus(context.response.id.with("favorite")));
811        if shown {
812            embedded_icons::CLOSE
813                .as_image_tinted(vec2(icon_sizes::SM, icon_sizes::SM), text)
814                .paint_at(
815                    ui,
816                    Rect::from_center_size(
817                        hide_rect.center(),
818                        vec2(icon_sizes::SM, icon_sizes::SM),
819                    ),
820                );
821        }
822        menu_row_focus(ui, &hide);
823        hide.on_hover_text(label);
824
825        *self.toggle_favorite = favorite_star(ui, context, self.favorite, true, text);
826        // A long label ends before the buttons it would run under.
827        let label_rect = if shown {
828            rect.with_max_x(hide_rect.left())
829        } else {
830            rect
831        };
832        ui.painter().with_clip_rect(label_rect).text(
833            pos2(rect.min.x, rect.center().y),
834            egui::Align2::LEFT_CENTER,
835            l10n::text(ui.ctx(), display_long(self.tf)),
836            egui::FontId::proportional(LABEL_SIZE),
837            text,
838        );
839        menu_row_focus(ui, context.response);
840        context.response.clone()
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::model::Timeframe as Model;
848
849    #[test]
850    fn interval_buttons_retain_last_non_favorite_without_duplicates() {
851        for theme in [Theme::light(), Theme::dark()] {
852            let ctx = egui::Context::default();
853            egui_extras::install_image_loaders(&ctx);
854            crate::theme::apply_to_egui(&ctx, &theme);
855            ctx.enable_accesskit();
856            let mut selector = TimeframeSelector::new(Model::Min1);
857            assert!(selector.preferences().favorites.is_empty());
858            let frame = |selector: &mut TimeframeSelector, events| {
859                let mut action = TimeframeAction::None;
860                let output = ctx.run_ui(
861                    egui::RawInput {
862                        screen_rect: Some(Rect::from_min_size(
863                            egui::Pos2::ZERO,
864                            vec2(1440.0, 2400.0),
865                        )),
866                        events,
867                        ..Default::default()
868                    },
869                    |ui| {
870                        ui.horizontal(|ui| action = selector.show(ui));
871                    },
872                );
873                let labels: Vec<_> = output
874                    .shapes
875                    .iter()
876                    .filter_map(|shape| match &shape.shape {
877                        egui::Shape::Text(text) if !text.galley.text().is_empty() => Some((
878                            text.galley.text().to_owned(),
879                            text.pos + text.galley.size() / 2.0,
880                        )),
881                        _ => None,
882                    })
883                    .collect();
884                let buttons: Vec<_> = output
885                    .platform_output
886                    .accesskit_update
887                    .iter()
888                    .flat_map(|update| &update.nodes)
889                    .filter_map(|(_, node)| {
890                        let bounds = node.bounds()?;
891                        Some((
892                            node.label()?.to_owned(),
893                            Rect::from_min_max(
894                                pos2(bounds.x0 as f32, bounds.y0 as f32),
895                                pos2(bounds.x1 as f32, bounds.y1 as f32),
896                            ),
897                        ))
898                    })
899                    .collect();
900                let active = ctx.global_style().visuals.widgets.active.bg_fill;
901                let pressed: Vec<_> = labels
902                    .iter()
903                    .filter(|(_, pos)| {
904                        output.shapes.iter().any(|shape| match &shape.shape {
905                            egui::Shape::Rect(rect) => {
906                                rect.rect.contains(*pos) && rect.fill == active
907                            }
908                            _ => false,
909                        })
910                    })
911                    .map(|(label, _)| label.clone())
912                    .collect();
913                output.drop_without_applying_deltas();
914                (action, labels, buttons, pressed)
915            };
916            let click = |selector: &mut TimeframeSelector, pos| {
917                frame(selector, vec![egui::Event::PointerMoved(pos)]);
918                let event = |pressed| egui::Event::PointerButton {
919                    pos,
920                    button: egui::PointerButton::Primary,
921                    pressed,
922                    modifiers: Default::default(),
923                };
924                frame(selector, vec![event(true)]);
925                frame(selector, vec![event(false)]).0
926            };
927            let strip = |selector: &mut TimeframeSelector, expected: &[&str], selected: &str| {
928                frame(selector, vec![]);
929                let (_, labels, _, pressed) = frame(selector, vec![egui::Event::PointerGone]);
930                assert_eq!(
931                    labels
932                        .iter()
933                        .map(|(text, _)| text.as_str())
934                        .collect::<Vec<_>>(),
935                    expected
936                );
937                assert_eq!(
938                    pressed,
939                    if selected.is_empty() {
940                        vec![]
941                    } else {
942                        vec![selected.to_owned()]
943                    }
944                );
945            };
946            strip(&mut selector, &["1m"], "");
947            for tf in [Model::Min5, Model::Hour1, Model::Day1] {
948                let (_, _, buttons, _) = frame(&mut selector, vec![]);
949                let trigger = buttons
950                    .iter()
951                    .find(|(name, _)| name == "Chart interval")
952                    .or_else(|| buttons.iter().find(|(name, _)| name == "1 minute"))
953                    .unwrap()
954                    .1;
955                click(&mut selector, trigger.center());
956                frame(&mut selector, vec![]);
957                let (_, _, buttons, _) = frame(&mut selector, vec![]);
958                let row = buttons
959                    .iter()
960                    .find(|(name, _)| *name == display_long(tf))
961                    .unwrap_or_else(|| panic!("{tf:?}: {buttons:?}"))
962                    .1;
963                assert_eq!(
964                    click(
965                        &mut selector,
966                        pos2(row.right() - icon_sizes::SM / 2.0, row.center().y)
967                    ),
968                    TimeframeAction::ToggleFavorite(tf)
969                );
970                assert!(selector.preferences().favorites.contains(&tf));
971                // The menu stays open after starring; close it before checking the strip.
972                click(&mut selector, pos2(1000.0, 2200.0));
973            }
974            strip(&mut selector, &["5m", "1h", "D", "1m"], "1m");
975            let (_, labels, _, _) = frame(&mut selector, vec![]);
976            let favorite = labels.iter().find(|(name, _)| name == "5m").unwrap().1;
977            assert_eq!(
978                click(&mut selector, favorite),
979                TimeframeAction::Select(Model::Min5)
980            );
981            strip(&mut selector, &["5m", "1h", "D", "1m"], "5m");
982            let (_, _, buttons, _) = frame(&mut selector, vec![]);
983            let trigger = buttons
984                .iter()
985                .find(|(name, _)| name == "Chart interval")
986                .unwrap()
987                .1;
988            click(&mut selector, trigger.center());
989            frame(&mut selector, vec![]);
990            let (_, labels, _, _) = frame(&mut selector, vec![]);
991            let quarter = labels
992                .iter()
993                .find(|(name, _)| name == "3 months")
994                .unwrap()
995                .1;
996            assert_eq!(
997                click(&mut selector, quarter),
998                TimeframeAction::Select("3month".parse().unwrap())
999            );
1000            strip(&mut selector, &["5m", "1h", "D", "3M"], "3M");
1001            let saved = selector.preferences();
1002            let mut restored = TimeframeSelector::new(Model::Min5);
1003            restored.set_preferences(saved.clone());
1004            strip(&mut restored, &["5m", "1h", "D"], "5m");
1005            assert_eq!(restored.preferences(), saved);
1006            restored.current = Model::Week1;
1007            strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1008            restored.favorites.insert(Model::Week1);
1009            strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1010            restored.favorites.remove(&Model::Week1);
1011            strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1012            let custom = [
1013                interval(7, TimeframeUnit::Minute),
1014                interval(8, TimeframeUnit::Minute),
1015            ];
1016            restored.custom_timeframes.extend(custom);
1017            restored.favorites.extend(custom);
1018            strip(&mut restored, &["5m", "1h", "D", "7m", "8m", "W"], "W");
1019            restored.set_supported_timeframes(Some(vec![Model::Min5, Model::Week1]));
1020            strip(&mut restored, &["5m", "W"], "W");
1021        }
1022    }
1023
1024    #[test]
1025    fn custom_intervals_and_stars_survive_saved_preferences() {
1026        for theme in [Theme::light(), Theme::dark()] {
1027            for width in [320.0, 400.0] {
1028                let ctx = egui::Context::default();
1029                crate::theme::apply_to_egui(&ctx, &theme);
1030                let custom = interval(7, TimeframeUnit::Minute);
1031                let mut selector = TimeframeSelector {
1032                    show_custom_input: true,
1033                    custom_dialog: CustomTimeframeDialog {
1034                        value_input: "7".to_owned(),
1035                        ..Default::default()
1036                    },
1037                    ..Default::default()
1038                };
1039                let frame = |selector: &mut TimeframeSelector, events| {
1040                    let mut action = TimeframeAction::None;
1041                    let mut right = 0.0;
1042                    let mut output = ctx.run_ui(
1043                        egui::RawInput {
1044                            screen_rect: Some(Rect::from_min_size(
1045                                egui::Pos2::ZERO,
1046                                vec2(width, 2600.0),
1047                            )),
1048                            events,
1049                            ..Default::default()
1050                        },
1051                        |ui| {
1052                            action = selector.menu_ui(ui, &theme, 2500.0);
1053                            right = ui.min_rect().right();
1054                        },
1055                    );
1056                    assert!(right <= width, "{right} exceeds {width}");
1057                    let labels: Vec<_> = output
1058                        .shapes
1059                        .iter()
1060                        .filter_map(|shape| match &shape.shape {
1061                            egui::Shape::Text(text) => Some((
1062                                text.galley.text().to_owned(),
1063                                text.pos + text.galley.size() / 2.0,
1064                            )),
1065                            _ => None,
1066                        })
1067                        .collect();
1068                    output.textures_delta.clear();
1069                    output.drop_without_applying_deltas();
1070                    (action, labels, right)
1071                };
1072                let click = |selector: &mut TimeframeSelector, pos| {
1073                    frame(selector, vec![egui::Event::PointerMoved(pos)]);
1074                    let event = |pressed| egui::Event::PointerButton {
1075                        pos,
1076                        button: egui::PointerButton::Primary,
1077                        pressed,
1078                        modifiers: Default::default(),
1079                    };
1080                    frame(selector, vec![event(true)]);
1081                    frame(selector, vec![event(false)]).0
1082                };
1083                frame(&mut selector, vec![]);
1084                // A section header folds its section and unfolds it again.
1085                for unfolded in [false, true] {
1086                    let (_, labels, _) = frame(&mut selector, vec![]);
1087                    let header = labels.iter().find(|(label, _)| label == "TICKS").unwrap().1;
1088                    assert_eq!(click(&mut selector, header), TimeframeAction::None);
1089                    let (_, labels, _) = frame(&mut selector, vec![]);
1090                    assert_eq!(labels.iter().any(|(label, _)| label == "1 tick"), unfolded);
1091                }
1092                let (_, labels, _) = frame(&mut selector, vec![]);
1093                let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1094                assert_eq!(click(&mut selector, add), TimeframeAction::Select(custom));
1095                assert_eq!(selector.custom_timeframes, vec![custom]);
1096                // Adding the same custom interval again must not duplicate its row.
1097                selector.show_custom_input = true;
1098                frame(&mut selector, vec![]);
1099                let (_, labels, _) = frame(&mut selector, vec![]);
1100                let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1101                click(&mut selector, add);
1102                assert_eq!(selector.custom_timeframes, vec![custom]);
1103                let selected = selector.current;
1104                for favorite in [true, false, true] {
1105                    let (_, labels, right) = frame(&mut selector, vec![]);
1106                    let y = labels
1107                        .iter()
1108                        .find(|(label, _)| *label == display_long(custom))
1109                        .unwrap()
1110                        .1
1111                        .y;
1112                    let action = click(
1113                        &mut selector,
1114                        pos2(right - theme.spacing.lg - icon_sizes::SM / 2.0, y),
1115                    );
1116                    assert_eq!(action, TimeframeAction::ToggleFavorite(custom));
1117                    assert_eq!(selector.favorites.contains(&custom), favorite);
1118                    assert_eq!(selector.current, selected);
1119                }
1120                // Hiding a favorite unpins it, retains the selected interval and persists with host settings.
1121                for tf in [Timeframe::Min1, custom] {
1122                    let (_, labels, right) = frame(&mut selector, vec![]);
1123                    let y = labels
1124                        .iter()
1125                        .find(|(label, _)| *label == display_long(tf))
1126                        .unwrap()
1127                        .1
1128                        .y;
1129                    assert_eq!(
1130                        click(
1131                            &mut selector,
1132                            pos2(right - theme.spacing.lg - icon_sizes::SM / 2.0 - 28.0, y)
1133                        ),
1134                        TimeframeAction::Hide(tf)
1135                    );
1136                    let (_, labels, _) = frame(&mut selector, vec![]);
1137                    assert!(!labels.iter().any(|(label, _)| *label == display_long(tf)));
1138                    assert_eq!(selector.current, selected);
1139                    assert!(!selector.favorites.contains(&tf));
1140                }
1141                let saved = serde_json::to_string(&selector.preferences()).unwrap();
1142                let mut restored = TimeframeSelector::default();
1143                restored.set_preferences(serde_json::from_str(&saved).unwrap());
1144                assert_eq!(restored.preferences(), selector.preferences());
1145                restored.set_supported_timeframes(Some(vec![]));
1146                frame(&mut restored, vec![]);
1147                assert_eq!(restored.preferences(), selector.preferences());
1148                restored.set_supported_timeframes(None);
1149                let legacy: TimeframePreferences =
1150                    serde_json::from_str(r#"{"favorites":[],"custom_timeframes":[]}"#).unwrap();
1151                assert!(legacy.hidden_timeframes.is_empty());
1152                let (_, labels, _) = frame(&mut restored, vec![]);
1153                assert!(!labels.iter().any(|(label, _)| label == "1 minute"));
1154                let restore = labels
1155                    .iter()
1156                    .find(|(label, _)| label == "Restore hidden intervals")
1157                    .unwrap()
1158                    .1;
1159                click(&mut restored, restore);
1160                let (_, labels, _) = frame(&mut restored, vec![]);
1161                assert!(labels.iter().any(|(label, _)| label == "1 minute"));
1162                assert!(
1163                    labels
1164                        .iter()
1165                        .any(|(label, _)| *label == display_long(custom))
1166                );
1167                assert!(!restored.favorites.contains(&Timeframe::Min1));
1168                let saved = restored.preferences();
1169                restored.set_supported_timeframes(Some(vec![Model::Min1]));
1170                restored.show_custom_input = true;
1171                restored.custom_dialog.value_input = "8".to_owned();
1172                let (_, labels, _) = frame(&mut restored, vec![]);
1173                assert!(
1174                    !labels
1175                        .iter()
1176                        .any(|(label, _)| *label == display_long(custom))
1177                );
1178                let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1179                assert_eq!(click(&mut restored, add), TimeframeAction::None);
1180                assert!(restored.custom_dialog.error.is_some());
1181                assert_eq!(restored.preferences(), saved);
1182                restored.custom_dialog.unit = TimeframeUnit::Millisecond;
1183                restored.custom_dialog.value_input = "1".to_owned();
1184                assert_eq!(click(&mut restored, add), TimeframeAction::None);
1185                assert!(restored.custom_dialog.error.is_some());
1186                assert_eq!(restored.preferences(), saved);
1187                restored.set_supported_timeframes(None);
1188                let (_, labels, _) = frame(&mut restored, vec![]);
1189                assert!(
1190                    labels
1191                        .iter()
1192                        .any(|(label, _)| *label == display_long(custom))
1193                );
1194            }
1195        }
1196    }
1197
1198    /// The feed's intervals and the symbol's both apply, and an empty list differs from none.
1199    #[test]
1200    fn feed_and_symbol_intervals_both_restrict_choices() {
1201        use crate::data::FeedCapabilities;
1202        let feed = |timeframes: Option<Vec<Model>>| {
1203            let mut capabilities = FeedCapabilities::new();
1204            capabilities.timeframes = timeframes;
1205            capabilities
1206        };
1207        for (feed_list, symbol_list, min1, day1) in [
1208            (None, None, true, true),
1209            (Some(vec![]), None, false, false),
1210            (Some(vec![Model::Min1]), None, true, false),
1211            (
1212                Some(vec![Model::Min1, Model::Day1]),
1213                Some(vec![Model::Day1]),
1214                false,
1215                true,
1216            ),
1217        ] {
1218            let mut selector = TimeframeSelector::default();
1219            selector.set_feed_capabilities(&feed(feed_list));
1220            selector.set_supported_timeframes(symbol_list);
1221            assert_eq!(selector.supports(Model::Min1), min1);
1222            assert_eq!(selector.supports(Model::Day1), day1);
1223        }
1224    }
1225
1226    #[test]
1227    fn interval_pick_updates_the_trigger_and_selection() {
1228        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1229            for (selected, short, long) in [
1230                (
1231                    Model::Ticks(std::num::NonZeroU64::new(100).unwrap()),
1232                    "100T",
1233                    "100 ticks",
1234                ),
1235                (Model::Ms100, "100ms", "100 milliseconds"),
1236                (Model::Ms250, "250ms", "250 milliseconds"),
1237                (Model::Ms500, "500ms", "500 milliseconds"),
1238                (Model::Sec2, "2S", "2 seconds"),
1239                ("45s".parse().unwrap(), "45S", "45 seconds"),
1240                (Model::Min5, "5m", "5 minutes"),
1241                (Model::Hour1, "1h", "1 hour"),
1242                (Model::Day1, "D", "1 day"),
1243                (Model::Week1, "W", "1 week"),
1244                (Model::Month1, "M", "1 month"),
1245                ("2D".parse().unwrap(), "2D", "2 days"),
1246                ("2W".parse().unwrap(), "2W", "2 weeks"),
1247                ("3month".parse().unwrap(), "3M", "3 months"),
1248                ("6month".parse().unwrap(), "6M", "6 months"),
1249                ("12month".parse().unwrap(), "12M", "12 months"),
1250                (
1251                    Model::Custom(std::num::NonZeroU64::MAX),
1252                    "18446744073709551615S",
1253                    "18446744073709551615 seconds",
1254                ),
1255            ] {
1256                assert_eq!(display(selected), short);
1257                assert_eq!(display_long(selected), long);
1258                for supported in [None, Some(vec![selected]), Some(vec![])] {
1259                    let ctx = egui::Context::default();
1260                    crate::theme::apply_to_egui(&ctx, &theme);
1261                    let mut selector = TimeframeSelector::new(Model::Min1);
1262                    if !PRESETS.contains(&selected) {
1263                        selector.custom_timeframes.push(selected);
1264                    }
1265                    let available = supported.as_ref().is_none_or(|values| !values.is_empty());
1266                    selector.set_supported_timeframes(supported.clone());
1267                    let mut frame = |events| {
1268                        let mut action = TimeframeAction::None;
1269                        let mut output = ctx.run_ui(
1270                            egui::RawInput {
1271                                screen_rect: Some(Rect::from_min_size(
1272                                    egui::Pos2::ZERO,
1273                                    vec2(320.0, 2400.0),
1274                                )),
1275                                events,
1276                                ..Default::default()
1277                            },
1278                            |ui| {
1279                                action = selector.menu_ui(ui, &theme, 2400.0);
1280                                assert!(
1281                                    ui.min_rect().right() <= 320.0,
1282                                    "{selected:?}: {:?}",
1283                                    ui.min_rect()
1284                                );
1285                            },
1286                        );
1287                        output.textures_delta.clear();
1288                        if supported.is_some() {
1289                            let labels: Vec<_> = output
1290                                .shapes
1291                                .iter()
1292                                .filter_map(|shape| match &shape.shape {
1293                                    egui::Shape::Text(text) => Some(text.galley.text()),
1294                                    _ => None,
1295                                })
1296                                .collect();
1297                            for &tf in PRESETS {
1298                                assert_eq!(
1299                                    labels.contains(&display_long(tf).as_str()),
1300                                    available && tf == selected,
1301                                    "{tf:?}, {supported:?}"
1302                                );
1303                            }
1304                        }
1305                        let pos = output.shapes.iter().find_map(|shape| match &shape.shape {
1306                            egui::Shape::Text(text)
1307                                if text.galley.text() == display_long(selected) =>
1308                            {
1309                                Some(text.pos + text.galley.size() / 2.0)
1310                            }
1311                            _ => None,
1312                        });
1313                        output.textures_delta.clear();
1314                        output.drop_without_applying_deltas();
1315                        (pos, action)
1316                    };
1317                    let (pos, _) = frame(vec![]);
1318                    let Some(pos) = pos else {
1319                        assert!(!available, "{selected:?}, {supported:?}");
1320                        continue;
1321                    };
1322                    frame(vec![egui::Event::PointerMoved(pos)]);
1323                    let click = |pressed| egui::Event::PointerButton {
1324                        pos,
1325                        button: egui::PointerButton::Primary,
1326                        pressed,
1327                        modifiers: Default::default(),
1328                    };
1329                    frame(vec![click(true)]);
1330                    let (_, action) = frame(vec![click(false)]);
1331                    assert!(
1332                        matches!(action, TimeframeAction::Select(timeframe) if timeframe == selected)
1333                    );
1334                    assert_eq!(selector.current, selected);
1335                }
1336            }
1337        }
1338    }
1339}