1use 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
20pub struct TimeframeToolbar {
22 pub state: TimeframeToolbarState,
24 pub config: TimeframeToolbarConfig,
26 config_theme: Option<(Theme, TimeframeToolbarConfig)>,
28 hidden: HashSet<Control>,
30}
31
32#[derive(Debug, Clone)]
34pub struct TimeframeToolbarConfig {
35 pub background_color: Color32,
37 pub border_color: Color32,
39 pub text_color: Color32,
41 pub hover_text_color: Color32,
43 pub active_text_color: Color32,
45 pub separator_color: Color32,
47 pub hover_bg: Color32,
49 pub active_bg: Color32,
51 pub font_size: f32,
53 pub panel_height: f32,
55 pub btn_padding_h: f32,
57 pub btn_padding_v: f32,
59}
60
61impl TimeframeToolbarConfig {
62 pub fn from_theme(theme: &Theme) -> Self {
64 let ui = &theme.ui;
65 Self {
66 background_color: Color32::TRANSPARENT, 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 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 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 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 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 pub fn update_time(&mut self) {
139 self.state.update_time();
140 }
141
142 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 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 ResponsiveToolbar::horizontal("timeframe_toolbar_scroll")
162 .show(ui, |ui, _ctx| self.show_contents(ui))
163 }
164
165 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 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 ui.space_lg();
238 self.separator(ui);
239 ui.space_sm();
240
241 if self.icon_button_calendar(ui, "Go to").clicked() {
243 result_action = TimeframeToolbarAction::OpenDatePicker;
244 }
245
246 let remaining = ui.available_width() - reserved;
249 if remaining > 0.0 {
250 ui.add_space(remaining);
251 }
252
253 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 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 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 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 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 if bg_color != Color32::TRANSPARENT {
343 ui.painter()
344 .rect_filled(rect, ui.active_theme().radius.md, bg_color);
345 }
346
347 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 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 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 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 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 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 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 let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
458
459 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 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 pub fn ui(&mut self, ui: &mut Ui) -> Response {
497 let _ = self.show(ui);
498 ui.response()
499 }
500
501 pub fn state(&self) -> &TimeframeToolbarState {
503 &self.state
504 }
505
506 pub fn state_mut(&mut self) -> &mut TimeframeToolbarState {
508 &mut self.state
509 }
510
511 pub fn set_date_range(&mut self, range: DateRange) {
513 self.state.sel_range = range;
514 }
515
516 pub fn set_timezone(&mut self, timezone: String) {
518 self.state.timezone = timezone;
519 }
520
521 pub fn set_session(&mut self, session: SessionType) {
523 self.state.session_type = session;
524 }
525
526 pub fn toggle_dividends(&mut self) {
528 self.state.adjust_for_dividends = !self.state.adjust_for_dividends;
529 }
530
531 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 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}