1use crate::icons::{Icon, icons as embedded_icons};
9use crate::localization as l10n;
10use crate::model::Timeframe;
11use crate::theme::ThemeExt;
12use crate::ui_kit::buttons::IconButton;
13use crate::ui_kit::list_item::{LabelContent, ListItem};
14use egui::{Role, Ui, WidgetInfo};
15
16use super::{
17 actions::TopToolbarAction,
18 buttons::{IconTextButton, PillButton, TextButton, TextIconButton, ToolbarIconButton, Trigger},
19 components::{
20 chart_grid_selector::ChartGridSelector,
21 chart_type_selector::{ChartTypeAction, ChartTypeSelector},
22 layout_menu::{LayoutAction, LayoutMenu},
23 timeframe_selector::{TimeframeAction, TimeframeSelector},
24 },
25 config::TopToolbarConfig,
26 state::TopToolbarState,
27};
28use crate::model::ChartType;
29use crate::tokens::DESIGN_TOKENS;
30use crate::ui::Control;
31use crate::ui::dialogs::keyboard_shortcuts::shortcut_key_labels;
32use std::collections::HashSet;
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36enum Slot {
37 Symbol,
38 Compare,
39 Interval,
40 ChartType,
41 Indicators,
42 Templates,
43 Alert,
44 Replay,
45 Undo,
46 Redo,
47 Theme,
48 Layouts,
49 LayoutName,
50 Save,
51 QuickSearch,
52 Settings,
53 Fullscreen,
54 Screenshot,
55 Publish,
56 Trade,
57}
58
59use Slot as S;
60
61pub(super) const DOWNLOAD_ICON: Icon =
63 Icon::new("top-toolbar-download.svg", include_bytes!("download.svg"));
64const OPEN_NEW_ICON: Icon = Icon::new("top-toolbar-open-new.svg", include_bytes!("open-new.svg"));
66const LINK_ICON: Icon = Icon::new("top-toolbar-link.svg", include_bytes!("link.svg"));
68
69const LEFT: &[&[Slot]] = &[
71 &[S::Symbol, S::Compare],
72 &[S::Interval],
73 &[S::ChartType],
74 &[S::Indicators, S::Templates],
75 &[S::Alert, S::Replay],
76 &[S::Undo, S::Redo],
77];
78
79const RIGHT: &[&[Slot]] = &[
81 &[S::Layouts, S::LayoutName, S::Save],
82 &[
83 S::QuickSearch,
84 S::Settings,
85 S::Fullscreen,
86 S::Screenshot,
87 S::Publish,
88 S::Trade,
89 ],
90];
91
92fn gap(ui: &Ui) -> f32 {
94 ui.active_theme().spacing.sm
95}
96
97fn separator(ui: &Ui) -> f32 {
99 2.0 * gap(ui) + DESIGN_TOKENS.sizing.toolbar.separator_width
100}
101
102fn show_separator(ui: &mut Ui) {
104 ui.add_space(gap(ui));
105 let (rect, _) = ui.allocate_exact_size(egui::vec2(1.0, 22.0), egui::Sense::hover());
106 let color = super::buttons::rule_color(&ui.active_theme());
107 ui.painter().rect_filled(rect, 0.0, color);
108 ui.add_space(gap(ui));
109}
110
111fn snapshot_shortcut_hint(ctx: &egui::Context, font: &egui::FontId) -> Option<String> {
113 let shortcut = ctx
114 .data(|data| {
115 data.get_temp::<crate::widget::ShortcutMap>(egui::Id::NULL.with("chart_shortcuts"))
116 })
117 .unwrap_or_default()
118 .shortcut(crate::config::KeyboardAction::Snapshot)?;
119 let keys: Vec<_> = shortcut_key_labels(ctx, font, &shortcut)
120 .iter()
121 .map(|key| l10n::text(ctx, key))
122 .collect();
123 Some(keys.join(" + "))
124}
125
126impl Slot {
127 fn entry(self) -> Option<(&'static Icon, &'static str, TopToolbarAction)> {
129 use TopToolbarAction as A;
130 Some(match self {
131 S::Symbol => (
132 &embedded_icons::QUICK_SEARCH,
133 "Symbol search",
134 A::OpenSymbolSearch,
135 ),
136 S::Compare => (
137 &embedded_icons::COMPARE_ADD,
138 "Compare symbols",
139 A::OpenCompare,
140 ),
141 S::Indicators => (&embedded_icons::INDICATORS, "Indicators", A::OpenIndicators),
142 S::Templates => (&embedded_icons::LAYOUT_GRID, "Indicator templates", A::None),
143 S::Alert => (&embedded_icons::BELL, "Alert", A::CreateAlert),
144 S::Replay => (&embedded_icons::REPLAY, "Replay", A::ToggleReplay),
145 S::Undo => (&embedded_icons::UNDO, "Undo", A::Undo),
146 S::Redo => (&embedded_icons::REDO, "Redo", A::Redo),
147 S::Theme => (
148 &embedded_icons::THEME_TOGGLE,
149 "Toggle Theme",
150 A::ThemeToggle,
151 ),
152 S::Save => (&embedded_icons::DATABASE, "Save chart", A::Save),
153 S::QuickSearch => (
154 &embedded_icons::COMMAND_SEARCH,
155 "Quick Search",
156 A::OpenQuickSearch,
157 ),
158 S::Settings => (&embedded_icons::SETTINGS_NUT, "Settings", A::OpenSettings),
159 S::Fullscreen => (
160 &embedded_icons::FULLSCREEN,
161 "Fullscreen",
162 A::ToggleFullscreen,
163 ),
164 S::Screenshot => (&embedded_icons::CAMERA, "Snapshot", A::TakeScreenshot),
165 S::Publish => (&embedded_icons::PUBLISH, "Publish", A::Publish),
166 S::Interval | S::ChartType | S::Layouts | S::LayoutName | S::Trade => return None,
167 })
168 }
169
170 fn host_control(self) -> Control {
172 match self {
173 S::Compare => Control::Compare,
174 S::Templates => Control::Templates,
175 S::Alert => Control::Alerts,
176 S::Replay => Control::Replay,
177 S::Save | S::Layouts | S::LayoutName => Control::Layouts,
178 S::Symbol => Control::SymbolSearch,
179 S::Interval => Control::Interval,
180 S::ChartType => Control::ChartType,
181 S::Indicators => Control::Indicators,
182 S::Undo => Control::Undo,
183 S::Redo => Control::Redo,
184 S::Theme => Control::Theme,
185 S::QuickSearch => Control::QuickSearch,
186 S::Settings => Control::Settings,
187 S::Fullscreen => Control::Fullscreen,
188 S::Screenshot => Control::Snapshot,
189 S::Publish => Control::Publish,
190 S::Trade => Control::Trading,
191 }
192 }
193}
194
195#[derive(Clone, Copy, PartialEq, Default)]
197enum TemplatesPage {
198 #[default]
199 Indicators,
200 Templates,
201}
202
203impl TemplatesPage {
204 fn label(self) -> &'static str {
205 match self {
206 Self::Indicators => "Indicators",
207 Self::Templates => "Templates",
208 }
209 }
210}
211
212pub struct TopToolbar {
214 pub config: TopToolbarConfig,
216 pub state: TopToolbarState,
218 pub timeframe_selector: TimeframeSelector,
220 pub chart_type_selector: ChartTypeSelector,
222 pub chart_grid_selector: ChartGridSelector,
224 pub layout_menu: LayoutMenu,
226 host_hidden: HashSet<Control>,
228 pub indicator_templates: Vec<(String, String)>,
230 pub favorite_indicators: Vec<(String, String)>,
234 templates_page: TemplatesPage,
235 pub host_entries: Vec<super::ToolbarEntry>,
237 pub call_to_action: Option<(String, String)>,
240}
241
242impl Default for TopToolbar {
243 fn default() -> Self {
244 Self {
245 config: TopToolbarConfig::default(),
246 state: TopToolbarState::default(),
247 timeframe_selector: TimeframeSelector::new(Timeframe::Day1),
248 chart_type_selector: ChartTypeSelector::new(ChartType::Candles),
249 layout_menu: LayoutMenu::new(),
250 chart_grid_selector: ChartGridSelector::default(),
251 host_hidden: HashSet::new(),
252 indicator_templates: Vec::new(),
253 favorite_indicators: Vec::new(),
254 templates_page: TemplatesPage::default(),
255 host_entries: Vec::new(),
256 call_to_action: None,
257 }
258 }
259}
260
261impl TopToolbar {
262 pub fn new() -> Self {
264 Self::default()
265 }
266
267 pub fn with_config(mut self, config: TopToolbarConfig) -> Self {
269 self.config = config;
270 self
271 }
272
273 pub fn set_control_visible(&mut self, control: Control, visible: bool) {
277 crate::ui::app_state::set_control_visible(&mut self.host_hidden, control, visible);
278 }
279
280 fn is_shown(&self, slot: Slot) -> bool {
282 !self.host_hidden.contains(&slot.host_control())
283 && (slot != S::Trade || self.state.can_trade)
284 && (slot != S::Publish || self.state.can_publish_image)
285 }
286
287 pub fn show_with_state(
289 &mut self,
290 ui: &mut Ui,
291 app_state: &dyn crate::ui::app_state::ChartAppState,
292 ) -> TopToolbarAction {
293 let mut menu_action = TopToolbarAction::None;
294 let menu_visible = [S::QuickSearch, S::Theme].map(|slot| self.is_shown(slot));
295 let action = self.show_with_slots(
296 ui,
297 app_state,
298 |ui| Self::show_menu(ui, &mut menu_action, menu_visible),
299 |_| {},
300 );
301 if menu_action != TopToolbarAction::None {
302 menu_action
303 } else {
304 action
305 }
306 }
307
308 pub fn show_inline(
310 &mut self,
311 ui: &mut Ui,
312 app_state: &dyn crate::ui::app_state::ChartAppState,
313 ) -> TopToolbarAction {
314 self.show_with_state(ui, app_state)
315 }
316
317 pub fn show_with_slots(
325 &mut self,
326 ui: &mut Ui,
327 app_state: &dyn crate::ui::app_state::ChartAppState,
328 menu: impl FnOnce(&mut Ui),
329 actions: impl FnOnce(&mut Ui),
330 ) -> TopToolbarAction {
331 ui.horizontal_top(|ui| {
332 if !self.host_hidden.contains(&Control::Menu) {
333 let spacing = std::mem::take(&mut ui.spacing_mut().item_spacing.x);
336 ui.allocate_ui_with_layout(
337 egui::vec2(48.0, self.config.height),
338 egui::Layout::centered_and_justified(egui::Direction::LeftToRight),
339 menu,
340 );
341 ui.spacing_mut().item_spacing.x = spacing;
342 }
343 let width = ui.available_width();
344 egui::ScrollArea::horizontal()
345 .id_salt("top_toolbar")
346 .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
347 .scroll_source(egui::scroll_area::ScrollSource::ALL)
348 .auto_shrink([false, true])
349 .show(ui, |ui| {
350 ui.set_width(width);
351 let row = ui.horizontal(|ui| {
352 ui.set_height(self.config.height);
353 ui.spacing_mut().item_spacing.x = 0.0;
354 let width_id = ui.scope_id().with("host_actions_width");
355 let actions_width = ui.data(|d| d.get_temp::<f32>(width_id)).unwrap_or(0.0);
356 let action = ui
357 .allocate_ui_with_layout(
358 egui::vec2(
359 (ui.available_width() - actions_width).max(0.0),
360 self.config.height,
361 ),
362 egui::Layout::left_to_right(egui::Align::Center),
363 |ui| self.show_contents_desktop(ui, app_state.active_symbol()),
364 )
365 .inner;
366 ui.add_space((ui.available_width() - actions_width).max(0.0));
367 let mut host_action = TopToolbarAction::None;
368 let width = ui
369 .allocate_ui_with_layout(
370 egui::vec2(ui.available_width(), self.config.height),
371 egui::Layout::left_to_right(egui::Align::Center),
372 |ui| {
373 for entry in &self.host_entries {
374 entry.show(ui, &mut host_action);
375 }
376 actions(ui);
377 if let Some((id, label)) = &self.call_to_action {
378 let response = PillButton {
379 label,
380 tooltip: None,
381 filled: true,
382 }
383 .show(ui);
384 if response.clicked() {
385 host_action = TopToolbarAction::Host(id.clone());
386 }
387 ui.add_space(gap(ui));
388 }
389 },
390 )
391 .response
392 .rect
393 .width();
394 if (width - actions_width).abs() > 0.5 {
395 ui.data_mut(|d| d.insert_temp(width_id, width));
396 ui.ctx().request_discard("Host actions changed width");
397 }
398 if host_action != TopToolbarAction::None {
399 host_action
400 } else {
401 action
402 }
403 });
404 if let Some(response) = ui
405 .ctx()
406 .memory(|m| m.focused())
407 .and_then(|id| ui.ctx().read_response(id))
408 && response.gained_focus()
409 && row.response.rect.contains_rect(response.rect)
410 {
411 response.scroll_to_me(None);
412 }
413 row.inner
414 })
415 .inner
416 })
417 .inner
418 }
419
420 fn show_menu(ui: &mut Ui, action: &mut TopToolbarAction, visible: [bool; 2]) {
421 if !visible.into_iter().any(|shown| shown) {
422 return;
423 }
424 let response = IconButton::new(&super::buttons::MENU_ICON)
425 .icon_scale(24.0 / 28.0)
426 .tooltip("Menu")
427 .show(ui);
428 egui::Popup::menu(&response).show(|ui| {
429 ui.set_width(DESIGN_TOKENS.sizing.dialog.submenu_width);
430 for (control, visible) in [S::QuickSearch, S::Theme].into_iter().zip(visible) {
431 if !visible {
432 continue;
433 }
434 if let Some((icon, label, clicked_action)) = control.entry()
435 && ListItem::new().show_with_icon(ui, label, icon).clicked()
436 {
437 *action = clicked_action;
438 ui.close();
439 }
440 }
441 });
442 }
443
444 fn show_contents_desktop(&mut self, ui: &mut Ui, symbol: &str) -> TopToolbarAction {
446 let mut action = TopToolbarAction::None;
447 ui.spacing_mut().item_spacing.x = 0.0;
449 let font = egui::FontId::proportional(ui.active_theme().text.title);
451 ui.style_mut()
452 .text_styles
453 .insert(egui::TextStyle::Button, font);
454
455 ui.add_space(ui.active_theme().spacing.lg);
456 self.show_groups(ui, LEFT, symbol, &mut action);
457 let right = self.groups_width(ui, RIGHT) + ui.active_theme().spacing.lg;
458 ui.add_space((ui.available_width() - right).max(separator(ui)));
459 self.show_groups(ui, RIGHT, symbol, &mut action);
460 ui.add_space(ui.active_theme().spacing.lg);
461 action
462 }
463
464 fn groups_width(&self, ui: &Ui, groups: &[&[Slot]]) -> f32 {
466 let mut width = 0.0;
467 let mut shown = 0_usize;
468 for group in groups {
469 let mut count = 0;
470 for &control in group.iter().filter(|&&c| self.is_shown(c)) {
471 width += match control {
472 S::LayoutName => self.layout_menu.width(ui),
473 S::Save => {
474 Self::save_button(&self.config).width(ui)
475 + Self::save_menu_button(&self.config).width(ui)
476 }
477 S::Trade => Self::trade_button().width(ui),
478 _ => self.config.btn_size,
479 };
480 count += 1;
481 }
482 if count > 0 {
483 width += gap(ui) * (count - 1) as f32;
484 shown += 1;
485 }
486 }
487 width + separator(ui) * shown.saturating_sub(1) as f32
488 }
489
490 fn show_groups(
492 &mut self,
493 ui: &mut Ui,
494 groups: &[&[Slot]],
495 symbol: &str,
496 action: &mut TopToolbarAction,
497 ) {
498 let mut first_group = true;
499 for group in groups {
500 let visible: Vec<Slot> = group
501 .iter()
502 .copied()
503 .filter(|&c| self.is_shown(c))
504 .collect();
505 if visible.is_empty() {
506 continue;
507 }
508 if !first_group {
509 show_separator(ui);
510 }
511 first_group = false;
512 for (i, control) in visible.into_iter().enumerate() {
513 if i > 0 {
514 ui.add_space(gap(ui));
515 }
516 self.show_control(ui, control, symbol, action);
517 }
518 }
519 }
520
521 fn show_control(
522 &mut self,
523 ui: &mut Ui,
524 control: Slot,
525 symbol: &str,
526 action: &mut TopToolbarAction,
527 ) {
528 match control {
529 S::Layouts => {
530 if let Some(mode) = self.chart_grid_selector.show(ui) {
531 *action = TopToolbarAction::Layout(LayoutAction::SelectGrid(mode));
532 }
533 return;
534 }
535 S::LayoutName => {
536 let layout_action = self.layout_menu.show(ui);
537 if layout_action != LayoutAction::None {
538 *action = TopToolbarAction::Layout(layout_action);
539 }
540 return;
541 }
542 S::Interval => {
543 if let TimeframeAction::Select(tf) = self.timeframe_selector.show(ui) {
544 *action = TopToolbarAction::IntervalSelected(tf);
545 }
546 return;
547 }
548 S::ChartType => {
549 if let ChartTypeAction::Select(ct) = self.chart_type_selector.show(ui) {
550 *action = TopToolbarAction::ChartStyleSelected(ct);
551 }
552 return;
553 }
554 S::Trade => {
555 if Self::trade_button().show(ui).clicked() {
556 *action = TopToolbarAction::ToggleTrading;
557 }
558 return;
559 }
560 _ => {}
561 }
562 let Some((icon, label, clicked_action)) = control.entry() else {
563 return;
564 };
565 let response = match control {
566 S::Symbol => Self::symbol_trigger(symbol).show(ui, false),
567 S::Compare => ToolbarIconButton::new(icon, label, &self.config).show(ui),
568 S::Templates => {
569 let response = ToolbarIconButton::new(icon, label, &self.config).show(ui);
570 egui::Popup::menu(&response)
571 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
573 .align(egui::RectAlign::BOTTOM_START)
574 .align_alternatives(&[])
575 .frame(egui::Frame::popup(ui.style()).fill(ui.active_theme().ui.panel_bg))
576 .show(|ui| self.show_indicator_templates(ui, action));
577 return;
578 }
579 S::Save => {
580 let response = Self::save_button(&self.config).show(ui);
581 let arrow = Self::save_menu_button(&self.config).show(ui);
582 egui::Popup::menu(&arrow)
583 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
584 .align(egui::RectAlign::BOTTOM_START)
585 .align_alternatives(&[])
586 .frame(egui::Frame::popup(ui.style()).fill(ui.active_theme().ui.panel_bg))
587 .show(|ui| {
588 let layout_action = self.layout_menu.show_menu(ui);
589 if layout_action != LayoutAction::None {
590 *action = TopToolbarAction::Layout(layout_action);
591 }
592 });
593 if arrow.clicked() {
594 return;
595 }
596 response
597 }
598
599 S::Screenshot => {
600 let response = ToolbarIconButton::new(icon, label, &self.config).show(ui);
601 egui::Popup::menu(&response)
602 .align(egui::RectAlign::BOTTOM_START)
603 .align_alternatives(&[])
604 .frame(egui::Frame::popup(ui.style()).fill(ui.active_theme().ui.panel_bg))
605 .show(|ui| {
606 ui.set_width((ui.ctx().content_rect().width() - 24.0).clamp(0.0, 240.0));
607 let font = egui::FontId::proportional(14.0);
608 ui.style_mut().override_font_id = Some(font.clone());
609 let theme = ui.active_theme();
610 let hint = snapshot_shortcut_hint(ui.ctx(), &font);
611 let height =
612 (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - 16.0)
613 .max(0.0);
614 egui::ScrollArea::vertical()
615 .max_height(height)
616 .show(ui, |ui| {
617 ui.label(
618 egui::RichText::new(l10n::text(ui.ctx(), "SNAPSHOT"))
619 .size(11.0)
620 .color(theme.ui.text_muted),
621 );
622 for (label, icon, value, available) in [
623 (
624 "Download image",
625 &DOWNLOAD_ICON,
626 TopToolbarAction::TakeScreenshot,
627 true,
628 ),
629 (
630 "Copy image",
631 &embedded_icons::DUPLICATE,
632 TopToolbarAction::CopyImage,
633 true,
634 ),
635 (
636 "Open in new tab",
637 &OPEN_NEW_ICON,
638 TopToolbarAction::OpenImage,
639 true,
640 ),
641 (
642 "Copy link",
643 &LINK_ICON,
644 TopToolbarAction::CopyImageLink,
645 self.state.can_publish_image,
646 ),
647 (
648 "Post image",
649 &embedded_icons::PUBLISH,
650 TopToolbarAction::Publish,
651 self.state.can_publish_image,
652 ),
653 ] {
654 if !available {
655 continue;
656 }
657 let item = ListItem::new().height(32.0).show(
658 ui,
659 LabelContent::new(label).with_icon(icon).icon_size(17.0),
660 );
661 if value == TopToolbarAction::TakeScreenshot
663 && let Some(hint) = &hint
664 {
665 ui.painter().text(
666 item.rect.right_center()
667 - egui::vec2(theme.spacing.lg, 0.0),
668 egui::Align2::RIGHT_CENTER,
669 hint,
670 font.clone(),
671 theme.ui.text_muted,
672 );
673 }
674 if item.clicked() {
675 *action = value;
676 ui.close();
677 }
678 }
679 });
680 });
681 return;
682 }
683 S::Undo | S::Redo => {
684 let enabled = if control == S::Undo {
685 self.state.can_undo
686 } else {
687 self.state.can_redo
688 };
689 ui.scope(|ui| {
690 ui.visuals_mut().disabled_alpha = 1.0;
692 ui.add_enabled(enabled, |ui: &mut Ui| {
693 ToolbarIconButton::new(icon, label, &self.config).show(ui)
694 })
695 })
696 .inner
697 .on_disabled_hover_text(l10n::text(
698 ui.ctx(),
699 if control == S::Undo {
700 "Nothing to undo"
701 } else {
702 "Nothing to redo"
703 },
704 ))
705 }
706 S::Indicators | S::Alert | S::Replay => {
707 IconTextButton::new(icon, label, &self.config).show(ui)
708 }
709 _ => ToolbarIconButton::new(icon, label, &self.config).show(ui),
710 };
711 if response.clicked() {
712 if control == S::Replay {
713 self.state.toggle_replay();
714 }
715 *action = clicked_action;
716 }
717 }
718
719 fn show_templates_tabs(&mut self, ui: &mut Ui) {
721 let theme = ui.active_theme();
722 let size = egui::vec2(ui.available_width(), 34.0);
723 let (track, _) = ui.allocate_exact_size(size, egui::Sense::hover());
724 ui.painter().rect_filled(
725 track,
726 theme.radius.md,
727 theme
728 .ui
729 .panel_bg
730 .lerp_to_gamma(theme.ui.panel_bg_secondary, 0.65),
731 );
732 for (index, page) in [TemplatesPage::Indicators, TemplatesPage::Templates]
733 .into_iter()
734 .enumerate()
735 {
736 let half = egui::Rect::from_min_size(
737 track.min + egui::vec2(index as f32 * size.x / 2.0, 0.0),
738 egui::vec2(size.x / 2.0, size.y),
739 );
740 let id = ui.scope_id().with(page.label());
741 let response = ui.interact(half, id, egui::Sense::click());
742 if response.clicked() {
743 self.templates_page = page;
744 }
745 let selected = self.templates_page == page;
746 let label = l10n::text(ui.ctx(), page.label());
747 response.widget_info(|| WidgetInfo::labeled(Role::Tab, ui.is_enabled(), &label));
748 ui.ctx()
749 .accesskit_node_builder(response.id, |node| node.set_selected(selected));
750 if selected {
751 ui.painter().rect_filled(
752 half.shrink(theme.spacing.xs),
753 theme.radius.sm,
754 theme.ui.panel_bg,
755 );
756 }
757 ui.painter().text(
758 half.center(),
759 egui::Align2::CENTER_CENTER,
760 label,
761 egui::FontId::proportional(theme.text.title),
762 if selected || response.hovered() {
763 theme.ui.text
764 } else {
765 theme.ui.text_muted
766 },
767 );
768 crate::styles::focus::draw_focus_ring(ui, &response, theme.radius.md);
769 }
770 }
771
772 fn show_indicator_templates(&mut self, ui: &mut Ui, action: &mut TopToolbarAction) {
774 use crate::ui_kit::{EmptyState, list_item::LabelContent};
775 ui.set_width(292.0);
776 self.show_templates_tabs(ui);
777 ui.style_mut().override_font_id = Some(egui::FontId::proportional(14.0));
778 let (entries, empty) = match self.templates_page {
779 TemplatesPage::Indicators => (
780 &self.favorite_indicators,
781 "No favorite indicators added yet",
782 ),
783 TemplatesPage::Templates => {
784 if ListItem::new()
785 .height(40.0)
786 .show_with_icon(ui, "Save indicator template", &embedded_icons::PLUS)
787 .clicked()
788 {
789 *action = TopToolbarAction::SaveIndicatorTemplate;
790 ui.close();
791 }
792 (&self.indicator_templates, "No templates saved yet")
793 }
794 };
795 if entries.is_empty() {
796 let height = (203.0 - (ui.cursor().top() - ui.min_rect().top())).max(0.0);
798 ui.allocate_ui(egui::vec2(ui.available_width(), height), |ui| {
799 ui.set_min_height(height);
800 EmptyState::new(empty).fill_height().show(ui);
801 });
802 return;
803 }
804 egui::ScrollArea::vertical()
805 .max_height(320.0)
806 .show(ui, |ui| {
807 for (id, name) in entries {
808 if ListItem::new()
809 .height(32.0)
810 .show(ui, LabelContent::new(name))
811 .clicked()
812 {
813 *action = match self.templates_page {
814 TemplatesPage::Indicators => TopToolbarAction::AddIndicator(id.clone()),
815 TemplatesPage::Templates => {
816 TopToolbarAction::ApplyIndicatorTemplate(id.clone())
817 }
818 };
819 ui.close();
820 }
821 }
822 });
823 }
824
825 fn symbol_trigger(symbol: &str) -> Trigger<'static> {
826 Trigger {
827 icon: Some(&embedded_icons::QUICK_SEARCH),
828 label: symbol.to_owned(),
829 name: "Symbol search".to_owned(),
830 min_width: 128.0,
831 filled: true,
832 }
833 }
834
835 fn trade_button() -> PillButton<'static> {
836 PillButton {
837 label: "Trade",
838 tooltip: Some("Order ticket"),
839 filled: false,
840 }
841 }
842
843 fn save_button(config: &TopToolbarConfig) -> TextButton {
844 TextButton::new("Save".to_owned(), config).with_tooltip("Save chart")
845 }
846
847 fn save_menu_button(config: &TopToolbarConfig) -> TextIconButton<'static> {
848 TextIconButton::new("", &embedded_icons::CHEVRON_DOWN, "Layout options", config)
849 }
850}
851
852impl TopToolbar {
853 pub fn sync_from_app_state(&mut self, app_state: &dyn crate::ui::app_state::ChartAppState) {
855 self.state.sync_from_app_state(app_state);
856 }
857}
858
859#[cfg(test)]
860mod tests {
861 use super::*;
862 use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
863
864 struct State;
865 impl crate::ui::app_state::ChartAppState for State {
866 fn active_symbol(&self) -> &str {
867 "SYNTH:LONG_SYMBOL"
868 }
869 fn active_timeframe(&self) -> &crate::model::Timeframe {
870 &crate::model::Timeframe::Hour1
871 }
872 fn chart_type(&self) -> ChartType {
873 ChartType::Candles
874 }
875 fn is_connected(&self) -> bool {
876 true
877 }
878 fn is_replay_active(&self) -> bool {
879 false
880 }
881 }
882
883 fn click_along(ctx: &egui::Context, toolbar: &mut TopToolbar) -> Vec<TopToolbarAction> {
886 let mut actions = Vec::new();
887 let screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0));
888 for x in (0..1440).step_by(4) {
889 let pos = pos2(x as f32, 16.0);
890 for pressed in [true, false] {
891 let input = RawInput {
892 screen_rect: Some(screen),
893 events: vec![
894 Event::PointerMoved(pos),
895 Event::PointerButton {
896 pos,
897 button: PointerButton::Primary,
898 pressed,
899 modifiers: Default::default(),
900 },
901 ],
902 ..Default::default()
903 };
904 ctx.run_ui(input, |ui| {
905 let action = ui
906 .horizontal(|ui| {
907 ui.set_height(toolbar.config.height);
908 toolbar.show_contents_desktop(ui, "BTCUSD")
909 })
910 .inner;
911 actions.push(action);
912 })
913 .drop_without_applying_deltas();
914 }
915 }
916 actions
917 }
918
919 #[test]
920 fn disabled_history_icons_use_the_theme_ink_without_fading() {
921 for (_, mut theme) in crate::theme::presets() {
922 theme.ui.text_disabled = egui::Color32::from_rgb(123, 100, 90);
923 let ctx = egui::Context::default();
924 egui_extras::install_image_loaders(&ctx);
925 crate::theme::apply_to_egui(&ctx, &theme);
926 let mut toolbar = TopToolbar::new();
927 toolbar.state.can_undo = false;
928 toolbar.state.can_redo = false;
929 for _ in 0..2 {
930 let mut output = ctx.run_ui(Default::default(), |ui| {
931 for slot in [S::Undo, S::Redo] {
932 toolbar.show_control(ui, slot, "SAMPLE", &mut TopToolbarAction::None);
933 }
934 });
935 output.textures_delta.clear();
936 let icons: Vec<_> = output
937 .shapes
938 .iter()
939 .filter_map(|shape| match &shape.shape {
940 egui::Shape::Rect(rect) if rect.brush.is_some() => Some(rect.fill),
941 _ => None,
942 })
943 .collect();
944 assert_eq!(icons, vec![theme.ui.text_disabled; 2]);
945 output.drop_without_applying_deltas();
946 }
947 }
948 }
949
950 #[test]
951 fn templates_tabs_switch_the_page_and_keep_the_menu_open() {
952 use egui::accesskit::Role;
953 use egui_kittest::{
954 Harness,
955 kittest::{NodeT, Queryable},
956 };
957 let mut harness = Harness::builder()
958 .with_size(vec2(400.0, 400.0))
959 .with_context_setup(egui_extras::install_image_loaders)
960 .build_ui_state(
961 |ui, toolbar: &mut TopToolbar| {
962 toolbar.show_control(ui, S::Templates, "SAMPLE", &mut TopToolbarAction::None);
963 },
964 TopToolbar::new(),
965 );
966 harness.run();
967 harness
968 .get_by_role_and_label(Role::Button, "Indicator templates")
969 .click();
970 harness.run();
971 harness
972 .get_by_role_and_label(Role::Tab, "Templates")
973 .click();
974 harness.run();
975 harness.get_by_label("Save indicator template");
976 for (tab, selected) in [("Indicators", false), ("Templates", true)] {
977 let node = harness.get_by_role_and_label(Role::Tab, tab);
978 assert_eq!(node.accesskit_node().is_selected(), Some(selected), "{tab}");
979 }
980 }
981
982 #[test]
983 fn hover_labels_name_the_control_and_show_keys_as_symbols() {
984 use egui_kittest::{Harness, kittest::Queryable};
985 for (slot, button, label, keys) in [
986 (S::Indicators, "Indicators", "Indicators", &["/"][..]),
987 (S::Alert, "Alert", "Create alert", &["Alt", "A"]),
988 (S::Save, "Layout options", "Layout options", &[]),
989 ] {
990 let mut harness = Harness::builder()
991 .with_size(vec2(400.0, 200.0))
992 .with_context_setup(egui_extras::install_image_loaders)
993 .build_ui_state(
994 move |ui, toolbar: &mut TopToolbar| {
995 ui.horizontal(|ui| {
996 toolbar.show_control(ui, slot, "SAMPLE", &mut TopToolbarAction::None);
997 });
998 },
999 TopToolbar::new(),
1000 );
1001 harness.run();
1002 harness
1003 .get_by_role_and_label(egui::accesskit::Role::Button, button)
1004 .hover();
1005 harness.run_steps(4);
1006 harness.get_by_role_and_label(egui::accesskit::Role::Label, label);
1007 for key in keys {
1008 let chip = harness.get_by_role_and_label(egui::accesskit::Role::Label, key);
1009 assert_eq!(chip.rect().height(), 18.0, "the {key} chip of {label}");
1010 }
1011 }
1012 }
1013
1014 #[test]
1015 fn save_and_layout_options_have_separate_hover_and_focus_targets() {
1016 use egui_kittest::{Harness, kittest::Queryable};
1017 for (_, theme) in crate::theme::presets() {
1018 let context_theme = theme.clone();
1019 let mut harness = Harness::builder()
1020 .with_size(vec2(300.0, 100.0))
1021 .with_context_setup(move |ctx| {
1022 egui_extras::install_image_loaders(ctx);
1023 crate::theme::apply_to_egui(ctx, &context_theme);
1024 l10n::set_translator(ctx, |english| {
1025 assert!(
1026 !english.is_empty(),
1027 "icon-only arrows have no text to translate"
1028 );
1029 None
1030 });
1031 })
1032 .build_ui_state(
1033 |ui, toolbar: &mut TopToolbar| {
1034 ui.horizontal(|ui| {
1035 ui.spacing_mut().item_spacing.x = 0.0;
1036 toolbar.show_control(
1037 ui,
1038 S::Save,
1039 "SAMPLE",
1040 &mut TopToolbarAction::None,
1041 );
1042 });
1043 },
1044 TopToolbar::new(),
1045 );
1046 harness.run();
1047 let save = harness
1048 .get_by_role_and_label(egui::accesskit::Role::Button, "Save chart")
1049 .rect();
1050 let options = harness
1051 .get_by_role_and_label(egui::accesskit::Role::Button, "Layout options")
1052 .rect();
1053 assert!(save.right() <= options.left());
1054 for (label, rect) in [("Save chart", save), ("Layout options", options)] {
1055 harness
1056 .get_by_role_and_label(egui::accesskit::Role::Button, label)
1057 .hover();
1058 harness.run();
1059 let tiles: Vec<_> = harness
1060 .output()
1061 .shapes
1062 .iter()
1063 .filter_map(|shape| match &shape.shape {
1064 egui::Shape::Rect(tile) if tile.fill == theme.ui.btn_bg_hover => {
1065 Some(tile.rect)
1066 }
1067 _ => None,
1068 })
1069 .collect();
1070 assert_eq!(tiles, [rect], "{label} must hover independently");
1071 harness
1072 .get_by_role_and_label(egui::accesskit::Role::Button, label)
1073 .focus();
1074 harness.run();
1075 assert!(
1076 harness
1077 .get_by_role_and_label(egui::accesskit::Role::Button, label)
1078 .is_focused()
1079 );
1080 }
1081 }
1082 }
1083
1084 #[test]
1085 fn menu_triggers_fit_without_input_outlines_or_chart_type_text() {
1086 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1087 for (width, host_label) in [
1088 (320.0, "Host action"),
1089 (393.0, "Host action"),
1090 (640.0, "Host action"),
1091 (1440.0, "Host action"),
1092 (600.0, "Search Trade Publish"),
1093 ] {
1094 let ctx = egui::Context::default();
1095 crate::theme::apply_to_egui(&ctx, &theme);
1096 let mut toolbar = TopToolbar::new();
1097 toolbar.timeframe_selector.current = Timeframe::Hour1;
1098 toolbar.state.can_undo = true;
1099 toolbar.state.can_redo = true;
1100 toolbar.state.can_publish_image = true;
1101 ctx.global_style_mut(|style| {
1102 style.scroll_animation = egui::style::ScrollAnimation::none();
1103 style.spacing.scroll.kinetic.decay_time = 0.0;
1104 });
1105 let mut menu_rect = None;
1106 let mut frame = |events| {
1107 let mut host_rect = Rect::NOTHING;
1108 let mut action = TopToolbarAction::None;
1109 let mut strip = Rect::NOTHING;
1110 let output = ctx.run_ui(
1111 RawInput {
1112 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width, 50.0))),
1113 events,
1114 ..Default::default()
1115 },
1116 |ui| {
1117 action = toolbar.show_with_slots(
1118 ui,
1119 &State,
1120 |ui| {
1121 let rect = IconButton::new(&embedded_icons::MENU)
1122 .tooltip("Menu")
1123 .show(ui)
1124 .rect;
1125 assert_eq!(
1126 *menu_rect.get_or_insert(rect),
1127 rect,
1128 "menu must not scroll with the strip"
1129 );
1130 },
1131 |ui| {
1132 host_rect = crate::ui_kit::buttons::Button::new(host_label)
1133 .ghost()
1134 .show(ui)
1135 .rect;
1136 },
1137 );
1138 strip = ui.min_rect();
1139 if !ui.ctx().will_discard() {
1140 assert!(
1141 ui.min_rect().right() <= width,
1142 "controls exceed the strip"
1143 );
1144 }
1145 },
1146 );
1147 (output, host_rect, action, strip)
1148 };
1149 let (mut output, host_rect, _, strip) = frame(vec![]);
1150 output.textures_delta.clear();
1151 assert_eq!(strip.height(), 38.0, "scrollbar must take no space");
1152 if width < 1000.0 {
1153 assert!(host_rect.left() > width, "controls must stay in the row");
1154 }
1155 let mut interval = false;
1156 for shape in &output.shapes {
1157 match &shape.shape {
1158 egui::Shape::Text(text) => {
1159 assert_ne!(text.galley.text(), "Candles");
1160 interval |= text.galley.text() == "1h";
1161 assert!(
1162 text.galley
1163 .rect
1164 .translate(text.pos.to_vec2())
1165 .intersect(shape.clip_rect)
1166 .right()
1167 <= width,
1168 "{} outside toolbar at {} with width {}",
1169 text.galley.text(),
1170 text.pos.x,
1171 text.galley.size().x
1172 );
1173 }
1174 egui::Shape::Rect(rect)
1175 if rect.rect.height() >= 28.0
1176 && (rect.fill != egui::Color32::TRANSPARENT
1177 || rect.stroke.width > 0.0
1178 || rect.brush.is_some()) =>
1179 {
1180 assert!(
1181 rect.rect.right() <= host_rect.left() || rect.rect == host_rect,
1182 "control {:?} overlaps host {:?}",
1183 rect.rect,
1184 host_rect
1185 );
1186 assert_eq!(
1187 rect.stroke.width, 0.0,
1188 "triggers must have no input outline"
1189 );
1190 }
1191 _ => {}
1192 }
1193 }
1194 output.drop_without_applying_deltas();
1195 assert!(interval);
1196 if width < 1000.0 {
1197 let pointer = |pos, pressed| Event::PointerButton {
1198 pos,
1199 button: PointerButton::Primary,
1200 pressed,
1201 modifiers: Default::default(),
1202 };
1203 let touch = |pos, phase| Event::Touch {
1204 device_id: egui::TouchDeviceId(0),
1205 id: egui::TouchId(0),
1206 pos,
1207 phase,
1208 force: None,
1209 };
1210 let start = pos2(200.0, 19.0);
1212 let end = pos2(100.0, 19.0);
1213 frame(vec![Event::PointerMoved(start)])
1214 .0
1215 .drop_without_applying_deltas();
1216 frame(vec![
1217 touch(start, egui::TouchPhase::Start),
1218 pointer(start, true),
1219 ])
1220 .0
1221 .drop_without_applying_deltas();
1222 frame(vec![
1223 touch(pos2(180.0, 19.0), egui::TouchPhase::Move),
1224 Event::PointerMoved(pos2(180.0, 19.0)),
1225 ])
1226 .0
1227 .drop_without_applying_deltas();
1228 frame(vec![
1229 touch(end, egui::TouchPhase::Move),
1230 Event::PointerMoved(end),
1231 ])
1232 .0
1233 .drop_without_applying_deltas();
1234 let (out, moved, action, _) = frame(vec![]);
1235 assert_eq!(action, TopToolbarAction::None);
1236 assert!(
1237 host_rect.left() - moved.left() > 50.0,
1238 "button drag must scroll: {host_rect:?} -> {moved:?}, dragged {:?}",
1239 ctx.dragged_id()
1240 );
1241 out.drop_without_applying_deltas();
1242 let (out, _, action, _) =
1243 frame(vec![touch(end, egui::TouchPhase::End), pointer(end, false)]);
1244 assert_eq!(action, TopToolbarAction::None, "swipe must not activate");
1245 out.drop_without_applying_deltas();
1246 for source in [
1247 egui::MouseWheelSource::Wheel,
1248 egui::MouseWheelSource::Trackpad,
1249 ] {
1250 for delta in [-2000.0, 2000.0] {
1251 let (out, before, _, _) =
1252 frame(vec![Event::PointerMoved(pos2(150.0, 19.0))]);
1253 out.drop_without_applying_deltas();
1254 frame(vec![Event::MouseWheel {
1255 unit: egui::MouseWheelUnit::Point,
1256 delta: vec2(delta, 0.0),
1257 phase: egui::TouchPhase::Move,
1258 source,
1259 modifiers: Default::default(),
1260 }])
1261 .0
1262 .drop_without_applying_deltas();
1263 for _ in 0..60 {
1264 frame(vec![]).0.drop_without_applying_deltas();
1265 }
1266 let (out, after, _, _) = frame(vec![]);
1267 out.drop_without_applying_deltas();
1268 assert!(
1269 (after.left() - before.left()) * delta > 0.0,
1270 "horizontal wheel must scroll"
1271 );
1272 if delta < 0.0 {
1273 assert!(after.right() <= width, "last control must be reachable");
1274 }
1275 }
1276 }
1277 frame(vec![Event::PointerMoved(start)])
1278 .0
1279 .drop_without_applying_deltas();
1280 frame(vec![
1281 touch(start, egui::TouchPhase::Start),
1282 pointer(start, true),
1283 ])
1284 .0
1285 .drop_without_applying_deltas();
1286 let (out, _, action, _) = frame(vec![
1287 touch(start, egui::TouchPhase::End),
1288 pointer(start, false),
1289 ]);
1290 assert_eq!(
1291 action,
1292 TopToolbarAction::OpenSymbolSearch,
1293 "plain tap must activate"
1294 );
1295 out.drop_without_applying_deltas();
1296 ctx.memory_mut(|m| {
1297 if let Some(id) = m.focused() {
1298 m.surrender_focus(id);
1299 }
1300 });
1301 }
1302 let mut labels = Vec::new();
1303 let mut previous = None;
1304 let mut reached_host = false;
1305 for index in 0..30 {
1306 let (output, _, _, _) = frame(vec![Event::Key {
1307 key: egui::Key::Tab,
1308 physical_key: None,
1309 pressed: true,
1310 repeat: false,
1311 modifiers: egui::Modifiers::NONE,
1312 }]);
1313 for event in &output.platform_output.events {
1314 if let egui::output::OutputEvent::FocusGained(info) = event
1315 && let Some(label) = &info.label
1316 {
1317 labels.push(label.clone());
1318 }
1319 }
1320 output.drop_without_applying_deltas();
1321 for _ in 0..3 {
1322 frame(vec![]).0.drop_without_applying_deltas();
1323 }
1324 let (output, host_rect, _, _) = frame(vec![]);
1325 let focused = ctx
1326 .memory(|m| m.focused())
1327 .and_then(|id| ctx.read_response(id))
1328 .unwrap()
1329 .rect;
1330 assert!(
1331 focused.left() >= 0.0 && focused.right() <= width,
1332 "focus must scroll into view: {focused:?}"
1333 );
1334 assert!(output.shapes.iter().any(|shape| matches!(&shape.shape,
1335 egui::Shape::Rect(r) if (r.rect.min - focused.expand(2.0).min).length() < 1.5 && (r.rect.max - focused.expand(2.0).max).length() < 1.5 && r.stroke.width == 2.0)), "focused control has no ring: {focused:?}");
1336 output.drop_without_applying_deltas();
1337 let content_x = focused.center().x - host_rect.right();
1338 if let Some(center) = previous {
1339 assert!(content_x >= center, "Tab moved backwards: {focused:?}");
1340 }
1341 previous = Some(content_x);
1342 if focused == host_rect {
1343 assert!(index > 1, "host actions preceded built-in controls");
1344 reached_host = true;
1345 break;
1346 }
1347 }
1348 assert!(reached_host);
1349 assert_eq!(
1350 labels,
1351 [
1352 "Menu",
1353 "Symbol search",
1354 "Compare symbols",
1355 "1 hour",
1356 "Candles",
1357 "Indicators",
1358 "Indicator templates",
1359 "Alert",
1360 "Replay",
1361 "Undo",
1362 "Redo",
1363 "Chart layout",
1364 "Named layouts",
1365 "Save chart",
1366 "Layout options",
1367 "Quick Search",
1368 "Settings",
1369 "Fullscreen",
1370 "Snapshot",
1371 "Publish",
1372 host_label,
1373 ],
1374 "accessible controls must keep their order at every width"
1375 );
1376 }
1377 }
1378 }
1379
1380 #[test]
1381 fn toolbar_buttons_and_menu_choices_emit_distinct_actions() {
1382 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1383 let ctx = egui::Context::default();
1384 crate::theme::apply_to_egui(&ctx, &theme);
1385 ctx.enable_accesskit();
1386 for (slot, label, expected) in [
1387 (
1388 S::QuickSearch,
1389 "Quick Search",
1390 TopToolbarAction::OpenQuickSearch,
1391 ),
1392 (S::Symbol, "Symbol", TopToolbarAction::OpenSymbolSearch),
1393 (
1394 S::Screenshot,
1395 "Download image",
1396 TopToolbarAction::TakeScreenshot,
1397 ),
1398 (S::Screenshot, "Copy image", TopToolbarAction::CopyImage),
1399 (
1400 S::Screenshot,
1401 "Open in new tab",
1402 TopToolbarAction::OpenImage,
1403 ),
1404 (S::Screenshot, "Copy link", TopToolbarAction::CopyImageLink),
1405 (S::Screenshot, "Post image", TopToolbarAction::Publish),
1406 (
1407 S::Save,
1408 "Save layout",
1409 TopToolbarAction::Layout(LayoutAction::Save),
1410 ),
1411 (
1412 S::Save,
1413 "Download chart data",
1414 TopToolbarAction::Layout(LayoutAction::DownloadData),
1415 ),
1416 (
1417 S::Save,
1418 "Create new layout",
1419 TopToolbarAction::Layout(LayoutAction::CreateNew),
1420 ),
1421 (
1422 S::Save,
1423 "Open layout",
1424 TopToolbarAction::Layout(LayoutAction::OpenLayout),
1425 ),
1426 (
1427 S::Layouts,
1428 "2x2 Grid",
1429 TopToolbarAction::Layout(LayoutAction::SelectGrid(
1430 crate::widget::ChartLayoutMode::Four,
1431 )),
1432 ),
1433 (
1434 S::LayoutName,
1435 "Save layout",
1436 TopToolbarAction::Layout(LayoutAction::Save),
1437 ),
1438 (
1439 S::LayoutName,
1440 "Rename…",
1441 TopToolbarAction::Layout(LayoutAction::Rename("Unnamed".into(), "Desk".into())),
1442 ),
1443 (
1444 S::LayoutName,
1445 "Save layout as…",
1446 TopToolbarAction::Layout(LayoutAction::SaveAs("Desk".into())),
1447 ),
1448 (
1449 S::Templates,
1450 "Simple Moving Average",
1451 TopToolbarAction::AddIndicator("SMA".into()),
1452 ),
1453 (
1454 S::Templates,
1455 "Save indicator template",
1456 TopToolbarAction::SaveIndicatorTemplate,
1457 ),
1458 (
1459 S::Templates,
1460 "My studies",
1461 TopToolbarAction::ApplyIndicatorTemplate("custom-id".into()),
1462 ),
1463 (S::Undo, "Undo", TopToolbarAction::Undo),
1464 (S::Undo, "Undo", TopToolbarAction::None),
1465 (S::Redo, "Redo", TopToolbarAction::Redo),
1466 (S::Redo, "Redo", TopToolbarAction::None),
1467 (S::Replay, "Replay", TopToolbarAction::ToggleReplay),
1468 (S::Publish, "Publish", TopToolbarAction::Publish),
1469 (S::Trade, "Trade", TopToolbarAction::ToggleTrading),
1470 ] {
1471 let direct = matches!(slot, S::Undo | S::Redo | S::Replay | S::Publish | S::Trade);
1472 let mut toolbar = TopToolbar::new();
1473 let publishing = label != "Copy image";
1474 toolbar.state.can_publish_image = publishing;
1475 toolbar.state.can_undo = expected != TopToolbarAction::None;
1476 toolbar.state.can_redo = expected != TopToolbarAction::None;
1477 toolbar
1478 .indicator_templates
1479 .push(("custom-id".into(), "My studies".into()));
1480 toolbar
1481 .favorite_indicators
1482 .push(("SMA".into(), "Simple Moving Average".into()));
1483 if matches!(
1484 expected,
1485 TopToolbarAction::SaveIndicatorTemplate
1486 | TopToolbarAction::ApplyIndicatorTemplate(_)
1487 ) {
1488 toolbar.templates_page = TemplatesPage::Templates;
1489 }
1490 let height = if slot == S::Screenshot { 120.0 } else { 900.0 };
1491 let mut frame = |events| {
1492 let mut action = TopToolbarAction::None;
1493 let mut rect = Rect::NOTHING;
1494 let output = ctx.run_ui(
1495 RawInput {
1496 screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(320.0, height))),
1497 events,
1498 ..Default::default()
1499 },
1500 |ui| {
1501 rect = ui
1502 .horizontal(|ui| {
1503 toolbar.show_control(ui, slot, "SAMPLE", &mut action);
1504 })
1505 .response
1506 .rect;
1507 },
1508 );
1509 let labels: Vec<_> = output
1510 .shapes
1511 .iter()
1512 .filter_map(|shape| match &shape.shape {
1513 egui::Shape::Text(text) => Some((
1514 text.galley.text().to_owned(),
1515 text.pos + text.galley.size() / 2.0,
1516 )),
1517 _ => None,
1518 })
1519 .chain(
1521 output
1522 .platform_output
1523 .accesskit_update
1524 .iter()
1525 .flat_map(|update| &update.nodes)
1526 .filter_map(|(_, node)| {
1527 let bounds = node.bounds()?;
1528 Some((
1529 node.label()?.to_owned(),
1530 pos2(
1531 ((bounds.x0 + bounds.x1) / 2.0) as f32,
1532 ((bounds.y0 + bounds.y1) / 2.0) as f32,
1533 ),
1534 ))
1535 }),
1536 )
1537 .collect();
1538 output.drop_without_applying_deltas();
1539 (action, rect, labels)
1540 };
1541 let (_, rect, _) = frame(vec![]);
1542 let pos = if slot == S::Save {
1543 pos2(rect.right() - 8.0, rect.center().y)
1544 } else {
1545 rect.center()
1546 };
1547 frame(vec![Event::PointerMoved(pos)]);
1548 let click = |pos, pressed| Event::PointerButton {
1549 pos,
1550 button: PointerButton::Primary,
1551 pressed,
1552 modifiers: Default::default(),
1553 };
1554 frame(vec![click(pos, true)]);
1555 let (action, _, _) = frame(vec![click(pos, false)]);
1556 if matches!(slot, S::QuickSearch | S::Symbol) || direct {
1557 assert_eq!(action, expected);
1558 if slot == S::Replay {
1559 assert!(toolbar.state.replay_active);
1560 }
1561 continue;
1562 }
1563 assert_eq!(
1564 action,
1565 TopToolbarAction::None,
1566 "opening the menu must not capture an image"
1567 );
1568 frame(vec![]);
1569 let (_, _, mut labels) = frame(vec![]);
1570 if slot == S::Screenshot && !publishing {
1571 assert!(!labels.iter().any(|(text, _)| text == "Copy link"));
1572 assert!(!labels.iter().any(|(text, _)| text == "Post image"));
1573 }
1574 for _ in 0..32 {
1575 if labels
1576 .iter()
1577 .any(|(text, at)| text == label && (5.0..height - 16.0).contains(&at.y))
1578 {
1579 break;
1580 }
1581 labels = frame(vec![
1582 Event::PointerMoved(pos2(150.0, 100.0)),
1583 Event::MouseWheel {
1584 unit: egui::MouseWheelUnit::Point,
1585 delta: vec2(0.0, -6.0),
1586 phase: egui::TouchPhase::Move,
1587 source: egui::MouseWheelSource::Unknown,
1588 modifiers: Default::default(),
1589 },
1590 ])
1591 .2;
1592 }
1593 let pos = labels
1594 .iter()
1595 .find(|(text, at)| text == label && (5.0..height - 16.0).contains(&at.y))
1596 .unwrap_or_else(|| panic!("reachable {label}: {labels:?}"))
1597 .1;
1598 frame(vec![Event::PointerMoved(pos)]);
1599 frame(vec![click(pos, true)]);
1600 let (mut action, _, _) = frame(vec![click(pos, false)]);
1601 if matches!(
1602 expected,
1603 TopToolbarAction::Layout(LayoutAction::Rename(_, _) | LayoutAction::SaveAs(_))
1604 ) {
1605 let (_, _, labels) = frame(vec![]);
1606 assert!(
1607 labels.iter().any(|(text, _)| text == "Apply"),
1608 "editing must keep the popup open"
1609 );
1610 let key = |key, modifiers| Event::Key {
1611 key,
1612 physical_key: None,
1613 pressed: true,
1614 repeat: false,
1615 modifiers,
1616 };
1617 frame(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
1618 frame(vec![Event::Text("Desk".into())]);
1619 action = frame(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]).0;
1620 }
1621 assert_eq!(action, expected);
1622 }
1623 }
1624 }
1625
1626 #[test]
1627 fn controls_that_are_off_emit_nothing() {
1628 type Off = fn(&mut TopToolbar);
1629 let rows: &[(Off, &[TopToolbarAction])] = &[
1630 (|t| t.state.can_undo = false, &[TopToolbarAction::Undo]),
1631 (|t| t.state.can_redo = false, &[TopToolbarAction::Redo]),
1632 (
1633 |t| t.state.can_publish_image = false,
1634 &[TopToolbarAction::Publish],
1635 ),
1636 (
1637 |t| t.set_control_visible(Control::SymbolSearch, false),
1638 &[TopToolbarAction::OpenSymbolSearch],
1639 ),
1640 (
1641 |t| t.set_control_visible(Control::Indicators, false),
1642 &[TopToolbarAction::OpenIndicators],
1643 ),
1644 (
1645 |t| t.set_control_visible(Control::Undo, false),
1646 &[TopToolbarAction::Undo],
1647 ),
1648 (
1649 |t| t.set_control_visible(Control::Redo, false),
1650 &[TopToolbarAction::Redo],
1651 ),
1652 (
1653 |t| t.set_control_visible(Control::QuickSearch, false),
1654 &[TopToolbarAction::OpenQuickSearch],
1655 ),
1656 (
1657 |t| t.set_control_visible(Control::Settings, false),
1658 &[TopToolbarAction::OpenSettings],
1659 ),
1660 (
1661 |t| t.set_control_visible(Control::Fullscreen, false),
1662 &[TopToolbarAction::ToggleFullscreen],
1663 ),
1664 (
1665 |t| t.set_control_visible(Control::Compare, false),
1666 &[TopToolbarAction::OpenCompare],
1667 ),
1668 (
1669 |t| t.set_control_visible(Control::Layouts, false),
1670 &[TopToolbarAction::Save],
1671 ),
1672 (
1673 |t| t.set_control_visible(Control::Publish, false),
1674 &[TopToolbarAction::Publish],
1675 ),
1676 (
1677 |t| t.set_control_visible(Control::Trading, false),
1678 &[TopToolbarAction::ToggleTrading],
1679 ),
1680 (
1681 |t| t.state.can_trade = false,
1682 &[TopToolbarAction::ToggleTrading],
1683 ),
1684 (
1685 |t| {
1686 t.timeframe_selector.favorites.remove(&Timeframe::Min5);
1687 },
1688 &[TopToolbarAction::IntervalSelected(Timeframe::Min5)],
1689 ),
1690 ];
1691 let ctx = egui::Context::default();
1692 let broker = || {
1693 let mut toolbar = TopToolbar::default();
1694 toolbar.state.can_trade = true;
1695 toolbar.state.can_publish_image = true;
1696 toolbar.timeframe_selector.favorites.insert(Timeframe::Min5);
1697 toolbar
1698 };
1699 let on = click_along(&ctx, &mut broker());
1700 for (turn_off, gone) in rows {
1701 for action in *gone {
1702 assert!(on.contains(action), "{action:?} missing while on");
1703 }
1704 let mut toolbar = broker();
1705 turn_off(&mut toolbar);
1706 let off = click_along(&ctx, &mut toolbar);
1707 for action in *gone {
1708 assert!(!off.contains(action), "{action:?} emitted while off");
1709 }
1710 if !gone.contains(&TopToolbarAction::OpenIndicators) {
1711 assert!(off.contains(&TopToolbarAction::OpenIndicators));
1712 }
1713 }
1714 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1716 for width in [320.0, 1440.0] {
1717 for (control, hidden, retained) in [
1718 (Control::QuickSearch, "Quick Search", "Toggle Theme"),
1719 (Control::Theme, "Toggle Theme", "Quick Search"),
1720 ] {
1721 let ctx = egui::Context::default();
1722 crate::theme::apply_to_egui(&ctx, &theme);
1723 let mut toolbar = TopToolbar::new();
1724 toolbar.set_control_visible(control, false);
1725 let frame = |toolbar: &mut TopToolbar, events| {
1726 let mut output = ctx.run_ui(
1727 RawInput {
1728 screen_rect: Some(Rect::from_min_size(
1729 Pos2::ZERO,
1730 vec2(width, 900.0),
1731 )),
1732 events,
1733 ..Default::default()
1734 },
1735 |ui| {
1736 toolbar.show_with_state(ui, &State);
1737 },
1738 );
1739 let labels = output
1740 .shapes
1741 .iter()
1742 .filter_map(|shape| match &shape.shape {
1743 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
1744 _ => None,
1745 })
1746 .collect::<Vec<_>>();
1747 output.textures_delta.clear();
1748 output.drop_without_applying_deltas();
1749 labels
1750 };
1751 frame(&mut toolbar, vec![]);
1752 let pos = pos2(32.0, 27.0);
1753 for pressed in [true, false] {
1754 frame(
1755 &mut toolbar,
1756 vec![
1757 Event::PointerMoved(pos),
1758 Event::PointerButton {
1759 pos,
1760 button: PointerButton::Primary,
1761 pressed,
1762 modifiers: Default::default(),
1763 },
1764 ],
1765 );
1766 }
1767 let labels = frame(&mut toolbar, vec![]);
1768 assert!(
1769 !labels.iter().any(|label| label == hidden),
1770 "hidden menu row: {labels:?}"
1771 );
1772 assert!(
1773 labels.iter().any(|label| label == retained),
1774 "missing retained menu row: {labels:?}"
1775 );
1776 toolbar.set_control_visible(control, true);
1777 let labels = frame(&mut toolbar, vec![]);
1778 assert!(labels.iter().any(|label| label == hidden));
1779 }
1780 }
1781 }
1782 }
1783}