Skip to main content

egui_charts/ui/top_toolbar/
toolbar.rs

1//! Top Toolbar Widget
2//!
3//! Symbol search, interval, chart type, indicators, alert, replay, undo and redo
4//! on the left; the chart layout, layout name, save, quick search, settings,
5//! fullscreen, screenshot, publish and trade on the right, then the host's call to
6//! action, in a horizontally scrolling strip after the fixed main menu.
7
8use 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/// A place in the strip, holding one control.
35#[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
61/// Glyph of the snapshot menu's download entry, and of the save menu's.
62pub(super) const DOWNLOAD_ICON: Icon =
63    Icon::new("top-toolbar-download.svg", include_bytes!("download.svg"));
64/// Glyph of the snapshot menu's new tab entry.
65const OPEN_NEW_ICON: Icon = Icon::new("top-toolbar-open-new.svg", include_bytes!("open-new.svg"));
66/// Glyph of the snapshot menu's link entry.
67const LINK_ICON: Icon = Icon::new("top-toolbar-link.svg", include_bytes!("link.svg"));
68
69/// Left part of the strip, in groups divided by separators.
70const 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
79/// Right-aligned part of the strip, in groups divided by separators.
80const 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
92/// Space between the controls of a group.
93fn gap(ui: &Ui) -> f32 {
94    ui.active_theme().spacing.sm
95}
96
97/// Width of the group rule with its padding.
98fn separator(ui: &Ui) -> f32 {
99    2.0 * gap(ui) + DESIGN_TOKENS.sizing.toolbar.separator_width
100}
101
102/// Neutral group rule, centred in the strip.
103fn 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
111/// Keys of the snapshot shortcut as the snapshot menu shows them.
112fn 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    /// Icon, name and action of a control that acts on a click; `None` for the dropdowns.
128    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    /// The host control that hides this slot.
171    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/// A page of the indicator templates menu.
196#[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
212/// Top toolbar widget
213pub struct TopToolbar {
214    /// Display and layout options for this component.
215    pub config: TopToolbarConfig,
216    /// Mutable interaction state of this component.
217    pub state: TopToolbarState,
218    /// Timeframe selector dropdown
219    pub timeframe_selector: TimeframeSelector,
220    /// Chart type selector dropdown
221    pub chart_type_selector: ChartTypeSelector,
222    /// Chart preset and synchronization menu.
223    pub chart_grid_selector: ChartGridSelector,
224    /// Layout selector dropdown
225    pub layout_menu: LayoutMenu,
226    /// Controls the host hid.
227    host_hidden: HashSet<Control>,
228    /// Host-supplied saved indicator sets as (id, name), in display order.
229    pub indicator_templates: Vec<(String, String)>,
230    /// Host-supplied favourite studies as (factory name, display name), in display order,
231    /// such as the starred studies of an
232    /// [`IndicatorDialog`](super::components::IndicatorDialog).
233    pub favorite_indicators: Vec<(String, String)>,
234    templates_page: TemplatesPage,
235    /// Host buttons and dropdowns in registration order, placed before the host action slot.
236    pub host_entries: Vec<super::ToolbarEntry>,
237    /// Host call to action as (id, label), drawn as a filled pill at the right end after the host
238    /// action slot. Choosing it emits [`TopToolbarAction::Host`] with the id; `None` shows nothing.
239    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    /// Create a new top toolbar with default configuration
263    pub fn new() -> Self {
264        Self::default()
265    }
266
267    /// Set a custom toolbar configuration
268    pub fn with_config(mut self, config: TopToolbarConfig) -> Self {
269        self.config = config;
270        self
271    }
272
273    /// Shows or hides the named top-toolbar control, including its popup.
274    /// Controls for other components are ignored. This controls UI visibility; configure chart
275    /// behavior and shortcut bindings separately through `ChartOptions` and `ShortcutMap`.
276    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    /// Whether the host left `slot` visible. Trade also needs a broker.
281    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    /// Show the full toolbar, reading the active symbol from app state.
288    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    /// Show the toolbar inline (same layout, no outer frame).
309    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    /// Show host menu content before the symbol and host actions at the far right.
318    ///
319    /// The menu slot is 48 px wide. Keep host actions compact; their measured width is
320    /// reserved alongside the built-in controls. The closures
321    /// handle their own responses and may open menus or return actions to the host.
322    /// The strip after the menu slot scrolls horizontally when its controls cannot fit, without a
323    /// visible scroll bar; the menu slot stays at the left edge.
324    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                // The strip follows the slot directly but keeps the default spacing, which it
334                // uses as the margin when scrolling a focused control into view.
335                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    /// Desktop layout: horizontal toolbar contents.
445    fn show_contents_desktop(&mut self, ui: &mut Ui, symbol: &str) -> TopToolbarAction {
446        let mut action = TopToolbarAction::None;
447        // Controls carry their own token gaps (`gap`, separators).
448        ui.spacing_mut().item_spacing.x = 0.0;
449        // Kit buttons label in egui's button style; every label of the strip has the title size.
450        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    /// Width of the right-hand controls with their gaps and separators.
465    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    /// Draw the visible controls of `groups`, with separators between groups.
491    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                    // Choosing a page keeps the menu open; its rows close it themselves.
572                    .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                                    // Hosts bind the snapshot shortcut to this entry's action.
662                                    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                    // Disabled glyphs carry their own theme ink instead of egui's alpha fade.
691                    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    /// The templates menu's pages as equal halves of one track, the selected one a panel tile.
720    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    /// Favourite studies to add and saved templates to apply, on two pages.
773    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            // Keep room for a few rows, with the sentence centred in it.
797            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    /// Sync toolbar state from application state.
854    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    /// Actions from clicking the strip every 4 px along its middle, one click at a time, in a
884    /// window tall enough for an open menu to sit below the strip.
885    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                    // Both ends are over the symbol button: movement must cancel its click.
1211                    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                        // Icon-only choices, such as the layout diagrams, by their accessible names.
1520                        .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        // The same flags apply to the default menu, independently of the strip.
1715        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}