Skip to main content

egui_charts/ui/timeframe_toolbar/
toolbar.rs

1//! Bottom panel UI rendering
2//!
3//! Bottom toolbar with date range selection, timezone display,
4//! session controls, and dividend adjustment.
5//! Narrow bars keep date ranges in a menu beside the clock and session controls.
6
7use super::{DateRange, SessionType, TimeframeToolbarAction, TimeframeToolbarState};
8use crate::icons::icons as embedded_icons;
9use crate::localization as l10n;
10use crate::styles::{icons as icon_sizes, stroke};
11use crate::theme::ThemeExt;
12use crate::theme::{Theme, follow_active_theme, theme_derived};
13use crate::tokens::DESIGN_TOKENS;
14use crate::ui::Control;
15use crate::ui_kit::ext::UiExt;
16use crate::ui_kit::toolbar::ResponsiveToolbar;
17use egui::{Color32, FontId, Response, Sense, Stroke, Ui, Vec2};
18use std::collections::HashSet;
19
20/// Bottom panel component
21pub struct TimeframeToolbar {
22    /// State
23    pub state: TimeframeToolbarState,
24    /// Configuration
25    pub config: TimeframeToolbarConfig,
26    /// Theme and config `config` was last derived from; `None` when the caller supplied it.
27    config_theme: Option<(Theme, TimeframeToolbarConfig)>,
28    /// Controls the host hid.
29    hidden: HashSet<Control>,
30}
31
32/// Configuration for the bottom panel appearance
33#[derive(Debug, Clone)]
34pub struct TimeframeToolbarConfig {
35    /// Background color
36    pub background_color: Color32,
37    /// Border color (top border)
38    pub border_color: Color32,
39    /// Text color (inactive)
40    pub text_color: Color32,
41    /// Text color (hover)
42    pub hover_text_color: Color32,
43    /// Text color (active/selected)
44    pub active_text_color: Color32,
45    /// Separator color
46    pub separator_color: Color32,
47    /// Button hover background
48    pub hover_bg: Color32,
49    /// Button active background
50    pub active_bg: Color32,
51    /// Font size
52    pub font_size: f32,
53    /// Panel height
54    pub panel_height: f32,
55    /// Button padding horizontal
56    pub btn_padding_h: f32,
57    /// Button padding vertical
58    pub btn_padding_v: f32,
59}
60
61impl TimeframeToolbarConfig {
62    /// Create config from `theme`
63    pub fn from_theme(theme: &Theme) -> Self {
64        let ui = &theme.ui;
65        Self {
66            background_color: Color32::TRANSPARENT, // Let parent Frame show through
67            border_color: ui.border,
68            text_color: ui.text,
69            hover_text_color: ui.text,
70            active_text_color: ui.accent,
71            separator_color: crate::ui::top_toolbar::buttons::rule_color(theme),
72            hover_bg: ui.btn_bg_hover,
73            active_bg: ui.btn_bg_active,
74            font_size: theme.text.bottom_bar,
75            panel_height: DESIGN_TOKENS.sizing.toolbar.bottom_height,
76            btn_padding_h: theme.spacing.md,
77            btn_padding_v: theme.spacing.sm,
78        }
79    }
80}
81
82impl Default for TimeframeToolbarConfig {
83    fn default() -> Self {
84        // Default uses light UI chrome theme
85        Self::from_theme(&Theme::dark())
86    }
87}
88
89theme_derived!(TimeframeToolbarConfig {
90    background_color,
91    border_color,
92    text_color,
93    hover_text_color,
94    active_text_color,
95    separator_color,
96    hover_bg,
97    active_bg,
98    font_size,
99    panel_height,
100    btn_padding_h,
101    btn_padding_v
102});
103
104impl Default for TimeframeToolbar {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl TimeframeToolbar {
111    /// Create new bottom panel
112    pub fn new() -> Self {
113        Self {
114            state: TimeframeToolbarState::default(),
115            config: TimeframeToolbarConfig::default(),
116            config_theme: Some((Theme::dark(), TimeframeToolbarConfig::default())),
117            hidden: HashSet::new(),
118        }
119    }
120
121    /// Create with custom config
122    pub fn with_config(config: TimeframeToolbarConfig) -> Self {
123        Self {
124            state: TimeframeToolbarState::default(),
125            config,
126            config_theme: None,
127            hidden: HashSet::new(),
128        }
129    }
130
131    /// Shows or hides `control`: TimeZone, Session and DividendAdjustment. The toolbar
132    /// ignores the other controls.
133    pub fn set_control_visible(&mut self, control: Control, visible: bool) {
134        crate::ui::app_state::set_control_visible(&mut self.hidden, control, visible);
135    }
136
137    /// Update the current time display
138    pub fn update_time(&mut self) {
139        self.state.update_time();
140    }
141
142    /// Show bottom panel and return action
143    pub fn show(&mut self, ui: &mut Ui) -> TimeframeToolbarAction {
144        follow_active_theme(
145            ui.ctx(),
146            &mut self.config_theme,
147            &mut self.config,
148            TimeframeToolbarConfig::from_theme,
149        );
150        self.state.update_time();
151        if !self.hidden.contains(&Control::TimeZone) {
152            // Use the same instant for the label and the next whole-second deadline.
153            let nanos = self.state.curr_time.unwrap().timestamp_subsec_nanos();
154            ui.ctx()
155                .request_repaint_after(std::time::Duration::from_nanos(
156                    1_000_000_000 - u64::from(nanos),
157                ));
158        }
159
160        // Background and border are handled by parent Frame in platform.rs
161        ResponsiveToolbar::horizontal("timeframe_toolbar_scroll")
162            .show(ui, |ui, _ctx| self.show_contents(ui))
163    }
164
165    /// Internal: render toolbar contents
166    fn show_contents(&mut self, ui: &mut Ui) -> TimeframeToolbarAction {
167        let mut result_action = TimeframeToolbarAction::None;
168
169        ui.horizontal_centered(|ui| {
170            ui.spacing_mut().item_spacing.x = 0.0;
171            ui.space_lg();
172
173            // Left section: Date range btns
174            let ranges = DateRange::default_presets();
175            let ranges_width: f32 = ranges
176                .iter()
177                .map(|range| self.range_btn_width(ui, range.label()))
178                .sum();
179            let mut time_text = self.state.formatted_time();
180            let mut reserved = ui.active_theme().spacing.lg;
181            if !self.hidden.contains(&Control::TimeZone) {
182                reserved += self.text_btn_width(ui, &time_text) + ui.active_theme().spacing.lg;
183            }
184            if !self.hidden.contains(&Control::Session) {
185                reserved += self.text_btn_width(ui, self.state.session_type.label());
186            }
187            if !self.hidden.contains(&Control::DividendAdjustment) {
188                reserved += 2.0 * ui.active_theme().spacing.sm
189                    + stroke::HAIRLINE
190                    + self.text_btn_width(ui, "ADJ");
191            }
192            let compact =
193                self.range_btn_width(ui, "Ranges") + reserved + 48.0 > ui.available_width();
194            if compact && !self.hidden.contains(&Control::TimeZone) {
195                reserved -= self.text_btn_width(ui, &time_text);
196                time_text.truncate(8);
197                reserved += self.text_btn_width(ui, &time_text);
198            }
199            if ranges_width + reserved + 48.0 > ui.available_width() {
200                let response = ui.add(
201                    egui::Button::new(
202                        egui::RichText::new(l10n::text(
203                            ui.ctx(),
204                            if compact { "…" } else { "Ranges" },
205                        ))
206                        .size(self.config.font_size),
207                    )
208                    .frame(false),
209                );
210                let response = response.on_hover_text(l10n::text(ui.ctx(), "Date ranges"));
211                egui::Popup::menu(&response).show(|ui| {
212                    for range in ranges {
213                        if ui
214                            .button(l10n::text(ui.ctx(), range.label()))
215                            .on_hover_text(l10n::text(ui.ctx(), range.tooltip()))
216                            .clicked()
217                        {
218                            self.state.set_date_range(range);
219                            result_action = TimeframeToolbarAction::DateRangeChanged(range);
220                            ui.close();
221                        }
222                    }
223                });
224            } else {
225                for range in ranges {
226                    if self
227                        .date_range_btn(ui, range.label(), range.tooltip())
228                        .clicked()
229                    {
230                        self.state.set_date_range(range);
231                        result_action = TimeframeToolbarAction::DateRangeChanged(range);
232                    }
233                }
234            }
235
236            // Separator
237            ui.space_lg();
238            self.separator(ui);
239            ui.space_sm();
240
241            // Go to date button with calendar icon
242            if self.icon_button_calendar(ui, "Go to").clicked() {
243                result_action = TimeframeToolbarAction::OpenDatePicker;
244            }
245
246            // Flexible spacer - push right section to the right. Hidden controls give back
247            // their share of the reservation, so the rest stays flush right.
248            let remaining = ui.available_width() - reserved;
249            if remaining > 0.0 {
250                ui.add_space(remaining);
251            }
252
253            // Right section: Timezone, Session, Dividends
254            // Timezone button with time
255            if !self.hidden.contains(&Control::TimeZone) {
256                if self
257                    .text_btn(
258                        ui,
259                        &time_text,
260                        &l10n::format_text(
261                            ui.ctx(),
262                            "Timezone: {zone}",
263                            &[("zone", self.state.timezone.as_str())],
264                        ),
265                        false,
266                    )
267                    .clicked()
268                {
269                    result_action = TimeframeToolbarAction::OpenTimezoneMenu;
270                }
271                ui.space_lg();
272            }
273
274            // RTH/Session button
275            if !self.hidden.contains(&Control::Session) {
276                let session_label = self.state.session_type.label();
277                let session_tooltip = self.state.session_type.tooltip();
278                if self
279                    .text_btn(
280                        ui,
281                        session_label,
282                        session_tooltip,
283                        self.state.session_type == SessionType::RTH,
284                    )
285                    .clicked()
286                {
287                    result_action = TimeframeToolbarAction::OpenSessionMenu;
288                }
289            }
290
291            // ADJ button, after a separator
292            if !self.hidden.contains(&Control::DividendAdjustment) {
293                ui.space_sm();
294                self.separator(ui);
295                ui.space_sm();
296
297                let adj_active = self.state.adjust_for_dividends;
298                if self
299                    .text_btn(
300                        ui,
301                        "ADJ",
302                        if adj_active {
303                            "Dividend adjustment: on"
304                        } else {
305                            "Dividend adjustment: off"
306                        },
307                        adj_active,
308                    )
309                    .clicked()
310                {
311                    self.state.adjust_for_dividends = !adj_active;
312                    result_action = TimeframeToolbarAction::AdjustDividendsToggled(
313                        self.state.adjust_for_dividends,
314                    );
315                }
316            }
317
318            ui.space_lg();
319        });
320
321        result_action
322    }
323
324    /// Render a date range button (compact style)
325    fn date_range_btn(&self, ui: &mut Ui, text: &str, tooltip: &str) -> Response {
326        let font_id = FontId::proportional(self.config.font_size);
327
328        let btn_width = self.range_btn_width(ui, text);
329        let btn_height = self.config.panel_height - ui.active_theme().spacing.sm;
330
331        let (rect, response) =
332            ui.allocate_exact_size(Vec2::new(btn_width, btn_height), Sense::click());
333
334        // Colors based on state
335        let (bg_color, text_color) = if response.hovered() {
336            (self.config.hover_bg, self.config.hover_text_color)
337        } else {
338            (Color32::TRANSPARENT, self.config.text_color)
339        };
340
341        // Draw background on hover/active
342        if bg_color != Color32::TRANSPARENT {
343            ui.painter()
344                .rect_filled(rect, ui.active_theme().radius.md, bg_color);
345        }
346
347        // Draw text
348        ui.painter().text(
349            rect.center(),
350            egui::Align2::CENTER_CENTER,
351            l10n::text(ui.ctx(), text),
352            font_id,
353            text_color,
354        );
355
356        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.md);
357        let translated_label = l10n::text(ui.ctx(), tooltip);
358        response.widget_info(|| {
359            egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &translated_label)
360        });
361        response.on_hover_text(l10n::text(ui.ctx(), tooltip))
362    }
363
364    fn range_btn_width(&self, ui: &Ui, text: &str) -> f32 {
365        let galley = ui.painter().layout_no_wrap(
366            l10n::text(ui.ctx(), text),
367            FontId::proportional(self.config.font_size),
368            self.config.text_color,
369        );
370        galley.size().x + self.config.btn_padding_h * 2.0
371    }
372
373    /// Width of a [`text_btn`](Self::text_btn) labelled `text`.
374    fn text_btn_width(&self, ui: &Ui, text: &str) -> f32 {
375        self.range_btn_width(ui, text)
376            .max(DESIGN_TOKENS.sizing.button_md)
377    }
378
379    fn text_btn(&self, ui: &mut Ui, text: &str, tooltip: &str, is_active: bool) -> Response {
380        let font_id = FontId::proportional(self.config.font_size);
381        let btn_height = self.config.panel_height - ui.active_theme().spacing.sm;
382
383        let (rect, response) = ui.allocate_exact_size(
384            Vec2::new(self.text_btn_width(ui, text), btn_height),
385            Sense::click(),
386        );
387
388        // Colors based on state
389        let (bg_color, text_color) = if is_active {
390            (self.config.active_bg, self.config.active_text_color)
391        } else if response.hovered() {
392            (self.config.hover_bg, self.config.hover_text_color)
393        } else {
394            (Color32::TRANSPARENT, self.config.text_color)
395        };
396
397        // Draw background
398        if bg_color != Color32::TRANSPARENT {
399            ui.painter()
400                .rect_filled(rect, ui.active_theme().radius.md, bg_color);
401        }
402
403        if is_active {
404            ui.painter().hline(
405                rect.x_range(),
406                rect.bottom() - 1.0,
407                Stroke::new(2.0, self.config.active_text_color),
408            );
409        }
410
411        // Draw text
412        ui.painter().text(
413            rect.center(),
414            egui::Align2::CENTER_CENTER,
415            l10n::text(ui.ctx(), text),
416            font_id,
417            text_color,
418        );
419
420        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.md);
421        let translated_label = l10n::text(ui.ctx(), tooltip);
422        response.widget_info(|| {
423            egui::WidgetInfo::selected(
424                egui::Role::Button,
425                ui.is_enabled(),
426                is_active,
427                &translated_label,
428            )
429        });
430        response.on_hover_text(l10n::text(ui.ctx(), tooltip))
431    }
432
433    /// Render the calendar icon button (Go to date)
434    /// Uses the GoToDate SVG icon from the app's icon system
435    fn icon_button_calendar(&self, ui: &mut Ui, tooltip: &str) -> Response {
436        let icon_size = icon_sizes::LG;
437        let btn_size = Vec2::new(
438            DESIGN_TOKENS.sizing.button_md,
439            self.config.panel_height - ui.active_theme().spacing.sm,
440        );
441        let (rect, response) = ui.allocate_exact_size(btn_size, Sense::click());
442
443        // Draw hover background
444        if self.state.date_picker_open || response.hovered() {
445            ui.painter().rect_filled(
446                rect,
447                ui.active_theme().radius.md,
448                if self.state.date_picker_open {
449                    self.config.active_bg
450                } else {
451                    self.config.hover_bg
452                },
453            );
454        }
455
456        // Center the icon in the button
457        let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
458
459        // Render the icon with theme-aware coloring
460        let icon_color = if self.state.date_picker_open {
461            self.config.active_text_color
462        } else if response.hovered() {
463            ui.visuals().widgets.hovered.fg_stroke.color
464        } else {
465            ui.visuals().widgets.noninteractive.fg_stroke.color
466        };
467        embedded_icons::GO_TO_DATE
468            .as_image_tinted(Vec2::splat(icon_size), icon_color)
469            .paint_at(ui, icon_rect);
470
471        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.md);
472        let translated_label = l10n::text(ui.ctx(), tooltip);
473        response.widget_info(|| {
474            egui::WidgetInfo::selected(
475                egui::Role::Button,
476                ui.is_enabled(),
477                self.state.date_picker_open,
478                &translated_label,
479            )
480        });
481        response.on_hover_text(l10n::text(ui.ctx(), tooltip))
482    }
483
484    /// Render a vertical separator
485    fn separator(&self, ui: &mut Ui) {
486        let height = 22.0;
487        let (rect, _) = ui.allocate_exact_size(Vec2::new(stroke::HAIRLINE, height), Sense::hover());
488        ui.painter().vline(
489            rect.center().x,
490            rect.y_range(),
491            Stroke::new(stroke::HAIRLINE, self.config.separator_color),
492        );
493    }
494
495    /// Show bottom panel (without action)
496    pub fn ui(&mut self, ui: &mut Ui) -> Response {
497        let _ = self.show(ui);
498        ui.response()
499    }
500
501    /// Get the current state
502    pub fn state(&self) -> &TimeframeToolbarState {
503        &self.state
504    }
505
506    /// Get mutable state
507    pub fn state_mut(&mut self) -> &mut TimeframeToolbarState {
508        &mut self.state
509    }
510
511    /// Set the selected date range
512    pub fn set_date_range(&mut self, range: DateRange) {
513        self.state.sel_range = range;
514    }
515
516    /// Set the timezone
517    pub fn set_timezone(&mut self, timezone: String) {
518        self.state.timezone = timezone;
519    }
520
521    /// Set the session type
522    pub fn set_session(&mut self, session: SessionType) {
523        self.state.session_type = session;
524    }
525
526    /// Toggle dividend adjustment
527    pub fn toggle_dividends(&mut self) {
528        self.state.adjust_for_dividends = !self.state.adjust_for_dividends;
529    }
530
531    /// Sync from application state.
532    pub fn sync_from_app_state(&mut self, app_state: &dyn crate::ui::app_state::ChartAppState) {
533        self.state.set_timeframe(*app_state.active_timeframe());
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn visible_clock_schedules_the_next_whole_second() {
543        use chrono::Timelike;
544        use std::time::Duration;
545        for visible in [true, false] {
546            let ctx = egui::Context::default();
547            let mut toolbar = TimeframeToolbar::new();
548            toolbar.set_control_visible(Control::TimeZone, visible);
549            for frame in 0..5 {
550                let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
551                    toolbar.show(ui);
552                });
553                output.textures_delta.clear();
554                if frame == 4 {
555                    let delay = output.viewport_output[&egui::ViewportId::ROOT].repaint_delay;
556                    let now = toolbar.state.curr_time.unwrap();
557                    let boundary = now.with_nanosecond(0).unwrap() + chrono::Duration::seconds(1);
558                    let expected = (boundary - now).to_std().unwrap();
559                    assert_eq!(delay, if visible { expected } else { Duration::MAX });
560                }
561                output.drop_without_applying_deltas();
562            }
563        }
564    }
565
566    #[test]
567    fn active_modes_and_open_calendar_use_the_accent() {
568        for theme in [Theme::light(), Theme::dark()] {
569            for active in [false, true] {
570                let ctx = egui::Context::default();
571                crate::theme::apply_to_egui(&ctx, &theme);
572                let mut toolbar =
573                    TimeframeToolbar::with_config(TimeframeToolbarConfig::from_theme(&theme));
574                toolbar.state.date_picker_open = active;
575                let mut calendar = egui::Rect::NOTHING;
576                let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
577                    toolbar.text_btn(ui, "ADJ", "Dividend adjustment", active);
578                    calendar = toolbar.icon_button_calendar(ui, "Go to").rect;
579                });
580                output.textures_delta.clear();
581                assert_eq!(output.shapes.iter().any(|shape| matches!(&shape.shape,
582                    egui::Shape::Rect(rect) if rect.rect == calendar && rect.fill == toolbar.config.active_bg)), active);
583                let label = output
584                    .shapes
585                    .iter()
586                    .find_map(|shape| match &shape.shape {
587                        egui::Shape::Text(text) if text.galley.text() == "ADJ" => Some(text),
588                        _ => None,
589                    })
590                    .unwrap();
591                assert_eq!(
592                    label.galley.job.sections[0].format.color,
593                    if active {
594                        theme.ui.accent
595                    } else {
596                        theme.ui.text
597                    }
598                );
599                assert_eq!(output.shapes.iter().any(|shape| matches!(&shape.shape,
600                    egui::Shape::LineSegment { stroke, .. } if stroke.width == 2.0 && stroke.color == theme.ui.accent)), active);
601                output.drop_without_applying_deltas();
602            }
603        }
604    }
605
606    #[test]
607    fn right_controls_stay_at_the_edge_when_text_sizes_or_visibility_change() {
608        for (theme, width, font_size, timezone) in [
609            (Theme::light(), 900.0, 12.0, "UTC"),
610            (Theme::dark(), 900.0, 14.0, "UTC"),
611            (Theme::light(), 600.0, 14.0, "America/New_York"),
612            (Theme::dark(), 640.0, 14.0, "America/New_York"),
613            (Theme::light(), 320.0, 14.0, "UTC"),
614            (Theme::dark(), 320.0, 14.0, "America/New_York"),
615            (Theme::light(), 320.0, 14.0, "+23:59:59"),
616            (Theme::dark(), 320.0, 14.0, "-23:59:59"),
617        ] {
618            for hidden in [
619                vec![],
620                vec![Control::TimeZone],
621                vec![Control::DividendAdjustment],
622                vec![Control::Session, Control::DividendAdjustment],
623            ] {
624                let ctx = egui::Context::default();
625                crate::theme::apply_to_egui(&ctx, &theme);
626                let mut toolbar =
627                    TimeframeToolbar::with_config(TimeframeToolbarConfig::from_theme(&theme));
628                toolbar.set_timezone(timezone.to_owned());
629                toolbar.config.font_size = font_size;
630                toolbar.hidden.extend(hidden);
631                let last = if !toolbar.hidden.contains(&Control::DividendAdjustment) {
632                    "ADJ".to_owned()
633                } else if !toolbar.hidden.contains(&Control::Session) {
634                    toolbar.state.session_type.label().to_owned()
635                } else {
636                    toolbar.state.formatted_time()
637                };
638                // The clock also has a trailing gap when it is the last control.
639                let padding = crate::theme::Theme::default().spacing.lg
640                    * if toolbar.hidden.contains(&Control::Session)
641                        && toolbar.hidden.contains(&Control::DividendAdjustment)
642                    {
643                        2.0
644                    } else {
645                        1.0
646                    };
647                let mut expected = 0.0;
648                let output = ctx.run_ui(
649                    egui::RawInput {
650                        screen_rect: Some(egui::Rect::from_min_size(
651                            egui::Pos2::ZERO,
652                            egui::vec2(width, 40.0),
653                        )),
654                        ..Default::default()
655                    },
656                    |ui| {
657                        expected = ui.available_rect_before_wrap().max.x - padding;
658                        toolbar.show_contents(ui);
659                    },
660                );
661                let text = output
662                    .shapes
663                    .iter()
664                    .find_map(|shape| match &shape.shape {
665                        egui::Shape::Text(text)
666                            if text.galley.text() == last
667                                || last.starts_with(&format!("{} ", text.galley.text())) =>
668                        {
669                            Some(text)
670                        }
671                        _ => None,
672                    })
673                    .expect("the last visible control must be drawn");
674                expected -= (text.galley.size().x + 2.0 * toolbar.config.btn_padding_h)
675                    .max(DESIGN_TOKENS.sizing.button_md)
676                    / 2.0;
677                let center = text.pos.x + text.galley.size().x / 2.0;
678                output.drop_without_applying_deltas();
679                assert!(
680                    (center - expected).abs() < 1.0,
681                    "{font_size}, {:?}: {center} != {expected}",
682                    toolbar.hidden
683                );
684            }
685        }
686    }
687
688    #[test]
689    fn ranges_have_no_resting_fill_and_can_be_requested_again() {
690        for theme in [Theme::light(), Theme::dark()] {
691            let ctx = egui::Context::default();
692            crate::theme::apply_to_egui(&ctx, &theme);
693            let mut toolbar =
694                TimeframeToolbar::with_config(TimeframeToolbarConfig::from_theme(&theme));
695            let mut pos = egui::Pos2::ZERO;
696            let mut requested = false;
697            for pressed in [None, Some(true), Some(false)] {
698                let output = ctx.run_ui(
699                    egui::RawInput {
700                        screen_rect: Some(egui::Rect::from_min_size(
701                            egui::Pos2::ZERO,
702                            egui::vec2(900.0, 40.0),
703                        )),
704                        events: pressed
705                            .map(|pressed| {
706                                vec![
707                                    egui::Event::PointerMoved(pos),
708                                    egui::Event::PointerButton {
709                                        pos,
710                                        button: egui::PointerButton::Primary,
711                                        pressed,
712                                        modifiers: Default::default(),
713                                    },
714                                ]
715                            })
716                            .unwrap_or_default(),
717                        ..Default::default()
718                    },
719                    |ui| {
720                        requested |= toolbar.show_contents(ui)
721                            == TimeframeToolbarAction::DateRangeChanged(DateRange::Month1);
722                    },
723                );
724                let text = output
725                    .shapes
726                    .iter()
727                    .find_map(|s| match &s.shape {
728                        egui::Shape::Text(t) if t.galley.text() == "1M" => Some(t),
729                        _ => None,
730                    })
731                    .expect("month range is visible");
732                pos = text.pos + text.galley.size() / 2.0;
733                let filled = output.shapes.iter().any(|s| matches!(&s.shape,
734                    egui::Shape::Rect(r) if r.rect.contains(pos) && r.fill != Color32::TRANSPARENT
735                ));
736                output.drop_without_applying_deltas();
737                if pressed.is_none() {
738                    assert!(
739                        !filled,
740                        "a remembered range must not claim the current viewport"
741                    );
742                }
743            }
744            assert!(
745                requested,
746                "a range can be requested after the view was panned"
747            );
748        }
749    }
750}